@boyingliu01/xp-gate 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/gate-3.sh +18 -1
  2. package/hooks/gate-3.sh +18 -1
  3. package/hooks/hook-module-resolution.sh +39 -0
  4. package/hooks/pre-commit +315 -38
  5. package/hooks/pre-push +26 -2
  6. package/lib/__tests__/doctor-module-resolution-parity.test.js +79 -11
  7. package/lib/__tests__/sprint-state-manager.test.js +31 -0
  8. package/lib/doctor.js +60 -27
  9. package/lib/hook-module-resolution.js +127 -0
  10. package/lib/mattpocock-license-verdict.json +1 -1
  11. package/lib/retro.js +24 -7
  12. package/lib/sprint-state-manager.js +14 -6
  13. package/mock-policy/AGENTS.md +3 -3
  14. package/mutation/AGENTS.md +3 -3
  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 +3 -3
  18. package/plugins/claude-code/skills/grill-with-docs/SKILL.md +1 -0
  19. package/plugins/claude-code/skills/grilling/SKILL.md +1 -0
  20. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/dsh/skills/grill-with-docs/SKILL.md +1 -0
  25. package/plugins/dsh/skills/grilling/SKILL.md +1 -0
  26. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  27. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  28. package/plugins/opencode/package.json +1 -1
  29. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  30. package/plugins/opencode/skills/grill-with-docs/SKILL.md +1 -0
  31. package/plugins/opencode/skills/grilling/SKILL.md +1 -0
  32. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/plugins/qoder/plugin.json +1 -1
  35. package/plugins/qoder/skills/batch-grill-me/SKILL.md +9 -2
  36. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/qoder/skills/grill-with-docs/SKILL.md +2 -2
  38. package/plugins/qoder/skills/grilling/SKILL.md +23 -5
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  41. package/principles/AGENTS.md +3 -3
  42. package/scripts/sync-package-content.js +3 -1
  43. package/skills/delphi-review/AGENTS.md +3 -3
  44. package/skills/grill-with-docs/SKILL.md +1 -0
  45. package/skills/grilling/SKILL.md +1 -0
  46. package/skills/sprint-flow/AGENTS.md +3 -3
  47. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -16,10 +16,20 @@
16
16
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { join, resolve } from 'node:path';
19
- import { spawnSync } from 'node:child_process';
19
+ import { spawnSync, execFileSync } from 'node:child_process';
20
20
  import { describe, expect, it } from 'vitest';
21
21
 
22
22
  const PRE_COMMIT = readFileSync(resolve(__dirname, '..', '..', '..', '..', 'githooks', 'pre-commit'), 'utf8');
23
+ // #505 replaced the two hand-written shell chains with one data file generated
24
+ // from lib/hook-module-resolution.js — the same module doctor.js requires. The
25
+ // parity test therefore verifies the NEW contract: the generated data is current,
26
+ // and the hook + doctor both honour it. The scenarios below still drive the hook's
27
+ // real text through bash, so agreement is measured, not asserted.
28
+ const RESOLUTION_DATA = readFileSync(
29
+ resolve(__dirname, '..', '..', '..', '..', 'githooks', 'hook-module-resolution.sh'),
30
+ 'utf8',
31
+ );
32
+ const { RESOLUTION_ORDER, GATE_RESOLUTION_ORDER, ADAPTER_DIR_ORDER } = require('../hook-module-resolution.js');
23
33
 
