@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.
- package/README.md +82 -243
- package/agents/developer.md +5 -5
- package/agents/scrum-master.md +23 -23
- package/agents/tester.md +5 -5
- package/bin/jenga.js +10 -0
- package/hooks/copilot_session_end.sh +7 -3
- package/hooks/prompt_router_helper.js +17 -5
- package/lib/commands/doctor.js +351 -0
- package/lib/commands/init.js +16 -0
- package/lib/generate-copilot-hooks.js +116 -0
- package/lib/generate-skill-allow-list.js +9 -3
- package/lib/legacy-shipped-paths.json +336 -0
- package/lib/postinstall-manifest.js +469 -0
- package/lib/skill-allow-list.json +2 -3
- package/package.json +16 -25
- package/scripts/apply-j-prefix.sh +25 -12
- package/scripts/generate-j-alias.sh +333 -0
- package/scripts/generate-legacy-shipped-paths.js +248 -0
- package/scripts/postinstall.js +205 -2
- package/scripts/verify-legacy-seed-reconcile.sh +254 -0
- package/scripts/verify-postinstall-reconcile.sh +392 -0
- package/skills/{brainstorm → j-brainstorm}/SKILL.md +9 -2
- package/skills/{btw → j-btw}/SKILL.md +9 -2
- package/skills/{clearify → j-clearify}/SKILL.md +9 -2
- package/skills/{close-story → j-close-story}/SKILL.md +17 -10
- package/skills/{close-story → j-close-story}/scripts/check-privatized.sh +2 -2
- package/skills/{close-story → j-close-story}/scripts/check-story-closeable.sh +1 -1
- package/skills/{close-story → j-close-story}/scripts/extract-task-diff-stats.sh +1 -1
- package/skills/{commit → j-commit}/SKILL.md +9 -2
- package/skills/j-continue/SKILL.md +36 -0
- package/skills/{deep-dive → j-deep-dive}/SKILL.md +9 -8
- package/skills/{dev-done → j-dev-done}/SKILL.md +11 -4
- package/skills/{dev-done → j-dev-done}/scripts/classify-commit-outcome.sh +4 -4
- package/skills/{distribute → j-distribute}/SKILL.md +17 -10
- package/skills/{distribute → j-distribute}/scripts/distribute-changes.sh +1 -1
- package/skills/{do → j-do}/SKILL.md +12 -5
- package/skills/{doc → j-doc}/README.md +5 -5
- package/skills/{doc → j-doc}/SKILL.md +15 -8
- package/skills/{doc → j-doc}/authoring-notes.md +1 -1
- package/skills/{doc-sync → j-doc-sync}/SKILL.md +9 -2
- package/skills/{dooo → j-dooo}/SKILL.md +9 -2
- package/skills/j-error/SKILL.md +36 -0
- package/skills/{evaluate → j-evaluate}/SKILL.md +9 -2
- package/skills/j-examplify/SKILL.md +49 -0
- package/skills/{help → j-help}/SKILL.md +9 -2
- package/skills/{idea → j-idea}/SKILL.md +10 -3
- package/skills/{idea → j-idea}/assets/idea_handoff_template.md +1 -1
- package/skills/{improve → j-improve}/SKILL.md +9 -2
- package/skills/j-init/SKILL.md +2 -2
- package/skills/j-jbp/SKILL.md +32 -0
- package/skills/j-lgtm/SKILL.md +28 -0
- package/skills/{pi-plan → j-pi-plan}/SKILL.md +10 -3
- package/skills/{proceed → j-proceed}/SKILL.md +9 -2
- package/skills/{publish → j-publish}/SKILL.md +47 -40
- package/skills/{publish → j-publish}/adapters/droplet.md +1 -1
- package/skills/{publish → j-publish}/adapters/mobile-ios.md +3 -3
- package/skills/{publish → j-publish}/adapters/npm-ci.md +3 -3
- package/skills/{publish → j-publish}/adapters/npm.md +8 -8
- package/skills/{publish → j-publish}/assets/ci-contract.md +2 -2
- package/skills/{publish → j-publish}/schemas/publish.schema.json +1 -1
- package/skills/{publish → j-publish}/scripts/npm_stage_inspect.sh +34 -1
- package/skills/{publish → j-publish}/scripts/npm_stage_pipeline.sh +9 -4
- package/skills/{publish → j-publish}/scripts/publish_deploy.sh +4 -4
- package/skills/{publish → j-publish}/scripts/validate_npm_stage_env.sh +1 -1
- package/skills/{publish → j-publish}/wizards/droplet.md +1 -1
- package/skills/{publish → j-publish}/wizards/mobile-ios.md +1 -1
- package/skills/{publish → j-publish}/wizards/npm-ci.md +1 -1
- package/skills/{publish → j-publish}/wizards/npm.md +1 -1
- package/skills/{reconcile → j-reconcile}/SKILL.md +12 -5
- package/skills/{reconcile → j-reconcile}/scripts/detect-unlinked-code.sh +2 -2
- package/skills/{reconcile → j-reconcile}/scripts/resolve-reconcile-scope.sh +3 -3
- package/skills/{reconcile-origin → j-reconcile-origin}/SKILL.md +13 -6
- package/skills/{redo → j-redo}/SKILL.md +9 -2
- package/skills/{skillify → j-skillify}/SKILL.md +10 -3
- package/skills/{spinoff → j-spinoff}/SKILL.md +9 -2
- package/skills/{status → j-status}/SKILL.md +9 -2
- package/skills/{todo → j-todo}/SKILL.md +10 -3
- package/skills/{todo → j-todo}/assets/todo_handoff_template.md +1 -1
- package/skills/{todo → j-todo}/scripts/add_trivial_task.sh +3 -3
- package/skills/{todo → j-todo}/scripts/update_story_tasks.py +2 -2
- package/skills/{uncharted → j-uncharted}/SKILL.md +35 -28
- package/skills/{uncharted → j-uncharted}/assets/UNDERSTANDING_DOC_TEMPLATE.md +2 -2
- package/skills/{uncharted → j-uncharted}/scripts/detect-dependencies.sh +1 -1
- package/skills/{uncharted → j-uncharted}/scripts/detect-tests.sh +1 -1
- package/skills/{uncharted → j-uncharted}/scripts/directory-triage.sh +3 -3
- package/skills/{uncharted → j-uncharted}/scripts/elicitation-state.sh +3 -3
- package/skills/{uncharted → j-uncharted}/scripts/enumerate-target.sh +1 -1
- package/skills/{uncharted → j-uncharted}/scripts/import-source.sh +1 -1
- package/skills/{uncharted → j-uncharted}/scripts/inspect-provenance.sh +1 -1
- package/skills/{uncharted → j-uncharted}/scripts/resolve-segment-target.sh +5 -5
- package/skills/{uncharted → j-uncharted}/scripts/run-engine.sh +1 -1
- package/skills/{uncharted → j-uncharted}/scripts/validate-proposed-items.sh +2 -2
- package/skills/{uncharted → j-uncharted}/scripts/write-backfilled-epics.sh +1 -1
- package/skills/j-wtf/SKILL.md +27 -0
- package/skills/jenga/SKILL.md +1 -1
- package/skills/jenga-permission-level/SKILL.md +1 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +1 -1
- package/templates/agent-context.md.tpl +10 -10
- package/templates/copilot-instructions.md.tpl +89 -19
- package/skills/continue/SKILL.md +0 -29
- package/skills/error/SKILL.md +0 -29
- package/skills/examplify/SKILL.md +0 -42
- package/skills/init/SKILL.md +0 -155
- package/skills/init/assets/scope-thresholds_template.json +0 -7
- package/skills/init/assets/strategy_stub_template.md +0 -38
- package/skills/init/assets/workflow_template.json +0 -30
- package/skills/init/scripts/apply-project-visibility.sh +0 -176
- package/skills/init/scripts/detect-existing-codebase.sh +0 -166
- package/skills/init/scripts/init.sh +0 -116
- package/skills/jbp/SKILL.md +0 -25
- package/skills/lgtm/SKILL.md +0 -21
- package/skills/skillify/assets/init-new/assets/.gitignore_template +0 -15
- package/skills/skillify/assets/init-new/assets/PROJECT_SUMMARY_template.md +0 -13
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +0 -14
- package/skills/skillify/assets/init-new/assets/test-config_template.json +0 -4
- package/skills/wtf/SKILL.md +0 -20
- /package/skills/{close-story → j-close-story}/scripts/compute-scope-divergence.sh +0 -0
- /package/skills/{close-story → j-close-story}/scripts/extract-diff-stats.sh +0 -0
- /package/skills/{close-story → j-close-story}/scripts/update-task-frontmatter.sh +0 -0
- /package/skills/{commit → j-commit}/assets/user_instructions_template.md +0 -0
- /package/skills/{distribute → j-distribute}/CONFIG_SCHEMA.md +0 -0
- /package/skills/{distribute → j-distribute}/scripts/check-version.sh +0 -0
- /package/skills/{distribute → j-distribute}/scripts/commit-version-bump.sh +0 -0
- /package/skills/{do → j-do}/assets/intent-vs-diff-prompt.md +0 -0
- /package/skills/{do → j-do}/assets/sender_template.json +0 -0
- /package/skills/{doc → j-doc}/assets/path-objectives.yaml +0 -0
- /package/skills/{doc → j-doc}/scripts/resolve_last_update.py +0 -0
- /package/skills/{doc-sync → j-doc-sync}/assets/default_excludes.txt +0 -0
- /package/skills/{doc-sync → j-doc-sync}/assets/doc_targets.md +0 -0
- /package/skills/{evaluate → j-evaluate}/assets/evaluation_invokation_template.yml +0 -0
- /package/skills/{evaluate → j-evaluate}/assets/evaluation_rapport_template.md +0 -0
- /package/skills/{idea → j-idea}/assets/idea_template.md +0 -0
- /package/skills/{pi-plan → j-pi-plan}/assets/epic.json +0 -0
- /package/skills/{pi-plan → j-pi-plan}/assets/story_template.md +0 -0
- /package/skills/{publish → j-publish}/assets/ExportOptions.plist.template +0 -0
- /package/skills/{publish → j-publish}/assets/ownership-matrix.md +0 -0
- /package/skills/{publish → j-publish}/assets/publish.example.json +0 -0
- /package/skills/{publish → j-publish}/assets/publish.example.npm-ci.json +0 -0
- /package/skills/{publish → j-publish}/assets/publish.example.npm.json +0 -0
- /package/skills/{publish → j-publish}/assets/secrets-guide.md +0 -0
- /package/skills/{publish → j-publish}/schemas/fixtures/npm-ci-minimal.json +0 -0
- /package/skills/{publish → j-publish}/schemas/fixtures/npm-ci-with-empty-secrets.json +0 -0
- /package/skills/{publish → j-publish}/schemas/fixtures/npm-ci-with-workflow-path.json +0 -0
- /package/skills/{publish → j-publish}/scripts/check_target_config.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/droplet_pipeline.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/finalize_changelog.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/generate_release_notes.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/ios_pipeline.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/npm_ci_pipeline.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/npm_pipeline.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/publish_common.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/reconcile_tags.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/run_gates.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/setup_wizard.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/show_history.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/suggest_semver_bump.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/validate_config.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/validate_droplet_env.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/validate_ios_env.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/validate_npm_ci_env.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/validate_npm_env.sh +0 -0
- /package/skills/{publish → j-publish}/scripts/write_ledger_entry.sh +0 -0
- /package/skills/{reconcile → j-reconcile}/assets/report_format.md +0 -0
- /package/skills/{reconcile-origin → j-reconcile-origin}/scripts/reconcile-origin.sh +0 -0
- /package/skills/{skillify → j-skillify}/assets/init-new/SKILL.md +0 -0
- /package/skills/{init → j-skillify/assets/init-new}/assets/.gitignore_template +0 -0
- /package/skills/{init → j-skillify/assets/init-new}/assets/PROJECT_SUMMARY_template.md +0 -0
- /package/skills/{init → j-skillify/assets/init-new}/assets/directory_structure.txt +0 -0
- /package/skills/{init → j-skillify/assets/init-new}/assets/test-config_template.json +0 -0
- /package/skills/{skillify → j-skillify}/assets/init-new/assets/workflow_template.json +0 -0
- /package/skills/{skillify → j-skillify}/assets/init-new/scripts/init.sh +0 -0
- /package/skills/{skillify → j-skillify}/assets/init-old/SKILL.md +0 -0
- /package/skills/{status → j-status}/assets/output_format.md +0 -0
- /package/skills/{todo → j-todo}/assets/todo_template.md +0 -0
- /package/skills/{uncharted → j-uncharted}/assets/SEGMENT_PROPOSAL_TEMPLATE.md +0 -0
- /package/skills/{uncharted → j-uncharted}/scripts/apply-subsystem-cap.sh +0 -0
- /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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7
|
-
#
|
|
8
|
-
# that
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 };
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
|
59
|
-
//
|
|
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
|
-
|
|
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
|
/**
|