@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
@@ -191,6 +191,9 @@ jobs:
191
191
  - name: Install dependencies
192
192
  run: npm ci
193
193
 
194
+ - name: Install project/app workspace dependencies (E29_S05_T01)
195
+ run: npm ci --prefix project/app
196
+
194
197
  - name: Regenerate lib/legacy-shipped-paths.json (E26_S08_T03)
195
198
  run: node scripts/generate-legacy-shipped-paths.js || echo "::warning::legacy-shipped-paths generation failed; publishing without an updated list"
196
199
 
@@ -224,6 +227,9 @@ jobs:
224
227
  - name: Install dependencies
225
228
  run: npm ci
226
229
 
230
+ - name: Install project/app workspace dependencies (E29_S05_T01)
231
+ run: npm ci --prefix project/app
232
+
227
233
  - name: Stage to npm
228
234
  run: npm stage publish --provenance --access ${NPM_ACCESS} --tag ${DIST_TAG}
229
235
 
@@ -11,10 +11,16 @@
11
11
  * inventory (`readSkillAllowList()`, which reads the committed `lib/skill-allow-list.json`
12
12
  * artifact) — this script does not independently re-scan `skills/` for a name list of its own,
13
13
  * per E53_S01_T02's acceptance criteria and the drift lesson E41_S04 already documented for that
14
- * generator. For each name in that inventory, this script reads exactly one file —
15
- * `skills/<name>/SKILL.md` — to populate the remaining catalog fields: `description`, `keywords`,
16
- * `examples`, and `metadata.prefered_agent`. These are the same fields `/route`'s Step 1
17
- * ("Discover Available Skills") collects.
14
+ * generator. That inventory holds bare identifiers (e.g. "brainstorm"), stripped of the `j.`
15
+ * frontmatter prefix — but per docs/skill-authoring.md's Canonical Naming Contract, the actual
16
+ * on-disk directory is `skills/j-<name>/`, except the three permanent exceptions (`jenga`,
17
+ * `jenga-permission-level`, `index`) which keep their bare directory name. For each name in the
18
+ * inventory, this script re-derives that canonical directory name, reads exactly one file —
19
+ * `skills/<dirName>/SKILL.md` — to populate the remaining catalog fields: `description`,
20
+ * `keywords`, `examples`, and `metadata.prefered_agent`, and emits `dirName` (not the bare
21
+ * identifier) as the catalog entry's `name` — callers like `/jenga`'s Skill invocation and
22
+ * `playbook-new.sh`'s `validate-skill` need the real, invokable directory name. These are the
23
+ * same fields `/route`'s Step 1 ("Discover Available Skills") collects.
18
24
  *
19
25
  * ---------------------------------------------------------------------------
20
26
  * USAGE
@@ -61,6 +67,15 @@ import { readFileSync, existsSync } from "fs";
61
67
  import { join } from "path";
62
68
  import { pathToFileURL } from "url";
63
69
 