24
34
  /**
25
35
  * Extract one bash function's full text, brace-balanced from the declaration.
@@ -149,13 +159,28 @@ function hookResolvesAll(blocks, resolverText, t, lang, gateModule) {
149
159
  `GLOBAL_ADAPTER_DIR='${t.globalAdapters}'`,
150
160
  `PROJECT_GITHOOKS='${t.projectGithooks}'`,
151
161
  `SCRIPT_DIR='${t.scriptDir}'`,
152
- blocks.adapterDir,
162
+ `ADAPTER_DIR=''`,
163
+ resolverText,
153
164
  blocks.gateDir,
165
+ // The real chains, driven by the same data the hook loads.
166
+ '_hook_adapter_dir() {',
167
+ ' local entry base suffix base_dir',
168
+ ' eval "set -- \\"\\${HOOK_ADAPTER_DIR_TIERS[@]}\\""',
169
+ ' for entry in "$@"; do',
170
+ ' entry="${entry#*|}"; base="${entry%%|*}"; suffix="${entry#*|}"',
171
+ ' base_dir="$(_hook_tier_base "$base")"',
172
+ ' [ -n "$base_dir" ] || continue',
173
+ ' if [ -f "$base_dir/$suffix" ]; then printf "%s" "$base_dir"; return 0; fi',
174
+ ' done',
175
+ ' return 1',
176
+ '}',
177
+ 'ADAPTER_DIR="$(_hook_adapter_dir)" || ADAPTER_DIR="$SCRIPT_DIR"',
178
+ 'GATE_DIR="$(_hook_resolve_dir "$HOOK_GATE_PROBE" HOOK_GATE_TIERS)" || GATE_DIR=""',
179
+ 'if [ -z "$GATE_DIR" ]; then GATE_DIR="$ADAPTER_DIR"; fi',
154
180
  'echo "ADAPTER=$ADAPTER_DIR"',
155
181
  'echo "GATE=$GATE_DIR"',
156
182
  `if [ -f "$ADAPTER_DIR/adapter-common.sh" ]; then echo "COMMON=$ADAPTER_DIR/adapter-common.sh"; else echo "COMMON="; fi`,
157
183
  `if [ -f "$GATE_DIR/${gateModule}" ]; then echo "MODULE=$GATE_DIR/${gateModule}"; else echo "MODULE="; fi`,
158
- resolverText,
159
184
  // Captured by substitution rather than read off the last stdout line: the
160
185
  // script ends with a newline, so "last line" is always the empty one.
161
186
  `echo "LANG=$(resolve_adapter_path ${lang} || true)"`,
@@ -194,14 +219,53 @@ function hookResolves(fnText, tree, lang) {
194
219
  }
195
220
 
196
221
  describe('doctor module resolution parity with pre-commit (#495)', () => {
197
- const resolverText = extractFunction(PRE_COMMIT, 'resolve_adapter_path() {');
198
- // The two tier chains the hook runs before it ever calls the resolver. Read
199
- // from the hook's text, not restated here: #495 Round 2's project-tier label
200
- // was wrong precisely because doctor carried a second, hand-written tier list.
222
+ // #505: the resolver helpers come from the hook, the tier DATA from the
223
+ // generated file. Concatenating them is what the hook itself does, so the
224
+ // bash side here is the production code path, not a paraphrase of it.
225
+ const helpers = [
226
+ extractFunction(PRE_COMMIT, '_hook_tier_base() {'),
227
+ extractFunction(PRE_COMMIT, '_hook_resolve() {'),
228
+ extractFunction(PRE_COMMIT, '_hook_resolve_dir() {'),
229
+ extractFunction(PRE_COMMIT, 'resolve_adapter_path() {'),
230
+ ].join('\n');
231
+ // `blocks` keeps the names the scenarios use: the data file supplies the
232
+ // ADAPTER_DIR/GATE_DIR chains that used to be matched out of the hook text.
201
233
  const blocks = {
202
- adapterDir: matchBlock(PRE_COMMIT, /if \[ -f "\$GLOBAL_ADAPTER_DIR\/adapter-common\.sh" \]; then[\s\S]*?\nfi/, 'the ADAPTER_DIR tier chain'),
203
- gateDir: matchBlock(PRE_COMMIT, /if \[ -f "\$ADAPTER_DIR\/gate-3\.sh" \]; then[\s\S]*?\nfi/, 'the GATE_DIR tier chain'),
234
+ adapterDir: '',
235
+ gateDir: '',
204
236
  };
237
+ const resolverText = `${RESOLUTION_DATA}\n${helpers}`;
238
+
239
+ it('AC-505-01: the generated shell data is current with its JS source', () => {
240
+ // Stale data would silently run a different order than doctor reports — the
241
+ // exact failure mode #505 exists to make impossible.
242
+ expect(() => execFileSync(
243
+ process.execPath,
244
+ [resolve(__dirname, '..', '..', '..', '..', 'scripts', 'generate-hook-resolution.cjs'), '--check'],
245
+ { stdio: 'pipe' },
246
+ )).not.toThrow();
247
+ });
248
+
249
+ it('AC-505-02: the hook loads the generated data instead of carrying its own order', () => {
250
+ // Anti-vacuity for the whole feature: if the hook re-grew a literal cascade,
251
+ // the single-source-of-truth claim would be false even while scenarios pass.
252
+ expect(PRE_COMMIT).toContain('hook-module-resolution.sh');
253
+ expect(PRE_COMMIT).toContain('HOOK_ADAPTER_TIERS');
254
+ expect(PRE_COMMIT).toContain('HOOK_GATE_TIERS');
255
+ // The old hand-written chains must be gone.
256
+ expect(PRE_COMMIT).not.toContain('$ADAPTER_DIR/${lang}.sh');
257
+ expect(PRE_COMMIT).not.toMatch(/if \[ -f "\$ADAPTER_DIR\/gate-3\.sh" \]; then/);
258
+ // And every tier the JS declares must appear in the emitted shell data.
259
+ for (const t of RESOLUTION_ORDER) {
260
+ expect(RESOLUTION_DATA).toContain(`${t.tier_id}|${t.base}|${t.template}`);
261
+ }
262
+ for (const t of GATE_RESOLUTION_ORDER) {
263
+ expect(RESOLUTION_DATA).toContain(`'${t.tier_id}|${t.base}|'`);
264
+ }
265
+ for (const t of ADAPTER_DIR_ORDER) {
266
+ expect(RESOLUTION_DATA).toContain(`${t.tier_id}|${t.base}|${t.probe}`);
267
+ }
268
+ });
205
269
 
206
270
  it('AC-495-11: flat and nested present -> both languages pick the flat copy', () => {
207
271
  const { listExecutedModules } = require('../doctor');
@@ -241,8 +305,12 @@ describe('doctor module resolution parity with pre-commit (#495)', () => {
241
305
  }
242
306
 
243
307
  // Anti-vacuity: the guard executed the hook's real text, not a paraphrase.
244
- expect(resolverText).toMatch(/\$ADAPTER_DIR\/\$\{lang\}\.sh/);
245
- expect(resolverText).toMatch(/\$ADAPTER_DIR\/adapters\/\$\{lang\}\.sh/);
308
+ // #505 moved the literal paths out of the hook, so assert that the tiers it
309
+ // now reads still describe the two layouts those paths used to spell out.
310
+ expect(resolverText).toContain('HOOK_ADAPTER_TIERS');
311
+ const adapterTemplates = RESOLUTION_ORDER.map((t) => t.template);
312
+ expect(adapterTemplates).toContain('{name}.sh');
313
+ expect(adapterTemplates).toContain('adapters/{name}.sh');
246
314
  });
247
315
 
248
316
  // The scenarios below put a copy of the same module in EVERY tier the hook can
@@ -143,6 +143,37 @@ describe('SprintStateManager', () => {
143
143
  expect(result.phase_history[0].duration_seconds).toBeGreaterThanOrEqual(0);
144
144
  });
145
145
 
146
+ it('stamps a duration when a phase goes straight from pending to completed', () => {
147
+ // Regression: `xp-gate phase-transition N completed --render` never emits
148
+ // an in_progress step, so gating started_at on in_progress left
149
+ // duration_seconds permanently null — the blank duration column on the
150
+ // status panel. Observed on sprint-20261019-47 phases 2-6.
151
+ const initialState = createInitialState(tmpDir);
152
+ manager.write(initialState);
153
+
154
+ const result = manager.transitionPhase(2, 'completed');
155
+
156
+ expect(result.phase_history[0].status).toBe('completed');
157
+ expect(result.phase_history[0].started_at).toBeDefined();
158
+ expect(result.phase_history[0].completed_at).toBeDefined();
159
+ expect(result.phase_history[0].duration_seconds).not.toBeNull();
160
+ expect(result.phase_history[0].duration_seconds).toBeGreaterThanOrEqual(0);
161
+ expect(Number.isNaN(result.phase_history[0].duration_seconds)).toBe(false);
162
+ });
163
+
164
+ it('never writes a NaN duration, even for an unparseable started_at', () => {
165
+ const state = createInitialState(tmpDir);
166
+ state.phase_history = [
167
+ { phase: 2, phase_name: 'DESIGN', status: 'pending', started_at: 'not-a-date' },
168
+ ];
169
+ manager.write(state);
170
+
171
+ const result = manager.transitionPhase(2, 'completed');
172
+
173
+ expect(Number.isNaN(result.phase_history[0].duration_seconds)).toBe(false);
174
+ expect(result.phase_history[0].duration_seconds).toBe(0);
175
+ });
176
+
146
177
  it('calls onTransition callback', () => {
147
178
  const initialState = createInitialState(tmpDir);
148
179
  manager.write(initialState);
package/lib/doctor.js CHANGED
@@ -15,6 +15,9 @@ const {
15
15
  } = require('./shared-paths.js');
16
16
  const { checkUpgrade, formatUpgradeMsg } = require('./check-version.js');
17
17
  const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('./detect-deps.js');
18
+ // #505: the single source of truth for module resolution order. The hook reads a
19
+ // generated shell projection of this same module.
20
+ const { RESOLUTION_ORDER } = require('./hook-module-resolution.js');
18
21
  const {
19
22
  diagnoseTuiRegistration, diagnoseInstalledSkills,
20
23
  fixTuiRegistration, ensureTuiRegistration, readTuiJson,
@@ -398,6 +401,16 @@ function pathsEquivalent(a, b) {
398
401
  */
