@boyingliu01/xp-gate 0.30.2 → 0.30.3

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 (45) hide show
  1. package/adapters/iac.sh +45 -4
  2. package/adapters/java.sh +82 -16
  3. package/adapters/powershell.sh +70 -9
  4. package/gate-10.sh +32 -2
  5. package/gate-3.sh +17 -0
  6. package/gate-4.sh +12 -0
  7. package/gate-7.sh +4 -0
  8. package/hooks/gate-10.sh +32 -2
  9. package/hooks/gate-3.sh +17 -0
  10. package/hooks/gate-4.sh +12 -0
  11. package/hooks/gate-7.sh +4 -0
  12. package/hooks/hook-module-resolution.sh +14 -0
  13. package/hooks/lib/coverage-total.sh +1 -1
  14. package/hooks/pre-commit +138 -17
  15. package/hooks/pre-push +97 -6
  16. package/hooks/sprint-gate.sh +81 -2
  17. package/lib/__tests__/hook-health.test.js +85 -0
  18. package/lib/coverage-total.sh +92 -0
  19. package/lib/doctor.js +44 -0
  20. package/lib/hook-health.js +121 -0
  21. package/lib/mattpocock-license-verdict.json +1 -1
  22. package/mock-policy/AGENTS.md +2 -2
  23. package/mutation/AGENTS.md +2 -2
  24. package/package.json +1 -1
  25. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  26. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  27. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  28. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/dsh/package.json +1 -1
  30. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/plugins/opencode/package.json +1 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  35. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  36. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  37. package/plugins/qoder/plugin.json +1 -1
  38. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  41. package/principles/AGENTS.md +2 -2
  42. package/skills/delphi-review/AGENTS.md +2 -2
  43. package/skills/sprint-flow/AGENTS.md +2 -2
  44. package/skills/test-specification-alignment/AGENTS.md +2 -2
  45. package/sprint-gate.sh +81 -2
@@ -181,6 +181,65 @@ read_verdict() {
181
181
  json_extract "$APPROVED_FILE" '.verdict // ""' || echo ""
182
182
  }
183
183
 
184
+ # --- Helper: sprint age in days, or empty if unknown ---
185
+ # Reads status + started_at from sprint-state.json. #551: the gate judged only
186
+ # `phase` and the delphi verdict, so a state file left at status=in_progress
187
+ # reported "sprint phase 6 validated" on unrelated commits for as long as
188
+ # nobody fixed it. An advancing sprint and an abandoned one were
189
+ # indistinguishable -- worse than SKIP, because SKIP is honest and PASS lies.
190
+ read_sprint_status() {
191
+ if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
192
+ echo ""
193
+ return
194
+ fi
195
+ json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
196
+ json_extract "$SPRINT_STATE_JSON" '.status // ""' 2>/dev/null || echo ""
197
+ }
198
+
199
+ read_sprint_age_days() {
200
+ if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
201
+ echo ""
202
+ return
203
+ fi
204
+ json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
205
+ local started
206
+ started=$(json_extract "$SPRINT_STATE_JSON" '.started_at // ""' 2>/dev/null || echo "")
207
+ # Older state files may carry updated_at only; either is the last sign of life.
208
+ if [ -z "$started" ] || [ "$started" = "null" ]; then
209
+ started=$(json_extract "$SPRINT_STATE_JSON" '.updated_at // ""' 2>/dev/null || echo "")
210
+ fi
211
+ if [ -z "$started" ] || [ "$started" = "null" ]; then
212
+ echo ""
213
+ return
214
+ fi
215
+ # Portable ISO-8601 -> epoch. GNU date first, BSD date second, node last; if
216
+ # all fail the age is unknown and the caller must not invent one.
217
+ local epoch=""
218
+ epoch=$(date -u -d "$started" +%s 2>/dev/null || true)
219
+ if [ -z "$epoch" ]; then
220
+ epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$started" +%s 2>/dev/null || true)
221
+ fi
222
+ if [ -z "$epoch" ] && command -v node >/dev/null 2>&1; then
223
+ epoch=$(node -e "const t=Date.parse(process.argv[1]);if(Number.isFinite(t))process.stdout.write(String(Math.floor(t/1000)))" "$started" 2>/dev/null || true)
224
+ fi
225
+ if [ -z "$epoch" ]; then
226
+ echo ""
227
+ return
228
+ fi
229
+ local now
230
+ now=$(date -u +%s)
231
+ local age=$(( (now - epoch) / 86400 ))
232
+ [ "$age" -lt 0 ] && age=0
233
+ echo "$age"
234
+ }
235
+
236
+ # Threshold in days beyond which an in_progress sprint is considered abandoned.
237
+ # Configurable because the right number is project-specific: a weekly cadence
238
+ # wants 14, a monthly one wants more. Default 14 = two full weeks without
239
+ # progress: enough that a sprint paused over a holiday is not called dead, tight
240
+ # enough that the reported 12-day case is caught within two days of it firing.
241
+ SPRINT_STALE_DAYS="${SPRINT_STALE_DAYS:-14}"
242
+
184
243
  # --- Pre-commit mode ---