70
+ // The three permanent exceptions to the `j-<name>` canonical directory convention — see
71
+ // docs/skill-authoring.md's Canonical Naming Contract and scripts/audit-twin-divergence.sh's
72
+ // NEVER_TWINNED list, which this mirrors.
73
+ const NEVER_TWINNED = new Set(["jenga", "jenga-permission-level", "index"]);
74
+
75
+ function canonicalSkillDir(name) {
76
+ return NEVER_TWINNED.has(name) ? name : `j-${name}`;
77
+ }
78
+
64
79
  /**
65
80
  * Parses YAML frontmatter from a SKILL.md's content into a plain object. This is a hand-rolled,
66
81
  * intentionally minimal parser scoped to the small set of shapes SKILL.md frontmatter actually
@@ -161,7 +176,8 @@ async function main() {
161
176
 
162
177
  const catalog = [];
163
178
  for (const name of names) {
164
- const skillMdPath = join(pkgRoot, "skills", name, "SKILL.md");
179
+ const dirName = canonicalSkillDir(name);
180
+ const skillMdPath = join(pkgRoot, "skills", dirName, "SKILL.md");
165
181
  if (!existsSync(skillMdPath)) {
166
182
  process.stderr.write(
167
183
  `Warning: ${skillMdPath} not found for allow-listed skill '${name}' — skipped\n`
@@ -186,7 +202,7 @@ async function main() {
186
202
  }
187
203
 
188
204
  catalog.push({
189
- name,
205
+ name: dirName,
190
206
  description: fm.description,
191
207
  keywords: Array.isArray(fm.keywords) ? fm.keywords : [],
192
208
  examples: Array.isArray(fm.examples) ? fm.examples : [],
@@ -256,10 +256,30 @@
256
256
  # ---------------------------------------------------------------------------
257
257
  # DATA SOURCE
258
258
  # ---------------------------------------------------------------------------
259
- # Every `*.json` file directly under `skills/jenga/playbooks/`, EXCLUDING `schema.json` (which
260
- # documents the required shape — see that file's own header — but is never itself a playbook
261
- # entry). See `schema.json` for the authoritative field list; this script's validation below is
262
- # a runtime mirror of that schema, not a substitute for it.
259
+ # TWO directories are scanned and MERGED into one catalog (E53_S09_T01):
260
+ #
261
+ # 1. `skills/jenga/playbooks/` (BUILTIN, framework-owned) — every `*.json` file directly under
262
+ # it, EXCLUDING `schema.json` (which documents the required shape — see that file's own
263
+ # header — but is never itself a playbook entry). See `schema.json` for the authoritative
264
+ # field list; this script's validation below is a runtime mirror of that schema, not a
265
+ # substitute for it.
266
+ #
267
+ # 2. `project/.playbooks/` (PROJECT, project-owned, resolved relative to this script's own
268
+ # `PROJECT_DIR` — see its resolution below, including the `JENGA_PLAYBOOKS_TEST_ROOT`
269
+ # override) — every `*.json` file directly under it, same `schema.json`-exclusion rule.
270
+ # A MISSING `project/.playbooks/` directory is a SILENT NO-OP: no warning, no error — a
271
+ # project with no custom playbooks is the common case, not an exceptional one.
272
+ #
273
+ # The builtin directory is scanned and locally validated FIRST, in full, before the project
274
+ # directory is touched at all. Every project-directory file is then checked for an `id` collision
275
+ # against the already-loaded builtin set: if a project playbook's basename/id matches a
276
+ # SUCCESSFULLY LOADED builtin playbook's id, the project playbook is SKIPPED — a stderr warning
277
+ # names BOTH the project file's path and the builtin file's path — and the builtin entry is the
278
+ # one that survives into the catalog. This is never a silent override in either direction. A
279
+ # project playbook whose id does not collide goes through the exact same local validation,
280
+ # composition resolution, and `forward_from`/`conditional` validation as a builtin one — no
281
+ # separate or weaker path. See "VALIDATION / SKIP CONDITIONS" below for the exact collision rule,
282
+ # and "OUTPUT SCHEMA" for the `source` field every catalog entry gains as a result of this merge.
263
283
  #
264
284
  # ---------------------------------------------------------------------------
265
285
  # USAGE
@@ -273,8 +293,10 @@
273
293
  #
274
294
  # Additive sibling mode (E53_S06_T02), for direct-by-id lookup (`j.playbook <id>`,
275
295
  # `skills/j-playbook/SKILL.md`, E53_S06_T03) — runs the SAME PASS 1-3 pipeline as the no-argument
276
- # mode above (never a separate implementation), then emits exactly ONE JSON object to stdout
277
- # (never the full catalog, never warnings about OTHER playbooks) and exits 0:
296
+ # mode above (never a separate implementation), against the SAME MERGED builtin+project catalog
297
+ # (E53_S09_T01) — an id may resolve from either source, and the returned `playbook` object carries
298
+ # the same `source` field a full-catalog entry would — then emits exactly ONE JSON object to
299
+ # stdout (never the full catalog, never warnings about OTHER playbooks) and exits 0:
278
300
  #
279
301
  # {"status": "valid", "playbook": {...}} -- <id> resolved to a real file and passed every
280
302
  # validation pass; `playbook` has the same field
@@ -306,6 +328,11 @@
306
328
  # asserting on candidate sets never targets the repository root. Never set this variable in a real
307
329
  # invocation.
308
330
  #
331
+ # Because the SAME override also becomes the PROJECT root (see "DATA SOURCE" above), a fixture
332
+ # wanting project-local playbook coverage places its files at
333
+ # `<value>/project/.playbooks/<id>.json` — no second, project-specific test variable is
334
+ # introduced (E53_S09_T01, `tests/load-playbooks-project-source.bats`).
335
+ #
309
336
  # ---------------------------------------------------------------------------
310
337
  # OUTPUT SCHEMA
311
338
  # ---------------------------------------------------------------------------
@@ -318,11 +345,16 @@
318
345
  # "description": "...",
319
346
  # "keywords": ["..."],
320
347
  # "examples": ["..."],
321
- # "steps": ["j-brainstorm", "j-todo", "j-do", "j-dev-done", "j-mirror-public"]
348
+ # "steps": ["j-brainstorm", "j-todo", "j-do", "j-dev-done", "j-mirror-public"],
349
+ # "source": "builtin"
322
350
  # },
323
351
  # ...
324
352
  # ]
325
353
  #
354
+ # `source` (E53_S09_T01) is `"builtin"` for anything loaded from `skills/jenga/playbooks/`, or
355
+ # `"project"` for anything loaded from `project/.playbooks/` — present on EVERY catalog entry,
356
+ # regardless of source, additive to the pre-existing field shape.
357
+ #
326
358
  # `steps` entries are emitted exactly as validated/resolved: a depth-1 bare string stays a bare
327
359
  # string; a depth-1 StepObject is emitted as an object carrying only its recognized fields (`skill`
328
360
  # XOR `playbook` — though by the time this is emitted, a `playbook`-type step has already been
@@ -347,6 +379,11 @@
347
379
  # - `id` does not equal the filename's basename without `.json` -> skipped
348
380
  # (prevents a playbook's identity from silently drifting from its
349
381
  # file location)
382
+ # - A PROJECT playbook's id/basename matches a SUCCESSFULLY LOADED
383
+ # BUILTIN playbook's id (E53_S09_T01) -> project playbook
384
+ # skipped; stderr warning names BOTH the project file's path and the
385
+ # builtin file's path; the builtin entry is the one that survives into
386
+ # the catalog (never a silent override in either direction)
350
387
  # - A `steps` entry is neither a string nor an object, or is an empty
351
388
  # string -> skipped
352
389
  # - A StepObject step carries BOTH `skill` and `playbook`, or NEITHER -> skipped
@@ -467,6 +504,12 @@ project_dir = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
467
504
  mode = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "catalog"
468
505
  lookup_id = sys.argv[5] if len(sys.argv) > 5 else ""
469
506
 
507
+ # --- E53_S09_T01: project-local playbook source directory ------------------------------------
508
+ # `project/.playbooks/`, resolved relative to the SAME project_dir already threaded above (which
509
+ # already honors JENGA_PLAYBOOKS_TEST_ROOT -- see header "TESTING OVERRIDE"). A missing directory
510
+ # is a silent no-op, not an error -- see header "DATA SOURCE".
511
+ project_playbooks_dir = os.path.join(project_dir, "project", ".playbooks") if project_dir else None
512
+
470
513
  REQUIRED_FIELDS = ["id", "name", "description", "keywords", "examples", "steps"]
471
514
  LIST_FIELDS = ["keywords", "examples", "steps"]
472
515
 
@@ -673,7 +716,7 @@ def extract_output_types(skill_md_path):
673
716
 
674
717
 
675
718
  try:
676
- filenames = sorted(
719
+ builtin_filenames = sorted(
677
720
  f for f in os.listdir(playbooks_dir)
678
721
  if f.endswith(".json") and f != "schema.json"
679
722
  )
@@ -681,17 +724,41 @@ except OSError as e:
681
724
  print(f"Error: could not list {playbooks_dir}: {e}", file=sys.stderr)
682
725
  sys.exit(2)
683
726
 
727
+ # --- E53_S09_T01: project-local playbook directory listing --------------------------------------
728
+ # Non-fatal, unlike the builtin listing above: a missing directory (the common case -- most
729
+ # projects have no custom playbooks) or an OSError while listing it both yield an empty list,
730
+ # never a setup-error exit. See header "DATA SOURCE".
731
+ project_filenames = []
732
+ if project_playbooks_dir and os.path.isdir(project_playbooks_dir):
733
+ try:
734
+ project_filenames = sorted(
735
+ f for f in os.listdir(project_playbooks_dir)
736
+ if f.endswith(".json") and f != "schema.json"
737
+ )
738
+ except OSError as e:
739
+ print(f"Warning: could not list {project_playbooks_dir}: {e}", file=sys.stderr)
740
+ project_filenames = []
741
+
684
742
  # --- PASS 1: local (non-composition) validation -------------------------------------------------
685
743
  # Builds `raw[pid]` for every playbook that passes purely local validation (parse/shape/id-match/
686
744
  # step-shape/skill-existence) -- independent of any OTHER playbook. Composition resolution (PASS
687
745
  # 2, below) needs this map fully populated before it can recurse into a referenced playbook.
746
+ #
747
+ # As of E53_S09_T01, `raw`/`order` are populated from BOTH the builtin and project directories
748
+ # (builtin first, in full, then project -- see the id-collision check below). Every playbook run
749
+ # through `process_playbook_file()` carries a `source` tag ("builtin" or "project") in its `raw`
750
+ # entry, which flows through unchanged into the final catalog entry (PASS 3, below).
688
751
  raw = {}
689
- order = [] # preserves the original sorted-filename order for stable catalog/warning output
752
+ order = [] # preserves the original builtin-then-project, sorted-within-source filename order
690
753
 
691
- for filename in filenames:
692
- path = os.path.join(playbooks_dir, filename)
693
- basename = filename[: -len(".json")]
694
754
 
755
+ def process_playbook_file(path, basename, source):
756
+ """Runs PASS 1's local validation for one playbook file (identical logic regardless of which
757
+ source directory it came from -- see header 'DATA SOURCE': project playbooks go through the
758
+ exact same pipeline as builtin ones, never a separate or weaker path). On success, populates
759
+ `raw[basename]` (tagged with `source`) and appends to `order`. On failure, prints the same
760
+ stderr warning this script has always printed for that condition and records the reason in
761
+ `skip_reasons[basename]`."""
695
762
  try:
696
763
  with open(path, encoding="utf-8") as fh:
697
764
  data = json.load(fh)
@@ -699,20 +766,20 @@ for filename in filenames:
699
766
  reason = f"is not valid JSON ({e})"
700
767
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
701
768
  skip_reasons[basename] = reason
702
- continue
769
+ return
703
770
 
704
771
  if not isinstance(data, dict):
705
772
  reason = "is not a JSON object"
706
773
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
707
774
  skip_reasons[basename] = reason
708
- continue
775
+ return
709
776
 
710
777
  missing = [f for f in REQUIRED_FIELDS if f not in data]
711
778
  if missing:
712
779
  reason = f"missing required field(s) {missing}"
713
780
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
714
781
  skip_reasons[basename] = reason
715
- continue
782
+ return
716
783
 
717
784
  bad_list = [
718
785
  f for f in LIST_FIELDS
@@ -722,7 +789,7 @@ for filename in filenames:
722
789
  reason = f"field(s) {bad_list} must be non-empty lists"
723
790
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
724
791
  skip_reasons[basename] = reason
725
- continue
792
+ return
726
793
 
727
794
  if data["id"] != basename:
728
795
  reason = (
@@ -730,7 +797,7 @@ for filename in filenames:
730
797
  )
731
798
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
732
799
  skip_reasons[basename] = reason
733
- continue
800
+ return
734
801
 
735
802
  # --- E53_S03_T01: StepObject shape acceptance + bare-string back-compat ---
736
803
  normalized_steps = []
@@ -745,7 +812,7 @@ for filename in filenames:
745
812
  if step_error:
746
813
  print(f"Warning: {path} {step_error} — skipped", file=sys.stderr)
747
814
  skip_reasons[basename] = step_error
748
- continue
815
+ return
749
816
 
750
817
  # Existence check for skill-type steps only (bare string, or StepObject with `skill`).
751
818
  # `playbook`-type steps have no single skill name (step_skill_name returns None for them) and
@@ -766,10 +833,11 @@ for filename in filenames:
766
833
  )
767
834
  print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
768
835
  skip_reasons[basename] = reason
769
- continue
836
+ return
770
837
 
771
838
  raw[basename] = {
772
839
  "path": path,
840
+ "source": source,
773
841
  "name": data["name"],
774
842
  "description": data["description"],
775
843
  "keywords": data["keywords"],
@@ -778,6 +846,36 @@ for filename in filenames:
778
846
  }
779
847
  order.append(basename)
780
848
 
849
+
850
+ for filename in builtin_filenames:
851
+ path = os.path.join(playbooks_dir, filename)
852
+ basename = filename[: -len(".json")]
853
+ process_playbook_file(path, basename, "builtin")
854
+
855
+ # --- E53_S09_T01: project playbooks, id-collision check against the already-loaded builtin set --
856
+ # Runs AFTER the builtin loop above has fully populated `raw`, so a collision check here is
857
+ # checking against every SUCCESSFULLY LOADED builtin playbook -- never a builtin file that itself
858
+ # failed validation (that basename never made it into `raw`, so a project playbook may legitimately
859
+ # claim that id instead). See header 'VALIDATION / SKIP CONDITIONS'.
860
+ for filename in project_filenames:
861
+ path = os.path.join(project_playbooks_dir, filename)
862
+ basename = filename[: -len(".json")]
863
+
864
+ if basename in raw:
865
+ builtin_path = raw[basename]["path"]
866
+ reason = (
867
+ f"id '{basename}' collides with a built-in playbook already loaded from "
868
+ f"{builtin_path} — the built-in entry is retained, never silently overridden"
869
+ )
870
+ print(
871
+ f"Warning: {path} {reason} (project file skipped)",
872
+ file=sys.stderr,
873
+ )
874
+ skip_reasons[basename] = reason
875
+ continue
876
+
877
+ process_playbook_file(path, basename, "project")
878
+
781
879
  # --- PASS 2: composition resolution (E53_S05_T01) ------------------------------------------------
782
880
  # Recursively resolves `{"playbook": "<id>"}` steps into a flat, fully-spliced step list. See
783
881
  # header 'COMPOSITION RESOLUTION' for the full algorithm description; this is that algorithm.
@@ -987,6 +1085,7 @@ for pid in order:
987
1085
  "keywords": entry["keywords"],
988
1086
  "examples": entry["examples"],
989
1087
  "steps": flattened_steps,
1088
+ "source": entry["source"],
990
1089
  })
991
1090
 
992
1091
  # --- E53_S06_T02: `lookup <id>` mode output ------------------------------------------------------
@@ -1001,11 +1100,11 @@ if mode == "lookup":
1001
1100
  if result is None:
1002
1101
  if lookup_id in skip_reasons:
1003
1102
  result = {"status": "invalid", "reason": skip_reasons[lookup_id]}
1004
- elif f"{lookup_id}.json" in filenames:
1005
- # Defensive fallback -- should not normally happen, since every basename scanned
1006
- # into `filenames` either lands in `catalog` (valid) or `skip_reasons` (invalid) by
1007
- # this point. Guards against ever silently reporting `not_found` for a file that
1008
- # does exist on disk.
1103
+ elif f"{lookup_id}.json" in builtin_filenames or f"{lookup_id}.json" in project_filenames:
1104
+ # Defensive fallback -- should not normally happen, since every basename scanned from
1105
+ # EITHER source directory (E53_S09_T01) either lands in `catalog` (valid) or
1106
+ # `skip_reasons` (invalid) by this point. Guards against ever silently reporting
1107
+ # `not_found` for a file that does exist on disk.
1009
1108
  result = {
1010
1109
  "status": "invalid",
1011
1110
  "reason": "failed validation (no specific reason captured)",
@@ -105,7 +105,9 @@ date_started:
105
105
  date_completed:
106
106
  dates_previously_completed: # comma-separated list, e.g. 2026-01-15, 2026-03-22
107
107
  reopened_on: # comma-separated list, e.g. 2026-02-01, 2026-04-10
108
- reopened_reason: # comma-separated list, e.g. "Scope expanded", "Bug found post-release"
108
+ reopened_reason: # YAML list, one entry per reopen cycle; see Reopen Tracking Fields
109
+ - "Scope expanded"
110
+ - "Bug found post-release"
109
111
  docs: [] # optional list of repo-relative documentation paths, e.g. ["README.md", "docs/API.md"]
110
112
  epic_scope_approval: false # set to true by the human operator only when any task in this epic has execution_scope: epic
111
113
  provenance: # optional; only valid value is `backfilled` (epic reverse-engineered from pre-existing code by `/uncharted onboard`). Omit for normally-authored epics.
@@ -139,7 +141,9 @@ date_started:
139
141
  date_completed:
140
142
  dates_previously_completed: # comma-separated list, e.g. 2026-01-15, 2026-03-22
141
143
  reopened_on: # comma-separated list, e.g. 2026-02-01, 2026-04-10
142
- reopened_reason: # comma-separated list, e.g. "Scope expanded", "Bug found post-release"
144
+ reopened_reason: # YAML list, one entry per reopen cycle; see Reopen Tracking Fields
145
+ - "Scope expanded"
146
+ - "Bug found post-release"
143
147
  docs: [] # optional list of repo-relative documentation paths, e.g. ["README.md", "docs/API.md"]
144
148
  crucial_level: # optional; advisory | gated | locked; absence means no elevated caution
145
149
  crucial_set_by: # required when crucial_level is set; user | scrum-master | <agent>-escalation
@@ -177,7 +181,9 @@ date_started:
177
181
  date_completed:
178
182
  dates_previously_completed: # comma-separated list, e.g. 2026-01-15, 2026-03-22
179
183
  reopened_on: # comma-separated list, e.g. 2026-02-01, 2026-04-10
180
- reopened_reason: # comma-separated list, e.g. "Scope expanded", "Bug found post-release"
184
+ reopened_reason: # YAML list, one entry per reopen cycle; see Reopen Tracking Fields
185
+ - "Scope expanded"
186
+ - "Bug found post-release"
181
187
  assigned_to: developer | tester | scrum-master
182
188
  docs: [] # optional list of repo-relative documentation paths, e.g. ["README.md", "docs/API.md"]
183
189
  execution_scope: task # task | story | epic | inline | light; omit for legacy tasks (defaults to task)
@@ -209,7 +215,7 @@ crucial_declined_note: # required when crucial_declined: true; free-te
209
215
 
210
216
  ### Runtime-written task fields
211
217
 
212
- These four fields are **not authored by hand**. They are appended to a task's frontmatter after execution and are absent from any task that has not yet run. They are listed here so that tooling — in particular `scripts/validate-board.sh` — recognises them as valid rather than unknown.
218
+ These five fields are **not authored by hand**. They are appended to a task's frontmatter after execution and are absent from any task that has not yet run. They are listed here so that tooling — in particular `scripts/validate-board.sh` — recognises them as valid rather than unknown.
213
219
 
214
220
  | Field | Written by | Meaning |
215
221
  |---|---|---|
@@ -217,8 +223,9 @@ These four fields are **not authored by hand**. They are appended to a task's fr
217
223
  | `actual_lines_delta` | `/close-story` | Net line delta for the task, from the same extraction |
218
224
  | `scope_divergence_flag` | `/close-story` | Set when actual diff stats exceed the thresholds that justified the assigned `execution_scope` |
219
225
  | `divergence_flag` | `/do` | Set to `true` by the intent-vs-diff check when a `needs_docs: false` task touched unregistered files |
226
+ | `date_deployed_prod` | `scripts/mark-deployed.sh` | ISO 8601 date (e.g. `2026-09-12`) written in the same locked write window a ticket is set to `status: Deployed to Prod`. Never written on a `Deployed to Stage`-only write. |
220
227
 
221
- All four are advisory and non-blocking — they record evidence for later review and never change a task's Passed/Failed outcome.
228
+ All five are advisory and non-blocking — they record evidence for later review and never change a task's Passed/Failed outcome. `date_deployed_prod` is allow-listed in `scripts/validate-board.sh`'s `ALLOWED_KEYS` for `epic`, `story`, and `task` (a new `DEPLOY_KEYS` group), even though in practice only tasks (and occasionally stories) are expected to ever carry it — `mark-deployed.sh` only ever writes it to a task/story board file, never to an epic.
222
229
 
223
230
  > `task_changed_files` is **not** a frontmatter field despite the similar name. It lives in the bundle manifest at `project/queue/bundle-<E##_S##>.json`, keyed by task ID.
224
231
 
@@ -228,7 +235,12 @@ All four are advisory and non-blocking — they record evidence for later review
228
235
 
229
236
  ## Reopen Tracking Fields
230
237
 
231
- **`dates_previously_completed`, `reopened_on`, `reopened_reason`** — These fields are **only populated when a previously completed item is being reopened and modified**. Leave them blank on first-run items. Each value is a comma-separated list to support multiple reopen cycles.
238
+ **`dates_previously_completed`, `reopened_on`, `reopened_reason`** — These fields are **only populated when a previously completed item is being reopened and modified**. Leave them blank on first-run items. All three support multiple reopen cycles, but they are **not written the same way**:
239
+
240
+ - `dates_previously_completed` and `reopened_on` hold a plain comma-separated string, e.g. `reopened_on: 2026-02-01, 2026-04-10`. Bare dates need no quoting, so this parses fine.
241
+ - `reopened_reason` **must be a YAML list** (a block sequence, one `- "reason"` per line). It cannot be comma-separated, because reopen reasons are free text that routinely contains colons and commas and therefore has to be quoted — and a run of comma-separated quoted strings (`reopened_reason: "A", "B"`) is not valid YAML. This schema previously documented exactly that invalid form, and every board file that followed it became unparseable: the board parser skipped those files silently, so the board under-reported itself with no error surfaced anywhere. 14 files were repaired on 2026-09-13; do not reintroduce the comma-separated form here.
242
+
243
+ Note the resulting asymmetry is deliberate and load-bearing, not an oversight: the first two fields stay strings because migrating them would churn every existing file for no parsing benefit.
232
244
 
233
245
  ## Planning Fields
234
246