pan-wizard 3.31.0 → 3.32.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-design-checker.md +18 -0
- package/agents/pan-optimizer.md +5 -1
- package/agents/pan-plan-checker.md +23 -0
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-verifier.md +23 -0
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/review-deep.md +5 -5
- 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 +2 -2
- 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/commands.cjs +2 -1
- package/pan-wizard-core/bin/lib/constants.cjs +28 -0
- package/pan-wizard-core/bin/lib/cost.cjs +3 -3
- package/pan-wizard-core/bin/lib/findings.cjs +381 -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 +4 -1
- package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
- package/pan-wizard-core/bin/lib/state.cjs +7 -2
- package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
- package/pan-wizard-core/bin/lib/verify.cjs +25 -2
- package/pan-wizard-core/bin/pan-tools.cjs +59 -5
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/tool-registry.cjs +2 -0
- package/pan-wizard-core/references/verdict-contract.md +101 -0
- package/pan-wizard-core/templates/verification-report.md +16 -0
- package/pan-wizard-core/workflows/exec-phase.md +65 -47
- package/pan-wizard-core/workflows/health.md +2 -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/optimize.md +14 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- package/pan-wizard-core/workflows/verify-phase.md +3 -13
- package/pan-zcode/README.md +3 -3
- package/scripts/git-hooks/pre-commit +2 -1
|
@@ -0,0 +1,431 @@
|
|
|
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 });
|
|
44
|
+
const AGENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
45
|
+
const OPEN_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*pan-verdict[ \t]*$/;
|
|
46
|
+
const CLOSE_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
|
|
47
|
+
|
|
48
|
+
function sha256(s) {
|
|
49
|
+
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** EOL-normalised text: BOM dropped, CRLF → LF. */
|
|
53
|
+
function normaliseEol(text) {
|
|
54
|
+
return String(text == null ? '' : text).replace(/^/, '').replace(/\r\n/g, '\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Phase numbers compare without their zero padding ("03" === "3", "03.1" === "3.1"). */
|
|
58
|
+
function comparablePhase(p) {
|
|
59
|
+
return String(p == null ? '' : p).trim().replace(/^0+(?=\d)/, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every fenced `pan-verdict` block in `text`, in order. Backtick or tilde fences,
|
|
64
|
+
* any leading indentation (the host indents framed subagent results). An
|
|
65
|
+
* unterminated block runs to the end of the text and is marked `closed: false`.
|
|
66
|
+
* @returns {{body: string, closed: boolean}[]}
|
|
67
|
+
*/
|
|
68
|
+
function findVerdictBlocks(text) {
|
|
69
|
+
const lines = normaliseEol(text).split('\n');
|
|
70
|
+
const blocks = [];
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const open = OPEN_FENCE_RE.exec(lines[i]);
|
|
73
|
+
if (!open) continue;
|
|
74
|
+
const fence = open[1];
|
|
75
|
+
const body = [];
|
|
76
|
+
let closed = false;
|
|
77
|
+
let j = i + 1;
|
|
78
|
+
for (; j < lines.length; j++) {
|
|
79
|
+
const close = CLOSE_FENCE_RE.exec(lines[j]);
|
|
80
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) { closed = true; break; }
|
|
81
|
+
body.push(lines[j]);
|
|
82
|
+
}
|
|
83
|
+
blocks.push({ body: body.join('\n'), closed });
|
|
84
|
+
i = j;
|
|
85
|
+
}
|
|
86
|
+
return blocks;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncate(s, max) {
|
|
90
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanText(v) {
|
|
94
|
+
return String(v).replace(/\s+/g, ' ').trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalise contract `1`, `"1"`, `"1.x"` to a `1.x` string; null for anything else.
|
|
99
|
+
*/
|
|
100
|
+
function normaliseContract(c) {
|
|
101
|
+
if (c === 1 || c === '1') return VERDICT_CONTRACT;
|
|
102
|
+
if (typeof c === 'string' && /^1\.\d+$/.test(c.trim())) return c.trim();
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate and normalise a parsed verdict object.
|
|
108
|
+
*
|
|
109
|
+
* Refuses (ok:false) only what makes the verdict unusable: not an object, an
|
|
110
|
+
* unsupported contract, no agent, no valid outcome. Everything else is recorded
|
|
111
|
+
* in its closest valid form and reported in `warnings`.
|
|
112
|
+
*
|
|
113
|
+
* @param {*} obj - the parsed block
|
|
114
|
+
* @param {{agent?: string, phase?: string|number}} [opts] - the orchestrator's view:
|
|
115
|
+
* the agent it spawned (wins over the block's own claim) and the phase it asked about
|
|
116
|
+
* @returns {{ok: true, verdict: Object, warnings: string[]}|{ok: false, error: string, reason: string}}
|
|
117
|
+
*/
|
|
118
|
+
function validateVerdict(obj, opts = {}) {
|
|
119
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
120
|
+
return { ok: false, error: 'invalid_verdict', reason: 'the pan-verdict block must hold a JSON object' };
|
|
121
|
+
}
|
|
122
|
+
const warnings = [];
|
|
123
|
+
const contract = normaliseContract(obj.contract);
|
|
124
|
+
if (!contract) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: 'unsupported_contract',
|
|
128
|
+
reason: `contract ${JSON.stringify(obj.contract === undefined ? null : obj.contract)} is not a supported 1.x version`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const claimed = typeof obj.agent === 'string' ? obj.agent.trim() : '';
|
|
133
|
+
const given = typeof opts.agent === 'string' ? opts.agent.trim() : '';
|
|
134
|
+
const agent = given || claimed;
|
|
135
|
+
if (!AGENT_RE.test(agent)) {
|
|
136
|
+
return { ok: false, error: 'missing_agent', reason: 'no valid agent name (block "agent" or --agent)' };
|
|
137
|
+
}
|
|
138
|
+
if (given && claimed && given !== claimed) warnings.push(`agent_mismatch: the block says ${claimed}, recorded as ${given}`);
|
|
139
|
+
|
|
140
|
+
const outcome = typeof obj.outcome === 'string' ? obj.outcome.trim().toLowerCase() : '';
|
|
141
|
+
if (!OUTCOMES.includes(outcome)) {
|
|
142
|
+
return { ok: false, error: 'invalid_outcome', reason: `outcome must be one of ${OUTCOMES.join(', ')}` };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let verdict = null;
|
|
146
|
+
if (obj.verdict != null && String(obj.verdict).trim() !== '') {
|
|
147
|
+
verdict = String(obj.verdict).trim();
|
|
148
|
+
if (verdict.length > LIMITS.verdict) {
|
|
149
|
+
verdict = verdict.slice(0, LIMITS.verdict);
|
|
150
|
+
warnings.push('verdict_truncated');
|
|
151
|
+
}
|
|
152
|
+
const implied = KNOWN_VERDICTS[agent] && Object.prototype.hasOwnProperty.call(KNOWN_VERDICTS[agent], verdict)
|
|
153
|
+
? KNOWN_VERDICTS[agent][verdict] : null;
|
|
154
|
+
if (implied && implied !== outcome) warnings.push(`outcome_mismatch: ${verdict} implies ${implied}, the block says ${outcome}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const phase = obj.phase == null || String(obj.phase).trim() === '' ? null : String(obj.phase).trim();
|
|
158
|
+
if (phase && opts.phase != null && comparablePhase(phase) !== comparablePhase(opts.phase)) {
|
|
159
|
+
warnings.push(`phase_mismatch: the block says ${phase}, recorded under ${opts.phase}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const score = obj.score == null ? null : truncate(cleanText(obj.score), LIMITS.score);
|
|
163
|
+
|
|
164
|
+
let raw = [];
|
|
165
|
+
if (obj.findings != null) {
|
|
166
|
+
if (Array.isArray(obj.findings)) raw = obj.findings;
|
|
167
|
+
else warnings.push('findings_not_an_array');
|
|
168
|
+
}
|
|
169
|
+
if (raw.length > LIMITS.findings) {
|
|
170
|
+
warnings.push(`findings_truncated: ${raw.length} reported, ${LIMITS.findings} kept`);
|
|
171
|
+
raw = raw.slice(0, LIMITS.findings);
|
|
172
|
+
}
|
|
173
|
+
const findings = [];
|
|
174
|
+
raw.forEach((f, i) => {
|
|
175
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) || typeof f.summary !== 'string' || !cleanText(f.summary)) {
|
|
176
|
+
warnings.push(`finding_dropped: #${i} has no summary`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let cls = typeof f.class === 'string' ? f.class.trim().toLowerCase() : '';
|
|
180
|
+
if (!FINDING_CLASSES.includes(cls)) {
|
|
181
|
+
warnings.push(`unknown_class: #${i} ${JSON.stringify(f.class === undefined ? null : f.class)} recorded as quality`);
|
|
182
|
+
cls = 'quality';
|
|
183
|
+
}
|
|
184
|
+
let severity = typeof f.severity === 'string' ? f.severity.trim().toLowerCase() : '';
|
|
185
|
+
if (!SEVERITIES.includes(severity)) {
|
|
186
|
+
warnings.push(`unknown_severity: #${i} ${JSON.stringify(f.severity === undefined ? null : f.severity)} recorded as info`);
|
|
187
|
+
severity = 'info';
|
|
188
|
+
}
|
|
189
|
+
let summary = cleanText(f.summary);
|
|
190
|
+
if (summary.length > LIMITS.summary) {
|
|
191
|
+
summary = truncate(summary, LIMITS.summary);
|
|
192
|
+
warnings.push(`summary_truncated: #${i}`);
|
|
193
|
+
}
|
|
194
|
+
const where = f.where == null || cleanText(f.where) === '' ? null : truncate(cleanText(f.where).replace(/\\/g, '/'), LIMITS.where);
|
|
195
|
+
findings.push({ class: cls, severity, where, summary });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings }, warnings };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── verification.md frontmatter adapter ─────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** The YAML between the leading `---` fences, or null. */
|
|
204
|
+
function frontmatterText(text) {
|
|
205
|
+
const m = normaliseEol(text).match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
206
|
+
return m ? m[1] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A YAML value without its trailing comment. The verifier's templates annotate list
|
|
211
|
+
* keys (`gaps: # Only if status: gaps_found`), and a model copying the template
|
|
212
|
+
* copies the comment, so `# …` must not read as a value. A quoted value ends at its
|
|
213
|
+
* closing quote.
|
|
214
|
+
*/
|
|
215
|
+
function stripComment(v) {
|
|
216
|
+
const s = String(v == null ? '' : v).trim();
|
|
217
|
+
if (s[0] === '"' || s[0] === "'") {
|
|
218
|
+
const q = s[0];
|
|
219
|
+
for (let i = 1; i < s.length; i++) {
|
|
220
|
+
if (q === '"' && s[i] === '\\') { i++; continue; }
|
|
221
|
+
if (s[i] === q) {
|
|
222
|
+
if (q === "'" && s[i + 1] === "'") { i++; continue; }
|
|
223
|
+
return s.slice(0, i + 1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return s;
|
|
227
|
+
}
|
|
228
|
+
return s.replace(/(^|\s)#.*$/, '').trim();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function unquote(v) {
|
|
232
|
+
const s = stripComment(v);
|
|
233
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
234
|
+
try { return JSON.parse(s); } catch { return s.slice(1, -1); }
|
|
235
|
+
}
|
|
236
|
+
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1).replace(/''/g, "'");
|
|
237
|
+
return s;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const KEY_VALUE_RE = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/;
|
|
241
|
+
const ITEM_RE = /^([ \t]*)-[ \t]*(.*)$/;
|
|
242
|
+
const indentOf = (line) => /^[ \t]*/.exec(line)[0].length;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A top-level YAML list of mappings, in the shape the verifier template writes:
|
|
246
|
+
*
|
|
247
|
+
* gaps:
|
|
248
|
+
* - truth: "…"
|
|
249
|
+
* status: failed
|
|
250
|
+
* artifacts:
|
|
251
|
+
* - path: "src/x.ts"
|
|
252
|
+
*
|
|
253
|
+
* Each item comes back as its scalar fields plus `_first_path` (the first nested
|
|
254
|
+
* `path:` value, for `artifacts`). PAN's general frontmatter parser reads such a
|
|
255
|
+
* list as bare strings (`'truth: "…'`), so this reader exists for the adapter alone
|
|
256
|
+
* rather than changing a parser every other module depends on.
|
|
257
|
+
*/
|
|
258
|
+
function listOfMappings(yaml, key) {
|
|
259
|
+
const lines = yaml.split('\n');
|
|
260
|
+
const start = lines.findIndex((l) => {
|
|
261
|
+
const m = KEY_VALUE_RE.exec(l);
|
|
262
|
+
return m && m[1] === key && indentOf(l) === 0;
|
|
263
|
+
});
|
|
264
|
+
if (start === -1) return [];
|
|
265
|
+
const head = KEY_VALUE_RE.exec(lines[start]);
|
|
266
|
+
if (stripComment(head[2]) !== '') return []; // `gaps: []` or a scalar — nothing to read
|
|
267
|
+
const items = [];
|
|
268
|
+
let itemIndent = -1;
|
|
269
|
+
let item = null;
|
|
270
|
+
let contentIndent = -1;
|
|
271
|
+
let nested = null;
|
|
272
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
273
|
+
const line = lines[i];
|
|
274
|
+
if (!line.trim()) continue;
|
|
275
|
+
const ind = indentOf(line);
|
|
276
|
+
const isItem = ITEM_RE.exec(line);
|
|
277
|
+
if (ind === 0 && !isItem) break; // the next top-level key
|
|
278
|
+
if (isItem && (itemIndent === -1 || ind === itemIndent)) {
|
|
279
|
+
if (itemIndent === -1) itemIndent = ind;
|
|
280
|
+
item = {};
|
|
281
|
+
items.push(item);
|
|
282
|
+
contentIndent = ind + (line.length - line.replace(/^[ \t]*-[ \t]*/, '').length) - ind;
|
|
283
|
+
nested = null;
|
|
284
|
+
const kv = KEY_VALUE_RE.exec(isItem[2]);
|
|
285
|
+
if (kv) {
|
|
286
|
+
if (stripComment(kv[2]) === '') nested = kv[1];
|
|
287
|
+
else item[kv[1]] = unquote(kv[2]);
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!item) continue;
|
|
292
|
+
const body = line.trim();
|
|
293
|
+
if (ind === contentIndent) {
|
|
294
|
+
const kv = KEY_VALUE_RE.exec(body);
|
|
295
|
+
if (!kv) continue;
|
|
296
|
+
if (stripComment(kv[2]) === '') { nested = kv[1]; continue; }
|
|
297
|
+
item[kv[1]] = unquote(kv[2]);
|
|
298
|
+
nested = null;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (ind > contentIndent && nested) {
|
|
302
|
+
const inner = body.replace(/^-[ \t]*/, '');
|
|
303
|
+
const kv = KEY_VALUE_RE.exec(inner);
|
|
304
|
+
if (kv && kv[1] === 'path' && item._first_path === undefined && stripComment(kv[2]) !== '') {
|
|
305
|
+
item._first_path = unquote(kv[2]);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return items;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A top-level scalar from the frontmatter (e.g. `status`), or null. */
|
|
313
|
+
function frontmatterScalar(yaml, key) {
|
|
314
|
+
for (const l of yaml.split('\n')) {
|
|
315
|
+
const m = KEY_VALUE_RE.exec(l);
|
|
316
|
+
if (m && m[1] === key && indentOf(l) === 0) {
|
|
317
|
+
const v = unquote(m[2]);
|
|
318
|
+
return v === '' ? null : v;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Build a raw verdict object from a verification.md frontmatter (the adapter, D2).
|
|
326
|
+
* `status` → verdict and outcome; `gaps[]` → missing (failed) / partial;
|
|
327
|
+
* `human_verification[]` → human; `unrequested[]` (M11) → unrequested.
|
|
328
|
+
* @returns {Object|null} a raw verdict for validateVerdict, or null when the text
|
|
329
|
+
* carries no verification frontmatter status
|
|
330
|
+
*/
|
|
331
|
+
function verdictFromVerificationFrontmatter(text, agent = 'pan-verifier') {
|
|
332
|
+
const yaml = frontmatterText(text);
|
|
333
|
+
if (yaml === null) return null;
|
|
334
|
+
const status = frontmatterScalar(yaml, 'status');
|
|
335
|
+
if (!status) return null;
|
|
336
|
+
const table = KNOWN_VERDICTS['pan-verifier'];
|
|
337
|
+
const outcome = Object.prototype.hasOwnProperty.call(table, status) ? table[status] : null;
|
|
338
|
+
const findings = [];
|
|
339
|
+
for (const g of listOfMappings(yaml, 'gaps')) {
|
|
340
|
+
const truth = g.truth ? String(g.truth) : '';
|
|
341
|
+
const reason = g.reason ? String(g.reason) : '';
|
|
342
|
+
const summary = truth && reason ? `${truth} — ${reason}` : (truth || reason);
|
|
343
|
+
if (!summary) continue;
|
|
344
|
+
const partial = String(g.status || '').toLowerCase() === 'partial';
|
|
345
|
+
findings.push({ class: partial ? 'partial' : 'missing', severity: partial ? 'medium' : 'high', where: g._first_path || null, summary });
|
|
346
|
+
}
|
|
347
|
+
for (const h of listOfMappings(yaml, 'human_verification')) {
|
|
348
|
+
const summary = h.test ? String(h.test) + (h.expected ? ` — expected: ${h.expected}` : '') : '';
|
|
349
|
+
if (summary) findings.push({ class: 'human', severity: 'info', where: null, summary });
|
|
350
|
+
}
|
|
351
|
+
for (const u of listOfMappings(yaml, 'unrequested')) {
|
|
352
|
+
const what = u.what ? String(u.what) : (u.why ? String(u.why) : '');
|
|
353
|
+
const path = u.path ? String(u.path) : null;
|
|
354
|
+
const summary = what || (path ? `unrequested change to ${path}` : '');
|
|
355
|
+
if (summary) findings.push({ class: 'unrequested', severity: 'low', where: path, summary });
|
|
356
|
+
}
|
|
357
|
+
return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read a judge's verdict from an artifact: the LAST `pan-verdict` block wins; with
|
|
362
|
+
* no block, a verification.md frontmatter `status` is read through the adapter.
|
|
363
|
+
* @param {string} text
|
|
364
|
+
* @param {{agent?: string, phase?: string|number}} [opts]
|
|
365
|
+
* @returns {{ok: true, verdict: Object, warnings: string[], source_kind: 'block'|'frontmatter'}
|
|
366
|
+
* |{ok: false, error: string, reason: string}}
|
|
367
|
+
*/
|
|
368
|
+
function parseVerdictText(text, opts = {}) {
|
|
369
|
+
const blocks = findVerdictBlocks(text);
|
|
370
|
+
if (blocks.length) {
|
|
371
|
+
const last = blocks[blocks.length - 1];
|
|
372
|
+
if (!last.closed) return { ok: false, error: 'unterminated_block', reason: 'the last pan-verdict block has no closing fence' };
|
|
373
|
+
let obj;
|
|
374
|
+
try { obj = JSON.parse(last.body); } catch (e) {
|
|
375
|
+
return { ok: false, error: 'invalid_json', reason: `the last pan-verdict block is not valid JSON: ${e.message}` };
|
|
376
|
+
}
|
|
377
|
+
const r = validateVerdict(obj, opts);
|
|
378
|
+
return r.ok ? { ...r, source_kind: 'block' } : r;
|
|
379
|
+
}
|
|
380
|
+
const adapted = verdictFromVerificationFrontmatter(text, opts.agent || 'pan-verifier');
|
|
381
|
+
if (adapted) {
|
|
382
|
+
if (!adapted.outcome) {
|
|
383
|
+
return { ok: false, error: 'unknown_status', reason: `verification status "${adapted.verdict}" is not passed, gaps_found or human_needed` };
|
|
384
|
+
}
|
|
385
|
+
const r = validateVerdict(adapted, opts);
|
|
386
|
+
return r.ok ? { ...r, source_kind: 'frontmatter' } : r;
|
|
387
|
+
}
|
|
388
|
+
return { ok: false, error: 'no_verdict', reason: 'no pan-verdict block and no verification frontmatter status' };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Identity of the artifact a verdict was read from: EOL-normalised content hash. */
|
|
392
|
+
function recordSig(text) {
|
|
393
|
+
return sha256(normaliseEol(text).replace(/\s+$/, '')).slice(0, 16);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A finding's stable id: the same finding reported again keeps its id, so a
|
|
398
|
+
* re-verification can say which earlier findings it no longer reports.
|
|
399
|
+
*/
|
|
400
|
+
function findingId(phase, agent, finding) {
|
|
401
|
+
const summary = cleanText(finding.summary || '').toLowerCase().replace(/[.;:,!]+$/, '');
|
|
402
|
+
const where = finding.where ? cleanText(finding.where).replace(/\\/g, '/') : '';
|
|
403
|
+
return 'f_' + sha256([comparablePhase(phase), agent, finding.class, where, summary].join('\u0000')).slice(0, 10);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The outcome an agent's own verdict word implies, or null when it is not a known pair. */
|
|
407
|
+
function outcomeForVerdict(agent, verdict) {
|
|
408
|
+
const table = KNOWN_VERDICTS[agent];
|
|
409
|
+
return table && Object.prototype.hasOwnProperty.call(table, verdict) ? table[verdict] : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
VERDICT_CONTRACT,
|
|
414
|
+
OUTCOMES,
|
|
415
|
+
FINDING_CLASSES,
|
|
416
|
+
SEVERITIES,
|
|
417
|
+
DISPOSITIONS,
|
|
418
|
+
REASON_REQUIRED,
|
|
419
|
+
AUTO_FIX_EXEMPT,
|
|
420
|
+
KNOWN_VERDICTS,
|
|
421
|
+
LIMITS,
|
|
422
|
+
findVerdictBlocks,
|
|
423
|
+
validateVerdict,
|
|
424
|
+
verdictFromVerificationFrontmatter,
|
|
425
|
+
parseVerdictText,
|
|
426
|
+
recordSig,
|
|
427
|
+
findingId,
|
|
428
|
+
outcomeForVerdict,
|
|
429
|
+
comparablePhase,
|
|
430
|
+
normaliseEol,
|
|
431
|
+
};
|
|
@@ -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 {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Verify scope — the mechanical half of the unrequested-work check (market item M11,
|
|
4
|
+
* evidence loop spec D12). Re-exported by verify.cjs, like the other verify-* modules.
|
|
5
|
+
*
|
|
6
|
+
* It answers one question in code: which files did this phase change that none of
|
|
7
|
+
* its plans declared? The verifier judges each candidate. Tests for a declared file
|
|
8
|
+
* or a config change the feature needs are supporting work; anything else it
|
|
9
|
+
* records as `unrequested:` in verification.md. A candidate is a lead, not a
|
|
10
|
+
* finding: code cannot tell scope creep from necessary glue.
|
|
11
|
+
*
|
|
12
|
+
* declared = the union of the phase plans' `files_modified`
|
|
13
|
+
* changed = files touched by the phase's plan commits (subject `{type}({phase}-{plan}):`,
|
|
14
|
+
* the executor's convention), plus the summaries' `key-files`
|
|
15
|
+
* excluded = the planning tree, lockfiles, and PAN's own runtime directories
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { safeReadFile, execGit, findPhaseInternal, toPosix, output, escapeRegex } = require('./core.cjs');
|
|
20
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
21
|
+
const { isPlanFile, isSummaryFile } = require('./constants.cjs');
|
|
22
|
+
const { planningRel } = require('./utils.cjs');
|
|
23
|
+
|
|
24
|
+
const LOCKFILES = new Set([
|
|
25
|
+
'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock',
|
|
26
|
+
'Cargo.lock', 'go.sum', 'poetry.lock', 'Pipfile.lock', 'uv.lock', 'composer.lock', 'Gemfile.lock', 'mix.lock', 'pubspec.lock',
|
|
27
|
+
]);
|
|
28
|
+
// PAN's own installed trees: a phase commit that touches them is PAN bookkeeping,
|
|
29
|
+
// never the project's scope (pan-reviewer skips them for the same reason).
|
|
30
|
+
const PAN_OWNED_PREFIXES = ['.claude/', '.codex/', '.gemini/', '.opencode/', '.agents/', '.github/pan-wizard-core/', '.github/agents/', '.github/prompts/', '.github/skills/'];
|
|
31
|
+
const PAN_OWNED_FILES = new Set(['.github/copilot-instructions.md', '.github/hooks/pan.json']);
|
|
32
|
+
const TEST_PATH_RE = /(^|\/)(tests?|__tests__|specs?)\//i;
|
|
33
|
+
const TEST_FILE_RE = /\.(test|spec)\.[a-z0-9]+$/i;
|
|
34
|
+
const COMMIT_SCAN_LIMIT = 2000;
|
|
35
|
+
|
|
36
|
+
function normalisePath(p) {
|
|
37
|
+
return toPosix(String(p || '').trim()).replace(/^\.\/+/, '').replace(/\\/g, '/');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asList(v) {
|
|
41
|
+
if (Array.isArray(v)) return v.map(String);
|
|
42
|
+
if (typeof v === 'string' && v.trim()) return [v];
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A file's stem without directory, a test/spec infix, or extension: `src/auth.ts` → `auth`. */
|
|
47
|
+
function stem(p) {
|
|
48
|
+
return path.posix.basename(p).replace(/\.(test|spec)(?=\.)/i, '').replace(/\.[^.]+$/, '').toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isDeclared(file, declared) {
|
|
52
|
+
for (const d of declared) {
|
|
53
|
+
if (d === file) return true;
|
|
54
|
+
if (d.endsWith('/') && file.startsWith(d)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exclusionReason(file, planningPrefix) {
|
|
60
|
+
if (file === planningPrefix || file.startsWith(planningPrefix + '/')) return 'planning tree';
|
|
61
|
+
if (LOCKFILES.has(path.posix.basename(file))) return 'lockfile';
|
|
62
|
+
if (PAN_OWNED_FILES.has(file) || PAN_OWNED_PREFIXES.some((p) => file.startsWith(p))) return 'PAN runtime directory';
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Files the phase's plan commits touched, paths relative to `cwd` (git's --relative).
|
|
68
|
+
* @returns {{git: boolean, commits: number, files: Set<string>}}
|
|
69
|
+
*/
|
|
70
|
+
function committedFiles(cwd, phaseNumber) {
|
|
71
|
+
const r = execGit(cwd, ['-c', 'core.quotepath=off', 'log', '--no-merges', '-n', String(COMMIT_SCAN_LIMIT), '--relative', '--name-only', '--format=@@%H%x09%s']);
|
|
72
|
+
if (r.exitCode !== 0) return { git: false, commits: 0, files: new Set() };
|
|
73
|
+
// `{type}({phase}-{plan}):` with the phase's zero padding optional ("feat(03-01):", "fix(3-02)!:").
|
|
74
|
+
const unpadded = String(phaseNumber).replace(/^0+(?=\d)/, '');
|
|
75
|
+
const subjectRe = new RegExp(`^[a-z]+\\(0*${escapeRegex(unpadded)}-[0-9]+\\)!?:`);
|
|
76
|
+
const files = new Set();
|
|
77
|
+
let commits = 0;
|
|
78
|
+
let inPhase = false;
|
|
79
|
+
for (const line of r.stdout.split('\n')) {
|
|
80
|
+
if (line.startsWith('@@')) {
|
|
81
|
+
const subject = line.slice(2).split('\t').slice(1).join('\t');
|
|
82
|
+
inPhase = subjectRe.test(subject);
|
|
83
|
+
if (inPhase) commits++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inPhase && line.trim()) files.add(normalisePath(line));
|
|
87
|
+
}
|
|
88
|
+
return { git: true, commits, files };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The scope check for one phase.
|
|
93
|
+
* @returns {Object} `{phase, directory, declared, changed, candidates, excluded, git, commits}` or `{error}`
|
|
94
|
+
*/
|
|
95
|
+
function scopePhase(cwd, phase) {
|
|
96
|
+
const info = findPhaseInternal(cwd, phase);
|
|
97
|
+
if (!info) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
|
|
98
|
+
const dir = path.join(cwd, info.directory);
|
|
99
|
+
const declared = new Set();
|
|
100
|
+
for (const f of info.plans.filter(isPlanFile)) {
|
|
101
|
+
const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
|
|
102
|
+
for (const p of asList(fm.files_modified || fm['files-modified'])) {
|
|
103
|
+
const n = normalisePath(p);
|
|
104
|
+
if (n) declared.add(n);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const sources = new Map();
|
|
108
|
+
const note = (file, source) => {
|
|
109
|
+
if (!file) return;
|
|
110
|
+
if (!sources.has(file)) sources.set(file, new Set());
|
|
111
|
+
sources.get(file).add(source);
|
|
112
|
+
};
|
|
113
|
+
for (const f of info.summaries.filter(isSummaryFile)) {
|
|
114
|
+
const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
|
|
115
|
+
const kf = fm['key-files'] || fm.key_files || {};
|
|
116
|
+
for (const p of [...asList(kf.created), ...asList(kf.modified)]) note(normalisePath(p), 'summary');
|
|
117
|
+
}
|
|
118
|
+
const git = committedFiles(cwd, info.phase_number);
|
|
119
|
+
for (const p of git.files) note(p, 'commit');
|
|
120
|
+
|
|
121
|
+
const planningPrefix = normalisePath(planningRel());
|
|
122
|
+
const declaredList = [...declared].sort();
|
|
123
|
+
const declaredStems = new Set(declaredList.filter((d) => !d.endsWith('/')).map(stem));
|
|
124
|
+
const changed = [...sources.keys()].sort();
|
|
125
|
+
const candidates = [];
|
|
126
|
+
const excluded = [];
|
|
127
|
+
for (const file of changed) {
|
|
128
|
+
const why = exclusionReason(file, planningPrefix);
|
|
129
|
+
if (why) { excluded.push({ path: file, reason: why }); continue; }
|
|
130
|
+
if (isDeclared(file, declared)) continue;
|
|
131
|
+
const isTest = TEST_PATH_RE.test(file) || TEST_FILE_RE.test(file);
|
|
132
|
+
candidates.push({
|
|
133
|
+
path: file,
|
|
134
|
+
hint: isTest && declaredStems.has(stem(file)) ? 'test_for_declared' : null,
|
|
135
|
+
sources: [...sources.get(file)].sort(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
phase: info.phase_number,
|
|
140
|
+
directory: info.directory,
|
|
141
|
+
declared: declaredList,
|
|
142
|
+
changed,
|
|
143
|
+
candidates,
|
|
144
|
+
excluded,
|
|
145
|
+
git: git.git,
|
|
146
|
+
commits: git.commits,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cmdVerifyScope(cwd, phase, raw) {
|
|
151
|
+
if (!phase) { output({ error: 'phase_required', reason: 'Usage: verify scope <phase>' }, raw); return; }
|
|
152
|
+
const r = scopePhase(cwd, phase);
|
|
153
|
+
output(r, raw, r.error ? undefined : String(r.candidates.length));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { scopePhase, cmdVerifyScope, LOCKFILES };
|
|
@@ -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, comparePhaseNum, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, EXIT_OK, error, escapeRegex } = require('./core.cjs');
|
|
8
|
+
const { safeReadFile, normalizePhaseName, comparePhaseNum, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, EXIT_OK, error, escapeRegex, loadConfig, isGitIgnored, isGitRepo } = require('./core.cjs');
|
|
9
9
|
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
|
10
10
|
const { writeStateMd, readStateSafe } = require('./state.cjs');
|
|
11
11
|
const {
|
|
@@ -22,6 +22,7 @@ const { runDriftCheck, parseConventionRules, checkFileConventions, calculateDrif
|
|
|
22
22
|
const { collectVerificationStats, countRoadmapPhases, groupGapPatterns, cmdRetro } = require('./verify-retro.cjs');
|
|
23
23
|
const { detectInstalledRuntimes, validateRuntimeInstall, cmdValidateDeployment } = require('./verify-deploy.cjs');
|
|
24
24
|
const { cmdPreflight, cmdDepsValidate } = require('./verify-preflight.cjs');
|
|
25
|
+
const { scopePhase, cmdVerifyScope } = require('./verify-scope.cjs');
|
|
25
26
|
const { planningRootRel } = require('./planning-root.cjs');
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -821,6 +822,23 @@ function checkStateFile(cwd, addIssue, repairs) {
|
|
|
821
822
|
* @param {Function} addIssue - Issue recording callback
|
|
822
823
|
* @param {string[]} repairs - Mutable array of repair actions to schedule
|
|
823
824
|
*/
|
|
825
|
+
/**
|
|
826
|
+
* I005: the planning tree is ignored by git while `commit_docs` is on, so every
|
|
827
|
+
* `pan-tools commit` of planning docs commits nothing, silently. planning-with-files
|
|
828
|
+
* adds `.planning/` to .gitignore by default (its README, read 2026-09-28), which is
|
|
829
|
+
* how a PAN project ends up like this (market item M3). Info, not a warning: keeping
|
|
830
|
+
* the docs out of git is a legitimate choice, and it is one config key away.
|
|
831
|
+
*/
|
|
832
|
+
function checkPlanningIgnored(cwd, addIssue) {
|
|
833
|
+
try {
|
|
834
|
+
if (loadConfig(cwd).commit_docs === false || !isGitRepo(cwd)) return;
|
|
835
|
+
const rel = planningRootRel();
|
|
836
|
+
if (!isGitIgnored(cwd, `${rel}/`)) return;
|
|
837
|
+
addIssue('info', 'I005', `${rel}/ is ignored by git while commit_docs is true, so PAN's planning commits commit nothing`,
|
|
838
|
+
`Remove ${rel}/ from .gitignore (planning-with-files adds it by default), or keep the docs out of git on purpose: pan-tools config-set commit_docs false`);
|
|
839
|
+
} catch { /* best effort: a git failure is not a finding */ }
|
|
840
|
+
}
|
|
841
|
+
|
|
824
842
|
function checkConfigFile(cwd, addIssue, repairs) {
|
|
825
843
|
const configFullPath = path.join(planningPath(cwd), CONFIG_FILE);
|
|
826
844
|
let rawContent;
|
|
@@ -1314,7 +1332,9 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1314
1332
|
// error and stop: E002-E005 would describe a foreign layout as a broken PAN one,
|
|
1315
1333
|
// and --repair must never write into it. Reality check R15.
|
|
1316
1334
|
const foreign = detectForeignPlanningTree(planningPath(cwd));
|
|
1317
|
-
if (foreign) {
|
|
1335
|
+
if (foreign && foreign.coexists) {
|
|
1336
|
+
addIssue('info', 'I004', `${foreign.tool} also writes into this planning tree: ${foreign.evidence.join(', ')}`, 'None needed: PAN leaves its files alone. Give PAN its own tree with --planning-dir if you prefer them apart (ADR-0043)');
|
|
1337
|
+
} else if (foreign) {
|
|
1318
1338
|
addIssue('error', 'E006', `planning tree belongs to ${foreign.tool}: ${foreign.evidence.join(', ')}`, 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)');
|
|
1319
1339
|
output({ status: HEALTH_STATUS.BROKEN, errors, warnings, info, repairable_count: 0 }, raw, undefined, 1);
|
|
1320
1340
|
return;
|
|
@@ -1337,6 +1357,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1337
1357
|
addIssue('info', 'I003', `${shape.model}-model project (${shape.evidence.join(', ')}) — the phase-model checks (project.md, roadmap.md, state.md, phases/) do not apply`, null);
|
|
1338
1358
|
}
|
|
1339
1359
|
checkConfigFile(cwd, addIssue, repairs);
|
|
1360
|
+
checkPlanningIgnored(cwd, addIssue);
|
|
1340
1361
|
if (phaseModel) {
|
|
1341
1362
|
checkPhaseDirectories(cwd, addIssue);
|
|
1342
1363
|
checkPhaseContents(cwd, addIssue);
|
|
@@ -1535,6 +1556,8 @@ module.exports = {
|
|
|
1535
1556
|
cmdVerifyReconcile,
|
|
1536
1557
|
scanStubs,
|
|
1537
1558
|
cmdVerifyStubs,
|
|
1559
|
+
scopePhase,
|
|
1560
|
+
cmdVerifyScope,
|
|
1538
1561
|
cmdValidateConsistency,
|
|
1539
1562
|
cmdValidateHealth,
|
|
1540
1563
|
cmdPreflight,
|