@jenga-ai/agent 3.2.0 → 3.5.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 (68) 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-init/SKILL.md +52 -13
  55. package/skills/j-init/assets/.gitignore_template +1 -2
  56. package/skills/j-init/scripts/apply-scaffold-visibility.sh +192 -0
  57. package/skills/j-init/scripts/init.sh +19 -5
  58. package/skills/j-playbook/SKILL.md +12 -0
  59. package/skills/j-playbook-new/SKILL.md +155 -0
  60. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  61. package/skills/j-publish/scripts/npm_ci_pipeline.sh +6 -0
  62. package/skills/j-skillify/assets/init-new/assets/.gitignore_template +1 -2
  63. package/skills/j-uncharted/SKILL.md +54 -7
  64. package/skills/j-uncharted/assets/NODE_QUESTION_TEMPLATE.md +69 -0
  65. package/skills/j-uncharted/scripts/elicitation-state.sh +45 -7
  66. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  67. package/skills/jenga/scripts/load-playbooks.sh +146 -35
  68. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
@@ -0,0 +1,69 @@
1
+ <!--
2
+ NODE QUESTION TEMPLATE — /uncharted Convergence Loop.
3
+
4
+ Added by E40_S06_T03. Consumed by the Convergence Loop's Step 5
5
+ ("Confirm/correct", skills/j-uncharted/SKILL.md) whenever risk-weighted
6
+ gating (Step 4) decides a finding needs a confirm prompt under
7
+ `verification_depth: shallow` or `moderate`. Never reached under `strict`
8
+ — Step 4's strict branch converges the node directly, without ever
9
+ reaching Step 5, so this template is simply not invoked in that case.
10
+
11
+ Two fixed variants, selected by node kind:
12
+
13
+ INTERNAL — the node represents the candidate/service itself (the thing
14
+ `/uncharted` is investigating).
15
+ EXTERNAL — the node represents something outside the candidate that the
16
+ traces surfaced: a dependency it calls, or a consumer that calls it.
17
+
18
+ This replaces a fully open-ended "propose understanding, ask the user to
19
+ confirm or correct it" prompt for any node that fits one of these two
20
+ kinds — which, per the Convergence Loop, is every node this flow drafts.
21
+ Wording is fixed and matches the brainstorm's agreed phrasing verbatim
22
+ (project/documentation/examples/uncharted-conversational-elicitation-procedure.md);
23
+ do not paraphrase it when presenting the prompt.
24
+
25
+ Usage: present the drafted node/edge summary first (per Step 3's draft),
26
+ then ask the questions below for the selected variant, then offer the
27
+ same confirm / correct-with-detail / defer-as-unconfirmed / other choice
28
+ Step 5 already documents. This file supplies the fixed *questions*; the
29
+ response mechanics (turn cap, converge call) are unchanged and live in
30
+ SKILL.md, not here.
31
+ -->
32
+
33
+ # Node Question Template
34
+
35
+ ## Internal Node
36
+
37
+ Use when the node represents the candidate/service itself — the thing this investigation is about,
38
+ not something external to it.
39
+
40
+ 1. What is this?
41
+ 2. What does it do?
42
+ 3. Who consumes it?
43
+ 4. Other (describe below)
44
+
45
+ ## External Node
46
+
47
+ Use when the node represents a dependency or consumer the traces surfaced outside the candidate —
48
+ something the service calls, or something that calls the service.
49
+
50
+ 1. What is this?
51
+ 2. What does it do?
52
+ 3. Is the service producing to it, or consuming from it?
53
+ 4. Who is the producer, and who is the consumer?
54
+ 5. Other (describe below)
55
+
56
+ ---
57
+
58
+ After the selected question set, present the standard confirm/correct choice (per the Interaction
59
+ Pattern in `CLAUDE.md` and Convergence Loop Step 5):
60
+
61
+ ```
62
+ 1. Confirm the draft as accurate
63
+ 2. Correct it — describe what's wrong
64
+ 3. Defer — mark this node "unconfirmed" for now
65
+ 4. Other (describe below)
66
+ ```
67
+
68
+ Silence, a counter-question, or an ambiguous reply is not consent — re-ask, the same convention used
69
+ at every other confirmation gate in this skill.
@@ -53,7 +53,23 @@
53
53
  # "nodes": {
54
54
  # "<node-id>": { "turns": <int>, "status": "pending"|"converged"|"flagged", "note": "<text>" }
55
55
  # },
56
- # "checkpoint": { ...arbitrary, agent-defined fields, e.g. directory-triage results... }
56
+ # "checkpoint": {
57
+ # ...arbitrary, agent-defined fields, e.g. directory-triage results...
58
+ # "verification_depth": {
59
+ # // Recognized field (E40_S06_T01). Per-candidate Familiarity Check
60
+ # // answer from skills/j-uncharted/SKILL.md's Convergence Loop Step 1,
61
+ # // keyed by candidate/node id so a resumed elicitation can tell which
62
+ # // candidates already answered. Values are "shallow" | "moderate" |
63
+ # // "strict". Written and read by the agent driving /uncharted, same
64
+ # // as every other checkpoint field — this script assigns it no
65
+ # // special handling beyond the dict-merge behavior documented under
66
+ # // `checkpoint` below (which exists so that checking in one
67
+ # // candidate's depth never clobbers another's already recorded
68
+ # // here). Consumed by E40_S06_T02's risk-weighted gating; unread by
69
+ # // anything in this script or E40_S06_T01's own scope.
70
+ # "<candidate-id>": "shallow" | "moderate" | "strict"
71
+ # }
72
+ # }
57
73
  # }
58
74
  #
59
75
  # ---------------------------------------------------------------------------
@@ -79,11 +95,22 @@
79
95
  # the node's note (e.g. a one-line summary of what was confirmed).
80
96
  #
81
97
  # checkpoint --id ID --json FILE
82
- # Shallow-merge the JSON object in FILE (or stdin when FILE is "-")
83
- # into the state's top-level "checkpoint" field. New keys are added;
84
- # existing keys are overwritten by the new value. This is the generic
85
- # "save progress" primitive — directory-triage results, draft node
86
- # content, anything else the flow wants durable before it might pause.
98
+ # Merge the JSON object in FILE (or stdin when FILE is "-") into the
99
+ # state's top-level "checkpoint" field. New keys are added; existing
100
+ # keys are overwritten by the new value — EXCEPT when both the existing
101
+ # value and the new value for a given key are themselves JSON objects,
102
+ # in which case they are merged one level deep instead of one replacing
103
+ # the other (existing sub-keys are kept, new sub-keys are added,
104
+ # conflicting sub-keys take the new value). This one-level dict merge
105
+ # is what lets a map-shaped field addressed by its own sub-keys — e.g.
106
+ # "verification_depth", keyed per candidate id (E40_S06_T01) — accumulate
107
+ # entries across separate checkpoint calls instead of each call
108
+ # clobbering every entry a previous call wrote. Plain (non-dict)
109
+ # values — strings, numbers, lists, directory-triage's own arrays —
110
+ # still simply overwrite, exactly as before this addition. This is the
111
+ # generic "save progress" primitive — directory-triage results, draft
112
+ # node content, anything else the flow wants durable before it might
113
+ # pause.
87
114
  #
88
115
  # pause --id ID
89
116
  # Set status "paused" and update "updated_at". The caller (the agent
@@ -422,7 +449,18 @@ elif subcommand == "checkpoint":
422
449
  state = load()
423
450
  payload = json.loads(checkpoint_json)
424
451
  cp = state.setdefault("checkpoint", {})
425
- cp.update(payload)
452
+ # One-level-deep merge when both sides are dicts (E40_S06_T01) — lets a
453
+ # map-shaped field keyed by its own sub-keys (e.g. verification_depth,
454
+ # keyed per candidate id) accumulate entries across separate checkpoint
455
+ # calls instead of each call replacing the whole field. Anything else
456
+ # (strings, numbers, lists, or a dict landing on a non-dict/absent key)
457
+ # keeps the prior plain overwrite behavior.
458
+ for key, value in payload.items():
459
+ existing = cp.get(key)
460
+ if isinstance(existing, dict) and isinstance(value, dict):
461
+ existing.update(value)
462
+ else:
463
+ cp[key] = value
426
464
  state["updated_at"] = now
427
465
  atomic_write(state)
428
466
  result = {"checkpoint_keys": list(payload.keys())}
@@ -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
@@ -424,18 +461,30 @@ if [ -n "${JENGA_PLAYBOOKS_TEST_ROOT:-}" ]; then
424
461
  # E53_S05_T01 to also cover playbook-config.json resolution). Never set in a real invocation.
425
462
  PKG_ROOT="$JENGA_PLAYBOOKS_TEST_ROOT"
426
463
  PROJECT_DIR="$JENGA_PLAYBOOKS_TEST_ROOT"
427
- # Resolve the jenga-agent PACKAGE root (where the canonical skills/ tree actually lives) — same
428
- # monorepo-checkout vs. installed-npm-package detection used by
429
- # skills/jenga/scripts/load-nl-catalog.sh's PKG_ROOT resolution and skills/init/scripts/init.sh.
430
- elif [ -d "$SCRIPT_DIR/../../../templates" ]; then
431
- PKG_ROOT="$SCRIPT_DIR/../../.."
432
- PROJECT_DIR="${JENGA_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)}}"
433
- elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent/templates" ]; then
434
- PKG_ROOT="${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent"
435
- PROJECT_DIR="${JENGA_PROJECT_DIR:-$CLAUDE_PROJECT_DIR}"
436
464
  else
437
- echo "Error: could not locate the jenga-agent package root (templates/ not found via monorepo checkout or node_modules/@jenga-ai/agent)." >&2
438
- exit 2
465
+ # Resolve JENGA_PROJECT_DIR the same way every other script in skills/jenga/scripts/ does.
466
+ if [ -f "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh" ]; then
467
+ # shellcheck source=/dev/null
468
+ source "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh"
469
+ elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
470
+ JENGA_PROJECT_DIR="$CLAUDE_PROJECT_DIR"
471
+ else
472
+ JENGA_PROJECT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
473
+ fi
474
+
475
+ # Resolve the jenga-agent PACKAGE root (where the canonical skills/ tree actually lives) — same
476
+ # monorepo-checkout vs. installed-npm-package detection used by
477
+ # skills/jenga/scripts/load-nl-catalog.sh's PKG_ROOT resolution and skills/init/scripts/init.sh.
478
+ if [ -d "$SCRIPT_DIR/../../../templates" ]; then
479
+ PKG_ROOT="$SCRIPT_DIR/../../.."
480
+ PROJECT_DIR="$JENGA_PROJECT_DIR"
481
+ elif [ -d "$JENGA_PROJECT_DIR/node_modules/@jenga-ai/agent/templates" ]; then
482
+ PKG_ROOT="$JENGA_PROJECT_DIR/node_modules/@jenga-ai/agent"
483
+ PROJECT_DIR="$JENGA_PROJECT_DIR"
484
+ else
485
+ echo "Error: could not locate the jenga-agent package root (templates/ not found via monorepo checkout or node_modules/@jenga-ai/agent)." >&2
486
+ exit 2
487
+ fi
439
488
  fi
440
489
 
441
490
  PLAYBOOKS_DIR="$PKG_ROOT/skills/jenga/playbooks"
@@ -467,6 +516,12 @@ project_dir = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
467
516
  mode = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "catalog"
468
517
  lookup_id = sys.argv[5] if len(sys.argv) > 5 else ""
469
518
 
519
+ # --- E53_S09_T01: project-local playbook source directory ------------------------------------
520
+ # `project/.playbooks/`, resolved relative to the SAME project_dir already threaded above (which
521
+ # already honors JENGA_PLAYBOOKS_TEST_ROOT -- see header "TESTING OVERRIDE"). A missing directory
522
+ # is a silent no-op, not an error -- see header "DATA SOURCE".
523
+ project_playbooks_dir = os.path.join(project_dir, "project", ".playbooks") if project_dir else None
524
+
470
525
  REQUIRED_FIELDS = ["id", "name", "description", "keywords", "examples", "steps"]
471
526
  LIST_FIELDS = ["keywords", "examples", "steps"]
472
527
 
@@ -673,7 +728,7 @@ def extract_output_types(skill_md_path):
673
728
 
674
729
 
675
730
  try:
676
- filenames = sorted(
731
+ builtin_filenames = sorted(
677
732
  f for f in os.listdir(playbooks_dir)
678
733
  if f.endswith(".json") and f != "schema.json"
679
734
  )
@@ -681,17 +736,41 @@ except OSError as e:
681
736
  print(f"Error: could not list {playbooks_dir}: {e}", file=sys.stderr)
682
737
  sys.exit(2)
683
738
 
739
+ # --- E53_S09_T01: project-local playbook directory listing --------------------------------------
740
+ # Non-fatal, unlike the builtin listing above: a missing directory (the common case -- most
741
+ # projects have no custom playbooks) or an OSError while listing it both yield an empty list,
742
+ # never a setup-error exit. See header "DATA SOURCE".
743
+ project_filenames = []
744
+ if project_playbooks_dir and os.path.isdir(project_playbooks_dir):
745
+ try:
746
+ project_filenames = sorted(
747
+ f for f in os.listdir(project_playbooks_dir)
748
+ if f.endswith(".json") and f != "schema.json"
749
+ )
750
+ except OSError as e:
751
+ print(f"Warning: could not list {project_playbooks_dir}: {e}", file=sys.stderr)
752
+ project_filenames = []
753
+
684
754
  # --- PASS 1: local (non-composition) validation -------------------------------------------------
685
755
  # Builds `raw[pid]` for every playbook that passes purely local validation (parse/shape/id-match/
686
756
  # step-shape/skill-existence) -- independent of any OTHER playbook. Composition resolution (PASS
687
757
  # 2, below) needs this map fully populated before it can recurse into a referenced playbook.
758
+ #
759
+ # As of E53_S09_T01, `raw`/`order` are populated from BOTH the builtin and project directories
760
+ # (builtin first, in full, then project -- see the id-collision check below). Every playbook run
761
+ # through `process_playbook_file()` carries a `source` tag ("builtin" or "project") in its `raw`
762
+ # entry, which flows through unchanged into the final catalog entry (PASS 3, below).
688
763
  raw = {}
689
- order = [] # preserves the original sorted-filename order for stable catalog/warning output
764
+ order = [] # preserves the original builtin-then-project, sorted-within-source filename order
690
765
 
691
- for filename in filenames:
692
- path = os.path.join(playbooks_dir, filename)
693
- basename = filename[: -len(".json")]
694
766
 
767
+ def process_playbook_file(path, basename, source):
768
+ """Runs PASS 1's local validation for one playbook file (identical logic regardless of which
769
+ source directory it came from -- see header 'DATA SOURCE': project playbooks go through the
770
+ exact same pipeline as builtin ones, never a separate or weaker path). On success, populates
771
+ `raw[basename]` (tagged with `source`) and appends to `order`. On failure, prints the same
772
+ stderr warning this script has always printed for that condition and records the reason in
773
+ `skip_reasons[basename]`."""
695
774
  try:
696
775
  with open(path, encoding="utf-8") as fh:
697
776
  data = json.load(fh)
@@ -699,20 +778,20 @@ for filename in filenames:
699
778
  reason = f"is not valid JSON ({e})"
700
779
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
701
780
  skip_reasons[basename] = reason
702
- continue
781
+ return
703
782
 
704
783
  if not isinstance(data, dict):
705
784
  reason = "is not a JSON object"
706
785
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
707
786
  skip_reasons[basename] = reason
708
- continue
787
+ return
709
788
 
710
789
  missing = [f for f in REQUIRED_FIELDS if f not in data]
711
790
  if missing:
712
791
  reason = f"missing required field(s) {missing}"
713
792
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
714
793
  skip_reasons[basename] = reason
715
- continue
794
+ return
716
795
 
717
796
  bad_list = [
718
797
  f for f in LIST_FIELDS
@@ -722,7 +801,7 @@ for filename in filenames:
722
801
  reason = f"field(s) {bad_list} must be non-empty lists"
723
802
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
724
803
  skip_reasons[basename] = reason
725
- continue
804
+ return
726
805
 
727
806
  if data["id"] != basename:
728
807
  reason = (
@@ -730,7 +809,7 @@ for filename in filenames:
730
809
  )
731
810
  print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
732
811
  skip_reasons[basename] = reason
733
- continue
812
+ return
734
813
 
735
814
  # --- E53_S03_T01: StepObject shape acceptance + bare-string back-compat ---
736
815
  normalized_steps = []
@@ -745,7 +824,7 @@ for filename in filenames:
745
824
  if step_error:
746
825
  print(f"Warning: {path} {step_error} — skipped", file=sys.stderr)
747
826
  skip_reasons[basename] = step_error
748
- continue
827
+ return
749
828
 
750
829
  # Existence check for skill-type steps only (bare string, or StepObject with `skill`).
751
830
  # `playbook`-type steps have no single skill name (step_skill_name returns None for them) and
@@ -766,10 +845,11 @@ for filename in filenames:
766
845
  )
767
846
  print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
768
847
  skip_reasons[basename] = reason
769
- continue
848
+ return
770
849
 
771
850
  raw[basename] = {
772
851
  "path": path,
852
+ "source": source,
773
853
  "name": data["name"],
774
854
  "description": data["description"],
775
855
  "keywords": data["keywords"],
@@ -778,6 +858,36 @@ for filename in filenames:
778
858
  }
779
859
  order.append(basename)
780
860
 
861
+
862
+ for filename in builtin_filenames:
863
+ path = os.path.join(playbooks_dir, filename)
864
+ basename = filename[: -len(".json")]
865
+ process_playbook_file(path, basename, "builtin")
866
+
867
+ # --- E53_S09_T01: project playbooks, id-collision check against the already-loaded builtin set --
868
+ # Runs AFTER the builtin loop above has fully populated `raw`, so a collision check here is
869
+ # checking against every SUCCESSFULLY LOADED builtin playbook -- never a builtin file that itself
870
+ # failed validation (that basename never made it into `raw`, so a project playbook may legitimately
871
+ # claim that id instead). See header 'VALIDATION / SKIP CONDITIONS'.
872
+ for filename in project_filenames:
873
+ path = os.path.join(project_playbooks_dir, filename)
874
+ basename = filename[: -len(".json")]
875
+
876
+ if basename in raw:
877
+ builtin_path = raw[basename]["path"]
878
+ reason = (
879
+ f"id '{basename}' collides with a built-in playbook already loaded from "
880
+ f"{builtin_path} — the built-in entry is retained, never silently overridden"
881
+ )
882
+ print(
883
+ f"Warning: {path} {reason} (project file skipped)",
884
+ file=sys.stderr,
885
+ )
886
+ skip_reasons[basename] = reason
887
+ continue
888
+
889
+ process_playbook_file(path, basename, "project")
890
+
781
891
  # --- PASS 2: composition resolution (E53_S05_T01) ------------------------------------------------
782
892
  # Recursively resolves `{"playbook": "<id>"}` steps into a flat, fully-spliced step list. See
783
893
  # header 'COMPOSITION RESOLUTION' for the full algorithm description; this is that algorithm.
@@ -987,6 +1097,7 @@ for pid in order:
987
1097
  "keywords": entry["keywords"],
988
1098
  "examples": entry["examples"],
989
1099
  "steps": flattened_steps,
1100
+ "source": entry["source"],
990
1101
  })
991
1102
 
992
1103
  # --- E53_S06_T02: `lookup <id>` mode output ------------------------------------------------------
@@ -1001,11 +1112,11 @@ if mode == "lookup":
1001
1112
  if result is None:
1002
1113
  if lookup_id in skip_reasons:
1003
1114
  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.
1115
+ elif f"{lookup_id}.json" in builtin_filenames or f"{lookup_id}.json" in project_filenames:
1116
+ # Defensive fallback -- should not normally happen, since every basename scanned from
1117
+ # EITHER source directory (E53_S09_T01) either lands in `catalog` (valid) or
1118
+ # `skip_reasons` (invalid) by this point. Guards against ever silently reporting
1119
+ # `not_found` for a file that does exist on disk.
1009
1120
  result = {
1010
1121
  "status": "invalid",
1011
1122
  "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