@ktpartners/dgs-platform 2.9.0 → 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 (166) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +34 -2
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +61 -3
  6. package/agents/dgs-planner.md +51 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/abandon-quick.md +28 -0
  9. package/commands/dgs/add-tests.md +2 -2
  10. package/commands/dgs/audit-milestone.md +4 -3
  11. package/commands/dgs/capture-principle.md +11 -11
  12. package/commands/dgs/cleanup.md +2 -2
  13. package/commands/dgs/complete-milestone.md +11 -11
  14. package/commands/dgs/complete-quick.md +28 -0
  15. package/commands/dgs/create-milestone-job.md +2 -2
  16. package/commands/dgs/debug.md +3 -3
  17. package/commands/dgs/develop-idea.md +1 -1
  18. package/commands/dgs/diff-report.md +124 -0
  19. package/commands/dgs/fast.md +3 -1
  20. package/commands/dgs/health.md +1 -1
  21. package/commands/dgs/map-codebase.md +6 -6
  22. package/commands/dgs/new-milestone.md +5 -5
  23. package/commands/dgs/new-project.md +8 -21
  24. package/commands/dgs/package-scan.md +43 -0
  25. package/commands/dgs/plan-milestone-gaps.md +1 -1
  26. package/commands/dgs/progress.md +3 -3
  27. package/commands/dgs/quick-abandon.md +8 -0
  28. package/commands/dgs/quick-complete.md +8 -0
  29. package/commands/dgs/quick.md +10 -3
  30. package/commands/dgs/research-idea.md +3 -2
  31. package/commands/dgs/research-phase.md +3 -3
  32. package/commands/dgs/switch-project.md +14 -1
  33. package/commands/dgs/write-spec.md +3 -3
  34. package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
  35. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  36. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  37. package/deliver-great-systems/bin/lib/commands.cjs +626 -46
  38. package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
  39. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  40. package/deliver-great-systems/bin/lib/config.cjs +80 -6
  41. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  42. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  43. package/deliver-great-systems/bin/lib/core.cjs +35 -14
  44. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  45. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  46. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  47. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  48. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  49. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  50. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  51. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  52. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  53. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  54. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  55. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  56. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  57. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  58. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  59. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  60. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  61. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  62. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  63. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  64. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  65. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  66. package/deliver-great-systems/bin/lib/init.cjs +357 -61
  67. package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
  68. package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
  69. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  70. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  71. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  72. package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
  73. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  74. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  75. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  76. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  77. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  78. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  79. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  80. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  81. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  82. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  83. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  84. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  85. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  86. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  87. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  88. package/deliver-great-systems/bin/lib/phase.cjs +146 -3
  89. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  90. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  91. package/deliver-great-systems/bin/lib/projects.cjs +65 -10
  92. package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
  93. package/deliver-great-systems/bin/lib/quick.cjs +739 -0
  94. package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
  95. package/deliver-great-systems/bin/lib/repos.cjs +37 -13
  96. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  97. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  98. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  99. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  100. package/deliver-great-systems/bin/lib/state.cjs +147 -55
  101. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  102. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  103. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  104. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  105. package/deliver-great-systems/bin/lib/verify.cjs +198 -7
  106. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  107. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  108. package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
  109. package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
  110. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  111. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  112. package/deliver-great-systems/references/context-tiers.md +4 -0
  113. package/deliver-great-systems/references/package-scan-config.md +151 -0
  114. package/deliver-great-systems/references/questioning.md +0 -30
  115. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  116. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  117. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  118. package/deliver-great-systems/templates/REVIEW.md +35 -0
  119. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  120. package/deliver-great-systems/templates/claude-md.md +27 -0
  121. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  122. package/deliver-great-systems/templates/project.md +6 -170
  123. package/deliver-great-systems/templates/summary.md +3 -1
  124. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  125. package/deliver-great-systems/workflows/add-idea.md +3 -3
  126. package/deliver-great-systems/workflows/add-phase.md +5 -0
  127. package/deliver-great-systems/workflows/add-tests.md +14 -0
  128. package/deliver-great-systems/workflows/add-todo.md +1 -0
  129. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  130. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  131. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  132. package/deliver-great-systems/workflows/cancel-job.md +2 -2
  133. package/deliver-great-systems/workflows/check-todos.md +2 -3
  134. package/deliver-great-systems/workflows/codereview.md +103 -9
  135. package/deliver-great-systems/workflows/complete-milestone.md +218 -24
  136. package/deliver-great-systems/workflows/complete-quick.md +106 -0
  137. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  138. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  139. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  140. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  141. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  142. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  143. package/deliver-great-systems/workflows/execute-phase.md +209 -33
  144. package/deliver-great-systems/workflows/execute-plan.md +22 -22
  145. package/deliver-great-systems/workflows/help.md +53 -20
  146. package/deliver-great-systems/workflows/import-spec.md +65 -7
  147. package/deliver-great-systems/workflows/init-product.md +45 -167
  148. package/deliver-great-systems/workflows/new-milestone.md +140 -33
  149. package/deliver-great-systems/workflows/new-project.md +60 -331
  150. package/deliver-great-systems/workflows/package-scan.md +59 -0
  151. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  152. package/deliver-great-systems/workflows/progress-all.md +133 -0
  153. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  154. package/deliver-great-systems/workflows/quick-complete.md +106 -0
  155. package/deliver-great-systems/workflows/quick.md +328 -26
  156. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  157. package/deliver-great-systems/workflows/research-idea.md +77 -139
  158. package/deliver-great-systems/workflows/resume-project.md +2 -2
  159. package/deliver-great-systems/workflows/run-job.md +29 -43
  160. package/deliver-great-systems/workflows/settings.md +13 -77
  161. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  162. package/deliver-great-systems/workflows/verify-work.md +14 -0
  163. package/deliver-great-systems/workflows/write-spec.md +11 -13
  164. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  165. package/package.json +1 -1
  166. package/scripts/build-hooks.js +1 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * RED test scaffold for REL-01 (Phase 156 plan 02).
