@jenga-ai/agent 3.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 +7 -0
- 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/legacy-shipped-paths.json +336 -0
- package/lib/postinstall-manifest.js +469 -0
- package/lib/skill-allow-list.json +1 -1
- package/package.json +2 -1
- 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/templates/copilot-instructions.md.tpl +32 -0
package/README.md
CHANGED
|
@@ -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
|
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
|
+
}
|