gsdd-cli 0.19.0 → 0.19.2

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.
@@ -1,7 +1,14 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { output } from './cli-utils.mjs';
4
- import { describeEvidenceSurface } from './evidence-contract.mjs';
4
+ import {
5
+ DELIVERY_POSTURES,
6
+ EVIDENCE_KINDS,
7
+ RELEASE_CLAIM_POSTURES,
8
+ describeEvidenceSurface,
9
+ evaluateReleaseClaimCloseoutContract,
10
+ getEvidenceContract,
11
+ } from './evidence-contract.mjs';
5
12
  import { evaluateLifecycleState, normalizePhaseToken } from './lifecycle-state.mjs';
6
13
  import { checkDrift } from './session-fingerprint.mjs';
7
14
  import { resolveWorkspaceContext } from './workspace-root.mjs';
@@ -12,6 +19,12 @@ const SURFACE_POLICIES = {
12
19
  ownedWrites: [],
13
20
  explicitLifecycleMutation: 'none',
14
21
  },
22
+ plan: {
23
+ classification: 'owned_write',
24
+ ownedWrites: ['research', 'plan'],
25
+ explicitLifecycleMutation: 'none',
26
+ phaseRequired: true,
27
+ },
15
28
  execute: {
16
29
  classification: 'owned_write',
17
30
  ownedWrites: ['summary'],
@@ -46,6 +59,17 @@ const SURFACE_POLICIES = {
46
59
  },
47
60
  };
48
61
 
