@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
@@ -60,6 +60,21 @@ function v2FixtureWithProject() {
60
60
  });
61
61
  }
62
62
 
63
+ // ─── v2 fixture for multi-project case (existing active + new slug) ──────────
64
+
65
+ function v2FixtureMultiProject() {
66
+ // Active project exists and is populated; we are about to create a second, distinct project.
67
+ return createFixture({
68
+ 'config.json': JSON.stringify({ current_project: 'test-project' }),
69
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
70
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
71
+ 'projects/test-project/STATE.md': '# State',
72
+ 'projects/test-project/ROADMAP.md': '# Roadmap',
73
+ 'projects/test-project/REQUIREMENTS.md': '# Requirements',
74
+ 'projects/test-project/PROJECT.md': '# Project',
75
+ });
76
+ }
77
+
63
78
  // ─── v2 fixture without current_project (for guard tests) ────────────────────
64
79
 
65
80
  function v2FixtureOneProject() {
@@ -67,6 +82,7 @@ function v2FixtureOneProject() {
67
82
  'config.json': JSON.stringify({}),
68
83
  'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
69
84
  'REPOS.md': '# Repos\n\n| Name | Path |\n',
85
+ 'projects/test-project/PROJECT.md': '# Project\n',
70
86
  'projects/test-project/STATE.md': '# State',
71
87
  });
72
88
  }
@@ -84,7 +100,9 @@ function v2FixtureMultipleProjects() {
84
100
  'config.json': JSON.stringify({}),
85
101
  'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
86
102
  'REPOS.md': '# Repos\n\n| Name | Path |\n',
103
+ 'projects/alpha-project/PROJECT.md': '# Project\n',
87
104
  'projects/alpha-project/STATE.md': '# State',
105
+ 'projects/beta-project/PROJECT.md': '# Project\n',
88
106
  'projects/beta-project/STATE.md': '# State',
89
107
  });
90
108
  }
@@ -278,7 +296,8 @@ describe('v1 mode: init todos', () => {
278
296
  });
279
297
 
280
298
  it('returns v1 pending_dir path', () => {
281
- assert.equal(result.pending_dir, 'todos/pending');
299
+ // Flat layout: pending_dir points to todos/ (not todos/pending/)
300
+ assert.equal(result.pending_dir, 'todos');
282
301
  });
283
302
 
