UNRELEASED DEVELOPMENT SNAPSHOT · READ-ONLY EXCERPT
bin/_jobs.sh
This is the cited technical source at local revision 275a354, inspected on 22 September 2026. It differs from the public release. This reference publishes only the cited sections, not an installable release or the development branch.
Line numbers refer to the complete recorded file. Uncited lines are omitted. Full-file SHA-256: a92f576ee19148a646968e4dd3c52540f9d2efe1248f960bb7562e5b95fb3fd5.
1
#!/usr/bin/env bash2
# bin/_jobs.sh — sourced helper for jobs system (v3.8 Feature 1)3
#4
# Lifecycle:5
# job_create <workflow> <mode> → creates ~/.lintel/jobs/<id>/{job.yaml,outputs,inputs} + regenerates _active.md6
# job_update <id> <step> <status> → updates job.yaml current_step + step status + last_touched7
# job_archive <id> <result> → moves to _archive/<date>/<id>/ + applies cleanup8
# regenerate_active → rebuilds _active.md from all jobs/<id>/job.yaml9
# list_jobs → reads _active.md (or rebuilds if missing)10
# job_path <id> → echoes absolute job-dir path11
# stale_jobs <hours> → lists jobs untouched > N hours12
#13
# Source-once-and-call pattern (matches bin/_aliases.sh):14
# source "$(dirname "$0")/_jobs.sh"15
# job_create cycle internal-tool16
#17
# Scope (v5, ADR-0005): in a repo carrying the v5 layout marker, job data lives18
# in <repo>/.claude/runtime/jobs/ and ~/.lintel/jobs/_active.md becomes a thin19
# cross-repo REGISTRY (one line per job, pointing at the owning repo) so20
# /li:resume and /li:status keep their "what's open anywhere" view. Un-migrated21
# repos keep the historical global layout unchanged.23
LINTEL_HOME="${LINTEL_HOME:-$HOME/.lintel}"25
# v5 scope resolution — explicit LINTEL_JOBS_DIR env always wins (tests).26
_JOBS_REPO_ROOT="${LINTEL_REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)}"27
_JOBS_SCOPE="global"28
if [ -z "${LINTEL_JOBS_DIR:-}" ] && [ -n "$_JOBS_REPO_ROOT" ] \29
&& [ -f "$_JOBS_REPO_ROOT/.claude/lintel-layout.yaml" ]; then30
_jobs_layout_v=$(grep -E '^layout_version:' "$_JOBS_REPO_ROOT/.claude/lintel-layout.yaml" 2>/dev/null \31
| head -1 | awk '{print $2}' | tr -d '\r')32
if [ "${_jobs_layout_v:-0}" -ge 5 ] 2>/dev/null; then33
LINTEL_JOBS_DIR="$_JOBS_REPO_ROOT/.claude/runtime/jobs"34
_JOBS_SCOPE="repo"35
fi36
fi38
LINTEL_JOBS_DIR="${LINTEL_JOBS_DIR:-$LINTEL_HOME/jobs}"39
LINTEL_JOBS_ACTIVE="${LINTEL_JOBS_ACTIVE:-$LINTEL_JOBS_DIR/_active.md}"40
LINTEL_JOBS_ARCHIVE="${LINTEL_JOBS_ARCHIVE:-$LINTEL_JOBS_DIR/_archive}"41
LINTEL_JOBS_REGISTRY="${LINTEL_JOBS_REGISTRY:-$LINTEL_HOME/jobs/_active.md}"42
LINTEL_AUDIT_DIR="${LINTEL_AUDIT_DIR:-$LINTEL_HOME/audit}"44
# Unified audit writer (sibling in bin/). Idempotent source.45
command -v audit_log >/dev/null 2>&1 || source "$(dirname "${BASH_SOURCE[0]}")/_audit.sh"47
# LINTEL_JOBS_NO_INIT=1 -> read-only source (the session digest sources this48
# just to call job_ready; a digest must not create directories as a side effect)49
if [ -z "${LINTEL_JOBS_NO_INIT:-}" ]; then50
mkdir -p "$LINTEL_JOBS_DIR" "$LINTEL_JOBS_ARCHIVE" "$LINTEL_AUDIT_DIR" 2>/dev/null || true51
fi53
_jobs_iso_now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }54
_jobs_epoch_now() { date +%s; }56
# Generate a job-id: <workflow>-<YYYYMMDD-HHMM>-<short-hash>57
job_id() {58
local workflow="$1"59
local stamp60
stamp=$(date +"%Y%m%d-%H%M")61
local hash62
hash=$(printf '%s' "$RANDOM-$$-$(_jobs_epoch_now)" | shasum 2>/dev/null | cut -c1-6 || \63
printf '%06x' $((RANDOM * RANDOM)) | cut -c1-6)64
printf '%s-%s-%s' "$workflow" "$stamp" "$hash"65
}67
# Create a new job folder + job.yaml + regenerate _active.md68
# Args: <workflow> <mode> [<step-spec> ...]69
#70
# Each optional <step-spec> is a pipe-delimited per-step contract (same grammar71
# as job_set_steps below). When none are given, job.yaml is written with an72
# empty `steps: []` block (back-compat — the historical behaviour) and the73
# caller can populate it later via job_set_steps.74
job_create() {75
local workflow="${1:-cycle}"76
local mode="${2:-internal-tool}"77
shift 2 2>/dev/null || true78
local id79
id=$(job_id "$workflow")80
local dir="$LINTEL_JOBS_DIR/$id"81
mkdir -p "$dir/outputs" "$dir/inputs" 2>/dev/null || true83
local ts84
ts=$(_jobs_iso_now)85
cat > "$dir/job.yaml" <<EOF86
workflow: $workflow87
job_id: $id88
called_by: ${CALLED_BY:-operator}89
mode: $mode90
started_at: $ts91
last_touched: $ts92
current_step: SENSE93
status: ACTIVE94
cleanup_policy:95
keep: [adr, lessons, plan.md, spec.md, prompt.md]96
discard: [scratch/*]97
steps: []98
EOF100
# Touch a placeholder 00-state.md inside the job101
: > "$dir/00-state.md"103
# Audit (unified writer → ~/.lintel/audit/jobs.jsonl)104
audit_log "jobs" "job_begin" "job_id=$id" "workflow=$workflow" "mode=$mode"106
# Optional inline step contracts (design §3.4 — per-step consumes/produces).107
if [ "$#" -gt 0 ]; then108
job_set_steps "$id" "$@"109
fi111
regenerate_active113
printf '%s\n' "$id"114
}116
# ── Per-step contracts (design §3.4 / docs/concepts/jobs-system.md "Mechanics") ──117
#118
# A <step-spec> is a single pipe-delimited record describing one WBS leaf/phase:119
#120
# NAME|consumes=a.md,b.md|produces=c.md,d.md|status=PENDING|blocked_until=PLAN.status == DONE121
#122
# - field 1 (positional) is the step NAME. For flat/phased plans this is a123
# phase token (PLAN, BUILD); for tree plans it is a WBS node-path (1.1.a).124
# - consumes / produces are comma-separated artifact lists (rendered as YAML125
# inline sequences). Omit → [].126
# - status defaults to PENDING.127
# - blocked_until is a predicate over OTHER steps' status (grammar below).128
#129
# Field order after NAME is free; unknown keys are ignored.130
#131
# blocked_until predicate grammar (kept deliberately small — see job_can_start):132
# <predicate> := <clause> ( "&&" <clause> )*133
# <clause> := <STEP>.status == DONE134
# i.e. a conjunction of "this named step has reached DONE" tests. Empty/absent135
# means "never blocked". No OR, no negation, no other status values — by design.137
# Render one step-spec to indented YAML on stdout.138
_jobs_render_step() {139
local spec="$1"140
local name consumes produces status blocked field key val141
name=""; consumes=""; produces=""; status="PENDING"; blocked=""143
# First pipe-field is the name; the rest are key=value.144
name="${spec%%|*}"145
local rest="${spec#"$name"}"146
rest="${rest#|}"148
# Split remaining fields on '|'149
local IFS_SAVE="$IFS"150
IFS='|'151
# shellcheck disable=SC2206152
local fields=($rest)153
IFS="$IFS_SAVE"154
for field in "${fields[@]}"; do155
[ -n "$field" ] || continue156
key="${field%%=*}"157
val="${field#*=}"158
case "$key" in159
consumes) consumes="$val" ;;160
produces) produces="$val" ;;161
status) status="$val" ;;162
blocked_until) blocked="$val" ;;163
esac164
done166
# Comma-list → YAML inline sequence "[a, b]"167
_jobs_yaml_seq() {168
local csv="$1"169
[ -n "$csv" ] || { printf '[]'; return; }170
local out="" item171
local IFS=','172
# shellcheck disable=SC2206173
local items=($csv)174
for item in "${items[@]}"; do175
item="${item# }"; item="${item%% }"176
[ -n "$item" ] || continue177
[ -n "$out" ] && out="$out, $item" || out="$item"178
done179
printf '[%s]' "$out"180
}182
printf ' - name: %s\n' "$name"183
printf ' consumes: %s\n' "$(_jobs_yaml_seq "$consumes")"184
printf ' produces: %s\n' "$(_jobs_yaml_seq "$produces")"185
printf ' status: %s\n' "$status"186
if [ -n "$blocked" ]; then187
printf ' blocked_until: %s\n' "$blocked"188
fi189
}191
# Write the steps[] block of a job from one-or-more step-specs.192
# Args: <id> <step-spec> [<step-spec> ...]193
# Replaces any existing steps[] block (everything from the `steps:` line to EOF).194
job_set_steps() {195
local id="$1"; shift || true196
local dir="$LINTEL_JOBS_DIR/$id"197
[ -f "$dir/job.yaml" ] || return 1199
local body200
body=$(201
for spec in "$@"; do202
[ -n "$spec" ] || continue203
_jobs_render_step "$spec"204
done205
)207
# Reprint job.yaml up to (not including) the `steps:` line, then re-emit steps.208
# The steps block is always the trailing block (job_create writes it last),209
# so dropping from `^steps:` to EOF and re-appending is safe and idempotent.210
awk '211
/^steps:/ { found=1 }212
!found { print }213
' "$dir/job.yaml" > "$dir/job.yaml.tmp"215
if [ -z "$body" ]; then216
printf 'steps: []\n' >> "$dir/job.yaml.tmp"217
else218
printf 'steps:\n' >> "$dir/job.yaml.tmp"219
printf '%s\n' "$body" >> "$dir/job.yaml.tmp"220
fi222
mv "$dir/job.yaml.tmp" "$dir/job.yaml"224
audit_log "jobs" "job_set_steps" "job_id=$id" "count=$#"… omitted uncited lines …
280
{ print }281
' "$dir/job.yaml" > "$dir/job.yaml.tmp" && mv "$dir/job.yaml.tmp" "$dir/job.yaml"283
audit_log "jobs" "job_update" "job_id=$id" "step=$step" "status=$status"285
regenerate_active286
}288
# Archive a job + apply cleanup289
# Args: <id> <result> result in: DONE / ABORTED / FAILED290
job_archive() {291
local id="$1"292
local result="${2:-DONE}"293
local dir="$LINTEL_JOBS_DIR/$id"294
[ -d "$dir" ] || return 1296
local date_stamp297
date_stamp=$(date +"%Y-%m-%d")298
local archive_dir="$LINTEL_JOBS_ARCHIVE/$date_stamp"299
mkdir -p "$archive_dir" 2>/dev/null || true301
# Apply cleanup-policy: discard scratch/*302
if [ -d "$dir/scratch" ]; then303
rm -rf "$dir/scratch" 2>/dev/null || true304
fi306
# Mark final status + ts in job.yaml307
local ts308
ts=$(_jobs_iso_now)309
awk -v ts="$ts" -v result="$result" '310
BEGIN { updated_lt=0; updated_st=0; appended_arc=0 }311
/^last_touched:/ && !updated_lt { print "last_touched: " ts; updated_lt=1; next }312
/^status:/ && !updated_st { print "status: " result; updated_st=1; next }313
{ print }314
END { print "archived_at: " ts }315
' "$dir/job.yaml" > "$dir/job.yaml.tmp" && mv "$dir/job.yaml.tmp" "$dir/job.yaml"317
# Move to archive (mv across paths)318
mv "$dir" "$archive_dir/" 2>/dev/null || true320
audit_log "jobs" "job_end" "job_id=$id" "result=$result"322
regenerate_active323
}325
# Regenerate the active-jobs view.326
# Repo scope: detailed repo-local _active.md + registry line sync.327
# Global scope: the global _active.md IS the registry — write registry format328
# there too, preserving migrated repos' lines (mixed-scope grace window must329
# not clobber either side's entries).330
regenerate_active() {331
if [ "$_JOBS_SCOPE" = "repo" ]; then332
local out="$LINTEL_JOBS_ACTIVE"333
{334
printf '# Active Lintel jobs (this repo)\n'335
printf '\n'336
printf '> Generated by bin/_jobs.sh at %s. Do not edit by hand.\n' "$(_jobs_iso_now)"337
printf '\n'339
local count=0340
for d in "$LINTEL_JOBS_DIR"/*/; do341
[ -d "$d" ] || continue342
local f="$d/job.yaml"343
[ -f "$f" ] || continue344
local id workflow mode current_step status started last_touched345
id=$(grep '^job_id:' "$f" | head -1 | awk '{print $2}')346
workflow=$(grep '^workflow:' "$f" | head -1 | awk '{print $2}')347
mode=$(grep '^mode:' "$f" | head -1 | awk '{print $2}')348
current_step=$(grep '^current_step:' "$f" | head -1 | awk '{print $2}')349
status=$(grep '^status:' "$f" | head -1 | awk '{print $2}')350
started=$(grep '^started_at:' "$f" | head -1 | awk '{print $2}')351
last_touched=$(grep '^last_touched:' "$f" | head -1 | awk '{print $2}')353
printf '## %s\n' "$id"354
printf -- '- workflow: `%s`\n' "$workflow"355
printf -- '- mode: `%s`\n' "$mode"356
printf -- '- current_step: `%s`\n' "$current_step"357
printf -- '- status: `%s`\n' "$status"358
printf -- '- started: %s\n' "$started"359
printf -- '- last_touched: %s\n' "$last_touched"360
printf '\n'361
count=$((count + 1))362
done364
if [ "$count" -eq 0 ]; then365
printf '_No active jobs._\n'366
fi367
} > "$out"368
_registry_sync "$_JOBS_REPO_ROOT"369
else370
# Global/legacy scope: write OUR jobs as registry lines into the shared371
# file, keeping every line owned by other scopes (migrated repos).372
LINTEL_JOBS_REGISTRY="$LINTEL_JOBS_ACTIVE" _registry_sync "global"373
fi374
}376
# ── cross-repo registry (v5) ─────────────────────────────────────────────────377
# ~/.lintel/jobs/_active.md holds one line per open job across ALL scopes.378
# Each line carries an HTML-comment marker with the owning scope (a repo root,379
# or "global" for legacy ~/.lintel/jobs data); syncing = drop this scope's380
# lines, re-append the current ones, preserve everyone else's.381
# Args: <scope-tag> (repo root path, or "global")382
_registry_sync() {383
local scope_tag="${1:-$_JOBS_REPO_ROOT}"384
local reg="$LINTEL_JOBS_REGISTRY"385
mkdir -p "$(dirname "$reg")" 2>/dev/null || true387
local marker="<!-- repo:$scope_tag -->"388
local tmp="$reg.tmp.$$"389
{390
if [ -f "$reg" ] && grep -q '^# Lintel jobs registry' "$reg" 2>/dev/null; then391
grep -vF "$marker" "$reg" | grep -v '^_No open jobs' || true392
else393
# Fresh registry (or first write over a legacy-format file — legacy394
# entries reappear on that scope's next regenerate_active).395
printf '# Lintel jobs registry (cross-repo)\n\n'396
printf '> One line per open job; data lives in each repo'"'"'s .claude/runtime/jobs/.\n'397
printf '> Generated by bin/_jobs.sh. Do not edit by hand.\n\n'398
fi399
local d f id workflow status current_step400
for d in "$LINTEL_JOBS_DIR"/*/; do401
[ -d "$d" ] || continue402
f="$d/job.yaml"403
[ -f "$f" ] || continue404
id=$(grep '^job_id:' "$f" | head -1 | awk '{print $2}')405
workflow=$(grep '^workflow:' "$f" | head -1 | awk '{print $2}')406
status=$(grep '^status:' "$f" | head -1 | awk '{print $2}')407
current_step=$(grep '^current_step:' "$f" | head -1 | awk '{print $2}')408
printf -- '- `%s` · %s · %s · step:%s · %s %s\n' \409
"$id" "$workflow" "$status" "$current_step" "$scope_tag" "$marker"410
done411
} > "$tmp"… omitted uncited lines …
418
}420
# Print all jobs421
list_jobs() {422
[ -f "$LINTEL_JOBS_ACTIVE" ] || regenerate_active423
cat "$LINTEL_JOBS_ACTIVE"424
}426
# Find jobs untouched > N hours427
# Args: <hours> (default 24)428
stale_jobs() {429
local hours="${1:-24}"430
local now_epoch431
now_epoch=$(_jobs_epoch_now)432
local threshold=$((hours * 3600))434
for d in "$LINTEL_JOBS_DIR"/*/; do435
[ -d "$d" ] || continue436
local f="$d/job.yaml"437
[ -f "$f" ] || continue438
local id last_touched439
id=$(grep '^job_id:' "$f" | head -1 | awk '{print $2}')440
last_touched=$(grep '^last_touched:' "$f" | head -1 | awk '{print $2}')441
[ -z "$last_touched" ] && continue