@jenga-ai/agent 2.0.0 → 3.1.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 (177) hide show
  1. package/README.md +82 -243
  2. package/agents/developer.md +5 -5
  3. package/agents/scrum-master.md +23 -23
  4. package/agents/tester.md +5 -5
  5. package/bin/jenga.js +10 -0
  6. package/hooks/copilot_session_end.sh +7 -3
  7. package/hooks/prompt_router_helper.js +17 -5
  8. package/lib/commands/doctor.js +351 -0
  9. package/lib/commands/init.js +16 -0
  10. package/lib/generate-copilot-hooks.js +116 -0
  11. package/lib/generate-skill-allow-list.js +9 -3
  12. package/lib/legacy-shipped-paths.json +336 -0
  13. package/lib/postinstall-manifest.js +469 -0
  14. package/lib/skill-allow-list.json +2 -3
  15. package/package.json +16 -25
  16. package/scripts/apply-j-prefix.sh +25 -12
  17. package/scripts/generate-j-alias.sh +333 -0
  18. package/scripts/generate-legacy-shipped-paths.js +248 -0
  19. package/scripts/postinstall.js +205 -2
  20. package/scripts/verify-legacy-seed-reconcile.sh +254 -0
  21. package/scripts/verify-postinstall-reconcile.sh +392 -0
  22. package/skills/{brainstorm → j-brainstorm}/SKILL.md +9 -2
  23. package/skills/{btw → j-btw}/SKILL.md +9 -2
  24. package/skills/{clearify → j-clearify}/SKILL.md +9 -2
  25. package/skills/{close-story → j-close-story}/SKILL.md +17 -10
  26. package/skills/{close-story → j-close-story}/scripts/check-privatized.sh +2 -2
  27. package/skills/{close-story → j-close-story}/scripts/check-story-closeable.sh +1 -1
  28. package/skills/{close-story → j-close-story}/scripts/extract-task-diff-stats.sh +1 -1
  29. package/skills/{commit → j-commit}/SKILL.md +9 -2
  30. package/skills/j-continue/SKILL.md +36 -0
  31. package/skills/{deep-dive → j-deep-dive}/SKILL.md +9 -8
  32. package/skills/{dev-done → j-dev-done}/SKILL.md +11 -4
  33. package/skills/{dev-done → j-dev-done}/scripts/classify-commit-outcome.sh +4 -4
  34. package/skills/{distribute → j-distribute}/SKILL.md +17 -10
  35. package/skills/{distribute → j-distribute}/scripts/distribute-changes.sh +1 -1
  36. package/skills/{do → j-do}/SKILL.md +12 -5
  37. package/skills/{doc → j-doc}/README.md +5 -5
  38. package/skills/{doc → j-doc}/SKILL.md +15 -8
  39. package/skills/{doc → j-doc}/authoring-notes.md +1 -1
  40. package/skills/{doc-sync → j-doc-sync}/SKILL.md +9 -2
  41. package/skills/{dooo → j-dooo}/SKILL.md +9 -2
  42. package/skills/j-error/SKILL.md +36 -0
  43. package/skills/{evaluate → j-evaluate}/SKILL.md +9 -2
  44. package/skills/j-examplify/SKILL.md +49 -0
  45. package/skills/{help → j-help}/SKILL.md +9 -2
  46. package/skills/{idea → j-idea}/SKILL.md +10 -3
  47. package/skills/{idea → j-idea}/assets/idea_handoff_template.md +1 -1
  48. package/skills/{improve → j-improve}/SKILL.md +9 -2
  49. package/skills/j-init/SKILL.md +2 -2
  50. package/skills/j-jbp/SKILL.md +32 -0
  51. package/skills/j-lgtm/SKILL.md +28 -0
  52. package/skills/{pi-plan → j-pi-plan}/SKILL.md +10 -3
  53. package/skills/{proceed → j-proceed}/SKILL.md +9 -2
  54. package/skills/{publish → j-publish}/SKILL.md +47 -40
  55. package/skills/{publish → j-publish}/adapters/droplet.md +1 -1
  56. package/skills/{publish → j-publish}/adapters/mobile-ios.md +3 -3
  57. package/skills/{publish → j-publish}/adapters/npm-ci.md +3 -3
  58. package/skills/{publish → j-publish}/adapters/npm.md +8 -8
  59. package/skills/{publish → j-publish}/assets/ci-contract.md +2 -2
  60. package/skills/{publish → j-publish}/schemas/publish.schema.json +1 -1
  61. package/skills/{publish → j-publish}/scripts/npm_stage_inspect.sh +34 -1
  62. package/skills/{publish → j-publish}/scripts/npm_stage_pipeline.sh +9 -4
  63. package/skills/{publish → j-publish}/scripts/publish_deploy.sh +4 -4
  64. package/skills/{publish → j-publish}/scripts/validate_npm_stage_env.sh +1 -1
  65. package/skills/{publish → j-publish}/wizards/droplet.md +1 -1
  66. package/skills/{publish → j-publish}/wizards/mobile-ios.md +1 -1
  67. package/skills/{publish → j-publish}/wizards/npm-ci.md +1 -1
  68. package/skills/{publish → j-publish}/wizards/npm.md +1 -1
  69. package/skills/{reconcile → j-reconcile}/SKILL.md +12 -5
  70. package/skills/{reconcile → j-reconcile}/scripts/detect-unlinked-code.sh +2 -2
  71. package/skills/{reconcile → j-reconcile}/scripts/resolve-reconcile-scope.sh +3 -3
  72. package/skills/{reconcile-origin → j-reconcile-origin}/SKILL.md +13 -6
  73. package/skills/{redo → j-redo}/SKILL.md +9 -2
  74. package/skills/{skillify → j-skillify}/SKILL.md +10 -3
  75. package/skills/{spinoff → j-spinoff}/SKILL.md +9 -2
  76. package/skills/{status → j-status}/SKILL.md +9 -2
  77. package/skills/{todo → j-todo}/SKILL.md +10 -3
  78. package/skills/{todo → j-todo}/assets/todo_handoff_template.md +1 -1
  79. package/skills/{todo → j-todo}/scripts/add_trivial_task.sh +3 -3
  80. package/skills/{todo → j-todo}/scripts/update_story_tasks.py +2 -2
  81. package/skills/{uncharted → j-uncharted}/SKILL.md +35 -28
  82. package/skills/{uncharted → j-uncharted}/assets/UNDERSTANDING_DOC_TEMPLATE.md +2 -2
  83. package/skills/{uncharted → j-uncharted}/scripts/detect-dependencies.sh +1 -1
  84. package/skills/{uncharted → j-uncharted}/scripts/detect-tests.sh +1 -1
  85. package/skills/{uncharted → j-uncharted}/scripts/directory-triage.sh +3 -3
  86. package/skills/{uncharted → j-uncharted}/scripts/elicitation-state.sh +3 -3
  87. package/skills/{uncharted → j-uncharted}/scripts/enumerate-target.sh +1 -1
  88. package/skills/{uncharted → j-uncharted}/scripts/import-source.sh +1 -1
  89. package/skills/{uncharted → j-uncharted}/scripts/inspect-provenance.sh +1 -1
  90. package/skills/{uncharted → j-uncharted}/scripts/resolve-segment-target.sh +5 -5
  91. package/skills/{uncharted → j-uncharted}/scripts/run-engine.sh +1 -1
  92. package/skills/{uncharted → j-uncharted}/scripts/validate-proposed-items.sh +2 -2
  93. package/skills/{uncharted → j-uncharted}/scripts/write-backfilled-epics.sh +1 -1
  94. package/skills/j-wtf/SKILL.md +27 -0
  95. package/skills/jenga/SKILL.md +1 -1
  96. package/skills/jenga-permission-level/SKILL.md +1 -1
  97. package/templates/SCRUM_BOARD_SCHEMA.md +1 -1
  98. package/templates/agent-context.md.tpl +10 -10
  99. package/templates/copilot-instructions.md.tpl +89 -19
  100. package/skills/continue/SKILL.md +0 -29
  101. package/skills/error/SKILL.md +0 -29
  102. package/skills/examplify/SKILL.md +0 -42
  103. package/skills/init/SKILL.md +0 -155
  104. package/skills/init/assets/scope-thresholds_template.json +0 -7
  105. package/skills/init/assets/strategy_stub_template.md +0 -38
  106. package/skills/init/assets/workflow_template.json +0 -30
  107. package/skills/init/scripts/apply-project-visibility.sh +0 -176
  108. package/skills/init/scripts/detect-existing-codebase.sh +0 -166
  109. package/skills/init/scripts/init.sh +0 -116
  110. package/skills/jbp/SKILL.md +0 -25
  111. package/skills/lgtm/SKILL.md +0 -21
  112. package/skills/skillify/assets/init-new/assets/.gitignore_template +0 -15
  113. package/skills/skillify/assets/init-new/assets/PROJECT_SUMMARY_template.md +0 -13
  114. package/skills/skillify/assets/init-new/assets/directory_structure.txt +0 -14
  115. package/skills/skillify/assets/init-new/assets/test-config_template.json +0 -4
  116. package/skills/wtf/SKILL.md +0 -20
  117. /package/skills/{close-story → j-close-story}/scripts/compute-scope-divergence.sh +0 -0
  118. /package/skills/{close-story → j-close-story}/scripts/extract-diff-stats.sh +0 -0
  119. /package/skills/{close-story → j-close-story}/scripts/update-task-frontmatter.sh +0 -0
  120. /package/skills/{commit → j-commit}/assets/user_instructions_template.md +0 -0
  121. /package/skills/{distribute → j-distribute}/CONFIG_SCHEMA.md +0 -0
  122. /package/skills/{distribute → j-distribute}/scripts/check-version.sh +0 -0
  123. /package/skills/{distribute → j-distribute}/scripts/commit-version-bump.sh +0 -0
  124. /package/skills/{do → j-do}/assets/intent-vs-diff-prompt.md +0 -0
  125. /package/skills/{do → j-do}/assets/sender_template.json +0 -0
  126. /package/skills/{doc → j-doc}/assets/path-objectives.yaml +0 -0
  127. /package/skills/{doc → j-doc}/scripts/resolve_last_update.py +0 -0
  128. /package/skills/{doc-sync → j-doc-sync}/assets/default_excludes.txt +0 -0
  129. /package/skills/{doc-sync → j-doc-sync}/assets/doc_targets.md +0 -0
  130. /package/skills/{evaluate → j-evaluate}/assets/evaluation_invokation_template.yml +0 -0
  131. /package/skills/{evaluate → j-evaluate}/assets/evaluation_rapport_template.md +0 -0
  132. /package/skills/{idea → j-idea}/assets/idea_template.md +0 -0
  133. /package/skills/{pi-plan → j-pi-plan}/assets/epic.json +0 -0
  134. /package/skills/{pi-plan → j-pi-plan}/assets/story_template.md +0 -0
  135. /package/skills/{publish → j-publish}/assets/ExportOptions.plist.template +0 -0
  136. /package/skills/{publish → j-publish}/assets/ownership-matrix.md +0 -0
  137. /package/skills/{publish → j-publish}/assets/publish.example.json +0 -0
  138. /package/skills/{publish → j-publish}/assets/publish.example.npm-ci.json +0 -0
  139. /package/skills/{publish → j-publish}/assets/publish.example.npm.json +0 -0
  140. /package/skills/{publish → j-publish}/assets/secrets-guide.md +0 -0
  141. /package/skills/{publish → j-publish}/schemas/fixtures/npm-ci-minimal.json +0 -0
  142. /package/skills/{publish → j-publish}/schemas/fixtures/npm-ci-with-empty-secrets.json +0 -0
  143. /package/skills/{publish → j-publish}/schemas/fixtures/npm-ci-with-workflow-path.json +0 -0
  144. /package/skills/{publish → j-publish}/scripts/check_target_config.sh +0 -0
  145. /package/skills/{publish → j-publish}/scripts/droplet_pipeline.sh +0 -0
  146. /package/skills/{publish → j-publish}/scripts/finalize_changelog.sh +0 -0
  147. /package/skills/{publish → j-publish}/scripts/generate_release_notes.sh +0 -0
  148. /package/skills/{publish → j-publish}/scripts/ios_pipeline.sh +0 -0
  149. /package/skills/{publish → j-publish}/scripts/npm_ci_pipeline.sh +0 -0
  150. /package/skills/{publish → j-publish}/scripts/npm_pipeline.sh +0 -0
  151. /package/skills/{publish → j-publish}/scripts/publish_common.sh +0 -0
  152. /package/skills/{publish → j-publish}/scripts/reconcile_tags.sh +0 -0
  153. /package/skills/{publish → j-publish}/scripts/run_gates.sh +0 -0
  154. /package/skills/{publish → j-publish}/scripts/setup_wizard.sh +0 -0
  155. /package/skills/{publish → j-publish}/scripts/show_history.sh +0 -0
  156. /package/skills/{publish → j-publish}/scripts/suggest_semver_bump.sh +0 -0
  157. /package/skills/{publish → j-publish}/scripts/validate_config.sh +0 -0
  158. /package/skills/{publish → j-publish}/scripts/validate_droplet_env.sh +0 -0
  159. /package/skills/{publish → j-publish}/scripts/validate_ios_env.sh +0 -0
  160. /package/skills/{publish → j-publish}/scripts/validate_npm_ci_env.sh +0 -0
  161. /package/skills/{publish → j-publish}/scripts/validate_npm_env.sh +0 -0
  162. /package/skills/{publish → j-publish}/scripts/write_ledger_entry.sh +0 -0
  163. /package/skills/{reconcile → j-reconcile}/assets/report_format.md +0 -0
  164. /package/skills/{reconcile-origin → j-reconcile-origin}/scripts/reconcile-origin.sh +0 -0
  165. /package/skills/{skillify → j-skillify}/assets/init-new/SKILL.md +0 -0
  166. /package/skills/{init → j-skillify/assets/init-new}/assets/.gitignore_template +0 -0
  167. /package/skills/{init → j-skillify/assets/init-new}/assets/PROJECT_SUMMARY_template.md +0 -0
  168. /package/skills/{init → j-skillify/assets/init-new}/assets/directory_structure.txt +0 -0
  169. /package/skills/{init → j-skillify/assets/init-new}/assets/test-config_template.json +0 -0
  170. /package/skills/{skillify → j-skillify}/assets/init-new/assets/workflow_template.json +0 -0
  171. /package/skills/{skillify → j-skillify}/assets/init-new/scripts/init.sh +0 -0
  172. /package/skills/{skillify → j-skillify}/assets/init-old/SKILL.md +0 -0
  173. /package/skills/{status → j-status}/assets/output_format.md +0 -0
  174. /package/skills/{todo → j-todo}/assets/todo_template.md +0 -0
  175. /package/skills/{uncharted → j-uncharted}/assets/SEGMENT_PROPOSAL_TEMPLATE.md +0 -0
  176. /package/skills/{uncharted → j-uncharted}/scripts/apply-subsystem-cap.sh +0 -0
  177. /package/skills/{uncharted → j-uncharted}/scripts/discover-subsystems.sh +0 -0