62
+ const RELEASE_CONTRADICTION_CHECKS = Object.freeze([
63
+ 'evidence',
64
+ 'public_surface',
65
+ 'runtime',
66
+ 'delivery',
67
+ 'planning_drift',
68
+ 'generated_surface',
69
+ ]);
70
+
71
+ const RELEASE_CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']);
72
+
49
73
  export function evaluateLifecyclePreflight({
50
74
  planningDir,
51
75
  surface,
@@ -125,15 +149,33 @@ export function evaluateLifecyclePreflight({
125
149
  }
126
150
 
127
151
  const warnings = [];
152
+ let planningState = null;
128
153
 
129
154
  if (existsSync(planningDir)) {
130
155
  const drift = checkDrift(planningDir);
156
+ planningState = {
157
+ classification: drift.classification,
158
+ drifted: drift.drifted,
159
+ noBaseline: drift.noBaseline,
160
+ details: drift.details,
161
+ files: drift.files,
162
+ };
131
163
  if (drift.drifted) {
132
- warnings.push({
164
+ const driftNotice = {
133
165
  code: 'planning_state_drift',
134
- message: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
166
+ message: `${surface} cannot proceed because planning state drifted since the last recorded session: ${drift.details.join('; ')}`,
135
167
  artifacts: ['.planning/ROADMAP.md', '.planning/SPEC.md', '.planning/config.json'],
136
- });
168
+ details: drift.details,
169
+ files: drift.files,
170
+ };
171
+ if (policy.classification === 'owned_write') {
172
+ blockers.push(driftNotice);
173
+ } else {
174
+ warnings.push({
175
+ ...driftNotice,
176
+ message: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
177
+ });
178
+ }
137
179
  }
138
180
  }
139
181
 
@@ -158,6 +200,7 @@ export function evaluateLifecyclePreflight({
158
200
  reason: blockers[0]?.code ?? null,
159
201
  blockers,
160
202
  warnings,
203
+ planningState,
161
204
  lifecycle: {
162
205
  currentMilestone: lifecycle.currentMilestone,
163
206
  currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
@@ -207,6 +250,16 @@ function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
207
250
  }
208
251
  }
209
252
 
253
+ if (surface === 'plan' && phaseEntry.status === 'done') {
254
+ blockers.push(
255
+ blocker(
256
+ 'phase_already_complete',
257
+ `Phase ${phaseToken} is already complete and should not be planned again.`,
258
+ ['.planning/ROADMAP.md']
259
+ )
260
+ );
261
+ }
262
+
210
263
  if (surface === 'verify') {
211
264
  if (planArtifacts.length === 0) {
212
265
  blockers.push(
@@ -302,8 +355,9 @@ function buildCompletionBlockers(planningDir, lifecycle) {
302
355
  }
303
356
 
304
357
  const auditContent = readFileSync(auditPath, 'utf-8');
305
- const statusMatch = auditContent.match(/^status:\s*(.+)$/m);
306
- if (!statusMatch || statusMatch[1].trim() !== 'passed') {
358
+ const auditFrontmatter = extractFrontmatter(auditContent);
359
+ const auditStatus = readTopLevelScalar(auditFrontmatter || auditContent, 'status');
360
+ if (auditStatus !== 'passed') {
307
361
  return [
308
362
  blocker(
309
363
  'audit_not_passed',
@@ -313,9 +367,367 @@ function buildCompletionBlockers(planningDir, lifecycle) {
313
367
  ];
314
368
  }
315
369
 
370
+ const releaseContractBlockers = buildReleaseClaimCompletionBlockers(auditContent, auditPath);
371
+ if (releaseContractBlockers.length > 0) return releaseContractBlockers;
372
+
316
373
  return [];
317
374
  }
318
375
 
376
+ function buildReleaseClaimCompletionBlockers(auditContent, auditPath) {
377
+ const frontmatter = extractFrontmatter(auditContent);
378
+ const deliveryPosture = readTopLevelScalar(frontmatter, 'delivery_posture');
379
+ const releaseClaimPosture = readTopLevelScalar(frontmatter, 'release_claim_posture');
380
+ const evidenceBlock = extractYamlBlock(frontmatter, 'evidence_contract');
381
+ const releaseBlock = extractYamlBlock(frontmatter, 'release_claim_contract');
382
+ const missing = [];
383
+
384
+ if (!deliveryPosture) missing.push('delivery_posture');
385
+ if (!releaseClaimPosture) missing.push('release_claim_posture');
386
+ if (!evidenceBlock) missing.push('evidence_contract');
387
+ if (!releaseBlock) missing.push('release_claim_contract');
388
+
389
+ if (missing.length > 0) {
390
+ return [blocker(
391
+ 'missing_release_claim_contract',
392
+ `Milestone audit is missing release closeout metadata (${missing.join(', ')}). Re-run audit before completion.`,
393
+ [auditPath]
394
+ )];
395
+ }
396
+
397
+ const requiredKinds = readBlockList(evidenceBlock, 'required_kinds');
398
+ const observedKinds = readBlockList(evidenceBlock, 'observed_kinds');
399
+ const missingKinds = readBlockList(evidenceBlock, 'missing_kinds');
400
+ const unsupportedClaims = readBlockList(releaseBlock, 'unsupported_claims');
401
+ const waivedKinds = readBlockList(releaseBlock, 'waivers');
402
+ const deferrals = readBlockList(releaseBlock, 'deferrals');
403
+ const contradictionChecks = readNestedStatusBlock(releaseBlock, 'contradiction_checks');
404
+ const blockers = [];
405
+ const invalidEvidenceKinds = [
406
+ ...findInvalidEvidenceKinds('required_kinds', requiredKinds),
407
+ ...findInvalidEvidenceKinds('observed_kinds', observedKinds),
408
+ ...findInvalidEvidenceKinds('missing_kinds', missingKinds),
409
+ ...findInvalidEvidenceKinds('waivers', waivedKinds),
410
+ ];
411
+
412
+ if (requiredKinds.length === 0 && observedKinds.length === 0) {
413
+ blockers.push(blocker(
414
+ 'missing_release_evidence_contract',
415
+ 'Milestone audit evidence_contract must include required_kinds and observed_kinds before completion.',
416
+ [auditPath]
417
+ ));
418
+ }
419
+
420
+ if (!DELIVERY_POSTURES.includes(deliveryPosture)) {
421
+ blockers.push(blocker(
422
+ 'invalid_delivery_posture',
423
+ `Milestone audit has invalid delivery_posture (${deliveryPosture}). Re-run audit before completion.`,
424
+ [auditPath]
425
+ ));
426
+ }
427
+
428
+ if (!RELEASE_CLAIM_POSTURES.includes(releaseClaimPosture)) {
429
+ blockers.push(blocker(
430
+ 'invalid_release_claim_posture',
431
+ `Milestone audit has invalid release_claim_posture (${releaseClaimPosture}). Re-run audit before completion.`,
432
+ [auditPath]
433
+ ));
434
+ }
435
+
436
+ if (invalidEvidenceKinds.length > 0) {
437
+ blockers.push(blocker(
438
+ 'invalid_release_evidence_kinds',
439
+ `Milestone audit has invalid release evidence kind values (${invalidEvidenceKinds.join(', ')}). Supported values are ${EVIDENCE_KINDS.join(', ')}.`,
440
+ [auditPath]
441
+ ));
442
+ }
443
+
444
+ const missingContradictionChecks = RELEASE_CONTRADICTION_CHECKS.filter((name) => !(name in contradictionChecks));
445
+ const unknownContradictionChecks = Object.keys(contradictionChecks)
446
+ .filter((name) => !RELEASE_CONTRADICTION_CHECKS.includes(name));
447
+ const invalidContradictionChecks = Object.entries(contradictionChecks)
448
+ .filter(([, status]) => !RELEASE_CONTRADICTION_STATUSES.includes(status))
449
+ .map(([name]) => name);
450
+
451
+ if (missingContradictionChecks.length > 0) {
452
+ blockers.push(blocker(
453
+ 'missing_release_contradiction_checks',
454
+ `Milestone audit release_claim_contract.contradiction_checks is missing required checks (${missingContradictionChecks.join(', ')}).`,
455
+ [auditPath]
456
+ ));
457
+ }
458
+
459
+ if (invalidContradictionChecks.length > 0) {
460
+ blockers.push(blocker(
461
+ 'invalid_release_contradiction_checks',
462
+ `Milestone audit release_claim_contract.contradiction_checks has invalid statuses (${invalidContradictionChecks.join(', ')}).`,
463
+ [auditPath]
464
+ ));
465
+ }
466
+
467
+ if (unknownContradictionChecks.length > 0) {
468
+ blockers.push(blocker(
469
+ 'unknown_release_contradiction_checks',
470
+ `Milestone audit release_claim_contract.contradiction_checks has unknown checks (${unknownContradictionChecks.join(', ')}). Supported checks are ${RELEASE_CONTRADICTION_CHECKS.join(', ')}.`,
471
+ [auditPath]
472
+ ));
473
+ }
474
+
475
+ if (!DELIVERY_POSTURES.includes(deliveryPosture) || !RELEASE_CLAIM_POSTURES.includes(releaseClaimPosture)) {
476
+ return blockers;
477
+ }
478
+
479
+ const releaseEvaluation = evaluateReleaseClaimCloseoutContract({
480
+ surface: 'complete-milestone',
481
+ deliveryPosture,
482
+ releaseClaimPosture,
483
+ observedKinds,
484
+ waivedKinds,
485
+ unsupportedClaims,
486
+ deferrals,
487
+ contradictionChecks,
488
+ });
489
+
490
+ if (DELIVERY_POSTURES.includes(deliveryPosture)) {
491
+ const evidenceContract = getEvidenceContract('complete-milestone', deliveryPosture);
492
+ const enforcedRequiredKinds = [...new Set([...evidenceContract.requiredKinds, ...releaseEvaluation.requiredKinds])];
493
+ const undeclaredRequiredKinds = enforcedRequiredKinds.filter((kind) => !requiredKinds.includes(kind));
494
+ const recomputedMissingKinds = enforcedRequiredKinds.filter((kind) => !observedKinds.includes(kind));
495
+
496
+ if (undeclaredRequiredKinds.length > 0) {
497
+ blockers.push(blocker(
498
+ 'invalid_release_evidence_contract',
499
+ `Milestone audit evidence_contract.required_kinds omits required closeout evidence (${undeclaredRequiredKinds.join(', ')}).`,
500
+ [auditPath]
501
+ ));
502
+ }
503
+
504
+ if (recomputedMissingKinds.length > 0) {
505
+ blockers.push(blocker(
506
+ 'missing_required_release_evidence',
507
+ `Milestone audit observed evidence is missing required closeout kinds (${recomputedMissingKinds.join(', ')}).`,
508
+ [auditPath]
509
+ ));
510
+ }
511
+ }
512
+
513
+ if (missingKinds.length > 0) {
514
+ blockers.push(blocker(
515
+ 'missing_required_release_evidence',
516
+ `Milestone audit is missing required evidence kinds for closeout (${missingKinds.join(', ')}).`,
517
+ [auditPath]
518
+ ));
519
+ }
520
+
521
+ if (releaseEvaluation.invalidWaivers.length > 0) {
522
+ blockers.push(blocker(
523
+ 'invalid_release_waivers',
524
+ `Milestone audit has invalid waivers for missing required evidence (${releaseEvaluation.invalidWaivers.join(', ')}).`,
525
+ [auditPath]
526
+ ));
527
+ }
528
+ if (releaseEvaluation.blockers.some((releaseBlocker) => releaseBlocker.code === 'incompatible_release_claim_posture')) {
529
+ blockers.push(blocker(
530
+ 'incompatible_release_claim_posture',
531
+ `Milestone audit release_claim_posture (${releaseClaimPosture}) is incompatible with delivery_posture (${deliveryPosture}).`,
532
+ [auditPath]
533
+ ));
534
+ }
535
+ if (releaseEvaluation.unresolvedUnsupportedClaims.length > 0) {
536
+ blockers.push(blocker(
537
+ 'unsupported_release_claims',
538
+ `Milestone audit has unsupported release claims without downgrade or deferral (${releaseEvaluation.unresolvedUnsupportedClaims.join(', ')}).`,
539
+ [auditPath]
540
+ ));
541
+ }
542
+ if (releaseEvaluation.failedContradictionChecks.length > 0) {
543
+ blockers.push(blocker(
544
+ 'failed_release_contradiction_checks',
545
+ `Milestone audit has failed release contradiction checks (${releaseEvaluation.failedContradictionChecks.join(', ')}).`,
546
+ [auditPath]
547
+ ));
548
+ }
549
+
550
+ return blockers;
551
+ }
552
+
553
+ function extractFrontmatter(content) {
554
+ const match = String(content || '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
555
+ return match ? match[1] : '';
556
+ }
557
+
558
+ function readTopLevelScalar(frontmatter, key) {
559
+ const match = String(frontmatter || '').match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
560
+ return match ? cleanYamlValue(match[1]) : null;
561
+ }
562
+
563
+ function extractYamlBlock(frontmatter, key) {
564
+ const lines = String(frontmatter || '').replace(/\r\n/g, '\n').split('\n');
565
+ const startIndex = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line.trim()));
566
+ if (startIndex === -1) return '';
567
+
568
+ const collected = [];
569
+ for (const line of lines.slice(startIndex + 1)) {
570
+ if (/^[A-Za-z0-9_-]+:\s*/.test(line)) break;
571
+ collected.push(line);
572
+ }
573
+ return collected.join('\n');
574
+ }
575
+
576
+ function readBlockList(block, key) {
577
+ const lines = String(block || '').replace(/\r\n/g, '\n').split('\n');
578
+ const startIndex = lines.findIndex((line) => new RegExp(`^\\s+${key}:`).test(line));
579
+ if (startIndex === -1) return [];
580
+ const baseIndent = lines[startIndex].match(/^\s*/)[0].length;
581
+
582
+ const inline = lines[startIndex].match(/^\s+[^:]+:\s*\[([^\]]*)\]/);
583
+ if (inline) return splitInlineList(inline[1]);
584
+
585
+ const collected = [];
586
+ for (const line of lines.slice(startIndex + 1)) {
587
+ const indent = line.match(/^\s*/)[0].length;
588
+ if (line.trim() && indent <= baseIndent && /^\s*[A-Za-z0-9_-]+:\s*/.test(line)) break;
589
+ collected.push(line);
590
+ }
591
+
592
+ return parseYamlListItems(collected, baseIndent)
593
+ .map((item) => item.join(' '))
594
+ .map(cleanYamlValue);
595
+ }
596
+
597
+ function parseYamlListItems(lines, baseIndent) {
598
+ const items = [];
599
+ let current = null;
600
+ let itemIndent = null;
601
+
602
+ for (const line of lines) {
603
+ const match = line.match(/^(\s*)-\s*(.+?)\s*$/);
604
+ const indent = line.match(/^\s*/)[0].length;
605
+
606
+ if (match && indent > baseIndent && (itemIndent === null || indent === itemIndent)) {
607
+ if (current) items.push(current);
608
+ current = [match[2]];
609
+ itemIndent = indent;
610
+ continue;
611
+ }
612
+
613
+ if (current && line.trim()) {
614
+ current.push(line.trim());
615
+ }
616
+ }
617
+
618
+ if (current) items.push(current);
619
+ return items;
620
+ }
621
+
622
+ function findInvalidEvidenceKinds(field, kinds) {
623
+ return kinds
624
+ .filter((kind) => !EVIDENCE_KINDS.includes(kind))
625
+ .map((kind) => `${field}: ${kind}`);
626
+ }
627
+
628
+ function readNestedStatusBlock(block, key) {
629
+ const nested = extractIndentedBlock(block, key);
630
+ const statuses = {};
631
+ for (const line of nested.split('\n')) {
632
+ const match = line.match(/^\s+([A-Za-z0-9_-]+):\s*(.+)$/);
633
+ if (match) statuses[match[1]] = cleanYamlValue(match[2]);
634
+ }
635
+ return statuses;
636
+ }
637
+
638
+ function extractIndentedBlock(block, key) {
639
+ const lines = String(block || '').replace(/\r\n/g, '\n').split('\n');
640
+ const startIndex = lines.findIndex((line) => new RegExp(`^\\s+${key}:`).test(line));
641
+ if (startIndex === -1) return '';
642
+ const baseIndent = lines[startIndex].match(/^\s*/)[0].length;
643
+
644
+ const collected = [];
645
+ for (const line of lines.slice(startIndex + 1)) {
646
+ const indent = line.match(/^\s*/)[0].length;
647
+ if (line.trim() && indent <= baseIndent && /^\s*[A-Za-z0-9_-]+:\s*/.test(line)) break;
648
+ collected.push(line);
649
+ }
650
+ return collected.join('\n');
651
+ }
652
+
653
+ function splitInlineList(value) {
654
+ return splitCommaAware(value)
655
+ .map(cleanYamlValue)
656
+ .filter(Boolean);
657
+ }
658
+
659
+ function splitCommaAware(value) {
660
+ const items = [];
661
+ let current = '';
662
+ let quote = null;
663
+ let escaped = false;
664
+
665
+ for (const char of String(value || '')) {
666
+ if (escaped) {
667
+ current += char;
668
+ escaped = false;
669
+ continue;
670
+ }
671
+ if (char === '\\' && quote) {
672
+ current += char;
673
+ escaped = true;
674
+ continue;
675
+ }
676
+ if ((char === '"' || char === "'") && (!quote || quote === char)) {
677
+ quote = quote ? null : char;
678
+ current += char;
679
+ continue;
680
+ }
681
+ if (char === ',' && !quote) {
682
+ items.push(current);
683
+ current = '';
684
+ continue;
685
+ }
686
+ current += char;
687
+ }
688
+
689
+ items.push(current);
690
+ return items;
691
+ }
692
+
693
+ function cleanYamlValue(value) {
694
+ return stripInlineYamlComment(String(value || ''))
695
+ .trim()
696
+ .replace(/^['"]|['"]$/g, '')
697
+ .trim();
698
+ }
699
+
700
+ function stripInlineYamlComment(value) {
701
+ let current = '';
702
+ let quote = null;
703
+ let escaped = false;
704
+
705
+ for (let index = 0; index < value.length; index += 1) {
706
+ const char = value[index];
707
+ if (escaped) {
708
+ current += char;
709
+ escaped = false;
710
+ continue;
711
+ }
712
+ if (char === '\\' && quote) {
713
+ current += char;
714
+ escaped = true;
715
+ continue;
716
+ }
717
+ if ((char === '"' || char === "'") && (!quote || quote === char)) {
718
+ quote = quote ? null : char;
719
+ current += char;
720
+ continue;
721
+ }
722
+ if (char === '#' && !quote && (index === 0 || /\s/.test(value[index - 1]))) {
723
+ return current.trimEnd();
724
+ }
725
+ current += char;
726
+ }
727
+
728
+ return current;
729
+ }
730
+
319
731
  function blocker(code, message, artifacts) {
320
732
  return { code, message, artifacts };
321
733
  }
@@ -42,6 +42,7 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {})
42
42
  return {
43
43
  ...phase,
44
44
  hasArtifacts: matchingArtifacts.length > 0,
45
+ hasLifecycleArtifacts: hasPlan || hasSummary,
45
46
  hasPlan,
46
47
  hasSummary,
47
48
  artifacts: matchingArtifacts,
@@ -339,10 +340,10 @@ function classifyPhaseArtifact(dir, name) {
339
340
  if (!baseIdMatch || !phaseTokenMatch) return null;
340
341
 
341
342
  let kind = 'other';
342
- if (name.includes('PLAN')) kind = 'plan';
343
- else if (name.includes('SUMMARY')) kind = 'summary';
344
- else if (name.includes('VERIFICATION')) kind = 'verification';
345
- else if (name.includes('APPROACH')) kind = 'approach';
343
+ if (isNamedPhaseArtifact(name, baseIdMatch[1], 'PLAN')) kind = 'plan';
344
+ else if (isNamedPhaseArtifact(name, baseIdMatch[1], 'SUMMARY')) kind = 'summary';
345
+ else if (isNamedPhaseArtifact(name, baseIdMatch[1], 'VERIFICATION')) kind = 'verification';
346
+ else if (isNamedPhaseArtifact(name, baseIdMatch[1], 'APPROACH')) kind = 'approach';
346
347
 
347
348
  return {
348
349
  dir,
@@ -354,6 +355,10 @@ function classifyPhaseArtifact(dir, name) {
354
355
  };
355
356
  }
356
357
 
358
+ function isNamedPhaseArtifact(name, baseId, kind) {
359
+ return name.toLowerCase() === `${baseId.toLowerCase()}-${kind.toLowerCase()}.md`;
360
+ }
361
+
357
362
  function parseMilestoneLedger(content) {
358
363
  if (!content) return [];
359
364
 
package/bin/lib/phase.mjs CHANGED
@@ -72,13 +72,14 @@ function listPhaseArtifacts(dir) {
72
72
  }
73
73
 
74
74
  function classifyPhaseArtifact(dir, name) {
75
+ const baseIdMatch = name.match(/^(\d+(?:\.\d+)*[a-z]?(?:-\d+)?)/i);
75
76
  const dirMatch = dir ? dir.match(/^(\d+(?:\.\d+)*[a-z]?)-/i) : null;
76
77
  const nameMatch = name.match(/^(\d+(?:\.\d+)*[a-z]?)/i);
77
78
  const phaseToken = normalizePhaseToken((dirMatch || nameMatch)?.[1] || '');
78
79
 
79
80
  let kind = 'OTHER';
80
- if (name.includes('PLAN')) kind = 'PLAN';
81
- else if (name.includes('SUMMARY')) kind = 'SUMMARY';
81
+ if (baseIdMatch && isNamedPhaseArtifact(name, baseIdMatch[1], 'PLAN')) kind = 'PLAN';
82
+ else if (baseIdMatch && isNamedPhaseArtifact(name, baseIdMatch[1], 'SUMMARY')) kind = 'SUMMARY';
82
83
 
83
84
  return {
84
85
  dir,
@@ -89,6 +90,10 @@ function classifyPhaseArtifact(dir, name) {
89
90
  };
90
91
  }
91
92
 
93
+ function isNamedPhaseArtifact(name, baseId, kind) {
94
+ return name.toLowerCase() === `${baseId.toLowerCase()}-${kind.toLowerCase()}.md`;
95
+ }
96
+
92
97
  function padPhase(n) {
93
98
  return String(n).padStart(2, '0');
94
99
  }
@@ -7,6 +7,11 @@ export const PLAN_CHECK_DIMENSIONS = [
7
7
  'must_have_quality',
8
8
  'context_compliance',
9
9
  'goal_achievement',
10
+ 'scope_boundaries',
11
+ 'anti_regression_capture',
12
+ 'escalation_integrity',
13
+ 'closure_honesty',
14
+ 'high_leverage_review',
10
15
  'approach_alignment',
11
16
  ];
12
17
 
@@ -46,12 +46,14 @@ function renderPlanningCliLauncher() {
46
46
  import { cmdFileOp } from './lib/file-ops.mjs';
47
47
  import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
48
48
  import { cmdPhaseStatus } from './lib/phase.mjs';
49
+ import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
49
50
  import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs';
50
51
 
51
52
  const COMMANDS = {
52
53
  'file-op': cmdFileOp,
53
54
  'lifecycle-preflight': cmdLifecyclePreflight,
54
55
  'phase-status': cmdPhaseStatus,
56
+ 'session-fingerprint': cmdSessionFingerprint,
55
57
  };
56
58
 
57
59
  function printHelp() {
@@ -67,6 +69,8 @@ function printHelp() {
67
69
  ' lifecycle-preflight <surface> [phase]',
68
70
  ' Inspect lifecycle gate results for a workflow surface',
69
71
  ' Example: node .planning/bin/gsdd.mjs lifecycle-preflight verify 1 --expects-mutation phase-status',
72
+ ' session-fingerprint write',
73
+ ' Rebaseline planning-state drift after reviewing changed planning files',
70
74
  '',
71
75
  'Advanced option:',
72
76
  ' --workspace-root <path> Override workspace root discovery before or after the subcommand',