@jenga-ai/agent 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +16 -1
  3. package/agents/scrum-master.md +1 -0
  4. package/bin/jenga.js +10 -0
  5. package/lib/commands/dashboard.js +92 -0
  6. package/lib/skill-allow-list.json +6 -2
  7. package/package.json +21 -2
  8. package/project/app/api/lib/resolve-project-root.js +120 -0
  9. package/project/app/api/package.json +16 -0
  10. package/project/app/api/parsers/architecture.js +72 -0
  11. package/project/app/api/parsers/board.js +141 -0
  12. package/project/app/api/parsers/documentation.js +125 -0
  13. package/project/app/api/parsers/git-log.js +52 -0
  14. package/project/app/api/parsers/ideas.js +62 -0
  15. package/project/app/api/parsers/knowledge-graph.js +73 -0
  16. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  17. package/project/app/api/parsers/rapports.js +148 -0
  18. package/project/app/api/parsers/todo.js +179 -0
  19. package/project/app/api/response.js +47 -0
  20. package/project/app/api/routes/architecture.js +23 -0
  21. package/project/app/api/routes/board.js +46 -0
  22. package/project/app/api/routes/documentation.js +24 -0
  23. package/project/app/api/routes/health.js +25 -0
  24. package/project/app/api/routes/history.js +55 -0
  25. package/project/app/api/routes/rapports.js +24 -0
  26. package/project/app/api/scripts/capture-snapshot.js +294 -0
  27. package/project/app/api/server.js +112 -0
  28. package/project/app/api/types.js +40 -0
  29. package/project/app/package.json +21 -0
  30. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  31. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  32. package/project/app/ui/dist/index.html +13 -0
  33. package/project/app/ui/package.json +23 -0
  34. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  35. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  36. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  37. package/scripts/acquire-concurrency-slot.sh +220 -0
  38. package/scripts/compute-deploy-reconcile.sh +439 -0
  39. package/scripts/jenga-permission-level-switch.sh +19 -3
  40. package/scripts/mark-deployed.sh +532 -0
  41. package/scripts/populate-knowledge-graph.js +429 -0
  42. package/scripts/release-concurrency-slot.sh +129 -0
  43. package/scripts/validate-board.sh +60 -2
  44. package/scripts/verify-consumer-install.sh +470 -0
  45. package/skills/j-cloud-connect/SKILL.md +95 -0
  46. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  47. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  48. package/skills/j-dashboard/SKILL.md +144 -0
  49. package/skills/j-dashboard/scripts/launch.sh +121 -0
  50. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  51. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  52. package/skills/j-dashboard-share/SKILL.md +96 -0
  53. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  54. package/skills/j-playbook/SKILL.md +12 -0
  55. package/skills/j-playbook-new/SKILL.md +155 -0
  56. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  57. package/skills/j-publish/scripts/npm_ci_pipeline.sh +6 -0
  58. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  59. package/skills/jenga/scripts/load-playbooks.sh +123 -24
  60. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # scripts/mark-deployed.sh