package/agents/tester.md CHANGED
@@ -167,7 +167,7 @@ Always include the sender object in the response.
167
167
 
168
168
  **Trigger.** During steps 4-6 of "Invoked for test implementation and/or execution" above, you find a defect. Before defaulting to step 9's rapport path, classify it: is this **mechanical** (safe to fix in place) or does it **need a rapport** (a design decision or real risk is involved)? This classification is what determines which of the two remediation paths below you take — it is not optional bookkeeping, and it happens at the moment the defect is found, not retroactively.
169
169
 
170
- **Motivating case.** `project/rapports/analysis/E42_S04-execution-overhead-postmortem.md` Finding 3: the tester found `skills/dev-done/scripts/classify-commit-outcome.sh` committed without its executable bit (`chmod +x`) plus a dead, unreachable error branch. Both were one-line-class fixes, but the only path available at the time was the full formal one — a `test_failure` rapport, a separate developer fix commit, a re-verification pass, and a second complete `j:self-sync` mirror run. That produced 3 of the task's 6 total commits for defects that needed no design judgment at all. This subsection exists so that pattern doesn't repeat.
170
+ **Motivating case.** `project/rapports/analysis/E42_S04-execution-overhead-postmortem.md` Finding 3: the tester found `skills/dev-done/scripts/classify-commit-outcome.sh` committed without its executable bit (`chmod +x`) plus a dead, unreachable error branch. Both were one-line-class fixes, but the only path available at the time was the full formal one — a `test_failure` rapport, a separate developer fix commit, a re-verification pass, and a second complete `j.self-sync` mirror run. That produced 3 of the task's 6 total commits for defects that needed no design judgment at all. This subsection exists so that pattern doesn't repeat.
171
171
 
