@ktpartners/dgs-platform 3.3.1 → 3.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -1
  3. package/bin/install.js +1 -1
  4. package/commands/dgs/abandon-milestone.md +28 -0
  5. package/commands/dgs/new-milestone.md +3 -1
  6. package/deliver-great-systems/bin/dgs-tools.cjs +22 -4
  7. package/deliver-great-systems/bin/lib/context.cjs +45 -15
  8. package/deliver-great-systems/bin/lib/docs.cjs +73 -29
  9. package/deliver-great-systems/bin/lib/docs.test.cjs +49 -6
  10. package/deliver-great-systems/bin/lib/init.cjs +9 -3
  11. package/deliver-great-systems/bin/lib/init.test.cjs +61 -3
  12. package/deliver-great-systems/bin/lib/milestone.cjs +470 -2
  13. package/deliver-great-systems/bin/lib/milestone.test.cjs +653 -0
  14. package/deliver-great-systems/bin/lib/search.cjs +5 -16
  15. package/deliver-great-systems/bin/lib/state.cjs +152 -1
  16. package/deliver-great-systems/bin/lib/worktrees.cjs +182 -1
  17. package/deliver-great-systems/bin/lib/worktrees.test.cjs +409 -0
  18. package/deliver-great-systems/templates/claude-md.md +2 -0
  19. package/deliver-great-systems/templates/state.md +16 -0
  20. package/deliver-great-systems/workflows/abandon-milestone.md +120 -0
  21. package/deliver-great-systems/workflows/complete-milestone.md +58 -4
  22. package/deliver-great-systems/workflows/create-milestone-job.md +15 -0
  23. package/deliver-great-systems/workflows/execute-plan.md +1 -1
  24. package/deliver-great-systems/workflows/help.md +7 -0
  25. package/deliver-great-systems/workflows/init-product.md +8 -8
  26. package/deliver-great-systems/workflows/new-milestone.md +69 -0
  27. package/deliver-great-systems/workflows/progress.md +5 -1
  28. package/deliver-great-systems/workflows/run-job.md +23 -1
  29. package/hooks/dist/dgs-enforce-discipline.js +34 -1
  30. package/package.json +1 -1
@@ -11,6 +11,7 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { safeReadFile, output, error } = require('./core.cjs');
13
13
  const { getPlanningRoot } = require('./paths.cjs');
14
+ const { enumerateIdeaDocsDirs } = require('./docs.cjs');
14
15
 
15
16
  // ─── Fuzzy Matching Engine ──────────────────────────────────────────────────
16
17
 