3
+ *
4
+ * The `verifyPlanCommit` helper is implemented in plan 02; running this
5
+ * file before plan 02 lands MUST produce 6 failed tests with
6
+ * 'not yet implemented — REL-01' style messages.
7
+ *
8
+ * Behaviour under test:
9
+ * - happy path: planner-reported created_files match HEAD; returns
10
+ * { ok: true, hash: '<short>', missing: [] }
11
+ * - missing-file: planner reports a file not on disk → returns
12
+ * { ok: false, exitLabel: 'plan-commit-incomplete', missing: [...] }
13
+ * - commit_docs:false: silently treated as success
14
+ * ({ ok: true, hash: null, reason: 'skipped_commit_docs_false' })
15
+ * - empty createdFiles: returns plan-commit-incomplete WITHOUT calling
16
+ * cmdCommit (defends against the cmdCommit `['.']` fallback at
17
+ * bin/lib/commands.cjs:321 — Hypothesis C from 156-Q1-FINDINGS.md)
18
+ * - commit failure (cmdCommit returns committed:false with
19
+ * non-skipped reason): returns plan-commit-incomplete
20
+ * - working tree unchanged on plan-commit-incomplete: snapshot of
21
+ * git status --porcelain before/after the failed call MUST match
22
+ *
23
+ * Conventions:
24
+ * - Uses node:test runner + node:assert (matches state-transition-gate.test.cjs)
25
+ * - Each test creates and tears down its own temp git repo via os.tmpdir()
26
+ * - Until plan 02 lands, every test fails with 'not yet implemented'
27
+ * so the file is RED in a controlled way (no parse errors).
28
+ */
29
+
30
+ const test = require('node:test');
31
+ const assert = require('node:assert');
32
+ const fs = require('fs');
33
+ const os = require('os');
34
+ const path = require('path');
35
+ const { execSync } = require('child_process');
36
+
37
+ const NOT_IMPL = 'verifyPlanCommit not yet implemented — REL-01';
38
+
39
+ // ─── Helpers ──────────────────────────────────────────────────────────────
40
+
41
+ function tryRequireCommands() {
42
+ try {
43
+ // Reset the planning-root cache between tests so each makeTempRepo()
44
+ // sees a fresh root; getPlanningRoot caches per-process and would
45
+ // otherwise pin the first temp dir for the whole test run.
46
+ try { require('./paths.cjs').resetPaths(); } catch { /* paths module may not load if commands fails */ }
47
+ return require('./commands.cjs');
48
+ } catch (err) {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function makeTempRepo() {
54
+ // Resolve real path to handle symlink wrap on macOS (/tmp -> /private/tmp).
55
+ // getPlanningRoot returns the symlink-resolved path; loadConfig later
56
+ // composes it with config.json — they MUST match.
57
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'commit-verify-test-')));
58
+ execSync('git init --quiet', { cwd: dir });
59
+ execSync('git config user.email test@example.com', { cwd: dir });
60
+ execSync('git config user.name "Test User"', { cwd: dir });
61
+ // Create a config.json with commit_docs: true for verifyPlanCommit's loadConfig path.
62
+ // Stage and commit it FIRST so the seed commit exists, then any later
63
+ // overwrite of config.json (test 3) is just an unstaged edit on top.
64
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ commit_docs: true }, null, 2));
65
+ fs.writeFileSync(path.join(dir, 'README.md'), '# seed\n');
66
+ execSync('git add README.md config.json', { cwd: dir });
67
+ execSync('git commit --quiet -m "seed"', { cwd: dir });
68
+ return dir;
69
+ }
70
+
71
+ function cleanupRepo(dir) {
72
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
73
+ }
74
+
75
+ function porcelain(cwd) {
76
+ return execSync('git status --porcelain', { cwd }).toString();
77
+ }
78
+
79
+ // ─── Test 1: happy path ────────────────────────────────────────────────────
80
+
81
+ test('REL-01 happy path: orchestrator commits planner-reported created_files and verification passes', () => {
82
+ const cmds = tryRequireCommands();
83
+ if (!cmds || typeof cmds.verifyPlanCommit !== 'function') {
84
+ assert.fail(NOT_IMPL);
85
+ return;
86
+ }
87
+ const repo = makeTempRepo();
88
+ try {
89
+ const created = ['plan-01.md', 'plan-02.md', 'plan-03.md'];
90
+ for (const f of created) fs.writeFileSync(path.join(repo, f), '# ' + f + '\n');
91
+ const result = cmds.verifyPlanCommit(repo, {
92
+ message: 'docs(99): create phase plan',
93
+ createdFiles: created,
94
+ }, true);
95
+ assert.strictEqual(result.ok, true, 'expected ok:true');
96
+ assert.match(result.hash || '', /^[0-9a-f]{6,}/, 'expected short hash');
97
+ assert.deepStrictEqual(result.missing || [], [], 'no missing files expected');
98
+ } finally {
99
+ cleanupRepo(repo);
100
+ }
101
+ });
102
+
103
+ // ─── Test 2: missing-file ──────────────────────────────────────────────────
104
+
105
+ test('REL-01 missing-file: planner reported a path that is not in the commit returns plan-commit-incomplete', () => {
106
+ const cmds = tryRequireCommands();
107
+ if (!cmds || typeof cmds.verifyPlanCommit !== 'function') {
108
+ assert.fail(NOT_IMPL);
109
+ return;
110
+ }
111
+ const repo = makeTempRepo();
112
+ try {
113
+ // Only 2 of 3 reported files exist on disk
114
+ fs.writeFileSync(path.join(repo, 'plan-01.md'), 'a\n');
115
+ fs.writeFileSync(path.join(repo, 'plan-02.md'), 'b\n');
116
+ const created = ['plan-01.md', 'plan-02.md', 'plan-03.md'];
117
+ const result = cmds.verifyPlanCommit(repo, {
118
+ message: 'docs(99): create phase plan',
119
+ createdFiles: created,
120
+ }, true);
121
+ assert.strictEqual(result.ok, false, 'expected ok:false');
122
+ assert.strictEqual(result.exitLabel, 'plan-commit-incomplete');
123
+ assert.ok(Array.isArray(result.missing) && result.missing.length > 0, 'expected missing array');
124
+ assert.ok(result.missing.includes('plan-03.md'), 'missing should list plan-03.md');
125
+ } finally {
126
+ cleanupRepo(repo);
127
+ }
128
+ });
129
+
130
+ // ─── Test 3: commit_docs:false silent success ──────────────────────────────
131
+
132
+ test('REL-01 commit_docs:false skipped is treated as success silently (NOT plan-commit-incomplete)', () => {
133
+ const cmds = tryRequireCommands();
134
+ if (!cmds || typeof cmds.verifyPlanCommit !== 'function') {
135
+ assert.fail(NOT_IMPL);
136
+ return;
137
+ }
138
+ const repo = makeTempRepo();
139
+ try {
140
+ // Override config to disable commit_docs
141
+ fs.writeFileSync(path.join(repo, 'config.json'), JSON.stringify({ commit_docs: false }));
142
+ fs.writeFileSync(path.join(repo, 'plan-01.md'), 'a\n');
143
+ const result = cmds.verifyPlanCommit(repo, {
144
+ message: 'docs(99): create phase plan',
145
+ createdFiles: ['plan-01.md'],
146
+ }, true);
147
+ assert.strictEqual(result.ok, true, 'commit_docs:false MUST NOT be treated as plan-commit-incomplete');
148
+ assert.strictEqual(result.hash, null);
149
+ assert.strictEqual(result.reason, 'skipped_commit_docs_false');
150
+ } finally {
151
+ cleanupRepo(repo);
152
+ }
153
+ });
154
+
155
+ // ─── Test 4: empty createdFiles list ───────────────────────────────────────
156
+
157
+ test('REL-01 empty created_files list exits plan-commit-incomplete (no [.] fallback)', () => {
158
+ const cmds = tryRequireCommands();
159
+ if (!cmds || typeof cmds.verifyPlanCommit !== 'function') {
160
+ assert.fail(NOT_IMPL);
161
+ return;
162
+ }
163
+ const repo = makeTempRepo();
164
+ try {
165
+ // Add an extra dirty file in the working tree to detect a `['.']` sweep
166
+ // — if the helper falls back to `['.']` the commit would include this
167
+ // unrelated file. The helper must reject empty createdFiles BEFORE
168
+ // any cmdCommit call.
169
+ fs.writeFileSync(path.join(repo, 'unrelated-edit.md'), 'should not be committed\n');
170
+ const before = porcelain(repo);
171
+ const result = cmds.verifyPlanCommit(repo, {
172
+ message: 'docs(99): create phase plan',
173
+ createdFiles: [],
174
+ }, true);
175
+ assert.strictEqual(result.ok, false);
176
+ assert.strictEqual(result.exitLabel, 'plan-commit-incomplete');
177
+ assert.strictEqual(result.reason, 'empty_created_files');
178
+ // Working tree must be unchanged — no commit should have happened.
179
+ const after = porcelain(repo);
180
+ assert.strictEqual(after, before, 'working tree must be unchanged after empty-list rejection');
181
+ } finally {
182
+ cleanupRepo(repo);
183
+ }
184
+ });
185
+
186
+ // ─── Test 5: cmdCommit returns committed:false with non-skipped reason ────
187
+
188
+ test('REL-01 cmdCommit returns committed:false with non-skipped reason exits plan-commit-incomplete', () => {
189
+ const cmds = tryRequireCommands();
190
+ if (!cmds || typeof cmds.verifyPlanCommit !== 'function') {
191
+ assert.fail(NOT_IMPL);
192
+ return;
193
+ }
194
+ const repo = makeTempRepo();
195
+ try {
196
+ // The reported file does not exist on disk, so cmdCommit's `git add`
197
+ // will fail and the commit will resolve as nothing_to_commit /
198
+ // commit_failed. verifyPlanCommit must surface this as
199
+ // plan-commit-incomplete.
200
+ const result = cmds.verifyPlanCommit(repo, {
201
+ message: 'docs(99): create phase plan',
202
+ createdFiles: ['nonexistent-plan.md'],
203
+ }, true);
204
+ assert.strictEqual(result.ok, false);
205
+ assert.strictEqual(result.exitLabel, 'plan-commit-incomplete');
206
+ } finally {
207
+ cleanupRepo(repo);
208
+ }
209
+ });
210
+
211
+ // ─── Test 6: working tree unchanged on plan-commit-incomplete ─────────────
212
+
213
+ test('REL-01 working tree unchanged on plan-commit-incomplete', () => {
214
+ const cmds = tryRequireCommands();
215
+ if (!cmds || typeof cmds.verifyPlanCommit !== 'function') {
216
+ assert.fail(NOT_IMPL);
217
+ return;
218
+ }
219
+ const repo = makeTempRepo();
220
+ try {
221
+ // One reported file that does not exist on disk
222
+ const before = porcelain(repo);
223
+ const result = cmds.verifyPlanCommit(repo, {
224
+ message: 'docs(99): create phase plan',
225
+ createdFiles: ['missing.md'],
226
+ }, true);
227
+ assert.strictEqual(result.ok, false);
228
+ const after = porcelain(repo);
229
+ assert.strictEqual(after, before, 'porcelain output must match before/after');
230
+ // No new commit was created (HEAD still points at seed)
231
+ const log = execSync('git log --oneline', { cwd: repo }).toString().trim().split('\n');
232
+ assert.strictEqual(log.length, 1, 'no new commit expected after plan-commit-incomplete');
233
+ } finally {
234
+ cleanupRepo(repo);
235
+ }
236
+ });
@@ -26,6 +26,7 @@ const LOCAL_KEYS = new Set([
26
26
  'planningRoot',
27
27
  'v2_hint_shown',
28
28
  'sync_hint_shown',
29
+ 'execution',
29
30
  ]);