172
172
  **Qualifying examples (mechanical — fix in place).**
173
173
  - A missing executable bit or other permission-bit error (e.g. a script committed without `chmod +x`) — the exact E42_S04 Finding 3 case.
@@ -258,11 +258,11 @@ Append this as a new array entry — never overwrite existing log content. This
258
258
 
259
259
  **Trigger.** The task being verified — or its parent story — carries `crucial_level: locked` in frontmatter, per `templates/SCRUM_BOARD_SCHEMA.md`'s "Crucial Flag Fields (Story, Task)" section.
260
260
 
261
- **Effect — forced inline scope.** `execution_scope` is force-set to `inline` for any `locked` task, overriding whatever scope `j:jenga`'s Execution Scope Assignment heuristics would otherwise assign — or auto-correcting a wrong value in place, with a logged `override_justification` note. The concrete mechanism is `skills/jenga/SKILL.md` Phase 0.5's **Rule 4 — `crucial_level: locked` forces `execution_scope: inline`** (added by E39_S03_T03). As tester, verify this field is actually `inline` on any `locked` item you're validating — a value that slipped through would itself be a defect worth flagging.
261
+ **Effect — forced inline scope.** `execution_scope` is force-set to `inline` for any `locked` task, overriding whatever scope `j.jenga`'s Execution Scope Assignment heuristics would otherwise assign — or auto-correcting a wrong value in place, with a logged `override_justification` note. The concrete mechanism is `skills/jenga/SKILL.md` Phase 0.5's **Rule 4 — `crucial_level: locked` forces `execution_scope: inline`** (added by E39_S03_T03). As tester, verify this field is actually `inline` on any `locked` item you're validating — a value that slipped through would itself be a defect worth flagging.
262
262
 
263
- **Effect — dispatch-time rejection of backgrounding.** A `locked` task can never be routed to a background subagent, a worktree-isolated session, or a bundled `j:jenga` story-batch execution, regardless of its `execution_scope` value. This is enforced at two points, both added by E39_S03_T04: `skills/jenga/SKILL.md` Phase 3.5 step 5's **Guard: locked-task disqualifier (defense-in-depth)** and `skills/do/SKILL.md` Section 4.2's **Locked-task dispatch guard (defense-in-depth)**. This matters directly to you as tester: you must never yourself dispatch, recommend, or improvise a background subagent, a separate worktree-isolated session, or a bundled batch run in order to verify a `locked` item faster or in parallel with other work — verification of a `locked` item happens in the same foreground session the guards already pinned it to, same as implementation.
263
+ **Effect — dispatch-time rejection of backgrounding.** A `locked` task can never be routed to a background subagent, a worktree-isolated session, or a bundled `j.jenga` story-batch execution, regardless of its `execution_scope` value. This is enforced at two points, both added by E39_S03_T04: `skills/jenga/SKILL.md` Phase 3.5 step 5's **Guard: locked-task disqualifier (defense-in-depth)** and `skills/do/SKILL.md` Section 4.2's **Locked-task dispatch guard (defense-in-depth)**. This matters directly to you as tester: you must never yourself dispatch, recommend, or improvise a background subagent, a separate worktree-isolated session, or a bundled batch run in order to verify a `locked` item faster or in parallel with other work — verification of a `locked` item happens in the same foreground session the guards already pinned it to, same as implementation.
264
264
 
