@ktpartners/dgs-platform 3.0.4 → 3.3.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 (115) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +8 -1
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +32 -0
  6. package/agents/dgs-planner.md +41 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/audit-milestone.md +2 -1
  9. package/commands/dgs/diff-report.md +124 -0
  10. package/commands/dgs/new-project.md +8 -21
  11. package/commands/dgs/package-scan.md +43 -0
  12. package/commands/dgs/research-idea.md +1 -0
  13. package/commands/dgs/switch-project.md +13 -0
  14. package/deliver-great-systems/bin/dgs-tools.cjs +120 -5
  15. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  16. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  17. package/deliver-great-systems/bin/lib/commands.cjs +311 -16
  18. package/deliver-great-systems/bin/lib/commands.test.cjs +115 -0
  19. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  20. package/deliver-great-systems/bin/lib/config.cjs +41 -0
  21. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  22. package/deliver-great-systems/bin/lib/core.cjs +7 -3
  23. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  24. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  25. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  26. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  27. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  28. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  29. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  30. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  31. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  32. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  33. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  34. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  35. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  36. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  37. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  38. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  39. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  40. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  41. package/deliver-great-systems/bin/lib/init.cjs +56 -27
  42. package/deliver-great-systems/bin/lib/init.test.cjs +212 -5
  43. package/deliver-great-systems/bin/lib/jobs.cjs +7 -4
  44. package/deliver-great-systems/bin/lib/milestone.cjs +101 -3
  45. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  46. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  47. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  48. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  49. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  50. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  51. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  52. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  53. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  54. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  55. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  56. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  57. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  58. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  59. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  60. package/deliver-great-systems/bin/lib/phase.cjs +18 -1
  61. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  62. package/deliver-great-systems/bin/lib/projects.cjs +38 -3
  63. package/deliver-great-systems/bin/lib/projects.test.cjs +112 -2
  64. package/deliver-great-systems/bin/lib/quick.cjs +178 -23
  65. package/deliver-great-systems/bin/lib/quick.test.cjs +138 -4
  66. package/deliver-great-systems/bin/lib/repos.cjs +12 -12
  67. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  68. package/deliver-great-systems/bin/lib/state.cjs +7 -3
  69. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  70. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  71. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  72. package/deliver-great-systems/bin/lib/verify.cjs +118 -6
  73. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  74. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  75. package/deliver-great-systems/bin/lib/worktrees.cjs +27 -1
  76. package/deliver-great-systems/bin/lib/worktrees.test.cjs +76 -0
  77. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  78. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  79. package/deliver-great-systems/references/context-tiers.md +4 -0
  80. package/deliver-great-systems/references/package-scan-config.md +151 -0
  81. package/deliver-great-systems/references/questioning.md +0 -30
  82. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  83. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  84. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  85. package/deliver-great-systems/templates/REVIEW.md +35 -0
  86. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  87. package/deliver-great-systems/templates/claude-md.md +11 -0
  88. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  89. package/deliver-great-systems/templates/project.md +6 -170
  90. package/deliver-great-systems/templates/summary.md +3 -1
  91. package/deliver-great-systems/workflows/add-phase.md +5 -0
  92. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  93. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  94. package/deliver-great-systems/workflows/codereview.md +103 -9
  95. package/deliver-great-systems/workflows/complete-milestone.md +26 -7
  96. package/deliver-great-systems/workflows/complete-quick.md +40 -2
  97. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  98. package/deliver-great-systems/workflows/execute-phase.md +89 -2
  99. package/deliver-great-systems/workflows/execute-plan.md +10 -1
  100. package/deliver-great-systems/workflows/help.md +51 -18
  101. package/deliver-great-systems/workflows/import-spec.md +65 -7
  102. package/deliver-great-systems/workflows/init-product.md +46 -152
  103. package/deliver-great-systems/workflows/new-milestone.md +115 -14
  104. package/deliver-great-systems/workflows/new-project.md +60 -331
  105. package/deliver-great-systems/workflows/package-scan.md +59 -0
  106. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  107. package/deliver-great-systems/workflows/quick-complete.md +40 -2
  108. package/deliver-great-systems/workflows/quick.md +183 -10
  109. package/deliver-great-systems/workflows/research-idea.md +80 -142
  110. package/deliver-great-systems/workflows/run-job.md +21 -35
  111. package/deliver-great-systems/workflows/settings.md +13 -77
  112. package/deliver-great-systems/workflows/write-spec.md +9 -11
  113. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  114. package/package.json +1 -1
  115. package/scripts/build-hooks.js +1 -0
