@boyingliu01/xp-gate 0.24.0 → 0.26.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 (46) hide show
  1. package/adapter-common.sh +51 -2
  2. package/adapters/python.sh +63 -5
  3. package/hooks/adapter-common.sh +51 -2
  4. package/hooks/lib/coverage-total.sh +20 -0
  5. package/hooks/lib/python-incremental-typecheck.sh +104 -0
  6. package/hooks/lib/python-test-selection.sh +132 -0
  7. package/hooks/lib/typecheck.sh +10 -0
  8. package/hooks/pre-commit +157 -33
  9. package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
  10. package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
  11. package/lib/init.js +46 -1
  12. package/lib/mattpocock-license-verdict.json +1 -1
  13. package/lib/phase-transition.js +61 -0
  14. package/lib/sprint-state-manager.js +86 -39
  15. package/lib/sprint-state-migrator.js +60 -22
  16. package/mock-policy/AGENTS.md +3 -3
  17. package/mutation/AGENTS.md +3 -3
  18. package/mutation/__tests__/gremlins-runner.test.ts +167 -0
  19. package/mutation/__tests__/mutmut-runner.test.ts +20 -22
  20. package/mutation/__tests__/runners-index.test.ts +31 -3
  21. package/mutation/gate-m.ts +22 -4
  22. package/mutation/runners/gremlins-runner.ts +238 -0
  23. package/mutation/runners/index.ts +9 -1
  24. package/mutation/runners/mutmut-runner.ts +18 -12
  25. package/mutation/runners/types.ts +20 -0
  26. package/package.json +1 -1
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/dsh/package.json +1 -1
  32. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  33. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  34. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/principles/AGENTS.md +3 -3
  44. package/skills/delphi-review/AGENTS.md +3 -3
  45. package/skills/sprint-flow/AGENTS.md +3 -3
  46. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/hooks/pre-commit CHANGED
@@ -230,6 +230,16 @@ source "${AUDIT_SCRIPT_DIR}/lib/sprint-gate-report.sh"
230
230
  # unit-testable without executing this hook.
231
231
  source "${AUDIT_SCRIPT_DIR}/lib/coverage-total.sh"
232
232
 
233
+ # Issue #399: Python smart test selection — strategy decision + related-test
234
+ # lookup, kept in a library so the truth table and the path conventions are
235
+ # unit-testable without executing this hook.
236
+ source "${AUDIT_SCRIPT_DIR}/lib/python-test-selection.sh"
237
+
238
+ # Issue #534: incremental Python type-check for the pre-commit python branch —
239
+ # staged-only mypy (fast) that complements python.sh's CI-aligned full pass.
240
+ # Kept in a library so the exit-code capture and scope handling are unit-testable.
241
+ source "${AUDIT_SCRIPT_DIR}/lib/python-incremental-typecheck.sh"
242
+
233
243
  # Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
234
244
  _MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
235
245
 
@@ -1303,24 +1313,38 @@ else
1303
1313
  fi
1304
1314
  fi
1305
1315
 
1306
- # Optional: Type checking with mypy
1316
+ # Optional: Type checking with mypy — INCREMENTAL (fast, staged-only).
1317
+ #
1318
+ # Responsibility boundary (#534):
1319
+ # * This branch (python case) runs mypy ONLY over the staged .py/.pyi
1320
+ # files via python_typecheck_incremental — seconds, not a full inference
1321
+ # pass. Uses --follow-imports=silent so a change to A.py that references
1322
+ # an unchanged B.py does NOT surface B's pre-existing diagnostics; only
1323
+ # caller-level signature errors still block. Its exit code is captured
1324
+ # directly (temp file), which is deterministic and does not depend on
1325
+ # set -o pipefail (the old `... | head | tail; rc=$?` worked only under
1326
+ # pipefail and could drop mypy's status when the pattern is copied into
1327
+ # a non-pipefail context).
1328
+ # * The FULL mypy pass lives OUTSIDE this hook: run_static_analysis()
1329
+ # (githooks/adapters/python.sh) is reachable via the adapter CLI and the
1330
+ # general adapter route, and CI installs mypy (quality-gates.yml) for
1331
+ # typed projects. NOTE: the pre-commit python case is fully inline and
1332
+ # never routes to run_static_analysis — so the incremental blind spot
1333
+ # (cross-module signature break on an unchanged file) is the accepted
1334
+ # trade-off here, backstopped by full mypy wherever that surface runs.
1335
+ # Both paths coexist: this one is the fast pre-commit pass.
1307
1336
  if command -v mypy &> /dev/null; then