185
244
  if [ "$MODE" = "pre-commit" ]; then
186
245
  PHASE=$(read_phase)
@@ -222,8 +281,28 @@ if [ "$MODE" = "pre-commit" ]; then
222
281
  exit 1
223
282
  fi
224
283
 
225
- # All checks passed
226
- echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated\"}"
284
+ # #551: staleness. A sprint left at status=in_progress past the threshold is
285
+ # abandoned, not validated -- reporting PASS for it means the gate cannot
286
+ # distinguish an advancing sprint from a forgotten one, and it injects
287
+ # ancient phase_history into every unrelated commit's audit trail. SKIP is
288
+ # the honest answer: the gate declines to certify a sprint nobody is
289
+ # driving, without blocking work that has nothing to do with it.
290
+ SPRINT_STATUS=$(read_sprint_status)
291
+ SPRINT_AGE_DAYS=$(read_sprint_age_days)
292
+ if [ "$SPRINT_STATUS" = "in_progress" ] && [ -n "$SPRINT_AGE_DAYS" ] \
293
+ && [ "$SPRINT_AGE_DAYS" -ge "$SPRINT_STALE_DAYS" ] 2>/dev/null; then
294
+ echo "{\"decision\":\"skip\",\"reason\":\"STALE SPRINT: status=in_progress for ${SPRINT_AGE_DAYS} days (threshold ${SPRINT_STALE_DAYS}) — not certifying it. Finish it (sprint-flow close) or clear .sprint-state/sprint-state.json\"}"
295
+ exit 0
296
+ fi
297
+
298
+ # All checks passed. Name the age so "validated" is not an unconditional
299
+ # claim: a 29-day-old sprint under a 30-day threshold is a very different
300
+ # situation from one opened today, and the log should say which it is.
301
+ if [ -n "$SPRINT_AGE_DAYS" ]; then
302
+ echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint open ${SPRINT_AGE_DAYS} day(s), staleness threshold ${SPRINT_STALE_DAYS})\"}"
303
+ else
304
+ echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint age unknown — started_at unreadable)\"}"
305
+ fi
227
306
  exit 0
228
307
  fi