30
31
 
31
32
  const VALID_CONFIG_KEYS = new Set([
@@ -34,11 +35,28 @@ const VALID_CONFIG_KEYS = new Set([
34
35
  'workflow.research', 'workflow.plan_check', 'workflow.verifier',
35
36
  'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
36
37
  'workflow._auto_chain_active', 'workflow.discipline', 'workflow.codereview',
37
- 'git.base_branch', 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template',
38
+ 'workflow.four_eyes',
39
+ 'git.base_branch',
38
40
  'git.sync', 'git.sync_push', 'git.sync_pull',
39
41
  'planning.commit_docs', 'planning.search_gitignored',
42
+ 'testing.packages.tool',
43
+ 'testing.packages.severity_threshold',
44
+ 'testing.packages.include_dev_dependencies',
45
+ 'testing.packages.timeout_seconds',
46
+ // UAT Bug 2: Snyk org UUID for multi-org accounts. Free-form string or null.
47
+ // Not local-only — shareable setting; config.local.json still wins via _readPackagesConfig merge.
48
+ 'testing.packages.snyk_org',
40
49
  ]);
41
50
 
51
+ /** Enum: valid values for testing.packages.tool. Pinned to Phase 150 cascade. */
52
+ const VALID_PACKAGES_TOOL = new Set(['auto', 'snyk', 'osv', 'native']);
53
+
54
+ /** Enum: valid values for testing.packages.severity_threshold. */
55
+ const VALID_PACKAGES_SEVERITY = new Set(['critical', 'high', 'medium', 'low']);
56
+
57
+ /** Keys rejected by cmdConfigSet -- require cmdConfigLocalSet (secrets / machine-specific). */
58
+ const LOCAL_ONLY_KEYS = new Set(['testing.packages.snyk_token']);
59
+
42
60
  /**
43
61
  * Get the path to the shared (tracked) config file.
44
62
  *
@@ -161,9 +179,6 @@ function cmdConfigEnsureSection(cwd, raw) {
161
179
  model_profile: 'balanced',
162
180
  commit_docs: true,
163
181
  search_gitignored: false,
164
- branching_strategy: 'none',
165
- phase_branch_template: 'dgs/{project}/phase-{phase}-{slug}',
166
- milestone_branch_template: 'dgs/{project}/{milestone}-{slug}',
167
182
  base_branch: 'main',
168
183
  workflow: {
169
184
  research: true,
@@ -173,8 +188,8 @@ function cmdConfigEnsureSection(cwd, raw) {
173
188
  discipline: true,
174
189
  },
175
190
  git: {
176
- sync_push: 'prompt',
177
- sync_pull: 'prompt',
191
+ sync_push: 'auto',
192
+ sync_pull: 'auto',
178
193
  },
179
194
  parallelization: true,
180
195
  brave_search: hasBraveSearch,
@@ -205,6 +220,12 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
205
220
  error('Usage: config-set <key.path> <value>');
206
221
  }
207
222
 
223
+ // LOCAL_ONLY guard -- runs BEFORE the unknown-key check so the error is precise
224
+ // (PKG-19: testing.packages.snyk_token is a secret; must route through config-local-set)
225
+ if (LOCAL_ONLY_KEYS.has(keyPath)) {
226
+ error(`"${keyPath}" is a local-only key. Use: dgs-tools config-local-set ${keyPath} <value>`);
227
+ }
228
+
208
229
  if (!VALID_CONFIG_KEYS.has(keyPath)) {
209
230
  error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}`);
210
231
  }
@@ -215,6 +236,24 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
215
236
  error(`Invalid sync mode: "${value}". Valid values: off, prompt, auto`);
216
237
  }
217
238
 
239
+ // PKG-19: testing.packages.tool enum validator
240
+ if (keyPath === 'testing.packages.tool' && !VALID_PACKAGES_TOOL.has(value)) {
241
+ error(`Invalid packages tool: "${value}". Valid values: auto, snyk, osv, native`);
242
+ }
243
+
244
+ // PKG-19: testing.packages.severity_threshold enum validator
245
+ if (keyPath === 'testing.packages.severity_threshold' && !VALID_PACKAGES_SEVERITY.has(value)) {
246
+ error(`Invalid packages severity: "${value}". Valid values: critical, high, medium, low`);
247
+ }
248
+
249
+ // PKG-19: testing.packages.timeout_seconds integer range validator [10, 3600]
250
+ if (keyPath === 'testing.packages.timeout_seconds') {
251
+ const n = Number(value);
252
+ if (!Number.isInteger(n) || n < 10 || n > 3600) {
253
+ error(`Invalid packages timeout: "${value}". Must be integer between 10 and 3600 seconds`);
254
+ }
255
+ }
256
+
218
257
  // Parse value (handle booleans and numbers)
219
258
  let parsedValue = value;
220
259
  if (value === 'true') parsedValue = true;
@@ -265,6 +304,40 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
265
304
  }
266
305
  }
267
306
 
307
+ /**
308
+ * CLI: Set a key in config.local.json (no VALID_CONFIG_KEYS gate).
309
+ * Used by workflows to write local-only fields like execution.active_context.
310
+ */
311
+ function cmdConfigLocalSet(cwd, keyPath, value, raw) {
312
+ if (!keyPath) {
313
+ error('Usage: config-local-set <key.path> <value>');
314
+ }
315
+
316
+ let parsedValue = value;
317
+ if (value === 'true') parsedValue = true;
318
+ else if (value === 'false') parsedValue = false;
319
+ else if (value === 'null') parsedValue = null;
320
+ else if (!isNaN(value) && value !== '') parsedValue = Number(value);
321
+
322
+ const localPath = getLocalConfigPath(cwd);
323
+ let config = _readJsonSafe(localPath);
324
+
325
+ const keys = keyPath.split('.');
326
+ let current = config;
327
+ for (let i = 0; i < keys.length - 1; i++) {
328
+ const key = keys[i];
329
+ if (current[key] === undefined || typeof current[key] !== 'object') {
330
+ current[key] = {};
331
+ }
332
+ current = current[key];
333
+ }
334
+ current[keys[keys.length - 1]] = parsedValue;
335
+
336
+ _writeJson(localPath, config);
337
+ const result = { updated: true, key: keyPath, value: parsedValue, file: 'config.local.json' };
338
+ output(result, raw, `${keyPath}=${parsedValue}`);
339
+ }
340
+
268
341
  function cmdConfigGet(cwd, keyPath, raw) {
269
342
  if (!keyPath) {
270
343
  error('Usage: config-get <key.path>');
@@ -520,6 +593,7 @@ module.exports = {
520
593
  getReviewKeysPath,
521
594
  cmdConfigEnsureSection,
522
595
  cmdConfigSet,
596
+ cmdConfigLocalSet,
523
597
  cmdConfigGet,
524
598
  writeConfigField,
525
599
  loadReviewConfig,
@@ -0,0 +1,309 @@
1
+ /**
2
+ * config.test.cjs -- Unit tests for config validators, including Phase 150
3
+ * testing.packages.* extensions (PKG-19).
4
+ *
5
+ * Phase 150 Plan 01 -- testing.packages.* config key validators + LOCAL_ONLY guard.
6
+ * Verifies:
7
+ * - four testing.packages.* keys accepted by VALID_CONFIG_KEYS
8
+ * - VALID_PACKAGES_TOOL enum validation (auto/snyk/osv/native)
9
+ * - VALID_PACKAGES_SEVERITY enum validation (critical/high/medium/low)
10
+ * - include_dev_dependencies boolean coercion
11
+ * - timeout_seconds integer range [10, 3600]
12
+ * - LOCAL_ONLY_KEYS guard redirects snyk_token to config-local-set
13
+ * - no regression on pre-existing keys (workflow.research, git.sync_push, unknown keys)
14
+ */
15
+ 'use strict';
16
+ const { test, describe, beforeEach, afterEach } = require('node:test');
17
+ const assert = require('node:assert');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+
22
+ const { execSync } = require('child_process');
23
+ const { cmdConfigSet, cmdConfigLocalSet } = require('./config.cjs');
24
+ const { loadConfig } = require('./core.cjs');
25
+ const { resetPaths } = require('./paths.cjs');
26
+
27
+ // ─── Harness ──────────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Intercept process.exit and stderr writes during a cmdConfig* call.
31
+ * Returns { exitCode, stderr, stdout, threw }.
32
+ *
33
+ * process.exit only throws for non-zero codes (to escape error()); for exit(0),
34
+ * we throw a distinct __EXIT0__ marker that cmdConfigSet's try/catch would
35
+ * otherwise intercept. To prevent that, we only throw on non-zero; exit(0) sets
36
+ * the flag and the output() call naturally returns to the caller via the thrown
37
+ * marker which we then swallow at the outermost try.
38
+ */
39
+ function interceptExit(fn) {
40
+ let exitCode = null;
41
+ let stderr = '';
42
+ let stdout = '';
43
+ let threw = null;
44
+ const origExit = process.exit;
45
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
46
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
47
+ process.exit = (code) => {
48
+ exitCode = code;
49
+ // For success exits (code 0) we return silently so cmdConfigSet's outer
50
+ // try/catch around _writeJson + output() doesn't mistake our throw for a
51
+ // write failure. For non-zero exits (triggered by error()), we throw a
52
+ // sentinel so the error call does not proceed further.
53
+ if (code === 0) return;
54
+ const err = new Error('__EXIT__' + code);
55
+ err.__isExitSentinel = true;
56
+ throw err;
57
+ };
58
+ process.stderr.write = (data) => { stderr += String(data); return true; };
59
+ process.stdout.write = (data) => { stdout += String(data); return true; };
60
+ try {
61
+ fn();
62
+ } catch (e) {
63
+ if (!e || !e.__isExitSentinel) {
64
+ threw = e;
65
+ }
66
+ } finally {
67
+ process.exit = origExit;
68
+ process.stderr.write = origStderrWrite;
69
+ process.stdout.write = origStdoutWrite;
70
+ }
71
+ return { exitCode, stderr, stdout, threw };
72
+ }
73
+
74
+ function makeTmpDir() {
75
+ // mkdtempSync + realpath resolves macOS /private/var -> /var symlinks so
76
+ // getPlanningRoot's git output matches the dir we're operating in.
77
+ const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-config-test-')));
78
+ // getPlanningRoot requires a git repo
79
+ execSync('git init -q', { cwd: d });
80
+ execSync('git config user.email "t@t" && git config user.name "t"', { cwd: d });
81
+ // Reset cached planning root so each test's fresh tmpDir is honoured
82
+ resetPaths();
83
+ return d;
84
+ }
85
+
86
+ function writeMinimalConfig(tmpDir) {
87
+ fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify({
88
+ current_project: 'testproj',
89
+ }, null, 2));
90
+ }
91
+
92
+ function readSharedConfig(tmpDir) {
93
+ const p = path.join(tmpDir, 'config.json');
94
+ if (!fs.existsSync(p)) return {};
95
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
96
+ }
97
+
98
+ function readLocalConfig(tmpDir) {
99
+ const p = path.join(tmpDir, 'config.local.json');
100
+ if (!fs.existsSync(p)) return {};
101
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
102
+ }
103
+
104
+ // ─── Tests ───────────────────────────────────────────────────────────────────
105
+
106
+ describe('cmdConfigSet - testing.packages.tool validator (PKG-19)', () => {
107
+ let tmpDir;
108
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
109
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
110
+
111
+ test('accepts "auto"', () => {
112
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.tool', 'auto', true));
113
+ assert.strictEqual(r.exitCode, 0);
114
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.tool, 'auto');
115
+ });
116
+
117
+ test('accepts "snyk"', () => {
118
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.tool', 'snyk', true));
119
+ assert.strictEqual(r.exitCode, 0);
120
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.tool, 'snyk');
121
+ });
122
+
123
+ test('accepts "osv"', () => {
124
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.tool', 'osv', true));
125
+ assert.strictEqual(r.exitCode, 0);
126
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.tool, 'osv');
127
+ });
128
+
129
+ test('accepts "native"', () => {
130
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.tool', 'native', true));
131
+ assert.strictEqual(r.exitCode, 0);
132
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.tool, 'native');
133
+ });
134
+
135
+ test('rejects "bogus"', () => {
136
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.tool', 'bogus', true));
137
+ assert.strictEqual(r.exitCode, 1);
138
+ assert.match(r.stderr, /Invalid packages tool/);
139
+ assert.match(r.stderr, /auto, snyk, osv, native/);
140
+ });
141
+
142
+ test('rejects empty string', () => {
143
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.tool', '', true));
144
+ assert.strictEqual(r.exitCode, 1);
145
+ assert.match(r.stderr, /Invalid packages tool/);
146
+ });
147
+ });
148
+
149
+ describe('cmdConfigSet - testing.packages.severity_threshold validator (PKG-19)', () => {
150
+ let tmpDir;
151
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
152
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
153
+
154
+ test('accepts "critical"', () => {
155
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.severity_threshold', 'critical', true));
156
+ assert.strictEqual(r.exitCode, 0);
157
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.severity_threshold, 'critical');
158
+ });
159
+
160
+ test('accepts "high"', () => {
161
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.severity_threshold', 'high', true));
162
+ assert.strictEqual(r.exitCode, 0);
163
+ });
164
+
165
+ test('accepts "medium"', () => {
166
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.severity_threshold', 'medium', true));
167
+ assert.strictEqual(r.exitCode, 0);
168
+ });
169
+
170
+ test('accepts "low"', () => {
171
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.severity_threshold', 'low', true));
172
+ assert.strictEqual(r.exitCode, 0);
173
+ });
174
+
175
+ test('rejects "wat"', () => {
176
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.severity_threshold', 'wat', true));
177
+ assert.strictEqual(r.exitCode, 1);
178
+ assert.match(r.stderr, /Invalid packages severity/);
179
+ assert.match(r.stderr, /critical, high, medium, low/);
180
+ });
181
+ });
182
+
183
+ describe('cmdConfigSet - testing.packages.include_dev_dependencies (PKG-19)', () => {
184
+ let tmpDir;
185
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
186
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
187
+
188
+ test('accepts "true" and stores as boolean true', () => {
189
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.include_dev_dependencies', 'true', true));
190
+ assert.strictEqual(r.exitCode, 0);
191
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.include_dev_dependencies, true);
192
+ });
193
+
194
+ test('accepts "false" and stores as boolean false', () => {
195
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.include_dev_dependencies', 'false', true));
196
+ assert.strictEqual(r.exitCode, 0);
197
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.include_dev_dependencies, false);
198
+ });
199
+ });
200
+
201
+ describe('cmdConfigSet - testing.packages.timeout_seconds range validator (PKG-19)', () => {
202
+ let tmpDir;
203
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
204
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
205
+
206
+ test('accepts 300 (default)', () => {
207
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.timeout_seconds', '300', true));
208
+ assert.strictEqual(r.exitCode, 0);
209
+ assert.strictEqual(readSharedConfig(tmpDir).testing.packages.timeout_seconds, 300);
210
+ });
211
+
212
+ test('accepts 10 (min)', () => {
213
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.timeout_seconds', '10', true));
214
+ assert.strictEqual(r.exitCode, 0);
215
+ });
216
+
217
+ test('accepts 3600 (max)', () => {
218
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.timeout_seconds', '3600', true));
219
+ assert.strictEqual(r.exitCode, 0);
220
+ });
221
+
222
+ test('rejects 5 (below min)', () => {
223
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.timeout_seconds', '5', true));
224
+ assert.strictEqual(r.exitCode, 1);
225
+ assert.match(r.stderr, /Invalid packages timeout/);
226
+ assert.match(r.stderr, /between 10 and 3600/);
227
+ });
228
+
229
+ test('rejects 7200 (above max)', () => {
230
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.timeout_seconds', '7200', true));
231
+ assert.strictEqual(r.exitCode, 1);
232
+ assert.match(r.stderr, /Invalid packages timeout/);
233
+ });
234
+
235
+ test('rejects non-integer string (e.g., "abc")', () => {
236
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.timeout_seconds', 'abc', true));
237
+ assert.strictEqual(r.exitCode, 1);
238
+ assert.match(r.stderr, /Invalid packages timeout/);
239
+ });
240
+ });
241
+
242
+ describe('cmdConfigSet - LOCAL_ONLY_KEYS guard for snyk_token (PKG-19, success criterion 5)', () => {
243
+ let tmpDir;
244
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
245
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
246
+
247
+ test('rejects testing.packages.snyk_token with local-only error message', () => {
248
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.snyk_token', 'abc123', true));
249
+ assert.strictEqual(r.exitCode, 1);
250
+ assert.match(r.stderr, /local-only/);
251
+ assert.match(r.stderr, /config-local-set/);
252
+ assert.match(r.stderr, /testing\.packages\.snyk_token/);
253
+ });
254
+
255
+ test('rejection message does NOT contain "Unknown config key" (guard fires BEFORE membership check)', () => {
256
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.snyk_token', 'abc123', true));
257
+ assert.strictEqual(r.exitCode, 1);
258
+ assert.doesNotMatch(r.stderr, /Unknown config key/);
259
+ });
260
+
261
+ test('rejection happens even when value is empty string', () => {
262
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.packages.snyk_token', '', true));
263
+ assert.strictEqual(r.exitCode, 1);
264
+ assert.match(r.stderr, /local-only/);
265
+ });
266
+ });
267
+
268
+ describe('cmdConfigLocalSet - accepts snyk_token unchanged', () => {
269
+ let tmpDir;
270
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
271
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
272
+
273
+ test('cmdConfigLocalSet writes snyk_token to config.local.json', () => {
274
+ const r = interceptExit(() => cmdConfigLocalSet(tmpDir, 'testing.packages.snyk_token', 'abc123', true));
275
+ assert.strictEqual(r.exitCode, 0);
276
+ const local = readLocalConfig(tmpDir);
277
+ assert.strictEqual(local.testing.packages.snyk_token, 'abc123');
278
+ });
279
+
280
+ test('value is readable back from config.local.json on disk', () => {
281
+ interceptExit(() => cmdConfigLocalSet(tmpDir, 'testing.packages.snyk_token', 'abc123', true));
282
+ const local = readLocalConfig(tmpDir);
283
+ assert.strictEqual(local.testing.packages.snyk_token, 'abc123');
284
+ });
285
+ });
286
+
287
+ describe('cmdConfigSet - no regression on existing keys', () => {
288
+ let tmpDir;
289
+ beforeEach(() => { tmpDir = makeTmpDir(); writeMinimalConfig(tmpDir); });
290
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
291
+
292
+ test('workflow.research still accepts true', () => {
293
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'workflow.research', 'true', true));
294
+ assert.strictEqual(r.exitCode, 0);
295
+ assert.strictEqual(readSharedConfig(tmpDir).workflow.research, true);
296
+ });
297
+
298
+ test('git.sync_push still rejects invalid sync mode', () => {
299
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'git.sync_push', 'bogus', true));
300
+ assert.strictEqual(r.exitCode, 1);
301
+ assert.match(r.stderr, /Invalid sync mode/);
302
+ });
303
+
304
+ test('Unknown key (e.g., testing.unknown) still rejects with "Unknown config key"', () => {
305
+ const r = interceptExit(() => cmdConfigSet(tmpDir, 'testing.unknown', 'x', true));
306
+ assert.strictEqual(r.exitCode, 1);
307
+ assert.match(r.stderr, /Unknown config key/);
308
+ });
309
+ });