1337
+ # Collect staged .py/.pyi via the helper (whitespace-safe, bash-3
1338
+ # compatible — macOS default bash is 3.2, so avoid mapfile).
1308
1339
  MYPY_ARGS=()
1309
1340
  while IFS= read -r staged_file; do
1310
- case "$staged_file" in
1311
- *.py|*.pyi) MYPY_ARGS+=("$staged_file") ;;
1312
- esac
1313
- done <<< "$CHANGED_FILES"
1314
- if [ "${#MYPY_ARGS[@]}" -eq 0 ]; then
1315
- MYPY_EXIT=0
1316
- else
1317
- echo "Running mypy type checking (optional)..."
1318
- mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
1319
- MYPY_EXIT=$?
1320
- fi
1341
+ MYPY_ARGS+=("$staged_file")
1342
+ done < <(python_staged_py_files)
1343
+ python_typecheck_incremental "${MYPY_ARGS[@]}"
1344
+ MYPY_EXIT=$?
1321
1345
  if [ "$MYPY_EXIT" -ne 0 ]; then
1322
1346
  echo ""
1323
- echo "❌ BLOCKED - MYPI TYPE ERRORS detected"
1347
+ echo "❌ BLOCKED - MYPY TYPE ERRORS detected"
1324
1348
  echo "Fix the type errors above before committing."
1325
1349
  exit 1
1326
1350
  fi
@@ -1337,16 +1361,32 @@ else
1337
1361
  echo "ℹ️ golangci-lint not available — skipping Go linting"
1338
1362
  echo "⏭️ SKIPPED - Code quality (golangci-lint not available)"
1339
1363
  else
1340
- echo "Running golangci-lint (comprehensive static analysis)..."
1341
- golangci-lint run 2>&1 | head -30
1342
- GOLANGCI_EXIT=$?
1343
- if [ "$GOLANGCI_EXIT" -ne 0 ]; then
1344
- echo ""
1345
- echo "❌ BLOCKED - GOLANGCI-LINT ERRORS detected"
1346
- echo "Fix the lint errors above before committing."
1347
- exit 1
1364
+ # #534: run golangci-lint over ONLY the staged .go files (incremental),
1365
+ # not the whole repo. `golangci-lint run` with no args scans every package
1366
+ # and dominates pre-commit time on large Go repos. golangci-lint accepts
1367
+ # explicit file targets. No staged .go files -> SKIP (nothing to lint).
1368
+ GO_FILES=$(echo "$CHANGED_FILES" | grep '\.go$' || true)
1369
+ if [ -z "$GO_FILES" ]; then
1370
+ echo "✅ PASSED - No staged Go files to lint."
1371
+ else
1372
+ echo "Running golangci-lint (incremental, staged .go)..."
1373
+ # #534: capture golangci-lint's REAL exit code via a temp file, not a
1374
+ # pipe stage's (`... | head -30; rc=$?` would take head's status; it only
1375
+ # works because of set -o pipefail, which is fragile and inconsistent with
1376
+ # the python incremental helper's temp-file pattern).
1377
+ GO_TMP=$(mktemp)
1378
+ golangci-lint run $GO_FILES > "$GO_TMP" 2>&1
1379
+ GOLANGCI_EXIT=$?
1380
+ head -30 "$GO_TMP"
1381
+ rm -f "$GO_TMP"
1382
+ if [ "$GOLANGCI_EXIT" -ne 0 ]; then
1383
+ echo ""
1384
+ echo "❌ BLOCKED - GOLANGCI-LINT ERRORS detected"
1385
+ echo "Fix the lint errors above before committing."
1386
+ exit 1
1387
+ fi
1388
+ echo "✅ PASSED - golangci-lint."
1348
1389
  fi
1349
- echo "✅ PASSED - golangci-lint."
1350
1390
  fi
1351
1391
  ;;
