pan-wizard 3.31.0 → 3.33.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 +28 -24
- package/agents/pan-conductor.md +2 -2
- package/agents/pan-design-checker.md +18 -0
- package/agents/pan-document_code.md +2 -2
- package/agents/pan-executor.md +3 -3
- package/agents/pan-experiment-runner.md +7 -10
- package/agents/pan-hardener.md +15 -15
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +2 -2
- package/agents/pan-optimizer.md +7 -3
- package/agents/pan-phase-researcher.md +2 -2
- package/agents/pan-plan-checker.md +29 -2
- package/agents/pan-planner.md +23 -10
- package/agents/pan-project-researcher.md +10 -67
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-roadmapper.md +5 -48
- package/agents/pan-verifier.md +49 -11
- package/bin/install-lib.cjs +170 -15
- package/bin/install.js +39 -14
- package/commands/pan/army.md +2 -2
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/experiment.md +2 -2
- package/commands/pan/focus-auto.md +12 -8
- package/commands/pan/focus-design.md +2 -0
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/focus-scan.md +2 -7
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-done.md +1 -2
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/phase-tests.md +2 -2
- package/commands/pan/profile.md +2 -2
- package/commands/pan/research-phase.md +2 -4
- package/commands/pan/review-deep.md +8 -8
- package/commands/pan/todo-add.md +2 -2
- package/commands/pan/todo-check.md +2 -2
- package/hooks/dist/pan-context-monitor.js +1 -1
- package/hooks/dist/pan-cost-logger.js +63 -11
- package/hooks/dist/pan-state-reinject.js +142 -17
- package/hooks/dist/pan-stop-guard.js +2 -2
- package/hooks/dist/pan-trace-logger.js +283 -18
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +4 -1
- package/pan-wizard-core/bin/lib/commands.cjs +95 -2
- package/pan-wizard-core/bin/lib/config.cjs +110 -0
- package/pan-wizard-core/bin/lib/constants.cjs +39 -9
- package/pan-wizard-core/bin/lib/core.cjs +27 -7
- package/pan-wizard-core/bin/lib/cost.cjs +16 -6
- package/pan-wizard-core/bin/lib/findings.cjs +385 -0
- package/pan-wizard-core/bin/lib/focus.cjs +72 -19
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
- package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
- package/pan-wizard-core/bin/lib/init.cjs +9 -2
- package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/phase.cjs +52 -2
- package/pan-wizard-core/bin/lib/review-deep.cjs +38 -2
- package/pan-wizard-core/bin/lib/state.cjs +10 -4
- package/pan-wizard-core/bin/lib/verdict.cjs +481 -0
- package/pan-wizard-core/bin/lib/verify-drift.cjs +14 -5
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +223 -0
- package/pan-wizard-core/bin/lib/verify.cjs +111 -8
- package/pan-wizard-core/bin/pan-tools.cjs +68 -6
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/server.cjs +18 -9
- package/pan-wizard-core/mcp/tool-registry.cjs +2 -0
- package/pan-wizard-core/references/checkpoints.md +4 -2
- package/pan-wizard-core/references/verdict-contract.md +102 -0
- package/pan-wizard-core/templates/discovery.md +1 -1
- package/pan-wizard-core/templates/phase-prompt.md +2 -1
- package/pan-wizard-core/templates/verification-report.md +21 -0
- package/pan-wizard-core/workflows/exec-phase.md +73 -50
- package/pan-wizard-core/workflows/health.md +3 -0
- package/pan-wizard-core/workflows/learn.md +4 -2
- package/pan-wizard-core/workflows/milestone-audit.md +15 -3
- package/pan-wizard-core/workflows/new-project.md +1 -1
- package/pan-wizard-core/workflows/optimize.md +14 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- package/pan-wizard-core/workflows/quick.md +6 -6
- package/pan-wizard-core/workflows/verify-phase.md +10 -15
- package/pan-zcode/README.md +3 -3
- package/scripts/build-agent-plugin.js +19 -5
- package/scripts/git-hooks/pre-commit +2 -1
- package/scripts/test-surface.cjs +4 -0
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
|
-
const { output, error, safeReadFile, toPosix } = require('./core.cjs');
|
|
22
|
+
const { output, error, safeReadFile, toPosix, escapeTableCell } = require('./core.cjs');
|
|
23
23
|
const { planningPath } = require('./utils.cjs');
|
|
24
24
|
const { publish } = require('./bus.cjs');
|
|
25
|
+
const { findVerdictBlocks, validateVerdict } = require('./verdict.cjs');
|
|
25
26
|
|
|
26
27
|
const REVIEWS_DIR = 'reviews';
|
|
27
28
|
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
@@ -41,12 +42,19 @@ function reviewsDir(cwd) {
|
|
|
41
42
|
* Recognized severities (case-insensitive): critical, high, medium, low, info.
|
|
42
43
|
* Missing severity defaults to `info`.
|
|
43
44
|
*
|
|
45
|
+
* A report that ends with a valid `pan-verdict` block (verdict.cjs) is read from
|
|
46
|
+
* the block instead. pan-reviewer's report puts its findings in tables under
|
|
47
|
+
* `### Findings`, which the bullet grammar above never matched, so before the
|
|
48
|
+
* block existed the reviewer half of every deep review merged as zero findings.
|
|
49
|
+
*
|
|
44
50
|
* @param {string} content - Full markdown content
|
|
45
51
|
* @param {string} source - Label for finding.source (e.g. "reviewer", "hardener")
|
|
46
52
|
* @returns {Array<Object>}
|
|
47
53
|
*/
|
|
48
54
|
function parseReviewFindings(content, source) {
|
|
49
55
|
if (typeof content !== 'string' || !content) return [];
|
|
56
|
+
const fromBlock = findingsFromVerdictBlock(content, source);
|
|
57
|
+
if (fromBlock) return fromBlock;
|
|
50
58
|
const findings = [];
|
|
51
59
|
const lines = content.split('\n');
|
|
52
60
|
let inFindings = false;
|
|
@@ -81,6 +89,34 @@ function parseReviewFindings(content, source) {
|
|
|
81
89
|
return findings;
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The findings of a report's last `pan-verdict` block, in this module's finding
|
|
94
|
+
* shape; null when the report has no usable block (then the bullet grammar runs).
|
|
95
|
+
* `where` is `path[:line]`; the class becomes the category.
|
|
96
|
+
*/
|
|
97
|
+
function findingsFromVerdictBlock(content, source) {
|
|
98
|
+
const blocks = findVerdictBlocks(content);
|
|
99
|
+
if (!blocks.length) return null;
|
|
100
|
+
const last = blocks[blocks.length - 1];
|
|
101
|
+
if (!last.closed) return null;
|
|
102
|
+
let parsed;
|
|
103
|
+
try { parsed = JSON.parse(last.body); } catch { return null; }
|
|
104
|
+
const r = validateVerdict(parsed);
|
|
105
|
+
if (!r.ok) return null;
|
|
106
|
+
return r.verdict.findings.map((f) => {
|
|
107
|
+
const m = f.where ? /^(.*?)(?::(\d+))?$/.exec(f.where) : null;
|
|
108
|
+
return {
|
|
109
|
+
source,
|
|
110
|
+
severity: f.severity,
|
|
111
|
+
category: f.class,
|
|
112
|
+
description: f.summary,
|
|
113
|
+
file: m ? m[1] : null,
|
|
114
|
+
line: m && m[2] ? Number(m[2]) : null,
|
|
115
|
+
rationale: null,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
84
120
|
/**
|
|
85
121
|
* Merge findings from the three reviewers into a consolidated list plus
|
|
86
122
|
* a conflict table. A "conflict" is when the meta-reviewer explicitly
|
|
@@ -199,7 +235,7 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
|
|
|
199
235
|
const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
|
|
200
236
|
// Neutralize markdown-table-breaking chars: escape pipes and flatten any
|
|
201
237
|
// newlines so a finding description can't corrupt the table structure.
|
|
202
|
-
const desc =
|
|
238
|
+
const desc = escapeTableCell(f.description);
|
|
203
239
|
lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
|
|
204
240
|
}
|
|
205
241
|
lines.push('');
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error } = require('./core.cjs');
|
|
7
|
+
const { loadConfig, getMilestoneInfo, escapeRegex, escapeTableCell, safeReadFile, output, error } = require('./core.cjs');
|
|
8
8
|
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { withFileLock, writeFileAtomic } = require('./lock.cjs');
|
|
10
10
|
const {
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
PROGRESS_BAR_WIDTH,
|
|
21
21
|
FILLED_BLOCK,
|
|
22
22
|
EMPTY_BLOCK,
|
|
23
|
+
STATE_CONTRACT,
|
|
23
24
|
} = require('./constants.cjs');
|
|
24
25
|
const {
|
|
25
26
|
planningPath,
|
|
@@ -51,6 +52,7 @@ function cmdStateLoad(cwd, raw) {
|
|
|
51
52
|
const stateExists = stateRaw.length > 0;
|
|
52
53
|
|
|
53
54
|
const result = {
|
|
55
|
+
contract: STATE_CONTRACT,
|
|
54
56
|
config,
|
|
55
57
|
state_raw: stateRaw,
|
|
56
58
|
state_exists: stateExists,
|
|
@@ -298,7 +300,8 @@ function cmdStateRecordMetric(cwd, options, raw) {
|
|
|
298
300
|
|
|
299
301
|
if (metricsMatch) {
|
|
300
302
|
let tableBody = metricsMatch[2].trimEnd();
|
|
301
|
-
const
|
|
303
|
+
const cell = escapeTableCell;
|
|
304
|
+
const newRow = `| Phase ${cell(phase)} P${cell(plan)} | ${cell(duration)} | ${cell(tasks || '-')} tasks | ${cell(files || '-')} files |`;
|
|
302
305
|
|
|
303
306
|
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
|
|
304
307
|
tableBody = newRow;
|
|
@@ -976,12 +979,15 @@ function cmdStateJson(cwd, raw) {
|
|
|
976
979
|
|
|
977
980
|
if (!frontmatter || Object.keys(frontmatter).length === 0) {
|
|
978
981
|
const body = stripFrontmatter(content).replace(/\r\n/g, '\n');
|
|
979
|
-
const built = buildStateFrontmatter(body, cwd);
|
|
982
|
+
const built = { ...buildStateFrontmatter(body, cwd), contract: STATE_CONTRACT };
|
|
980
983
|
output(built, raw, JSON.stringify(built, null, 2));
|
|
981
984
|
return;
|
|
982
985
|
}
|
|
983
986
|
|
|
984
|
-
output
|
|
987
|
+
// `contract` is the output's shape version (STATE_CONTRACT), never a state.md
|
|
988
|
+
// field: PAN's state frontmatter has no such key, so nothing is shadowed.
|
|
989
|
+
const withContract = { ...frontmatter, contract: STATE_CONTRACT };
|
|
990
|
+
output(withContract, raw, JSON.stringify(withContract, null, 2));
|
|
985
991
|
}
|
|
986
992
|
|
|
987
993
|
/**
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Verdict contract — the machine-readable half of a judging agent's report
|
|
4
|
+
* (evidence loop, docs/specs/evidence_loop_featureai.md §3.3; ADR-0049).
|
|
5
|
+
*
|
|
6
|
+
* The judges that return text (pan-plan-checker, pan-reviewer, pan-design-checker)
|
|
7
|
+
* end their report with a fenced `pan-verdict` JSON block. The verifier already
|
|
8
|
+
* writes a machine contract — the verification.md frontmatter that
|
|
9
|
+
* `/pan:plan-phase --gaps` consumes — so its verdict is read from that frontmatter
|
|
10
|
+
* instead of from a second block that could disagree with it.
|
|
11
|
+
*
|
|
12
|
+
* Pure: no filesystem access, no process exit. findings.cjs does the IO.
|
|
13
|
+
*
|
|
14
|
+
* Contract rules (D11): `contract` is "1.x"; fields may be ADDED within a major
|
|
15
|
+
* version and are never renamed or removed; readers accept any 1.x and ignore
|
|
16
|
+
* fields they do not know; an unknown major is refused.
|
|
17
|
+
*/
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
const VERDICT_CONTRACT = '1.0';
|
|
21
|
+
const OUTCOMES = Object.freeze(['pass', 'fail', 'needs_human']);
|
|
22
|
+
// Spec Kit's converge classes (missing / partial / contradicts / unrequested) for the
|
|
23
|
+
// verification side, plus the review side's defect / risk / quality, plus `human`
|
|
24
|
+
// for what no check can settle mechanically.
|
|
25
|
+
const FINDING_CLASSES = Object.freeze(['missing', 'partial', 'contradicts', 'unrequested', 'defect', 'risk', 'quality', 'human']);
|
|
26
|
+
// The one severity ladder in PAN — identical to review-deep.cjs SEVERITIES
|
|
27
|
+
// (tests/verdict.test.cjs pins the equality; a cycle stops this module importing it).
|
|
28
|
+
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low', 'info']);
|
|
29
|
+
const DISPOSITIONS = Object.freeze(['fixed', 'deferred', 'dismissed', 'decision']);
|
|
30
|
+
const REASON_REQUIRED = Object.freeze(['deferred', 'dismissed', 'decision']);
|
|
31
|
+
// A re-run does not re-check these, so their absence from a later verdict is not
|
|
32
|
+
// evidence that they were resolved (D6).
|
|
33
|
+
const AUTO_FIX_EXEMPT = Object.freeze(['human', 'unrequested']);
|
|
34
|
+
|
|
35
|
+
/** Each judge's own verdict word → the normalised outcome it implies. */
|
|
36
|
+
const KNOWN_VERDICTS = Object.freeze({
|
|
37
|
+
'pan-verifier': Object.freeze({ passed: 'pass', gaps_found: 'fail', human_needed: 'needs_human' }),
|
|
38
|
+
'pan-plan-checker': Object.freeze({ passed: 'pass', issues_found: 'fail' }),
|
|
39
|
+
'pan-reviewer': Object.freeze({ PASS: 'pass', PASS_WITH_WARNINGS: 'pass', NEEDS_FIXES: 'fail' }),
|
|
40
|
+
'pan-design-checker': Object.freeze({ PASS: 'pass', GAPS: 'fail' }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const LIMITS = Object.freeze({ findings: 100, where: 200, summary: 300, verdict: 40, score: 40, not_checked: 20, check: 80 });
|
|
44
|
+
// What the verifier's test gate can report (verify-phase.md, run_test_suite).
|
|
45
|
+
const TEST_GATE_VALUES = Object.freeze(['passed', 'failed', 'skipped']);
|
|
46
|
+
const AGENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
47
|
+
const OPEN_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*pan-verdict[ \t]*$/;
|
|
48
|
+
const CLOSE_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
|
|
49
|
+
|
|
50
|
+
function sha256(s) {
|
|
51
|
+
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** EOL-normalised text: BOM dropped, CRLF → LF. */
|
|
55
|
+
function normaliseEol(text) {
|
|
56
|
+
return String(text == null ? '' : text).replace(/^/, '').replace(/\r\n/g, '\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Phase numbers compare without their zero padding ("03" === "3", "03.1" === "3.1"). */
|
|
60
|
+
function comparablePhase(p) {
|
|
61
|
+
return String(p == null ? '' : p).trim().replace(/^0+(?=\d)/, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every fenced `pan-verdict` block in `text`, in order. Backtick or tilde fences,
|
|
66
|
+
* any leading indentation (the host indents framed subagent results). An
|
|
67
|
+
* unterminated block runs to the end of the text and is marked `closed: false`.
|
|
68
|
+
* @returns {{body: string, closed: boolean}[]}
|
|
69
|
+
*/
|
|
70
|
+
function findVerdictBlocks(text) {
|
|
71
|
+
const lines = normaliseEol(text).split('\n');
|
|
72
|
+
const blocks = [];
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const open = OPEN_FENCE_RE.exec(lines[i]);
|
|
75
|
+
if (!open) continue;
|
|
76
|
+
const fence = open[1];
|
|
77
|
+
const body = [];
|
|
78
|
+
let closed = false;
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
for (; j < lines.length; j++) {
|
|
81
|
+
const close = CLOSE_FENCE_RE.exec(lines[j]);
|
|
82
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) { closed = true; break; }
|
|
83
|
+
body.push(lines[j]);
|
|
84
|
+
}
|
|
85
|
+
blocks.push({ body: body.join('\n'), closed });
|
|
86
|
+
i = j;
|
|
87
|
+
}
|
|
88
|
+
return blocks;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function truncate(s, max) {
|
|
92
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cleanText(v) {
|
|
96
|
+
return String(v).replace(/\s+/g, ' ').trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalise contract `1`, `"1"`, `"1.x"` to a `1.x` string; null for anything else.
|
|
101
|
+
*/
|
|
102
|
+
function normaliseContract(c) {
|
|
103
|
+
if (c === 1 || c === '1') return VERDICT_CONTRACT;
|
|
104
|
+
if (typeof c === 'string' && /^1\.\d+$/.test(c.trim())) return c.trim();
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate and normalise a parsed verdict object.
|
|
110
|
+
*
|
|
111
|
+
* Refuses (ok:false) only what makes the verdict unusable: not an object, an
|
|
112
|
+
* unsupported contract, no agent, no valid outcome. Everything else is recorded
|
|
113
|
+
* in its closest valid form and reported in `warnings`.
|
|
114
|
+
*
|
|
115
|
+
* @param {*} obj - the parsed block
|
|
116
|
+
* @param {{agent?: string, phase?: string|number}} [opts] - the orchestrator's view:
|
|
117
|
+
* the agent it spawned (wins over the block's own claim) and the phase it asked about
|
|
118
|
+
* @returns {{ok: true, verdict: Object, warnings: string[]}|{ok: false, error: string, reason: string}}
|
|
119
|
+
*/
|
|
120
|
+
function validateVerdict(obj, opts = {}) {
|
|
121
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
122
|
+
return { ok: false, error: 'invalid_verdict', reason: 'the pan-verdict block must hold a JSON object' };
|
|
123
|
+
}
|
|
124
|
+
const warnings = [];
|
|
125
|
+
const contract = normaliseContract(obj.contract);
|
|
126
|
+
if (!contract) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: 'unsupported_contract',
|
|
130
|
+
reason: `contract ${JSON.stringify(obj.contract === undefined ? null : obj.contract)} is not a supported 1.x version`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const claimed = typeof obj.agent === 'string' ? obj.agent.trim() : '';
|
|
135
|
+
const given = typeof opts.agent === 'string' ? opts.agent.trim() : '';
|
|
136
|
+
const agent = given || claimed;
|
|
137
|
+
if (!AGENT_RE.test(agent)) {
|
|
138
|
+
return { ok: false, error: 'missing_agent', reason: 'no valid agent name (block "agent" or --agent)' };
|
|
139
|
+
}
|
|
140
|
+
if (given && claimed && given !== claimed) warnings.push(`agent_mismatch: the block says ${claimed}, recorded as ${given}`);
|
|
141
|
+
|
|
142
|
+
const outcome = typeof obj.outcome === 'string' ? obj.outcome.trim().toLowerCase() : '';
|
|
143
|
+
if (!OUTCOMES.includes(outcome)) {
|
|
144
|
+
return { ok: false, error: 'invalid_outcome', reason: `outcome must be one of ${OUTCOMES.join(', ')}` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let verdict = null;
|
|
148
|
+
if (obj.verdict != null && String(obj.verdict).trim() !== '') {
|
|
149
|
+
verdict = String(obj.verdict).trim();
|
|
150
|
+
if (verdict.length > LIMITS.verdict) {
|
|
151
|
+
verdict = verdict.slice(0, LIMITS.verdict);
|
|
152
|
+
warnings.push('verdict_truncated');
|
|
153
|
+
}
|
|
154
|
+
const implied = KNOWN_VERDICTS[agent] && Object.prototype.hasOwnProperty.call(KNOWN_VERDICTS[agent], verdict)
|
|
155
|
+
? KNOWN_VERDICTS[agent][verdict] : null;
|
|
156
|
+
if (implied && implied !== outcome) warnings.push(`outcome_mismatch: ${verdict} implies ${implied}, the block says ${outcome}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const phase = obj.phase == null || String(obj.phase).trim() === '' ? null : String(obj.phase).trim();
|
|
160
|
+
if (phase && opts.phase != null && comparablePhase(phase) !== comparablePhase(opts.phase)) {
|
|
161
|
+
warnings.push(`phase_mismatch: the block says ${phase}, recorded under ${opts.phase}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const score = obj.score == null ? null : truncate(cleanText(obj.score), LIMITS.score);
|
|
165
|
+
|
|
166
|
+
let raw = [];
|
|
167
|
+
if (obj.findings != null) {
|
|
168
|
+
if (Array.isArray(obj.findings)) raw = obj.findings;
|
|
169
|
+
else warnings.push('findings_not_an_array');
|
|
170
|
+
}
|
|
171
|
+
if (raw.length > LIMITS.findings) {
|
|
172
|
+
warnings.push(`findings_truncated: ${raw.length} reported, ${LIMITS.findings} kept`);
|
|
173
|
+
raw = raw.slice(0, LIMITS.findings);
|
|
174
|
+
}
|
|
175
|
+
const findings = [];
|
|
176
|
+
raw.forEach((f, i) => {
|
|
177
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) || typeof f.summary !== 'string' || !cleanText(f.summary)) {
|
|
178
|
+
warnings.push(`finding_dropped: #${i} has no summary`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let cls = typeof f.class === 'string' ? f.class.trim().toLowerCase() : '';
|
|
182
|
+
if (!FINDING_CLASSES.includes(cls)) {
|
|
183
|
+
warnings.push(`unknown_class: #${i} ${JSON.stringify(f.class === undefined ? null : f.class)} recorded as quality`);
|
|
184
|
+
cls = 'quality';
|
|
185
|
+
}
|
|
186
|
+
let severity = typeof f.severity === 'string' ? f.severity.trim().toLowerCase() : '';
|
|
187
|
+
if (!SEVERITIES.includes(severity)) {
|
|
188
|
+
warnings.push(`unknown_severity: #${i} ${JSON.stringify(f.severity === undefined ? null : f.severity)} recorded as info`);
|
|
189
|
+
severity = 'info';
|
|
190
|
+
}
|
|
191
|
+
let summary = cleanText(f.summary);
|
|
192
|
+
if (summary.length > LIMITS.summary) {
|
|
193
|
+
summary = truncate(summary, LIMITS.summary);
|
|
194
|
+
warnings.push(`summary_truncated: #${i}`);
|
|
195
|
+
}
|
|
196
|
+
const where = f.where == null || cleanText(f.where) === '' ? null : truncate(cleanText(f.where).replace(/\\/g, '/'), LIMITS.where);
|
|
197
|
+
findings.push({ class: cls, severity, where, summary });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const notChecked = normaliseNotChecked(obj.not_checked, warnings);
|
|
201
|
+
return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings, not_checked: notChecked }, warnings };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The checks a judge could not run, each `{check, reason}` (market-ideas M23).
|
|
206
|
+
* A verdict that carries them says what it did not see: a `pass` with tests that
|
|
207
|
+
* never ran is recorded as exactly that, never as a plain pass. Accepts bare
|
|
208
|
+
* strings as checks with no reason; drops entries with no check.
|
|
209
|
+
*/
|
|
210
|
+
function normaliseNotChecked(value, warnings) {
|
|
211
|
+
if (value == null) return [];
|
|
212
|
+
if (!Array.isArray(value)) {
|
|
213
|
+
warnings.push('not_checked_not_an_array');
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const entry of value.slice(0, LIMITS.not_checked)) {
|
|
218
|
+
const obj = typeof entry === 'string' ? { check: entry } : (entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null);
|
|
219
|
+
const check = obj ? truncate(cleanText(obj.check ?? obj.gate ?? ''), LIMITS.check) : '';
|
|
220
|
+
if (!check) continue;
|
|
221
|
+
const reason = obj.reason == null || cleanText(obj.reason) === '' ? null : truncate(cleanText(obj.reason), LIMITS.summary);
|
|
222
|
+
out.push({ check, reason });
|
|
223
|
+
}
|
|
224
|
+
if (value.length > LIMITS.not_checked) warnings.push(`not_checked_truncated: ${value.length} reported, ${LIMITS.not_checked} kept`);
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── verification.md frontmatter adapter ─────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/** The YAML between the leading `---` fences, or null. */
|
|
231
|
+
function frontmatterText(text) {
|
|
232
|
+
const m = normaliseEol(text).match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
233
|
+
return m ? m[1] : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A YAML value without its trailing comment. The verifier's templates annotate list
|
|
238
|
+
* keys (`gaps: # Only if status: gaps_found`), and a model copying the template
|
|
239
|
+
* copies the comment, so `# …` must not read as a value. A quoted value ends at its
|
|
240
|
+
* closing quote.
|
|
241
|
+
*/
|
|
242
|
+
function stripComment(v) {
|
|
243
|
+
const s = String(v == null ? '' : v).trim();
|
|
244
|
+
if (s[0] === '"' || s[0] === "'") {
|
|
245
|
+
const q = s[0];
|
|
246
|
+
for (let i = 1; i < s.length; i++) {
|
|
247
|
+
if (q === '"' && s[i] === '\\') { i++; continue; }
|
|
248
|
+
if (s[i] === q) {
|
|
249
|
+
if (q === "'" && s[i + 1] === "'") { i++; continue; }
|
|
250
|
+
return s.slice(0, i + 1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
return s.replace(/(^|\s)#.*$/, '').trim();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function unquote(v) {
|
|
259
|
+
const s = stripComment(v);
|
|
260
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
261
|
+
try { return JSON.parse(s); } catch { return s.slice(1, -1); }
|
|
262
|
+
}
|
|
263
|
+
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1).replace(/''/g, "'");
|
|
264
|
+
return s;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const KEY_VALUE_RE = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/;
|
|
268
|
+
const ITEM_RE = /^([ \t]*)-[ \t]*(.*)$/;
|
|
269
|
+
const indentOf = (line) => /^[ \t]*/.exec(line)[0].length;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A top-level YAML list of mappings, in the shape the verifier template writes:
|
|
273
|
+
*
|
|
274
|
+
* gaps:
|
|
275
|
+
* - truth: "…"
|
|
276
|
+
* status: failed
|
|
277
|
+
* artifacts:
|
|
278
|
+
* - path: "src/x.ts"
|
|
279
|
+
*
|
|
280
|
+
* Each item comes back as its scalar fields plus `_first_path` (the first nested
|
|
281
|
+
* `path:` value, for `artifacts`). PAN's general frontmatter parser reads such a
|
|
282
|
+
* list as bare strings (`'truth: "…'`), so this reader exists for the adapter alone
|
|
283
|
+
* rather than changing a parser every other module depends on.
|
|
284
|
+
*/
|
|
285
|
+
function listOfMappings(yaml, key) {
|
|
286
|
+
const lines = yaml.split('\n');
|
|
287
|
+
const start = lines.findIndex((l) => {
|
|
288
|
+
const m = KEY_VALUE_RE.exec(l);
|
|
289
|
+
return m && m[1] === key && indentOf(l) === 0;
|
|
290
|
+
});
|
|
291
|
+
if (start === -1) return [];
|
|
292
|
+
const head = KEY_VALUE_RE.exec(lines[start]);
|
|
293
|
+
if (stripComment(head[2]) !== '') return []; // `gaps: []` or a scalar — nothing to read
|
|
294
|
+
const items = [];
|
|
295
|
+
let itemIndent = -1;
|
|
296
|
+
let item = null;
|
|
297
|
+
let contentIndent = -1;
|
|
298
|
+
let nested = null;
|
|
299
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
300
|
+
const line = lines[i];
|
|
301
|
+
if (!line.trim()) continue;
|
|
302
|
+
const ind = indentOf(line);
|
|
303
|
+
const isItem = ITEM_RE.exec(line);
|
|
304
|
+
if (ind === 0 && !isItem) break; // the next top-level key
|
|
305
|
+
if (isItem && (itemIndent === -1 || ind === itemIndent)) {
|
|
306
|
+
if (itemIndent === -1) itemIndent = ind;
|
|
307
|
+
item = {};
|
|
308
|
+
items.push(item);
|
|
309
|
+
contentIndent = ind + (line.length - line.replace(/^[ \t]*-[ \t]*/, '').length) - ind;
|
|
310
|
+
nested = null;
|
|
311
|
+
const kv = KEY_VALUE_RE.exec(isItem[2]);
|
|
312
|
+
if (kv) {
|
|
313
|
+
if (stripComment(kv[2]) === '') nested = kv[1];
|
|
314
|
+
else item[kv[1]] = unquote(kv[2]);
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!item) continue;
|
|
319
|
+
const body = line.trim();
|
|
320
|
+
if (ind === contentIndent) {
|
|
321
|
+
const kv = KEY_VALUE_RE.exec(body);
|
|
322
|
+
if (!kv) continue;
|
|
323
|
+
if (stripComment(kv[2]) === '') { nested = kv[1]; continue; }
|
|
324
|
+
item[kv[1]] = unquote(kv[2]);
|
|
325
|
+
nested = null;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (ind > contentIndent && nested) {
|
|
329
|
+
const inner = body.replace(/^-[ \t]*/, '');
|
|
330
|
+
const kv = KEY_VALUE_RE.exec(inner);
|
|
331
|
+
if (kv && kv[1] === 'path' && item._first_path === undefined && stripComment(kv[2]) !== '') {
|
|
332
|
+
item._first_path = unquote(kv[2]);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return items;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A top-level scalar from the frontmatter (e.g. `status`), or null. */
|
|
340
|
+
function frontmatterScalar(yaml, key) {
|
|
341
|
+
for (const l of yaml.split('\n')) {
|
|
342
|
+
const m = KEY_VALUE_RE.exec(l);
|
|
343
|
+
if (m && m[1] === key && indentOf(l) === 0) {
|
|
344
|
+
const v = unquote(m[2]);
|
|
345
|
+
return v === '' ? null : v;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build a raw verdict object from a verification.md frontmatter (the adapter, D2).
|
|
353
|
+
* `status` → verdict and outcome; `gaps[]` → missing (failed) / partial;
|
|
354
|
+
* `human_verification[]` → human; `unrequested[]` (M11) → unrequested.
|
|
355
|
+
* @returns {Object|null} a raw verdict for validateVerdict, or null when the text
|
|
356
|
+
* carries no verification frontmatter status
|
|
357
|
+
*/
|
|
358
|
+
function verdictFromVerificationFrontmatter(text, agent = 'pan-verifier') {
|
|
359
|
+
const yaml = frontmatterText(text);
|
|
360
|
+
if (yaml === null) return null;
|
|
361
|
+
const status = frontmatterScalar(yaml, 'status');
|
|
362
|
+
if (!status) return null;
|
|
363
|
+
const table = KNOWN_VERDICTS['pan-verifier'];
|
|
364
|
+
const outcome = Object.prototype.hasOwnProperty.call(table, status) ? table[status] : null;
|
|
365
|
+
const findings = [];
|
|
366
|
+
for (const g of listOfMappings(yaml, 'gaps')) {
|
|
367
|
+
const truth = g.truth ? String(g.truth) : '';
|
|
368
|
+
const reason = g.reason ? String(g.reason) : '';
|
|
369
|
+
const summary = truth && reason ? `${truth} — ${reason}` : (truth || reason);
|
|
370
|
+
if (!summary) continue;
|
|
371
|
+
const partial = String(g.status || '').toLowerCase() === 'partial';
|
|
372
|
+
findings.push({ class: partial ? 'partial' : 'missing', severity: partial ? 'medium' : 'high', where: g._first_path || null, summary });
|
|
373
|
+
}
|
|
374
|
+
for (const h of listOfMappings(yaml, 'human_verification')) {
|
|
375
|
+
const summary = h.test ? String(h.test) + (h.expected ? ` — expected: ${h.expected}` : '') : '';
|
|
376
|
+
if (summary) findings.push({ class: 'human', severity: 'info', where: null, summary });
|
|
377
|
+
}
|
|
378
|
+
for (const u of listOfMappings(yaml, 'unrequested')) {
|
|
379
|
+
const what = u.what ? String(u.what) : (u.why ? String(u.why) : '');
|
|
380
|
+
const path = u.path ? String(u.path) : null;
|
|
381
|
+
const summary = what || (path ? `unrequested change to ${path}` : '');
|
|
382
|
+
if (summary) findings.push({ class: 'unrequested', severity: 'low', where: path, summary });
|
|
383
|
+
}
|
|
384
|
+
// The test gate (M23). `not_checked` lists what the verifier could not run; a
|
|
385
|
+
// `test_gate: skipped` with no tests entry there still counts as one, so a model
|
|
386
|
+
// that records the gate but forgets the list cannot turn it into a plain pass.
|
|
387
|
+
let notChecked = listOfMappings(yaml, 'not_checked').map((n) => ({ check: n.check || n.gate || '', reason: n.reason || null }));
|
|
388
|
+
const testGateRaw = frontmatterScalar(yaml, 'test_gate');
|
|
389
|
+
const testGate = testGateRaw ? String(testGateRaw).toLowerCase() : null;
|
|
390
|
+
const isTests = (n) => /test/i.test(String(n.check));
|
|
391
|
+
let contradicted = false;
|
|
392
|
+
if (testGate === 'skipped' && !notChecked.some(isTests)) {
|
|
393
|
+
notChecked.push({ check: 'tests', reason: frontmatterScalar(yaml, 'test_gate_reason') || 'the test gate was skipped' });
|
|
394
|
+
} else if ((testGate === 'passed' || testGate === 'failed') && notChecked.some(isTests)) {
|
|
395
|
+
// The gate ran, so a `tests` entry is the template's example copied over: the
|
|
396
|
+
// recorded gate is the measurement, and the entry goes.
|
|
397
|
+
notChecked = notChecked.filter((n) => !isTests(n));
|
|
398
|
+
contradicted = true;
|
|
399
|
+
}
|
|
400
|
+
return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings, not_checked: notChecked, test_gate: testGate, not_checked_contradicted: contradicted };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Read a judge's verdict from an artifact: the LAST `pan-verdict` block wins; with
|
|
405
|
+
* no block, a verification.md frontmatter `status` is read through the adapter.
|
|
406
|
+
* @param {string} text
|
|
407
|
+
* @param {{agent?: string, phase?: string|number}} [opts]
|
|
408
|
+
* @returns {{ok: true, verdict: Object, warnings: string[], source_kind: 'block'|'frontmatter'}
|
|
409
|
+
* |{ok: false, error: string, reason: string}}
|
|
410
|
+
*/
|
|
411
|
+
function parseVerdictText(text, opts = {}) {
|
|
412
|
+
const blocks = findVerdictBlocks(text);
|
|
413
|
+
if (blocks.length) {
|
|
414
|
+
const last = blocks[blocks.length - 1];
|
|
415
|
+
if (!last.closed) return { ok: false, error: 'unterminated_block', reason: 'the last pan-verdict block has no closing fence' };
|
|
416
|
+
let obj;
|
|
417
|
+
try { obj = JSON.parse(last.body); } catch (e) {
|
|
418
|
+
return { ok: false, error: 'invalid_json', reason: `the last pan-verdict block is not valid JSON: ${e.message}` };
|
|
419
|
+
}
|
|
420
|
+
const r = validateVerdict(obj, opts);
|
|
421
|
+
return r.ok ? { ...r, source_kind: 'block' } : r;
|
|
422
|
+
}
|
|
423
|
+
const adapted = verdictFromVerificationFrontmatter(text, opts.agent || 'pan-verifier');
|
|
424
|
+
if (adapted) {
|
|
425
|
+
if (!adapted.outcome) {
|
|
426
|
+
return { ok: false, error: 'unknown_status', reason: `verification status "${adapted.verdict}" is not passed, gaps_found or human_needed` };
|
|
427
|
+
}
|
|
428
|
+
const r = validateVerdict(adapted, opts);
|
|
429
|
+
if (!r.ok) return r;
|
|
430
|
+
// Say when the verification does not record its test gate, or records a failed
|
|
431
|
+
// gate under a pass: both make "passed" claim more than the verifier checked.
|
|
432
|
+
if (!adapted.test_gate) r.warnings.push('test_gate_unrecorded: the verification does not say whether the test suite ran');
|
|
433
|
+
else if (!TEST_GATE_VALUES.includes(adapted.test_gate)) r.warnings.push(`test_gate_unknown: "${adapted.test_gate}" is not passed, failed or skipped`);
|
|
434
|
+
else if (adapted.test_gate === 'failed' && adapted.outcome === 'pass') r.warnings.push('test_gate_failed_under_pass: the test gate failed but the status is passed');
|
|
435
|
+
if (adapted.not_checked_contradicted) r.warnings.push(`not_checked_contradicted: test_gate is ${adapted.test_gate}, so the not_checked tests entry was dropped`);
|
|
436
|
+
return { ...r, source_kind: 'frontmatter' };
|
|
437
|
+
}
|
|
438
|
+
return { ok: false, error: 'no_verdict', reason: 'no pan-verdict block and no verification frontmatter status' };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Identity of the artifact a verdict was read from: EOL-normalised content hash. */
|
|
442
|
+
function recordSig(text) {
|
|
443
|
+
return sha256(normaliseEol(text).replace(/\s+$/, '')).slice(0, 16);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A finding's stable id: the same finding reported again keeps its id, so a
|
|
448
|
+
* re-verification can say which earlier findings it no longer reports.
|
|
449
|
+
*/
|
|
450
|
+
function findingId(phase, agent, finding) {
|
|
451
|
+
const summary = cleanText(finding.summary || '').toLowerCase().replace(/[.;:,!]+$/, '');
|
|
452
|
+
const where = finding.where ? cleanText(finding.where).replace(/\\/g, '/') : '';
|
|
453
|
+
return 'f_' + sha256([comparablePhase(phase), agent, finding.class, where, summary].join('\u0000')).slice(0, 10);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The outcome an agent's own verdict word implies, or null when it is not a known pair. */
|
|
457
|
+
function outcomeForVerdict(agent, verdict) {
|
|
458
|
+
const table = KNOWN_VERDICTS[agent];
|
|
459
|
+
return table && Object.prototype.hasOwnProperty.call(table, verdict) ? table[verdict] : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = {
|
|
463
|
+
VERDICT_CONTRACT,
|
|
464
|
+
OUTCOMES,
|
|
465
|
+
FINDING_CLASSES,
|
|
466
|
+
SEVERITIES,
|
|
467
|
+
DISPOSITIONS,
|
|
468
|
+
REASON_REQUIRED,
|
|
469
|
+
AUTO_FIX_EXEMPT,
|
|
470
|
+
KNOWN_VERDICTS,
|
|
471
|
+
LIMITS,
|
|
472
|
+
findVerdictBlocks,
|
|
473
|
+
validateVerdict,
|
|
474
|
+
verdictFromVerificationFrontmatter,
|
|
475
|
+
parseVerdictText,
|
|
476
|
+
recordSig,
|
|
477
|
+
findingId,
|
|
478
|
+
outcomeForVerdict,
|
|
479
|
+
comparablePhase,
|
|
480
|
+
normaliseEol,
|
|
481
|
+
};
|
|
@@ -13,14 +13,24 @@ const {
|
|
|
13
13
|
const { planningPath } = require('./utils.cjs');
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* The codebase map's conventions document. map-codebase writes `conventions.md`;
|
|
17
|
+
* a map from an older PAN, or one written by hand, may be `CONVENTIONS.md`. Only a
|
|
18
|
+
* case-sensitive filesystem tells the two apart, which is why reading the uppercase
|
|
19
|
+
* name alone found nothing on Linux (prompt audit, market-ideas M34).
|
|
20
|
+
*/
|
|
21
|
+
function readConventions(cwd) {
|
|
22
|
+
const dir = path.join(planningPath(cwd), 'codebase');
|
|
23
|
+
return safeReadFile(path.join(dir, 'conventions.md')) || safeReadFile(path.join(dir, 'CONVENTIONS.md'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse convention rules from the conventions document's markdown content.
|
|
17
28
|
* Extracts anti-pattern rules from prose containing "instead of", "not", "never".
|
|
18
29
|
* Run drift check internally and return result object (no output).
|
|
19
30
|
* Used by cmdValidateHealth --drift.
|
|
20
31
|
*/
|
|
21
32
|
function runDriftCheck(cwd) {
|
|
22
|
-
const
|
|
23
|
-
const conventionsContent = safeReadFile(conventionsPath);
|
|
33
|
+
const conventionsContent = readConventions(cwd);
|
|
24
34
|
const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
|
|
25
35
|
const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
|
|
26
36
|
const rules = parseConventionRules(combined || null);
|
|
@@ -198,8 +208,7 @@ function cmdDriftCheck(cwd, raw, args) {
|
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
// Load convention rules
|
|
201
|
-
const
|
|
202
|
-
const conventionsContent = safeReadFile(conventionsPath);
|
|
211
|
+
const conventionsContent = readConventions(cwd);
|
|
203
212
|
const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
|
|
204
213
|
const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
|
|
205
214
|
const rules = parseConventionRules(combined || null);
|
|
@@ -102,10 +102,12 @@ function cmdPreflight(cwd, target, raw) {
|
|
|
102
102
|
const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/);
|
|
103
103
|
const currentPhase = currentPhaseMatch ? currentPhaseMatch[1] : null;
|
|
104
104
|
if (target === 'batch') {
|
|
105
|
-
// Check that a batch file exists
|
|
105
|
+
// Check that a batch file exists, and name the one `focus exec` would run (the
|
|
106
|
+
// shared ordering in focus.cjs; required lazily, it is only needed here)
|
|
106
107
|
const focusDir = path.join(planDir, 'focus');
|
|
107
108
|
try {
|
|
108
|
-
|
|
109
|
+
fs.readdirSync(focusDir);
|
|
110
|
+
const files = require('./focus.cjs').listBatchFiles(cwd);
|
|
109
111
|
if (files.length > 0) {
|
|
110
112
|
checks.push({ name: 'batch_exists', passed: true, detail: files[files.length - 1] });
|
|
111
113
|
} else {
|