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.
Files changed (84) hide show
  1. package/README.md +53 -22
  2. package/agents/DISTILLATION.md +2 -2
  3. package/agents/README.md +4 -4
  4. package/agents/approach-explorer.md +4 -4
  5. package/agents/executor.md +20 -20
  6. package/agents/integration-checker.md +2 -2
  7. package/agents/planner.md +10 -26
  8. package/agents/researcher.md +2 -2
  9. package/agents/roadmapper.md +6 -6
  10. package/agents/synthesizer.md +18 -18
  11. package/agents/verifier.md +3 -3
  12. package/bin/adapters/agents.mjs +3 -3
  13. package/bin/adapters/claude.mjs +16 -14
  14. package/bin/adapters/opencode.mjs +15 -12
  15. package/bin/gsdd.mjs +16 -13
  16. package/bin/lib/{models.mjs → config.mjs} +23 -16
  17. package/bin/lib/control-map.mjs +17 -488
  18. package/bin/lib/health-truth.mjs +8 -13
  19. package/bin/lib/health.mjs +25 -39
  20. package/bin/lib/init-flow.mjs +44 -38
  21. package/bin/lib/init-prompts.mjs +3 -3
  22. package/bin/lib/init-runtime.mjs +11 -30
  23. package/bin/lib/lifecycle-preflight.mjs +97 -410
  24. package/bin/lib/lifecycle-state.mjs +2 -1
  25. package/bin/lib/next.mjs +243 -20
  26. package/bin/lib/phase.mjs +706 -280
  27. package/bin/lib/plan-constants.mjs +0 -5
  28. package/bin/lib/rendering.mjs +64 -44
  29. package/bin/lib/runtime-freshness.mjs +18 -15
  30. package/bin/lib/state-dir.mjs +45 -0
  31. package/bin/lib/templates.mjs +59 -22
  32. package/bin/lib/work-context.mjs +12 -1
  33. package/bin/lib/workflows.mjs +0 -1
  34. package/bin/lib/workspace-root.mjs +11 -6
  35. package/distilled/DESIGN.md +89 -31
  36. package/distilled/EVIDENCE-INDEX.md +18 -5
  37. package/distilled/README.md +23 -33
  38. package/distilled/SKILL.md +9 -10
  39. package/distilled/templates/agents.block.md +5 -5
  40. package/distilled/templates/approach.md +3 -3
  41. package/distilled/templates/auth-matrix.md +2 -2
  42. package/distilled/templates/brownfield-change/CHANGE.md +1 -1
  43. package/distilled/templates/delegates/approach-explorer.md +5 -5
  44. package/distilled/templates/delegates/mapper-arch.md +3 -3
  45. package/distilled/templates/delegates/mapper-concerns.md +4 -4
  46. package/distilled/templates/delegates/mapper-quality.md +3 -3
  47. package/distilled/templates/delegates/mapper-tech.md +3 -3
  48. package/distilled/templates/delegates/plan-checker.md +18 -19
  49. package/distilled/templates/delegates/researcher-architecture.md +3 -3
  50. package/distilled/templates/delegates/researcher-features.md +3 -3
  51. package/distilled/templates/delegates/researcher-pitfalls.md +3 -3
  52. package/distilled/templates/delegates/researcher-stack.md +3 -3
  53. package/distilled/templates/delegates/researcher-synthesizer.md +7 -7
  54. package/distilled/templates/research/architecture.md +1 -1
  55. package/distilled/templates/research/pitfalls.md +1 -1
  56. package/distilled/templates/research/stack.md +1 -1
  57. package/distilled/templates/roadmap.md +4 -4
  58. package/distilled/templates/spec.md +2 -2
  59. package/distilled/templates/ui-proof.md +81 -181
  60. package/distilled/workflows/audit-milestone.md +23 -23
  61. package/distilled/workflows/complete-milestone.md +35 -35
  62. package/distilled/workflows/execute.md +34 -35
  63. package/distilled/workflows/map-codebase.md +30 -30
  64. package/distilled/workflows/new-milestone.md +18 -18
  65. package/distilled/workflows/new-project.md +45 -45
  66. package/distilled/workflows/pause.md +15 -15
  67. package/distilled/workflows/plan.md +106 -114
  68. package/distilled/workflows/progress.md +40 -39
  69. package/distilled/workflows/quick.md +49 -50
  70. package/distilled/workflows/resume.md +23 -22
  71. package/distilled/workflows/verify-work.md +7 -7
  72. package/distilled/workflows/verify.md +26 -26
  73. package/docs/BROWNFIELD-PROOF.md +1 -1
  74. package/docs/RUNTIME-SUPPORT.md +13 -13
  75. package/docs/USER-GUIDE.md +26 -21
  76. package/docs/claude/context-monitor.md +1 -1
  77. package/docs/proof/consumer-node-cli/README.md +1 -1
  78. package/package.json +3 -3
  79. package/bin/lib/closeout-report.mjs +0 -318
  80. package/bin/lib/evidence-contract.mjs +0 -325
  81. package/bin/lib/provenance.mjs +0 -390
  82. package/bin/lib/session-fingerprint.mjs +0 -223
  83. package/bin/lib/ui-proof.mjs +0 -1007
  84. 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 isPlanArtifactSatisfied(artifact) {
179
- if (artifact.operation === 'delete') return !artifact.exists;
180
- return artifact.exists;
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 planArtifactFixHint(artifact) {
184
- if (artifact.operation === 'delete') {
185
- return `Complete the planned DELETE for ${artifact.file}, or revise the plan if the file should remain.`;
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 evaluatePlanArtifacts(artifacts) {
191
- const unsatisfied = artifacts
192
- .filter((artifact) => !isPlanArtifactSatisfied(artifact))
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 normalizeUiProofIssue(issue) {
206
- return {
207
- ...issue,
208
- severity: issue.severity || 'blocker',
209
- fix_hint: issue.fix_hint || issue.fix || 'Fix the UI proof issue before claiming verification is complete.',
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
- return String(value || '').replace(/\s+#.*$/, '').trim();
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 stripOuterScalarQuotes(value) {
218
- return String(value)
219
- .trim()
220
- .replace(/^(['"])([\s\S]*)\1$/g, '$2')
221
- .trim();
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 normalizeNullableFrontmatterValue(value) {
225
- const stripped = stripOuterScalarQuotes(value);
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 extractUiProofSlotIds(value) {
233
- const ids = [];
234
- const slotPattern = /['"]?slot_id['"]?\s*:\s*['"]?([^,'"\]\s}]+)['"]?/g;
235
- for (const match of String(value || '').matchAll(slotPattern)) {
236
- ids.push(match[1].replace(/^['"]|['"]$/g, ''));
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 ids;
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 readPlanFrontmatter(planContent) {
242
- const content = String(planContent || '');
243
- if (!content.startsWith('---')) return '';
244
- const lines = content.split(/\r?\n/);
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 frontmatterKeyBlock(frontmatter, key) {
254
- const lines = String(frontmatter || '').split(/\r?\n/);
255
- const keyPattern = new RegExp(`^${key}:[ \\t]*(.*)$`);
256
- for (let index = 0; index < lines.length; index += 1) {
257
- const match = lines[index].match(keyPattern);
258
- if (!match) continue;
259
- const block = [];
260
- for (let next = index + 1; next < lines.length; next += 1) {
261
- const line = lines[next];
262
- if (/^\S[^:\n]*:\s*/.test(line)) break;
263
- block.push(line);
264
- }
265
- return { inline: stripInlineComment(match[1]), block };
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 null;
331
+ return /\b(pass(?:ed|ing)?|satisfied|success(?:ful)?)\b/i.test(value);
268
332
  }
269
333
 
270
- function frontmatterScalar(frontmatter, key) {
271
- const entry = frontmatterKeyBlock(frontmatter, key);
272
- if (!entry) return null;
273
- if (entry.inline && !['|', '>'].includes(entry.inline)) return entry.inline;
274
- return entry.block
275
- .map((line) => line.trim())
276
- .filter(Boolean)
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 readPlanUiProofContract(planContent) {
282
- const frontmatter = readPlanFrontmatter(planContent);
283
- const slotsEntry = frontmatterKeyBlock(frontmatter, 'ui_proof_slots');
284
- const rationale = normalizeNullableFrontmatterValue(frontmatterScalar(frontmatter, 'no_ui_proof_rationale') || '');
285
- const result = {
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
- if (!slotsEntry) return result;
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
- if (slotsEntry.inline) {
297
- if (['[]', 'null', '~'].includes(slotsEntry.inline)) {
298
- result.explicitEmptySlots = true;
299
- return result;
300
- }
301
- result.declaresSlots = true;
302
- result.slotIds.push(...extractUiProofSlotIds(slotsEntry.inline));
303
- return result;
304
- }
305
-
306
- let sawMeaningfulLine = false;
307
- for (const line of slotsEntry.block) {
308
- const trimmed = line.trim();
309
- if (!trimmed || trimmed.startsWith('#')) continue;
310
- sawMeaningfulLine = true;
311
- if (/^\s+-\s+/.test(line)) result.declaresSlots = true;
312
- result.slotIds.push(...extractUiProofSlotIds(trimmed));
313
- }
314
-
315
- result.explicitEmptySlots = !result.declaresSlots && !sawMeaningfulLine;
316
- return result;
317
- }
318
-
319
- function findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths) {
320
- const candidates = new Set();
321
- const declaredPlans = [];
322
- const declaredSlotIds = [];
323
- const noUiPlans = [];
324
- const contractErrors = [];
325
- const names = new Set([
326
- 'ui-proof-slots.json',
327
- 'ui-proof-slots.md',
328
- 'UI-PROOF-SLOTS.json',
329
- 'UI-PROOF-SLOTS.md',
330
- 'planned-ui-proof.json',
331
- 'planned-ui-proof.md',
332
- ]);
333
-
334
- for (const planDisplayPath of planDisplayPaths) {
335
- const fullPlanPath = join(planningDir, 'phases', planDisplayPath);
336
- if (!existsSync(fullPlanPath)) continue;
337
- const planContent = readFileSync(fullPlanPath, 'utf-8');
338
- const relPlanPath = relative(planningDir, fullPlanPath).replace(/\\/g, '/');
339
- const contract = readPlanUiProofContract(planContent);
340
- const planDir = dirname(fullPlanPath);
341
- const sidecars = [];
342
- if (existsSync(planDir)) {
343
- for (const entry of readdirSync(planDir, { withFileTypes: true })) {
344
- if (entry.isFile() && names.has(entry.name)) {
345
- sidecars.push(join(planDir, entry.name));
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
- if (contract.hasUiProofKey && !contract.declaresSlots && !contract.hasNoUiProofRationale) {
351
- contractErrors.push(normalizeUiProofIssue({
352
- code: 'missing_no_ui_proof_rationale',
353
- path: `${relPlanPath}.no_ui_proof_rationale`,
354
- message: 'Plan declares empty ui_proof_slots but does not provide a no_ui_proof_rationale.',
355
- fix: 'Add a nonblank no_ui_proof_rationale for non-UI work, or declare required UI proof slots and add a planned slots artifact.',
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
- if (contract.hasNoUiProofRationale && !contract.declaresSlots) {
360
- noUiPlans.push({ plan: relPlanPath, sidecars });
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
- if (!contract.declaresSlots) continue;
365
-
366
- declaredPlans.push(relPlanPath);
367
- for (const slotId of contract.slotIds) {
368
- declaredSlotIds.push({ plan: relPlanPath, slot_id: slotId });
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
- for (const entry of readdirSync(planDir, { withFileTypes: true })) {
371
- if (entry.isFile() && names.has(entry.name)) {
372
- candidates.add(join(planDir, entry.name));
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 { declaredPlans, declaredSlotIds, noUiPlans, contractErrors, files: [...candidates].sort() };
688
+ return { records, blockers };
377
689
  }
378
690
 
379
- function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
380
- const plannedDiscovery = findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths);
381
- const plannedFiles = plannedDiscovery.files;
382
- const phaseDirs = new Set(planDisplayPaths.map((planDisplayPath) => dirname(join(planningDir, 'phases', planDisplayPath))));
383
- const observedFiles = findUiProofBundleFiles(planningDir)
384
- .filter((filePath) => phaseDirs.has(dirname(filePath)));
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
- const plannedSlots = [];
387
- const errors = [];
388
- const warnings = [];
389
- const planned = [];
390
- const observed = [];
391
-
392
- errors.push(...plannedDiscovery.contractErrors);
393
- for (const noUiPlan of plannedDiscovery.noUiPlans) {
394
- for (const filePath of noUiPlan.sidecars) {
395
- warnings.push({
396
- code: 'stale_ui_proof_sidecar_ignored',
397
- severity: 'warn',
398
- path: relative(workspaceRoot, filePath).replace(/\\/g, '/'),
399
- message: `Plan ${noUiPlan.plan} records no_ui_proof_rationale, so the UI proof sidecar is ignored for closure.`,
400
- fix_hint: 'Remove or classify the stale sidecar if it no longer belongs to this non-UI phase.',
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
- for (const filePath of plannedFiles) {
406
- const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/');
407
- const parsed = parseUiProofSlotsContent(readFileSync(filePath, 'utf-8'), rel);
408
- planned.push(rel);
409
- plannedSlots.push(...parsed.slots);
410
- errors.push(...parsed.errors.map(normalizeUiProofIssue));
411
- }
412
-
413
- if (plannedSlots.length > 0 && plannedDiscovery.declaredSlotIds.length > 0) {
414
- const plannedSlotIds = new Set(plannedSlots.map((slot) => String(slot?.slot_id || '')));
415
- for (const declaredSlot of plannedDiscovery.declaredSlotIds) {
416
- if (plannedSlotIds.has(String(declaredSlot.slot_id))) continue;
417
- errors.push(normalizeUiProofIssue({
418
- code: 'planned_ui_proof_slots_drift',
419
- path: `${declaredSlot.plan}.ui_proof_slots`,
420
- message: `Plan declares UI proof slot ${declaredSlot.slot_id}, but no matching slot exists in the planned UI proof artifact.`,
421
- fix: 'Update ui-proof-slots.json or ui-proof-slots.md beside the plan so it matches the plan-declared slot IDs, or update the plan declaration.',
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 observedBundles = [];
427
- for (const filePath of observedFiles) {
428
- const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/');
429
- const parsed = readUiProofBundleFile(filePath);
430
- observed.push(rel);
431
- if (parsed.errors.length > 0) {
432
- errors.push(...parsed.errors.map((error) => normalizeUiProofIssue({ ...error, path: error.path || rel })));
433
- continue;
434
- }
435
- observedBundles.push({
436
- source: rel,
437
- bundle: parsed.bundle,
438
- options: {
439
- requireLocalArtifactExists: true,
440
- workspaceRoot,
441
- bundleDir: dirname(filePath),
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
- if (plannedFiles.length === 0 && plannedDiscovery.declaredPlans.length > 0) {
447
- const missingError = {
448
- code: 'missing_planned_ui_proof_slots_file',
816
+ const issueBlockers = sections.flatMap(({ section, summaryPath }) => (
817
+ browserProofObservationIssues(section).map((issue) => ({
818
+ code: issue.code,
449
819
  severity: 'blocker',
450
- path: plannedDiscovery.declaredPlans[0],
451
- message: 'Plan declares ui_proof_slots but no ui-proof-slots artifact was found beside the plan.',
452
- fix_hint: 'Create ui-proof-slots.json or ui-proof-slots.md beside the plan, or set ui_proof_slots: [] with a no_ui_proof_rationale if the phase is not UI-sensitive.',
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
- planned,
456
- observed,
457
- status: 'missing',
458
- comparison: { status: 'missing', slots: [], errors: [missingError] },
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
- if (plannedFiles.length === 0) {
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
- planned,
467
- observed,
468
- status: errors.length > 0 ? 'partial' : 'not_applicable',
469
- comparison: null,
470
- errors,
471
- warnings,
848
+ satisfied: true,
849
+ sections: sections.map(({ section }) => section),
850
+ warnings: [],
851
+ blockers: [],
472
852
  };
473
853
  }
474
854
 
475
- const comparison = errors.length > 0
476
- ? { status: 'partial', slots: [], errors: errors.map(normalizeUiProofIssue) }
477
- : compareUiProofSlots(plannedSlots, observedBundles);
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
- planned,
481
- observed,
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: '.planning/ROADMAP.md', changed });
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: 'No .planning/ directory found. Run `npx -y gsdd-cli init` then the new-project workflow first.' });
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: 'No .planning/ directory found.', exitCode: 1 };
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: `.planning/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-PLAN.md`,
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: `.planning/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-SUMMARY.md`,
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 artifactStatus = evaluatePlanArtifacts(artifacts);
696
- const uiProof = comparePhaseUiProof({
697
- planningDir,
698
- workspaceRoot,
699
- planDisplayPaths: matchingPlans,
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 uiProofSatisfied = ['satisfied', 'not_applicable'].includes(uiProof.status);
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
- ...(uiProofGate.blocks_verification ? ['ui_proof'] : []),
1131
+ ...(browserProofBlockers.length > 0 ? ['browser_proof'] : []),
714
1132
  ];
715
- const closureVerified = legacyVerified && prerequisiteBlockers.length === 0 && artifactStatus.satisfied && uiProofSatisfied;
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
- uiProof,
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
  };