284
303
  it('returns v1 completed_dir path', () => {
@@ -518,7 +537,8 @@ describe('v2 mode with project: init todos', () => {
518
537
  });
519
538
 
520
539
  it('returns product-level pending_dir', () => {
521
- assert.equal(result.pending_dir, path.join('todos', 'pending'));
540
+ // Flat layout: pending_dir points to todos/ (not todos/pending/)
541
+ assert.equal(result.pending_dir, 'todos');
522
542
  });
523
543
 
524
544
  it('returns product-level completed_dir', () => {
@@ -1661,6 +1681,71 @@ describe('cmdInitQuick quick_dir resolution', () => {
1661
1681
  assert.equal(result.description, 'test task');
1662
1682
  });
1663
1683
  });
1684
+
1685
+ describe('product mode with active quick worktree (parity)', () => {
1686
+ let fixture;
1687
+
1688
+ beforeEach(() => {
1689
+ // Hand-rolled fixture (not v2FixtureWithProject) so we can seed
1690
+ // config.local.json with an active quick worktree whose slug has
1691
+ // already been truncated by worktrees.cjs::_sanitizeSlug to 50 chars.
1692
+ fixture = createFixture({
1693
+ 'config.json': JSON.stringify({ current_project: 'test-project' }),
1694
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
1695
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
1696
+ 'projects/test-project/PROJECT.md': '# Project',
1697
+ 'projects/test-project/STATE.md': '# State',
1698
+ 'projects/test-project/ROADMAP.md': '# Roadmap\n\n## Phases\n\n- [ ] **Phase 1: Test Phase** - A test\n',
1699
+ 'projects/test-project/REQUIREMENTS.md': '# Requirements',
1700
+ });
1701
+ // Seed the active quick worktree AFTER createFixture so we can point
1702
+ // entry.repos at fixture.cwd (which is a real on-disk tmp dir).
1703
+ // Slug is the SHORT (50-char) form that worktrees.cjs would produce.
1704
+ const shortSlug = '260428-k7f-this-is-a-very-long-description-that-w';
1705
+ const localConfig = {
1706
+ execution: { active_context: shortSlug },
1707
+ projects: {
1708
+ 'test-project': {
1709
+ worktrees: {
1710
+ [shortSlug]: {
1711
+ type: 'quick',
1712
+ repos: { 'deliver-great-systems': fixture.cwd },
1713
+ },
1714
+ },
1715
+ },
1716
+ },
1717
+ };
1718
+ fs.writeFileSync(path.join(fixture.cwd, 'config.local.json'),
1719
+ JSON.stringify(localConfig), 'utf-8');
1720
+ });
1721
+
1722
+ afterEach(() => {
1723
+ fixture.cleanup();
1724
+ });
1725
+
1726
+ it('task_dir basename matches active worktree slug (parity with getActiveQuick)', () => {
1727
+ // Description deliberately long enough that init.cjs's own 40-char
1728
+ // sanitiser would produce a descSlug differing from the worktree's
1729
+ // 39-char descSlug by exactly one char (pre-fix). Post-fix, init
1730
+ // defers to the worktree slug, so the two align.
1731
+ const result = runInit(fixture.cwd,
1732
+ 'quick "this is a very long description that would otherwise"');
1733
+
1734
+ assert.ok(result.task_dir, 'task_dir should be set');
1735
+ assert.equal(
1736
+ path.basename(result.task_dir),
1737
+ '260428-k7f-this-is-a-very-long-description-that-w',
1738
+ 'task_dir basename must equal active worktree slug (no second sanitiser pass)'
1739
+ );
1740
+ assert.equal(
1741
+ result.slug,
1742
+ 'this-is-a-very-long-description-that-w',
1743
+ 'slug must be derived from worktree slug (39 chars), not recomputed from description (40 chars)'
1744
+ );
1745
+ assert.equal(result.quick_id, '260428-k7f',
1746
+ 'quick_id should still be extracted from worktree slug prefix (regression guard)');
1747
+ });
1748
+ });
1664
1749
  });
1665
1750
 
1666
1751
  // ─── init progress-all ───────────────────────────────────────────────────────
@@ -1756,7 +1841,9 @@ describe('init progress-all: excludes completed projects', () => {
1756
1841
  'config.json': JSON.stringify({}),
1757
1842
  'PROJECTS.md': '# Projects\n',
1758
1843
  'REPOS.md': '# Repos\n',
1844
+ 'projects/active-x/PROJECT.md': '# Project\n',
1759
1845
  'projects/active-x/STATE.md': '# Project State\n\nPhase: 1\nStatus: In progress\nProgress: [#---------] 10%\n',
1846
+ 'projects/done-y/PROJECT.md': '# Project\n',
1760
1847
  'projects/done-y/STATE.md': '# Project State\n\nPhase: 5\nStatus: completed\nProgress: [##########] 100%\nCompleted: 2026-01-20\n',
1761
1848
  });
1762
1849
  result = runInit(fixture.cwd, 'progress-all --raw');
@@ -1898,9 +1985,9 @@ describe('init progress-all: counts backlog files', () => {
1898
1985
  'config.json': JSON.stringify({}),
1899
1986
  'PROJECTS.md': '# Projects\n',
1900
1987
  'REPOS.md': '# Repos\n',
1901
- 'todos/pending/todo-1.md': '# todo 1',
1902
- 'todos/pending/todo-2.md': '# todo 2',
1903
- 'ideas/pending/idea-1.md': '# idea 1',
1988
+ 'todos/todo-1.md': '---\nstatus: pending\n---\n# todo 1',
1989
+ 'todos/todo-2.md': '---\nstatus: pending\n---\n# todo 2',
1990
+ 'ideas/idea-1.md': '---\nstatus: pending\n---\n# idea 1',
1904
1991
  'specs/spec-1.md': '# spec 1',
1905
1992
  'specs/spec-2.md': '# spec 2',
1906
1993
  'specs/spec-3.md': '# spec 3',
@@ -1931,3 +2018,123 @@ describe('init progress-all: counts backlog files', () => {
1931
2018
  assert.equal(result.product.backlog.debug_active, 1);
1932
2019
  });
1933
2020
  });