399
402
  const DRIFT_COMPARED_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
400
403
 
404
+ /**
405
+ * Non-hook files that must sit beside the installed hooks.
406
+ *
407
+ * These are not Git entry points, so they are not in DRIFT_COMPARED_HOOKS, but
408
+ * the hooks source them and die without them:
409
+ * - hook-module-resolution.sh (#505): pre-commit aborts FATAL when the module
410
+ * resolution order is missing. It is generated from lib/hook-module-resolution.js.
411
+ */
412
+ const HOOK_SUPPORT_FILES = ['hook-module-resolution.sh'];
413
+
401
414
  /**
402
415
  * Compare the hooks that actually execute (the installed copy under
403
416
  * core.hooksPath) against the repository's source-of-truth `githooks/` (#451).
@@ -546,35 +559,35 @@ function listExecutedModules(installedAdapterDir) {
546
559
  const found = [];
547
560
  const seen = new Set();
548
561
 
549
- let entries;
550
- try {
551
- entries = fs.readdirSync(installedAdapterDir, { withFileTypes: true });
552
- } catch {
553
- return found;
554
- }
562
+ // #505: the tier order is DATA, not a second implementation. The hook reads a
563
+ // generated shell projection of the same module; both reference
564
+ // RESOLUTION_ORDER, so "which copy executes" has one answer. Previously this
565
+ // function re-derived the order by hand and a parity test was the only thing
566
+ // keeping it honest.
567
+ //
568
+ // Tiers are walked in order; a name is reported once, from the first tier that
569
+ // resolves it — matching the hook, which stops at the first existing file.
570
+ for (const tier of RESOLUTION_ORDER) {
571
+ // Only the two ADAPTER_DIR-rooted tiers describe the installed adapter dir.
572
+ if (tier.base !== 'ADAPTER_DIR') continue;
573
+ // `{name}.sh` (flat) -> the dir itself; `adapters/{name}.sh` -> <dir>/adapters.
574
+ const dir = tier.template.includes('/')
575
+ ? path.join(installedAdapterDir, tier.template.split('/{name}.sh')[0])
576
+ : installedAdapterDir;
555
577
 
556
- for (const entry of entries) {
557
- if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
558
- const file = path.join(installedAdapterDir, entry.name);
559
- if (seen.has(entry.name)) continue;
560
- seen.add(entry.name);
561
- found.push({ name: entry.name, file });
562
- }
578
+ let entries;
579
+ try {
580
+ entries = fs.readdirSync(dir, { withFileTypes: true });
581
+ } catch {
582
+ continue;
583
+ }
563
584
 
564
- // Nested copies only count for names with no flat counterpart, because that
565
- // is exactly the fallback resolve_adapter_path() takes.
566
- const nestedDir = path.join(installedAdapterDir, 'adapters');
567
- let nestedEntries = [];
568
- try {
569
- nestedEntries = fs.readdirSync(nestedDir, { withFileTypes: true });
570
- } catch {
571
- nestedEntries = [];
572
- }
573
- for (const entry of nestedEntries) {
574
- if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
575
- if (seen.has(entry.name)) continue;
576
- seen.add(entry.name);
577
- found.push({ name: entry.name, file: path.join(nestedDir, entry.name) });
585
+ for (const entry of entries) {
586
+ if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
587
+ if (seen.has(entry.name)) continue;
588
+ seen.add(entry.name);
589
+ found.push({ name: entry.name, file: path.join(dir, entry.name) });
590
+ }
578
591
  }
579
592
 
580
593
  return found;
@@ -1153,6 +1166,26 @@ function syncGlobalHooksFromRepo(ctx = {}) {
1153
1166
  }
1154
1167
  }
1155
1168
 
1169
+ // Hook support files (#505): sourced by the hooks but not Git entry points, so
1170
+ // they need the same copy treatment or an upgraded install dies at the source
1171
+ // line. Treated like the hooks themselves (always refreshed, no refusal) —
1172
+ // they hold no machine-local content.
1173
+ for (const name of HOOK_SUPPORT_FILES) {
1174
+ const source = path.join(repoHooks, name);
1175
+ if (!fs.existsSync(source)) continue;
1176
+ const target = path.join(installedHooks, name);
1177
+ try {
1178
+ if (fs.existsSync(target) && filesAreIdentical(source, target)) {
1179
+ skipped.push(name);
1180
+ continue;
1181
+ }
1182
+ fs.copyFileSync(source, target);
1183
+ synced.push(name);
1184
+ } catch (err) {
1185
+ errors.push(`${name}: ${err.message}`);
1186
+ }
1187
+ }
1188
+
1156
1189
  // lib/ shared libraries: pre-commit sources lib/*.sh, so a synced hook file
1157
1190
  // without its libraries dies at the source line on the machine (#488).
1158
1191
  const repoLib = path.join(repoHooks, 'lib');
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Single source of truth for hook module resolution order (#505).
4
+ *
5
+ * THE PROBLEM THIS SOLVES
6
+ * The order in which a hook finds its modules (adapter-common.sh, gate-*.sh,
7
+ * lib/*.sh, language adapters) used to be written out twice: once in shell
8
+ * (`resolve_adapter_path()` and the `GATE_DIR` cascade in githooks/pre-commit)
9
+ * and once in JavaScript (`listExecutedModules()` in lib/doctor.js). The two must
10
+ * agree or doctor reports a different module surface than the one that actually
11
+ * executes — exactly the divergence behind #495/#500. A parity test pinned the
12
+ * behaviour, but a test only fails AFTER someone writes the wrong thing; the
13
+ * order itself was still duplicated.
14
+ *
15
+ * THE FIX
16
+ * The order lives here, in RESOLUTION_ORDER, as data. Both sides read it:
17
+ * - JS: require('./hook-module-resolution.js') (doctor.js)
18
+ * - SH: the hook sources hook-module-resolution.sh, GENERATED from this file
19
+ * by `node scripts/generate-hook-resolution.sh` (run by sync-package-content).
20
+ * A change to the order reaches the shell automatically; drifting requires
21
+ * deliberately ignoring the generator.
22
+ *
23
+ * SCOPE NOTE: this is the ADAPTER/GATE lookup order only. It does not change
24
+ * which files are installed or how they are copied.
25
+ */
26
+
27
+ /**
28
+ * Ordered resolution tiers. The first tier that contains the requested file wins.
29
+ *
30
+ * `base` names which directory variable supplies the root; `template` is the path
31
+ * relative to it, with `{name}` standing for the module basename.
32
+ *
33
+ * `tier_id` is stable and is what shell and JS compare on, so both can report the
34
+ * same tier for the same file without string-matching paths.
35
+ */
36
+ const RESOLUTION_ORDER = Object.freeze([
37
+ Object.freeze({
38
+ tier_id: 'adapter_flat',
39
+ base: 'ADAPTER_DIR',
40
+ template: '{name}.sh',
41
+ comment: 'Global install layout: modules sit flat next to adapter-common.sh',
42
+ }),
43
+ Object.freeze({
44
+ tier_id: 'adapter_nested',
45
+ base: 'ADAPTER_DIR',
46
+ template: 'adapters/{name}.sh',
47
+ comment: 'Nested layout under the adapter dir',
48
+ }),
49
+ Object.freeze({
50
+ tier_id: 'project_nested',
51
+ base: 'PROJECT_GITHOOKS',
52
+ template: 'adapters/{name}.sh',
53
+ comment: 'Project githooks adapters/',
54
+ }),
55
+ Object.freeze({
56
+ tier_id: 'script_nested',
57
+ base: 'SCRIPT_DIR',
58
+ template: 'adapters/{name}.sh',
59
+ comment: 'Hook directory fallback',
60
+ }),
61
+ ]);
62
+
63
+ /**
64
+ * Order used when resolving GATE scripts. Gates are looked up flat first
65
+ * (co-located with adapter-common.sh), then in the project githooks root.
66
+ */
67
+ const GATE_RESOLUTION_ORDER = Object.freeze([
68
+ Object.freeze({
69
+ tier_id: 'gate_adapter_dir',
70
+ base: 'ADAPTER_DIR',
71
+ template: '{name}',
72
+ comment: 'Gates co-located with adapter-common.sh',
73
+ }),
74
+ Object.freeze({
75
+ tier_id: 'gate_project',
76
+ base: 'PROJECT_GITHOOKS',
77
+ template: '{name}',
78
+ comment: 'Project githooks/',
79
+ }),
80
+ Object.freeze({
81
+ tier_id: 'gate_script_dir',
82
+ base: 'SCRIPT_DIR',
83
+ template: '{name}',
84
+ comment: 'Hook directory fallback',
85
+ }),
86
+ ]);
87
+
88
+ /**
89
+ * Order in which the hook picks the directory that holds adapter-common.sh
90
+ * (ADAPTER_DIR). Same reasoning as RESOLUTION_ORDER: this cascade decides which
91
+ * tree every later lookup searches, so it must have one definition.
92
+ *
93
+ * `probe` is the file whose presence in `base` makes that tier win.
94
+ */
95
+ const ADAPTER_DIR_ORDER = Object.freeze([
96
+ Object.freeze({
97
+ tier_id: 'global',
98
+ base: 'GLOBAL_ADAPTER_DIR',
99
+ probe: 'adapter-common.sh',
100
+ comment: '~/.config/xp-gate/adapters — global install',
101
+ }),
102
+ Object.freeze({
103
+ tier_id: 'project',
104
+ base: 'PROJECT_GITHOOKS',
105
+ probe: 'adapter-common.sh',
106
+ comment: '<repo>/githooks — per-project init',
107
+ }),
108
+ Object.freeze({
109
+ tier_id: 'script',
110
+ base: 'SCRIPT_DIR',
111
+ probe: 'adapter-common.sh',
112
+ comment: 'Hook directory fallback',
113
+ }),
114
+ ]);
115
+
116
+ /**
117
+ * The probe file used to decide which tier supplies gate scripts. It is the
118
+ * first gate, so it is present wherever the gate suite is installed.
119
+ */
120
+ const GATE_PROBE = 'gate-3.sh';
121
+
122
+ module.exports = {
123
+ RESOLUTION_ORDER,
124
+ GATE_RESOLUTION_ORDER,
125
+ ADAPTER_DIR_ORDER,
126
+ GATE_PROBE,
127
+ };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-19T12:54:56.485Z",
5
+ "checked_at": "2026-10-20T10:37:54.490Z",
6
6
  "source": "network"
