@boyingliu01/xp-gate 0.35.21 → 0.35.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/adapters/kotlin.sh +53 -8
  2. package/bin/xp-gate.js +12 -0
  3. package/gate-13-compiler-strictness.sh +15 -1
  4. package/hooks/gate-13-compiler-strictness.sh +15 -1
  5. package/hooks/pre-commit +137 -9
  6. package/lib/__tests__/agent-runtime-journal.test.js +302 -0
  7. package/lib/__tests__/mirror-sync.test.js +277 -0
  8. package/lib/__tests__/runtime-journal.test.js +164 -0
  9. package/lib/agent-runtime-journal.js +347 -0
  10. package/lib/mattpocock-license-verdict.json +1 -1
  11. package/lib/mirror-sync.js +335 -0
  12. package/lib/runtime-journal.js +156 -0
  13. package/mock-policy/AGENTS.md +2 -2
  14. package/mutation/AGENTS.md +2 -2
  15. package/package.json +1 -1
  16. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  17. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  18. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  19. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  20. package/plugins/dsh/package.json +1 -1
  21. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  22. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  23. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  24. package/plugins/opencode/package.json +1 -1
  25. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  26. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  27. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  28. package/plugins/qoder/plugin.json +1 -1
  29. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  32. package/principles/AGENTS.md +2 -2
  33. package/scripts/delphi-external-review.cjs +68 -1
  34. package/skills/delphi-review/AGENTS.md +2 -2
  35. package/skills/sprint-flow/AGENTS.md +2 -2
  36. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,347 @@