265
- **No agent-discretion obligation.** Unlike `advisory` (a reporting-cadence habit) and `gated` (a confirmation you must actively pause and perform), `locked` requires no judgment call from you. It is fully enforced by pre-flight validation (Rule 4) and dispatch-time guards (the Phase 3.5 and `j:do` guards above) before the developer ever begins work — none of this depends on you noticing or remembering anything mid-verification. Your only obligation is to recognize that a `locked` task always runs (and was always verified) in the current foreground session, and to never suggest or perform a workaround that would route around that guarantee. If you find evidence during verification that a `locked` item was actually run in a backgrounded or worktree-isolated context, treat that as a guard failure worth flagging (see Rapport System), not something to silently pass.
265
+ **No agent-discretion obligation.** Unlike `advisory` (a reporting-cadence habit) and `gated` (a confirmation you must actively pause and perform), `locked` requires no judgment call from you. It is fully enforced by pre-flight validation (Rule 4) and dispatch-time guards (the Phase 3.5 and `j.do` guards above) before the developer ever begins work — none of this depends on you noticing or remembering anything mid-verification. Your only obligation is to recognize that a `locked` task always runs (and was always verified) in the current foreground session, and to never suggest or perform a workaround that would route around that guarantee. If you find evidence during verification that a `locked` item was actually run in a backgrounded or worktree-isolated context, treat that as a guard failure worth flagging (see Rapport System), not something to silently pass.
266
266
 
267
267
  ### Invoked for analysis or comparison testing
268
268
  When invoked to run an analysis or comparison:
@@ -455,7 +455,7 @@ There is no default analytics run. Analytics only happen when explicitly scoped
455
455
 
456
456
  ## Investigative Mode
457
457
 
458
- **Trigger.** You are sometimes dispatched not to validate a task's implementation, but purely to build understanding of what the existing test suite actually covers for a named flow or target — e.g. by the scrum-master during `j:uncharted`'s conversational architecture elicitation, alongside the developer's Investigative Mode pass over the same flow. This is a distinct dispatch mode from the standard Sender Object / Task Intake / Status Management flow above, recognized by the request itself (you are asked to *trace coverage*, not to *validate a task*), not by any board field.
458
+ **Trigger.** You are sometimes dispatched not to validate a task's implementation, but purely to build understanding of what the existing test suite actually covers for a named flow or target — e.g. by the scrum-master during `j.uncharted`'s conversational architecture elicitation, alongside the developer's Investigative Mode pass over the same flow. This is a distinct dispatch mode from the standard Sender Object / Task Intake / Status Management flow above, recognized by the request itself (you are asked to *trace coverage*, not to *validate a task*), not by any board field.
459
459
 
460
460
  **Hard constraints.** Investigative Mode is strictly read-only:
461
461
  - No worktree is created for write purposes, no test files are written or modified, no test runs that mutate state, no dependency installs.
package/bin/jenga.js CHANGED
@@ -19,6 +19,8 @@ Usage:
19
19
  jenga init Run the setup wizard (creates jenga.cli.json, registers hook)
20
20
  jenga attach Write MCP config for this project so new sessions route through Jenga
21
21
  jenga status Show router status and active session
22
+ jenga doctor Scan .agents/ and .claude/ for orphaned package files and clean up
23
+ interactively (alias: jenga clean). Add --dry-run to preview only.
22
24
 
23
25
  Options:
24
26
  --version, -v Print version
@@ -57,6 +59,14 @@ async function main() {
57
59
  await runStatus(args);
58
60
  break;
59
61
  }
62
+ case "doctor":
63
+ case "clean": {
64
+ // Both names are the same code path (E26_S08_T02) — "doctor" and "clean" are
65
+ // intentionally interchangeable, never two implementations.
66
+ const { runDoctor } = await import("../lib/commands/doctor.js");
67
+ await runDoctor(args);
68
+ break;
69
+ }
60
70
  default:
61
71
  console.error(`Unknown command: ${cmd}\n`);
62
72
  console.log(USAGE);
@@ -3,9 +3,13 @@
3
3
  #
4
4
  # Copilot CLI session-end entry point.
5
5
  #
6
- # GitHub Copilot CLI does not fire a native SessionEnd hook. Call this script
7
- # manually at the end of a Copilot session or as a post-step in any skill
8
- # that completes a significant pipeline stage (e.g. commit, lgtm).
6
+ # GitHub Copilot CLI DOES fire a native `sessionEnd` hook (confirmed empirically against a real
7
+ # installed `copilot` CLI under E16_S03_T03 — see docs/hook-parity.md). This script is wired as
8
+ # that hook's command via `.github/hooks/jenga.json` (committed here; generated per-consumer at
9
+ # `jenga init` / npm postinstall time via lib/generate-copilot-hooks.js — see E16_S03_T04).
10
+ # It can also still be called manually — e.g. as a post-step in a skill, or on a Copilot install
11
+ # that hasn't run `jenga init`/postinstall yet and so has no `.github/hooks/jenga.json` — the
12
+ # manual path documented below remains a valid fallback, not the primary mechanism.
9
13
  #
10
14
  # This wrapper:
11
15
  # 1. Sources lib/resolve-project-dir.sh to export JENGA_PROJECT_DIR,
@@ -1,16 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  // hooks/prompt_router_helper.js
3
- // Companion helper for prompt_router.sh — implements Claude Code UserPromptSubmit logic.
3
+ // Companion helper for prompt_router.sh — implements the UserPromptSubmit /
4
+ // userPromptSubmitted routing logic shared by Claude Code and GitHub Copilot CLI
5
+ // (E16_S03_T04 — both platforms deliver a JSON stdin payload with a `prompt` field, so
6
+ // no platform branching is needed here).
4
7
  // Reads JSON payload from stdin, checks if the Jenga Router is running,
5
8
  // and either routes the prompt or passes it through unchanged.
9
+ //
10
+ // ESM (root package.json sets "type": "module") — found and fixed under E16_S03_T04 while
11
+ // verifying the new Copilot userPromptSubmitted wiring end-to-end: this file previously used
12
+ // CommonJS require()/__dirname, which crashes under Node's ESM loader with
13
+ // "ReferenceError: require is not defined in ES module scope". That crash predates this task
14
+ // (present since the file's original authoring, e09242d) and affected the existing Claude-side
15
+ // UserPromptSubmit hook equally — not something newly introduced by the Copilot wiring.
6
16
 
7
- "use strict";
8
- const { readFileSync, existsSync } = require("fs");
9
- const { join } = require("path");
17
+ import { readFileSync, existsSync } from "fs";
18
+ import { join, dirname } from "path";
19
+ import { fileURLToPath } from "url";
10
20
 
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
22
  const projectRoot = join(__dirname, "..");
12
23
 
13
- // Read stdin (JSON payload from Claude Code: {"prompt": "..."})
24
+ // Read stdin (JSON payload from Claude Code: {"prompt": "..."}, or from Copilot CLI's
25
+ // userPromptSubmitted event: {"sessionId": "...", "timestamp": ..., "cwd": "...", "prompt": "..."})
14
26
  let input;