229
308
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @test REQ-546 hook health: line endings and bash parseability
3
+ * @intent Verify doctor can tell a hook that merely EXISTS from a hook git can
4
+ * actually run. #546: a pre-commit rewritten with CRLF line endings
5
+ * failed every commit while `xp-gate doctor` reported it healthy,
6
+ * because the check only asked whether the file was there.
7
+ * @covers AC-05, AC-08
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const { inspectHookFile } = require('../hook-health');
14
+
15
+ let TMP_DIR;
16
+
17
+ beforeAll(() => {
18
+ TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-health-'));
19
+ });
20
+
21
+ afterAll(() => {
22
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
23
+ });
24
+
25
+ function write(name, content) {
26
+ const p = path.join(TMP_DIR, name);
27
+ fs.writeFileSync(p, content);
28
+ return p;
29
+ }
30
+
31
+ describe('inspectHookFile', () => {
32
+ test('a healthy LF hook reports ok', () => {
33
+ const p = write('good.sh', '#!/bin/bash\nfoo() {\n echo hi\n}\nfoo\n');
34
+ const r = inspectHookFile(p);
35
+ expect(r.ok).toBe(true);
36
+ expect(r.crlfLines).toBe(0);
37
+ expect(r.problems).toEqual([]);
38
+ });
39
+
40
+ test('an all-CRLF hook is reported with a CRLF count', () => {
41
+ const p = write('crlf.sh', '#!/bin/bash\r\nfoo() {\r\n echo hi\r\n}\r\nfoo\r\n');
42
+ const r = inspectHookFile(p);
43
+ expect(r.ok).toBe(false);
44
+ expect(r.crlfLines).toBe(5);
45
+ expect(r.problems.join(' ')).toMatch(/CRLF/);
46
+ });
47
+
48
+ test('a single stray CRLF is enough to fail', () => {
49
+ // One bad line is all bash needs to choke on, and it is the case an
50
+ // "is the file mostly fine" heuristic would wave through.
51
+ const p = write('one.sh', '#!/bin/bash\necho one\necho two\r\necho three\n');
52
+ const r = inspectHookFile(p);
53
+ expect(r.ok).toBe(false);
54
+ expect(r.crlfLines).toBe(1);
55
+ });
56
+
57
+ test('CRLF in the middle of a multi-line construct is flagged', () => {
58
+ // The exact shape from #546: the \r lands inside a construct bash is
59
+ // mid-way through parsing, so the reported line number drifts far from
60
+ // the real cause.
61
+ const p = write('mid.sh', 'case "$x" in\r\n a) echo a ;;\r\nesac\n');
62
+ const r = inspectHookFile(p);
63
+ expect(r.ok).toBe(false);
64
+ expect(r.crlfLines).toBe(2);
65
+ });
66
+
67
+ test('a syntax error with clean line endings is reported as unparseable', () => {
68
+ const p = write('bad.sh', '#!/bin/bash\nif [ 1 -eq 1 ]; then\n echo x\n');
69
+ const r = inspectHookFile(p);
70
+ expect(r.ok).toBe(false);
71
+ expect(r.problems.join(' ')).toMatch(/not parseable|syntax/i);
72
+ });
73
+
74
+ test('a binary-looking file does not crash the inspector', () => {
75
+ const p = path.join(TMP_DIR, 'bin.sh');
76
+ fs.writeFileSync(p, Buffer.from([0x00, 0xff, 0xfe, 0x0a]));
77
+ expect(() => inspectHookFile(p)).not.toThrow();
78
+ });
79
+
80
+ test('a missing file reports missing rather than throwing', () => {
81
+ const r = inspectHookFile(path.join(TMP_DIR, 'nope.sh'));
82
+ expect(r.ok).toBe(false);
83
+ expect(r.problems.join(' ')).toMatch(/missing/i);
84
+ });
85
+ });
@@ -0,0 +1,92 @@
1
+ #!/bin/bash
2
+ # Gate 5's Python coverage parsing and threshold verdict (#523).
3
+ #
4
+ # Sourced by githooks/pre-commit; kept in its own file so the contract is
5
+ # unit-testable without executing the hook
6
+ # (githooks/__tests__/issue-523-python-coverage-parse.test.bats).
7
+ #
8
+ # The defect this file exists for: `coverage report --format=total` prints a
9
+ # BARE number ("97"), but the hook piped it through `grep -o '[0-9]*%'` — a
10
+ # pattern that requires a literal percent sign — so COVERAGE_PERCENT was always
11
+ # empty and the 80% threshold branch never executed. The gate answered "Could
12
+ # not determine" forever: a silent no-op, not a verdict.
13
+
14
+ # Parse the raw output of `coverage report --format=total` (stdin) and echo the
15
+ # integer total percentage — or nothing when the input carries no number.
16
+ #
17
+ # coverage.py 7.x --format=total prints a bare number; other tools (and older
18
+ # forms) append a percent sign. Only a line that IS a bare number (optionally
19
+ # %-suffixed) is accepted — a line that merely CONTAINS a number ("WARNING: 2
20
+ # files skipped") must not be mistaken for the total, so the whole line is
21
+ # matched, not the first token. A decimal part is truncated (floor — the
22
+ # conservative direction for a threshold gate, the same convention as the Java
23
+ # path's ${VAR%.*}). Inputs without any bare-number line (empty, "No data to
24
+ # report.", warnings) produce NO output so the caller takes its
25
+ # cannot-determine path: a wrong number is worse than none.
26
+ parse_coverage_total() {
27
+ awk '
28
+ {
29
+ line = $0
30
+ sub(/%$/, "", line)
31
+ gsub(/^[ \t]+|[ \t]+$/, "", line)
32
+ if (line ~ /^[0-9]+(\.[0-9]+)?$/) {
33
+ sub(/\.[0-9]+$/, "", line)
34
+ print line
35
+ exit
36
+ }
37
+ }
38
+ '
39
+ }
40
+
41
+ # Gate 5 Stage 2 Python coverage enforcement — the hook's python case body,
42
+ # extracted so the mock-PATH test exercises the real control flow. Echoes the
43
+ # verdict lines and returns 1 on BLOCK (the caller turns that into `exit 1`).
44
+ #
45
+ # COVERAGE_PERCENT is deliberately NOT declared local: the hook's quality
46
+ # report reads it after the Stage-2 loop (last writer wins across languages),
47
+ # exactly as the inline branch did.
48
+ enforce_python_coverage() {
49
+ # Critical-1 (#399): when the test run was skipped (PY_STRATEGY=skip, set in
50
+ # pre-commit before this Stage-2 function), refuse to consume a stale
51
+ # .coverage left by an earlier subset run — its low % is meaningless for a
52
+ # commit that changed no Python and must not BLOCK a docs-only change. This
53
+ # mirrors the TS branch's TESTS_SKIPPED guard.
54
+ if [ "${PY_TEST_SKIPPED:-false}" = "true" ]; then
55
+ echo "ℹ️ Test run was skipped — skipping Python coverage check."
56
+ return 0
57
+ fi
58
+
59
+ if ! command -v coverage &> /dev/null; then
60
+ echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
61
+ return 0
62
+ fi
63
+
64
+ COVERAGE_PERCENT=""
65
+ if [ -f ".coverage" ]; then
66
+ COVERAGE_PERCENT=$(coverage report --format=total 2>/dev/null | parse_coverage_total) || true
67
+ fi
68
+ if [ -z "$COVERAGE_PERCENT" ] && [ -f "coverage/.coverage" ]; then
69
+ COVERAGE_PERCENT=$(coverage report --format=total -i coverage 2>/dev/null | parse_coverage_total) || true
70
+ fi
71
+
72
+ if [ -n "$COVERAGE_PERCENT" ]; then
73
+ if [ "$COVERAGE_PERCENT" -lt "${COVERAGE_THRESHOLD:-80}" ]; then
74
+ # #399: a subset run (smart test selection) measures only the tested
75
+ # files, so its global total is meaningless as a verdict — the full
76
+ # suite may well be >=80% even when the subset is not. Warn exactly as
77
+ # the TypeScript branch does (#286) instead of blocking. Full runs keep
78
+ # the hard BLOCK below.
79
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
80
+ echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
81
+ echo " Run the full suite for a real Python coverage check."
82
+ return 0
83
+ fi
84
+ echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
85
+ return 1
86
+ fi
87
+ echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
88
+ else
89
+ echo "⚠️ Could not determine Python coverage percentage. Ensure pytest-cov or coverage.py data exists."
90
+ fi
91
+ return 0
92
+ }
package/lib/doctor.js CHANGED
@@ -18,6 +18,14 @@ const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('
18
18
  // #505: the single source of truth for module resolution order. The hook reads a
19
19
  // generated shell projection of this same module.
20
20
  const { RESOLUTION_ORDER } = require('./hook-module-resolution.js');
21
+ const { inspectHookFile } = require('./hook-health.js');
22
+
23
+ /**
24
+ * Files above this size get a `bash -n` probe in addition to the CRLF scan.
25
+ * Real xp-gate hooks are 40 KB (pre-push) to 190 KB (pre-commit); test fixtures
26
+ * and stubs are a few hundred bytes. 4 KB separates them comfortably.
27
+ */
28
+ const PARSE_CHECK_MIN_BYTES = 4096;
21
29
  const {
22
30
  diagnoseTuiRegistration, diagnoseInstalledSkills,
23
31
  fixTuiRegistration, ensureTuiRegistration, readTuiJson,
@@ -1256,10 +1264,46 @@ function checkSingleHook(hooksDir, name, signature, label, checks) {
1256
1264
  checks.push({ name: `${label}: ${name}`, status: 'FAIL', detail: 'Missing or not xp-gate' });
1257
1265
  return 1;
1258
1266
  }
1267
+ // Existence and signature are not enough (#546): a hook rewritten with CRLF
1268
+ // line endings, or truncated mid-write, satisfies both and still cannot run.
1269
+ // Reporting it as healthy is worse than reporting nothing, because the
1270
+ // operator stops looking.
1271
+ //
1272
+ // The CRLF scan is pure and cheap; the `bash -n` probe spawns a process, so it
1273
+ // is skipped for files under the size floor below — test fixtures and stub
1274
+ // hooks are not the things that break here, and spawning bash per stub made
1275
+ // doctor slower than its own test timeout.
1276
+ const health = inspectHookFile(hookPath, { parseCheck: shouldParseCheck(hookPath) });
1277
+ if (!health.ok) {
1278
+ checks.push({
1279
+ name: `${label}: ${name}`,
1280
+ status: 'FAIL',
1281
+ detail: health.problems.join('; '),
1282
+ });
1283
+ return 1;
1284
+ }
1259
1285
  checks.push({ name: `${label}: ${name}`, status: 'PASS', detail: hookPath });
1260
1286
  return 0;
1261
1287
  }
1262
1288
 
1289
+ /**
1290
+ * Should the `bash -n` probe run for this hook?
1291
+ *
1292
+ * Measured on a real install: a 190 KB pre-commit takes ~90 ms to parse, but a
1293
+ * dozen stubs in a test fixture still add up to seconds of process spawns. The
1294
+ * CRLF scan catches the #546 failure without spawning anything, so the probe is
1295
+ * reserved for files large enough to be a real hook.
1296
+ * @param {string} hookPath - Hook path
1297
+ * @returns {boolean} Whether to run the parse check
1298
+ */
1299
+ function shouldParseCheck(hookPath) {
1300
+ try {
1301
+ return fs.statSync(hookPath).size > PARSE_CHECK_MIN_BYTES;
1302
+ } catch {
1303
+ return false;
1304
+ }
1305
+ }
1306
+
1263
1307
  /**
1264
1308
  * Expected gate scripts that should be present in the adapters directory.
1265
1309
  * gate-5.sh and gate-6.sh are inline in pre-commit, so they are NOT expected here.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Hook file health inspection (#546).
3
+ *
4
+ * `xp-gate doctor` used to answer one question about an installed hook: does the
5
+ * file exist and does it carry the xp-gate signature. A hook can satisfy both
6
+ * and still be unrunnable — #546 was a pre-commit that had been rewritten with
7
+ * CRLF line endings, which fails every commit while doctor reported ✓.
8
+ *
9
+ * Two independent failure modes are checked here:
10
+ * - CRLF line endings: git runs hooks through bash, and a stray \r makes the
11
+ * parser choke at a line number far from the real cause (the reported line
12
+ * is wherever the multi-line construct that swallowed the \r began).
13
+ * - Unparseable syntax: a truncated or mangled write leaves a hook that
14
+ * exists but cannot run.
15
+ *
16
+ * Both are reported per file rather than as a single boolean, because the
17
+ * operator needs to know which one happened: CRLF has a mechanical fix, a
18
+ * syntax error does not.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ /**
25
+ * Locate a bash binary without assuming it is on PATH under that exact name.
26
+ * @returns {string|null} Path or bare command name, null when none found
27
+ */
28
+ function findBash() {
29
+ const candidates = ['bash'];
30
+ if (process.platform === 'win32') {
31
+ // Git for Windows ships bash outside PATH far more often than inside it.
32
+ const pf = process.env.ProgramFiles || 'C:\\Program Files';
33
+ const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
34
+ const local = process.env.LOCALAPPDATA;
35
+ if (local) candidates.push(path.join(local, 'Programs', 'Git', 'bin', 'bash.exe'));
36
+ candidates.push(path.join(pf, 'Git', 'bin', 'bash.exe'));
37
+ candidates.push(path.join(pf86, 'Git', 'bin', 'bash.exe'));
38
+ candidates.push(path.join(pf, 'Git', 'usr', 'bin', 'bash.exe'));
39
+ }
40
+ for (const c of candidates) {
41
+ if (path.isAbsolute(c)) {
42
+ if (fs.existsSync(c)) return c;
43
+ continue;
44
+ }
45
+ const probe = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 10000 });
46
+ if (!probe.error && probe.status === 0) return c;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Count lines that end with CRLF.
53
+ * @param {string} text - File content
54
+ * @returns {number} Number of CRLF-terminated lines
55
+ */
56
+ function countCrlfLines(text) {
57
+ const matches = text.match(/\r\n/g);
58
+ return matches ? matches.length : 0;
59
+ }
60
+
61
+ /**
62
+ * Ask bash whether it can parse the file (`bash -n`).
63
+ * @param {string} filePath - Hook to check
64
+ * @param {string} bashBin - Resolved bash binary
65
+ * @returns {{checked: boolean, ok: boolean, detail: string}} Parse verdict
66
+ */
67
+ function checkParseable(filePath, bashBin) {
68
+ if (!bashBin) return { checked: false, ok: true, detail: 'bash not found; parse check skipped' };
69
+ const res = spawnSync(bashBin, ['-n', filePath], { encoding: 'utf8', timeout: 30000 });
70
+ if (res.error) return { checked: false, ok: true, detail: `bash -n could not run: ${res.error.message}` };
71
+ if (res.status === 0) return { checked: true, ok: true, detail: 'parses' };
72
+ // bash reports the first syntax error it hits. Under CRLF that line number is
73
+ // unreliable, which is exactly why the CRLF count is reported alongside.
74
+ const firstLine = String(res.stderr || '').split('\n').find(l => l.trim()) || `exit ${res.status}`;
75
+ return { checked: true, ok: false, detail: firstLine.trim() };
76
+ }
77
+
78
+ /**
79
+ * Inspect one hook file for the conditions that make git unable to run it.
80
+ * @param {string} filePath - Hook path
81
+ * @param {{bashBin?: string|null, parseCheck?: boolean}} [options] - Overrides
82
+ * @returns {{ok: boolean, crlfLines: number, parseable: boolean, problems: string[]}}
83
+ */
84
+ function inspectHookFile(filePath, options = {}) {
85
+ const problems = [];
86
+
87
+ if (!fs.existsSync(filePath)) {
88
+ return { ok: false, crlfLines: 0, parseable: false, problems: [`hook file is missing: ${filePath}`] };
89
+ }
90
+
91
+ let text = '';
92
+ try {
93
+ text = fs.readFileSync(filePath, 'latin1');
94
+ } catch (err) {
95
+ return { ok: false, crlfLines: 0, parseable: false, problems: [`hook file is unreadable: ${err.message}`] };
96
+ }
97
+
98
+ const crlfLines = countCrlfLines(text);
99
+ if (crlfLines > 0) {
100
+ problems.push(
101
+ `hook has CRLF line endings (${crlfLines} line(s)) — bash will fail to parse it; ` +
102
+ 'run `tr -d "\\r" < hook > hook.fixed && mv hook.fixed hook`'
103
+ );
104
+ }
105
+
106
+ // A CRLF hit already answers the question this probe exists to answer, and
107
+ // the probe is the expensive half. Callers can also opt out for stubs.
108
+ if (crlfLines > 0 || options.parseCheck === false) {
109
+ return { ok: problems.length === 0, crlfLines, parseable: crlfLines === 0, problems };
110
+ }
111
+
112
+ const bashBin = options.bashBin !== undefined ? options.bashBin : findBash();
113
+ const parsed = checkParseable(filePath, bashBin);
114
+ if (!parsed.ok) {
115
+ problems.push(`hook is not parseable by bash: ${parsed.detail}`);
116
+ }
117
+
118
+ return { ok: problems.length === 0, crlfLines, parseable: parsed.ok, problems };
119
+ }
120
+
121
+ module.exports = { inspectHookFile, findBash, countCrlfLines };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-20T12:36:08.136Z",
5
+ "checked_at": "2026-10-20T19:22:03.640Z",
6
6
  "source": "network"
7
7
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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.30.2",
3
+ "version": "0.30.3",
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.30.2",
3
+ "version": "0.30.3",
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
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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.30.2",
3
+ "version": "0.30.3",
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
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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/opencode-plugin",
3
- "version": "0.30.2",
3
+ "version": "0.30.3",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "XP-Gate quality gates + AI workflow skills + Sprint Flow TUI sidebar for OpenCode",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 03f6d10a
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
6
+ **Version:** 0.30.3.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": "xp-gate",
3
- "version": "0.30.2",
3
+ "version": "0.30.3",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Qoder. 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": {