@jenga-ai/agent 3.0.0 → 3.1.1

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 CHANGED
@@ -40,7 +40,7 @@ Jenga AI solves each of these with structure: persistent engineering context mai
40
40
  - **Isolated git worktrees per task** — the Developer never works directly on your main branch
41
41
  - **Works with any AI coding agent or AI-native IDE** — Claude Code, GitHub Copilot, and Codex CLI are all supported today
42
42
 
43
- > 📖 **Full reference:** [project/.wiki/documentation.md](project/.wiki/documentation.md) | [Intro Guide](project/.wiki/intro-guide.md)
43
+ > 📖 **Full reference:** [Docs site](https://samwelmunga.github.io/jenga-npm/reference.html) · **Intro Guide:** [Docs site](https://samwelmunga.github.io/jenga-npm/getting-started.html) — mirrored at [project/.wiki/documentation.md](project/.wiki/documentation.md) / [intro-guide.md](project/.wiki/intro-guide.md)
44
44
 
45
45
  ### "Isn't this just an LLM grading another LLM?"
46
46
 
@@ -171,6 +171,13 @@ Or clone directly:
171
171
 
172
172
  Run `j.status` at any time to see where the project stands.
173
173
 
174
+ **CLI maintenance commands.** The `jenga` binary installed alongside the package (`jenga --help`)
175
+ also ships a couple of maintenance commands, distinct from the in-agent `j.<name>` skills above:
176
+
177
+ | Command | Description |
178
+ |---|---|
179
+ | `jenga doctor` (alias: `jenga clean`) | Scans `.agents/` and `.claude/` for orphaned package-owned files left behind by an upgrade (or any other mirror drift), previews them, and deletes only on explicit confirmation. Never deletes unattended — add `--dry-run` to preview only. See `docs/distribution.md` for the ownership heuristic and its false-positive posture. |
180
+
174
181
  ---
175
182
 
176
183
  ## Supported Platforms
@@ -208,7 +215,7 @@ Skills live in `.agents/skills/<name>/SKILL.md`. Invoke with `j.<name>` in your
208
215
  | `j.do` | Execute tasks from the scrum board, drives the Developer agent through the full loop |
209
216
  | `j.status` | Print a full scrum board overview — epics, stories, tasks, rapports, queue depth |
210
217
 
211
- > 📖 **Full skill list** (planning, review, committing & maintenance commands): [project/.wiki/documentation.md](project/.wiki/documentation.md#skills)
218
+ > 📖 **Full skill list** (planning, review, committing & maintenance commands): [Docs site](https://samwelmunga.github.io/jenga-npm/skills.html) — mirrored at [project/.wiki/documentation.md#skills](project/.wiki/documentation.md#skills)
212
219
 
213
220
  ---
214
221
 
@@ -225,4 +232,4 @@ Skills live in `.agents/skills/<name>/SKILL.md`. Invoke with `j.<name>` in your
225
232
  - You're doing a quick one-off script or single-session experiment
226
233
  - Your project has no meaningful test surface
227
234
 
228
- 📖 **Full reference:** [project/.wiki/documentation.md](project/.wiki/documentation.md)
235
+ 📖 **Full reference:** [Docs site](https://samwelmunga.github.io/jenga-npm/reference.html) — mirrored at [project/.wiki/documentation.md](project/.wiki/documentation.md)
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
+ }