15
27
  try {
16
28
  const raw = readFileSync("/dev/stdin", "utf8").trim();
@@ -0,0 +1,351 @@
1
+ /**
2
+ * lib/commands/doctor.js — `jenga doctor` / `jenga clean` interactive orphan cleanup (E26_S08_T02)
3
+ *
4
+ * Why this exists
5
+ * ────────────────
6
+ * `scripts/postinstall.js`'s manifest-based delete reconciliation (E26_S08_T01) only ever cleans
7
+ * up a path that a manifest THIS package's postinstall previously wrote lists. That is safe, but
8
+ * purely forward-looking: a consumer already installed before any manifest existed can never have
9
+ * their pre-existing orphans (retired `j:`-form skill files, excluded `j-<name>` twins, or any
10
+ * other drift) reconciled by postinstall alone — see `E26_S08`'s "Scope gap discovered after T01"
11
+ * section. `E26_S08_T03` closes that gap automatically for the pre-manifest-migration case
12
+ * specifically, going forward. This command is the manual stopgap that works right now, for any
13
+ * cause of drift (not only the one migration that surfaced the bug): a human runs it, reviews a
14
+ * candidate list, and confirms before anything is deleted.
15
+ *
16
+ * Heuristic — "looks package-owned"
17
+ * ──────────────────────────────────
18
+ * There is no manifest guarantee to lean on here (that is exactly the gap this command exists to
19
+ * cover), so eligibility is decided by a heuristic instead of provenance:
20
+ *
21
+ * - A top-level directory under `<root>/skills/` is a candidate ONLY if it is not one of the
22
+ * currently-installed package's own skill directories AND its `SKILL.md` (if present) carries
23
+ * frontmatter whose `name:` matches `/^j[.:]/i` — the same anti-masquerading prefix regex
24
+ * `lib/generate-skill-allow-list.js` already uses as the canonical "this looks like a genuine
25
+ * Jenga skill identifier" signal. Every regular file recursively inside a confirmed candidate
26
+ * directory becomes a delete candidate, grouped under that directory.
27
+ * - A top-level `.md` file directly under `<root>/agents/` is a candidate if its name does not
28
+ * match one of the currently-installed package's own agent files.
29
+ * - Any path already recorded in that root's `.jenga-postinstall-manifest.json` is excluded —
30
+ * manifest-listed paths already self-heal via the normal postinstall path (or will, once
31
+ * E26_S08_T03 lands), so surfacing them here too would be redundant.
32
+ *
33
+ * False-positive posture (documented, not hidden): a consumer's own `SKILL.md` that happens to
34
+ * declare `name: j.<something>` frontmatter, or a consumer's own hand-dropped `.md` file placed
35
+ * directly under `agents/`, would be misclassified as package-owned. This is an accepted risk —
36
+ * the confirmation step below is the compensating control, not a convenience, per this task's
37
+ * `crucial_level: gated` note. A directory the package currently ships is NEVER inspected
38
+ * file-by-file, which is what lets a consumer's own extra file living inside an otherwise-current
39
+ * package skill directory survive untouched without a separate special case.
40
+ *
41
+ * Safety
42
+ * ──────
43
+ * Deletion re-uses the exact boundary/type checks `lib/postinstall-manifest.js`'s delete pass
44
+ * uses: `isInside` (must resolve strictly inside the mirror root), `lstatSync` regular-file check
45
+ * (symlinks refused, never followed), and directories are only ever pruned via the shared
46
+ * `pruneEmptyDirs` helper once genuinely empty — never deleted directly. Nothing is ever deleted
47
+ * without an explicit interactive "yes", and a non-TTY stdin never falls through to deleting.
48
+ *
49
+ * ESM, Node built-ins only — matches `lib/postinstall-manifest.js` and `lib/mirror.js`.
50
+ */
51
+
52
+ import fs from 'node:fs';
53
+ import path from 'node:path';
54
+ import { fileURLToPath } from 'node:url';
55
+ import { createInterface } from 'node:readline';
56
+
57
+ import { readManifest, isInside, pruneEmptyDirs } from '../postinstall-manifest.js';
58
+
59
+ const __filename = fileURLToPath(import.meta.url);
60
+ const __dirname = path.dirname(__filename);
61
+
62
+ /** Default package root: this file lives at <package>/lib/commands/doctor.js. */
63
+ const DEFAULT_PACKAGE_ROOT = path.join(__dirname, '..', '..');
64
+
65
+ /** Mirror roots a consumer install writes into (see docs/distribution.md). */
66
+ const MIRROR_ROOTS = ['.agents', '.claude'];
67
+
68
+ /** Jenga skill-name prefix regex — matches lib/generate-skill-allow-list.js's guard exactly. */
69
+ const JENGA_NAME_PREFIX = /^j[.:]/i;
70
+
71
+ // ── frontmatter helpers ─────────────────────────────────────────────────────
72
+
73
+ /** Extract the frontmatter `name:` field from SKILL.md content, or null. */
74
+ function extractSkillName(content) {
75
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
76
+ if (!fmMatch) return null;
77
+ const nameMatch = fmMatch[1].match(/^name:\s*(.+)$/m);
78
+ if (!nameMatch) return null;
79
+ return nameMatch[1].trim().replace(/^["']|["']$/g, '');
80
+ }
81
+
82
+ /** True iff `<dir>/SKILL.md` exists and its frontmatter name looks like a genuine Jenga skill. */
83
+ function looksLikeJengaSkillDir(dir) {
84
+ const skillMd = path.join(dir, 'SKILL.md');
85
+ let content;
86
+ try {
87
+ content = fs.readFileSync(skillMd, 'utf8');
88
+ } catch (_) {
89
+ return false; // no SKILL.md — not enough signal to call this package-owned
90
+ }
91
+ const name = extractSkillName(content);
92
+ return typeof name === 'string' && JENGA_NAME_PREFIX.test(name);
93
+ }
94
+
95
+ // ── package's own current shape ─────────────────────────────────────────────
96
+
97
+ /** Top-level skill directory names the installed package currently ships. */
98
+ function currentPackageSkillDirs(packageRoot) {
99
+ const dir = path.join(packageRoot, 'skills');
100
+ if (!fs.existsSync(dir)) return new Set();
101
+ return new Set(
102
+ fs.readdirSync(dir, { withFileTypes: true })
103
+ .filter((e) => e.isDirectory())
104
+ .map((e) => e.name)
105
+ );
106
+ }
107
+
108
+ /** Top-level agent `.md` file names the installed package currently ships. */
109
+ function currentPackageAgentFiles(packageRoot) {
110
+ const dir = path.join(packageRoot, 'agents');
111
+ if (!fs.existsSync(dir)) return new Set();
112
+ return new Set(
113
+ fs.readdirSync(dir, { withFileTypes: true })
114
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
115
+ .map((e) => e.name)
116
+ );
117
+ }
118
+
119
+ // ── scan ─────────────────────────────────────────────────────────────────────
120
+
121
+ /** Recursively collect relative POSIX paths of every regular file under `dir`. */
122
+ function collectFiles(root, dir, out) {
123
+ let entries;
124
+ try {
125
+ entries = fs.readdirSync(dir, { withFileTypes: true });
126
+ } catch (_) {
127
+ return;
128
+ }
129
+ for (const entry of entries) {
130
+ const abs = path.join(dir, entry.name);
131
+ if (entry.isDirectory()) {
132
+ collectFiles(root, abs, out);
133
+ } else {
134
+ let stat;
135
+ try {
136
+ stat = fs.lstatSync(abs);
137
+ } catch (_) {
138
+ continue;
139
+ }
140
+ if (!stat.isFile()) continue; // symlinks and other node types refused, not followed
141
+ out.push(path.relative(root, abs).split(path.sep).join('/'));
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Scan one mirror root for orphan candidates.
148
+ *
149
+ * @param {object} opts
150
+ * @param {string} opts.destRoot Absolute path to the mirror root (e.g. <consumer>/.agents).
151
+ * @param {string} opts.packageRoot Absolute path to the currently-installed package root.
152
+ * @returns {{groups: Array<{label: string, files: string[]}>, total: number}}
153
+ */
154
+ export function scanRoot({ destRoot, packageRoot }) {
155
+ const groups = [];
156
+ if (!fs.existsSync(destRoot)) return { groups, total: 0 };
157
+
158
+ const manifest = readManifest(destRoot);
159
+ const manifestPaths = new Set(manifest ? manifest.paths : []);
160
+
161
+ const currentSkillDirs = currentPackageSkillDirs(packageRoot);
162
+ const currentAgentFiles = currentPackageAgentFiles(packageRoot);
163
+
164
+ // skills/<name>/ — directory-level candidacy, then sweep every file inside.
165
+ const skillsDir = path.join(destRoot, 'skills');
166
+ if (fs.existsSync(skillsDir)) {
167
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
168
+ if (!entry.isDirectory()) continue;
169
+ if (currentSkillDirs.has(entry.name)) continue; // currently shipped — never inspected
170
+ const abs = path.join(skillsDir, entry.name);
171
+ if (!looksLikeJengaSkillDir(abs)) continue; // no Jenga-shaped SKILL.md — not enough signal
172
+
173
+ const files = [];
174
+ collectFiles(destRoot, abs, files);
175
+ const candidateFiles = files.filter((rel) => !manifestPaths.has(rel));
176
+ if (candidateFiles.length > 0) {
177
+ groups.push({ label: `skills/${entry.name}/`, files: candidateFiles });
178
+ }
179
+ }
180
+ }
181
+
182
+ // agents/<name>.md — flat file-level candidacy.
183
+ const agentsDir = path.join(destRoot, 'agents');
184
+ if (fs.existsSync(agentsDir)) {
185
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
186
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
187
+ if (currentAgentFiles.has(entry.name)) continue; // currently shipped
188
+ const rel = `agents/${entry.name}`;
189
+ if (manifestPaths.has(rel)) continue;
190
+ let stat;
191
+ try {
192
+ stat = fs.lstatSync(path.join(agentsDir, entry.name));
193
+ } catch (_) {
194
+ continue;
195
+ }
196
+ if (!stat.isFile()) continue; // symlink refused
197
+ groups.push({ label: rel, files: [rel] });
198
+ }
199
+ }
200
+
201
+ const total = groups.reduce((n, g) => n + g.files.length, 0);
202
+ return { groups, total };
203
+ }
204
+
205
+ // ── delete ───────────────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Delete every file across `groups` (relative to `destRoot`), applying the same boundary/type
209
+ * checks the postinstall delete pass uses, then prune directories left empty.
210
+ *
211
+ * @returns {{deleted: string[], refused: Array<{path: string, reason: string}>}}
212
+ */
213
+ function deleteGroups({ destRoot, groups }) {
214
+ const root = path.resolve(destRoot);
215
+ const deleted = [];
216
+ const refused = [];
217
+ const dirsToConsider = new Set();
218
+
219
+ for (const group of groups) {
220
+ for (const rel of group.files) {
221
+ const abs = path.resolve(root, rel);
222
+ if (!isInside(root, abs)) {
223
+ refused.push({ path: rel, reason: 'outside-dest-root' });
224
+ continue;
225
+ }
226
+ let stat;
227
+ try {
228
+ stat = fs.lstatSync(abs);
229
+ } catch (_) {
230
+ continue; // already gone
231
+ }
232
+ if (!stat.isFile()) {
233
+ refused.push({ path: rel, reason: 'not-a-regular-file' });
234
+ continue;
235
+ }
236
+ try {
237
+ fs.rmSync(abs);
238
+ deleted.push(rel);
239
+ dirsToConsider.add(path.dirname(abs));
240
+ } catch (e) {
241
+ refused.push({ path: rel, reason: `unlink-failed: ${e.code || e.message}` });
242
+ }
243
+ }
244
+ }
245
+
246
+ const prunedDirs = [];
247
+ for (const dir of dirsToConsider) {
248
+ pruneEmptyDirs(root, dir, prunedDirs);
249
+ }
250
+
251
+ return { deleted, refused, prunedDirs: prunedDirs.sort() };
252
+ }
253
+
254
+ // ── prompt ───────────────────────────────────────────────────────────────────
255
+
256
+ function askYesNo(question) {
257
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
258
+ return new Promise((resolve) => {
259
+ rl.question(`${question} [y/N] `, (answer) => {
260
+ rl.close();
261
+ resolve(/^y(es)?$/i.test(answer.trim()));
262
+ });
263
+ });
264
+ }
265
+
266
+ // ── main ─────────────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * @param {string[]} args
270
+ * @param {object} [opts]
271
+ * @param {string} [opts.packageRoot] Installed package root (default: this file's own package).
272
+ * @param {string} [opts.targetRoot] Consumer project root to scan (default: process.cwd()).
273
+ * @param {boolean} [opts.isInteractive] Test seam ONLY — overrides the real `process.stdin.isTTY`
274
+ * check. Never set by `bin/jenga.js`'s CLI dispatch, which
275
+ * always leaves this undefined so the real TTY check applies;
276
+ * exists so `tests/postinstall-doctor-cleanup.bats` can drive
277
+ * the actual confirm/decline delete code path deterministically
278
+ * without needing a real pty, matching the same non-TTY
279
+ * readline limitation already documented in tests/init.bats.
280
+ * @param {function} [opts.confirmFn] Test seam ONLY — overrides the real interactive `askYesNo`
281
+ * prompt with a function returning (or resolving to) a
282
+ * boolean. Never set by `bin/jenga.js`.
283
+ */
284
+ export async function runDoctor(args = [], opts = {}) {
285
+ const packageRoot = opts.packageRoot || DEFAULT_PACKAGE_ROOT;
286
+ const targetRoot = opts.targetRoot || process.cwd();
287
+ const dryRun = args.includes('--dry-run');
288
+ const isInteractive = typeof opts.isInteractive === 'boolean' ? opts.isInteractive : Boolean(process.stdin.isTTY);
289
+ const confirmFn = typeof opts.confirmFn === 'function' ? opts.confirmFn : askYesNo;
290
+
291
+ console.log('\n╔══════════════════════════════════════════════════════╗');
292
+ console.log('║ jenga doctor — orphan scan ║');
293
+ console.log('╚══════════════════════════════════════════════════════╝\n');
294
+
295
+ const scans = MIRROR_ROOTS
296
+ .map((root) => ({ root, destRoot: path.join(targetRoot, root) }))
297
+ .filter(({ destRoot }) => fs.existsSync(destRoot))
298
+ .map(({ root, destRoot }) => ({ root, destRoot, ...scanRoot({ destRoot, packageRoot }) }));
299
+
300
+ const grandTotal = scans.reduce((n, s) => n + s.total, 0);
301
+
302
+ if (grandTotal === 0) {
303
+ console.log(' ✓ No orphan candidates found. Nothing to clean up.\n');
304
+ return { deleted: [], total: 0 };
305
+ }
306
+
307
+ for (const s of scans) {
308
+ if (s.total === 0) continue;
309
+ console.log(` ${s.root}/ — ${s.total} candidate file(s):`);
310
+ for (const group of s.groups) {
311
+ console.log(` ${group.label} (${group.files.length} file(s))`);
312
+ }
313
+ }
314
+ console.log(`\n Total: ${grandTotal} candidate file(s) across ${scans.length} mirror root(s).\n`);
315
+
316
+ if (dryRun) {
317
+ console.log(' --dry-run: no files were deleted.\n');
318
+ return { deleted: [], total: grandTotal, dryRun: true };
319
+ }
320
+
321
+ if (!isInteractive) {
322
+ console.log(' Non-interactive session detected — nothing deleted.');
323
+ console.log(' Re-run `jenga doctor` interactively to confirm deletion.\n');
324
+ return { deleted: [], total: grandTotal, nonInteractive: true };
325
+ }
326
+
327
+ const confirmed = await confirmFn(`Delete all ${grandTotal} candidate file(s) listed above?`);
328
+ if (!confirmed) {
329
+ console.log('\n Declined — nothing was deleted.\n');
330
+ return { deleted: [], total: grandTotal, confirmed: false };
331
+ }
332
+
333
+ let totalDeleted = 0;
334
+ for (const s of scans) {
335
+ if (s.total === 0) continue;
336
+ const { deleted, refused, prunedDirs } = deleteGroups({ destRoot: s.destRoot, groups: s.groups });
337
+ totalDeleted += deleted.length;
338
+ if (deleted.length > 0) {
339
+ console.log(` ✓ ${s.root}/ — ${deleted.length} file(s) removed` +
340
+ (prunedDirs.length ? `, ${prunedDirs.length} empty dir(s) pruned` : ''));
341
+ }
342
+ for (const r of refused) {
343
+ console.log(` ⚠ ${s.root}/${r.path} — left in place (${r.reason})`);
344
+ }
345
+ }
346
+
347
+ console.log(`\n Done. ${totalDeleted} file(s) removed.\n`);
348
+ return { deleted: totalDeleted, total: grandTotal, confirmed: true };
349
+ }
350
+
351
+ export default { runDoctor, scanRoot };
@@ -6,6 +6,7 @@ import { validateConfig } from "../config-schema.js";
6
6
  import { injectSettings } from "../inject-settings.js";
7
7
  import { generateAgentContext } from "../generate-agent-context.js";
8
8
  import { generateCopilotInstructions } from "../generate-copilot-instructions.js";
9
+ import { generateCopilotHooks } from "../generate-copilot-hooks.js";
9
10
 
10
11
  const CONFIG_FILE = "jenga.cli.json";
11
12
 
@@ -154,6 +155,21 @@ export async function runInit(args, projectRoot = process.cwd()) {
154
155
  console.warn(`Warning: Could not write .github/copilot-instructions.md — ${e.message}`);
155
156
  }
156
157
 
158
+ // Generate .github/hooks/jenga.json — Copilot CLI's native sessionEnd/userPromptSubmitted
159
+ // hook config (E16_S03_T04), wiring the same cleanup/routing logic Claude Code's own
160
+ // SessionEnd/UserPromptSubmit hooks invoke. Uses the shared generator
161
+ // (lib/generate-copilot-hooks.js), also used unconditionally by scripts/postinstall.js at
162
+ // install time, mirroring the copilot-instructions.md generation immediately above. When
163
+ // PACKAGE_ROOT === projectRoot (running `jenga init` inside this monorepo itself) the
164
+ // generator resolves hook script paths dynamically via `$(git rev-parse --show-toplevel)`
165
+ // rather than baking in this run's absolute path — see that file's header comment.
166
+ try {
167
+ const result = generateCopilotHooks(projectRoot, PACKAGE_ROOT);
168
+ console.log(`✓ .github/hooks/jenga.json written (${result.path})`);
169
+ } catch (e) {
170
+ console.warn(`Warning: Could not write .github/hooks/jenga.json — ${e.message}`);
171
+ }
172
+
157
173
  // Generate CLAUDE.md / AGENTS.md from templates/agent-context.md.tpl.
158
174
  // Unconditional — never gated on agentTarget (E41_S04): every agent
159
175
  // benefits from these files being cheap to write, and users switch
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/generate-copilot-hooks.js — .github/hooks/jenga.json generation
4
+ *
5
+ * Single source of truth for scaffolding GitHub Copilot CLI's native hook config, wiring the two
6
+ * Copilot lifecycle events that map directly onto this project's existing Claude Code hooks
7
+ * (E16_S03_T04, following E16_S03_T03's empirical verification against a real installed
8
+ * `copilot` CLI):
9
+ *
10
+ * sessionEnd -> hooks/copilot_session_end.sh (mirrors Claude's SessionEnd hook)
11
+ * userPromptSubmitted -> hooks/prompt_router.sh (mirrors Claude's UserPromptSubmit hook)
12
+ *
13
+ * `WorktreeCreate`/`WorktreeRemove` have no direct Copilot-native lifecycle equivalent (they are
14
+ * git-worktree-specific, not generic session events) — T03's finding recommends keeping the
15
+ * existing manual workaround rather than a fragile `preToolUse`/`postToolUse` text-match
16
+ * approximation, so this generator does not wire those two events at all.
17
+ *
18
+ * Used by:
19
+ * - scripts/postinstall.js (runs unconditionally, non-interactively, on every `npm install`)
20
+ * - lib/commands/init.js (the interactive `jenga init` CLI wizard)
21
+ *
22
+ * Placement rationale (recorded in E16_S03_T03's finding): `.github/hooks/jenga.json` has no
23
+ * pre-existing root-level source directory to mirror from — unlike `.github/agents/*.md`, which
24
+ * mirrors real content already living at `agents/*.md`. The closer precedent is `settings.json`
25
+ * itself (Claude's own hook config): committed directly at this monorepo's own root for its own
26
+ * dev use, and separately generated per-consumer because a consumer's hook commands must resolve
27
+ * absolute `node_modules/@jenga-ai/agent/hooks/*.sh` paths rather than this repo's relative ones.
28
+ * This is why `.github/hooks/jenga.json` is entirely Jenga-owned (unlike
29
+ * `.github/copilot-instructions.md`, which preserves user content around JENGA markers) — there
30
+ * is no legitimate reason for a consumer to hand-edit it, so a full deterministic overwrite on
31
+ * every run is safe and simpler than a marker-merge.
32
+ *
33
+ * No `skills/self-sync/scripts/run.js` wiring is needed either: self-sync mirrors root-level
34
+ * source directories, and this file has none to mirror — it is generated directly, exactly like
35
+ * `.github/copilot-instructions.md` is (also outside self-sync's `COPY_SET`/`GITHUB_COPY_SET`).
36
+ *
37
+ * ESM, Node built-ins only — mirrors lib/generate-copilot-instructions.js and lib/mirror.js.
38
+ */
39
+
40
+ import { writeFileSync, existsSync, mkdirSync } from "fs";
41
+ import { join, resolve, dirname } from "path";
42
+ import { fileURLToPath } from "url";
43
+
44
+ // This file lives at <package>/lib/generate-copilot-hooks.js — one level up is the installed
45
+ // jenga-agent package root, which holds hooks/copilot_session_end.sh and hooks/prompt_router.sh.
46
+ const __dirname = dirname(fileURLToPath(import.meta.url));
47
+ const DEFAULT_PACKAGE_ROOT = join(__dirname, "..");
48
+
49
+ /**
50
+ * Build the `.github/hooks/jenga.json` config object.
51
+ *
52
+ * Two path-resolution modes, chosen by the caller (`generateCopilotHooks`) based on whether
53
+ * `projectRoot` and `packageRoot` are the same directory:
54
+ *
55
+ * - `dynamicRoot: true` (this monorepo generating its own config: `projectRoot === packageRoot`)
56
+ * — commands resolve `hooks/*.sh` at RUN time via `$(git rev-parse --show-toplevel)`, exactly
57
+ * like `settings.json`'s own hook commands already do. A baked-in absolute path would be wrong
58
+ * here — it would hardcode wherever the generator happened to run from (e.g. a throwaway
59
+ * worktree checkout) instead of resolving to whichever checkout of this repo is actually
60
+ * running the hook.
61
+ * - `dynamicRoot: false` (a real npm consumer: `projectRoot` is the consumer's project,
62
+ * `packageRoot` is the installed `node_modules/@jenga-ai/agent`) — commands bake in the
63
+ * absolute `packageRoot`-relative path, mirroring `lib/inject-settings.js`'s
64
+ * `resolve(PACKAGE_ROOT, "hooks", "prompt_router.sh")` for Claude's own `UserPromptSubmit`
65
+ * hook. This is safe because a consumer's installed package path is stable once `npm install`
66
+ * completes — unlike this monorepo's own dev checkouts/worktrees, which move around.
67
+ *
68
+ * @param {string} packageRoot - where hooks/*.sh actually live
69
+ * @param {boolean} dynamicRoot - see above
70
+ */
71
+ function buildHooksConfig(packageRoot, dynamicRoot) {
72
+ const sessionEndCmd = dynamicRoot
73
+ ? 'bash "$(git rev-parse --show-toplevel)/hooks/copilot_session_end.sh"'
74
+ : `bash "${resolve(packageRoot, "hooks", "copilot_session_end.sh")}"`;
75
+ const promptRouterCmd = dynamicRoot
76
+ ? 'bash "$(git rev-parse --show-toplevel)/hooks/prompt_router.sh"'
77
+ : `bash "${resolve(packageRoot, "hooks", "prompt_router.sh")}"`;
78
+
79
+ return {
80
+ version: 1,
81
+ hooks: {
82
+ sessionEnd: [
83
+ { type: "command", bash: sessionEndCmd },
84
+ ],
85
+ userPromptSubmitted: [
86
+ { type: "command", bash: promptRouterCmd },
87
+ ],
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Generate (or idempotently overwrite) `.github/hooks/jenga.json` at projectRoot.
94
+ *
95
+ * Idempotent by construction: the output is fully deterministic from `packageRoot` alone, so
96
+ * re-running (e.g. a later `jenga init`, or a repeat postinstall on the same version) always
97
+ * produces byte-identical content. Unlike `.github/copilot-instructions.md`, no marker-merge is
98
+ * needed — this file is entirely Jenga-owned.
99
+ *
100
+ * @param {string} projectRoot - project root directory to write into (default: cwd)
101
+ * @param {string} packageRoot - where hooks/*.sh live (default: derived from this file's own
102
+ * location, i.e. this monorepo's own root when run in-repo)
103
+ * @returns {{written: boolean, path: string}}
104
+ */
105
+ export function generateCopilotHooks(projectRoot = process.cwd(), packageRoot = DEFAULT_PACKAGE_ROOT) {
106
+ const hooksDir = join(projectRoot, ".github", "hooks");
107
+ const targetPath = join(hooksDir, "jenga.json");
108
+
109
+ const dynamicRoot = resolve(projectRoot) === resolve(packageRoot);
110
+ const config = buildHooksConfig(packageRoot, dynamicRoot);
111
+
112
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
113
+ writeFileSync(targetPath, JSON.stringify(config, null, 2) + "\n", "utf8");
114
+
115
+ return { written: true, path: targetPath };
116
+ }
@@ -55,10 +55,16 @@ function extractName(content) {
55
55
  const nameMatch = fmMatch[1].match(/^name:\s*(.+)$/m);
56
56
  if (!nameMatch) return null;
57
57
  const rawName = nameMatch[1].trim().replace(/^["']|["']$/g, "");
58
- // Frontmatter may carry either the pre-E50_S01 bare form or the post-rename "j:<name>" form
59
- // (skills/*/SKILL.md was migrated in E50_S01_T02) — strip the prefix so the allow-list always
58
+ // Frontmatter may carry the pre-E50_S01 bare form, the E50_S01 "j:<name>" form, or the
59
+ // current "j.<name>" form (E50_S07_T01 swapped the separator because GitHub Copilot CLI
60
+ // rejects ":" in a skill name) — strip whichever prefix is present so the allow-list always
60
61
  // holds bare identifiers, matching mcp/router/skill-index.js's `bareName` handling.
61
- return rawName.replace(/^j:/i, "");
62
+ //
63
+ // Both separators must stay accepted: this guard is the E50_S02 anti-masquerading check, and
64
+ // a normalizer that fails to strip silently populates the allow-list with prefixed entries
65
+ // ("j.brainstorm" instead of "brainstorm"), which no longer match what the guard compares
66
+ // against. That is a security-guard failure, not a cosmetic one.
67
+ return rawName.replace(/^j[.:]/i, "");
62
68
  }
63
69
 
64
70
  /**