1
+ /**
2
+ * xp-gate agent-runtime-journal — measure the AGENTS.md runtime anti-patterns.
3
+ *
4
+ * `AGENTS.md` §AGENT RUNTIME DISCIPLINE has named three rules since 2026-10-30.
5
+ * Rule 1 (native read/grep/glob instead of shell) took effect — measurement on
6
+ * 2026-11-02 showed pure-read shell calls fall from 32.7% to 0.2%. Rules 2 and
7
+ * 3 did not: calls/step stayed at 1.02, and `job_output` was called with an
8
+ * explicit `wait: true` 103 times across three days, blocking 8.9 h.
9
+ *
10
+ * The reason a prose rule was ignored is that nothing measured it. This module
11
+ * turns those rules into numbers read from the agent's own session journals, so
12
+ * `retro`/CI can show the cost instead of restating the prohibition.
13
+ *
14
+ * Zero npm dependencies (Node built-ins only) — `src/npm-package/` ships
15
+ * zero-install, and `zlib.zstdDecompressSync` covers the journal format.
16
+ *
17
+ * @module agent-runtime-journal
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const zlib = require('zlib');
25
+
26
+ /**
27
+ * A tool call cannot plausibly run longer than this. Beyond it, the recorded
28
+ * span measures a session resume or log flush, not work: the 2026-10-30
29
+ * diagnosis found single `pwsh` calls "lasting" 78 hours because the matching
30
+ * result was only written at the next compaction/prune. Excluding them is what
31
+ * makes the totals mean anything.
32
+ */
33
+ const DEFAULT_MAX_PLAUSIBLE_MS = 30 * 60 * 1000;
34
+
35
+ /** A `job_output` call counts as blocking once it waits longer than this. */
36
+ const DEFAULT_BLOCKING_THRESHOLD_MS = 30 * 1000;
37
+
38
+ /** Journal filename used by the DSH session store. */
39
+ const JOURNAL_BASENAME = 'session.v4.jsonl';
40
+
41
+ /**
42
+ * Parse a session journal buffer, accepting both the zstd-compressed on-disk
43
+ * form and plain UTF-8 (so fixtures need no encoder).
44
+ *
45
+ * Malformed lines are skipped rather than discarding the whole journal: one
46
+ * truncated tail line must not erase an otherwise usable session.
47
+ *
48
+ * @param {Buffer|string} buffer
49
+ * @returns {Array<object>} parsed events
50
+ */
51
+ function parseSessionBuffer(buffer) {
52
+ let text;
53
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(String(buffer), 'utf8');
54
+ // zstd magic number: 0x28 0xB5 0x2F 0xFD (little endian 0xFD2FB528).
55
+ if (buf.length >= 4 && buf[0] === 0x28 && buf[1] === 0xb5 && buf[2] === 0x2f && buf[3] === 0xfd) {
56
+ try {
57
+ text = zlib.zstdDecompressSync(buf).toString('utf8');
58
+ } catch {
59
+ return [];
60
+ }
61
+ } else {
62
+ text = buf.toString('utf8');
63
+ }
64
+
65
+ const events = [];
66
+ for (const line of text.split('\n')) {
67
+ if (!line) continue;
68
+ try {
69
+ events.push(JSON.parse(line));
70
+ } catch {
71
+ // Skip a malformed line; the rest of the journal is still valid.
72
+ }
73
+ }
74
+ return events;
75
+ }
76
+
77
+ /**
78
+ * Resolve the journal file inside a session directory.
79
+ * @param {string} dir
80
+ * @returns {string|null} absolute path, or null when absent
81
+ */
82
+ function journalPathIn(dir) {
83
+ for (const name of [JOURNAL_BASENAME, `${JOURNAL_BASENAME}.zstd`]) {
84
+ const p = path.join(dir, name);
85
+ if (fs.existsSync(p)) return p;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Classify a shell command against the waiting anti-patterns rule 3 forbids.
92
+ *
93
+ * Returned flags are independent: a command can be both a poll loop and a
94
+ * literal sleep, and reporting them separately keeps the cause visible.
95
+ *
96
+ * @param {string} command
97
+ * @returns {{sleep: boolean, pollLoop: boolean, ciPolling: boolean, oversizedTimeout: boolean}}
98
+ */
99
+ function classifyCommand(command) {
100
+ const cmd = String(command || '');
101
+ return {
102
+ // `sleep N` as a command, not the word "sleepless" inside an argument.
103
+ sleep: /(?:^|[;&|(]\s*|\s)sleep\s+\d+/.test(cmd),
104
+ pollLoop: /for\s+\w+\s+in\s+\$\(seq|while\s+true|while\s*:\s*;/.test(cmd),
105
+ ciPolling: /gh\s+run\s+(?:list|watch|view)/.test(cmd),
106
+ // `timeout` only counts as oversized past 10 minutes; the diagnosis found
107
+ // 375 such calls whose p50 runtime was 6.3 s — budget far above reality.
108
+ oversizedTimeout: /timeout\s+(\d{3,})\b/.test(cmd) && Number((cmd.match(/timeout\s+(\d{3,})\b/) || [])[1]) >= 600,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Median of a numeric array (0 for an empty input).
114
+ * @param {number[]} values
115
+ * @returns {number}
116
+ */
117
+ function median(values) {
118
+ if (!values.length) return 0;
119
+ const sorted = values.slice().sort((a, b) => a - b);
120
+ const mid = sorted.length >> 1;
121
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
122
+ }
123
+
124
+ /**
125
+ * Round to one decimal place without leaving float noise in JSON output.
126
+ * @param {number} value
127
+ * @returns {number}
128
+ */
129
+ function round1(value) {
130
+ return Math.round(value * 10) / 10;
131
+ }
132
+
133
+ /**
134
+ * Build a measured record for every tool call in one journal.
135
+ *
136
+ * @param {Array<object>} events
137
+ * @param {number} maxPlausibleMs
138
+ * @returns {Array<{name: string, t: number, durMs: number|null, args: object, implausible: boolean, turn: number, step: number}>}
139
+ */
140
+ function extractCalls(events, maxPlausibleMs) {
141
+ /** @type {Map<string, number>} callId -> result timestamp */
142
+ const endedAt = new Map();
143
+ for (const e of events) {
144
+ if (e && e.type === 'tool/result') {
145
+ const id = e.data && e.data.message && e.data.message.toolCallId;
146
+ if (id && typeof e.time === 'number') endedAt.set(id, e.time);
147
+ }
148
+ }
149
+
150
+ const calls = [];
151
+ for (const e of events) {
152
+ if (!e || e.type !== 'tool/call') continue;
153
+ const d = e.data || {};
154
+ let args = {};
155
+ try {
156
+ args = JSON.parse(d.arguments || '{}');
157
+ } catch {
158
+ args = {};
159
+ }
160
+ const end = endedAt.get(d.callId);
161
+ const durMs = typeof end === 'number' && typeof e.time === 'number' ? end - e.time : null;
162
+ calls.push({
163
+ name: d.name || 'unknown',
164
+ t: e.time,
165
+ durMs,
166
+ args,
167
+ implausible: durMs !== null && (durMs < 0 || durMs > maxPlausibleMs),
168
+ turn: d.turn ?? 0,
169
+ step: d.step ?? 0,
170
+ });
171
+ }
172
+ return calls;
173
+ }
174
+
175
+ /**
176
+ * Summarise tool-call efficiency across a set of session directories.
177
+ *
178
+ * @param {string[]} sessionDirs directories containing `session.v4.jsonl[.zstd]`
179
+ * @param {{maxPlausibleMs?: number, blockingThresholdMs?: number, sinceMs?: number, untilMs?: number}} [options]
180
+ * `sinceMs`/`untilMs` bound the calls considered, so the same journal supports a
181
+ * before/after comparison instead of only an all-time total.
182
+ * @returns {object} report
183
+ */
184
+ function analyseSessions(sessionDirs, options = {}) {
185
+ const maxPlausibleMs = options.maxPlausibleMs ?? DEFAULT_MAX_PLAUSIBLE_MS;
186
+ const blockingThresholdMs = options.blockingThresholdMs ?? DEFAULT_BLOCKING_THRESHOLD_MS;
187
+ const sinceMs = typeof options.sinceMs === 'number' ? options.sinceMs : -Infinity;
188
+ const untilMs = typeof options.untilMs === 'number' ? options.untilMs : Infinity;
189
+
190
+ const allCalls = [];
191
+ let sessions = 0;
192
+
193
+ for (const dir of sessionDirs || []) {
194
+ const jp = journalPathIn(dir);
195
+ if (!jp) continue;
196
+ let buf;
197
+ try {
198
+ buf = fs.readFileSync(jp);
199
+ } catch {
200
+ continue;
201
+ }
202
+ const events = parseSessionBuffer(buf);
203
+ if (!events.length) continue;
204
+ sessions += 1;
205
+ for (const call of extractCalls(events, maxPlausibleMs)) {
206
+ if (typeof call.t === 'number' && call.t >= sinceMs && call.t < untilMs) allCalls.push(call);
207
+ }
208
+ }
209
+
210
+ const plausible = allCalls.filter((c) => c.durMs !== null ? !c.implausible : true);
211
+ const implausible = allCalls.filter((c) => c.implausible);
212
+ const timed = plausible.filter((c) => c.durMs !== null);
213
+
214
+ // ---- rule 3a: job_output blocking ----
215
+ const jobCalls = plausible.filter((c) => c.name === 'job_output');
216
+ const blocking = jobCalls.filter((c) => c.durMs !== null && c.durMs > blockingThresholdMs);
217
+ const explicitWaitTrue = jobCalls.filter((c) => c.args && c.args.wait === true);
218
+
219
+ // ---- rule 3b: idle waiting inside bash ----
220
+ const bashCalls = plausible.filter((c) => c.name === 'bash' || c.name === 'pwsh' || c.name === 'powershell');
221
+ const sleep = [];
222
+ const pollLoop = [];
223
+ const ciPolling = [];
224
+ const oversizedTimeout = [];
225
+ for (const c of bashCalls) {
226
+ const flags = classifyCommand(c.args && c.args.command);
227
+ if (flags.sleep) sleep.push(c);
228
+ if (flags.pollLoop) pollLoop.push(c);
229
+ if (flags.ciPolling) ciPolling.push(c);
230
+ if (flags.oversizedTimeout) oversizedTimeout.push(c);
231
+ }
232
+
233
+ // ---- rule 2: call batching ----
234
+ const perStep = new Map();
235
+ for (const c of plausible) {
236
+ if (!c.turn && !c.step) continue;
237
+ const key = `${c.turn}:${c.step}`;
238
+ perStep.set(key, (perStep.get(key) || 0) + 1);
239
+ }
240
+ const stepCounts = Array.from(perStep.values());
241
+ const stepsWithMultiple = stepCounts.filter((n) => n >= 2).length;
242
+
243
+ const total = (arr) => arr.reduce((acc, c) => acc + (c.durMs || 0), 0);
244
+
245
+ return {
246
+ sessions,
247
+ toolCalls: allCalls.length,
248
+ timedCalls: timed.length,
249
+ implausible: { count: implausible.length, totalMs: total(implausible) },
250
+ maxPlausibleMs,
251
+ blockingJobOutput: {
252
+ count: blocking.length,
253
+ totalMs: total(blocking),
254
+ explicitWaitTrue: explicitWaitTrue.length,
255
+ maxMs: blocking.length ? Math.max(...blocking.map((c) => c.durMs)) : 0,
256
+ thresholdMs: blockingThresholdMs,
257
+ },
258
+ idleWait: {
259
+ sleep: { count: sleep.length, totalMs: total(sleep) },
260
+ pollLoop: { count: pollLoop.length, totalMs: total(pollLoop) },
261
+ ciPolling: { count: ciPolling.length, totalMs: total(ciPolling) },
262
+ oversizedTimeout: { count: oversizedTimeout.length, totalMs: total(oversizedTimeout) },
263
+ },
264
+ batching: {
265
+ stepsWithCalls: stepCounts.length,
266
+ stepsWithMultipleCalls: stepsWithMultiple,
267
+ batchedRatioPct: stepCounts.length ? round1((stepsWithMultiple / stepCounts.length) * 100) : 0,
268
+ callsPerStep: stepCounts.length ? round1((plausible.length / stepCounts.length) * 100) / 100 : 0,
269
+ },
270
+ toolMedianMs: median(timed.map((c) => c.durMs)),
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Reduce a report to a one-screen summary for `retro`/CI output.
276
+ *
277
+ * Deliberately reports NOT MEASURABLE instead of 0% when the journal holds
278
+ * nothing to measure — a zero that means "no data" reads as "no problem", the
279
+ * same false-green the gate-coverage work (#636) set out to kill.
280
+ *
281
+ * @param {object} report
282
+ * @returns {string[]} at most 15 lines
283
+ */
284
+ function summariseForSlot(report) {
285
+ if (!report || report.toolCalls === 0) {
286
+ return ['Runtime efficiency: NOT MEASURABLE (no tool calls in journal)'];
287
+ }
288
+
289
+ const hours = (ms) => `${round1(ms / 3600000)} h`;
290
+ const lines = [];
291
+
292
+ const blocked = report.blockingJobOutput;
293
+ lines.push(
294
+ `job_output blocked >${Math.round(blocked.thresholdMs / 1000)}s: ${blocked.count} call(s), ${hours(blocked.totalMs)}` +
295
+ ` (explicit wait:true = ${blocked.explicitWaitTrue})`
296
+ );
297
+
298
+ const idle = report.idleWait;
299
+ lines.push(`literal sleep: ${idle.sleep.count} call(s), ${hours(idle.sleep.totalMs)}`);
300
+ lines.push(`CI status polling: ${idle.ciPolling.count} call(s), ${hours(idle.ciPolling.totalMs)}`);
301
+ lines.push(`poll loops (seq/while): ${idle.pollLoop.count} call(s), ${hours(idle.pollLoop.totalMs)}`);
302
+ lines.push(`oversized timeout (>=600s): ${idle.oversizedTimeout.count} call(s), ${hours(idle.oversizedTimeout.totalMs)}`);
303
+
304
+ const batch = report.batching;
305
+ const batchLine = batch.stepsWithCalls
306
+ ? `call batching: ${batch.callsPerStep} calls/step, ${batch.batchedRatioPct}% of steps batched (target >=2.0)`
307
+ : 'call batching: NOT MEASURABLE (no steps in journal)';
308
+ lines.push(batchLine);
309
+
310
+ if (report.implausible.count) {
311
+ lines.push(`excluded ${report.implausible.count} implausible duration(s) (resume/log-flush artifacts)`);
312
+ }
313
+
314
+ return lines.slice(0, 15);
315
+ }
316
+
317
+ /**
318
+ * Discover session directories under a root (one level deep).
319
+ * @param {string} root
320
+ * @returns {string[]}
321
+ */
322
+ function discoverSessionDirs(root) {
323
+ let entries;
324
+ try {
325
+ entries = fs.readdirSync(root, { withFileTypes: true });
326
+ } catch {
327
+ return [];
328
+ }
329
+ const dirs = [];
330
+ for (const entry of entries) {
331
+ if (!entry.isDirectory()) continue;
332
+ const full = path.join(root, entry.name);
333
+ if (journalPathIn(full)) dirs.push(full);
334
+ }
335
+ return dirs;
336
+ }
337
+
338
+ module.exports = {
339
+ DEFAULT_MAX_PLAUSIBLE_MS,
340
+ DEFAULT_BLOCKING_THRESHOLD_MS,
341
+ parseSessionBuffer,
342
+ journalPathIn,
343
+ classifyCommand,
344
+ analyseSessions,
345
+ summariseForSlot,
346
+ discoverSessionDirs,
347
+ };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-11-02T04:04:20.814Z",
5
+ "checked_at": "2026-11-02T15:52:57.140Z",
6
6
  "source": "network"
7
7
  }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Mirror sync — bring every canonical tree / npm-package mirror back to parity.
3
+ *
4
+ * xp-gate keeps several trees in two places: `githooks/` is the source of truth
5
+ * and `src/npm-package/hooks/` is the copy that ships. Four `check-*-mirror.sh`
6
+ * scripts guard that duplication, but all four only *detect* drift; repairing it
7
+ * is a hand-copy, and the hand-copy is where the real cost shows up: over the
8
+ * three days to 2026-11-02, 29 of 141 commits (20.6%) touched only mirrors,
9
+ * version stamps, AGENTS.md copies and CHANGELOG, and the mirror/sync scripts
10
+ * were invoked 306 times.
11
+ *
12
+ * Two defects are fixed here:
13
+ *
14
+ * 1. **False drift from untracked build artifacts.** `check-hook-lib-mirror.sh`
15
+ * compared directory listings, so a `githooks/lib/__pycache__/` created by a
16
+ * Python test run made a byte-identical tree look out of sync. The set of
17
+ * files that matter is the set git tracks — an artifact that is not in the
18
+ * repository cannot be drift, because it cannot be committed.
19
+ * 2. **No repair path.** `syncMirrors` copies what is missing, rewrites what
20
+ * differs, and prunes what the canonical side no longer has — idempotently,
21
+ * so a second run is a no-op and the operation can run on every commit
22
+ * without becoming a churn generator itself.
23
+ *
24
+ * @module mirror-sync
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { execFileSync } = require('child_process');
32
+
33
+ /**
34
+ * Directory names that are never mirrored, even when tracked.
35
+ *
36
+ * A build artifact directory is machine state, not source. Copying it would
37
+ * make the mirror differ between machines for reasons no commit explains.
38
+ */
39
+ const SYNC_EXCLUDES = Object.freeze(['__pycache__', 'node_modules', '.git', '.DS_Store']);
40
+
41
+ /**
42
+ * The canonical -> mirror pairs this repository maintains for whole trees.
43
+ *
44
+ * Declared rather than discovered recursively: a glob that silently matches
45
+ * nothing would report "in sync" forever, which is the false green this whole
46
+ * task exists to remove.
47
+ *
48
+ * Deliberately NOT including `githooks` -> `src/npm-package/hooks`: that mapping
49
+ * is a curated *file list* (see `HOOK_MIRROR_FILES` in
50
+ * `scripts/check-hook-mirror.sh`), not the whole directory. `githooks/` also
51
+ * holds `__tests__/`, `AGENTS.md` and the adapters subtree, none of which ship.
52
+ * Mirroring the directory wholesale would copy 500+ files into the npm package —
53
+ * treating "canonical has it" as "the mirror should have it" where the project
54
+ * never said so.
55
+ *
56
+ * Per-pair `exclude` mirrors the guard scripts exactly. `src/principles` already
57
+ * excludes `__tests__` (check-hook-mirror.sh:98); a sync that copied them would
58
+ * ship test code in the package and make the guard fail immediately afterwards.
59
+ */
60
+ const KNOWN_PAIRS = Object.freeze([
61
+ { canonical: 'githooks/adapters', mirror: 'src/npm-package/adapters' },
62
+ { canonical: 'githooks/lib', mirror: 'src/npm-package/hooks/lib' },
63
+ { canonical: 'src/principles', mirror: 'src/npm-package/principles', exclude: ['__tests__'] },
64
+ { canonical: 'src/build-integrity', mirror: 'src/npm-package/build-integrity' },
65
+ { canonical: 'skills', mirror: 'src/npm-package/skills' },
66
+ ]);
67
+
68
+ /**
69
+ * List files under `dir` relative to `dir`, honouring exclusions.
70
+ *
71
+ * @param {string} root repository root
72
+ * @param {string} dir directory relative to root
73
+ * @returns {string[]} relative slash-separated paths
74
+ */
75
+ function listFiles(root, dir) {
76
+ const abs = path.join(root, dir);
77
+ const out = [];
78
+ const walk = (current, prefix) => {
79
+ let entries;
80
+ try {
81
+ entries = fs.readdirSync(current, { withFileTypes: true });
82
+ } catch {
83
+ return;
84
+ }
85
+ for (const entry of entries) {
86
+ if (SYNC_EXCLUDES.includes(entry.name)) continue;
87
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
88
+ if (entry.isDirectory()) {
89
+ walk(path.join(current, entry.name), rel);
90
+ } else if (entry.isFile()) {
91
+ out.push(rel);
92
+ }
93
+ }
94
+ };
95
+ walk(abs, '');
96
+ return out.sort();
97
+ }
98
+
99
+ /**
100
+ * List the files git tracks under `dir` that also exist on disk.
101
+ *
102
+ * The disk check matters: `git ls-files` reports the index, so a file deleted
103
+ * from the working tree (a mirror copy removed to simulate drift, or a fix in
104
+ * progress) is still listed. Reading it would throw ENOENT inside the diff.
105
+ *
106
+ * When `dir` is not inside a git repository (or git is unavailable), fall back to
107
+ * a plain walk — the caller is then responsible for the artifact caveat, but a
108
+ * check that crashes outside git is worse than one that reports what it sees.
109
+ *
110
+ * @param {string} root repository root
111
+ * @param {string} dir directory relative to root
112
+ * @returns {string[]}
113
+ */
114
+ function listTrackedFiles(root, dir) {
115
+ const normalised = dir.replace(/\\/g, '/');
116
+ let tracked;
117
+ try {
118
+ const out = execFileSync('git', ['ls-files', '-z', '--', dir], {
119
+ cwd: root,
120
+ encoding: 'utf8',
121
+ maxBuffer: 64 * 1024 * 1024,
122
+ });
123
+ tracked = out
124
+ .split('\0')
125
+ .filter(Boolean)
126
+ .map((p) => p.replace(/\\/g, '/'))
127
+ .filter((p) => p.startsWith(`${normalised}/`))
128
+ .map((p) => p.slice(normalised.length + 1))
129
+ .filter((p) => !p.split('/').some((seg) => SYNC_EXCLUDES.includes(seg)));
130
+ } catch {
131
+ return listFiles(root, dir);
132
+ }
133
+
134
+ // Keep only what is actually on disk; a tracked-but-deleted file must read as
135
+ // "missing from this side", which is what the caller then repairs.
136
+ return tracked
137
+ .filter((p) => fs.existsSync(path.join(root, dir, p)))
138
+ .sort();
139
+ }
140
+
141
+ /**
142
+ * Compare a canonical tree against its mirror.
143
+ *
144
+ * Both sides are compared on the tracked file set so an untracked artifact in
145
+ * either tree cannot be mistaken for drift.
146
+ *
147
+ * @param {string} root repository root
148
+ * @param {string} canonical canonical dir (relative)
149
+ * @param {string} mirror mirror dir (relative)
150
+ * @param {{exclude?: string[]}} [options] extra directory names to skip for this pair
151
+ * @returns {{inSync: boolean, missing: string[], differing: string[], extra: string[]}}
152
+ */
153
+ function diffTrees(root, canonical, mirror, options = {}) {
154
+ const exclude = options.exclude || [];
155
+ const keep = (rel) => !rel.split('/').some((seg) => exclude.includes(seg));
156
+ const canonicalFiles = listTrackedFiles(root, canonical).filter(keep);
157
+ const mirrorFiles = listTrackedFiles(root, mirror).filter(keep);
158
+ const mirrorSet = new Set(mirrorFiles);
159
+ const canonicalSet = new Set(canonicalFiles);
160
+
161
+ const missing = canonicalFiles.filter((f) => !mirrorSet.has(f));
162
+ const extra = mirrorFiles.filter((f) => !canonicalSet.has(f));
163
+ const differing = [];
164
+ for (const rel of canonicalFiles) {
165
+ if (!mirrorSet.has(rel)) continue;
166
+ const a = fs.readFileSync(path.join(root, canonical, rel));
167
+ const b = fs.readFileSync(path.join(root, mirror, rel));
168
+ if (!a.equals(b)) differing.push(rel);
169
+ }
170
+
171
+ return {
172
+ inSync: missing.length === 0 && differing.length === 0 && extra.length === 0,
173
+ missing,
174
+ differing,
175
+ extra,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Discover the known pairs that actually exist in this repository.
181
+ *
182
+ * A pair is only returned when both sides are present on disk: reporting a pair
183
+ * whose mirror does not exist yet would make `syncMirrors` create trees the
184
+ * project never asked for.
185
+ *
186
+ * @param {string} root repository root
187
+ * @returns {Array<{canonical: string, mirror: string}>}
188
+ */
189
+ function collectMirrorPairs(root) {
190
+ return KNOWN_PAIRS.filter(
191
+ (pair) =>
192
+ fs.existsSync(path.join(root, pair.canonical)) && fs.existsSync(path.join(root, pair.mirror))
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Copy one file, preserving its executable bit.
198
+ * @param {string} from
199
+ * @param {string} to
200
+ */
201
+ function copyFile(from, to) {
202
+ fs.mkdirSync(path.dirname(to), { recursive: true });
203
+ fs.copyFileSync(from, to);
204
+ // Hooks that lose +x stop running, and the failure looks like "the gate is
205
+ // silently skipped" rather than a permissions error.
206
+ try {
207
+ const mode = fs.statSync(from).mode;
208
+ fs.chmodSync(to, mode & 0o777);
209
+ } catch {
210
+ // chmod is a no-op on Windows; the content copy already succeeded.
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Bring mirrors into parity with their canonical trees.
216
+ *
217
+ * @param {string} root repository root
218
+ * @param {Array<{canonical: string, mirror: string}>} [pairs] defaults to the discoverable known pairs
219
+ * @param {{dryRun?: boolean, prune?: boolean}} [options]
220
+ * @returns {{copied: string[], cleaned: string[], pairs: number, dryRun: boolean}}
221
+ */
222
+ function syncMirrors(root, pairs, options = {}) {
223
+ const dryRun = options.dryRun === true;
224
+ const prune = options.prune !== false;
225
+ const selected = pairs || collectMirrorPairs(root);
226
+
227
+ const copied = [];
228
+ const cleaned = [];
229
+
230
+ for (const pair of selected) {
231
+ const canonicalAbs = path.join(root, pair.canonical);
232
+ if (!fs.existsSync(canonicalAbs) || !fs.statSync(canonicalAbs).isDirectory()) {
233
+ // Refusing beats guessing: treating a missing canonical tree as "empty"
234
+ // would mark every mirrored file an orphan and delete working code.
235
+ throw new Error(`canonical directory does not exist: ${pair.canonical}`);
236
+ }
237
+
238
+ const diff = diffTrees(root, pair.canonical, pair.mirror, { exclude: pair.exclude });
239
+ const toWrite = [...diff.missing, ...diff.differing];
240
+
241
+ for (const rel of toWrite) {
242
+ copied.push(rel);
243
+ if (dryRun) continue;
244
+ copyFile(path.join(root, pair.canonical, rel), path.join(root, pair.mirror, rel));
245
+ }
246
+
247
+ if (prune) {
248
+ for (const rel of diff.extra) {
249
+ cleaned.push(rel);
250
+ if (dryRun) continue;
251
+ try {
252
+ fs.rmSync(path.join(root, pair.mirror, rel));
253
+ } catch {
254
+ // A file that vanished between listing and prune is already the goal.
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ return { copied, cleaned, pairs: selected.length, dryRun };
261
+ }
262
+
263
+ /**
264
+ * Run the `sync-mirrors` subcommand.
265
+ *
266
+ * Defaults to a dry run. Writing is opt-in via `--write`, because the failure
267
+ * mode of a wrong pair declaration is silent mass overwrite — previewing costs
268
+ * one round trip and makes the destructive path a deliberate choice.
269
+ *
270
+ * `root` is a parameter rather than always `process.cwd()` so tests can point at
271
+ * a fixture; `process.chdir` is unavailable inside a vitest worker.
272
+ *
273
+ * @param {string[]} argv
274
+ * @param {{root?: string}} [options]
275
+ * @returns {number} exit code (0 when in sync / repaired; 1 when drift remains after a dry run)
276
+ */
277
+ function handleSyncMirrors(argv, options = {}) {
278
+ const args = argv || [];
279
+ const write = args.includes('--write');
280
+ const json = args.includes('--json');
281
+ const root = options.root || process.cwd();
282
+
283
+ const pairs = collectMirrorPairs(root);
284
+ if (pairs.length === 0) {
285
+ process.stdout.write('sync-mirrors: NOT MEASURABLE (no mirror pairs found — is this the xp-gate repo?)\n');
286
+ return 1;
287
+ }
288
+
289
+ const before = pairs.map((pair) => ({
290
+ pair: `${pair.canonical} -> ${pair.mirror}`,
291
+ diff: diffTrees(root, pair.canonical, pair.mirror, { exclude: pair.exclude }),
292
+ }));
293
+ const drifted = before.filter((entry) => !entry.diff.inSync);
294
+
295
+ const result = syncMirrors(root, pairs, { dryRun: !write });
296
+
297
+ if (json) {
298
+ process.stdout.write(
299
+ `${JSON.stringify({ root, write, driftedBefore: drifted.length, ...result }, null, 2)}\n`
300
+ );
301
+ } else {
302
+ for (const entry of before) {
303
+ const status = entry.diff.inSync ? 'IN SYNC' : 'DRIFT ';
304
+ process.stdout.write(` ${status} ${entry.pair}\n`);
305
+ if (!entry.diff.inSync) {
306
+ if (entry.diff.missing.length) process.stdout.write(` missing: ${entry.diff.missing.slice(0, 5).join(', ')}\n`);
307
+ if (entry.diff.differing.length) process.stdout.write(` differing: ${entry.diff.differing.slice(0, 5).join(', ')}\n`);
308
+ if (entry.diff.extra.length) process.stdout.write(` extra: ${entry.diff.extra.slice(0, 5).join(', ')}\n`);
309
+ }
310
+ }
311
+ if (result.copied.length === 0 && result.cleaned.length === 0) {
312
+ process.stdout.write('All mirrors in sync.\n');
313
+ return 0;
314
+ }
315
+ const verb = write ? 'Wrote' : 'Would write';
316
+ process.stdout.write(`${verb} ${result.copied.length} file(s), prune ${result.cleaned.length}.\n`);
317
+ if (!write) {
318
+ process.stdout.write('Re-run with --write to apply.\n');
319
+ return 1;
320
+ }
321
+ }
322
+
323
+ return write ? 0 : drifted.length > 0 ? 1 : 0;
324
+ }
325
+
326
+ module.exports = {
327
+ SYNC_EXCLUDES,
328
+ KNOWN_PAIRS,
329
+ listFiles,
330
+ listTrackedFiles,
331
+ diffTrees,
332
+ collectMirrorPairs,
333
+ syncMirrors,
334
+ handleSyncMirrors,
335
+ };