2021
+
2022
+ // ─── v2 mode multi-project: init new-project <slug> ──────────────────────────
2023
+
2024
+ describe('v2 mode multi-project: init new-project <slug>', () => {
2025
+ it('returns project_exists false for a new, non-existing slug', () => {
2026
+ const fixture = v2FixtureMultiProject();
2027
+ try {
2028
+ const result = runInit(fixture.cwd, 'new-project second-project');
2029
+ assert.equal(result.project_exists, false);
2030
+ } finally {
2031
+ fixture.cleanup();
2032
+ }
2033
+ });
2034
+
2035
+ it('returns project_path under the requested slug, not current_project', () => {
2036
+ const fixture = v2FixtureMultiProject();
2037
+ try {
2038
+ const result = runInit(fixture.cwd, 'new-project second-project');
2039
+ assert.equal(result.project_path, path.join('projects', 'second-project', 'PROJECT.md'));
2040
+ } finally {
2041
+ fixture.cleanup();
2042
+ }
2043
+ });
2044
+
2045
+ it('returns state/roadmap/requirements/research paths under the requested slug', () => {
2046
+ const fixture = v2FixtureMultiProject();
2047
+ try {
2048
+ const result = runInit(fixture.cwd, 'new-project second-project');
2049
+ assert.equal(result.state_path, path.join('projects', 'second-project', 'STATE.md'));
2050
+ assert.equal(result.roadmap_path, path.join('projects', 'second-project', 'ROADMAP.md'));
2051
+ assert.equal(result.requirements_path, path.join('projects', 'second-project', 'REQUIREMENTS.md'));
2052
+ assert.equal(result.research_dir, path.join('projects', 'second-project', 'research'));
2053
+ } finally {
2054
+ fixture.cleanup();
2055
+ }
2056
+ });
2057
+
2058
+ it('returns project_exists true when the slug DOES already exist', () => {
2059
+ const fixture = v2FixtureWithProject();
2060
+ try {
2061
+ const result = runInit(fixture.cwd, 'new-project test-project');
2062
+ assert.equal(result.project_exists, true);
2063
+ assert.equal(result.project_path, path.join('projects', 'test-project', 'PROJECT.md'));
2064
+ } finally {
2065
+ fixture.cleanup();
2066
+ }
2067
+ });
2068
+
2069
+ it('normalizes the slug consistently with generateSlugInternal', () => {
2070
+ // Lock in the contract that the resolver and `projects create` use the same normalizer.
2071
+ const { generateSlugInternal } = require('./core.cjs');
2072
+ assert.equal(generateSlugInternal('Second Project'), 'second-project');
2073
+ });
2074
+
2075
+ it('exposes requested_slug on the result object', () => {
2076
+ const fixture = v2FixtureMultiProject();
2077
+ try {
2078
+ const result = runInit(fixture.cwd, 'new-project second-project');
2079
+ assert.equal(result.requested_slug, 'second-project');
2080
+ // current_project should still report the active project.
2081
+ assert.equal(result.current_project, 'test-project');
2082
+ } finally {
2083
+ fixture.cleanup();
2084
+ }
2085
+ });
2086
+
2087
+ // Regression: backwards-compatible no-slug behaviour
2088
+ it('v2 with current_project but no slug arg: behaviour unchanged', () => {
2089
+ const fixture = v2FixtureWithProject();
2090
+ try {
2091
+ const result = runInit(fixture.cwd, 'new-project');
2092
+ assert.equal(result.project_path, path.join('projects', 'test-project', 'PROJECT.md'));
2093
+ assert.equal(result.project_exists, true);
2094
+ } finally {
2095
+ fixture.cleanup();
2096
+ }
2097
+ });
2098
+
2099
+ it('v1 mode with no slug arg: behaviour unchanged', () => {
2100
+ const fixture = v1Fixture();
2101
+ try {
2102
+ const result = runInit(fixture.cwd, 'new-project');
2103
+ assert.equal(result.project_path, 'PROJECT.md');
2104
+ } finally {
2105
+ fixture.cleanup();
2106
+ }
2107
+ });
2108
+ });
2109
+
2110
+ // ─── v2 mode multi-project: end-to-end ───────────────────────────────────────
2111
+
2112
+ describe('v2 mode multi-project: end-to-end', () => {
2113
+ it('init new-project <slug> agrees with projects create on the target directory', () => {
2114
+ const fixture = v2FixtureMultiProject();
2115
+ try {
2116
+ // Step 1: init new-project resolves paths under projects/second-project/
2117
+ const initResult = runInit(fixture.cwd, 'new-project second-project');
2118
+ assert.equal(initResult.project_exists, false);
2119
+ assert.equal(initResult.project_path, path.join('projects', 'second-project', 'PROJECT.md'));
2120
+ assert.equal(initResult.requested_slug, 'second-project');
2121
+ // current_project is still the active project — workflow uses requested_slug or
2122
+ // project_path, not current_project, when creating.
2123
+ assert.equal(initResult.current_project, 'test-project');
2124
+
2125
+ // Step 2: projects create creates the same folder init pointed at.
2126
+ execSync(`node "${CLI}" projects create "Second Project"`, {
2127
+ cwd: fixture.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
2128
+ });
2129
+ const createdDir = path.join(fixture.cwd, 'projects', 'second-project');
2130
+ assert.ok(fs.existsSync(createdDir), 'projects create should create projects/second-project/');
2131
+ assert.ok(fs.existsSync(path.join(createdDir, 'PROJECT.md')), 'PROJECT.md should exist in created folder');
2132
+
2133
+ // Step 3: re-running init new-project for the same slug now reports it exists.
2134
+ const reinit = runInit(fixture.cwd, 'new-project second-project');
2135
+ assert.equal(reinit.project_exists, true);
2136
+ } finally {
2137
+ fixture.cleanup();
2138
+ }
2139
+ });
2140
+ });
@@ -343,6 +343,9 @@ function updateJobStep(filePath, stepIndex, status, options) {
343
343
  }