1352
1392
 
@@ -2294,6 +2334,13 @@ else
2294
2334
  else
2295
2335
  echo "Running coverage check..."
2296
2336
 
2337
+ # Major-2 (Delphi): COV_EXIT aggregates coverage-run failures across ALL
2338
+ # languages in Stage 1. It starts at 0 and each language branch ORs its own
2339
+ # result in (`COV_EXIT=$((COV_EXIT || $?))`), so a later language's success
2340
+ # can never mask an earlier language's subset-run failure. Previously every
2341
+ # branch overwrote COV_EXIT and the last language's status won.
2342
+ COV_EXIT=0
2343
+
2297
2344
  for CURRENT_LANG in $PROJECT_LANGS; do
2298
2345
  ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
2299
2346
  if [ "${CURRENT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
@@ -2307,13 +2354,13 @@ else
2307
2354
  # If no tests were run (TESTS_SKIPPED=true), skip coverage check entirely.
2308
2355
  if [ "${TESTS_SKIPPED:-false}" = "true" ]; then
2309
2356
  echo "ℹ️ Test run was skipped — skipping coverage check."
2310
- COV_EXIT=0
2357
+ COV_EXIT=$((COV_EXIT || 0))
2311
2358
  elif [ -f "coverage/coverage-summary.json" ]; then
2312
2359
  echo "TypeScript coverage collected during test run."
2313
- COV_EXIT=0
2360
+ COV_EXIT=$((COV_EXIT || 0))
2314
2361
  else
2315
2362
  echo "ℹ️ No coverage data produced — test run was skipped (no TS changes)."
2316
- COV_EXIT=0
2363
+ COV_EXIT=$((COV_EXIT || 0))
2317
2364
  fi
2318
2365
  ;;
2319
2366
  "python")
@@ -2334,12 +2381,89 @@ else
2334
2381
  fi
2335
2382
  [ -z "$PY_COV_SOURCE" ] && PY_COV_SOURCE="--cov=."
2336
2383
  [ -z "$PY_COV_FAIL_UNDER" ] && PY_COV_FAIL_UNDER=80