@@ -283,18 +283,44 @@ function cmdResolveModel(cwd, agentType, raw) {
283
283
  * Config loading still uses cwd. Used by fast-path in milestone-context to
284
284
  * commit in a worktree while loading config from the planning root.
285
285
  */
286
- function cmdCommit(cwd, message, files, raw, amend, push, repoCwd) {
287
- if (!message && !amend) {
288
- error('commit message required');
289
- }
286
+ // Collect the list of still-dirty paths in `gitCwd` immediately after a
287
+ // commit (or nothing_to_commit). Purely informational: populates
288
+ // `result.dirty_after` so callers can detect verify-step side effects
289
+ // (formatter reflows, type narrowings) that leaked outside the staged
290
+ // file set. Never throws — returns [] on any error.
291
+ function collectDirtyAfter(gitCwd) {
292
+ const porcelain = execGit(gitCwd, ['status', '--porcelain']);
293
+ if (porcelain.exitCode !== 0) return [];
294
+ return (porcelain.stdout || '')
295
+ .split('\n')
296
+ .map(l => l.trim())
297
+ .filter(Boolean)
298
+ .map(l => l.replace(/^..\s+/, '')); // strip two-char XY status prefix + space
299
+ }
290
300
 
301
+ /**
302
+ * Internal commit primitive — RETURNS the JSON result object instead of
303
+ * calling output()/process.exit. Extracted from cmdCommit for reuse by
304
+ * verifyPlanCommit (REL-01, Phase 156). External callers should keep
305
+ * using cmdCommit; this helper is library-internal only.
306
+ *
307
+ * @param {string} cwd - Planning root (config loaded from here)
308
+ * @param {string} message - Commit message (required unless amend)
309
+ * @param {string[]|undefined} files - Files to stage; if empty/undefined,
310
+ * the historical behaviour is to fall back to ['.'] (sweeping the
311
+ * working tree). REL-01's verifyPlanCommit guards against this fallback
312
+ * for the orchestrator commit case BEFORE it ever reaches commitInternal.
313
+ * @param {boolean} amend
314
+ * @param {boolean} push
315
+ * @param {string} [repoCwd] - Where git operations actually run.
316
+ * @returns {object} JSON result matching cmdCommit's existing contract.
317
+ */
318
+ function commitInternal(cwd, message, files, amend, push, repoCwd) {
291
319
  const config = loadConfig(cwd);
292
320
 
293
321
  // Check commit_docs config
294
322
  if (!config.commit_docs) {
295
- const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
296
- output(result, raw, 'skipped');
297
- return;
323
+ return { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
298
324
  }
299
325
 
300
326
  // Resolve git-operation cwd: use repoCwd when provided, otherwise cwd.
@@ -313,19 +339,15 @@ function cmdCommit(cwd, message, files, raw, amend, push, repoCwd) {
313
339
  const commitResult = execGit(gitCwd, commitArgs);
314
340
  if (commitResult.exitCode !== 0) {
315
341
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
316
- const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
317
- output(result, raw, 'nothing');
318
- return;
342
+ return { committed: false, hash: null, reason: 'nothing_to_commit', dirty_after: collectDirtyAfter(gitCwd) };
319
343
  }
320
- const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr };
321
- output(result, raw, 'nothing');
322
- return;
344
+ return { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr, dirty_after: collectDirtyAfter(gitCwd) };
323
345
  }
324
346
 
325
347
  // Get short hash
326
348
  const hashResult = execGit(gitCwd, ['rev-parse', '--short', 'HEAD']);
327
349
  const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
328
- const result = { committed: true, hash, reason: 'committed' };
350
+ const result = { committed: true, hash, reason: 'committed', dirty_after: collectDirtyAfter(gitCwd) };
329
351
 
330
352
  // Handle push if requested
331
353
  if (push) {
@@ -347,7 +369,234 @@ function cmdCommit(cwd, message, files, raw, amend, push, repoCwd) {
347
369
  // 'off' or any other value: no push fields added
348
370
  }
349
371
 
350
- output(result, raw, hash || 'committed');
372
+ return result;
373
+ }
374
+
375
+ function cmdCommit(cwd, message, files, raw, amend, push, repoCwd) {
376
+ if (!message && !amend) {
377
+ error('commit message required');
378
+ }
379
+
380
+ const result = commitInternal(cwd, message, files, amend, push, repoCwd);
381
+
382
+ // Branch labels per the original cmdCommit output() calls
383
+ if (result.reason === 'skipped_commit_docs_false') {
384
+ output(result, raw, 'skipped');
385
+ return;
386
+ }
387
+ if (result.reason === 'nothing_to_commit') {
388
+ output(result, raw, 'nothing');
389
+ return;
390
+ }
391
+ output(result, raw, result.hash || 'committed');
392
+ }
393
+
394
+ /**
395
+ * REL-01 (Phase 156, plan 02): orchestrator-side commit + verification
396
+ * helper consumed by /dgs:plan-phase to commit planner-reported
397
+ * created_files. Guarantees:
398
+ *
399
+ * - Empty createdFiles → returns plan-commit-incomplete WITHOUT calling
400
+ * commitInternal. Defends against cmdCommit's `['.']` fallback that
401
+ * would otherwise sweep the working tree (Hypothesis C from
402
+ * 156-Q1-FINDINGS.md).
403
+ * - commit_docs:false → silent success ({ ok: true, hash: null,
404
+ * reason: 'skipped_commit_docs_false' }). NOT a failure.
405
+ * - cmdCommit failure → plan-commit-incomplete with reason: 'commit_failed'.
406
+ * - Verification: every entry in (createdFiles + extraFiles) MUST appear
407
+ * in `git show --name-only HEAD`; mismatches → plan-commit-incomplete
408
+ * with reason: 'verification_failed' and a `missing` array.
409
+ *
410
+ * @param {string} cwd - Planning root (config loaded from here)
411
+ * @param {object} options - {
412
+ * message: string (REQUIRED),
413
+ * createdFiles: string[] (REQUIRED),
414
+ * extraFiles?: string[],
415
+ * repoCwd?: string,
416
+ * push?: boolean,
417
+ * }
418
+ * @param {boolean} raw - Emit raw JSON via output() if true.
419
+ * @returns {object} Returns the result object directly (so the test
420
+ * harness can assert on it). Also calls output() when invoked from CLI.
421
+ */
422
+ function verifyPlanCommit(cwd, options, raw) {
423
+ const opts = options || {};
424
+ const message = opts.message;
425
+ const createdFiles = opts.createdFiles;
426
+ const extraFiles = opts.extraFiles || [];
427
+ const repoCwd = opts.repoCwd;
428
+ const push = !!opts.push;
429
+
430
+ // verifyPlanCommit is a pure helper — it RETURNS the result object
431
+ // (so library callers and tests can assert on it). The `raw` argument
432
+ // is accepted for CLI dispatch parity but is intentionally unused
433
+ // here; the dgs-tools CLI dispatcher (dgs-tools.cjs) is responsible
434
+ // for calling output()/process.exit on the returned object.
435
+ void raw;
436
+
437
+ if (!message) {
438
+ return {
439
+ ok: false,
440
+ exitLabel: 'plan-commit-incomplete',
441
+ reason: 'missing_message',
442
+ };
443
+ }
444
+
445
+ // Empty-list guard — defends Hypothesis C (cmdCommit `['.']` fallback)
446
+ if (!Array.isArray(createdFiles) || createdFiles.length === 0) {
447
+ return {
448
+ ok: false,
449
+ exitLabel: 'plan-commit-incomplete',
450
+ reason: 'empty_created_files',
451
+ };
452
+ }
453
+
454
+ const filesToCommit = [...createdFiles, ...extraFiles];
455
+
456
+ const commitResult = commitInternal(cwd, message, filesToCommit, false, push, repoCwd);
457
+
458
+ // commit_docs config gate — silent success
459
+ if (commitResult.reason === 'skipped_commit_docs_false') {
460
+ return {
461
+ ok: true,
462
+ hash: null,
463
+ reason: 'skipped_commit_docs_false',
464
+ };
465
+ }
466
+
467
+ // Any non-success commit reason is a plan-commit-incomplete failure
468
+ if (commitResult.committed !== true) {
469
+ return {
470
+ ok: false,
471
+ exitLabel: 'plan-commit-incomplete',
472
+ reason: 'commit_failed',
473
+ commit_reason: commitResult.reason,
474
+ error: commitResult.error,
475
+ };
476
+ }
477
+
478
+ // Verification: every reported path MUST appear in HEAD
479
+ const gitCwd = repoCwd || cwd;
480
+ const showResult = execGit(gitCwd, ['show', '--name-only', '--pretty=', 'HEAD']);
481
+ const committedFiles = (showResult.stdout || '')
482
+ .split('\n')
483
+ .map(l => l.trim())
484
+ .filter(Boolean);
485
+ const missing = filesToCommit.filter(f => !committedFiles.includes(f));
486
+
487
+ if (missing.length > 0) {
488
+ return {
489
+ ok: false,
490
+ exitLabel: 'plan-commit-incomplete',
491
+ reason: 'verification_failed',
492
+ hash: commitResult.hash,
493
+ missing,
494
+ committedFiles,
495
+ };
496
+ }
497
+
498
+ return {
499
+ ok: true,
500
+ hash: commitResult.hash,
501
+ reason: 'committed',
502
+ files_verified: filesToCommit,
503
+ missing: [],
504
+ };
505
+ }
506
+
507
+ /**
508
+ * REL-02 (Phase 156, plan 03): compute the executor's final-commit
509
+ * phase-dir sweep. Always sweeps the current phase directory and takes
510
+ * the UNION with the executor-reported modified_files list, then
511
+ * scope-filters out anything that does not start with the
512
+ * ${phasesDir}/${phaseDir}/ prefix.
513
+ *
514
+ * Hard scope guarantee: dirty files in sibling phases, ideas/, specs/,
515
+ * or the project root are NEVER returned in `swept`. They are returned
516
+ * in `dropped` for diagnostic visibility.
517
+ *
518
+ * @param {string} cwd - Planning root (where git status runs)
519
+ * @param {object} options - {
520
+ * phasesDir: string, // e.g. 'projects/gsd/phases'
521
+ * phaseDir: string, // e.g. '156-idea-26-closure-...'
522
+ * modifiedFiles?: string[] // executor-reported paths (planning-root-relative)
523
+ * }
524
+ * @param {boolean} raw
525
+ * @returns {{
526
+ * swept: string[], // commit list — UNION, scope-filtered, sorted
527
+ * dropped: string[], // out-of-scope paths the helper rejected
528
+ * gitDirtyPaths: string[], // git-discovered phase-dir paths (pre-union)
529
+ * reportedPaths: string[], // executor-reported list (pre-union)
530
+ * scopePrefix: string // ${phasesDir}/${phaseDir}/
531
+ * }}
532
+ */
533
+ function computePhaseSweep(cwd, options, raw) {
534
+ const opts = options || {};
535
+ const phasesDir = opts.phasesDir;
536
+ const phaseDir = opts.phaseDir;
537
+ const reportedPathsRaw = opts.modifiedFiles || [];
538
+
539
+ void raw;
540
+
541
+ if (!phasesDir || !phaseDir) {
542
+ return {
543
+ swept: [],
544
+ dropped: [],
545
+ gitDirtyPaths: [],
546
+ reportedPaths: [],
547
+ scopePrefix: null,
548
+ error: 'phasesDir and phaseDir required for compute-phase-sweep',
549
+ };
550
+ }
551
+
552
+ const scopePrefix = `${phasesDir}/${phaseDir}/`;
553
+
554
+ // Run path-scoped porcelain. Use --untracked-files=all so untracked
555
+ // files inside untracked directories are listed individually rather
556
+ // than collapsed into a single directory entry like `phases/{dir}/`.
557
+ const gitArgs = ['status', '--porcelain', '--untracked-files=all', '--', `${phasesDir}/${phaseDir}`];
558
+ const gitResult = execGit(cwd, gitArgs);
559
+ const gitDirtyPaths = gitResult.exitCode === 0
560
+ ? (gitResult.stdout || '')
561
+ .split('\n')
562
+ .map(l => l.trim())
563
+ .filter(Boolean)
564
+ // Strip 2-char XY status prefix + space (matches collectDirtyAfter)
565
+ .map(l => l.replace(/^..\s+/, ''))
566
+ : [];
567
+
568
+ // Strip optional `repoName:` prefix from reported paths — multi-repo
569
+ // entries with explicit repoName are out of scope for the planning-root
570
+ // sweep (they route through their own resolveRepoRelativePath flow).
571
+ const reportedPaths = reportedPathsRaw
572
+ .map(p => {
573
+ // If the entry contains a `:` and the leading segment looks like a
574
+ // repo name (no `/`), treat it as repoName:path and drop it for
575
+ // planning-root sweep purposes.
576
+ const colonIdx = p.indexOf(':');
577
+ if (colonIdx > 0 && p.indexOf('/') > colonIdx) return null;
578
+ return p;
579
+ })
580
+ .filter(p => p !== null);
581
+
582
+ // Compute the UNION (dedupe via Set)
583
+ const candidates = Array.from(new Set([...gitDirtyPaths, ...reportedPaths]));
584
+
585
+ // Belt-and-braces: scope-filter via prefix check
586
+ const swept = candidates
587
+ .filter(p => p.startsWith(scopePrefix))
588
+ .sort();
589
+ const dropped = candidates
590
+ .filter(p => !p.startsWith(scopePrefix))
591
+ .sort();
592
+
593
+ return {
594
+ swept,
595
+ dropped,
596
+ gitDirtyPaths,
597
+ reportedPaths,
598
+ scopePrefix,
599
+ };
351
600
  }
352
601
 
353
602
  /**
@@ -530,7 +779,9 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
530
779
  tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
531
780
  patterns: fm['patterns-established'] || [],
532
781
  decisions: parseDecisions(fm['key-decisions']),
533
- requirements_completed: fm['requirements-completed'] || [],
782
+ // REL-07/10 transitional dual-read: canonical underscore key takes precedence;
783
+ // legacy hyphen key fallback preserves audit-readability for archived v23.1 SUMMARYs.
784
+ requirements_completed: fm['requirements_completed'] || fm['requirements-completed'] || [],
534
785
  };
535
786
 
536
787
  // If fields specified, filter to only those fields
@@ -849,6 +1100,46 @@ function cmdContextHelp(raw) {
849
1100
  output(result, raw, result.subcommands.map(s => s.usage).join('\n'));
850
1101
  }
851
1102
 
1103
+ // REL-08 (Phase 157): pre-commit precondition gate.
1104
+ // Reads PLAN.md `requirements:` and SUMMARY.md `requirements_completed:` (canonical) /
1105
+ // `requirements-completed:` (legacy fallback). If PLAN is non-empty AND SUMMARY is empty,
1106
+ // writes `summary-frontmatter-mismatch:` label to stderr and exits non-zero.
1107
+ // NEVER writes to the working tree — read-only check.
1108
+ function cmdFinalCommitPrecondition(cwd, options) {
1109
+ const planPath = options && options.plan;
1110
+ const summaryPath = options && options.summary;
1111
+ if (!planPath || !summaryPath) {
1112
+ process.stderr.write('summary-frontmatter-mismatch: --plan and --summary required\n');
1113
+ process.exit(2);
1114
+ }
1115
+ const { extractFrontmatter } = require('./frontmatter.cjs');
1116
+ const planAbs = path.isAbsolute(planPath) ? planPath : path.join(cwd, planPath);
1117
+ const summaryAbs = path.isAbsolute(summaryPath) ? summaryPath : path.join(cwd, summaryPath);
1118
+
1119
+ if (!fs.existsSync(planAbs)) {
1120
+ process.stderr.write(`summary-frontmatter-mismatch: PLAN not found at ${planPath}\n`);
1121
+ process.exit(2);
1122
+ }
1123
+ if (!fs.existsSync(summaryAbs)) {
1124
+ process.stderr.write(`summary-frontmatter-mismatch: SUMMARY not found at ${summaryPath}\n`);
1125
+ process.exit(2);
1126
+ }
1127
+ const planFm = extractFrontmatter(fs.readFileSync(planAbs, 'utf-8'));
1128
+ const summaryFm = extractFrontmatter(fs.readFileSync(summaryAbs, 'utf-8'));
1129
+ const planReq = Array.isArray(planFm.requirements) ? planFm.requirements : [];
1130
+ // Dual-read: canonical underscore key first, legacy hyphen fallback (REL-07/10 dual-read).
1131
+ const summaryReq = summaryFm['requirements_completed'] || summaryFm['requirements-completed'] || [];
1132
+
1133
+ if (planReq.length > 0 && summaryReq.length === 0) {
1134
+ process.stderr.write(
1135
+ `summary-frontmatter-mismatch: PLAN.md declared requirements [${planReq.join(', ')}]; ` +
1136
+ `SUMMARY.md requirements_completed is empty. Re-run executor or manually populate before committing.\n`
1137
+ );
1138
+ process.exit(2);
1139
+ }
1140
+ process.exit(0);
1141
+ }
1142
+
852
1143
  module.exports = {
853
1144
  TODO_STATUSES,
854
1145
  setTodoStatus,
@@ -859,6 +1150,9 @@ module.exports = {
859
1150
  cmdHistoryDigest,
860
1151
  cmdResolveModel,
861
1152
  cmdCommit,
1153
+ commitInternal,
1154
+ verifyPlanCommit,
1155
+ computePhaseSweep,
862
1156
  cmdPlanFinalize,
863
1157
  cmdSummaryExtract,
864
1158
  cmdWebsearch,
@@ -866,4 +1160,5 @@ module.exports = {
866
1160
  cmdTodoComplete,
867
1161
  cmdScaffold,
868
1162
  cmdContextHelp,
1163
+ cmdFinalCommitPrecondition,
869
1164
  };
@@ -541,6 +541,121 @@ describe('cmdCommit with --repo-cwd flag', () => {
541
541
  });
542
542
  });
543
543
 
544
+ // ─── cmdCommit populates dirty_after ────────────────────────────────────────
545
+ // Uses captureStdout (not captureCommitOutput) because cmdCommit's output() goes
546
+ // through process.stdout.write + process.exit, not console.log. captureStdout
547
+ // mocks both correctly. captureCommitOutput exists for historical reasons but
548
+ // doesn't actually intercept output() writes — its assertions silently no-op
549
+ // because process.exit kills the test worker before they run.
550
+
551
+ describe('cmdCommit populates dirty_after', () => {
552
+ let fixture;
553
+ let commands;
554
+
555
+ beforeEach(() => {
556
+ fixture = setupGitFixture('off');
557
+ commands = require('./commands.cjs');
558
+ });
559
+
560
+ afterEach(() => {
561
+ fixture.cleanup();
562
+ });
563
+
564
+ it('returns dirty_after=[] when tree is clean after commit', () => {
565
+ writeAndStageFile(fixture.cwd, 'clean-after.txt', 'clean');
566
+ const capture = captureStdout(() => {
567
+ commands.cmdCommit(fixture.cwd, 'test: clean dirty_after', [], false, false, false);
568
+ });
569
+ const result = capture.json;
570
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
571
+ assert.equal(result.committed, true);
572
+ assert.ok(result.hash);
573
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array');
574
+ assert.equal(result.dirty_after.length, 0, `clean tree should have empty dirty_after, got: ${JSON.stringify(result.dirty_after)}`);
575
+ });
576
+
577
+ it('includes unstaged files in dirty_after after commit', () => {
578
+ // Stage and commit file A
579
+ writeAndStageFile(fixture.cwd, 'staged-file.txt', 'staged');
580
+ // Write (but do NOT stage) file B — it should remain dirty after the commit
581
+ fs.writeFileSync(path.join(fixture.cwd, 'dirty-file.txt'), 'unstaged');
582
+
583
+ const capture = captureStdout(() => {
584
+ commands.cmdCommit(fixture.cwd, 'test: dirty dirty_after', ['staged-file.txt'], false, false, false);
585
+ });
586
+ const result = capture.json;
587
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
588
+ assert.equal(result.committed, true);
589
+ assert.ok(result.hash);
590
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array');
591
+ assert.ok(
592
+ result.dirty_after.includes('dirty-file.txt'),
593
+ `dirty_after should include the unstaged file, got: ${JSON.stringify(result.dirty_after)}`
594
+ );
595
+ });
596
+
597
+ it('emits dirty_after in nothing_to_commit branch', () => {
598
+ // Create an unstaged file to make the tree dirty
599
+ fs.writeFileSync(path.join(fixture.cwd, 'unstaged-only.txt'), 'unstaged');
600
+
601
+ const capture = captureStdout(() => {
602
+ // Pass a pathspec that matches nothing so staging is a no-op — the
603
+ // unstaged file remains dirty AND there is nothing_to_commit.
604
+ commands.cmdCommit(fixture.cwd, 'test: nothing', ['nonexistent-pathspec-zzz'], false, false, false);
605
+ });
606
+ const result = capture.json;
607
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
608
+ assert.equal(result.committed, false, `expected committed=false, got result: ${JSON.stringify(result)}`);
609
+ assert.equal(result.reason, 'nothing_to_commit');
610
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array even when nothing_to_commit');
611
+ assert.ok(
612
+ result.dirty_after.includes('unstaged-only.txt'),
613
+ `dirty_after should include the unstaged file in nothing_to_commit branch, got: ${JSON.stringify(result.dirty_after)}`
614
+ );
615
+ });
616
+
617
+ it('runs porcelain in repoCwd when --repo-cwd is passed (not in cwd)', () => {
618
+ // Second fixture for the worktree
619
+ const worktreeFixture = setupGitFixture('off');
620
+ try {
621
+ // Write file in the worktree ONLY — planning root stays untouched
622
+ const stagedName = 'worktree-staged.txt';
623
+ fs.writeFileSync(path.join(worktreeFixture.cwd, stagedName), 'content');
624
+ execSync(`git add "${stagedName}"`, { cwd: worktreeFixture.cwd, stdio: 'pipe' });
625
+ // Dirty file in the worktree that is NOT staged
626
+ fs.writeFileSync(path.join(worktreeFixture.cwd, 'worktree-dirty.txt'), 'unstaged');
627
+ // Separately, make the PLANNING root dirty — this should NOT appear in dirty_after
628
+ fs.writeFileSync(path.join(fixture.cwd, 'planning-dirty.txt'), 'should-not-see-this');
629
+
630
+ const capture = captureStdout(() => {
631
+ commands.cmdCommit(
632
+ fixture.cwd, // cwd (planning root)
633
+ 'test: repo-cwd dirty', // message
634
+ [stagedName], // files (relative to repoCwd)
635
+ false, // raw=false -> JSON output
636
+ false,
637
+ false,
638
+ worktreeFixture.cwd // repoCwd
639
+ );
640
+ });
641
+ const result = capture.json;
642
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
643
+ assert.equal(result.committed, true);
644
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array');
645
+ assert.ok(
646
+ result.dirty_after.includes('worktree-dirty.txt'),
647
+ `dirty_after should include the worktree-unstaged file, got: ${JSON.stringify(result.dirty_after)}`
648
+ );
649
+ assert.ok(
650
+ !result.dirty_after.includes('planning-dirty.txt'),
651
+ `dirty_after must NOT include planning-root files when --repo-cwd is set, got: ${JSON.stringify(result.dirty_after)}`
652
+ );
653
+ } finally {
654
+ worktreeFixture.cleanup();
655
+ }
656
+ });
657
+ });
658
+
544
659
  // ─── TODO_STATUSES constant ─────────────────────────────────────────────────
545
660
 
546
661
  describe('TODO_STATUSES constant', () => {