4
+ #
5
+ # Given E51_S05_T01's compute-deploy-reconcile.sh TSV output (one line per
6
+ # resolved, not-yet-reconciled public tag:
7
+ # <tag_type>\t<version>\t<public_tag_name>\t<resolved_private_sha>
8
+ # where tag_type is "stage" or "prod"), this script:
9
+ # 1. Enumerates board tickets (tasks + stories) currently at
10
+ # status: Publicized or status: Deployed to Stage -- never Pending,
11
+ # Done, Passed, etc. directly (per E51's own DoD: "only Publicized
12
+ # tickets are eligible for Deployed to Stage -> Deployed to Prod").
13
+ # 2. Derives each eligible ticket's matched EST-tagged commit SHAs (and,
14
+ # for parity/logging, their unioned touched-file list) using the
15
+ # identical anchored-grep + union technique
16
+ # skills/self-sync/scripts/mark-merged.sh already implements.
17
+ # 3. Checks ANCESTRY, not diff-membership: a ticket is "covered" by a tag
18
+ # only when EVERY one of its matched commits is an ancestor of that
19
+ # tag's resolved_private_sha (git merge-base --is-ancestor). A tag
20
+ # represents a cumulative release, so "has this ticket's work already
21
+ # landed by the time this commit was tagged" is the correct question --
22
+ # unlike Merged/Publicized, which check "did this specific sync/mirror
23
+ # run's diff include these files".
24
+ # 4. Writes status via scripts/with-lock.sh + close-story's existing
25
+ # update-task-frontmatter.sh (reused, not reimplemented):
26
+ # stage tag: Publicized -> Deployed to Stage. Already at
27
+ # Deployed to Stage or Deployed to Prod -> unchanged
28
+ # (idempotent, never demotes Prod back to Stage).
29
+ # prod tag: Publicized OR Deployed to Stage -> Deployed to Prod
30
+ # directly (never left at Stage alongside a separate Prod
31
+ # entry). Already at Deployed to Prod -> unchanged.
32
+ # When (and only when) a ticket is set to Deployed to Prod, this script
33
+ # ALSO writes date_deployed_prod: <today, ISO 8601> to the same
34
+ # ticket's frontmatter, inside the SAME locked write window -- one
35
+ # with-lock.sh call wrapping a `bash -c '... status "Deployed to
36
+ # Prod" && ... date_deployed_prod <today>'`, never two separately
37
+ # locked writes.
38
+ # 5. Processing order: all stage-type tag lines first, then all prod-type
39
+ # tag lines -- so a same-run prod tag can promote a ticket the stage
40
+ # pass in this same run just touched. Ticket status is re-read fresh
41
+ # for every tag line processed (never cached across tag iterations),
42
+ # since an earlier tag in the same run may have already changed it.
43
+ # 6. Partial or zero coverage -> ticket left unchanged, no error. A ticket
44
+ # with zero matched commits is left unchanged (nothing to compare).
45
+ # 7. Only after this run's tag lines have each been attempted, advances
46
+ # project/data/deploy-reconcile-marker.json's reconciled_tags array --
47
+ # but PER-TAG, not all-or-nothing: a tag is appended only if every
48
+ # board write attempted while processing it succeeded. A tag that hit
49
+ # a write failure (e.g. a with-lock.sh timeout on one ticket's board
50
+ # file) is NOT appended, so a future run retries exactly that tag,
51
+ # while OTHER tags in the same run that fully succeeded are still
52
+ # recorded. This is a deliberate, finer-grained deviation from
53
+ # mark-merged.sh's all-or-nothing marker advancement, per this task's
54
+ # own spec (a single run may resolve several tags at once, and one
55
+ # tag's failure should not force every sibling tag to be redone).
56
+ #
57
+ # Marker file: project/data/deploy-reconcile-marker.json. Does not exist on
58
+ # disk yet as of this script's authoring -- created (seeded
59
+ # {"reconciled_tags": []}) on first successful append, never on a
60
+ # nothing-to-append run. Read-side of this file is compute-deploy-
61
+ # reconcile.sh's job (already implemented, read-only, lock-protected); this
62
+ # script owns the write side exclusively.
63
+ #
64
+ # Input source (documented interface -- caller's choice of any one of),
65
+ # mirroring mark-merged.sh's own --diff-file / --stdin / default convention:
66
+ # --diff-file <path> Read TSV lines from a file (primarily for
67
+ # testability -- feed a synthetic input without a
68
+ # real compute-deploy-reconcile.sh run).
69
+ # --stdin Explicitly read TSV lines from stdin, e.g.
70
+ # `compute-deploy-reconcile.sh | mark-deployed.sh
71
+ # --stdin`. Not auto-detected via a `[ ! -t 0 ]`
72
+ # check -- see mark-merged.sh's header for why a TTY
73
+ # check is unsafe for a subprocess-spawned caller.
74
+ # (neither) Default: invoke the sibling
75
+ # compute-deploy-reconcile.sh directly and use its
76
+ # stdout.
77
+ #
78
+ # Marker-file JSON read/write uses python3 directly (no jq path) --
79
+ # consistent with this repo's own scripts/validate-board.sh, which requires
80
+ # python3 unconditionally with no jq fallback at all. The frontmatter status
81
+ # writes below reuse update-task-frontmatter.sh (pure bash, no interpreter
82
+ # dependency), matching mark-merged.sh.
83
+ #
84
+ # Exit codes:
85
+ # 0 success -- every attempted board write succeeded (including the
86
+ # "nothing to reconcile" / empty-input case)
87
+ # 1 one or more board writes failed during this run -- the tags NOT
88
+ # affected by a failure were still recorded as reconciled; only the
89
+ # failing tag(s) are left for a future retry. Not a hard abort: this
90
+ # script always finishes processing every tag line in its input before
91
+ # exiting.
92
+ # ---------------------------------------------------------------------------
93
+
94
+ set -uo pipefail
95
+ IFS=$'\n\t'
96
+
97
+ # -----------------------------------------------------------------------------
98
+ # Helpers
99
+ # -----------------------------------------------------------------------------
100
+
101
+ die() {
102
+ printf 'mark-deployed.sh: error: %s\n' "$*" >&2
103
+ exit 1
104
+ }
105
+
106
+ log() {
107
+ printf 'mark-deployed.sh: %s\n' "$*" >&2
108
+ }
109
+
110
+ warn() {
111
+ printf 'mark-deployed.sh: WARNING: %s\n' "$*" >&2
112
+ }
113
+
114
+ usage() {
115
+ cat <<'EOF'
116
+ Usage: mark-deployed.sh [--diff-file <path> | --stdin] [-h|--help]
117
+
118
+ Reads TSV lines (tag_type<TAB>version<TAB>public_tag_name<TAB>resolved_private_sha)
119
+ as produced by scripts/compute-deploy-reconcile.sh, matches eligible board
120
+ tickets (status: Publicized or Deployed to Stage) by commit ancestry, writes
121
+ Deployed to Stage / Deployed to Prod status (plus date_deployed_prod on a
122
+ Prod write), and advances project/data/deploy-reconcile-marker.json's
123
+ reconciled_tags array for each tag line that completed with zero write
124
+ failures.
125
+
126
+ Input source (pick one; documented in the script header):
127
+ --diff-file <path> Read TSV lines from a file.
128
+ --stdin Explicitly read TSV lines from stdin (e.g.
129
+ `compute-deploy-reconcile.sh | mark-deployed.sh
130
+ --stdin`). Not auto-detected.
131
+ (neither) Default: invoke the sibling
132
+ compute-deploy-reconcile.sh.
133
+ EOF
134
+ }
135
+
136
+ INPUT_FILE_ARG=""
137
+ READ_STDIN=0
138
+
139
+ while [ $# -gt 0 ]; do
140
+ case "$1" in
141
+ --diff-file)
142
+ [ $# -ge 2 ] || die "--diff-file requires a path argument"
143
+ INPUT_FILE_ARG="$2"
144
+ shift 2
145
+ ;;
146
+ --stdin)
147
+ READ_STDIN=1
148
+ shift
149
+ ;;
150
+ -h|--help)
151
+ usage
152
+ exit 0
153
+ ;;
154
+ *)
155
+ usage >&2
156
+ die "unknown argument: $1"
157
+ ;;
158
+ esac
159
+ done
160
+
161
+ if [ -n "$INPUT_FILE_ARG" ] && [ "$READ_STDIN" -eq 1 ]; then
162
+ usage >&2
163
+ die "--diff-file and --stdin are mutually exclusive"
164
+ fi
165
+
166
+ # -----------------------------------------------------------------------------
167
+ # Locate script + repo root (same symlink-resolution + repo-root derivation
168
+ # pattern as mark-merged.sh / compute-deploy-reconcile.sh).
169
+ # -----------------------------------------------------------------------------
170
+
171
+ SCRIPT_PATH="${BASH_SOURCE[0]}"
172
+ while [ -h "$SCRIPT_PATH" ]; do
173
+ LINK_TARGET="$(readlink "$SCRIPT_PATH")"
174
+ case "$LINK_TARGET" in
175
+ /*) SCRIPT_PATH="$LINK_TARGET" ;;
176
+ *) SCRIPT_PATH="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)/$LINK_TARGET" ;;
177
+ esac
178
+ done
179
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
180
+
181
+ REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
182
+ [ -n "$REPO_ROOT" ] || die "could not locate repo root (git rev-parse failed from $SCRIPT_DIR)"
183
+
184
+ COMPUTE_SCRIPT="$SCRIPT_DIR/compute-deploy-reconcile.sh"
185
+ WITH_LOCK_SCRIPT="$REPO_ROOT/scripts/with-lock.sh"
186
+ UPDATE_FRONTMATTER_SCRIPT="$REPO_ROOT/skills/close-story/scripts/update-task-frontmatter.sh"
187
+ MARKER_FILE="$REPO_ROOT/project/data/deploy-reconcile-marker.json"
188
+
189
+ [ -f "$WITH_LOCK_SCRIPT" ] || die "expected script not found: $WITH_LOCK_SCRIPT"
190
+ [ -f "$UPDATE_FRONTMATTER_SCRIPT" ] || die "expected script not found: $UPDATE_FRONTMATTER_SCRIPT"
191
+ command -v python3 >/dev/null 2>&1 || die "python3 not installed (required to read/write $MARKER_FILE)"
192
+
193
+ TASKS_DIR="$REPO_ROOT/project/board/tasks"
194
+ STORIES_DIR="$REPO_ROOT/project/board/stories"
195
+
196
+ # -----------------------------------------------------------------------------
197
+ # Resolve the input source into a materialised temp file.
198
+ # -----------------------------------------------------------------------------
199
+
200
+ INPUT_FILE="$(mktemp)"
201
+ CLEANUP_FILES=("$INPUT_FILE")
202
+ cleanup() {
203
+ rm -f "${CLEANUP_FILES[@]}" 2>/dev/null || true
204
+ }
205
+ trap cleanup EXIT
206
+
207
+ if [ -n "$INPUT_FILE_ARG" ]; then
208
+ [ -f "$INPUT_FILE_ARG" ] || die "--diff-file path not found: $INPUT_FILE_ARG"
209
+ log "reading tag input from --diff-file: $INPUT_FILE_ARG"
210
+ cp "$INPUT_FILE_ARG" "$INPUT_FILE"
211
+ elif [ "$READ_STDIN" -eq 1 ]; then
212
+ log "reading tag input from stdin (--stdin)"
213
+ cat > "$INPUT_FILE"
214
+ else
215
+ [ -f "$COMPUTE_SCRIPT" ] || die "expected sibling script not found: $COMPUTE_SCRIPT"
216
+ log "no --diff-file and no --stdin -- invoking compute-deploy-reconcile.sh"
217
+ if ! "$COMPUTE_SCRIPT" > "$INPUT_FILE"; then
218
+ die "compute-deploy-reconcile.sh failed"
219
+ fi
220
+ fi
221
+
222
+ # Strip accidental blank lines (same rationale as mark-merged.sh: a
223
+ # genuinely-empty input must compare as zero-byte, not one blank line).
224
+ BLANK_STRIPPED="$(mktemp)"
225
+ CLEANUP_FILES+=("$BLANK_STRIPPED")
226
+ grep -v '^[[:space:]]*$' "$INPUT_FILE" > "$BLANK_STRIPPED" 2>/dev/null || true
227
+ mv "$BLANK_STRIPPED" "$INPUT_FILE"
228
+
229
+ LINE_COUNT="$(wc -l < "$INPUT_FILE" | tr -d '[:space:]')"
230
+ log "tag input contains $LINE_COUNT line(s)"
231
+
232
+ if [ "$LINE_COUNT" -eq 0 ]; then
233
+ log "nothing to reconcile -- zero tag lines, zero board writes, marker not touched"
234
+ exit 0
235
+ fi
236
+
237
+ # -----------------------------------------------------------------------------
238
+ # Parse TSV lines into stage/prod buckets, preserving input order within
239
+ # each bucket.
240
+ # -----------------------------------------------------------------------------
241
+
242
+ STAGE_LINES=()
243
+ PROD_LINES=()
244
+
245
+ while IFS=$'\t' read -r tag_type version tag_name sha; do
246
+ [ -n "$tag_type" ] || continue
247
+ case "$tag_type" in
248
+ stage) STAGE_LINES+=("$tag_type"$'\t'"$version"$'\t'"$tag_name"$'\t'"$sha") ;;
249
+ prod) PROD_LINES+=("$tag_type"$'\t'"$version"$'\t'"$tag_name"$'\t'"$sha") ;;
250
+ *) warn "unrecognized tag_type '$tag_type' on input line for tag '$tag_name' -- skipping this line entirely" ;;
251
+ esac
252
+ done < "$INPUT_FILE"
253
+
254
+ log "parsed ${#STAGE_LINES[@]} stage line(s), ${#PROD_LINES[@]} prod line(s)"
255
+
256
+ # -----------------------------------------------------------------------------
257
+ # Frontmatter field reader + trim (identical to mark-merged.sh's own
258
+ # helpers -- small, tolerated duplication rather than a third shared-helper
259
+ # script for two ~10-line functions; this repo already tolerates the same
260
+ # duplication between mark-merged.sh and compute-deploy-reconcile.sh's own
261
+ # COPY_SET extraction).
262
+ # -----------------------------------------------------------------------------
263
+
264
+ read_frontmatter_field() {
265
+ local file="$1" key="$2"
266
+ awk -v key="$key" '
267
+ NR==1 && $0=="---" { infm=1; next }
268
+ infm && $0=="---" { exit }
269
+ infm && $0 ~ ("^" key ":") {
270
+ sub("^" key ":[ \t]*", "", $0)
271
+ print $0
272
+ exit
273
+ }
274
+ ' "$file"
275
+ }
276
+
277
+ trim() {
278
+ local s="$1"
279
+ s="${s#"${s%%[![:space:]]*}"}"
280
+ s="${s%"${s##*[![:space:]]}"}"
281
+ s="${s%\"}"; s="${s#\"}"
282
+ s="${s%\'}"; s="${s#\'}"
283
+ printf '%s' "$s"
284
+ }
285
+
286
+ # -----------------------------------------------------------------------------
287
+ # Commit-matching + ancestry check
288
+ # -----------------------------------------------------------------------------
289
+
290
+ # Prints newline-separated matched EST-tagged commit SHAs for a ticket id
291
+ # (anchored grep, identical to mark-merged.sh -- guards against a story id
292
+ # being a literal string prefix of its own tasks' ids). Empty output means
293
+ # zero matched commits.
294
+ commits_for_ticket() {
295
+ local id="$1"
296
+ git -C "$REPO_ROOT" log --all --no-merges --extended-regexp \
297
+ --grep="${id}([^0-9_]|\$)" --pretty=format:'%H' 2>/dev/null || true
298
+ }
299
+
300
+ commit_files() {
301
+ local sha="$1"
302
+ if git -C "$REPO_ROOT" rev-parse --verify -q "${sha}~1" >/dev/null 2>&1; then
303
+ git -C "$REPO_ROOT" diff --name-only "${sha}~1..${sha}" 2>/dev/null || true
304
+ else
305
+ git -C "$REPO_ROOT" diff-tree --no-commit-id --name-only -r "$sha" 2>/dev/null || true
306
+ fi
307
+ }
308
+
309
+ # Sorted-unique union of every file touched by every commit matching this
310
+ # ticket id. Kept for parity/logging with mark-merged.sh's derivation, even
311
+ # though the actual coverage decision below is ancestry-based, not
312
+ # diff-membership-based.
313
+ collect_touched_files() {
314
+ local shas="$1" sha
315
+ [ -n "$shas" ] || return 0
316
+ while IFS= read -r sha; do
317
+ [ -n "$sha" ] || continue
318
+ commit_files "$sha"
319
+ done <<< "$shas" | sort -u
320
+ }
321
+
322
+ # True (0) iff EVERY commit sha in $1 (newline-separated, non-empty) is an
323
+ # ancestor of $2 (the tag's resolved private SHA). A commit sha that cannot
324
+ # be resolved to an ancestry relationship (e.g. object graph oddity) counts
325
+ # as "not an ancestor" rather than a hard script error -- coverage simply
326
+ # fails closed for that ticket/tag pair, and the ticket is left unchanged.
327
+ all_ancestors_of() {
328
+ local shas="$1" target_sha="$2" sha
329
+ while IFS= read -r sha; do
330
+ [ -n "$sha" ] || continue
331
+ git -C "$REPO_ROOT" merge-base --is-ancestor "$sha" "$target_sha" 2>/dev/null || return 1
332
+ done <<< "$shas"
333
+ return 0
334
+ }
335
+
336
+ # -----------------------------------------------------------------------------
337
+ # Status write helpers
338
+ # -----------------------------------------------------------------------------
339
+
340
+ write_status() {
341
+ local file="$1" new_status="$2"
342
+ "$WITH_LOCK_SCRIPT" "$file" -- bash "$UPDATE_FRONTMATTER_SCRIPT" "$file" status "$new_status"
343
+ }
344
+
345
+ # Writes status: Deployed to Prod AND date_deployed_prod inside the SAME
346
+ # locked window -- one with-lock.sh call wrapping a bash -c that chains both
347
+ # update-task-frontmatter.sh invocations, never two separately-locked calls
348
+ # (so no observer can see the status flip without the date already present).
349
+ write_deployed_to_prod() {
350
+ local file="$1" today="$2"
351
+ # update-task-frontmatter.sh does not carry an execute bit in this repo
352
+ # (same as mark-merged.sh's own documented reasoning) -- invoke it via
353
+ # `bash "$1"` inside the bash -c, not a direct exec.
354
+ "$WITH_LOCK_SCRIPT" "$file" -- bash -c \
355
+ 'bash "$1" "$2" status "Deployed to Prod" && bash "$1" "$2" date_deployed_prod "$3"' \
356
+ _ "$UPDATE_FRONTMATTER_SCRIPT" "$file" "$today"
357
+ }
358
+
359
+ # -----------------------------------------------------------------------------
360
+ # Marker file read/write (python3-only, matching validate-board.sh's own
361
+ # unconditional python3 dependency -- no jq fallback needed here).
362
+ # -----------------------------------------------------------------------------
363
+
364
+ advance_marker() {
365
+ # Filter out empty-string elements before counting: the call site passes
366
+ # "${RECONCILED_OK[@]:-}", which (per bash's :- expansion on an empty
367
+ # array) yields a single empty-string argument rather than zero
368
+ # arguments when RECONCILED_OK has no elements. Without this filter, that
369
+ # phantom empty string would be miscounted as one real tag and appended
370
+ # to reconciled_tags as "" (an empty version-name entry).
371
+ local -a tags=()
372
+ local t
373
+ for t in "$@"; do
374
+ [ -n "$t" ] || continue
375
+ tags+=("$t")
376
+ done
377
+ [ "${#tags[@]}" -gt 0 ] || { log "no tag(s) succeeded this run -- marker not advanced"; return 0; }
378
+
379
+ mkdir -p "$(dirname "$MARKER_FILE")"
380
+
381
+ if ! "$WITH_LOCK_SCRIPT" "$MARKER_FILE" -- python3 -c '
382
+ import json, sys
383
+
384
+ marker_file = sys.argv[1]
385
+ new_tags = sys.argv[2:]
386
+
387
+ try:
388
+ with open(marker_file) as fh:
389
+ data = json.load(fh)
390
+ except (FileNotFoundError, json.JSONDecodeError):
391
+ data = {}
392
+
393
+ existing = data.get("reconciled_tags") or []
394
+ merged = list(existing)
395
+ for t in new_tags:
396
+ if t not in merged:
397
+ merged.append(t)
398
+ data["reconciled_tags"] = merged
399
+
400
+ with open(marker_file, "w") as fh:
401
+ json.dump(data, fh, indent=2)
402
+ fh.write("\n")
403
+ ' "$MARKER_FILE" "${tags[@]}"; then
404
+ warn "failed to advance $MARKER_FILE under lock -- reconciled tag(s) not recorded (will be reprocessed next run): ${tags[*]}"
405
+ return 1
406
+ fi
407
+
408
+ log "advanced $MARKER_FILE: reconciled_tags += ${tags[*]}"
409
+ return 0
410
+ }
411
+
412
+ # -----------------------------------------------------------------------------
413
+ # Ticket enumeration + per-tag processing
414
+ # -----------------------------------------------------------------------------
415
+
416
+ TAG_HAD_ERROR=0
417
+ STAGE_COUNT=0
418
+ PROD_COUNT=0
419
+ DATE_TODAY="$(date -u +%Y-%m-%d)"
420
+
421
+ # Processes every eligible ticket in $1 (TASKS_DIR or STORIES_DIR) against a
422
+ # single tag ($2=tag_type, $3=version, $4=tag_name, $5=sha). Sets
423
+ # TAG_HAD_ERROR=1 (never reset here -- caller resets per tag) if any write
424
+ # fails.
425
+ process_ticket_dir_for_tag() {
426
+ local dir="$1" tag_type="$2" version="$3" tag_name="$4" sha="$5"
427
+ [ -d "$dir" ] || return 0
428
+
429
+ local file id status shas touched
430
+ while IFS= read -r -d '' file; do
431
+ id="$(trim "$(read_frontmatter_field "$file" "id")")"
432
+ status="$(trim "$(read_frontmatter_field "$file" "status")")"
433
+
434
+ [ -n "$id" ] || { log "WARNING: no 'id' frontmatter field found in $file -- skipping"; continue; }
435
+
436
+ case "$status" in
437
+ Publicized|"Deployed to Stage") ;;
438
+ *) continue ;; # not eligible for Deployed to Stage/Prod consideration
439
+ esac
440
+
441
+ shas="$(commits_for_ticket "$id")"
442
+ if [ -z "$shas" ]; then
443
+ log "$id: zero matched EST-tagged commits -- left unchanged (tag $tag_name)"
444
+ continue
445
+ fi
446
+
447
+ touched="$(collect_touched_files "$shas")"
448
+ log "$id: ${shas//$'\n'/,} matched commit(s), touching: ${touched:-<none>} (tag $tag_name)"
449
+
450
+ if ! all_ancestors_of "$shas" "$sha"; then
451
+ log "$id: not fully covered by tag $tag_name (some matched commit is not an ancestor of $sha) -- left unchanged"
452
+ continue
453
+ fi
454
+
455
+ if [ "$tag_type" = "stage" ]; then
456
+ if [ "$status" = "Publicized" ]; then
457
+ log "$id: covered by stage tag $tag_name -- writing status: Deployed to Stage ($file)"
458
+ if write_status "$file" "Deployed to Stage"; then
459
+ STAGE_COUNT=$((STAGE_COUNT + 1))
460
+ else
461
+ log "ERROR: failed to write status: Deployed to Stage for $id ($file)"
462
+ TAG_HAD_ERROR=1
463
+ fi
464
+ else
465
+ log "$id: already at '$status' -- unchanged (stage tag $tag_name, idempotent no-op)"
466
+ fi
467
+ else
468
+ # tag_type == prod
469
+ if [ "$status" = "Publicized" ] || [ "$status" = "Deployed to Stage" ]; then
470
+ log "$id: covered by prod tag $tag_name -- writing status: Deployed to Prod + date_deployed_prod ($file)"
471
+ if write_deployed_to_prod "$file" "$DATE_TODAY"; then
472
+ PROD_COUNT=$((PROD_COUNT + 1))
473
+ else
474
+ log "ERROR: failed to write status: Deployed to Prod / date_deployed_prod for $id ($file)"
475
+ TAG_HAD_ERROR=1
476
+ fi
477
+ else
478
+ log "$id: already at '$status' -- unchanged (prod tag $tag_name, idempotent no-op)"
479
+ fi
480
+ fi
481
+ done < <(find "$dir" -maxdepth 1 -type f -name '*.md' -print0 | sort -z)
482
+ }
483
+
484
+ RECONCILED_OK=()
485
+ ANY_ERROR_OCCURRED=0
486
+
487
+ process_tag_line() {
488
+ local line="$1"
489
+ local tag_type version tag_name sha
490
+ IFS=$'\t' read -r tag_type version tag_name sha <<< "$line"
491
+
492
+ TAG_HAD_ERROR=0
493
+ process_ticket_dir_for_tag "$TASKS_DIR" "$tag_type" "$version" "$tag_name" "$sha"
494
+ process_ticket_dir_for_tag "$STORIES_DIR" "$tag_type" "$version" "$tag_name" "$sha"
495
+
496
+ if [ "$TAG_HAD_ERROR" -eq 0 ]; then
497
+ RECONCILED_OK+=("$tag_name")
498
+ else
499
+ ANY_ERROR_OCCURRED=1
500
+ log "tag $tag_name ($tag_type $version) had one or more write failures -- will NOT be marked reconciled this run"
501
+ fi
502
+ }
503
+
504
+ for line in "${STAGE_LINES[@]:-}"; do
505
+ [ -n "$line" ] || continue
506
+ process_tag_line "$line"
507
+ done
508
+
509
+ for line in "${PROD_LINES[@]:-}"; do
510
+ [ -n "$line" ] || continue
511
+ process_tag_line "$line"
512
+ done
513
+
514
+ log "processed ${#STAGE_LINES[@]} stage tag(s), ${#PROD_LINES[@]} prod tag(s); $STAGE_COUNT ticket(s) set to Deployed to Stage, $PROD_COUNT ticket(s) set to Deployed to Prod"
515
+
516
+ # -----------------------------------------------------------------------------
517
+ # Advance the marker -- only for tags that completed with zero write
518
+ # failures. A tag with a failure is simply omitted, so a future run
519
+ # re-discovers and retries it (compute-deploy-reconcile.sh reports any tag
520
+ # not yet in reconciled_tags).
521
+ # -----------------------------------------------------------------------------
522
+
523
+ if ! advance_marker "${RECONCILED_OK[@]:-}"; then
524
+ ANY_ERROR_OCCURRED=1
525
+ fi
526
+
527
+ if [ "$ANY_ERROR_OCCURRED" -ne 0 ]; then
528
+ log "completed with one or more failures -- see ERROR/WARNING lines above; affected tag(s) will be retried on a future run"
529
+ exit 1
530
+ fi
531
+
532
+ exit 0