@@ -330,22 +331,10 @@ function scanDocs(cwd) {
330
331
  const productDocsDir = path.join(planRoot, 'docs');
331
332
  scanDocsDir(productDocsDir, 'product', path.join(planRootRel, 'docs'));
332
333
 
333
- // Idea-scoped docs
334
- const ideaStates = ['pending', 'done', 'rejected'];
335
- for (const state of ideaStates) {
336
- const stateDir = path.join(planRoot, 'ideas', state);
337
- let ideaDirs;
338
- try {
339
- ideaDirs = fs.readdirSync(stateDir, { withFileTypes: true })
340
- .filter(e => e.isDirectory())
341
- .map(e => e.name);
342
- } catch {
343
- continue;
344
- }
345
- for (const ideaSlug of ideaDirs) {
346
- const docsDir = path.join(stateDir, ideaSlug, 'docs');
347
- scanDocsDir(docsDir, `idea:${ideaSlug}`, path.join(planRootRel, 'ideas', state, ideaSlug, 'docs'));
348
- }
334
+ // Idea-scoped docs — flat-aware via the shared enumerator (legacy dirs included)
335
+ for (const { ideaSlug, docsDir } of enumerateIdeaDocsDirs(cwd)) {
336
+ const rel = path.relative(cwd, docsDir);
337
+ scanDocsDir(docsDir, `idea:${ideaSlug}`, rel);
349
338
  }
350
339
 
351
340
  // Spec-scoped docs
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error, getProjectRoot } = require('./core.cjs');
8
- const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
8
+ const { extractFrontmatter, reconstructFrontmatter, spliceFrontmatter } = require('./frontmatter.cjs');
9
9
  const { getPlanningRoot } = require('./paths.cjs');
10
10
 
11
11
  // Escape special regex characters in a string
@@ -961,6 +961,151 @@ function cmdMarkMilestoneComplete(cwd, raw) {
961
961
  output(result, raw);
962
962
  }
963
963
 
964
+ // ─── Ad-hoc milestone marker (Phase 159, ADH-04 primary) ─────────────────────
965
+
966
+ /**
967
+ * Read the `adhoc` frontmatter marker from STATE.md.
968
+ *
969
+ * The marker is the primary, committed source of truth for whether the active
970
+ * milestone is an ad-hoc container. Degrades to `false` when STATE.md or the
971
+ * field is absent. (extractFrontmatter yields the string "true" for a bare
972
+ * boolean, so both the boolean and string form are accepted.)
973
+ *
974
+ * @param {string} cwd - Planning root directory
975
+ * @returns {boolean}
976
+ */
977
+ function stateReadAdhoc(cwd) {
978
+ const statePath = resolveStatePath(cwd);
979
+ if (!fs.existsSync(statePath)) return false;
980
+ const content = fs.readFileSync(statePath, 'utf-8');
981
+ const fm = extractFrontmatter(content);
982
+ return fm.adhoc === true || fm.adhoc === 'true';
983
+ }
984
+
985
+ /**
986
+ * Set (or clear) the `adhoc` frontmatter marker on STATE.md.
987
+ *
988
+ * Writes `adhoc: true` when `value` is truthy. When falsy, the key is deleted
989
+ * so normal (non-adhoc) milestones keep clean frontmatter. Body content is
990
+ * preserved via spliceFrontmatter (reconstruct-and-splice).
991
+ *
992
+ * @param {string} cwd - Planning root directory
993
+ * @param {boolean} value
994
+ * @returns {{ success: boolean, adhoc: boolean }}
995
+ */
996
+ function stateSetAdhoc(cwd, value) {
997
+ const statePath = resolveStatePath(cwd);
998
+ if (!fs.existsSync(statePath)) {
999
+ error('STATE.md not found');
1000
+ }
1001
+ const content = fs.readFileSync(statePath, 'utf-8');
1002
+ const fm = extractFrontmatter(content);
1003
+ if (value) {
1004
+ fm.adhoc = true;
1005
+ } else {
1006
+ delete fm.adhoc;
1007
+ }
1008
+ const newContent = spliceFrontmatter(content, fm);
1009
+ fs.writeFileSync(statePath, newContent, 'utf-8');
1010
+ return { success: true, adhoc: !!value };
1011
+ }
1012
+
1013
+ function cmdStateReadAdhoc(cwd, raw) {
1014
+ const adhoc = stateReadAdhoc(cwd);
1015
+ output({ adhoc }, raw, String(adhoc));
1016
+ }
1017
+
1018
+ function cmdStateSetAdhoc(cwd, args, raw) {
1019
+ // Default to setting true; accept an explicit false/0/no to clear.
1020
+ const arg = args && args.length > 0 ? String(args[0]).toLowerCase() : 'true';
1021
+ const value = !(arg === 'false' || arg === '0' || arg === 'no');
1022
+ const result = stateSetAdhoc(cwd, value);
1023
+ output(result, raw, String(result.adhoc));
1024
+ }
1025
+
1026
+ // ─── Ad-hoc relaxed-completion readiness (Phase 161, ADH-10/11/13) ────────────
1027
+
1028
+ /**
1029
+ * Count well-formed data rows in the active STATE.md "Quick Tasks Completed"
1030
+ * table. Mirrors the section regex used by _archiveSingleStateFile. A row counts
1031
+ * only when its id cell AND its commit cell are both non-empty; header,
1032
+ * separator, and malformed/partial rows are skipped. Handles both the 5-col
1033
+ * (no Status) and 6-col (with Status) layouts — the Commit column is the 4th
1034
+ * data column (cells[4]) in both.
1035
+ *
1036
+ * @param {string} statePath - Resolved STATE.md path
1037
+ * @returns {number}
1038
+ */
1039
+ function _countCompletedQuickRows(statePath) {
1040
+ if (!fs.existsSync(statePath)) return 0;
1041
+ const content = fs.readFileSync(statePath, 'utf-8');
1042
+ const m = content.match(/(###\s*Quick Tasks Completed\s*\n)([\s\S]*?)(?=\n###?\s|\n##[^#]|$)/i);
1043
+ if (!m) return 0;
1044
+ let count = 0;
1045
+ for (const line of m[2].split('\n')) {
1046
+ if (!line.startsWith('|')) continue;
1047
+ if (/^\|[\s-|]+$/.test(line)) continue; // separator
1048
+ if (line.includes('Description')) continue; // header
1049
+ const cells = line.split('|').map((c) => c.trim());
1050
+ // cells[0] is the empty pre-pipe cell; cells[1] = id, cells[4] = Commit.
1051
+ if (cells[1] && cells[4]) count += 1;
1052
+ }
1053
+ return count;
1054
+ }
1055
+
1056
+ /**
1057
+ * Count phases with disk_status === 'complete' for the active milestone.
1058
+ * Runs `roadmap analyze --raw` as a subprocess (cmdRoadmapAnalyze is
1059
+ * process-exiting) and reads `.completed_phases`. Defaults to 0 on any
1060
+ * exec/parse failure — a quicks-only ad-hoc milestone may have no phases dir.
1061
+ *
1062
+ * @param {string} cwd - Planning root directory
1063
+ * @returns {number}
1064
+ */
1065
+ function _countCompletedPhases(cwd) {
1066
+ try {
1067
+ const { execSync } = require('child_process');
1068
+ const toolsPath = path.resolve(__dirname, '..', 'dgs-tools.cjs');
1069
+ const out = execSync(
1070
+ 'node ' + JSON.stringify(toolsPath) + ' roadmap analyze --raw',
1071
+ { cwd, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
1072
+ );
1073
+ const parsed = JSON.parse(out.trim());
1074
+ return Number(parsed.completed_phases) || 0;
1075
+ } catch {
1076
+ return 0;
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Compute relaxed-completion readiness for the active milestone.
1082
+ *
1083
+ * - adhoc: the committed STATE.md marker (false when absent — ADH-11 fall-through
1084
+ * to the strict completion gate).
1085
+ * - completedQuickRows / completedPhases: the two minimum-bar signals (ADH-10).
1086
+ * A planned-but-incomplete phase and a malformed quick row are NOT counted.
1087
+ * - ready: true iff at least one merged unit of work exists.
1088
+ * - reason: 'ok' when ready, else 'no_merged_work' (drives the ADH-13 refusal).
1089
+ *
1090
+ * @param {string} cwd - Planning root directory
1091
+ * @returns {{ adhoc: boolean, completedQuickRows: number, completedPhases: number, ready: boolean, reason: string }}
1092
+ */
1093
+ function stateAdhocReadiness(cwd) {
1094
+ const adhoc = stateReadAdhoc(cwd);
1095
+ const completedQuickRows = _countCompletedQuickRows(resolveStatePath(cwd));
1096
+ const completedPhases = _countCompletedPhases(cwd);
1097
+ const ready = completedQuickRows >= 1 || completedPhases >= 1;
1098
+ return { adhoc, completedQuickRows, completedPhases, ready, reason: ready ? 'ok' : 'no_merged_work' };
1099
+ }
1100
+
1101
+ function cmdStateAdhocReadiness(cwd, raw) {
1102
+ const r = stateAdhocReadiness(cwd);
1103
+ // The predicate is a machine contract consumed by complete-milestone.md and
1104
+ // the test suite, which `JSON.parse` the --raw output. Emit compact JSON for
1105
+ // --raw and pretty JSON otherwise (never a lossy human string).
1106
+ output(r, raw, JSON.stringify(r));
1107
+ }
1108
+
964
1109
  module.exports = {
965
1110
  stateExtractField,
966
1111
  stateReplaceField,
@@ -982,4 +1127,10 @@ module.exports = {
982
1127
  cmdStateArchiveQuickTasks,
983
1128
  markMilestoneComplete,
984
1129
  cmdMarkMilestoneComplete,
1130
+ stateReadAdhoc,
1131
+ stateSetAdhoc,
1132
+ cmdStateReadAdhoc,
1133
+ cmdStateSetAdhoc,
1134
+ stateAdhocReadiness,
1135
+ cmdStateAdhocReadiness,
985
1136
  };
@@ -17,7 +17,7 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const crypto = require('crypto');
19
19
  const { execSync } = require('child_process');
20
- const { execGit, output, error, loadConfig } = require('./core.cjs');
20
+ const { execGit, output, error, loadConfig, generateSlugInternal } = require('./core.cjs');
21
21
  const { getLocalConfigPath } = require('./config.cjs');
22
22
  const { getPlanningRoot } = require('./paths.cjs');
23
23
  const { parseReposMd } = require('./repos.cjs');
@@ -152,6 +152,78 @@ function _sanitizeSlug(input) {
152
152
  .replace(/-+$/, '');
153
153
  }
154
154
 
155
+ /**
156
+ * Compose a structural milestone slug: `[<project>-]<version>-<name-slug>`.
157
+ *
158
+ * This replaces the legacy bare-name slug formula (`generateSlugInternal(name)
159
+ * || 'milestone'`) which collapsed a placeholder/blank milestone name to the
160
+ * bare `milestone` branch, causing cross-milestone branch-name collisions.
161
+ *
162
+ * The bare `'milestone'` slug is returned ONLY as the absolute last resort —
163
+ * when BOTH version and name are empty. A non-empty version always guarantees
164
+ * uniqueness, so a blank name yields the version segment alone (NOT 'milestone').
165
+ *
166
+ * @param {object} opts
167
+ * @param {string} [opts.version] - Milestone version (e.g. 'v25.0'); dots -> dashes.
168
+ * @param {string} [opts.name] - Milestone name (e.g. 'Ad-hoc Container').
169
+ * @param {string} [opts.project] - Project name (used only when multiProject).
170
+ * @param {boolean} [opts.multiProject] - Prefix with project when >1 active project.
171
+ * @returns {string} Sanitized slug, <=50 chars.
172
+ */
173
+ function composeMilestoneSlug(opts) {
174
+ opts = opts || {};
175
+ // Version dots -> dashes, then sanitized (so 'v25.0' -> 'v25-0'). May be ''.
176
+ const sanitizedVersion = _sanitizeSlug(String(opts.version || '').replace(/\./g, '-'));
177
+ const nameSlug = _sanitizeSlug(opts.name || '');
178
+ const projectSlug = (opts.multiProject && opts.project) ? _sanitizeSlug(opts.project) : null;
179
+
180
+ const segments = [projectSlug, sanitizedVersion || null, nameSlug || null].filter(Boolean);
181
+ const joined = segments.join('-');
182
+
183
+ // Deterministic last-resort: ONLY when there is genuinely nothing else
184
+ // (both version AND name empty). Never applied to a non-empty version.
185
+ if (!joined) return 'milestone';
186
+
187
+ // Final pass guarantees a clean, <=50-char slug.
188
+ return _sanitizeSlug(joined);
189
+ }
190
+
191
+ /**
192
+ * Resolve the runtime milestone slug for a project, preferring a stamped
193
+ * worktree entry and legacy-falling-back to the OLD bare-name slug so that
194
+ * in-flight milestones created before the structural-slug upgrade are never
195
+ * orphaned.
196
+ *
197
+ * Resolution order:
198
+ * 1. NEW composed slug has a worktree entry -> use it.
199
+ * 2. Else OLD bare-name slug has a worktree entry -> use it (legacy fallback).
200
+ * 3. Else (fresh, not yet created) -> use the NEW composed slug, so creation
201
+ * stamps the new formula.
202
+ *
203
+ * @param {string} cwd
204
+ * @param {string} project
205
+ * @param {object} opts - { version, name, multiProject }
206
+ * @returns {string}
207
+ */
208
+ function resolveMilestoneSlug(cwd, project, opts) {
209
+ opts = opts || {};
210
+ const newSlug = composeMilestoneSlug({
211
+ version: opts.version,
212
+ name: opts.name,
213
+ project: project,
214
+ multiProject: opts.multiProject,
215
+ });
216
+
217
+ if (_getWorktreeState(cwd, project, newSlug)) return newSlug;
218
+
219
+ // Legacy fallback: the OLD bare-name formula, sanitized identically to how
220
+ // historical keys were derived.
221
+ const oldSlug = _sanitizeSlug(generateSlugInternal(opts.name || '') || '');
222
+ if (oldSlug && _getWorktreeState(cwd, project, oldSlug)) return oldSlug;
223
+
224
+ return newSlug;
225
+ }
226
+
155
227
  /**
156
228
  * Detect collision and disambiguate path if needed.
157
229
  * @param {string} targetPath
@@ -269,6 +341,13 @@ function cmdWorktreesCreate(cwd, args) {
269
341
  const repoIdx = args.indexOf('--repo');
270
342
  const repoFilter = repoIdx !== -1 ? args[repoIdx + 1] : null;
271
343
 
344
+ // Ad-hoc milestone container markers (Phase 159, ADH-04 mirror + ADH-06).
345
+ // Additive: only stamped on the entry when present, so non-adhoc worktree
346
+ // entries stay clean. Wired together by the new-milestone --adhoc branch.
347
+ const isAdhoc = args.includes('--adhoc');
348
+ const baseRefIdx = args.indexOf('--base-ref');
349
+ const adhocBaseRef = baseRefIdx !== -1 ? args[baseRefIdx + 1] : null;
350
+
272
351
  // Load config
273
352
  const config = loadConfig(cwd);
274
353
  const project = config.current_project;
@@ -350,6 +429,16 @@ function cmdWorktreesCreate(cwd, args) {
350
429
  mode: mode,
351
430
  setup_complete: setupComplete,
352
431
  milestone_version: milestoneVersion,
432
+ // Stamp the structural-slug formula on milestone entries so the runtime
433
+ // resolver (resolveMilestoneSlug) prefers the stamped key over recomputing
434
+ // from the name. Only present on milestone-type entries; quick entries
435
+ // stay clean.
436
+ ...(type === 'milestone' ? { milestone_slug: slug, slug_formula: 'v2' } : {}),
437
+ // Ad-hoc markers (Phase 159): re-passed each repo iteration so they
438
+ // survive the multi-repo merge in _setWorktreeState (which only
439
+ // special-cases `repos`). Idempotent and only present when set.
440
+ ...(isAdhoc ? { adhoc: true } : {}),
441
+ ...(adhocBaseRef ? { adhoc_base_ref: adhocBaseRef } : {}),
353
442
  repos: { [repo.name]: worktreePath },
354
443
  });
355
444
 
@@ -724,12 +813,24 @@ function rebaseAndMerge(cwd, repoName, slug, options) {
724
813
  }
725
814
  }
726
815
 
816
+ // Step 6: Delete the owned remote milestone branch (SUCCESS path only).
817
+ // This reclaims the milestone/<slug> name on origin so a future milestone
818
+ // cannot collide with a lingering squatting branch. A delete failure (e.g.
819
+ // no remote branch, offline) is a NON-FATAL warning and never changes the
820
+ // completion result. Never runs on a conflicted/failed merge.
821
+ const delRemote = execGit(mainCheckout, ['push', 'origin', '--delete', branchName]);
822
+ const remoteBranchDeleted = delRemote.exitCode === 0;
823
+ if (!remoteBranchDeleted) {
824
+ process.stderr.write('Warning: failed to delete remote ' + branchName + ': ' + delRemote.stderr + '\n');
825
+ }
826
+
727
827
  return {
728
828
  success: true,
729
829
  merged: true,
730
830
  skipped: rebaseSkipped,
731
831
  conflicted: false,
732
832
  pushed: pushed,
833
+ remoteBranchDeleted: remoteBranchDeleted,
733
834
  };
734
835
  }
735
836
 
@@ -779,6 +880,83 @@ function checkWorktreeHealth(cwd, slug) {
779
880
  return { healthy: issues.length === 0, issues: issues };
780
881
  }
781
882
 
883
+ /**
884
+ * Pre-flight check for a remote milestone-branch collision.
885
+ *
886
+ * For each repo in REPOS.md, fetch origin (best-effort), then check whether
887
+ * `refs/remotes/origin/milestone/<slug>` exists with commits NOT reachable from
888
+ * base_branch (i.e. unmerged work squatting the milestone name). Used by the
889
+ * run-job pre-flight guard to REFUSE starting a job that would collide.
890
+ *
891
+ * Never throws — degrades to `{ collision:false, ... }` on any git error.
892
+ *
893
+ * @param {string} cwd - Planning root
894
+ * @param {string} slug - Milestone slug
895
+ * @returns {{ collision: boolean, branch: string, repos: Array<{name:string, exists:boolean, unmergedCount:number}>, message: string }}
896
+ */
897
+ function checkRemoteCollision(cwd, slug) {
898
+ const branch = 'milestone/' + slug;
899
+ const empty = { collision: false, branch: branch, repos: [], message: '' };
900
+
901
+ let config;
902
+ try {
903
+ config = loadConfig(cwd);
904
+ } catch {
905
+ return empty;
906
+ }
907
+ const baseBranch = (config && config.base_branch) || 'main';
908
+
909
+ let parsed;
910
+ try {
911
+ parsed = parseReposMd(cwd);
912
+ } catch {
913
+ return empty;
914
+ }
915
+ const repos = (parsed && parsed.repos) ? parsed.repos : [];
916
+
917
+ const repoResults = [];
918
+ const collidingRepos = [];
919
+
920
+ for (const repo of repos) {
921
+ let mainCheckout;
922
+ try {
923
+ mainCheckout = _findMainCheckoutPath(cwd, repo.name);
924
+ } catch {
925
+ mainCheckout = null;
926
+ }
927
+ if (!mainCheckout || !fs.existsSync(mainCheckout)) {
928
+ repoResults.push({ name: repo.name, exists: false, unmergedCount: 0 });
929
+ continue;
930
+ }
931
+
932
+ // Best-effort fetch — ignore failure (offline, no remote, etc.).
933
+ execGit(mainCheckout, ['fetch', 'origin']);
934
+
935
+ const remoteRef = 'refs/remotes/origin/' + branch;
936
+ const verify = execGit(mainCheckout, ['rev-parse', '--verify', '--quiet', remoteRef]);
937
+ const exists = verify.exitCode === 0 && !!(verify.stdout || '').trim();
938
+ if (!exists) {
939
+ repoResults.push({ name: repo.name, exists: false, unmergedCount: 0 });
940
+ continue;
941
+ }
942
+
943
+ // Count commits on the remote milestone branch not reachable from base_branch.
944
+ const countRes = execGit(mainCheckout, ['rev-list', '--count', baseBranch + '..origin/' + branch]);
945
+ const unmergedCount = countRes.exitCode === 0 ? parseInt((countRes.stdout || '0').trim(), 10) || 0 : 0;
946
+ repoResults.push({ name: repo.name, exists: true, unmergedCount: unmergedCount });
947
+ if (unmergedCount > 0) collidingRepos.push(repo.name);
948
+ }
949
+
950
+ const collision = collidingRepos.length > 0;
951
+ const message = collision
952
+ ? 'Remote branch ' + branch + ' already exists with unmerged commits in: ' +
953
+ collidingRepos.join(', ') + '. Merge or delete it before starting this job ' +
954
+ '(e.g. git push origin --delete ' + branch + '), then re-run.'
955
+ : '';
956
+
957
+ return { collision: collision, branch: branch, repos: repoResults, message: message };
958
+ }
959
+
782
960
  module.exports = {
783
961
  cmdWorktreesCreate,
784
962
  cmdWorktreesRemove,
@@ -787,4 +965,7 @@ module.exports = {
787
965
  cmdWorktreesPrune,
788
966
  rebaseAndMerge,
789
967
  checkWorktreeHealth,
968
+ composeMilestoneSlug,
969
+ resolveMilestoneSlug,
970
+ checkRemoteCollision,
790
971
  };