gsdd-cli 0.27.0 → 0.29.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 +53 -22
- package/agents/DISTILLATION.md +2 -2
- package/agents/README.md +4 -4
- package/agents/approach-explorer.md +4 -4
- package/agents/executor.md +20 -20
- package/agents/integration-checker.md +2 -2
- package/agents/planner.md +10 -26
- package/agents/researcher.md +2 -2
- package/agents/roadmapper.md +6 -6
- package/agents/synthesizer.md +18 -18
- package/agents/verifier.md +3 -3
- package/bin/adapters/agents.mjs +3 -3
- package/bin/adapters/claude.mjs +16 -14
- package/bin/adapters/opencode.mjs +15 -12
- package/bin/gsdd.mjs +16 -13
- package/bin/lib/{models.mjs → config.mjs} +23 -16
- package/bin/lib/control-map.mjs +17 -488
- package/bin/lib/health-truth.mjs +8 -13
- package/bin/lib/health.mjs +25 -39
- package/bin/lib/init-flow.mjs +44 -38
- package/bin/lib/init-prompts.mjs +3 -3
- package/bin/lib/init-runtime.mjs +11 -30
- package/bin/lib/lifecycle-preflight.mjs +97 -410
- package/bin/lib/lifecycle-state.mjs +2 -1
- package/bin/lib/next.mjs +243 -20
- package/bin/lib/phase.mjs +706 -280
- package/bin/lib/plan-constants.mjs +0 -5
- package/bin/lib/rendering.mjs +64 -44
- package/bin/lib/runtime-freshness.mjs +18 -15
- package/bin/lib/state-dir.mjs +45 -0
- package/bin/lib/templates.mjs +59 -22
- package/bin/lib/work-context.mjs +12 -1
- package/bin/lib/workflows.mjs +0 -1
- package/bin/lib/workspace-root.mjs +11 -6
- package/distilled/DESIGN.md +89 -31
- package/distilled/EVIDENCE-INDEX.md +18 -5
- package/distilled/README.md +23 -33
- package/distilled/SKILL.md +9 -10
- package/distilled/templates/agents.block.md +5 -5
- package/distilled/templates/approach.md +3 -3
- package/distilled/templates/auth-matrix.md +2 -2
- package/distilled/templates/brownfield-change/CHANGE.md +1 -1
- package/distilled/templates/delegates/approach-explorer.md +5 -5
- package/distilled/templates/delegates/mapper-arch.md +3 -3
- package/distilled/templates/delegates/mapper-concerns.md +4 -4
- package/distilled/templates/delegates/mapper-quality.md +3 -3
- package/distilled/templates/delegates/mapper-tech.md +3 -3
- package/distilled/templates/delegates/plan-checker.md +18 -19
- package/distilled/templates/delegates/researcher-architecture.md +3 -3
- package/distilled/templates/delegates/researcher-features.md +3 -3
- package/distilled/templates/delegates/researcher-pitfalls.md +3 -3
- package/distilled/templates/delegates/researcher-stack.md +3 -3
- package/distilled/templates/delegates/researcher-synthesizer.md +7 -7
- package/distilled/templates/research/architecture.md +1 -1
- package/distilled/templates/research/pitfalls.md +1 -1
- package/distilled/templates/research/stack.md +1 -1
- package/distilled/templates/roadmap.md +4 -4
- package/distilled/templates/spec.md +2 -2
- package/distilled/templates/ui-proof.md +81 -181
- package/distilled/workflows/audit-milestone.md +23 -23
- package/distilled/workflows/complete-milestone.md +35 -35
- package/distilled/workflows/execute.md +34 -35
- package/distilled/workflows/map-codebase.md +30 -30
- package/distilled/workflows/new-milestone.md +18 -18
- package/distilled/workflows/new-project.md +45 -45
- package/distilled/workflows/pause.md +15 -15
- package/distilled/workflows/plan.md +106 -114
- package/distilled/workflows/progress.md +40 -39
- package/distilled/workflows/quick.md +49 -50
- package/distilled/workflows/resume.md +23 -22
- package/distilled/workflows/verify-work.md +7 -7
- package/distilled/workflows/verify.md +26 -26
- package/docs/BROWNFIELD-PROOF.md +1 -1
- package/docs/RUNTIME-SUPPORT.md +13 -13
- package/docs/USER-GUIDE.md +26 -21
- package/docs/claude/context-monitor.md +1 -1
- package/docs/proof/consumer-node-cli/README.md +1 -1
- package/package.json +3 -3
- package/bin/lib/closeout-report.mjs +0 -318
- package/bin/lib/evidence-contract.mjs +0 -325
- package/bin/lib/provenance.mjs +0 -390
- package/bin/lib/session-fingerprint.mjs +0 -223
- package/bin/lib/ui-proof.mjs +0 -1007
- package/distilled/workflows/plan-milestone-gaps.md +0 -204
package/bin/lib/phase.mjs
CHANGED
|
@@ -3,17 +3,10 @@
|
|
|
3
3
|
// IMPORTANT: No module-scope process.cwd() — ESM caching means sub-modules
|
|
4
4
|
// evaluate once, so CWD must be computed inside function bodies.
|
|
5
5
|
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
7
|
-
import { dirname, join, relative } from 'path';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path';
|
|
8
8
|
import { output } from './cli-utils.mjs';
|
|
9
|
-
import { writeFingerprint } from './session-fingerprint.mjs';
|
|
10
9
|
import { resolveWorkspaceContext } from './workspace-root.mjs';
|
|
11
|
-
import {
|
|
12
|
-
compareUiProofSlots,
|
|
13
|
-
findUiProofBundleFiles,
|
|
14
|
-
parseUiProofSlotsContent,
|
|
15
|
-
readUiProofBundleFile,
|
|
16
|
-
} from './ui-proof.mjs';
|
|
17
10
|
|
|
18
11
|
const PHASE_STATUS_MARKERS = {
|
|
19
12
|
not_started: '[ ]',
|
|
@@ -175,314 +168,722 @@ function extractPlanFileArtifacts(planContent, workspaceRoot) {
|
|
|
175
168
|
return artifacts;
|
|
176
169
|
}
|
|
177
170
|
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
return
|
|
171
|
+
function extractFrontmatter(content) {
|
|
172
|
+
const match = String(content || '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
|
|
173
|
+
return match ? match[1] : '';
|
|
181
174
|
}
|
|
182
175
|
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return `Create or update ${artifact.file} so the planned ${artifact.operation.toUpperCase()} artifact exists, or revise the plan if it is no longer in scope.`;
|
|
176
|
+
function readTopLevelScalar(frontmatter, key) {
|
|
177
|
+
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
178
|
+
const match = String(frontmatter || '').match(new RegExp(`^${escapedKey}:\\s*(.*)$`, 'm'));
|
|
179
|
+
return match ? normalizeScalarValue(match[1]) : null;
|
|
188
180
|
}
|
|
189
181
|
|
|
190
|
-
function
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
.map((artifact) => ({
|
|
194
|
-
...artifact,
|
|
195
|
-
severity: 'blocker',
|
|
196
|
-
expected: artifact.operation === 'delete' ? 'absent' : 'present',
|
|
197
|
-
fix_hint: planArtifactFixHint(artifact),
|
|
198
|
-
}));
|
|
199
|
-
return {
|
|
200
|
-
satisfied: unsatisfied.length === 0,
|
|
201
|
-
unsatisfied,
|
|
202
|
-
};
|
|
182
|
+
function hasTopLevelKey(frontmatter, key) {
|
|
183
|
+
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
184
|
+
return new RegExp(`^${escapedKey}:\\s*.*$`, 'm').test(String(frontmatter || ''));
|
|
203
185
|
}
|
|
204
186
|
|
|
205
|
-
function
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
187
|
+
function readTopLevelBlock(frontmatter, key) {
|
|
188
|
+
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
const lines = String(frontmatter || '').replace(/\r\n/g, '\n').split('\n');
|
|
190
|
+
const startIndex = lines.findIndex((line) => new RegExp(`^${escapedKey}:\\s*(.*)$`).test(line));
|
|
191
|
+
if (startIndex === -1) return null;
|
|
192
|
+
|
|
193
|
+
const scalar = normalizeScalarValue(lines[startIndex].replace(new RegExp(`^${escapedKey}:\\s*`), ''));
|
|
194
|
+
const nested = [];
|
|
195
|
+
for (const line of lines.slice(startIndex + 1)) {
|
|
196
|
+
if (/^\S[^:]*:\s*/.test(line)) break;
|
|
197
|
+
if (line.trim()) nested.push(line.trim());
|
|
198
|
+
}
|
|
199
|
+
return { scalar, nested };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function legacyUiProofSlotsState(frontmatter) {
|
|
203
|
+
const block = readTopLevelBlock(frontmatter, 'ui_proof_slots');
|
|
204
|
+
if (!block) return null;
|
|
205
|
+
if (/^\[\s*\]$/.test(block.scalar)) return 'empty';
|
|
206
|
+
if (isMeaningfulFieldValue(block.scalar)) return 'present';
|
|
207
|
+
return block.nested.length > 0 ? 'present' : 'empty';
|
|
211
208
|
}
|
|
212
209
|
|
|
213
210
|
function stripInlineComment(value) {
|
|
214
|
-
|
|
211
|
+
const text = String(value || '');
|
|
212
|
+
let quote = null;
|
|
213
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
214
|
+
const char = text[index];
|
|
215
|
+
if (quote) {
|
|
216
|
+
if (quote === '"' && char === '\\') {
|
|
217
|
+
index += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (char === quote) quote = null;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (char === '"' || char === "'") {
|
|
224
|
+
quote = char;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (char === '#') return text.slice(0, index);
|
|
228
|
+
}
|
|
229
|
+
return text;
|
|
215
230
|
}
|
|
216
231
|
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
.
|
|
221
|
-
.
|
|
232
|
+
function normalizeScalarValue(value) {
|
|
233
|
+
let normalized = stripInlineComment(value).trim();
|
|
234
|
+
if (
|
|
235
|
+
(normalized.startsWith('"') && normalized.endsWith('"'))
|
|
236
|
+
|| (normalized.startsWith("'") && normalized.endsWith("'"))
|
|
237
|
+
) {
|
|
238
|
+
normalized = normalized.slice(1, -1).trim();
|
|
239
|
+
}
|
|
240
|
+
return normalized;
|
|
222
241
|
}
|
|
223
242
|
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
if (!stripped) return '';
|
|
227
|
-
if (stripped === '~') return '';
|
|
228
|
-
if (/^null$/i.test(stripped)) return '';
|
|
229
|
-
return stripped;
|
|
243
|
+
function extractMarkdownSection(content, heading) {
|
|
244
|
+
return extractMarkdownSections(content, heading)[0] || '';
|
|
230
245
|
}
|
|
231
246
|
|
|
232
|
-
function
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
247
|
+
function extractMarkdownSections(content, heading) {
|
|
248
|
+
const normalized = String(content || '').replace(/\r\n/g, '\n');
|
|
249
|
+
const escapedHeading = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
250
|
+
const headingMatcher = new RegExp(`^##\\s+${escapedHeading}\\s*$`, 'gim');
|
|
251
|
+
const matches = Array.from(normalized.matchAll(headingMatcher));
|
|
252
|
+
return matches.map((headingMatch, index) => {
|
|
253
|
+
const start = headingMatch.index + headingMatch[0].length;
|
|
254
|
+
const nextStart = matches[index + 1]?.index;
|
|
255
|
+
const rest = normalized.slice(start, nextStart);
|
|
256
|
+
const nextHeadingIndex = rest.search(/^##\s+/m);
|
|
257
|
+
return (nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex)).trim();
|
|
258
|
+
}).filter(Boolean);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sectionFieldValue(section, label) {
|
|
262
|
+
const text = String(section || '');
|
|
263
|
+
const escapedLabel = String(label).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
264
|
+
const match = new RegExp(`^\\s*(?:[-*]\\s*)?${escapedLabel}:\\s*(.*)$`, 'im').exec(text);
|
|
265
|
+
if (!match) return '';
|
|
266
|
+
|
|
267
|
+
const inlineValue = normalizeScalarValue(match[1]);
|
|
268
|
+
if (inlineValue) return inlineValue;
|
|
269
|
+
|
|
270
|
+
const nestedValues = [];
|
|
271
|
+
const followingLines = text.slice(match.index + match[0].length).split(/\r?\n/);
|
|
272
|
+
for (const line of followingLines) {
|
|
273
|
+
if (!line.trim()) continue;
|
|
274
|
+
if (/^\S/.test(line)) break;
|
|
275
|
+
const nestedBullet = line.match(/^\s+[-*]\s*(.+)$/);
|
|
276
|
+
if (nestedBullet) {
|
|
277
|
+
nestedValues.push(nestedBullet[1].trim());
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const nestedText = line.match(/^\s{2,}(.+)$/);
|
|
281
|
+
if (nestedText) {
|
|
282
|
+
nestedValues.push(nestedText[1].trim());
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
237
286
|
}
|
|
238
|
-
return
|
|
287
|
+
return normalizeScalarValue(nestedValues.join(' '));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isMeaningfulFieldValue(value) {
|
|
291
|
+
const normalized = normalizeScalarValue(value);
|
|
292
|
+
if (!normalized) return false;
|
|
293
|
+
if (/^n\/?a$/i.test(normalized)) return false;
|
|
294
|
+
if (/^(none|null|nil|~|tbd|todo|unknown)$/i.test(normalized)) return false;
|
|
295
|
+
if (/^\[.*\]$/.test(normalized)) return false;
|
|
296
|
+
if (/^<.*>$/.test(normalized)) return false;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function nonEmptyField(section, label) {
|
|
301
|
+
const value = sectionFieldValue(section, label);
|
|
302
|
+
return isMeaningfulFieldValue(value);
|
|
239
303
|
}
|
|
240
304
|
|
|
241
|
-
function
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const frontmatter = [];
|
|
246
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
247
|
-
if (lines[index].trim() === '---') return frontmatter.join('\n');
|
|
248
|
-
frontmatter.push(lines[index]);
|
|
305
|
+
function firstFieldValue(section, labels) {
|
|
306
|
+
for (const label of labels) {
|
|
307
|
+
const value = sectionFieldValue(section, label);
|
|
308
|
+
if (isMeaningfulFieldValue(value)) return value;
|
|
249
309
|
}
|
|
250
310
|
return '';
|
|
251
311
|
}
|
|
252
312
|
|
|
253
|
-
function
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
function evidenceKindValues(section) {
|
|
314
|
+
const value = firstFieldValue(section, ['Evidence kind', 'Evidence kinds']);
|
|
315
|
+
return value
|
|
316
|
+
.split(/[,/;|]|\band\b/i)
|
|
317
|
+
.map((part) => normalizeScalarValue(part).toLowerCase())
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function hasSupportedBrowserProofEvidenceKind(section) {
|
|
322
|
+
return evidenceKindValues(section).some((kind) => kind === 'runtime' || kind === 'test');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function hasPassingBrowserProofResult(section) {
|
|
326
|
+
const value = firstFieldValue(section, ['Result']).toLowerCase();
|
|
327
|
+
if (!isMeaningfulFieldValue(value)) return false;
|
|
328
|
+
if (/\b(not\s+passed|fail(?:ed|ing)?|partial|partly|blocked|waived|deferred|missing|not[_ -]?applicable|unknown)\b/i.test(value)) {
|
|
329
|
+
return false;
|
|
266
330
|
}
|
|
267
|
-
return
|
|
331
|
+
return /\b(pass(?:ed|ing)?|satisfied|success(?:ful)?)\b/i.test(value);
|
|
268
332
|
}
|
|
269
333
|
|
|
270
|
-
function
|
|
271
|
-
const
|
|
272
|
-
if (!
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
.join(' ')
|
|
278
|
-
.trim();
|
|
334
|
+
function isBoundedBrowserProofClaim(section) {
|
|
335
|
+
const value = firstFieldValue(section, ['Claim limit']).toLowerCase();
|
|
336
|
+
if (!isMeaningfulFieldValue(value)) return false;
|
|
337
|
+
if (/\b(no limit|unlimited|entire app|whole app|full app|complete app|everything works|all works|all flows|all ui|all screens|works everywhere)\b/i.test(value)) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
279
341
|
}
|
|
280
342
|
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
hasUiProofKey: Boolean(slotsEntry),
|
|
287
|
-
declaresSlots: false,
|
|
288
|
-
explicitEmptySlots: false,
|
|
289
|
-
noUiProofRationale: rationale,
|
|
290
|
-
hasNoUiProofRationale: Boolean(rationale.trim()),
|
|
291
|
-
slotIds: [],
|
|
292
|
-
};
|
|
343
|
+
function hasPrivacySafetyNote(section) {
|
|
344
|
+
if (firstFieldValue(section, ['Privacy/safety', 'Privacy note', 'Safety note', 'Safe to publish'])) return true;
|
|
345
|
+
const artifacts = firstFieldValue(section, ['Artifacts']);
|
|
346
|
+
return /\b(local[-_ ]?only|safe to publish|not safe to publish|publishable|private|unsafe|sanitized)\b/i.test(artifacts);
|
|
347
|
+
}
|
|
293
348
|
|
|
294
|
-
|
|
349
|
+
function browserProofFixHint(code) {
|
|
350
|
+
if (code === 'retired_browser_proof_contract') {
|
|
351
|
+
return 'Replace retired ui_proof_slots/no_ui_proof_rationale fields with browser_proof_required and browser_proof_rationale.';
|
|
352
|
+
}
|
|
353
|
+
if (code === 'legacy_browser_proof_contract') {
|
|
354
|
+
return 'Update PLAN.md frontmatter to browser_proof_required: false and browser_proof_rationale when you next touch this phase.';
|
|
355
|
+
}
|
|
356
|
+
if (code === 'legacy_browser_proof_slots_require_migration') {
|
|
357
|
+
return 'Migrate non-empty ui_proof_slots to browser_proof_required: true plus a Browser Proof Plan before verifying this phase.';
|
|
358
|
+
}
|
|
359
|
+
if (code === 'conflicting_browser_proof_contract') {
|
|
360
|
+
return 'Use either the new browser_proof_* frontmatter or the legacy ui_proof_* frontmatter, not both.';
|
|
361
|
+
}
|
|
362
|
+
if (code === 'missing_browser_proof_declaration') {
|
|
363
|
+
return 'Add browser_proof_required: true|false and browser_proof_rationale to PLAN.md frontmatter.';
|
|
364
|
+
}
|
|
365
|
+
if (code === 'invalid_browser_proof_required') {
|
|
366
|
+
return 'Set browser_proof_required to true or false in PLAN.md frontmatter.';
|
|
367
|
+
}
|
|
368
|
+
if (code === 'missing_browser_proof_rationale') {
|
|
369
|
+
return 'Add a nonblank browser_proof_rationale explaining why browser proof is or is not required.';
|
|
370
|
+
}
|
|
371
|
+
if (code === 'missing_browser_proof_plan') {
|
|
372
|
+
return 'Add a ## Browser Proof Plan section or set browser_proof_required: false with a rationale if the work is not UI-sensitive.';
|
|
373
|
+
}
|
|
374
|
+
if (code === 'missing_browser_proof_observation') {
|
|
375
|
+
return 'Record a ## Browser Proof Observation in SUMMARY.md or link to an observation record before verifying browser-sensitive work.';
|
|
376
|
+
}
|
|
377
|
+
if (code === 'incomplete_browser_proof_observation') {
|
|
378
|
+
return 'Complete the Browser Proof Observation fields or narrow the proof claim.';
|
|
379
|
+
}
|
|
380
|
+
if (code === 'failed_browser_proof_observation') {
|
|
381
|
+
return 'Record a passing browser-proof observation, or narrow the claim and leave verification blocked.';
|
|
382
|
+
}
|
|
383
|
+
if (code === 'unsupported_browser_proof_evidence_kind') {
|
|
384
|
+
return 'Use Evidence kind: runtime or Evidence kind: test for browser proof, or narrow the claim.';
|
|
385
|
+
}
|
|
386
|
+
if (code === 'overbroad_browser_proof_claim') {
|
|
387
|
+
return 'Narrow Claim limit to the exact route, state, viewport, and behavior actually observed.';
|
|
388
|
+
}
|
|
389
|
+
if (code === 'invalid_browser_proof_observation_link') {
|
|
390
|
+
return 'Link browser-proof observation records as repo-local regular files inside this workspace.';
|
|
391
|
+
}
|
|
392
|
+
if (code === 'unmatched_browser_proof_observation') {
|
|
393
|
+
return 'Add a Plan field to each Browser Proof Observation that names the exact required PLAN.md artifact.';
|
|
394
|
+
}
|
|
395
|
+
return 'Complete the Browser Proof Plan fields or narrow the proof claim.';
|
|
396
|
+
}
|
|
295
397
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
398
|
+
function evaluateBrowserProofContract(planContent, planPath) {
|
|
399
|
+
const frontmatter = extractFrontmatter(planContent);
|
|
400
|
+
let requiredRaw = readTopLevelScalar(frontmatter, 'browser_proof_required');
|
|
401
|
+
let rationale = readTopLevelScalar(frontmatter, 'browser_proof_rationale');
|
|
402
|
+
let declarationPresent = requiredRaw !== null || rationale !== null;
|
|
403
|
+
const legacySlots = legacyUiProofSlotsState(frontmatter);
|
|
404
|
+
const legacyRationale = readTopLevelScalar(frontmatter, 'no_ui_proof_rationale');
|
|
405
|
+
const legacyNoUiCompatible = !declarationPresent
|
|
406
|
+
&& legacySlots === 'empty'
|
|
407
|
+
&& isMeaningfulFieldValue(legacyRationale);
|
|
408
|
+
const retiredKeys = ['ui_proof_slots', 'no_ui_proof_rationale', 'proof_bundle_version', 'claim_limits']
|
|
409
|
+
.filter((key) => hasTopLevelKey(frontmatter, key));
|
|
410
|
+
const blockers = [];
|
|
411
|
+
const warnings = [];
|
|
412
|
+
|
|
413
|
+
if (declarationPresent && retiredKeys.length > 0) {
|
|
414
|
+
blockers.push({
|
|
415
|
+
code: 'conflicting_browser_proof_contract',
|
|
416
|
+
severity: 'blocker',
|
|
417
|
+
path: planPath,
|
|
418
|
+
message: `PLAN.md mixes new browser-proof field(s) with retired field(s): ${retiredKeys.join(', ')}.`,
|
|
419
|
+
fix_hint: browserProofFixHint('conflicting_browser_proof_contract'),
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
path: planPath,
|
|
423
|
+
declaration_present: declarationPresent,
|
|
424
|
+
required: null,
|
|
425
|
+
rationale,
|
|
426
|
+
legacy_compatible: false,
|
|
427
|
+
satisfied: false,
|
|
428
|
+
warnings,
|
|
429
|
+
blockers,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!declarationPresent && legacySlots === 'present') {
|
|
434
|
+
blockers.push({
|
|
435
|
+
code: 'legacy_browser_proof_slots_require_migration',
|
|
436
|
+
severity: 'blocker',
|
|
437
|
+
path: planPath,
|
|
438
|
+
message: 'PLAN.md uses legacy non-empty ui_proof_slots; migrate them to browser_proof_required plus a Browser Proof Plan before verification.',
|
|
439
|
+
fix_hint: browserProofFixHint('legacy_browser_proof_slots_require_migration'),
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
path: planPath,
|
|
443
|
+
declaration_present: false,
|
|
444
|
+
required: null,
|
|
445
|
+
rationale: legacyRationale,
|
|
446
|
+
legacy_compatible: false,
|
|
447
|
+
satisfied: false,
|
|
448
|
+
warnings,
|
|
449
|
+
blockers,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!declarationPresent && legacySlots === 'empty' && !isMeaningfulFieldValue(legacyRationale)) {
|
|
454
|
+
blockers.push({
|
|
455
|
+
code: 'missing_browser_proof_rationale',
|
|
456
|
+
severity: 'blocker',
|
|
457
|
+
path: planPath,
|
|
458
|
+
message: 'Legacy ui_proof_slots: [] requires a meaningful no_ui_proof_rationale, or migration to browser_proof_required/browser_proof_rationale.',
|
|
459
|
+
fix_hint: browserProofFixHint('missing_browser_proof_rationale'),
|
|
460
|
+
});
|
|
461
|
+
return {
|
|
462
|
+
path: planPath,
|
|
463
|
+
declaration_present: false,
|
|
464
|
+
required: null,
|
|
465
|
+
rationale: legacyRationale,
|
|
466
|
+
legacy_compatible: false,
|
|
467
|
+
satisfied: false,
|
|
468
|
+
warnings,
|
|
469
|
+
blockers,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (legacyNoUiCompatible) {
|
|
474
|
+
requiredRaw = 'false';
|
|
475
|
+
rationale = legacyRationale;
|
|
476
|
+
declarationPresent = true;
|
|
477
|
+
warnings.push({
|
|
478
|
+
code: 'legacy_browser_proof_contract',
|
|
479
|
+
severity: 'warning',
|
|
480
|
+
path: planPath,
|
|
481
|
+
message: 'PLAN.md uses legacy no-UI proof frontmatter; treating it as browser_proof_required: false for compatibility.',
|
|
482
|
+
fix_hint: browserProofFixHint('legacy_browser_proof_contract'),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const blockingRetiredKeys = legacyNoUiCompatible
|
|
487
|
+
? retiredKeys.filter((key) => key !== 'ui_proof_slots' && key !== 'no_ui_proof_rationale')
|
|
488
|
+
: retiredKeys;
|
|
489
|
+
|
|
490
|
+
if (blockingRetiredKeys.length > 0) {
|
|
491
|
+
blockers.push({
|
|
492
|
+
code: 'retired_browser_proof_contract',
|
|
493
|
+
severity: 'blocker',
|
|
494
|
+
path: planPath,
|
|
495
|
+
message: `PLAN.md uses retired browser-proof field(s): ${blockingRetiredKeys.join(', ')}.`,
|
|
496
|
+
fix_hint: browserProofFixHint('retired_browser_proof_contract'),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!declarationPresent && retiredKeys.length === 0) {
|
|
501
|
+
blockers.push({
|
|
502
|
+
code: 'missing_browser_proof_declaration',
|
|
503
|
+
severity: 'blocker',
|
|
504
|
+
path: planPath,
|
|
505
|
+
message: 'PLAN.md must declare browser_proof_required and browser_proof_rationale before verification.',
|
|
506
|
+
fix_hint: browserProofFixHint('missing_browser_proof_declaration'),
|
|
507
|
+
});
|
|
508
|
+
return {
|
|
509
|
+
path: planPath,
|
|
510
|
+
declaration_present: false,
|
|
511
|
+
required: null,
|
|
512
|
+
rationale: null,
|
|
513
|
+
legacy_compatible: false,
|
|
514
|
+
satisfied: false,
|
|
515
|
+
warnings,
|
|
516
|
+
blockers,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const normalizedRequired = String(requiredRaw || '').toLowerCase();
|
|
521
|
+
const required = normalizedRequired === 'true'
|
|
522
|
+
? true
|
|
523
|
+
: normalizedRequired === 'false'
|
|
524
|
+
? false
|
|
525
|
+
: null;
|
|
526
|
+
|
|
527
|
+
if (required === null) {
|
|
528
|
+
blockers.push({
|
|
529
|
+
code: 'invalid_browser_proof_required',
|
|
530
|
+
severity: 'blocker',
|
|
531
|
+
path: planPath,
|
|
532
|
+
message: 'browser_proof_required must be true or false when the browser-proof contract is declared.',
|
|
533
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_required'),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!isMeaningfulFieldValue(rationale)) {
|
|
538
|
+
blockers.push({
|
|
539
|
+
code: 'missing_browser_proof_rationale',
|
|
540
|
+
severity: 'blocker',
|
|
541
|
+
path: planPath,
|
|
542
|
+
message: 'browser_proof_rationale is required when the browser-proof contract is declared.',
|
|
543
|
+
fix_hint: browserProofFixHint('missing_browser_proof_rationale'),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (required === true) {
|
|
548
|
+
const section = extractMarkdownSection(planContent, 'Browser Proof Plan');
|
|
549
|
+
if (!section) {
|
|
550
|
+
blockers.push({
|
|
551
|
+
code: 'missing_browser_proof_plan',
|
|
552
|
+
severity: 'blocker',
|
|
553
|
+
path: planPath,
|
|
554
|
+
message: 'browser_proof_required is true but PLAN.md has no ## Browser Proof Plan section.',
|
|
555
|
+
fix_hint: browserProofFixHint('missing_browser_proof_plan'),
|
|
556
|
+
});
|
|
557
|
+
} else {
|
|
558
|
+
const missingFields = ['Routes/states', 'Viewports', 'Runtime path', 'Observations', 'Artifacts', 'Claim limit']
|
|
559
|
+
.filter((field) => !nonEmptyField(section, field));
|
|
560
|
+
const hasEvidenceCommand = nonEmptyField(section, 'Evidence command');
|
|
561
|
+
const hasNoCommandRationale = nonEmptyField(section, 'No-command rationale');
|
|
562
|
+
if (!hasEvidenceCommand && !hasNoCommandRationale) {
|
|
563
|
+
missingFields.push('Evidence command or No-command rationale');
|
|
564
|
+
}
|
|
565
|
+
if (!hasSupportedBrowserProofEvidenceKind(section)) {
|
|
566
|
+
missingFields.push('Evidence kind');
|
|
567
|
+
}
|
|
568
|
+
if (!hasPrivacySafetyNote(section)) {
|
|
569
|
+
missingFields.push('Privacy/safety');
|
|
570
|
+
}
|
|
571
|
+
if (!isBoundedBrowserProofClaim(section)) {
|
|
572
|
+
missingFields.push('bounded Claim limit');
|
|
573
|
+
}
|
|
574
|
+
if (missingFields.length > 0) {
|
|
575
|
+
blockers.push({
|
|
576
|
+
code: 'incomplete_browser_proof_plan',
|
|
577
|
+
severity: 'blocker',
|
|
578
|
+
path: planPath,
|
|
579
|
+
message: `Browser Proof Plan is missing required field(s): ${missingFields.join(', ')}.`,
|
|
580
|
+
fix_hint: browserProofFixHint('incomplete_browser_proof_plan'),
|
|
581
|
+
});
|
|
347
582
|
}
|
|
348
583
|
}
|
|
584
|
+
}
|
|
349
585
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
586
|
+
return {
|
|
587
|
+
path: planPath,
|
|
588
|
+
declaration_present: declarationPresent,
|
|
589
|
+
required,
|
|
590
|
+
rationale,
|
|
591
|
+
legacy_compatible: legacyNoUiCompatible,
|
|
592
|
+
satisfied: blockers.length === 0,
|
|
593
|
+
warnings,
|
|
594
|
+
blockers,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
358
597
|
|
|
359
|
-
|
|
360
|
-
|
|
598
|
+
function sanitizeLinkedObservationPath(value) {
|
|
599
|
+
let text = normalizeScalarValue(value);
|
|
600
|
+
const markdownLink = text.match(/^\[[^\]]+\]\(([^)]+)\)$/);
|
|
601
|
+
if (markdownLink) text = markdownLink[1].trim();
|
|
602
|
+
text = text.replace(/^`|`$/g, '').trim();
|
|
603
|
+
return text;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function isInsidePath(parent, child) {
|
|
607
|
+
const relativePath = relative(resolve(parent), resolve(child));
|
|
608
|
+
return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function readLinkedObservationRecords(summaryContent, summaryFullPath, workspaceRoot) {
|
|
612
|
+
const records = [];
|
|
613
|
+
const blockers = [];
|
|
614
|
+
const matcher = /^\s*(?:[-*]\s*)?(?:Browser Proof Observation|Browser proof observation|Observation record|Browser proof record):\s*(.+)$/gim;
|
|
615
|
+
let match;
|
|
616
|
+
while ((match = matcher.exec(String(summaryContent || '')))) {
|
|
617
|
+
const linkedPath = sanitizeLinkedObservationPath(match[1]);
|
|
618
|
+
if (!linkedPath) continue;
|
|
619
|
+
if (/^[a-z]+:\/\//i.test(linkedPath)) {
|
|
620
|
+
blockers.push({
|
|
621
|
+
code: 'invalid_browser_proof_observation_link',
|
|
622
|
+
severity: 'blocker',
|
|
623
|
+
path: linkedPath,
|
|
624
|
+
message: 'Browser Proof Observation link must be a repo-local relative file path, not a URL.',
|
|
625
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
626
|
+
});
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (isAbsolute(linkedPath)) {
|
|
630
|
+
blockers.push({
|
|
631
|
+
code: 'invalid_browser_proof_observation_link',
|
|
632
|
+
severity: 'blocker',
|
|
633
|
+
path: linkedPath,
|
|
634
|
+
message: 'Browser Proof Observation link must be a repo-local relative file path.',
|
|
635
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
636
|
+
});
|
|
361
637
|
continue;
|
|
362
638
|
}
|
|
363
639
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
640
|
+
const candidates = [resolve(dirname(summaryFullPath), linkedPath), resolve(workspaceRoot, linkedPath)]
|
|
641
|
+
.filter((candidate, index, list) => list.indexOf(candidate) === index);
|
|
642
|
+
const existingPath = candidates.find((candidate) => existsSync(candidate));
|
|
643
|
+
if (!existingPath) {
|
|
644
|
+
blockers.push({
|
|
645
|
+
code: 'invalid_browser_proof_observation_link',
|
|
646
|
+
severity: 'blocker',
|
|
647
|
+
path: linkedPath,
|
|
648
|
+
message: 'Browser Proof Observation link does not resolve to an existing file in this workspace.',
|
|
649
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
650
|
+
});
|
|
651
|
+
continue;
|
|
369
652
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
653
|
+
try {
|
|
654
|
+
const realWorkspaceRoot = realpathSync(workspaceRoot);
|
|
655
|
+
const realExistingPath = realpathSync(existingPath);
|
|
656
|
+
if (!isInsidePath(realWorkspaceRoot, realExistingPath)) {
|
|
657
|
+
blockers.push({
|
|
658
|
+
code: 'invalid_browser_proof_observation_link',
|
|
659
|
+
severity: 'blocker',
|
|
660
|
+
path: linkedPath,
|
|
661
|
+
message: 'Browser Proof Observation link resolves outside this workspace.',
|
|
662
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
663
|
+
});
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const stat = statSync(existingPath);
|
|
667
|
+
if (!stat.isFile()) {
|
|
668
|
+
blockers.push({
|
|
669
|
+
code: 'invalid_browser_proof_observation_link',
|
|
670
|
+
severity: 'blocker',
|
|
671
|
+
path: linkedPath,
|
|
672
|
+
message: 'Browser Proof Observation link must resolve to a regular file.',
|
|
673
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
674
|
+
});
|
|
675
|
+
continue;
|
|
373
676
|
}
|
|
677
|
+
records.push(readFileSync(existingPath, 'utf-8'));
|
|
678
|
+
} catch (error) {
|
|
679
|
+
blockers.push({
|
|
680
|
+
code: 'invalid_browser_proof_observation_link',
|
|
681
|
+
severity: 'blocker',
|
|
682
|
+
path: linkedPath,
|
|
683
|
+
message: `Browser Proof Observation link could not be read: ${error.code || error.message}.`,
|
|
684
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
685
|
+
});
|
|
374
686
|
}
|
|
375
687
|
}
|
|
376
|
-
return {
|
|
688
|
+
return { records, blockers };
|
|
377
689
|
}
|
|
378
690
|
|
|
379
|
-
function
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
691
|
+
function findBrowserProofObservationSections(summaryContent, summaryFullPath, workspaceRoot) {
|
|
692
|
+
const linked = readLinkedObservationRecords(summaryContent, summaryFullPath, workspaceRoot);
|
|
693
|
+
return {
|
|
694
|
+
sections: [
|
|
695
|
+
...extractMarkdownSections(summaryContent, 'Browser Proof Observation'),
|
|
696
|
+
...linked.records
|
|
697
|
+
.flatMap((record) => extractMarkdownSections(record, 'Browser Proof Observation')),
|
|
698
|
+
].filter(Boolean),
|
|
699
|
+
blockers: linked.blockers,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
385
702
|
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
703
|
+
function browserProofObservationIssues(section) {
|
|
704
|
+
const requiredFieldSets = [
|
|
705
|
+
['Flow', 'Routes/states'],
|
|
706
|
+
['Viewports'],
|
|
707
|
+
['Runtime path'],
|
|
708
|
+
['Evidence kind', 'Evidence kinds'],
|
|
709
|
+
['Evidence command', 'No-command rationale'],
|
|
710
|
+
['Observed', 'Observations'],
|
|
711
|
+
['Artifacts'],
|
|
712
|
+
['Result'],
|
|
713
|
+
['Claim limit'],
|
|
714
|
+
];
|
|
715
|
+
const missingFields = requiredFieldSets
|
|
716
|
+
.filter((labels) => !labels.some((label) => nonEmptyField(section, label)))
|
|
717
|
+
.map((labels) => labels.join(' or '));
|
|
718
|
+
if (!hasPrivacySafetyNote(section)) {
|
|
719
|
+
missingFields.push('Privacy/safety');
|
|
403
720
|
}
|
|
721
|
+
const issues = missingFields.length > 0
|
|
722
|
+
? [{
|
|
723
|
+
code: 'incomplete_browser_proof_observation',
|
|
724
|
+
missingFields,
|
|
725
|
+
message: `Browser Proof Observation is missing required observed-proof field(s): ${missingFields.join(', ')}.`,
|
|
726
|
+
}]
|
|
727
|
+
: [];
|
|
728
|
+
if (!hasSupportedBrowserProofEvidenceKind(section)) {
|
|
729
|
+
issues.push({
|
|
730
|
+
code: 'unsupported_browser_proof_evidence_kind',
|
|
731
|
+
message: 'Browser Proof Observation evidence kind must include runtime or test.',
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
if (!hasPassingBrowserProofResult(section)) {
|
|
735
|
+
issues.push({
|
|
736
|
+
code: 'failed_browser_proof_observation',
|
|
737
|
+
message: 'Browser Proof Observation result is not an explicit pass.',
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
if (!isBoundedBrowserProofClaim(section)) {
|
|
741
|
+
issues.push({
|
|
742
|
+
code: 'overbroad_browser_proof_claim',
|
|
743
|
+
message: 'Browser Proof Observation claim limit is missing or too broad.',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
return issues;
|
|
747
|
+
}
|
|
404
748
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
749
|
+
function isCompleteBrowserProofObservation(section) {
|
|
750
|
+
return browserProofObservationIssues(section).length === 0;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function normalizeArtifactReference(value) {
|
|
754
|
+
return String(value || '').replace(/\\/g, '/').toLowerCase();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function observationReferencesPlan(section, planPath) {
|
|
758
|
+
const planReference = sectionFieldValue(section, 'Plan')
|
|
759
|
+
|| sectionFieldValue(section, 'Plan artifact')
|
|
760
|
+
|| sectionFieldValue(section, 'PLAN');
|
|
761
|
+
if (!isMeaningfulFieldValue(planReference)) return false;
|
|
762
|
+
const normalizedReferences = normalizeArtifactReference(planReference)
|
|
763
|
+
.split(/[\n,;]+/)
|
|
764
|
+
.map((reference) => reference.trim().replace(/^\.\//, ''))
|
|
765
|
+
.filter(Boolean);
|
|
766
|
+
const normalizedPlanPath = normalizeArtifactReference(planPath);
|
|
767
|
+
return normalizedReferences.some((reference) => reference === normalizedPlanPath);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function evaluateBrowserProofObservation(browserProofPlans, matchingSummaries, phasesDir, workspaceRoot) {
|
|
771
|
+
const requiredPlans = browserProofPlans.filter((plan) => plan.required === true);
|
|
772
|
+
if (requiredPlans.length === 0 || matchingSummaries.length === 0) {
|
|
773
|
+
return {
|
|
774
|
+
satisfied: true,
|
|
775
|
+
sections: [],
|
|
776
|
+
warnings: [],
|
|
777
|
+
blockers: [],
|
|
778
|
+
};
|
|
424
779
|
}
|
|
425
780
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
});
|
|
781
|
+
const sections = matchingSummaries.flatMap((summaryPath) => {
|
|
782
|
+
const summaryFullPath = join(phasesDir, summaryPath);
|
|
783
|
+
if (!existsSync(summaryFullPath)) return [];
|
|
784
|
+
const found = findBrowserProofObservationSections(
|
|
785
|
+
readFileSync(summaryFullPath, 'utf-8'),
|
|
786
|
+
summaryFullPath,
|
|
787
|
+
workspaceRoot
|
|
788
|
+
);
|
|
789
|
+
return found.sections.map((section) => ({ section, summaryPath }));
|
|
790
|
+
});
|
|
791
|
+
const linkBlockers = matchingSummaries.flatMap((summaryPath) => {
|
|
792
|
+
const summaryFullPath = join(phasesDir, summaryPath);
|
|
793
|
+
if (!existsSync(summaryFullPath)) return [];
|
|
794
|
+
return findBrowserProofObservationSections(
|
|
795
|
+
readFileSync(summaryFullPath, 'utf-8'),
|
|
796
|
+
summaryFullPath,
|
|
797
|
+
workspaceRoot
|
|
798
|
+
).blockers.map((blocker) => ({ ...blocker, path: `${summaryPath}: ${blocker.path}` }));
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
if (sections.length === 0 && linkBlockers.length === 0) {
|
|
802
|
+
return {
|
|
803
|
+
satisfied: false,
|
|
804
|
+
sections,
|
|
805
|
+
warnings: [],
|
|
806
|
+
blockers: [{
|
|
807
|
+
code: 'missing_browser_proof_observation',
|
|
808
|
+
severity: 'blocker',
|
|
809
|
+
path: matchingSummaries.join(', '),
|
|
810
|
+
message: 'A plan requires browser proof, but no SUMMARY.md or linked record contains ## Browser Proof Observation.',
|
|
811
|
+
fix_hint: browserProofFixHint('missing_browser_proof_observation'),
|
|
812
|
+
}],
|
|
813
|
+
};
|
|
444
814
|
}
|
|
445
815
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
code:
|
|
816
|
+
const issueBlockers = sections.flatMap(({ section, summaryPath }) => (
|
|
817
|
+
browserProofObservationIssues(section).map((issue) => ({
|
|
818
|
+
code: issue.code,
|
|
449
819
|
severity: 'blocker',
|
|
450
|
-
path:
|
|
451
|
-
message:
|
|
452
|
-
fix_hint:
|
|
453
|
-
}
|
|
820
|
+
path: summaryPath,
|
|
821
|
+
message: issue.message,
|
|
822
|
+
fix_hint: browserProofFixHint(issue.code),
|
|
823
|
+
}))
|
|
824
|
+
));
|
|
825
|
+
const completeSections = sections.filter(({ section }) => isCompleteBrowserProofObservation(section));
|
|
826
|
+
if (completeSections.length === 0) {
|
|
454
827
|
return {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
errors: [missingError],
|
|
460
|
-
warnings,
|
|
828
|
+
satisfied: false,
|
|
829
|
+
sections: sections.map(({ section }) => section),
|
|
830
|
+
warnings: [],
|
|
831
|
+
blockers: [...linkBlockers, ...issueBlockers],
|
|
461
832
|
};
|
|
462
833
|
}
|
|
463
834
|
|
|
464
|
-
|
|
835
|
+
const unmatchedPlans = requiredPlans.filter((plan) => (
|
|
836
|
+
!completeSections.some(({ section }) => observationReferencesPlan(section, plan.path))
|
|
837
|
+
));
|
|
838
|
+
const unmatchedBlockers = unmatchedPlans.map((plan) => ({
|
|
839
|
+
code: 'unmatched_browser_proof_observation',
|
|
840
|
+
severity: 'blocker',
|
|
841
|
+
path: plan.path,
|
|
842
|
+
message: `No complete Browser Proof Observation references required plan ${plan.path}.`,
|
|
843
|
+
fix_hint: browserProofFixHint('unmatched_browser_proof_observation'),
|
|
844
|
+
}));
|
|
845
|
+
const blockers = [...linkBlockers, ...issueBlockers, ...unmatchedBlockers];
|
|
846
|
+
if (blockers.length === 0) {
|
|
465
847
|
return {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
errors,
|
|
471
|
-
warnings,
|
|
848
|
+
satisfied: true,
|
|
849
|
+
sections: sections.map(({ section }) => section),
|
|
850
|
+
warnings: [],
|
|
851
|
+
blockers: [],
|
|
472
852
|
};
|
|
473
853
|
}
|
|
474
854
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
:
|
|
855
|
+
return {
|
|
856
|
+
satisfied: false,
|
|
857
|
+
sections: sections.map(({ section }) => section),
|
|
858
|
+
warnings: [],
|
|
859
|
+
blockers,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
478
862
|
|
|
863
|
+
function isPlanArtifactSatisfied(artifact) {
|
|
864
|
+
if (artifact.operation === 'delete') return !artifact.exists;
|
|
865
|
+
return artifact.exists;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function planArtifactFixHint(artifact) {
|
|
869
|
+
if (artifact.operation === 'delete') {
|
|
870
|
+
return `Complete the planned DELETE for ${artifact.file}, or revise the plan if the file should remain.`;
|
|
871
|
+
}
|
|
872
|
+
return `Create or update ${artifact.file} so the planned ${artifact.operation.toUpperCase()} artifact exists, or revise the plan if it is no longer in scope.`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function evaluatePlanArtifacts(artifacts) {
|
|
876
|
+
const unsatisfied = artifacts
|
|
877
|
+
.filter((artifact) => !isPlanArtifactSatisfied(artifact))
|
|
878
|
+
.map((artifact) => ({
|
|
879
|
+
...artifact,
|
|
880
|
+
severity: 'blocker',
|
|
881
|
+
expected: artifact.operation === 'delete' ? 'absent' : 'present',
|
|
882
|
+
fix_hint: planArtifactFixHint(artifact),
|
|
883
|
+
}));
|
|
479
884
|
return {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
status: comparison.status,
|
|
483
|
-
comparison,
|
|
484
|
-
errors: comparison.errors || errors,
|
|
485
|
-
warnings,
|
|
885
|
+
satisfied: unsatisfied.length === 0,
|
|
886
|
+
unsatisfied,
|
|
486
887
|
};
|
|
487
888
|
}
|
|
488
889
|
|
|
@@ -568,6 +969,7 @@ export function cmdPhaseStatus(...args) {
|
|
|
568
969
|
return;
|
|
569
970
|
}
|
|
570
971
|
const roadmapPath = join(planningDir, 'ROADMAP.md');
|
|
972
|
+
const stateName = basename(planningDir);
|
|
571
973
|
const [phaseNumber, status] = normalizedArgs;
|
|
572
974
|
|
|
573
975
|
if (!phaseNumber || !status) {
|
|
@@ -588,9 +990,8 @@ export function cmdPhaseStatus(...args) {
|
|
|
588
990
|
const changed = updated !== roadmap;
|
|
589
991
|
if (changed) {
|
|
590
992
|
writeFileSync(roadmapPath, updated);
|
|
591
|
-
try { writeFingerprint(planningDir); } catch { /* best-effort */ }
|
|
592
993
|
}
|
|
593
|
-
output({ phase: phaseNumber, status, roadmap:
|
|
994
|
+
output({ phase: phaseNumber, status, roadmap: `${stateName}/ROADMAP.md`, changed });
|
|
594
995
|
} catch (error) {
|
|
595
996
|
console.error(error.message);
|
|
596
997
|
process.exitCode = 1;
|
|
@@ -605,9 +1006,10 @@ export function cmdFindPhase(...args) {
|
|
|
605
1006
|
return;
|
|
606
1007
|
}
|
|
607
1008
|
const phaseNum = normalizedArgs[0];
|
|
1009
|
+
const stateName = basename(planningDir);
|
|
608
1010
|
|
|
609
1011
|
if (!existsSync(planningDir)) {
|
|
610
|
-
output({ error:
|
|
1012
|
+
output({ error: `No ${stateName}/ directory found. Run \`npx -y gsdd-cli init\` then the new-project workflow first.` });
|
|
611
1013
|
return;
|
|
612
1014
|
}
|
|
613
1015
|
|
|
@@ -660,9 +1062,10 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
660
1062
|
if (!phaseNum) {
|
|
661
1063
|
return { ok: false, error: 'Usage: gsdd verify <phase-number>', exitCode: 1 };
|
|
662
1064
|
}
|
|
1065
|
+
const stateName = basename(planningDir);
|
|
663
1066
|
|
|
664
1067
|
if (!existsSync(planningDir)) {
|
|
665
|
-
return { ok: false, error:
|
|
1068
|
+
return { ok: false, error: `No ${stateName}/ directory found.`, exitCode: 1 };
|
|
666
1069
|
}
|
|
667
1070
|
const phasesDir = join(planningDir, 'phases');
|
|
668
1071
|
const matchingPlans = findFiles(phasesDir, `${padPhase(phaseNum)}-PLAN`);
|
|
@@ -672,7 +1075,7 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
672
1075
|
prerequisiteBlockers.push({
|
|
673
1076
|
code: 'missing_phase_plan',
|
|
674
1077
|
severity: 'blocker',
|
|
675
|
-
path:
|
|
1078
|
+
path: `${stateName}/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-PLAN.md`,
|
|
676
1079
|
message: `No PLAN.md artifact was found for phase ${normalizePhaseToken(phaseNum)}.`,
|
|
677
1080
|
fix_hint: `Run /gsdd-plan ${normalizePhaseToken(phaseNum)} before verifying this phase.`,
|
|
678
1081
|
});
|
|
@@ -681,7 +1084,7 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
681
1084
|
prerequisiteBlockers.push({
|
|
682
1085
|
code: 'missing_phase_summary',
|
|
683
1086
|
severity: 'blocker',
|
|
684
|
-
path:
|
|
1087
|
+
path: `${stateName}/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-SUMMARY.md`,
|
|
685
1088
|
message: `No SUMMARY.md artifact was found for phase ${normalizePhaseToken(phaseNum)}.`,
|
|
686
1089
|
fix_hint: `Run /gsdd-execute ${normalizePhaseToken(phaseNum)} before verifying this phase.`,
|
|
687
1090
|
});
|
|
@@ -692,27 +1095,45 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
692
1095
|
? extractPlanFileArtifacts(readFileSync(fullPath, 'utf-8'), workspaceRoot)
|
|
693
1096
|
: [];
|
|
694
1097
|
});
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1098
|
+
const browserProofPlans = matchingPlans.map((planPath) => {
|
|
1099
|
+
const fullPath = join(phasesDir, planPath);
|
|
1100
|
+
return existsSync(fullPath)
|
|
1101
|
+
? evaluateBrowserProofContract(readFileSync(fullPath, 'utf-8'), planPath)
|
|
1102
|
+
: {
|
|
1103
|
+
path: planPath,
|
|
1104
|
+
declaration_present: false,
|
|
1105
|
+
required: null,
|
|
1106
|
+
rationale: null,
|
|
1107
|
+
satisfied: true,
|
|
1108
|
+
warnings: [],
|
|
1109
|
+
blockers: [],
|
|
1110
|
+
};
|
|
700
1111
|
});
|
|
701
|
-
const
|
|
1112
|
+
const browserProofObservationStatus = evaluateBrowserProofObservation(
|
|
1113
|
+
browserProofPlans,
|
|
1114
|
+
matchingSummaries,
|
|
1115
|
+
phasesDir,
|
|
1116
|
+
workspaceRoot
|
|
1117
|
+
);
|
|
1118
|
+
const browserProofBlockers = [
|
|
1119
|
+
...browserProofPlans.flatMap((plan) => plan.blockers),
|
|
1120
|
+
...browserProofObservationStatus.blockers,
|
|
1121
|
+
];
|
|
1122
|
+
const browserProofWarnings = [
|
|
1123
|
+
...browserProofPlans.flatMap((plan) => plan.warnings || []),
|
|
1124
|
+
...(browserProofObservationStatus.warnings || []),
|
|
1125
|
+
];
|
|
1126
|
+
const artifactStatus = evaluatePlanArtifacts(artifacts);
|
|
702
1127
|
const legacyVerified = matchingPlans.length > 0 && matchingSummaries.length > 0;
|
|
703
|
-
const uiProofGate = {
|
|
704
|
-
status: uiProof.status,
|
|
705
|
-
required: uiProof.status !== 'not_applicable',
|
|
706
|
-
satisfied: uiProofSatisfied,
|
|
707
|
-
blocks_verification: uiProof.status !== 'not_applicable' && !uiProofSatisfied,
|
|
708
|
-
required_block: uiProof.status !== 'not_applicable' && !uiProofSatisfied ? 'ui-proof-failed' : null,
|
|
709
|
-
};
|
|
710
1128
|
const blockedOn = [
|
|
711
1129
|
...(prerequisiteBlockers.length > 0 ? ['prerequisites'] : []),
|
|
712
1130
|
...(artifactStatus.satisfied ? [] : ['artifacts']),
|
|
713
|
-
...(
|
|
1131
|
+
...(browserProofBlockers.length > 0 ? ['browser_proof'] : []),
|
|
714
1132
|
];
|
|
715
|
-
const closureVerified = legacyVerified
|
|
1133
|
+
const closureVerified = legacyVerified
|
|
1134
|
+
&& prerequisiteBlockers.length === 0
|
|
1135
|
+
&& artifactStatus.satisfied
|
|
1136
|
+
&& browserProofBlockers.length === 0;
|
|
716
1137
|
|
|
717
1138
|
const result = {
|
|
718
1139
|
phase: normalizePhaseToken(phaseNum),
|
|
@@ -722,7 +1143,13 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
722
1143
|
artifacts,
|
|
723
1144
|
allExist: artifacts.every((artifact) => artifact.exists),
|
|
724
1145
|
artifact_status: artifactStatus,
|
|
725
|
-
|
|
1146
|
+
browser_proof_status: {
|
|
1147
|
+
satisfied: browserProofBlockers.length === 0,
|
|
1148
|
+
plans: browserProofPlans,
|
|
1149
|
+
observations: browserProofObservationStatus,
|
|
1150
|
+
warnings: browserProofWarnings,
|
|
1151
|
+
blockers: browserProofBlockers,
|
|
1152
|
+
},
|
|
726
1153
|
verified: closureVerified,
|
|
727
1154
|
legacy_verified: legacyVerified,
|
|
728
1155
|
phase_artifacts_present: legacyVerified,
|
|
@@ -730,7 +1157,6 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
730
1157
|
satisfied: prerequisiteBlockers.length === 0,
|
|
731
1158
|
blockers: prerequisiteBlockers,
|
|
732
1159
|
},
|
|
733
|
-
ui_proof: uiProofGate,
|
|
734
1160
|
blocked_on: blockedOn,
|
|
735
1161
|
blocks_verification: blockedOn.length > 0,
|
|
736
1162
|
};
|