2337
- pytest $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
2338
- COV_EXIT=$?
2384
+
2385
+ # ── #399: smart test selection, the Python counterpart of #286 ──
2386
+ # Three tiers (lib/python-test-selection.sh holds the pure
2387
+ # decision logic + related-test lookup, unit-tested separately):
2388
+ # changed-tests / related -> run ONLY those targets, with the
2389
+ # repo's coverage SOURCE but WITHOUT the global fail_under
2390
+ # (subset coverage is meaningless globally). The run is marked
2391
+ # partial so Stage 2 warns instead of falsely blocking.
2392
+ # full -> unchanged behavior: whole suite + real threshold.
2393
+ # skip -> nothing test-relevant changed; no pytest at all.
2394
+ PY_CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2395
+ | grep -E '(^|/)test_[^/]*\.py$|_test\.py$' || true)
2396
+ PY_CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2397
+ | grep -E '\.py$' | grep -vE '(^|/)test_[^/]*\.py$|_test\.py$' || true)
2398
+ PY_CHANGED_ALL=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)
2399
+ # `grep -c '.'` prints its own zero AND exits 1 on an empty list
2400
+ # (#493), so the count is derived with `|| true` + wc -l instead.
2401
+ PY_TEST_COUNT=$(printf '%s\n' "$PY_CHANGED_TEST_FILES" | grep -c '.' || true)
2402
+ PY_SRC_COUNT=$(printf '%s\n' "$PY_CHANGED_SRC_FILES" | grep -c '.' || true)
2403
+ PY_DEPS_CHANGED=0
2404
+ if python_dependency_changed $PY_CHANGED_ALL; then PY_DEPS_CHANGED=1; fi
2405
+ PY_STRATEGY=$(select_python_test_strategy "$PY_TEST_COUNT" "$PY_SRC_COUNT" "$PY_DEPS_CHANGED")
2406
+
2407
+ # xdist parallel args (#516) — empty + warning when unavailable,
2408
+ # never a BLOCK; unquoted expansion is the multi-word arg list.
2409
+ PY_XDIST_ARGS=$(python_xdist_args 2>/dev/null || true)
2410
+
2411
+ if [ "$PY_STRATEGY" = "skip" ]; then
2412
+ echo "ℹ️ Python: no test/source changes — skipping test run (#399)."
2413
+ # Critical-1 (#399): mark the run skipped so Stage 2's
2414
+ # enforce_python_coverage refuses to consume a stale .coverage
2415
+ # left by a prior subset run. Without this flag the gate read the
2416
+ # leftover low-% file and BLOCKED a docs-only commit that changed
2417
+ # no Python. Mirrors the TS branch's TESTS_SKIPPED guard.
2418
+ PY_TEST_SKIPPED=true
2419
+ COV_EXIT=$((COV_EXIT || 0))
2420
+ elif [ "$PY_STRATEGY" = "full" ]; then
2421
+ if [ "$PY_DEPS_CHANGED" = "1" ]; then
2422
+ echo "ℹ️ Python: dependency manifest changed — running FULL suite (#399)."
2423
+ elif [ "$PY_TEST_COUNT" -gt 20 ] 2>/dev/null; then
2424
+ echo "ℹ️ Python: $PY_TEST_COUNT changed test files (>20) — running FULL suite (#399)."
2425
+ fi
2426
+ # shellcheck disable=SC2086
2427
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
2428
+ COV_EXIT=$((COV_EXIT || $?))
2429
+ else
2430
+ # changed-tests | related → resolve the exact targets.
2431
+ PY_TARGETS=""
2432
+ if [ "$PY_STRATEGY" = "changed-tests" ]; then
2433
+ PY_TARGETS="$PY_CHANGED_TEST_FILES"
2434
+ else
2435
+ for _py_src in $PY_CHANGED_SRC_FILES; do
2436
+ _py_related=$(python_related_tests "$_py_src")
2437
+ if [ -n "$_py_related" ]; then
2438
+ PY_TARGETS="${PY_TARGETS}${_py_related}
2439
+ "
2440
+ fi
2441
+ done
2442
+ PY_TARGETS=$(printf '%s\n' "$PY_TARGETS" | grep -v '^$' | sort -u)
2443
+ fi
2444
+
2445
+ if [ -z "$PY_TARGETS" ]; then
2446
+ # related lookup found nothing → tier 3 full run is safest.
2447
+ echo "ℹ️ Python: no related tests found for changed sources — running FULL suite (#399)."
2448
+ # shellcheck disable=SC2086
2449
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
2450
+ COV_EXIT=$((COV_EXIT || $?))
2451
+ else
2452
+ PY_TARGET_COUNT=$(printf '%s\n' "$PY_TARGETS" | grep -c '.' || true)
2453
+ echo "ℹ️ Python: selective run ($PY_STRATEGY) — $PY_TARGET_COUNT target(s):"
2454
+ printf '%s\n' "$PY_TARGETS" | sed 's/^/ /'
2455
+ # Subset run: keep --cov so data is collected, but drop the
2456
+ # global threshold — subset coverage is meaningless globally.
2457
+ PARTIAL_TEST_RUN=true
2458
+ # shellcheck disable=SC2086
2459
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --tb=short $PY_TARGETS
2460
+ COV_EXIT=$((COV_EXIT || $?))
2461
+ fi
2462
+ fi
2339
2463
  else
2340
2464
  echo "pytest/coverage not available, running coverage via adapter..."
2341
2465
  run_coverage_output=$(run_coverage 2>&1)
2342
- COV_EXIT=$?
2466
+ COV_EXIT=$((COV_EXIT || $?))
2343
2467
  echo "$run_coverage_output" | head -30
2344
2468
  fi
2345
2469
  ;;
@@ -2352,30 +2476,30 @@ else
2352
2476
  exit 1
2353
2477
  fi
2354
2478
  echo "Go coverage: $TOTAL_COVERAGE%"
2355
- COV_EXIT=0
2479
+ COV_EXIT=$((COV_EXIT || 0))
2356
2480
  fi
2357
2481
  ;;
2358
2482
  "shell")
2359
2483
  shell_cov_output=$(run_coverage 2>&1)
2360
- COV_EXIT=$?
2484
+ COV_EXIT=$((COV_EXIT || $?))
2361
2485
  echo "$shell_cov_output" | head -10
