gsdd-cli 0.20.0 → 0.21.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.
@@ -186,9 +186,12 @@ Commands:
186
186
  phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])
187
187
  lifecycle-preflight <surface> [phase]
188
188
  Inspect deterministic lifecycle gate results for a workflow surface
189
- session-fingerprint write Rebaseline planning-state drift after reviewing changed planning files
189
+ session-fingerprint write [--allow-changed <ROADMAP.md,SPEC.md,config.json>]
190
+ Rebaseline planning-state drift after reviewing changed planning files
190
191
  ui-proof validate <path> [--claim <public|publication|tracked|delivery|release>]
191
192
  Validate UI proof metadata; use --claim for stronger proof uses
193
+ ui-proof compare <planned-slots-json> [observed-bundle-json ...]
194
+ Compare planned UI proof slots against observed bundles
192
195
  help Show this summary
193
196
 
194
197
  Platforms (for --tools):
@@ -259,7 +262,7 @@ Advanced/internal helpers (kept available, but not the primary first-run user st
259
262
  lifecycle-preflight Inspect deterministic lifecycle gate results for a workflow surface
260
263
  session-fingerprint Rebaseline the local planning-state fingerprint after review
261
264
  phase-status Update ROADMAP.md phase status through the local helper surface
262
- ui-proof Validate UI proof metadata; use --claim for stronger proof uses
265
+ ui-proof Validate UI proof metadata and compare planned slots to observed bundles
263
266
  file-op Deterministic workspace-confined file copy/delete/text mutation
264
267
  `;
265
268
  }
@@ -52,6 +52,11 @@ const SURFACE_POLICIES = {
52
52
  ownedWrites: ['spec', 'roadmap', 'phase-directories'],
53
53
  explicitLifecycleMutation: 'none',
54
54
  },
55
+ 'plan-milestone-gaps': {
56
+ classification: 'owned_write',
57
+ ownedWrites: ['roadmap', 'phase-directories'],
58
+ explicitLifecycleMutation: 'none',
59
+ },
55
60
  resume: {
56
61
  classification: 'owned_write',
57
62
  ownedWrites: ['checkpoint-cleanup'],
@@ -72,10 +72,12 @@ function printHelp() {
72
72
  ' lifecycle-preflight <surface> [phase]',
73
73
  ' Inspect lifecycle gate results for a workflow surface',
74
74
  ' Example: node .planning/bin/gsdd.mjs lifecycle-preflight verify 1 --expects-mutation phase-status',
75
- ' session-fingerprint write',
75
+ ' session-fingerprint write [--allow-changed <ROADMAP.md,SPEC.md,config.json>]',
76
76
  ' Rebaseline planning-state drift after reviewing changed planning files',
77
77
  ' ui-proof validate <path> [--claim <public|publication|tracked|delivery|release>]',
78
78
  ' Validate UI proof metadata; use --claim for stronger proof uses',
79
+ ' ui-proof compare <planned-slots-json> [observed-bundle-json ...]',
80
+ ' Compare planned UI proof slots against observed bundles',
79
81
  '',
80
82
  'Advanced option:',
81
83
  ' --workspace-root <path> Override workspace root discovery before or after the subcommand',
@@ -63,16 +63,56 @@ export function cmdSessionFingerprint(...args) {
63
63
  return;
64
64
  }
65
65
 
66
- const [action] = normalizedArgs;
66
+ const [action, ...flags] = normalizedArgs;
67
67
  if (action !== 'write') {
68
- console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write');
68
+ console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write [--allow-changed <ROADMAP.md,SPEC.md,config.json>]');
69
69
  process.exitCode = 1;
70
70
  return;
71
71
  }
72
72
 
73
+ const allowChanged = parseAllowChanged(flags);
74
+ if (allowChanged.invalid) {
75
+ console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write [--allow-changed <ROADMAP.md,SPEC.md,config.json>]');
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+
80
+ if (allowChanged.files.length > 0) {
81
+ const drift = checkDrift(planningDir);
82
+ const changedFiles = drift.files.filter((file) => file.status !== 'unchanged').map((file) => file.file);
83
+ const unexpected = changedFiles.filter((file) => !allowChanged.files.includes(file));
84
+ if (unexpected.length > 0) {
85
+ output({
86
+ operation: 'session-fingerprint write',
87
+ changedFiles,
88
+ allowedChanged: allowChanged.files,
89
+ written: false,
90
+ reason: 'unexpected_planning_drift',
91
+ unexpected,
92
+ });
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ }
97
+
73
98
  output({ operation: 'session-fingerprint write', fingerprint: writeFingerprint(planningDir) });
74
99
  }
75
100
 
101
+ function parseAllowChanged(flags) {
102
+ const files = [];
103
+ for (let index = 0; index < flags.length; index += 1) {
104
+ if (flags[index] !== '--allow-changed') return { invalid: true, files: [] };
105
+ const value = flags[index + 1];
106
+ if (!value || value.startsWith('--')) return { invalid: true, files: [] };
107
+ files.push(...value.split(',').map((entry) => entry.trim()).filter(Boolean));
108
+ index += 1;
109
+ }
110
+ for (const file of files) {
111
+ if (!FINGERPRINT_SOURCES.includes(file)) return { invalid: true, files: [] };
112
+ }
113
+ return { invalid: false, files: [...new Set(files)] };
114
+ }
115
+
76
116
  /**
77
117
  * Read the stored fingerprint from .planning/.state-fingerprint.json.
78
118
  * Returns null if the file does not exist or is unparseable.
@@ -305,6 +305,301 @@ function validateObservationArtifactRefs(bundle, artifactRefs, errors) {
305
305
  }
306
306
  }
307
307
 
308
+ function stableString(value) {
309
+ return JSON.stringify(canonicalize(value));
310
+ }
311
+
312
+ function canonicalize(value) {
313
+ if (Array.isArray(value)) return value.map(canonicalize);
314
+ if (!isPlainObject(value)) return value;
315
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
316
+ }
317
+
318
+ function valuesMatch(planned, observed) {
319
+ if (!hasValue(planned)) return true;
320
+ if (!hasValue(observed)) return false;
321
+ return stableString(planned) === stableString(observed);
322
+ }
323
+
324
+ function slotId(slot, index) {
325
+ return slot?.slot_id || slot?.slotId || slot?.id || `ui-proof-slot-${index + 1}`;
326
+ }
327
+
328
+ function observationText(observation) {
329
+ if (typeof observation === 'string') return observation;
330
+ if (isPlainObject(observation) && typeof observation.observation === 'string') return observation.observation;
331
+ return '';
332
+ }
333
+
334
+ function includesObservation(observations, expected) {
335
+ const expectedText = typeof expected === 'string' ? expected.trim() : observationText(expected).trim();
336
+ if (!expectedText) return true;
337
+ return observations.some((observation) => observationText(observation).includes(expectedText));
338
+ }
339
+
340
+ function normalizeObservedBundle(entry) {
341
+ if (entry?.bundle) {
342
+ return {
343
+ bundle: entry.bundle,
344
+ validation: entry.validation || validateUiProofBundle(entry.bundle, entry.options || {}),
345
+ source: entry.source || entry.filePath || 'observed bundle',
346
+ };
347
+ }
348
+ return {
349
+ bundle: entry,
350
+ validation: validateUiProofBundle(entry),
351
+ source: 'observed bundle',
352
+ };
353
+ }
354
+
355
+ function compareSlotToBundle(slot, slotIdValue, observed) {
356
+ const issues = [];
357
+ const bundle = observed.bundle;
358
+ const observations = normalizeArray(bundle?.observations);
359
+ if (!observed.validation.valid) {
360
+ issues.push({
361
+ code: 'invalid_observed_bundle',
362
+ path: observed.source,
363
+ message: `Observed UI proof bundle for slot ${slotIdValue} failed metadata validation.`,
364
+ details: observed.validation.errors,
365
+ });
366
+ }
367
+
368
+ const bundleStatus = bundle?.result?.comparison_status_by_slot?.[slotIdValue];
369
+ if (bundle?.result?.claim_status !== 'passed') {
370
+ issues.push({
371
+ code: 'unsatisfied_observed_claim_status',
372
+ path: 'result.claim_status',
373
+ message: `Observed UI proof bundle claim status is ${bundle?.result?.claim_status || 'missing'} for slot ${slotIdValue}.`,
374
+ });
375
+ }
376
+ if (bundleStatus !== 'satisfied') {
377
+ issues.push({
378
+ code: 'unsatisfied_observed_comparison_status',
379
+ path: `result.comparison_status_by_slot.${slotIdValue}`,
380
+ message: `Observed UI proof bundle reports ${bundleStatus || 'missing'} for slot ${slotIdValue}.`,
381
+ });
382
+ }
383
+
384
+ const requiredKinds = normalizeArray(slot?.required_evidence_kinds || slot?.requiredEvidenceKinds);
385
+ const observedKinds = normalizeArray(bundle?.evidence_inputs?.kinds);
386
+ const missingKinds = requiredKinds.filter((kind) => !observedKinds.includes(kind));
387
+ if (missingKinds.length > 0) {
388
+ issues.push({
389
+ code: 'missing_required_evidence_kind',
390
+ path: 'evidence_inputs.kinds',
391
+ message: `Observed UI proof for slot ${slotIdValue} is missing required evidence kind(s): ${missingKinds.join(', ')}.`,
392
+ });
393
+ }
394
+ const missingNonHuman = missingKinds.filter((kind) => kind !== 'human');
395
+ if (missingNonHuman.length > 0 && observedKinds.includes('human')) {
396
+ issues.push({
397
+ code: 'human_evidence_cannot_bypass_required_non_human_evidence',
398
+ path: 'evidence_inputs.kinds',
399
+ message: `Human evidence cannot satisfy missing non-human UI proof evidence for slot ${slotIdValue}: ${missingNonHuman.join(', ')}.`,
400
+ });
401
+ }
402
+
403
+ if (!valuesMatch(slot?.route_state || slot?.routeState, bundle?.route_state)) {
404
+ issues.push({
405
+ code: 'route_state_mismatch',
406
+ path: 'route_state',
407
+ message: `Observed UI proof route/state does not match planned slot ${slotIdValue}.`,
408
+ });
409
+ }
410
+
411
+ if (!valuesMatch(slot?.environment, bundle?.environment)) {
412
+ issues.push({
413
+ code: 'environment_mismatch',
414
+ path: 'environment',
415
+ message: `Observed UI proof environment does not match planned slot ${slotIdValue}.`,
416
+ });
417
+ }
418
+
419
+ if (!valuesMatch(slot?.viewport, bundle?.viewport)) {
420
+ issues.push({
421
+ code: 'viewport_mismatch',
422
+ path: 'viewport',
423
+ message: `Observed UI proof viewport does not match planned slot ${slotIdValue}.`,
424
+ });
425
+ }
426
+
427
+ const requirementId = slot?.requirement_id || slot?.requirementId;
428
+ if (hasValue(requirementId) && !normalizeArray(bundle?.scope?.requirement_ids).includes(requirementId)) {
429
+ issues.push({
430
+ code: 'requirement_mismatch',
431
+ path: 'scope.requirement_ids',
432
+ message: `Observed UI proof bundle does not declare planned requirement ${requirementId} for slot ${slotIdValue}.`,
433
+ });
434
+ }
435
+
436
+ if (hasValue(slot?.claim) && bundle?.scope?.claim !== slot.claim) {
437
+ issues.push({
438
+ code: 'claim_mismatch',
439
+ path: 'scope.claim',
440
+ message: `Observed UI proof bundle claim does not match planned slot ${slotIdValue}.`,
441
+ });
442
+ }
443
+
444
+ if (hasValue(slot?.claim) && !observations.some((observation) => observation?.claim === slot.claim)) {
445
+ issues.push({
446
+ code: 'observation_claim_mismatch',
447
+ path: 'observations[].claim',
448
+ message: `Observed UI proof observations do not support the exact planned claim for slot ${slotIdValue}.`,
449
+ });
450
+ }
451
+
452
+ const supportingObservations = observations
453
+ .map((observation, index) => ({ observation, index }))
454
+ .filter(({ observation }) => !hasValue(slot?.claim) || observation?.claim === slot.claim);
455
+
456
+ if (hasValue(slot?.route_state || slot?.routeState)) {
457
+ for (const { observation, index } of supportingObservations) {
458
+ if (!valuesMatch(slot?.route_state || slot?.routeState, observation?.route_state)) {
459
+ issues.push({
460
+ code: 'observation_route_state_mismatch',
461
+ path: `observations[${index}].route_state`,
462
+ message: `Observed UI proof observation route/state does not match planned slot ${slotIdValue}.`,
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ const passedSupportingKinds = new Set(
469
+ supportingObservations
470
+ .filter(({ observation }) => observation?.result === 'passed')
471
+ .map(({ observation }) => observation?.evidence_kind)
472
+ .filter(Boolean)
473
+ );
474
+ const missingSupportingKinds = requiredKinds.filter((kind) => !passedSupportingKinds.has(kind));
475
+ if (missingSupportingKinds.length > 0) {
476
+ issues.push({
477
+ code: 'missing_supporting_observation_evidence_kind',
478
+ path: 'observations[].evidence_kind',
479
+ message: `Observed UI proof for slot ${slotIdValue} lacks passed supporting observation(s) for required evidence kind(s): ${missingSupportingKinds.join(', ')}.`,
480
+ });
481
+ }
482
+
483
+ for (const [index, step] of normalizeArray(bundle?.commands_or_manual_steps).entries()) {
484
+ if (step?.result !== 'passed') {
485
+ issues.push({
486
+ code: 'unsatisfied_proof_step',
487
+ path: `commands_or_manual_steps[${index}].result`,
488
+ message: `Observed UI proof command/manual step is ${step?.result || 'missing'} for slot ${slotIdValue}.`,
489
+ });
490
+ }
491
+ }
492
+
493
+ const manualAcceptanceRequired = slot?.manual_acceptance_required === true || slot?.manualAcceptanceRequired === true;
494
+ if (manualAcceptanceRequired) {
495
+ if (!observedKinds.includes('human')) {
496
+ issues.push({
497
+ code: 'missing_manual_acceptance_evidence',
498
+ path: 'evidence_inputs.kinds',
499
+ message: `Observed UI proof for slot ${slotIdValue} is missing required human evidence for manual acceptance.`,
500
+ });
501
+ }
502
+ if (!passedSupportingKinds.has('human')) {
503
+ issues.push({
504
+ code: 'missing_manual_acceptance_observation',
505
+ path: 'observations[].evidence_kind',
506
+ message: `Observed UI proof for slot ${slotIdValue} lacks a passed human observation for manual acceptance.`,
507
+ });
508
+ }
509
+ }
510
+
511
+ for (const { observation, index } of supportingObservations) {
512
+ if (observation?.result !== 'passed') {
513
+ issues.push({
514
+ code: 'unsatisfied_observation_result',
515
+ path: `observations[${index}].result`,
516
+ message: `Observed UI proof observation is ${observation?.result || 'missing'} for slot ${slotIdValue}.`,
517
+ });
518
+ }
519
+ }
520
+
521
+ for (const expected of normalizeArray(slot?.minimum_observations || slot?.minimumObservations)) {
522
+ if (!includesObservation(supportingObservations.map(({ observation }) => observation), expected)) {
523
+ issues.push({
524
+ code: 'missing_minimum_observation',
525
+ path: 'observations',
526
+ message: `Observed UI proof for slot ${slotIdValue} is missing a planned minimum observation.`,
527
+ });
528
+ }
529
+ }
530
+
531
+ if (hasValue(slot?.claim_limit || slot?.claimLimit)) {
532
+ const claimLimit = slot.claim_limit || slot.claimLimit;
533
+ if (!normalizeArray(bundle?.claim_limits).includes(claimLimit)) {
534
+ issues.push({
535
+ code: 'missing_claim_limit',
536
+ path: 'claim_limits',
537
+ message: `Observed UI proof for slot ${slotIdValue} does not preserve the planned claim limit.`,
538
+ });
539
+ }
540
+ }
541
+
542
+ const status = issues.length === 0 ? 'satisfied' : (bundleStatus === 'missing' ? 'missing' : 'partial');
543
+ return { status, issues, source: observed.source };
544
+ }
545
+
546
+ export function compareUiProofSlots(plannedSlots, observedBundles) {
547
+ const slots = normalizeArray(plannedSlots);
548
+ const bundles = normalizeArray(observedBundles).map(normalizeObservedBundle);
549
+ const results = [];
550
+ const errors = [];
551
+
552
+ for (const observed of bundles) {
553
+ if (!observed.validation.valid) {
554
+ errors.push({
555
+ code: 'invalid_observed_bundle',
556
+ path: observed.source,
557
+ message: `Observed UI proof bundle ${observed.source} failed metadata validation.`,
558
+ details: observed.validation.errors,
559
+ });
560
+ }
561
+ }
562
+
563
+ for (const [index, slot] of slots.entries()) {
564
+ const slotIdValue = slotId(slot, index);
565
+ const matchingBundles = bundles.filter((observed) => normalizeArray(observed.bundle?.scope?.slot_ids).includes(slotIdValue));
566
+ if (matchingBundles.length === 0) {
567
+ results.push({
568
+ slot_id: slotIdValue,
569
+ status: 'missing',
570
+ issues: [{
571
+ code: 'missing_observed_bundle',
572
+ path: 'scope.slot_ids',
573
+ message: `No observed UI proof bundle declares planned slot ${slotIdValue}.`,
574
+ }],
575
+ });
576
+ continue;
577
+ }
578
+
579
+ const candidates = matchingBundles.map((observed) => compareSlotToBundle(slot, slotIdValue, observed));
580
+ const satisfied = candidates.find((candidate) => candidate.status === 'satisfied');
581
+ if (satisfied) {
582
+ results.push({ slot_id: slotIdValue, status: 'satisfied', issues: [], source: satisfied.source });
583
+ continue;
584
+ }
585
+ const partial = candidates.find((candidate) => candidate.status === 'partial') || candidates[0];
586
+ results.push({ slot_id: slotIdValue, status: partial.status, issues: partial.issues, source: partial.source });
587
+ }
588
+
589
+ const statuses = results.map((result) => result.status);
590
+ const status = errors.length > 0
591
+ ? 'partial'
592
+ : statuses.length === 0
593
+ ? 'not_applicable'
594
+ : statuses.every((value) => value === 'satisfied')
595
+ ? 'satisfied'
596
+ : statuses.every((value) => value === 'missing')
597
+ ? 'missing'
598
+ : 'partial';
599
+
600
+ return { status, slots: results, errors };
601
+ }
602
+
308
603
  export function validateUiProofBundle(bundle, options = {}) {
309
604
  const errors = [];
310
605
  const warnings = [];
@@ -332,10 +627,10 @@ export function validateUiProofBundle(bundle, options = {}) {
332
627
  return { valid: errors.length === 0, errors, warnings };
333
628
  }
334
629
 
335
- export function parseUiProofBundleContent(content, filePath = 'UI proof bundle') {
630
+ function parseJsonOrFencedContent(content, filePath, label) {
336
631
  const trimmed = content.trim();
337
632
  if (!trimmed) {
338
- return { bundle: null, errors: [{ code: 'empty_bundle_file', path: filePath, message: 'UI proof bundle file is empty.', fix: 'Write JSON UI proof metadata before validating.' }] };
633
+ return { value: null, errors: [{ code: 'empty_file', path: filePath, message: `${label} file is empty.`, fix: 'Write JSON metadata before validating.' }] };
339
634
  }
340
635
 
341
636
  const jsonCandidates = [trimmed];
@@ -344,18 +639,58 @@ export function parseUiProofBundleContent(content, filePath = 'UI proof bundle')
344
639
 
345
640
  for (const candidate of jsonCandidates) {
346
641
  try {
347
- return { bundle: JSON.parse(candidate), errors: [] };
642
+ return { value: JSON.parse(candidate), errors: [] };
348
643
  } catch {
349
644
  // Try next candidate; final error is reported below.
350
645
  }
351
646
  }
352
647
 
353
648
  return {
354
- bundle: null,
355
- errors: [{ code: 'unparseable_bundle', path: filePath, message: 'UI proof bundle metadata is not valid JSON.', fix: 'Use a .json proof bundle or a markdown fenced JSON block; no YAML parser dependency is installed.' }],
649
+ value: null,
650
+ errors: [{ code: 'unparseable_json', path: filePath, message: `${label} metadata is not valid JSON.`, fix: 'Use a .json file or a markdown fenced JSON block; no YAML parser dependency is installed.' }],
356
651
  };
357
652
  }
358
653
 
654
+ export function parseUiProofBundleContent(content, filePath = 'UI proof bundle') {
655
+ const parsed = parseJsonOrFencedContent(content, filePath, 'UI proof bundle');
656
+ return { bundle: parsed.value, errors: parsed.errors.map((error) => ({
657
+ ...error,
658
+ code: error.code === 'empty_file' ? 'empty_bundle_file' : error.code === 'unparseable_json' ? 'unparseable_bundle' : error.code,
659
+ message: error.code === 'empty_file'
660
+ ? 'UI proof bundle file is empty.'
661
+ : error.code === 'unparseable_json'
662
+ ? 'UI proof bundle metadata is not valid JSON.'
663
+ : error.message,
664
+ fix: error.code === 'empty_file'
665
+ ? 'Write JSON UI proof metadata before validating.'
666
+ : error.fix,
667
+ })) };
668
+ }
669
+
670
+ export function parseUiProofSlotsContent(content, filePath = 'UI proof slots') {
671
+ const parsed = parseJsonOrFencedContent(content, filePath, 'UI proof slots');
672
+ if (parsed.errors.length > 0) return { slots: [], errors: parsed.errors };
673
+
674
+ const value = parsed.value;
675
+ const slots = Array.isArray(value)
676
+ ? value
677
+ : normalizeArray(value?.ui_proof_slots || value?.uiProofSlots || value?.planned_slots || value?.plannedSlots);
678
+
679
+ if (slots.length === 0) {
680
+ return {
681
+ slots: [],
682
+ errors: [{
683
+ code: 'missing_planned_slots',
684
+ path: filePath,
685
+ message: 'Planned UI proof input must be an array or contain ui_proof_slots.',
686
+ fix: 'Provide JSON with an array of planned slots or an object with ui_proof_slots.',
687
+ }],
688
+ };
689
+ }
690
+
691
+ return { slots, errors: [] };
692
+ }
693
+
359
694
  export function readUiProofBundleFile(filePath) {
360
695
  return parseUiProofBundleContent(readFileSync(filePath, 'utf-8'), filePath);
361
696
  }
@@ -434,6 +769,50 @@ function cmdValidate(cwd, args) {
434
769
  if (!validation.valid) process.exitCode = 1;
435
770
  }
436
771
 
772
+ function cmdCompare(cwd, args) {
773
+ const [plannedArg, ...observedArgs] = args;
774
+ if (!plannedArg) fail('Usage: gsdd ui-proof compare <planned-slots-json> [observed-bundle-json ...]');
775
+
776
+ const plannedPath = resolveWorkspacePath(cwd, plannedArg);
777
+ if (!existsSync(plannedPath) || statSync(plannedPath).isDirectory()) fail(`Planned UI proof slots file does not exist: ${plannedArg}`);
778
+
779
+ const planned = parseUiProofSlotsContent(readFileSync(plannedPath, 'utf-8'), plannedArg);
780
+ const observedBundles = [];
781
+ const observedTargets = [];
782
+ const observedErrors = [];
783
+
784
+ for (const observedArg of observedArgs) {
785
+ const observedPath = resolveWorkspacePath(cwd, observedArg);
786
+ if (!existsSync(observedPath) || statSync(observedPath).isDirectory()) fail(`Observed UI proof bundle file does not exist: ${observedArg}`);
787
+ const parsed = readUiProofBundleFile(observedPath);
788
+ if (parsed.errors.length > 0) {
789
+ observedErrors.push(...parsed.errors.map((error) => ({ ...error, path: observedArg })));
790
+ observedBundles.push({
791
+ bundle: {},
792
+ validation: { valid: false, errors: parsed.errors, warnings: [] },
793
+ source: observedArg,
794
+ });
795
+ } else {
796
+ observedBundles.push({ bundle: parsed.bundle, source: observedArg });
797
+ }
798
+ observedTargets.push(observedArg);
799
+ }
800
+
801
+ const comparison = planned.errors.length > 0
802
+ ? { status: 'missing', slots: [], errors: planned.errors }
803
+ : compareUiProofSlots(planned.slots, observedBundles);
804
+
805
+ output({
806
+ operation: 'ui-proof compare',
807
+ planned: plannedArg,
808
+ observed: observedTargets,
809
+ status: comparison.status,
810
+ slots: comparison.slots,
811
+ errors: [...(comparison.errors || []), ...observedErrors],
812
+ });
813
+ if (!['satisfied', 'not_applicable'].includes(comparison.status)) process.exitCode = 1;
814
+ }
815
+
437
816
  export function cmdUiProof(...args) {
438
817
  const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(args);
439
818
  if (invalid) {
@@ -447,8 +826,11 @@ export function cmdUiProof(...args) {
447
826
  case 'validate':
448
827
  cmdValidate(workspaceRoot, rest);
449
828
  return;
829
+ case 'compare':
830
+ cmdCompare(workspaceRoot, rest);
831
+ return;
450
832
  default:
451
- fail('Usage: gsdd ui-proof validate <path> [--claim <public|publication|tracked|delivery|release>]');
833
+ fail('Usage: gsdd ui-proof <validate|compare> ...');
452
834
  }
453
835
  } catch (error) {
454
836
  if (error instanceof UiProofError) {
@@ -152,7 +152,7 @@ Bundle rules:
152
152
 
153
153
  ## Deterministic Validation
154
154
 
155
- Use `gsdd ui-proof validate <path>` on JSON proof-bundle metadata or markdown fenced JSON before relying on a bundle for closure; add `--claim <public|publication|tracked|delivery|release>` only when validating that stronger proof use. Required observed-bundle top-level fields are `proof_bundle_version`, `scope`, `route_state`, `environment`, `viewport`, `evidence_inputs`, `commands_or_manual_steps`, `observations`, `artifacts`, `privacy`, `result`, and `claim_limits`. The validator checks required bundle and observation fields, structured command/manual-step entries, fixed evidence kinds, `result.claim_status`, observation `result`, comparison statuses, non-empty claim limits, locked artifact and observation privacy fields, observation-to-artifact references, workspace-relative/http(s) artifact references, and explicit public/tracked/delivery proof claims that rely on local-only, unsafe, unsanitized, or privacy-contradictory artifacts. `claim_status`, observation `result`, and command/manual-step `result` use `passed`, `failed`, `partial`, `waived`, `deferred`, or `not_applicable`. It is metadata-only and does not inspect raw screenshot, trace, video, DOM, or report contents.
155
+ Use `gsdd ui-proof validate <path>` on JSON proof-bundle metadata or markdown fenced JSON before relying on a bundle for closure; add `--claim <public|publication|tracked|delivery|release>` only when validating that stronger proof use. Use `gsdd ui-proof compare <planned-slots-json> [observed-bundle-json ...]` when verifying planned proof slots against observed bundles through the deterministic product-facing path. Required observed-bundle top-level fields are `proof_bundle_version`, `scope`, `route_state`, `environment`, `viewport`, `evidence_inputs`, `commands_or_manual_steps`, `observations`, `artifacts`, `privacy`, `result`, and `claim_limits`. The validator checks required bundle and observation fields, structured command/manual-step entries, fixed evidence kinds, `result.claim_status`, observation `result`, comparison statuses, non-empty claim limits, locked artifact and observation privacy fields, observation-to-artifact references, workspace-relative/http(s) artifact references, and explicit public/tracked/delivery proof claims that rely on local-only, unsafe, unsanitized, or privacy-contradictory artifacts. `claim_status`, observation `result`, and command/manual-step `result` use `passed`, `failed`, `partial`, `waived`, `deferred`, or `not_applicable`. It is metadata-only and does not inspect raw screenshot, trace, video, DOM, or report contents.
156
156
 
157
157
  ## Comparison Statuses
158
158
 
@@ -15,6 +15,18 @@ If no audit file exists: stop and direct the user to run `/gsdd-audit-milestone`
15
15
  If audit status is `passed`: stop and direct the user to run `/gsdd-complete-milestone` instead.
16
16
  </prerequisites>
17
17
 
18
+ <repo_root_helper_contract>
19
+ All `node .planning/bin/gsdd.mjs ...` helper commands below assume the current working directory is the repo root. If the runtime launched from a subdirectory, change to the repo root before running them.
20
+ </repo_root_helper_contract>
21
+
22
+ <lifecycle_preflight>
23
+ Before writing ROADMAP gap-closure phases or phase directories, run:
24
+
25
+ - `node .planning/bin/gsdd.mjs lifecycle-preflight plan-milestone-gaps`
26
+
27
+ If the preflight result is `blocked`, STOP and report the blocker. This workflow intentionally mutates planning truth, so it must not proceed through pre-existing planning-state drift.
28
+ </lifecycle_preflight>
29
+
18
30
  <load_context>
19
31
  Before starting, read these files:
20
32
 
@@ -144,6 +156,14 @@ Create a directory for each gap closure phase:
144
156
 
145
157
  No files inside — `/gsdd-plan` populates them.
146
158
 
159
+ ## 8. Rebaseline Planning Fingerprint
160
+
161
+ After confirming the ROADMAP update and phase directories exist, run:
162
+
163
+ - `node .planning/bin/gsdd.mjs session-fingerprint write --allow-changed ROADMAP.md`
164
+
165
+ This records the user-confirmed ROADMAP mutation so the recommended `/gsdd-plan [N]` handoff does not immediately block on expected `planning_state_drift`. The `--allow-changed ROADMAP.md` guard must fail if `SPEC.md` or `config.json` also drifted after preflight; stop and reconcile that unexpected drift instead of rebaselining it. Do not run this if the ROADMAP write failed or the phase directories are missing.
166
+
147
167
  </process>
148
168
 
149
169
  <success_criteria>
@@ -155,6 +175,7 @@ No files inside — `/gsdd-plan` populates them.
155
175
  - [ ] User confirmed gap closure plan before ROADMAP.md was updated
156
176
  - [ ] ROADMAP.md updated with new gap closure phases
157
177
  - [ ] Phase directories created
178
+ - [ ] `session-fingerprint write` ran after the reviewed ROADMAP update so `/gsdd-plan [N]` is not stranded by expected planning drift
158
179
  </success_criteria>
159
180
 
160
181
  **MANDATORY: `.planning/ROADMAP.md` must be updated on disk before this workflow is complete. If the write fails, STOP and report the failure. Without the updated ROADMAP, the phase cycle cannot begin.**
@@ -129,7 +129,7 @@ Note: this step does NOT replace levels 1–3. An artifact can satisfy the evide
129
129
  </evidence_contract>
130
130
 
131
131
  <ui_proof_comparison>
132
- If the plan defines non-empty `ui_proof_slots`, compare planned UI proof against observed bundles before closure. If the plan records only `no_ui_proof_rationale`, verify the rationale instead of requiring a bundle. Each observed bundle must include top-level `proof_bundle_version`, `scope`, `route_state`, `environment`, `viewport`, `evidence_inputs`, `commands_or_manual_steps`, `observations`, `artifacts`, `privacy`, `result`, and `claim_limits`.
132
+ If the plan defines non-empty `ui_proof_slots`, compare planned UI proof against observed bundles before closure. Prefer `gsdd ui-proof compare <planned-slots-json> [observed-bundle-json ...]` when planned slots are available as JSON or fenced JSON; otherwise perform the same field-by-field comparison and record reduced assurance if no deterministic command could run. If the plan records only `no_ui_proof_rationale`, verify the rationale instead of requiring a bundle. Each observed bundle must include top-level `proof_bundle_version`, `scope`, `route_state`, `environment`, `viewport`, `evidence_inputs`, `commands_or_manual_steps`, `observations`, `artifacts`, `privacy`, `result`, and `claim_limits`.
133
133
  Classify each slot as exactly one of: `satisfied`, `partial`, `missing`, `waived`, `deferred`, or `not_applicable`. Waiver/deferment narrows the claim; it is not proof. Screenshots, traces, videos, reports, accessibility scans, Gherkin, visual diffs, and manual notes are artifact types or activities mapped onto existing evidence kinds, not new evidence kinds. Artifact count is never proof; each artifact must tie to the slot claim, route/state, observation, artifact path/link, privacy metadata, and claim limit.
134
134
  Artifact privacy metadata must include `visibility`, `retention`, `sensitivity`, and `safe_to_publish`; raw screenshots, traces, videos, DOM snapshots, and reports default to local-only and unsafe unless sanitized. Run `gsdd ui-proof validate <path>` or treat `gsdd health` E10 as blocking; add `--claim <...>` when relying on the bundle for public, tracked, delivery, release, or publication proof. Visual taste, accessibility judgment, baseline acceptance, subjective polish/layout quality, and privacy publication require human evidence or explicit waiver; human approval does not replace required `code`, `test`, `runtime`, or `delivery` evidence. Source annotations, AST/cAST findings, semantic search, comments, and Semble-like retrieval are discovery hints only.
135
135
  </ui_proof_comparison>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsdd-cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Workspine — a repo-native delivery spine for long-horizon AI-assisted work, with directly validated support for Claude Code, Codex CLI, and OpenCode, published as gsdd-cli.",
5
5
  "type": "module",
6
6
  "bin": {