7
7
  }
package/lib/retro.js CHANGED
@@ -23,6 +23,29 @@ const { execSync } = require('child_process');
23
23
  */
24
24
  const MAX_SANE_DURATION_MS = 2 * 60 * 60 * 1000;
25
25
 
26
+ /**
27
+ * Whether an audit entry's duration_ms is a trustworthy sample.
28
+ *
29
+ * `e.duration_ms || 0` used to fold null into ZERO, which both dragged the mean
30
+ * down and hid that the sample was missing — the same class of lie as the
31
+ * epoch-sized values that started this line of work.
32
+ *
33
+ * BOTH signals must be honoured, not just one:
34
+ * - duration_anomaly: entries written before #475 carry an explicit flag.
35
+ * Dropping this check silently re-admitted every legacy poisoned sample, and
36
+ * the fixture average came out 33433ms instead of 150ms.
37
+ * - non-finite / out-of-range: a poisoned start now writes null or a
38
+ * nonsensical value outright, so those are excluded too.
39
+ *
40
+ * @param {{duration_ms?: number|null, duration_anomaly?: boolean}} entry
41
+ * @returns {boolean}
42
+ */
43
+ function isUsableDuration(entry) {
44
+ if (entry.duration_anomaly) return false;
45
+ const ms = entry.duration_ms;
46
+ return Number.isFinite(ms) && ms >= 0 && ms <= MAX_SANE_DURATION_MS;
47
+ }
48
+
26
49
  /**
27
50
  * Get git commit subjects within a date window.
28
51
  * @param {string} dir - Repository root
@@ -113,13 +136,7 @@ function buildReport(opts) {
113
136
  L.push('## Activity Summary');
114
137
  const gateEntries = audit.filter(e => e.gate_id);
115
138
  const passed = gateEntries.filter(e => e.passed).length;
116
- // REQ-B: a duration is only usable when it is a finite non-negative number.
117
- // `e.duration_ms || 0` used to fold null into ZERO, which both dragged the
118
- // mean down and hid the fact that the sample was missing — the same class of
119
- // lie as the epoch-sized values this sprint removed. The legacy
120
- // duration_anomaly flag is no longer the only signal: a poisoned start now
121
- // writes null outright, so anything null must be excluded and counted.
122
- const measurable = gateEntries.filter(e => Number.isFinite(e.duration_ms) && e.duration_ms >= 0 && e.duration_ms <= MAX_SANE_DURATION_MS);
139
+ const measurable = gateEntries.filter(isUsableDuration);
123
140
  const excluded = gateEntries.length - measurable.length;
124
141
  const nullDurations = gateEntries.filter(e => e.duration_ms === null).length;
125
142
  const avgMs = measurable.length > 0
@@ -30,16 +30,24 @@ function applyHistoryEntry(state, phase, phaseName, status) {
30
30
  }
31
31
 
32
32
  entry.status = status;
33
- if (status === 'in_progress' && !entry.started_at) {
33
+ // Stamp started_at on FIRST sight of any status, not only on `in_progress`.
34
+ // Phases are routinely moved straight from pending to completed (the `--render`
35
+ // path, and any sprint where the agent does not explicitly mark in_progress),
36
+ // so gating this on `in_progress` left started_at unset and duration_seconds
37
+ // permanently null — the empty-duration column on the status panel.
38
+ if (!entry.started_at) {
34
39
  entry.started_at = new Date().toISOString();
35
40
  }
36
41
  if (status === 'completed' || status === 'skipped') {
37
42
  entry.completed_at = new Date().toISOString();
38
- if (entry.started_at) {
39
- const startMs = new Date(entry.started_at).getTime();
40
- const endMs = new Date(entry.completed_at).getTime();
41
- entry.duration_seconds = Math.round((endMs - startMs) / 1000);
42
- }
43
+ const startMs = new Date(entry.started_at).getTime();
44
+ const endMs = new Date(entry.completed_at).getTime();
45
+ // Never emit NaN/negative: a bad or future started_at degrades to 0, which
46
+ // the audit treats as "unknown" rather than inventing a number.
47
+ const elapsed = endMs - startMs;
48
+ entry.duration_seconds = Number.isFinite(elapsed) && elapsed >= 0
49
+ ? Math.round(elapsed / 1000)
50
+ : 0;
43
51
  }
44
52
  return entry;
45
53
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: grill-with-docs
3
+ version: 1.1.0
3
4
  description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
4
5
  disable-model-invocation: true
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: grilling
3
+ version: 1.1.0
3
4
  description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
4
5
  ---
5
6
 
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/dsh-plugin-xp-gate",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
4
4
  "description": "XP-Gate deterministic quality gates + AI workflow skills as a DeepSeek Harness (DSH) native plugin: gate-check / gate-principles / gate-arch tools + 12 SKILL.md skills",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: grill-with-docs
3
+ version: 1.1.0
3
4
  description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
4
5
  disable-model-invocation: true
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: grilling
3
+ version: 1.1.0
3
4
  description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
4
5
  ---
5
6
 
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-19
4
- **Commit:** 9b545de5
3
+ **Generated:** 2026-10-20
4
+ **Commit:** 81dd7464
5
5
  **Branch:** main
6
- **Version:** 0.28.1.0
6
+ **Version:** 0.30.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.