2362
2486
  echo "ℹ️ Shell coverage not typically measured"
2363
2487
  ;;
2364
2488
  *)
2365
2489
  default_cov_output=$(run_coverage 2>&1)
2366
- COV_EXIT=$?
2490
+ COV_EXIT=$((COV_EXIT || $?))
2367
2491
  echo "$default_cov_output" | head -30
2368
2492
  ;;
2369
2493
  esac
2370
2494
  else
2371
2495
  # Adapter source failed — warn and let Stage 2 attempt file-based enforcement
2372
2496
  echo "⚠️ Could not source ${CURRENT_LANG} adapter from ${ADAPTER_FILE}, will check coverage files directly..."
2373
- COV_EXIT=0
2497
+ COV_EXIT=$((COV_EXIT || 0))
2374
2498
  fi
2375
2499
  else
2376
2500
  # No adapter available — warn and let Stage 2 attempt file-based enforcement
2377
2501
  echo "⚠️ No adapter found for ${CURRENT_LANG} (searched: ${ADAPTER_DIR}/, ${ADAPTER_DIR}/adapters/, ${PROJECT_GITHOOKS}/adapters/, ${SCRIPT_DIR}/adapters/) — will check coverage files directly..."
2378
- COV_EXIT=0
2502
+ COV_EXIT=$((COV_EXIT || 0))
2379
2503
  fi
2380
2504
  done # end for CURRENT_LANG (Stage 1 coverage)
