pan-wizard 3.13.1 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/hooks/dist/pan-context-monitor.js +24 -12
- package/hooks/dist/pan-statusline.js +7 -1
- package/package.json +5 -5
- package/pan-wizard-core/bin/lib/config.cjs +14 -1
- package/pan-wizard-core/bin/lib/core.cjs +6 -2
- package/pan-wizard-core/bin/lib/doc-lint.cjs +86 -1
- package/pan-wizard-core/bin/lib/focus.cjs +48 -2
- package/pan-wizard-core/bin/lib/hud.cjs +19 -3
- package/pan-wizard-core/bin/lib/knowledge.cjs +2 -2
- package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +1 -1
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +3 -1
- package/pan-wizard-core/bin/lib/verify.cjs +172 -61
- package/pan-wizard-core/bin/pan-tools.cjs +10 -0
- package/pan-wizard-core/workflows/exec-phase.md +14 -0
- package/scripts/release-check.js +29 -14
- package/scripts/run-tests.cjs +44 -0
package/README.md
CHANGED
|
@@ -576,7 +576,7 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
|
|
|
576
576
|
|
|
577
577
|
| Command | What it does |
|
|
578
578
|
|---------|--------------|
|
|
579
|
-
| `/pan:progress` | Where am I? What's next?
|
|
579
|
+
| `/pan:progress` | Where am I? What's next? |
|
|
580
580
|
| `/pan:hud` (alias `/pan:dashboard`) | Render a self-contained HTML dashboard of project + bot-army state to `.planning/hud.html` (`--open`, `--out`, `--stdout`) |
|
|
581
581
|
| `/pan:help` | Show all commands and usage guide |
|
|
582
582
|
| `/pan:update` | Update PAN with changelog preview |
|
|
@@ -621,7 +621,7 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
|
|
|
621
621
|
| `/pan:todo-check` | List pending todos |
|
|
622
622
|
| `/pan:debug [desc]` | Systematic debugging with persistent state |
|
|
623
623
|
| `/pan:quick [--full]` | Execute ad-hoc task with PAN guarantees (`--full` adds plan-checking and verification) |
|
|
624
|
-
| `/pan:health [--repair]
|
|
624
|
+
| `/pan:health [--repair]` | Validate `.planning/` directory integrity; `--repair` auto-fixes detected issues |
|
|
625
625
|
| `/pan:hygiene [--apply] [--trace-age-days N]` | Scan for PAN version drift and stale project artifacts (legacy filenames, .tmp orphans, memory bloat, poisoned cost ledgers, trace debris, fragment planning dirs); `--apply` executes the safe fixes — ledgers are quarantined by rename, never deleted |
|
|
626
626
|
| `/pan:links [--strict]` | Validate the doc-code link graph: inline `[[<id>]]` refs, `// @pan:` source anchors, `require-code-mention` contracts (ADR-0027, v3.8.0+) |
|
|
627
627
|
| `/pan:phase-tests [N]` | Generate tests for a completed phase based on UAT criteria |
|
|
@@ -719,8 +719,7 @@ These spawn additional agents during planning/execution. They improve quality bu
|
|
|
719
719
|
| `workflow.plan_check` | `true` | Verifies plans achieve phase goals before execution |
|
|
720
720
|
| `workflow.verifier` | `true` | Confirms must-haves were delivered after execution |
|
|
721
721
|
| `workflow.auto_advance` | `false` | Auto-chain discuss → plan → execute without stopping |
|
|
722
|
-
| `workflow.nyquist_validation` | `
|
|
723
|
-
| `workflow.standards_health` | `true` | Include standards compliance in health reports |
|
|
722
|
+
| `workflow.nyquist_validation` | `false` | Map test coverage during planning (Nyquist layer) |
|
|
724
723
|
|
|
725
724
|
Use `/pan:settings` to toggle these, or override per-invocation:
|
|
726
725
|
- `/pan:plan-phase --skip-research`
|
|
@@ -21,6 +21,17 @@ const fs = require('fs');
|
|
|
21
21
|
const os = require('os');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
|
|
24
|
+
// Per-user bridge directory inside tmpdir, created 0700 so another user on a
|
|
25
|
+
// shared host can't pre-plant a symlink at a predictable session path or read
|
|
26
|
+
// the bridge files. Both hooks derive the same dir from the same uid, so the
|
|
27
|
+
// statusline→context-monitor IPC channel is preserved.
|
|
28
|
+
function bridgeDir() {
|
|
29
|
+
const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
|
|
30
|
+
const dir = path.join(os.tmpdir(), `pan-hooks-${uid}`);
|
|
31
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
|
|
25
36
|
const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
|
|
26
37
|
const STALE_SECONDS = 60; // ignore metrics older than 60s
|
|
@@ -38,15 +49,18 @@ process.stdin.on('end', () => {
|
|
|
38
49
|
process.exit(0);
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
const tmpDir =
|
|
52
|
+
const tmpDir = bridgeDir();
|
|
42
53
|
const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
|
|
43
54
|
|
|
44
|
-
//
|
|
45
|
-
|
|
55
|
+
// Read metrics directly; absence (subagent/fresh session) or a corrupt
|
|
56
|
+
// file just means "nothing to warn about" — exit silently. No
|
|
57
|
+
// existsSync-then-read gap.
|
|
58
|
+
let metrics;
|
|
59
|
+
try {
|
|
60
|
+
metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
61
|
+
} catch {
|
|
46
62
|
process.exit(0);
|
|
47
63
|
}
|
|
48
|
-
|
|
49
|
-
const metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
50
64
|
const now = Math.floor(Date.now() / 1000);
|
|
51
65
|
|
|
52
66
|
// Ignore stale metrics
|
|
@@ -67,13 +81,11 @@ process.stdin.on('end', () => {
|
|
|
67
81
|
let warnData = { callsSinceWarn: 0, lastLevel: null };
|
|
68
82
|
let firstWarn = true;
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Corrupted file, reset
|
|
76
|
-
}
|
|
84
|
+
try {
|
|
85
|
+
warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
|
|
86
|
+
firstWarn = false;
|
|
87
|
+
} catch {
|
|
88
|
+
// No prior warning file (or corrupted) — treat as first warning.
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
|
|
@@ -39,7 +39,13 @@ function buildStatuslineOutput(data, deps) {
|
|
|
39
39
|
|
|
40
40
|
if (session && d.skipBridge !== true) {
|
|
41
41
|
try {
|
|
42
|
-
|
|
42
|
+
// Write the bridge file into a per-user 0700 subdir so another user on
|
|
43
|
+
// a shared host can't symlink-attack the predictable session path.
|
|
44
|
+
// Mirrors bridgeDir() in pan-context-monitor.js (the reader).
|
|
45
|
+
const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
|
|
46
|
+
const bridgeSubdir = pathMod.join(tmpDir, `pan-hooks-${uid}`);
|
|
47
|
+
try { fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
|
|
48
|
+
const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
|
|
43
49
|
fsMod.writeFileSync(bridgePath, JSON.stringify({
|
|
44
50
|
session_id: session,
|
|
45
51
|
remaining_percentage: remaining,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -62,10 +62,10 @@
|
|
|
62
62
|
"prepare": "node scripts/install-git-hooks.js",
|
|
63
63
|
"release:check": "node scripts/release-check.js",
|
|
64
64
|
"prepublishOnly": "node scripts/release-check.js",
|
|
65
|
-
"test": "node
|
|
66
|
-
"test:scenarios": "node
|
|
67
|
-
"test:all": "node
|
|
68
|
-
"test:e2e": "node
|
|
65
|
+
"test": "node scripts/run-tests.cjs tests",
|
|
66
|
+
"test:scenarios": "node scripts/run-tests.cjs tests/scenarios",
|
|
67
|
+
"test:all": "node scripts/run-tests.cjs tests tests/scenarios",
|
|
68
|
+
"test:e2e": "node scripts/run-tests.cjs tests/scenarios",
|
|
69
69
|
"test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
|
|
70
70
|
"test:watch": "node --test --watch tests/*.test.cjs",
|
|
71
71
|
"build:plugin": "node scripts/build-plugin.js"
|
|
@@ -154,10 +154,23 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
154
154
|
// After the loop, `current` points to the parent object and the
|
|
155
155
|
// final segment is used as the property key for assignment.
|
|
156
156
|
const keys = keyPath.split('.');
|
|
157
|
+
|
|
158
|
+
// Reject prototype-polluting segments up front so a key path like
|
|
159
|
+
// "__proto__.x" or "constructor.prototype.y" can never walk into or mutate
|
|
160
|
+
// Object.prototype. Inline literal guard (no helper) so the check is
|
|
161
|
+
// unambiguous — every remaining assignment is on a vetted key.
|
|
162
|
+
for (const key of keys) {
|
|
163
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
164
|
+
error(`Invalid config key "${keyPath}": __proto__/constructor/prototype are not allowed`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Every segment is now vetted; walk the path building intermediate objects.
|
|
157
170
|
let current = config;
|
|
158
171
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
159
172
|
const key = keys[i];
|
|
160
|
-
if (current
|
|
173
|
+
if (!Object.prototype.hasOwnProperty.call(current, key) || typeof current[key] !== 'object' || current[key] === null) {
|
|
161
174
|
current[key] = {};
|
|
162
175
|
}
|
|
163
176
|
current = current[key];
|
|
@@ -159,9 +159,13 @@ function output(result, raw, rawValue) {
|
|
|
159
159
|
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
|
160
160
|
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
|
161
161
|
if (json.length > MAX_JSON_SIZE) {
|
|
162
|
-
|
|
162
|
+
// Create a fresh private directory (mkdtemp → unique, unguessable, owned
|
|
163
|
+
// by us) and write inside it, so a pre-planted file or symlink on a
|
|
164
|
+
// shared tmpdir can't be followed or overwritten.
|
|
163
165
|
try {
|
|
164
|
-
fs.
|
|
166
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-'));
|
|
167
|
+
const tmpPath = path.join(tmpDir, 'out.json');
|
|
168
|
+
fs.writeFileSync(tmpPath, json, { encoding: 'utf-8', flag: 'wx' });
|
|
165
169
|
process.stdout.write('@file:' + tmpPath);
|
|
166
170
|
} catch {
|
|
167
171
|
// Tmpfile write failed (disk full, permissions) — truncate and write to stdout
|
|
@@ -147,7 +147,13 @@ function cmdDocLintSchemaCheck(cwd, schemaPath, opts = {}) {
|
|
|
147
147
|
const COUNT_PATTERNS = [
|
|
148
148
|
// "52 commands", "21 agents", "30 modules", "2667 tests", etc.
|
|
149
149
|
// Word boundaries + allowed plurals; case-insensitive matching.
|
|
150
|
-
|
|
150
|
+
// Bare "N tests" / "N hooks" added (previously only "(N tests)" and
|
|
151
|
+
// "test files/suites" matched, so "3115 tests" slipped). Adjective-separated
|
|
152
|
+
// ("slash commands") and hyphen-compound ("sub-agents") variants are left
|
|
153
|
+
// uncaught on purpose — broadening to them also matched years ("2026
|
|
154
|
+
// multi-agent") and narrative, breaking the docs-clean invariant; the
|
|
155
|
+
// CLAUDE.md-table self-audit test is the stronger backstop for the counts.
|
|
156
|
+
{ re: /(?<!\.)\b(\d+)\s+(commands?|agents?|modules?|workflows?|templates?|references?|specs?|adrs?|hooks?|test\s+files?|test\s+suites?|tests?)\b/gi,
|
|
151
157
|
label: 'noun-phrase count' },
|
|
152
158
|
// "27th module", "21st agent", "52nd command" — drift-prone ordinals
|
|
153
159
|
{ re: /(?<!\.)\b(\d+)(th|st|nd|rd)\s+(module|reference|agent|command|template|hook|workflow|spec|adr)\b/gi,
|
|
@@ -277,10 +283,89 @@ function cmdDocLintCounts(cwd, dir, opts = {}) {
|
|
|
277
283
|
process.exit(violations.length > 0 ? 1 : 0);
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
// ─── Aspirational-flag checker (ADR-0036 review — closes the "documented CLI
|
|
287
|
+
// flag that doesn't exist in the parser" gap). Heuristic: a `--flag` that
|
|
288
|
+
// appears in a doc line referencing the PAN CLI but never appears as a literal
|
|
289
|
+
// anywhere in the source is very likely fake or stale. Scoped to PAN-CLI lines
|
|
290
|
+
// to avoid flagging unrelated tool flags (git/npm/node). ─────────────────────
|
|
291
|
+
function collectSourceFlags(cwd, roots) {
|
|
292
|
+
const flags = new Set();
|
|
293
|
+
const walk = (dir) => {
|
|
294
|
+
let entries;
|
|
295
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
296
|
+
for (const e of entries) {
|
|
297
|
+
const fp = path.join(dir, e.name);
|
|
298
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(fp); }
|
|
299
|
+
else if (/\.(cjs|js|mjs)$/.test(e.name)) {
|
|
300
|
+
let c = ''; try { c = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
|
301
|
+
for (const m of c.matchAll(/--[a-z][a-z0-9-]+/g)) flags.add(m[0]);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
for (const r of roots) walk(path.join(cwd, r));
|
|
306
|
+
return flags;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function scanDocFlags(cwd, opts = {}) {
|
|
310
|
+
const sourceFlags = collectSourceFlags(cwd, opts.sourceRoots || ['pan-wizard-core/bin', 'bin']);
|
|
311
|
+
const docDirs = opts.docDirs || ['docs'];
|
|
312
|
+
const collected = [];
|
|
313
|
+
for (const d of docDirs) {
|
|
314
|
+
const abs = path.join(cwd, d);
|
|
315
|
+
if (!fs.existsSync(abs)) continue;
|
|
316
|
+
for (const f of walkMarkdownFiles(abs)) {
|
|
317
|
+
if (f.readError) continue;
|
|
318
|
+
collected.push({ rel: path.join(d, f.relativePath).replace(/\\/g, '/'), content: f.content });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const rel of (opts.files || [])) {
|
|
322
|
+
try { collected.push({ rel: rel.replace(/\\/g, '/'), content: fs.readFileSync(path.join(cwd, rel), 'utf-8') }); } catch { /* skip */ }
|
|
323
|
+
}
|
|
324
|
+
// Scope to `pan-tools <cmd>` lines ONLY — that is the surface whose flags are
|
|
325
|
+
// parsed in bin/ source. Slash-command flags (`/pan:exec-phase --gaps-only`)
|
|
326
|
+
// are a different surface: they are parsed by the command/workflow markdown
|
|
327
|
+
// prompts, so they legitimately never appear in bin/ source and must not be
|
|
328
|
+
// flagged here.
|
|
329
|
+
const CLI_CTX = /\bpan-tools\b/;
|
|
330
|
+
const violations = [];
|
|
331
|
+
for (const file of collected) {
|
|
332
|
+
// Skip frozen/aspirational docs (feature specs, ADRs, experiments, archive)
|
|
333
|
+
// — by design they describe proposed/future flags, same allowlist as counts.
|
|
334
|
+
if (isCountAllowed(file.rel)) continue;
|
|
335
|
+
const lines = file.content.split(/\r?\n/);
|
|
336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
337
|
+
if (!CLI_CTX.test(lines[i])) continue;
|
|
338
|
+
// Lookbehind excludes mid-token double-dashes — notably markdown anchors
|
|
339
|
+
// like `#army--project-dashboard` — so only real ` --flag` tokens match.
|
|
340
|
+
for (const m of lines[i].matchAll(/(?<![\w#-])(--[a-z][a-z0-9-]+)/g)) {
|
|
341
|
+
if (!sourceFlags.has(m[1])) violations.push({ file: file.rel, line: i + 1, flag: m[1] });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { source_flags: sourceFlags.size, doc_files: collected.length, violation_count: violations.length, violations };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cmdDocLintFlags(cwd, opts = {}, raw) {
|
|
349
|
+
const r = scanDocFlags(cwd, opts);
|
|
350
|
+
if (raw) {
|
|
351
|
+
if (r.violation_count === 0) {
|
|
352
|
+
process.stdout.write(`OK — ${r.doc_files} docs scanned against ${r.source_flags} source flags, no aspirational CLI flags\n`);
|
|
353
|
+
} else {
|
|
354
|
+
for (const v of r.violations) process.stdout.write(`${v.file}:${v.line} — ${v.flag} (documented for the PAN CLI but not found in source)\n`);
|
|
355
|
+
process.stdout.write(`\n${r.violation_count} aspirational flag(s)\n`);
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
output(r, false);
|
|
359
|
+
}
|
|
360
|
+
process.exit(r.violation_count > 0 ? 1 : 0);
|
|
361
|
+
}
|
|
362
|
+
|
|
280
363
|
module.exports = {
|
|
281
364
|
cmdDocLint,
|
|
282
365
|
cmdDocLintSchemaCheck,
|
|
283
366
|
cmdDocLintCounts,
|
|
367
|
+
scanDocFlags,
|
|
368
|
+
cmdDocLintFlags,
|
|
284
369
|
isCountAllowed,
|
|
285
370
|
COUNT_PATTERNS,
|
|
286
371
|
DEFAULT_SCHEMA_PATH,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit } = require('./core.cjs');
|
|
11
|
+
const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
|
|
12
12
|
const {
|
|
13
13
|
PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, PATTERNS_FILE, EFFORT_POINTS, PRIORITY_LEVELS, EFFORT_SIZES,
|
|
14
14
|
FOCUS_MODES, FOCUS_TIERS, FOCUS_DIR,
|
|
@@ -457,7 +457,7 @@ function checkDocStaleness(cwd, opts) {
|
|
|
457
457
|
function checkOldCommandNames(content, file, stale) {
|
|
458
458
|
for (const [oldName, newName] of Object.entries(COMMAND_RENAME_MAP)) {
|
|
459
459
|
// Match /pan:old-name or pan:old-name (command references)
|
|
460
|
-
const pattern = new RegExp(`pan:${oldName
|
|
460
|
+
const pattern = new RegExp(`pan:${escapeRegex(oldName)}\\b`);
|
|
461
461
|
if (pattern.test(content)) {
|
|
462
462
|
stale.push({ file, entity: 'renamed_command', old: oldName, new: newName });
|
|
463
463
|
}
|
|
@@ -717,6 +717,45 @@ function focusAutoStop(cwd, raw) {
|
|
|
717
717
|
}, raw);
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
+
// Anti-fake (ADR-0036 review): the regression breaker compares tests_after vs
|
|
721
|
+
// tests_before, both historically agent-supplied CLI args that focus-auto never
|
|
722
|
+
// re-ran — so a fabricated "tests green" passed unchecked. When verification is
|
|
723
|
+
// enabled (config.focus.verify_tests), re-run the node:test suite and use the
|
|
724
|
+
// REAL pass count; otherwise keep the supplied value but mark the cycle
|
|
725
|
+
// tests_verified:false so the loop's trust is visible to the HUD/audit/human.
|
|
726
|
+
//
|
|
727
|
+
// Invocation is a fixed execFile('node', ['--test']) — NO shell and NO
|
|
728
|
+
// configurable command string, so there is no command-injection surface (same
|
|
729
|
+
// safe pattern verify.cjs uses). If no node:test suite is present, fall back to
|
|
730
|
+
// the supplied value rather than mis-firing the regression breaker.
|
|
731
|
+
function verifyTestCount(cwd, config, fallback) {
|
|
732
|
+
const focusCfg = (config && config.focus) || {};
|
|
733
|
+
if (!focusCfg.verify_tests) return { tests_after: fallback, verified: false };
|
|
734
|
+
const { execFileSync } = require('child_process');
|
|
735
|
+
const num = (out, re) => { const m = String(out || '').match(re); return m ? Number(m[1]) : null; };
|
|
736
|
+
const interpret = (out, exitCode) => {
|
|
737
|
+
// node --test summary lines use either the TAP marker ("# tests N") or the
|
|
738
|
+
// spec reporter's info marker ("ℹ tests N"), depending on Node version /
|
|
739
|
+
// reporter — match both so the count is captured regardless.
|
|
740
|
+
const total = num(out, /[#ℹ]\s*tests\s+(\d+)/);
|
|
741
|
+
if (!total) return { tests_after: fallback, verified: false }; // no node:test suite here
|
|
742
|
+
const passed = num(out, /[#ℹ]\s*pass\s+(\d+)/);
|
|
743
|
+
return { tests_after: passed !== null ? passed : (exitCode === 0 ? fallback : 0), verified: true, exit_code: exitCode };
|
|
744
|
+
};
|
|
745
|
+
// Strip any NODE_TEST_* context so the child runs as a standalone runner with
|
|
746
|
+
// predictable spec/TAP output — otherwise, when focus-auto is itself invoked
|
|
747
|
+
// from within a `node --test` process, the child inherits the nested-test
|
|
748
|
+
// context and emits a serialized format we can't parse.
|
|
749
|
+
const env = { ...process.env };
|
|
750
|
+
for (const k of Object.keys(env)) { if (k.startsWith('NODE_TEST')) delete env[k]; }
|
|
751
|
+
try {
|
|
752
|
+
const out = execFileSync('node', ['--test'], { cwd, timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env });
|
|
753
|
+
return interpret(out, 0);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
return interpret(err && err.stdout, (err && err.status) || 1);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
720
759
|
function focusAutoUpdate(cwd, raw, getVal) {
|
|
721
760
|
const run = readAutoRun(cwd);
|
|
722
761
|
if (!run) return error('No auto-run in progress. Cannot update.');
|
|
@@ -735,6 +774,12 @@ function focusAutoUpdate(cwd, raw, getVal) {
|
|
|
735
774
|
timestamp: new Date().toISOString(),
|
|
736
775
|
};
|
|
737
776
|
|
|
777
|
+
// Anti-fake: re-run the suite when verification is enabled; else record that
|
|
778
|
+
// this count was self-reported (tests_verified:false) so the trust is visible.
|
|
779
|
+
const tv = verifyTestCount(cwd, loadConfig(cwd), cycle.tests_after);
|
|
780
|
+
cycle.tests_after = tv.tests_after;
|
|
781
|
+
cycle.tests_verified = tv.verified;
|
|
782
|
+
|
|
738
783
|
if (!run.cycles) run.cycles = [];
|
|
739
784
|
run.cycles.push(cycle);
|
|
740
785
|
|
|
@@ -1016,6 +1061,7 @@ module.exports = {
|
|
|
1016
1061
|
writeAutoRun,
|
|
1017
1062
|
cmdFocusAuto,
|
|
1018
1063
|
determineStopReason,
|
|
1064
|
+
verifyTestCount,
|
|
1019
1065
|
// Opus 4.7
|
|
1020
1066
|
determineContinuation,
|
|
1021
1067
|
classifyStageDependencies,
|
|
@@ -822,13 +822,29 @@ ${body}
|
|
|
822
822
|
|
|
823
823
|
function openInBrowser(filePath) {
|
|
824
824
|
const { execFileSync } = require('child_process');
|
|
825
|
+
// Only open a path we can resolve to an existing regular file, and refuse
|
|
826
|
+
// anything carrying shell/cmd metacharacters — on Windows `start` is a cmd
|
|
827
|
+
// builtin that re-parses its command line, so a crafted --out value must not
|
|
828
|
+
// be able to reach it. The allowlist check is the taint barrier; `resolved`
|
|
829
|
+
// is what actually gets opened.
|
|
830
|
+
let resolved;
|
|
831
|
+
try {
|
|
832
|
+
resolved = path.resolve(filePath);
|
|
833
|
+
if (!fs.statSync(resolved).isFile()) return false;
|
|
834
|
+
} catch {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
// Allowlist barrier: only ordinary path characters may reach the opener.
|
|
838
|
+
// Anything outside this set (shell/cmd metacharacters, quotes, newlines) is
|
|
839
|
+
// rejected outright, so a crafted --out value cannot reach Windows `start`.
|
|
840
|
+
if (!/^[A-Za-z0-9 _.:\\/()-]+$/.test(resolved)) return false;
|
|
825
841
|
try {
|
|
826
842
|
if (process.platform === 'win32') {
|
|
827
|
-
execFileSync('cmd', ['/c', 'start', '',
|
|
843
|
+
execFileSync('cmd', ['/c', 'start', '', resolved], { stdio: 'ignore' });
|
|
828
844
|
} else if (process.platform === 'darwin') {
|
|
829
|
-
execFileSync('open', [
|
|
845
|
+
execFileSync('open', [resolved], { stdio: 'ignore' });
|
|
830
846
|
} else {
|
|
831
|
-
execFileSync('xdg-open', [
|
|
847
|
+
execFileSync('xdg-open', [resolved], { stdio: 'ignore' });
|
|
832
848
|
}
|
|
833
849
|
return true;
|
|
834
850
|
} catch {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
-
const { output, error, safeReadFile, toPosix } = require('./core.cjs');
|
|
17
|
+
const { output, error, safeReadFile, toPosix, escapeRegex } = require('./core.cjs');
|
|
18
18
|
const { PLANNING_DIR } = require('./constants.cjs');
|
|
19
19
|
const { planningPath } = require('./utils.cjs');
|
|
20
20
|
const { listMemoryAgents, readMemory } = require('./memory.cjs');
|
|
@@ -56,7 +56,7 @@ function scoreRelevance(question, content) {
|
|
|
56
56
|
const body = content.toLowerCase();
|
|
57
57
|
let score = 0;
|
|
58
58
|
for (const w of words) {
|
|
59
|
-
const count = (body.match(new RegExp(`\\b${w
|
|
59
|
+
const count = (body.match(new RegExp(`\\b${escapeRegex(w)}\\b`, 'g')) || []).length;
|
|
60
60
|
score += count;
|
|
61
61
|
}
|
|
62
62
|
return score;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { output } = require('./core.cjs');
|
|
11
|
+
const { output, escapeRegex } = require('./core.cjs');
|
|
12
12
|
const { PLANNING_DIR } = require('./constants.cjs');
|
|
13
13
|
|
|
14
14
|
// ─── Storage layout ──────────────────────────────────────────────────────────
|
|
@@ -1052,7 +1052,7 @@ function unpromotePattern(patternId, opts) {
|
|
|
1052
1052
|
// Strip the pattern's body section. Pattern body is a `## P-<id> — ...` heading
|
|
1053
1053
|
// followed by content until the next `## ` or end-of-file.
|
|
1054
1054
|
const headingRe = new RegExp(
|
|
1055
|
-
`\\n## ${patternId
|
|
1055
|
+
`\\n## ${escapeRegex(patternId)}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
|
|
1056
1056
|
''
|
|
1057
1057
|
);
|
|
1058
1058
|
const newBody = parsed.body.replace(headingRe, '');
|
|
@@ -47,7 +47,7 @@ function renumberDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
|
47
47
|
const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
|
|
48
48
|
|
|
49
49
|
// Find sibling decimals with higher numbers than the removed one
|
|
50
|
-
const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
|
|
50
|
+
const decPattern = new RegExp(`^${escapeRegex(String(baseInt))}\\.(\\d+)-(.+)$`);
|
|
51
51
|
const toRename = [];
|
|
52
52
|
for (const dir of dirs) {
|
|
53
53
|
const decMatch = dir.match(decPattern);
|
|
@@ -133,7 +133,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
133
133
|
const baseExists = dirs.some(dir => dir.startsWith(normalized + '-') || dir === normalized);
|
|
134
134
|
|
|
135
135
|
// Find existing decimal phases for this base
|
|
136
|
-
const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
|
|
136
|
+
const decimalPattern = new RegExp(`^${escapeRegex(normalized)}\\.(\\d+)`);
|
|
137
137
|
const existingDecimals = [];
|
|
138
138
|
|
|
139
139
|
for (const dir of dirs) {
|
|
@@ -444,7 +444,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
444
444
|
// Normalize input then strip leading zeros for flexible matching
|
|
445
445
|
const normalizedAfter = normalizePhaseName(afterPhase);
|
|
446
446
|
const unpadded = normalizedAfter.replace(/^0+/, '');
|
|
447
|
-
const afterPhaseEscaped = unpadded
|
|
447
|
+
const afterPhaseEscaped = escapeRegex(unpadded);
|
|
448
448
|
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
|
449
449
|
if (!targetPattern.test(content)) {
|
|
450
450
|
error(`Phase ${afterPhase} not found in roadmap.md`);
|
|
@@ -458,7 +458,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
458
458
|
try {
|
|
459
459
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
460
460
|
const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name);
|
|
461
|
-
const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
|
|
461
|
+
const decimalPattern = new RegExp(`^${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
462
462
|
for (const dir of dirs) {
|
|
463
463
|
const decMatch = dir.match(decimalPattern);
|
|
464
464
|
if (decMatch) existingDecimals.push(parseInt(decMatch[1], 10));
|
|
@@ -484,7 +484,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
484
484
|
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /pan:plan-phase ${decimalPhase} to break down)\n`;
|
|
485
485
|
|
|
486
486
|
// Insert after the target phase section
|
|
487
|
-
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
|
487
|
+
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); // afterPhaseEscaped already run through escapeRegex above
|
|
488
488
|
const headerMatch = content.match(headerPattern);
|
|
489
489
|
if (!headerMatch) {
|
|
490
490
|
error(`Could not find Phase ${afterPhase} header`);
|
|
@@ -198,7 +198,9 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
|
|
|
198
198
|
lines.push('|----------|--------|----------|-------------|------|');
|
|
199
199
|
for (const f of payload.findings) {
|
|
200
200
|
const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
|
|
201
|
-
|
|
201
|
+
// Neutralize markdown-table-breaking chars: escape pipes and flatten any
|
|
202
|
+
// newlines so a finding description can't corrupt the table structure.
|
|
203
|
+
const desc = String(f.description).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
|
|
202
204
|
lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
|
|
203
205
|
}
|
|
204
206
|
lines.push('');
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
|
-
const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, error } = require('./core.cjs');
|
|
8
|
+
const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, error, escapeRegex } = require('./core.cjs');
|
|
9
9
|
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
|
10
10
|
const { writeStateMd, readStateSafe } = require('./state.cjs');
|
|
11
11
|
const {
|
|
@@ -317,32 +317,22 @@ function cmdVerifyCommits(cwd, hashes, raw) {
|
|
|
317
317
|
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
318
318
|
* @returns {void}
|
|
319
319
|
*/
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const artifacts = parseMustHavesBlock(content, 'artifacts');
|
|
327
|
-
if (artifacts.length === 0) {
|
|
328
|
-
output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Pure substance check of must_haves.artifacts against disk — no output/exit,
|
|
322
|
+
* so `verify reconcile` can compose it. Returns {all_passed, passed, total, artifacts}.
|
|
323
|
+
*/
|
|
324
|
+
function checkArtifacts(cwd, planContent) {
|
|
325
|
+
const artifacts = parseMustHavesBlock(planContent, 'artifacts');
|
|
332
326
|
const results = [];
|
|
333
327
|
for (const artifact of artifacts) {
|
|
334
328
|
if (typeof artifact === 'string') continue; // skip simple string items
|
|
335
329
|
const artPath = artifact.path;
|
|
336
330
|
if (!artPath) continue;
|
|
337
|
-
|
|
338
|
-
const artFullPath = path.join(cwd, artPath);
|
|
339
|
-
const fileContent = safeReadFile(artFullPath);
|
|
331
|
+
const fileContent = safeReadFile(path.join(cwd, artPath));
|
|
340
332
|
const exists = fileContent !== null;
|
|
341
333
|
const check = { path: artPath, exists, issues: [], passed: false };
|
|
342
|
-
|
|
343
334
|
if (exists) {
|
|
344
335
|
const lineCount = fileContent.split('\n').length;
|
|
345
|
-
|
|
346
336
|
if (artifact.min_lines && lineCount < artifact.min_lines) {
|
|
347
337
|
check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`);
|
|
348
338
|
}
|
|
@@ -350,8 +340,8 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
|
|
|
350
340
|
check.issues.push(`Missing pattern: ${artifact.contains}`);
|
|
351
341
|
}
|
|
352
342
|
if (artifact.exports) {
|
|
353
|
-
const
|
|
354
|
-
for (const exp of
|
|
343
|
+
const exps = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports];
|
|
344
|
+
for (const exp of exps) {
|
|
355
345
|
if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`);
|
|
356
346
|
}
|
|
357
347
|
}
|
|
@@ -359,43 +349,42 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
|
|
|
359
349
|
} else {
|
|
360
350
|
check.issues.push('File not found');
|
|
361
351
|
}
|
|
362
|
-
|
|
363
352
|
results.push(check);
|
|
364
353
|
}
|
|
365
|
-
|
|
366
354
|
const passed = results.filter(r => r.passed).length;
|
|
367
|
-
|
|
368
|
-
all_passed: passed === results.length,
|
|
369
|
-
passed,
|
|
370
|
-
total: results.length,
|
|
371
|
-
artifacts: results,
|
|
372
|
-
}, raw, passed === results.length ? 'valid' : 'invalid');
|
|
355
|
+
return { all_passed: passed === results.length, passed, total: results.length, artifacts: results };
|
|
373
356
|
}
|
|
374
357
|
|
|
375
|
-
|
|
376
|
-
* Verify must_haves.key_links from a plan.md: source-to-target references and patterns.
|
|
377
|
-
* @param {string} cwd - Working directory path
|
|
378
|
-
* @param {string} planFilePath - Path to the plan.md file containing key link specs
|
|
379
|
-
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
380
|
-
* @returns {void}
|
|
381
|
-
*/
|
|
382
|
-
function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
|
|
358
|
+
function cmdVerifyArtifacts(cwd, planFilePath, raw) {
|
|
383
359
|
if (!planFilePath) { error('plan file path required'); }
|
|
384
360
|
const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
|
|
385
361
|
const content = safeReadFile(fullPath);
|
|
386
362
|
if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
|
|
363
|
+
const r = checkArtifacts(cwd, content);
|
|
364
|
+
if (r.total === 0) {
|
|
365
|
+
output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
|
|
391
366
|
return;
|
|
392
367
|
}
|
|
368
|
+
output(r, raw, r.all_passed ? 'valid' : 'invalid');
|
|
369
|
+
}
|
|
393
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Verify must_haves.key_links from a plan.md: source-to-target references and patterns.
|
|
373
|
+
* @param {string} cwd - Working directory path
|
|
374
|
+
* @param {string} planFilePath - Path to the plan.md file containing key link specs
|
|
375
|
+
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
376
|
+
* @returns {void}
|
|
377
|
+
*/
|
|
378
|
+
/**
|
|
379
|
+
* Pure wiring check of must_haves.key_links against disk — no output/exit.
|
|
380
|
+
* Returns {all_verified, verified, total, links}.
|
|
381
|
+
*/
|
|
382
|
+
function checkKeyLinks(cwd, planContent) {
|
|
383
|
+
const keyLinks = parseMustHavesBlock(planContent, 'key_links');
|
|
394
384
|
const results = [];
|
|
395
385
|
for (const link of keyLinks) {
|
|
396
386
|
if (typeof link === 'string') continue;
|
|
397
387
|
const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' };
|
|
398
|
-
|
|
399
388
|
const sourceContent = safeReadFile(path.join(cwd, link.from || ''));
|
|
400
389
|
if (!sourceContent) {
|
|
401
390
|
check.detail = 'Source file not found';
|
|
@@ -415,29 +404,142 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
|
|
|
415
404
|
}
|
|
416
405
|
}
|
|
417
406
|
} catch {
|
|
418
|
-
// Regex compilation failed -- report the invalid pattern to the caller
|
|
419
407
|
check.detail = `Invalid regex pattern: ${link.pattern}`;
|
|
420
408
|
}
|
|
409
|
+
} else if (sourceContent.includes(link.to || '')) {
|
|
410
|
+
check.verified = true;
|
|
411
|
+
check.detail = 'Target referenced in source';
|
|
421
412
|
} else {
|
|
422
|
-
|
|
423
|
-
if (sourceContent.includes(link.to || '')) {
|
|
424
|
-
check.verified = true;
|
|
425
|
-
check.detail = 'Target referenced in source';
|
|
426
|
-
} else {
|
|
427
|
-
check.detail = 'Target not referenced in source';
|
|
428
|
-
}
|
|
413
|
+
check.detail = 'Target not referenced in source';
|
|
429
414
|
}
|
|
430
|
-
|
|
431
415
|
results.push(check);
|
|
432
416
|
}
|
|
433
|
-
|
|
434
417
|
const verified = results.filter(r => r.verified).length;
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
418
|
+
return { all_verified: verified === results.length, verified, total: results.length, links: results };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
|
|
422
|
+
if (!planFilePath) { error('plan file path required'); }
|
|
423
|
+
const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
|
|
424
|
+
const content = safeReadFile(fullPath);
|
|
425
|
+
if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
|
|
426
|
+
const r = checkKeyLinks(cwd, content);
|
|
427
|
+
if (r.total === 0) {
|
|
428
|
+
output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
output(r, raw, r.all_verified ? 'valid' : 'invalid');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Reconcile: cross-check a written verification verdict against the
|
|
435
|
+
// mechanical signals (ADR-0036 review — closes the rubber-stamp gap). ─────────
|
|
436
|
+
|
|
437
|
+
function findPhaseDir(cwd, phaseNum) {
|
|
438
|
+
const base = phasesPath(cwd);
|
|
439
|
+
let entries;
|
|
440
|
+
try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { return null; }
|
|
441
|
+
const re = new RegExp('^0*' + String(phaseNum).replace(/[^0-9A-Za-z.]/g, '') + '-');
|
|
442
|
+
for (const e of entries) {
|
|
443
|
+
if (e.isDirectory() && re.test(e.name)) return path.join(base, e.name);
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Re-derive the mechanical signals for a phase and check them against the
|
|
450
|
+
* verdict written in its -verification.md. A verification that CLAIMS a pass
|
|
451
|
+
* while artifacts fail substance checks or key-links are unwired is a
|
|
452
|
+
* contradiction (a rubber stamp) — reported deterministically, never trusted.
|
|
453
|
+
* When no must_haves are declared there are no mechanical signals to reconcile,
|
|
454
|
+
* so the verdict is passed through (reconciled: true, with a note).
|
|
455
|
+
*/
|
|
456
|
+
function reconcilePhase(cwd, phaseNum) {
|
|
457
|
+
const base = { phase: String(phaseNum), found: false, reconciled: true, contradictions: [] };
|
|
458
|
+
const dir = findPhaseDir(cwd, phaseNum);
|
|
459
|
+
if (!dir) return { ...base, note: 'phase directory not found' };
|
|
460
|
+
let files;
|
|
461
|
+
try { files = fs.readdirSync(dir); } catch { return { ...base, note: 'phase directory unreadable' }; }
|
|
462
|
+
const verFile = files.find(f => isVerificationFile(f));
|
|
463
|
+
if (!verFile) return { ...base, note: 'no verification.md — absence is covered by the verification gate, not reconcile' };
|
|
464
|
+
const verRaw = safeReadFile(path.join(dir, verFile)) || '';
|
|
465
|
+
const sm = verRaw.match(/^status:\s*([A-Za-z_-]+)/m);
|
|
466
|
+
const status = sm ? sm[1].toLowerCase() : 'unknown';
|
|
467
|
+
const claimsPass = /^(pass|passed|verified|complete|verified_pass)$/.test(status);
|
|
468
|
+
const planFile = files.find(f => isPlanFile(f));
|
|
469
|
+
const planContent = planFile ? (safeReadFile(path.join(dir, planFile)) || '') : '';
|
|
470
|
+
const artifacts = checkArtifacts(cwd, planContent);
|
|
471
|
+
const keyLinks = checkKeyLinks(cwd, planContent);
|
|
472
|
+
const signals = artifacts.total + keyLinks.total;
|
|
473
|
+
const contradictions = [];
|
|
474
|
+
if (claimsPass) {
|
|
475
|
+
if (artifacts.total > 0 && !artifacts.all_passed) {
|
|
476
|
+
contradictions.push(`verification status "${status}" but ${artifacts.total - artifacts.passed}/${artifacts.total} artifact substance check(s) FAIL`);
|
|
477
|
+
}
|
|
478
|
+
if (keyLinks.total > 0 && !keyLinks.all_verified) {
|
|
479
|
+
contradictions.push(`verification status "${status}" but ${keyLinks.total - keyLinks.verified}/${keyLinks.total} key-link(s) UNWIRED`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
phase: String(phaseNum), found: true, verification_status: status, claims_pass: claimsPass,
|
|
484
|
+
mechanical_signals: signals, artifacts, key_links: keyLinks, contradictions,
|
|
485
|
+
reconciled: contradictions.length === 0,
|
|
486
|
+
note: signals === 0 ? 'no must_haves declared — mechanical reconciliation unavailable; verdict trusted' : undefined,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function cmdVerifyReconcile(cwd, phaseNum, raw) {
|
|
491
|
+
if (!phaseNum) { error('Usage: verify reconcile <phase>'); }
|
|
492
|
+
const r = reconcilePhase(cwd, phaseNum);
|
|
493
|
+
output(r, raw, r.reconciled ? 'valid' : 'invalid');
|
|
494
|
+
process.exit(r.reconciled ? 0 : 1);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ─── Stub / fake-return scanner (ADR-0036 review — closes the hardcoded
|
|
498
|
+
// "return {ok:true}" / "not implemented" gap the old anti-pattern grep missed,
|
|
499
|
+
// which only blocked the literal `return {}` and `placeholder`/`coming soon`). ─
|
|
500
|
+
const STUB_PATTERNS = [
|
|
501
|
+
{ re: /\bnot[\s_-]?implemented\b/i, marker: 'not-implemented', severity: 'high' },
|
|
502
|
+
{ re: /\bNotImplemented(Error)?\b/, marker: 'NotImplemented', severity: 'high' },
|
|
503
|
+
{ re: /throw\s+new\s+\w*Error\s*\(\s*['"`][^'"`]*\b(unimplemented|not\s+implemented|stub|todo)\b/i, marker: 'throw-stub', severity: 'high' },
|
|
504
|
+
{ re: /\bres(ponse)?\.status\(\s*501\s*\)/, marker: 'http-501', severity: 'high' },
|
|
505
|
+
{ re: /\b(coming\s+soon|placeholder)\b/i, marker: 'placeholder', severity: 'medium' },
|
|
506
|
+
{ re: /return\s*(\{\s*\}|\[\s*\])\s*;?\s*(\/\/.*)?$/, marker: 'empty-return', severity: 'medium' },
|
|
507
|
+
{ re: /return\s*\{\s*ok\s*:\s*true\s*\}\s*;?\s*(\/\/.*)?$/, marker: 'fake-ok-return', severity: 'medium' },
|
|
508
|
+
{ re: /\b(TODO|FIXME|XXX|HACK)\b/, marker: 'todo-marker', severity: 'low' },
|
|
509
|
+
];
|
|
510
|
+
const STUB_CODE_EXT = /\.(js|cjs|mjs|jsx|ts|tsx|py|go|rb|java|php|rs|c|cc|cpp|h|hpp|cs|kt|swift|scala)$/i;
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Scan source files for stub / fake-implementation markers. Defaults to the
|
|
514
|
+
* git-changed files (so it gates a handoff), or a caller-supplied file list.
|
|
515
|
+
* `high`-severity markers are the blocking set; TODO markers are informational.
|
|
516
|
+
* @returns {{scanned, findings: Array, blocking: number, total: number}}
|
|
517
|
+
*/
|
|
518
|
+
function scanStubs(cwd, opts = {}) {
|
|
519
|
+
let files = Array.isArray(opts.files) ? opts.files : getChangedFiles(cwd);
|
|
520
|
+
files = (files || []).filter(f => STUB_CODE_EXT.test(f));
|
|
521
|
+
const findings = [];
|
|
522
|
+
for (const rel of files) {
|
|
523
|
+
const content = safeReadFile(path.join(cwd, rel));
|
|
524
|
+
if (content === null) continue;
|
|
525
|
+
const lines = content.split(/\r?\n/);
|
|
526
|
+
for (let i = 0; i < lines.length; i++) {
|
|
527
|
+
for (const { re, marker, severity } of STUB_PATTERNS) {
|
|
528
|
+
if (re.test(lines[i])) {
|
|
529
|
+
findings.push({ file: toPosix(rel), line: i + 1, marker, severity, text: lines[i].trim().slice(0, 160) });
|
|
530
|
+
break; // one finding per line
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const blocking = findings.filter(f => f.severity === 'high').length;
|
|
536
|
+
return { scanned: files.length, findings, blocking, total: findings.length };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function cmdVerifyStubs(cwd, opts = {}, raw) {
|
|
540
|
+
const r = scanStubs(cwd, opts);
|
|
541
|
+
output(r, raw, r.blocking === 0 ? 'valid' : 'invalid');
|
|
542
|
+
if (opts.gate) process.exit(r.blocking > 0 ? 1 : 0);
|
|
441
543
|
}
|
|
442
544
|
|
|
443
545
|
/**
|
|
@@ -995,12 +1097,12 @@ function syncRequirementCheckboxes(cwd) {
|
|
|
995
1097
|
let fixed = 0;
|
|
996
1098
|
for (const phaseNum of completedPhases) {
|
|
997
1099
|
const reqMatch = roadmapContent.match(
|
|
998
|
-
new RegExp(`Phase\\s+${phaseNum
|
|
1100
|
+
new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
|
|
999
1101
|
);
|
|
1000
1102
|
if (!reqMatch) continue;
|
|
1001
1103
|
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(id => id.trim()).filter(Boolean);
|
|
1002
1104
|
for (const reqId of reqIds) {
|
|
1003
|
-
const escaped = reqId
|
|
1105
|
+
const escaped = escapeRegex(reqId);
|
|
1004
1106
|
const re = new RegExp(`(- \\[) (\\]\\s*\\*\\*${escaped}\\*\\*)`, 'gi');
|
|
1005
1107
|
const before = reqContent;
|
|
1006
1108
|
reqContent = reqContent.replace(re, '$1x$2');
|
|
@@ -1284,8 +1386,11 @@ function runFullTestCheck(cwd) {
|
|
|
1284
1386
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1285
1387
|
encoding: 'utf-8',
|
|
1286
1388
|
});
|
|
1287
|
-
|
|
1288
|
-
|
|
1389
|
+
// Match both TAP ("# tests N") and spec-reporter ("ℹ tests N") summaries —
|
|
1390
|
+
// modern node --test defaults to the spec reporter, which the old "# "-only
|
|
1391
|
+
// regex silently missed (returning tests: null).
|
|
1392
|
+
const testMatch = result.match(/[#ℹ]\s*tests\s+(\d+)/);
|
|
1393
|
+
const passMatch = result.match(/[#ℹ]\s*pass\s+(\d+)/);
|
|
1289
1394
|
return {
|
|
1290
1395
|
pass: true,
|
|
1291
1396
|
exitCode: 0,
|
|
@@ -1344,6 +1449,12 @@ module.exports = {
|
|
|
1344
1449
|
cmdVerifyCommits,
|
|
1345
1450
|
cmdVerifyArtifacts,
|
|
1346
1451
|
cmdVerifyKeyLinks,
|
|
1452
|
+
checkArtifacts,
|
|
1453
|
+
checkKeyLinks,
|
|
1454
|
+
reconcilePhase,
|
|
1455
|
+
cmdVerifyReconcile,
|
|
1456
|
+
scanStubs,
|
|
1457
|
+
cmdVerifyStubs,
|
|
1347
1458
|
cmdValidateConsistency,
|
|
1348
1459
|
cmdValidateHealth,
|
|
1349
1460
|
cmdPreflight,
|
|
@@ -524,6 +524,10 @@ async function main() {
|
|
|
524
524
|
verify.cmdVerifyArtifacts(cwd, args[2], raw);
|
|
525
525
|
} else if (subcommand === 'key-links') {
|
|
526
526
|
verify.cmdVerifyKeyLinks(cwd, args[2], raw);
|
|
527
|
+
} else if (subcommand === 'reconcile') {
|
|
528
|
+
verify.cmdVerifyReconcile(cwd, args[2], raw);
|
|
529
|
+
} else if (subcommand === 'stubs') {
|
|
530
|
+
verify.cmdVerifyStubs(cwd, { gate: args.includes('--gate') }, raw);
|
|
527
531
|
} else {
|
|
528
532
|
error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links');
|
|
529
533
|
}
|
|
@@ -1303,6 +1307,12 @@ async function main() {
|
|
|
1303
1307
|
docLint.cmdDocLintCounts(cwd, dir, { raw, exclude });
|
|
1304
1308
|
break;
|
|
1305
1309
|
}
|
|
1310
|
+
if (subcommand === 'flags') {
|
|
1311
|
+
const docDirs = [];
|
|
1312
|
+
for (let k = 0; k < args.length; k++) if (args[k] === '--doc-dir') docDirs.push(args[k + 1]);
|
|
1313
|
+
docLint.cmdDocLintFlags(cwd, { docDirs: docDirs.length ? docDirs : undefined }, raw);
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1306
1316
|
// Default: lint a directory
|
|
1307
1317
|
const dir = args[1];
|
|
1308
1318
|
if (!dir || dir.startsWith('--')) { error('doc-lint <dir> required (or doc-lint schema-check <path>, doc-lint counts <dir>)'); }
|
|
@@ -659,6 +659,20 @@ if [ -n "$VERIF" ]; then
|
|
|
659
659
|
fi
|
|
660
660
|
```
|
|
661
661
|
|
|
662
|
+
**Cross-check the written verdict against the mechanical signals (anti-rubber-stamp, ADR-0036).** The `status:` string above is authored by the verifier agent; `reconcile` re-derives the artifact/key-link checks from disk and exits non-zero when a claimed pass contradicts them:
|
|
663
|
+
```bash
|
|
664
|
+
node ~/.claude/pan-wizard-core/bin/pan-tools.cjs verify reconcile "${PHASE_NUMBER}" --raw
|
|
665
|
+
RECONCILE_EXIT=$?
|
|
666
|
+
```
|
|
667
|
+
If `RECONCILE_EXIT` is non-zero:
|
|
668
|
+
```
|
|
669
|
+
⚠ Reconcile gate: Phase ${PHASE_NUMBER} verification says "passed" but the mechanical
|
|
670
|
+
checks disagree — artifacts fail substance checks or key-links are unwired.
|
|
671
|
+
This is a rubber-stamped verification. Do NOT auto-advance.
|
|
672
|
+
Re-run /pan:verify-phase and fix the failing artifacts/key-links.
|
|
673
|
+
```
|
|
674
|
+
STOP — do not auto-advance. Return to user.
|
|
675
|
+
|
|
662
676
|
If `VERIF_STATUS` is not `passed`:
|
|
663
677
|
```
|
|
664
678
|
⚠ Verification gate: Phase ${PHASE_NUMBER} verification status is "${VERIF_STATUS:-missing}"
|
package/scripts/release-check.js
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
* release-check.js — Pre-publish validation gate.
|
|
4
4
|
*
|
|
5
5
|
* Wired into `prepublishOnly` so `npm publish` fails BEFORE upload if any
|
|
6
|
-
* gate is red. Runs
|
|
6
|
+
* gate is red. Runs seven checks in order; first failure aborts.
|
|
7
7
|
*
|
|
8
8
|
* 1. build:hooks — hook scripts copy/build cleanly
|
|
9
9
|
* 2. test:all — full test suite (unit + scenario) passes
|
|
10
10
|
* 3. npm audit — no known vulnerabilities in production deps
|
|
11
|
-
* (we have zero runtime deps, but
|
|
11
|
+
* (we have zero runtime deps, but the dev-deps are checked)
|
|
12
12
|
* 4. doc-lint counts — no drift-prone count violations in user-facing docs
|
|
13
|
-
* 5.
|
|
14
|
-
* 6.
|
|
13
|
+
* 5. links validate — doc↔code link graph resolves (no broken references)
|
|
14
|
+
* 6. npm pack dry-run — package builds; size is sane
|
|
15
|
+
* 7. smoke install — npm pack + install into temp dir + run pan-tools list
|
|
15
16
|
* catches "ships but doesn't actually work" failures
|
|
16
17
|
*
|
|
17
18
|
* Usage:
|
|
@@ -56,7 +57,7 @@ function run(cmd, args, opts = {}) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
// Gate 1: build:hooks
|
|
59
|
-
process.stderr.write('\n[release-check] Gate 1/
|
|
60
|
+
process.stderr.write('\n[release-check] Gate 1/7: build:hooks\n');
|
|
60
61
|
{
|
|
61
62
|
const r = run('npm', ['run', 'build:hooks']);
|
|
62
63
|
logGate('build:hooks', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
|
|
@@ -64,7 +65,7 @@ process.stderr.write('\n[release-check] Gate 1/6: build:hooks\n');
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// Gate 2: test:all
|
|
67
|
-
process.stderr.write('\n[release-check] Gate 2/
|
|
68
|
+
process.stderr.write('\n[release-check] Gate 2/7: test:all\n');
|
|
68
69
|
{
|
|
69
70
|
const r = run('npm', ['run', 'test:all']);
|
|
70
71
|
logGate('test:all', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
|
|
@@ -73,9 +74,9 @@ process.stderr.write('\n[release-check] Gate 2/6: test:all\n');
|
|
|
73
74
|
|
|
74
75
|
// Gate 3: npm audit (production deps only)
|
|
75
76
|
if (SKIP_AUDIT) {
|
|
76
|
-
process.stderr.write('\n[release-check] Gate 3/
|
|
77
|
+
process.stderr.write('\n[release-check] Gate 3/7: npm audit (SKIPPED)\n');
|
|
77
78
|
} else {
|
|
78
|
-
process.stderr.write('\n[release-check] Gate 3/
|
|
79
|
+
process.stderr.write('\n[release-check] Gate 3/7: npm audit --omit=dev\n');
|
|
79
80
|
const r = run('npm', ['audit', '--omit=dev', '--audit-level=high'], { capture: true });
|
|
80
81
|
// npm audit exits non-zero on findings. We tolerate moderate; fail on high+.
|
|
81
82
|
const ok = r.status === 0;
|
|
@@ -87,7 +88,7 @@ if (SKIP_AUDIT) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
// Gate 4: doc-lint counts on user-facing docs (count-SSoT enforcement)
|
|
90
|
-
process.stderr.write('\n[release-check] Gate 4/
|
|
91
|
+
process.stderr.write('\n[release-check] Gate 4/7: doc-lint counts docs/\n');
|
|
91
92
|
{
|
|
92
93
|
const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
|
|
93
94
|
const docsDir = path.join(REPO_ROOT, 'docs');
|
|
@@ -100,6 +101,20 @@ process.stderr.write('\n[release-check] Gate 4/6: doc-lint counts docs/\n');
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// Gate 5: doc↔code link graph resolves (anti-fake — a doc cannot reference a
|
|
105
|
+
// code anchor that doesn't exist; deterministic, self-enforcing exit 1).
|
|
106
|
+
process.stderr.write('\n[release-check] Gate 5/7: links validate\n');
|
|
107
|
+
{
|
|
108
|
+
const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
|
|
109
|
+
const r = run('node', [tools, 'links', 'validate', '--raw'], { capture: true });
|
|
110
|
+
const ok = r.status === 0;
|
|
111
|
+
logGate('links validate', ok, ok ? 'doc↔code link graph resolves' : 'broken doc↔code references');
|
|
112
|
+
if (!ok) {
|
|
113
|
+
process.stderr.write((r.stdout || '') + '\n');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
|
|
104
119
|
// npm runs lifecycle scripts (prepare) before pack; any of their stdout noise
|
|
105
120
|
// lands ahead of the --json payload. npm pretty-prints the JSON array starting
|
|
@@ -111,8 +126,8 @@ function parseNpmJson(stdout) {
|
|
|
111
126
|
return JSON.parse(stdout.slice(m));
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
// Gate
|
|
115
|
-
process.stderr.write('\n[release-check] Gate
|
|
129
|
+
// Gate 6: npm pack dry-run
|
|
130
|
+
process.stderr.write('\n[release-check] Gate 6/7: npm pack --dry-run\n');
|
|
116
131
|
{
|
|
117
132
|
const r = run('npm', ['pack', '--dry-run', '--json'], { capture: true });
|
|
118
133
|
if (r.status !== 0) {
|
|
@@ -137,11 +152,11 @@ process.stderr.write('\n[release-check] Gate 5/6: npm pack --dry-run\n');
|
|
|
137
152
|
}
|
|
138
153
|
}
|
|
139
154
|
|
|
140
|
-
// Gate
|
|
155
|
+
// Gate 7: smoke install — pack and install into temp dir, run pan-tools
|
|
141
156
|
if (SKIP_SMOKE) {
|
|
142
|
-
process.stderr.write('\n[release-check] Gate
|
|
157
|
+
process.stderr.write('\n[release-check] Gate 7/7: smoke install (SKIPPED)\n');
|
|
143
158
|
} else {
|
|
144
|
-
process.stderr.write('\n[release-check] Gate
|
|
159
|
+
process.stderr.write('\n[release-check] Gate 7/7: smoke install (npm pack + install + sanity)\n');
|
|
145
160
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-smoke-'));
|
|
146
161
|
try {
|
|
147
162
|
// Pack
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Glob-free test runner. `node --test tests/*.test.cjs` relies on shell glob
|
|
4
|
+
* expansion: bash provides it (linux/macOS) and Node >=22 expands test-path
|
|
5
|
+
* globs itself, but Windows PowerShell does neither on Node 18/20 — the
|
|
6
|
+
* literal pattern "tests/*.test.cjs" matches no file and the run exits 1.
|
|
7
|
+
* This script expands the pattern deterministically on every platform.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node scripts/run-tests.cjs <dir> [<dir> ...]
|
|
10
|
+
* Runs every *.test.cjs DIRECTLY inside each listed directory (no recursion,
|
|
11
|
+
* so `tests` and `tests/scenarios` stay separately addressable).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const dirs = process.argv.slice(2);
|
|
19
|
+
if (dirs.length === 0) {
|
|
20
|
+
console.error('Usage: node scripts/run-tests.cjs <dir> [<dir> ...]');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const dir of dirs) {
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(dir);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error(`run-tests: cannot read directory ${dir}: ${e.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
for (const name of entries.sort()) {
|
|
34
|
+
if (name.endsWith('.test.cjs')) files.push(path.join(dir, name));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (files.length === 0) {
|
|
39
|
+
console.error(`run-tests: no *.test.cjs files found in: ${dirs.join(', ')}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' });
|
|
44
|
+
process.exit(result.status ?? 1);
|