344
344
 
345
345
  /**
346
+ * @deprecated Use setJobStatus() for state transitions instead of physical file moves.
347
+ * Kept for backward compatibility with legacy directory-based job layouts.
348
+ *
346
349
  * Move a job file from its current location to a target directory.
347
350
  *
348
351
  * @param {string} filePath - Absolute path to the job file
@@ -1461,7 +1464,7 @@ function listJobs(cwd) {
1461
1464
  }
1462
1465
 
1463
1466
  /**
1464
- * Cancel an in-progress job: reset [>] steps to [ ], set Status to pending, move to pending/.
1467
+ * Cancel an in-progress job: reset [>] steps to [ ], set Status to pending.
1465
1468
  *
1466
1469
  * @param {string} cwd - Working directory
1467
1470
  * @param {string} version - Milestone version (e.g., "v6.0")
@@ -1472,7 +1475,7 @@ function cancelJob(cwd, version) {
1472
1475
  if (!found.found) {
1473
1476
  return { cancelled: false, reason: 'not_found' };
1474
1477
  }
1475
- if (found.directory !== 'in-progress') {
1478
+ if (found.status !== 'in-progress') {
1476
1479
  return { cancelled: false, reason: 'not_in_progress' };
1477
1480
  }
1478
1481
 
@@ -1814,8 +1817,8 @@ function recordStartShas(cwd, jobFilePath) {
1814
1817
  if (name === 'Name' || name.startsWith('-')) continue;
1815
1818
  if (!repoPath || repoPath.startsWith('-')) continue;
1816
1819
 
1817
- // Resolve absolute path: paths in REPOS.md are relative to planning root's parent
1818
- const absRepoPath = path.resolve(path.dirname(planningRoot), repoPath);
1820
+ // Resolve absolute path: paths in REPOS.md are relative to the planning root
1821
+ const absRepoPath = path.resolve(planningRoot, repoPath);
1819
1822
  if (!fs.existsSync(absRepoPath)) continue;
1820
1823
 
1821
1824
  try {
@@ -4,10 +4,12 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error } = require('./core.cjs');
7
+ const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error, loadConfig } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
9
  const { getPlanningRoot } = require('./paths.cjs');
10
10
  const { writeStateMd } = require('./state.cjs');
11
+ const { getContributors, checkFourEyes } = require('./governance.cjs');
12
+ const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
11
13
 
12
14
  /**
13
15
  * Internal helper: marks requirement IDs complete in REQUIREMENTS.md.
@@ -159,6 +161,72 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
159
161
  }
160
162
  } catch {}
161
163
 
164
+ // ── Four-Eyes Gate (GATE-01) ───────────────────────────────────────────────
165
+ const config = loadConfig(cwd);
166
+ const rawConfig = (() => {
167
+ try {
168
+ return JSON.parse(fs.readFileSync(path.join(planRoot, 'config.json'), 'utf-8'));
169
+ } catch { return {}; }
170
+ })();
171
+ const fourEyesMode = (rawConfig.workflow && rawConfig.workflow.four_eyes) || 'off';
172
+ let governanceNote = null;
173
+
174
+ if (fourEyesMode !== 'off') {
175
+ // Resolve current user identity
176
+ let currentUserStr = '';
177
+ try {
178
+ const identity = requireGitIdentity(cwd);
179
+ currentUserStr = formatAuthorString(identity);
180
+ } catch {
181
+ // Identity gate already ran — this is a safety fallback
182
+ currentUserStr = '';
183
+ }
184
+
185
+ // Get contributors for this milestone
186
+ const contribResult = getContributors(cwd);
187
+ const contributors = contribResult.contributors || [];
188
+
189
+ // Run four-eyes check
190
+ const feResult = checkFourEyes(contributors, currentUserStr, fourEyesMode);
191
+
192
+ // Display contributor list (SHR-02 — always when not off)
193
+ const contribNames = contributors.length > 0
194
+ ? contributors.join(', ')
195
+ : '(none detected)';
196
+
197
+ if (feResult.passed) {
198
+ // Four-eyes satisfied — display and continue
199
+ process.stderr.write('Contributors: ' + contribNames + ' \u2014 \u2714 Four-eyes satisfied\n');
200
+ } else {
201
+ // Four-eyes failed — display contributor list first
202
+ process.stderr.write('Contributors: ' + contribNames + '\n');
203
+
204
+ // Extract current user name for display
205
+ let displayName = currentUserStr;
206
+ try {
207
+ const identity = requireGitIdentity(cwd);
208
+ displayName = identity.name;
209
+ } catch { /* use full string */ }
210
+
211
+ if (fourEyesMode === 'warn') {
212
+ // GATE-04: warn mode — display warning, proceed, log
213
+ process.stderr.write('\u26A0 You (' + displayName + ') contributed to this milestone. Completing anyway (warn mode).\n');
214
+ governanceNote = 'Four-eyes warning: ' + displayName + ' completed while also a contributor. Contributors: ' + contribNames;
215
+ } else if (fourEyesMode === 'enforce') {
216
+ if (options.force) {
217
+ // GATE-06: --force bypasses enforce
218
+ process.stderr.write('\u26A0 Forced: you (' + displayName + ') are the only contributor. Override logged.\n');
219
+ governanceNote = 'Four-eyes override: ' + displayName + ' force-completed. Contributors: ' + contribNames;
220
+ } else {
221
+ // GATE-05: enforce mode — block completion
222
+ error('\u2718 Blocked: you (' + displayName + ') are the only contributor. Use --force to override.');
223
+ return;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ // Off mode: no check, no output, no performance impact (GATE-03)
229
+
162
230
  // Archive ROADMAP.md
163
231
  if (fs.existsSync(roadmapPath)) {
164
232
  const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
@@ -180,7 +248,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
180
248
 
181
249
  // Create/append MILESTONES.md entry
182
250
  const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n');
183
- const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
251
+ const governanceLine = governanceNote ? `\n**Governance:** ${governanceNote}\n` : '';
252
+ const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}${governanceLine}\n\n---\n\n`;
184
253
 
185
254
  if (fs.existsSync(milestonesPath)) {
186
255
  const existing = fs.readFileSync(milestonesPath, 'utf-8');
@@ -214,9 +283,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
214
283
  /(\*\*Last Activity:\*\*\s*).*/,
215
284
  `$1${today}`
216
285
  );
286
+ const activitySuffix = governanceNote
287
+ ? ` (four-eyes: ${fourEyesMode === 'warn' ? 'warn' : 'force'} override by ${(() => { try { return requireGitIdentity(cwd).name; } catch { return 'unknown'; } })()})`
288
+ : '';
217
289
  stateContent = stateContent.replace(
218
290
  /(\*\*Last Activity Description:\*\*\s*).*/,
219
- `$1${version} milestone completed and archived`
291
+ `$1${version} milestone completed and archived${activitySuffix}`
220
292
  );
221
293
  writeStateMd(statePath, stateContent, cwd);
222
294
  }
@@ -240,6 +312,31 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
240
312
  } catch {}
241
313
  }
242
314
 
315
+ // Archive completed quick task directories
316
+ let quickArchived = false;
317
+ const quickDir = path.join(projectRoot, 'quick');
318
+ if (fs.existsSync(quickDir)) {
319
+ try {
320
+ const quickArchiveDir = path.join(archiveDir, `${version}-quick`);
321
+ const quickEntries = fs.readdirSync(quickDir, { withFileTypes: true });
322
+ const quickDirNames = quickEntries.filter(e => e.isDirectory()).map(e => e.name);
323
+ let archivedCount = 0;
324
+ for (const dir of quickDirNames) {
325
+ // Check if this quick dir has a SUMMARY file (completed)
326
+ const dirPath = path.join(quickDir, dir);
327
+ const dirFiles = fs.readdirSync(dirPath);
328
+ const hasSummary = dirFiles.some(f => f.endsWith('-SUMMARY.md'));
329
+ if (!hasSummary) continue;
330
+ if (!fs.existsSync(quickArchiveDir)) {
331
+ fs.mkdirSync(quickArchiveDir, { recursive: true });
332
+ }
333
+ fs.renameSync(dirPath, path.join(quickArchiveDir, dir));
334
+ archivedCount++;
335
+ }
336
+ quickArchived = archivedCount > 0;
337
+ } catch {}
338
+ }
339
+
243
340
  const result = {
244
341
  version,
245
342
  name: milestoneName,
@@ -253,6 +350,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
253
350
  requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
254
351
  audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
255
352
  phases: phasesArchived,
353
+ quick: quickArchived,
256
354
  },
257
355
  milestones_updated: true,
258
356
  state_updated: fs.existsSync(statePath),
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tests for milestone.cjs — quick dir archival in cmdMilestoneComplete
3
+ *
4
+ * Uses Node.js built-in test runner (node:test) and assert (node:assert).
5
+ * Each test creates an isolated temp directory fixture and cleans up after.
6
+ */
7
+
8
+ const { describe, it, beforeEach, afterEach } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const { createFixture, writeFile } = require('./test-helpers.cjs');
14
+ const { cmdMilestoneComplete } = require('./milestone.cjs');
15
+
16
+ // Helper: capture stdout from cmdMilestoneComplete (which calls output())
17
+ function captureResult(fn) {
18
+ const chunks = [];
19
+ const origWrite = process.stdout.write;
20
+ process.stdout.write = function (chunk) {
21
+ chunks.push(String(chunk));
22
+ };
23
+ // Also suppress stderr (governance / state messages)
24
+ const origStderrWrite = process.stderr.write;
25
+ process.stderr.write = function () {};
26
+ // Prevent process.exit from actually exiting during tests
27
+ const origExit = process.exit;
28
+ process.exit = function () {};
29
+ try {
30
+ fn();
31
+ } finally {
32
+ process.stdout.write = origWrite;
33
+ process.stderr.write = origStderrWrite;
34
+ process.exit = origExit;
35
+ }
36
+ const raw = chunks.join('');
37
+ try {
38
+ return JSON.parse(raw);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Creates a root-layout fixture suitable for cmdMilestoneComplete.
46
+ * Root layout: no PROJECTS.md/REPOS.md (avoids v2 detection).
47
+ * Includes config.local.json with planningRoot, STATE.md, ROADMAP.md, phases/.
48
+ */
49
+ function createMilestoneFixture(extras) {
50
+ const base = {
51
+ 'config.json': JSON.stringify({}),
52
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
53
+ 'STATE.md': '# Project State\n\nPhase: 1\nStatus: Ready\nProgress: [----------] 0%\n',
54
+ 'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
55
+ 'phases/': null,
56
+ };
57
+ if (extras) {
58
+ Object.assign(base, extras);
59
+ }
60
+ return createFixture(base);
61
+ }
62
+
63
+ // ─── Quick Dir Archival Tests ────────────────────────────────────────────────
64
+
65
+ describe('cmdMilestoneComplete quick dir archival', () => {
66
+ let fixture;
67
+
68
+ afterEach(() => {
69
+ if (fixture) fixture.cleanup();
70
+ });
71
+
72
+ it('archives completed quick dirs (with SUMMARY.md) to milestones/v1.0-quick/', () => {
73
+ fixture = createMilestoneFixture({
74
+ 'quick/260101-abc-some-task/260101-abc-PLAN.md': '# Plan',
75
+ 'quick/260101-abc-some-task/260101-abc-SUMMARY.md': '# Summary',
76
+ });
77
+ const cwd = fixture.cwd;
78
+
79
+ const result = captureResult(() => {
80
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
81
+ });
82
+
83
+ // Quick dir should be moved to milestones/v1.0-quick/
84
+ const archivedDir = path.join(cwd, 'milestones', 'v1.0-quick', '260101-abc-some-task');
85
+ assert.ok(fs.existsSync(archivedDir), 'Quick dir should be archived to milestones/v1.0-quick/');
86
+ assert.ok(!fs.existsSync(path.join(cwd, 'quick', '260101-abc-some-task')), 'Quick dir should be removed from quick/');
87
+ assert.ok(result, 'Should return result JSON');
88
+ assert.equal(result.archived.quick, true, 'archived.quick should be true');
89
+ });
90
+
91
+ it('does not move HISTORY.md from quick/', () => {
92
+ fixture = createMilestoneFixture({
93
+ 'quick/HISTORY.md': '# History\n',
94
+ 'quick/260102-def-other/260102-def-SUMMARY.md': '# Summary',
95
+ });
96
+ const cwd = fixture.cwd;
97
+
98
+ const result = captureResult(() => {
99
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
100
+ });
101
+
102
+ // HISTORY.md should remain in quick/
103
+ assert.ok(fs.existsSync(path.join(cwd, 'quick', 'HISTORY.md')), 'HISTORY.md should stay in quick/');
104
+ // The completed dir should be archived
105
+ assert.ok(fs.existsSync(path.join(cwd, 'milestones', 'v1.0-quick', '260102-def-other')), 'Completed dir should be archived');
106
+ });
107
+
108
+ it('skips plain files (non-directories) in quick/', () => {
109
+ fixture = createMilestoneFixture({
110
+ 'quick/some-notes.txt': 'random notes',
111
+ 'quick/260103-ghi-task/260103-ghi-SUMMARY.md': '# Summary',
112
+ });
113
+ const cwd = fixture.cwd;
114
+
115
+ const result = captureResult(() => {
116
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
117
+ });
118
+
119
+ // File should remain
120
+ assert.ok(fs.existsSync(path.join(cwd, 'quick', 'some-notes.txt')), 'Plain files should remain in quick/');
121
+ // Dir should be archived
122
+ assert.ok(fs.existsSync(path.join(cwd, 'milestones', 'v1.0-quick', '260103-ghi-task')), 'Completed dir should be archived');
123
+ });
124
+
125
+ it('does NOT move quick dirs without SUMMARY.md (incomplete)', () => {
126
+ fixture = createMilestoneFixture({
127
+ 'quick/260104-jkl-wip/260104-jkl-PLAN.md': '# Plan',
128
+ });
129
+ const cwd = fixture.cwd;
130
+
131
+ const result = captureResult(() => {
132
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
133
+ });
134
+
135
+ // Should NOT be moved
136
+ assert.ok(fs.existsSync(path.join(cwd, 'quick', '260104-jkl-wip')), 'Incomplete quick dir should stay in quick/');
137
+ assert.ok(!fs.existsSync(path.join(cwd, 'milestones', 'v1.0-quick')), 'No quick archive dir should be created');
138
+ assert.ok(result, 'Should return result JSON');
139
+ assert.equal(result.archived.quick, false, 'archived.quick should be false when nothing archived');
140
+ });
141
+
142
+ it('gracefully handles when quick/ does not exist', () => {
143
+ fixture = createMilestoneFixture();
144
+ const cwd = fixture.cwd;
145
+
146
+ // No quick/ directory at all
147
+ const result = captureResult(() => {
148
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
149
+ });
150
+
151
+ assert.ok(result, 'Should return result JSON without error');
152
+ assert.equal(result.archived.quick, false, 'archived.quick should be false when no quick/ dir');
153
+ });
154
+
155
+ it('returns quickArchived=false when quick/ has no completed dirs', () => {
156
+ fixture = createMilestoneFixture({
157
+ 'quick/HISTORY.md': '# History',
158
+ 'quick/260105-mno-wip/260105-mno-PLAN.md': '# Plan',
159
+ });
160
+ const cwd = fixture.cwd;
161
+
162
+ const result = captureResult(() => {
163
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
164
+ });
165
+
166
+ assert.equal(result.archived.quick, false, 'archived.quick should be false');
167
+ assert.ok(!fs.existsSync(path.join(cwd, 'milestones', 'v1.0-quick')), 'No quick archive dir should be created');
168
+ });
169
+
170
+ it('creates milestones/v{X.Y}-quick/ only when there are dirs to archive', () => {
171
+ fixture = createMilestoneFixture({
172
+ 'quick/260106-pqr-first/260106-pqr-SUMMARY.md': '# Summary',
173
+ 'quick/260107-stu-second/260107-stu-SUMMARY.md': '# Summary',
174
+ 'quick/260108-vwx-wip/260108-vwx-PLAN.md': '# Plan',
175
+ });
176
+ const cwd = fixture.cwd;
177
+
178
+ const result = captureResult(() => {
179
+ cmdMilestoneComplete(cwd, 'v2.0', {}, false);
180
+ });
181
+
182
+ // Both completed dirs should be archived
183
+ assert.ok(fs.existsSync(path.join(cwd, 'milestones', 'v2.0-quick', '260106-pqr-first')), 'First completed dir archived');
184
+ assert.ok(fs.existsSync(path.join(cwd, 'milestones', 'v2.0-quick', '260107-stu-second')), 'Second completed dir archived');
185
+ // Incomplete dir should remain
186
+ assert.ok(fs.existsSync(path.join(cwd, 'quick', '260108-vwx-wip')), 'Incomplete dir should stay');
187
+ assert.equal(result.archived.quick, true, 'archived.quick should be true');
188
+ });
189
+
190
+ it('result JSON includes archived.quick field', () => {
191
+ fixture = createMilestoneFixture();
192
+ const cwd = fixture.cwd;
193
+
194
+ const result = captureResult(() => {
195
+ cmdMilestoneComplete(cwd, 'v1.0', {}, false);
196
+ });
197
+
198
+ assert.ok(result, 'Should return result');
199
+ assert.ok('archived' in result, 'Result should have archived object');
200
+ assert.ok('quick' in result.archived, 'archived should have quick field');
201
+ assert.equal(typeof result.archived.quick, 'boolean', 'archived.quick should be boolean');
202
+ });
203
+ });