2381
2505
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @intent #511: Gate 8 is fail-closed since #499 (missing gitleaks BLOCKS the
3
+ * commit). The npm init path never surfaced that fact, so an upgrading
4
+ * user met a hard BLOCK with no prior signal. `probeGitleaksNotice`
5
+ * prints a migration notice and never installs anything.
6
+ * @covers probeGitleaksNotice() in lib/init.js
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { createRequire } from 'module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { probeGitleaksNotice } = require('../init.js');
14
+
15
+ describe('probeGitleaksNotice (#511)', () => {
16
+ it('reports present and stays quiet when gitleaks is available', () => {
17
+ const lines = [];
18
+ const result = probeGitleaksNotice(
19
+ () => ({ available: true, version: '8.24.2' }),
20
+ l => lines.push(l),
21
+ );
22
+ expect(result.available).toBe(true);
23
+ const joined = lines.join('\n');
24
+ expect(joined).toContain('gitleaks present');
25
+ expect(joined).toContain('8.24.2');
26
+ expect(joined).not.toContain('FAIL-CLOSED');
27
+ });
28
+
29
+ it('prints the fail-closed migration notice when gitleaks is missing', () => {
30
+ const lines = [];
31
+ const result = probeGitleaksNotice(() => ({ available: false }), l => lines.push(l));
32
+ expect(result.available).toBe(false);
33
+ const joined = lines.join('\n');
34
+ expect(joined).toContain('FAIL-CLOSED');
35
+ expect(joined).toContain('Gate 8');
36
+ expect(joined).toContain('install-gitleaks.sh');
37
+ expect(joined).toContain('brew install gitleaks');
38
+ expect(joined).toContain('winget install gitleaks');
39
+ });
40
+
41
+ it('probes exactly the gitleaks tool name', () => {
42
+ const checkTool = vi.fn(() => ({ available: true }));
43
+ probeGitleaksNotice(checkTool, () => {});
44
+ expect(checkTool).toHaveBeenCalledWith('gitleaks');
45
+ });
46
+
47
+ it('never installs anything — it is a pure text probe', () => {
48
+ // The helper must not shell out: no exec/spawn import is exercised here,
49
+ // and the only observable effect is log lines. A missing tool returns the
50
+ // notice; the caller decides whether to install.
51
+ const lines = [];
52
+ const before = process.env.PATH;
53
+ probeGitleaksNotice(() => ({ available: false }), l => lines.push(l));
54
+ expect(process.env.PATH).toBe(before);
55
+ expect(lines.some(l => /dev\/null|execSync|spawn/.test(l))).toBe(false);
56
+ });
57
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * @intent #519: A real pointer-drift incident (#507) showed that a MISSED
3
+ * phase transition is invisible — the state file cannot distinguish
4
+ * "never ran" from "not yet advanced". Guards under test:
5
+ * (1) sequence guard: `completed` on phase n is refused unless phase
6
+ * n-1 is completed/skipped, with `--force` as the explicit escape;
7
+ * (2) before/after echo so "command didn't run" and "ran without
8
+ * effect" are distinguishable in the output;
9
+ * (3) an append-only transition audit log;
10
+ * (4) phase_history field normalization (`name` → `phase_name`).
11
+ * @covers handlePhaseTransition guard/echo, appendTransitionAudit, migrateState
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import { handlePhaseTransition } from '../phase-transition.js';
19
+ import { SprintStateManager } from '../sprint-state-manager.js';
20
+ import { migrateState } from '../sprint-state-migrator.js';
21
+
22
+ function writeState(dir, state) {
23
+ try {
24
+ fs.mkdirSync(path.join(dir, '.sprint-state'), { recursive: true });
25
+ fs.writeFileSync(
26
+ path.join(dir, '.sprint-state', 'sprint-state.json'),
27
+ JSON.stringify(state, null, 2),
28
+ );
29
+ } catch (err) {
30
+ throw new Error(`writeState failed for ${dir}: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ function baseState(overrides = {}) {
35
+ return {
36
+ _schema_version: 1,
37
+ id: 'sprint-519',
38
+ task_description: 'test',
39
+ phase: 2,
40
+ status: 'in_progress',
41
+ started_at: '2026-10-14T00:00:00Z',
42
+ phase_history: [],
43
+ isolation: { worktree_path: null, branch: null },
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('#519 phase-transition sequence guard', () => {
49
+ let dir;
50
+
51
+ beforeEach(() => {
52
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519-'));
53
+ });
54
+
55
+ afterEach(() => {
56
+ fs.rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ it('refuses `completed` on phase 3 when phase 2 is not completed/skipped', async () => {
60
+ writeState(dir, baseState({
61
+ phase: 3,
62
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
63
+ }));
64
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
65
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
66
+ const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
67
+ errSpy.mockRestore();
68
+ expect(code).toBe(1);
69
+ expect(stderr).toContain('Cannot complete phase 3');
70
+ expect(stderr).toContain('phase 2');
71
+ expect(stderr).toContain('phase history');
72
+ });
73
+
74
+ it('refuses the jump and names --force as the override', async () => {
75
+ writeState(dir, baseState({ phase: 3, phase_history: [] }));
76
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
77
+ await handlePhaseTransition(['5', 'completed', '--dir', dir, '--skip-evidence', 'x']);
78
+ const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
79
+ errSpy.mockRestore();
80
+ expect(stderr).toContain('--force');
81
+ });
82
+
83
+ it('allows `completed` when the previous phase is completed', async () => {
84
+ writeState(dir, baseState({
85
+ phase: 2,
86
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'completed' }],
87
+ }));
88
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
89
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
90
+ outSpy.mockRestore();
91
+ expect(code).toBe(0);
92
+ });
93
+
94
+ it('treats a skipped previous phase as valid', async () => {
95
+ writeState(dir, baseState({
96
+ phase: 2,
97
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'skipped' }],
98
+ }));
99
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
100
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
101
+ outSpy.mockRestore();
102
+ expect(code).toBe(0);
103
+ });
104
+
105
+ it('--force overrides the guard and warns', async () => {
106
+ writeState(dir, baseState({ phase: 3, phase_history: [] }));
107
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
108
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
109
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x', '--force']);
110
+ const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
111
+ outSpy.mockRestore();
112
+ warnSpy.mockRestore();
113
+ expect(code).toBe(0);
114
+ expect(warned).toContain('overridden');
115
+ });
116
+
117
+ it('does not guard phase 1 (no predecessor)', async () => {
118
+ writeState(dir, baseState({ phase: 1, phase_history: [] }));
119
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
120
+ const code = await handlePhaseTransition(['1', 'completed', '--dir', dir, '--skip-evidence', 'x']);
121
+ outSpy.mockRestore();
122
+ expect(code).toBe(0);
123
+ });
124
+
125
+ it('does not guard non-completed statuses', async () => {
126
+ writeState(dir, baseState({ phase: 3, phase_history: [] }));
127
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
128
+ const code = await handlePhaseTransition(['3', 'in_progress', '--dir', dir]);
129
+ outSpy.mockRestore();
130
+ expect(code).toBe(0);
131
+ });
132
+ });
133
+
134
+ describe('#519 before/after echo', () => {
135
+ let dir;
136
+ beforeEach(() => {
137
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519echo-'));
138
+ });
139
+ afterEach(() => {
140
+ fs.rmSync(dir, { recursive: true, force: true });
141
+ });
142
+
143
+ it('prints before→after for the top-level phase/status and the entry', async () => {
144
+ // Phase 1 must be completed for the phase-2 completion to pass the
145
+ // sequence guard (#519) — otherwise the echo is never reached.
146
+ writeState(dir, baseState({
147
+ phase: 2,
148
+ status: 'in_progress',
149
+ phase_history: [
150
+ { phase: 1, name: 'PREP', status: 'completed' },
151
+ { phase: 2, phase_name: 'DESIGN', status: 'in_progress' },
152
+ ],
153
+ }));
154
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
155
+ const code = await handlePhaseTransition(['2', 'completed', '--dir', dir, '--skip-evidence', 'x']);
156
+ const output = outSpy.mock.calls.map(c => c.join(' ')).join('\n');
157
+ outSpy.mockRestore();
158
+ expect(code).toBe(0);
159
+ expect(output).toContain('before→after');
160
+ expect(output).toContain('in_progress → completed');
161
+ });
162
+ });
163
+
164
+ describe('#519 transition audit log + field normalization', () => {
165
+ let dir;
166
+ beforeEach(() => {
167
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519audit-'));
168
+ });
169
+ afterEach(() => {
170
+ fs.rmSync(dir, { recursive: true, force: true });
171
+ });
172
+
173
+ it('appends one JSON line per transition with from/to', () => {
174
+ writeState(dir, baseState({ phase: 2, phase_history: [] }));
175
+ const manager = new SprintStateManager(dir);
176
+ manager.transitionPhase(2, 'in_progress');
177
+ manager.transitionPhase(2, 'completed');
178
+ const auditFile = path.join(dir, '.sprint-state', 'transition-audit.jsonl');
179
+ expect(fs.existsSync(auditFile)).toBe(true);
180
+ const lines = fs.readFileSync(auditFile, 'utf8').trim().split('\n');
181
+ expect(lines.length).toBe(2);
182
+ const first = JSON.parse(lines[0]);
183
+ expect(first.phase).toBe(2);
184
+ expect(first.phase_name).toBe('DESIGN');
185
+ expect(first.to_status).toBe('in_progress');
186
+ expect(first.timestamp).toBeTruthy();
187
+ const second = JSON.parse(lines[1]);
188
+ expect(second.to_status).toBe('completed');
189
+ });
190
+
191
+ it('audit is best-effort: a write failure does not break the transition', () => {
192
+ writeState(dir, baseState({ phase: 2, phase_history: [] }));
193
+ const manager = new SprintStateManager(dir);
194
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
195
+ // Break the real appendFileSync so appendTransitionAudit's OWN try/catch is
196
+ // exercised (mocking the method itself would bypass the production guard).
197
+ const appendSpy = vi.spyOn(fs, 'appendFileSync').mockImplementation(() => {
198
+ throw new Error('disk full');
199
+ });
200
+ expect(() => manager.transitionPhase(2, 'in_progress')).not.toThrow();
201
+ const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
202
+ appendSpy.mockRestore();
203
+ warnSpy.mockRestore();
204
+ expect(warned).toContain('transition audit write failed');
205
+ });
206
+
207
+ it('migrateState normalizes legacy `name` into `phase_name`', () => {
208
+ // A legacy file (no _schema_version) whose phase 1 entry carries `name`
209
+ // rather than `phase_name` — the exact shape #507 left behind.
210
+ const legacy = baseState({
211
+ _schema_version: undefined,
212
+ phase_history: [{ phase: 1, name: 'PREP', status: 'completed' }],
213
+ });
214
+ const migrated = migrateState(legacy, dir);
215
+ const entry = migrated.phase_history[0];
216
+ // The canonical field is populated (via the legacy remap and/or the
217
+ // name→phase_name normalization) and the legacy field is preserved.
218
+ expect(entry.phase_name).toBeTruthy();
219
+ expect(entry.name).toBe('PREP');
220
+ });
221
+
222
+ it('migrateState backfills `name` from `phase_name` (both directions)', () => {
223
+ const legacy = baseState({
224
+ _schema_version: undefined,
225
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
226
+ });
227
+ const migrated = migrateState(legacy, dir);
228
+ const entry = migrated.phase_history[0];
229
+ expect(entry.phase_name).toBeTruthy();
230
+ // `name` is backfilled so a reader consulting either field agrees.
231
+ expect(entry.name).toBe(entry.phase_name);
232
+ });
233
+ });
package/lib/init.js CHANGED
@@ -271,6 +271,46 @@ function printCliToolStatus() {
271
271
  }
272
272
  }
273
273
 
274
+ /**
275
+ * Probe for gitleaks and warn when it is missing (#511).
276
+ *
277
+ * Gate 8 became fail-closed in #499: a missing gitleaks BLOCKs the commit
278
+ * outright (it does NOT skip, unlike the tools reported by
279
+ * printCliToolStatus). The user-facing installers never provisioned it, so an
280
+ * existing user upgrading into #499 hits a hard BLOCK on their next commit with
281
+ * no prior signal — this is the missing migration notice.
282
+ *
283
+ * Probe-and-prompt only: nothing is installed here (installing is the user's
284
+ * explicit choice, via the printed command). The fail-closed semantics of
285
+ * #499 are untouched.
286
+ *
287
+ * @param {(name: string) => {available: boolean, version?: string}} checkTool
288
+ * @param {(line: string) => void} [log]
289
+ * @returns {{available: boolean}} the probe result, for callers that branch on it
290
+ */
291
+ function probeGitleaksNotice(checkTool, log = console.log) {
292
+ const result = checkTool('gitleaks');
293
+ if (result.available) {
294
+ log(`✓ gitleaks present${result.version ? `: ${result.version}` : ''}`);
295
+ return { available: true };
296
+ }
297
+
298
+ log('');
299
+ log('⚠ gitleaks is NOT installed.');
300
+ log(' Gate 8 (secret scanning) is FAIL-CLOSED since #499: your next commit');
301
+ log(' will be BLOCKED until gitleaks is available.');
302
+ log('');
303
+ log(' Install it now:');
304
+ log(' bash scripts/install-gitleaks.sh');
305
+ log('');
306
+ log(' Per-OS alternatives:');
307
+ log(' macOS: brew install gitleaks');
308
+ log(' Linux: https://github.com/gitleaks/gitleaks/releases');
309
+ log(' Windows: winget install gitleaks (or: scoop install gitleaks)');
310
+ log('');
311
+ return { available: false };
312
+ }
313
+
274
314
  /**
275
315
  * Ask the user for confirmation via stdin.
276
316
  * @param {string} question - The yes/no question to display
@@ -352,6 +392,11 @@ async function init(args) {
352
392
  console.log('Checking CLI tools...');
353
393
  printCliToolStatus();
354
394
 
395
+ // #511: gitleaks is the one tool whose absence BLOCKS rather than SKIPs
396
+ // (Gate 8 fail-closed, #499) — surface it separately so an upgrading user
397
+ // has a migration path before the first blocked commit.
398
+ probeGitleaksNotice(checkCliTool);
399
+
355
400
  console.log('Checking platform dependencies...');
356
401
  // Detect platform and check/auto-install dependencies
357
402
  const platform = detectPlatform();
@@ -925,4 +970,4 @@ function injectKarpathyPrinciples(projectRoot) {
925
970
  // setupGlobal is exported so the global-install wiring (agents deployed to the
926
971
  // real HOME) can be audited under a sandboxed HOME, not just asserted to exist
927
972
  // — the historical miss was "local deployed, global did not" (#417, #429).
928
- module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal };
973
+ module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal, probeGitleaksNotice };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-14T09:00:41.434Z",
5
+ "checked_at": "2026-10-16T00:33:22.006Z",
6
6
  "source": "network"
7
7
  }