Source index · Technical reference · Home

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 bash
2# 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.md
6# job_update <id> <step> <status> → updates job.yaml current_step + step status + last_touched
7# job_archive <id> <result> → moves to _archive/<date>/<id>/ + applies cleanup
8# regenerate_active → rebuilds _active.md from all jobs/<id>/job.yaml
9# list_jobs → reads _active.md (or rebuilds if missing)
10# job_path <id> → echoes absolute job-dir path
11# stale_jobs <hours> → lists jobs untouched > N hours
13# Source-once-and-call pattern (matches bin/_aliases.sh):
14# source "$(dirname "$0")/_jobs.sh"
15# job_create cycle internal-tool
17# Scope (v5, ADR-0005): in a repo carrying the v5 layout marker, job data lives
18# in <repo>/.claude/runtime/jobs/ and ~/.lintel/jobs/_active.md becomes a thin
19# cross-repo REGISTRY (one line per job, pointing at the owning repo) so
20# /li:resume and /li:status keep their "what's open anywhere" view. Un-migrated
21# repos keep the historical global layout unchanged.
23LINTEL_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"
28if [ -z "${LINTEL_JOBS_DIR:-}" ] && [ -n "$_JOBS_REPO_ROOT" ] \
29 && [ -f "$_JOBS_REPO_ROOT/.claude/lintel-layout.yaml" ]; then
30 _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; then
33 LINTEL_JOBS_DIR="$_JOBS_REPO_ROOT/.claude/runtime/jobs"
34 _JOBS_SCOPE="repo"
35 fi
36fi
38LINTEL_JOBS_DIR="${LINTEL_JOBS_DIR:-$LINTEL_HOME/jobs}"
39LINTEL_JOBS_ACTIVE="${LINTEL_JOBS_ACTIVE:-$LINTEL_JOBS_DIR/_active.md}"
40LINTEL_JOBS_ARCHIVE="${LINTEL_JOBS_ARCHIVE:-$LINTEL_JOBS_DIR/_archive}"
41LINTEL_JOBS_REGISTRY="${LINTEL_JOBS_REGISTRY:-$LINTEL_HOME/jobs/_active.md}"
42LINTEL_AUDIT_DIR="${LINTEL_AUDIT_DIR:-$LINTEL_HOME/audit}"
44# Unified audit writer (sibling in bin/). Idempotent source.
45command -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 this
48# just to call job_ready; a digest must not create directories as a side effect)
49if [ -z "${LINTEL_JOBS_NO_INIT:-}" ]; then
50 mkdir -p "$LINTEL_JOBS_DIR" "$LINTEL_JOBS_ARCHIVE" "$LINTEL_AUDIT_DIR" 2>/dev/null || true
51fi
53_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>
57job_id() {
58 local workflow="$1"
59 local stamp
60 stamp=$(date +"%Y%m%d-%H%M")
61 local hash
62 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"
67# Create a new job folder + job.yaml + regenerate _active.md
68# Args: <workflow> <mode> [<step-spec> ...]
70# Each optional <step-spec> is a pipe-delimited per-step contract (same grammar
71# as job_set_steps below). When none are given, job.yaml is written with an
72# empty `steps: []` block (back-compat — the historical behaviour) and the
73# caller can populate it later via job_set_steps.
74job_create() {
75 local workflow="${1:-cycle}"
76 local mode="${2:-internal-tool}"
77 shift 2 2>/dev/null || true
78 local id
79 id=$(job_id "$workflow")
80 local dir="$LINTEL_JOBS_DIR/$id"
81 mkdir -p "$dir/outputs" "$dir/inputs" 2>/dev/null || true
83 local ts
84 ts=$(_jobs_iso_now)
85 cat > "$dir/job.yaml" <<EOF
86workflow: $workflow
87job_id: $id
88called_by: ${CALLED_BY:-operator}
89mode: $mode
90started_at: $ts
91last_touched: $ts
92current_step: SENSE
93status: ACTIVE
94cleanup_policy:
95 keep: [adr, lessons, plan.md, spec.md, prompt.md]
96 discard: [scratch/*]
97steps: []
98EOF
100 # Touch a placeholder 00-state.md inside the job
101 : > "$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 ]; then
108 job_set_steps "$id" "$@"
109 fi
111 regenerate_active
113 printf '%s\n' "$id"
116# ── Per-step contracts (design §3.4 / docs/concepts/jobs-system.md "Mechanics") ──
118# A <step-spec> is a single pipe-delimited record describing one WBS leaf/phase:
120# NAME|consumes=a.md,b.md|produces=c.md,d.md|status=PENDING|blocked_until=PLAN.status == DONE
122# - field 1 (positional) is the step NAME. For flat/phased plans this is a
123# 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 YAML
125# inline sequences). Omit → [].
126# - status defaults to PENDING.
127# - blocked_until is a predicate over OTHER steps' status (grammar below).
129# Field order after NAME is free; unknown keys are ignored.
131# blocked_until predicate grammar (kept deliberately small — see job_can_start):
132# <predicate> := <clause> ( "&&" <clause> )*
133# <clause> := <STEP>.status == DONE
134# i.e. a conjunction of "this named step has reached DONE" tests. Empty/absent
135# 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 val
141 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=SC2206
152 local fields=($rest)
153 IFS="$IFS_SAVE"
154 for field in "${fields[@]}"; do
155 [ -n "$field" ] || continue
156 key="${field%%=*}"
157 val="${field#*=}"
158 case "$key" in
159 consumes) consumes="$val" ;;
160 produces) produces="$val" ;;
161 status) status="$val" ;;
162 blocked_until) blocked="$val" ;;
163 esac
164 done
166 # Comma-list → YAML inline sequence "[a, b]"
167 _jobs_yaml_seq() {
168 local csv="$1"
169 [ -n "$csv" ] || { printf '[]'; return; }
170 local out="" item
171 local IFS=','
172 # shellcheck disable=SC2206
173 local items=($csv)
174 for item in "${items[@]}"; do
175 item="${item# }"; item="${item%% }"
176 [ -n "$item" ] || continue
177 [ -n "$out" ] && out="$out, $item" || out="$item"
178 done
179 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" ]; then
187 printf ' blocked_until: %s\n' "$blocked"
188 fi
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).
194job_set_steps() {
195 local id="$1"; shift || true
196 local dir="$LINTEL_JOBS_DIR/$id"
197 [ -f "$dir/job.yaml" ] || return 1
199 local body
200 body=$(
201 for spec in "$@"; do
202 [ -n "$spec" ] || continue
203 _jobs_render_step "$spec"
204 done
205 )
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" ]; then
216 printf 'steps: []\n' >> "$dir/job.yaml.tmp"
217 else
218 printf 'steps:\n' >> "$dir/job.yaml.tmp"
219 printf '%s\n' "$body" >> "$dir/job.yaml.tmp"
220 fi
222 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_active
288# Archive a job + apply cleanup
289# Args: <id> <result> result in: DONE / ABORTED / FAILED
290job_archive() {
291 local id="$1"
292 local result="${2:-DONE}"
293 local dir="$LINTEL_JOBS_DIR/$id"
294 [ -d "$dir" ] || return 1
296 local date_stamp
297 date_stamp=$(date +"%Y-%m-%d")
298 local archive_dir="$LINTEL_JOBS_ARCHIVE/$date_stamp"
299 mkdir -p "$archive_dir" 2>/dev/null || true
301 # Apply cleanup-policy: discard scratch/*
302 if [ -d "$dir/scratch" ]; then
303 rm -rf "$dir/scratch" 2>/dev/null || true
304 fi
306 # Mark final status + ts in job.yaml
307 local ts
308 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 || true
320 audit_log "jobs" "job_end" "job_id=$id" "result=$result"
322 regenerate_active
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 format
328# there too, preserving migrated repos' lines (mixed-scope grace window must
329# not clobber either side's entries).
330regenerate_active() {
331 if [ "$_JOBS_SCOPE" = "repo" ]; then
332 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=0
340 for d in "$LINTEL_JOBS_DIR"/*/; do
341 [ -d "$d" ] || continue
342 local f="$d/job.yaml"
343 [ -f "$f" ] || continue
344 local id workflow mode current_step status started last_touched
345 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 done
364 if [ "$count" -eq 0 ]; then
365 printf '_No active jobs._\n'
366 fi
367 } > "$out"
368 _registry_sync "$_JOBS_REPO_ROOT"
369 else
370 # Global/legacy scope: write OUR jobs as registry lines into the shared
371 # file, keeping every line owned by other scopes (migrated repos).
372 LINTEL_JOBS_REGISTRY="$LINTEL_JOBS_ACTIVE" _registry_sync "global"
373 fi
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's
380# 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 || true
387 local marker="<!-- repo:$scope_tag -->"
388 local tmp="$reg.tmp.$$"
389 {
390 if [ -f "$reg" ] && grep -q '^# Lintel jobs registry' "$reg" 2>/dev/null; then
391 grep -vF "$marker" "$reg" | grep -v '^_No open jobs' || true
392 else
393 # Fresh registry (or first write over a legacy-format file — legacy
394 # 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 fi
399 local d f id workflow status current_step
400 for d in "$LINTEL_JOBS_DIR"/*/; do
401 [ -d "$d" ] || continue
402 f="$d/job.yaml"
403 [ -f "$f" ] || continue
404 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 done
411 } > "$tmp"
… omitted uncited lines …
420# Print all jobs
421list_jobs() {
422 [ -f "$LINTEL_JOBS_ACTIVE" ] || regenerate_active
423 cat "$LINTEL_JOBS_ACTIVE"
426# Find jobs untouched > N hours
427# Args: <hours> (default 24)
428stale_jobs() {
429 local hours="${1:-24}"
430 local now_epoch
431 now_epoch=$(_jobs_epoch_now)
432 local threshold=$((hours * 3600))
434 for d in "$LINTEL_JOBS_DIR"/*/; do
435 [ -d "$d" ] || continue
436 local f="$d/job.yaml"
437 [ -f "$f" ] || continue
438 local id last_touched
439 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