@ktpartners/dgs-platform 3.3.1 → 3.4.1

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 (28) hide show
  1. package/CHANGELOG.md +23 -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/help.md +7 -0
  24. package/deliver-great-systems/workflows/new-milestone.md +69 -0
  25. package/deliver-great-systems/workflows/progress.md +5 -1
  26. package/deliver-great-systems/workflows/run-job.md +23 -1
  27. package/hooks/dist/dgs-enforce-discipline.js +34 -1
  28. package/package.json +1 -1
@@ -4,12 +4,16 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const { execSync } = require('child_process');
7
8
  const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error, loadConfig } = require('./core.cjs');
8
- const { extractFrontmatter } = require('./frontmatter.cjs');
9
+ const { extractFrontmatter, spliceFrontmatter } = require('./frontmatter.cjs');
9
10
  const { getPlanningRoot } = require('./paths.cjs');
10
- const { writeStateMd } = require('./state.cjs');
11
+ const { writeStateMd, stateReadAdhoc } = require('./state.cjs');
12
+ const { parseReposMd } = require('./repos.cjs');
11
13
  const { getContributors, checkFourEyes } = require('./governance.cjs');
12
14
  const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
15
+ const { composeMilestoneSlug } = require('./worktrees.cjs');
16
+ const { listProjectsReadonly } = require('./projects.cjs');
13
17
 
14
18
  /**
15
19
  * Internal helper: marks requirement IDs complete in REQUIREMENTS.md.
@@ -359,8 +363,472 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
359
363
  output(result, raw);
360
364
  }
361
365
 
366
+ // ─── Ad-hoc Milestone Creation (Phase 159) ───────────────────────────────────
367
+
368
+ /**
369
+ * Sanitize a name into a slug (mirrors worktrees.cjs::_sanitizeSlug).
370
+ */
371
+ function _adhocSanitizeSlug(input) {
372
+ if (!input) return '';
373
+ return String(input)
374
+ .toLowerCase()
375
+ .replace(/[^a-z0-9-]/g, '-')
376
+ .replace(/-{2,}/g, '-')
377
+ .replace(/^-+|-+$/g, '')
378
+ .slice(0, 50)
379
+ .replace(/-+$/, '');
380
+ }
381
+
382
+ /** Resolve a repo's absolute path from its REPOS.md relative path. */
383
+ function _adhocRepoAbsPath(cwd, repoRelPath) {
384
+ return path.resolve(getPlanningRoot(cwd), repoRelPath);
385
+ }
386
+
387
+ /** Run a git command in a repo, returning { ok, stdout, stderr }. */
388
+ function _adhocGit(repoDir, gitArgs) {
389
+ try {
390
+ const stdout = execSync('git ' + gitArgs.map(a => JSON.stringify(a)).join(' '), {
391
+ cwd: repoDir, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8',
392
+ });
393
+ return { ok: true, stdout: stdout.trim(), stderr: '' };
394
+ } catch (err) {
395
+ return { ok: false, stdout: (err.stdout || '').toString().trim(), stderr: (err.stderr || err.message || '').toString().trim() };
396
+ }
397
+ }
398
+
399
+ /**
400
+ * True if a working tree has uncommitted changes.
401
+ *
402
+ * `config.local.json` is machine-local DGS state (normally gitignored) and is
403
+ * mutated as a side effect of routine commands, so it is excluded from the
404
+ * precondition — only source-tracked changes block ad-hoc creation.
405
+ */
406
+ function _adhocIsDirty(repoDir) {
407
+ const r = _adhocGit(repoDir, ['status', '--porcelain']);
408
+ if (!r.ok) return false;
409
+ const lines = r.stdout.split('\n').map(l => l.trim()).filter(Boolean);
410
+ const meaningful = lines.filter(l => {
411
+ const file = l.replace(/^\S+\s+/, '');
412
+ return file !== 'config.local.json';
413
+ });
414
+ return meaningful.length > 0;
415
+ }
416
+
417
+ /** Resolve the dgs-tools.cjs entrypoint path (this file lives in bin/lib/). */
418
+ function _adhocToolsPath() {
419
+ return path.resolve(__dirname, '..', 'dgs-tools.cjs');
420
+ }
421
+
422
+ /** Run a dgs-tools subcommand from the planning root. Returns { ok, stdout, stderr }. */
423
+ function _adhocTools(cwd, argStr) {
424
+ try {
425
+ const stdout = execSync('node ' + JSON.stringify(_adhocToolsPath()) + ' ' + argStr, {
426
+ cwd, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8',
427
+ });
428
+ return { ok: true, stdout: stdout.trim(), stderr: '' };
429
+ } catch (err) {
430
+ return { ok: false, stdout: (err.stdout || '').toString().trim(), stderr: (err.stderr || err.message || '').toString().trim() };
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Create an ad-hoc milestone container atomically (Phase 159).
436
+ *
437
+ * Canonical 6-step ordered creation with full rollback (RESEARCH.md):
438
+ * 1. assert preconditions (clean trees + no active_context) [ADH-19]
439
+ * 2. capture planning base ref refs/dgs/adhoc/{slug}/base [ADH-06]
440
+ * 3. create code worktrees --type milestone --adhoc --base-ref [ADH-02]
441
+ * 4. confirm adhoc marker on the worktree entry [ADH-04 mirror]
442
+ * 5. commit STATE.md with adhoc:true + milestone vX.Y [ADH-04 primary]
443
+ * 6. set execution.active_context LAST [ADH-03, no leak]
444
+ * On any failure, rolls back in reverse leaving NO residue (no ref, no entry,
445
+ * no marker, active_context unset). Prints the ADH-22 abandon advisory on
446
+ * success. detectQuickMode is never touched.
447
+ *
448
+ * @param {string} cwd - Planning root
449
+ * @param {string[]} args - [name..., --version vX.Y]
450
+ * @param {boolean} raw
451
+ */
452
+ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
453
+ // ── Parse args: collect name tokens until a flag; parse --version. ──
454
+ const versionIdx = args.indexOf('--version');
455
+ let explicitVersion = versionIdx !== -1 ? args[versionIdx + 1] : null;
456
+ const nameTokens = [];
457
+ for (let i = 0; i < args.length; i++) {
458
+ if (args[i].startsWith('--')) { if (args[i] === '--version') i++; continue; }
459
+ nameTokens.push(args[i]);
460
+ }
461
+ const name = nameTokens.join(' ').trim();
462
+ if (!name) error('Usage: milestone create-adhoc <name> [--version vX.Y]');
463
+
464
+ // Step 0 — version: default recommendation is MINOR (vX.Y two-component).
465
+ // Provisional only; nothing product-global is written at creation.
466
+ let version = explicitVersion || 'v0.1';
467
+ if (!/^v?\d+\.\d+$/.test(version)) error('--version must be two-component vX.Y');
468
+ if (!version.startsWith('v')) version = 'v' + version;
469
+
470
+ const config = loadConfig(cwd);
471
+ const project = config.current_project;
472
+ if (!project) error('No current project set.');
473
+ const baseBranch = config.base_branch || 'main';
474
+ const planningRoot = getPlanningRoot(cwd);
475
+
476
+ // Structural slug consistency: ad-hoc milestones use the SAME composition as
477
+ // spec/phase-driven ones ([<project>-]<version>-<name-slug>), so their branch
478
+ // never collapses to a bare name and never collides. Computed AFTER version
479
+ // and project are resolved (both feed the composition) and BEFORE the base-ref
480
+ // / active_context derivations below that consume `slug`.
481
+ const multiProject = listProjectsReadonly(cwd).projects.length > 1;
482
+ const slug = composeMilestoneSlug({ version, name, project, multiProject });
483
+ if (!slug) error('Could not derive a slug from name: ' + name);
484
+
485
+ // ── Step 1 — assert preconditions (ADH-19). No mutation before this. ──
486
+ // 1a. active_context already set?
487
+ const acRes = _adhocTools(cwd, 'config-get execution.active_context');
488
+ const activeContext = acRes.ok ? acRes.stdout.replace(/^"|"$/g, '').trim() : '';
489
+ if (activeContext && activeContext !== 'null' && activeContext !== '') {
490
+ error('An active context is already set (' + activeContext + '). Complete or abandon it first (e.g. /dgs:abandon-milestone), then retry.');
491
+ }
492
+
493
+ // 1b. dirty working trees (planning + every registered code repo)?
494
+ const parsed = parseReposMd(cwd);
495
+ const repos = (parsed && parsed.repos) ? parsed.repos : [];
496
+ if (_adhocIsDirty(planningRoot)) {
497
+ error('Planning working tree is dirty. Commit or stash changes first, then retry.');
498
+ }
499
+ for (const repo of repos) {
500
+ const repoAbs = _adhocRepoAbsPath(cwd, repo.path);
501
+ if (fs.existsSync(repoAbs) && _adhocIsDirty(repoAbs)) {
502
+ error('Code repo "' + repo.name + '" working tree is dirty. Commit or stash changes first, then retry.');
503
+ }
504
+ }
505
+
506
+ // Rollback bookkeeping — only undo what THIS invocation created.
507
+ const baseRef = 'refs/dgs/adhoc/' + slug + '/base';
508
+ let refCreated = false;
509
+ let worktreesCreated = false;
510
+ let stateCommitHash = null;
511
+ const statePath = path.join(planningRoot, getProjectRoot(cwd), 'STATE.md');
512
+
513
+ const rollback = (reasonMsg) => {
514
+ // Reverse order. active_context is set LAST so a pre-step-6 failure never set it.
515
+ if (stateCommitHash) {
516
+ // Reset the just-made STATE commit (not yet pushed at this point).
517
+ _adhocGit(planningRoot, ['reset', '--hard', stateCommitHash + '^']);
518
+ }
519
+ if (worktreesCreated) {
520
+ _adhocTools(cwd, 'worktrees remove ' + JSON.stringify(slug) + ' --force');
521
+ }
522
+ if (refCreated) {
523
+ _adhocGit(planningRoot, ['update-ref', '-d', baseRef]);
524
+ }
525
+ error(reasonMsg + ' — rolled back (no worktree, no base ref, no STATE marker, active_context unset).');
526
+ };
527
+
528
+ // ── Step 2 — capture planning base ref (ADH-06). ──
529
+ const refRes = _adhocGit(planningRoot, ['update-ref', baseRef, 'HEAD']);
530
+ if (!refRes.ok) error('Failed to capture base ref: ' + refRes.stderr);
531
+ refCreated = true;
532
+
533
+ // ── Step 3 — create code worktrees (ADH-02). ──
534
+ const wtRes = _adhocTools(cwd, 'worktrees create ' + JSON.stringify(slug) +
535
+ ' --type milestone --adhoc --base-ref ' + JSON.stringify(baseRef));
536
+ if (!wtRes.ok) {
537
+ worktreesCreated = true; // partial entry may exist — let rollback clean it.
538
+ rollback('Worktree creation failed: ' + (wtRes.stderr || wtRes.stdout));
539
+ }
540
+ worktreesCreated = true;
541
+
542
+ // ── Step 4 — confirm marker on the worktree entry (ADH-04 mirror). ──
543
+ let entryOk = false;
544
+ try {
545
+ const localCfg = JSON.parse(fs.readFileSync(path.join(planningRoot, 'config.local.json'), 'utf-8'));
546
+ const entry = localCfg.projects && localCfg.projects[project]
547
+ && localCfg.projects[project].worktrees && localCfg.projects[project].worktrees[slug];
548
+ entryOk = !!(entry && entry.adhoc === true && entry.adhoc_base_ref);
549
+ } catch { entryOk = false; }
550
+ if (!entryOk) rollback('Worktree entry missing adhoc marker / base-ref');
551
+
552
+ // ── Step 5 — commit STATE.md with adhoc:true + milestone vX.Y (ADH-04 primary). ──
553
+ if (!fs.existsSync(statePath)) rollback('STATE.md not found at ' + statePath);
554
+ try {
555
+ const content = fs.readFileSync(statePath, 'utf-8');
556
+ const fm = extractFrontmatter(content);
557
+ fm.milestone = version;
558
+ fm.milestone_name = name;
559
+ fm.status = 'executing';
560
+ fm.adhoc = true;
561
+ const newContent = spliceFrontmatter(content, fm);
562
+ fs.writeFileSync(statePath, newContent, 'utf-8');
563
+ } catch (e) {
564
+ rollback('Failed to write STATE.md adhoc marker: ' + e.message);
565
+ }
566
+ const stateRel = path.relative(planningRoot, statePath);
567
+ const addRes = _adhocGit(planningRoot, ['add', stateRel]);
568
+ if (!addRes.ok) rollback('Failed to stage STATE.md: ' + addRes.stderr);
569
+ const commitRes = _adhocGit(planningRoot, ['commit', '-m',
570
+ 'feat(milestone): establish ad-hoc container ' + slug + ' (' + version + ')']);
571
+ if (!commitRes.ok) rollback('Failed to commit STATE.md: ' + commitRes.stderr);
572
+ const hashRes = _adhocGit(planningRoot, ['rev-parse', 'HEAD']);
573
+ stateCommitHash = hashRes.ok ? hashRes.stdout : null;
574
+
575
+ // ── Step 6 — set active_context LAST (ADH-03, no leak window). ──
576
+ const setRes = _adhocTools(cwd, 'config-local-set execution.active_context ' + JSON.stringify(slug));
577
+ if (!setRes.ok) rollback('Failed to set active_context: ' + setRes.stderr);
578
+
579
+ // ── Success — ADH-22 advisory. ──
580
+ process.stderr.write(
581
+ 'Advisory: while ad-hoc milestone "' + slug + '" is active, any product-level ' +
582
+ '(--main) quick that commits to ' + baseBranch + ' (base_branch) will have its ' +
583
+ 'STATE.md row reverted if you later run /dgs:abandon-milestone.\n'
584
+ );
585
+
586
+ output({
587
+ created: true,
588
+ adhoc: true,
589
+ slug,
590
+ version,
591
+ name,
592
+ base_ref: baseRef,
593
+ active_context: slug,
594
+ state_commit: stateCommitHash,
595
+ }, raw);
596
+ }
597
+
598
+ /**
599
+ * Abandon an active ad-hoc milestone (Phase 160) — the mirror of
600
+ * cmdMilestoneCreateAdhoc.
601
+ *
602
+ * Guard-then-mutate order (NO mutation until every guard passes):
603
+ * 1. require --confirmed (ADH-12; loss message lives here) [guard]
604
+ * 2. resolve project + active ad-hoc slug from active_context [guard]
605
+ * 3. assert ad-hoc marker on STATE.md + worktree entry (ADH-08) [guard]
606
+ * 4. doc-scoped dirty check on the 5 restored docs (ADH-20a) [guard]
607
+ * 5. detect pushed milestone branches (warn-only, ADH-20) [collect]
608
+ * 6. teardown with reverse-order partial-failure bookkeeping:
609
+ * a. worktrees remove {slug} (subprocess; drops branches, nulls
610
+ * active_context — ADH-07)
611
+ * b. path-scoped git restore --source <baseRef> -- <present docs>
612
+ * (ADH-09; never a whole-tree reset)
613
+ * c. attributed reversion commit (ADH-20)
614
+ * 7. emit pushed-branch warnings, then output the result JSON.
615
+ *
616
+ * Forward-only and intentionally destructive: on mid-op failure it REPORTS
617
+ * precisely what was/was not reverted (remainder recoverable) rather than
618
+ * rolling back. Local worktrees/branches only; remote refs are never deleted.
619
+ * detectQuickMode is never touched.
620
+ *
621
+ * @param {string} cwd - Planning root
622
+ * @param {string[]} args - [--confirmed]
623
+ * @param {boolean} raw
624
+ */
625
+ function cmdAbandonMilestone(cwd, args, raw) {
626
+ // ── Guard 1 — confirmation (ADH-12). The CLI flag is the second guard
627
+ // behind the workflow's AskUserQuestion gate. ──
628
+ const confirmed = args.includes('--confirmed');
629
+ if (!confirmed) {
630
+ error('Refusing to abandon: --confirmed required. WARNING: all uncommitted and committed milestone changes will be lost.');
631
+ }
632
+
633
+ // ── Resolve project + planning root. ──
634
+ const config = loadConfig(cwd);
635
+ const project = config.current_project;
636
+ if (!project) error('No current project set.');
637
+ const planningRoot = getPlanningRoot(cwd);
638
+
639
+ // ── Guard 2 — resolve the ACTIVE ad-hoc slug from active_context, so
640
+ // worktrees-remove nulls context (Pitfall 3). ──
641
+ const acRes = _adhocTools(cwd, 'config-get execution.active_context');
642
+ const slug = acRes.ok ? acRes.stdout.replace(/^"|"$/g, '').trim() : '';
643
+ if (!slug || slug === 'null') error('No active milestone to abandon.');
644
+
645
+ // ── Guard 3 — ad-hoc-only (ADH-08). Cross-check STATE.md marker AND the
646
+ // worktree entry's adhoc flag + base ref. No mutation on failure. ──
647
+ let localCfg;
648
+ try {
649
+ localCfg = JSON.parse(fs.readFileSync(path.join(planningRoot, 'config.local.json'), 'utf-8'));
650
+ } catch {
651
+ localCfg = {};
652
+ }
653
+ const entry = localCfg.projects && localCfg.projects[project]
654
+ && localCfg.projects[project].worktrees && localCfg.projects[project].worktrees[slug];
655
+ if (!entry || entry.adhoc !== true || !entry.adhoc_base_ref || !stateReadAdhoc(cwd)) {
656
+ error('milestone abandon only discards ad-hoc milestones. "' + slug + '" is spec/phase-driven — complete it with /dgs:complete-milestone. No changes made.');
657
+ }
658
+ const baseRef = entry.adhoc_base_ref; // refs/dgs/adhoc/{slug}/base
659
+
660
+ // ── Compute the canonical 5-doc list, filtered to those present at the
661
+ // base ref (Pitfall 1 — git restore aborts on a missing pathspec). ──
662
+ const projRel = getProjectRoot(cwd);
663
+ const allDocs = ['PROJECT.md', 'STATE.md', 'ROADMAP.md', 'REQUIREMENTS.md']
664
+ .map(d => path.join(projRel, d))
665
+ .concat(['config.json']);
666
+ const present = allDocs.filter(d => _adhocGit(planningRoot, ['cat-file', '-e', baseRef + ':' + d]).ok);
667
+
668
+ // ── Guard 4 — doc-scoped dirty check (ADH-20a, Pitfall 5: NOT whole-tree). ──
669
+ const dirty = _adhocGit(planningRoot, ['status', '--porcelain', '--'].concat(present));
670
+ if (dirty.ok && dirty.stdout.trim()) {
671
+ error('Planning docs have uncommitted/staged edits (' + dirty.stdout.trim() + '). Commit or discard them first, then retry /dgs:abandon-milestone. No changes made.');
672
+ }
673
+
674
+ // ── Step 5 — delete the OWNED pushed milestone branch on origin (ADH-20).
675
+ // For each repo whose remote-tracking ref exists, run a NON-FATAL
676
+ // `git push origin --delete milestone/<slug>` so the milestone name is
677
+ // reclaimed (prevents branch-name collisions). A delete failure is
678
+ // collected as a warning and never aborts the abandon. ──
679
+ const warnings = [];
680
+ const parsed = parseReposMd(cwd);
681
+ const repos = (parsed && parsed.repos) ? parsed.repos : [];
682
+ for (const repo of repos) {
683
+ const repoAbs = _adhocRepoAbsPath(cwd, repo.path);
684
+ if (!fs.existsSync(repoAbs)) continue;
685
+ const r = _adhocGit(repoAbs, ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/milestone/' + slug]);
686
+ if (r.ok && r.stdout) {
687
+ const del = _adhocGit(repoAbs, ['push', 'origin', '--delete', 'milestone/' + slug]);
688
+ if (!del.ok) {
689
+ warnings.push('Warning: failed to delete remote milestone/' + slug + ' on origin in ' +
690
+ repo.name + ' (non-fatal): ' + (del.stderr || del.stdout) +
691
+ '. Delete it manually if needed: git push origin --delete milestone/' + slug);
692
+ }
693
+ }
694
+ }
695
+
696
+ // ── Step 6 — teardown with reverse-order partial-failure bookkeeping. ──
697
+ const reverted = { worktrees: false, docs: false, committed: false };
698
+
699
+ // 6a. Remove code worktrees + local branches via SUBPROCESS (Pitfall 4 —
700
+ // cmdWorktreesRemove process-exits). This also nulls active_context.
701
+ const rmRes = _adhocTools(cwd, 'worktrees remove ' + JSON.stringify(slug));
702
+ reverted.worktrees = rmRes.ok;
703
+ if (!rmRes.ok) {
704
+ error('Failed to remove worktrees for "' + slug + '": ' + (rmRes.stderr || rmRes.stdout) +
705
+ '. Reverted so far: ' + JSON.stringify(reverted) + '. Docs NOT restored; nothing committed. ' +
706
+ 'Re-run /dgs:abandon-milestone after resolving, or remove the worktree manually.');
707
+ }
708
+
709
+ // 6b. Path-scoped restore of exactly the present docs (ADH-09). NEVER a
710
+ // whole-tree reset / checkout <ref> .
711
+ if (present.length > 0) {
712
+ const restoreRes = _adhocGit(planningRoot, ['restore', '--source', baseRef, '--'].concat(present));
713
+ reverted.docs = restoreRes.ok;
714
+ if (!restoreRes.ok) {
715
+ error('Worktrees removed, but failed to restore planning docs from ' + baseRef + ': ' + restoreRes.stderr +
716
+ '. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — re-run: ' +
717
+ 'git restore --source ' + baseRef + ' -- ' + present.join(' '));
718
+ }
719
+ } else {
720
+ reverted.docs = true; // nothing to restore
721
+ }
722
+
723
+ // 6c. Attributed reversion commit (ADH-20). Skip gracefully if the restore
724
+ // produced no staged change (base === current for these docs).
725
+ if (present.length > 0) {
726
+ const addRes = _adhocGit(planningRoot, ['add', '--'].concat(present));
727
+ if (!addRes.ok) {
728
+ error('Docs restored but failed to stage them: ' + addRes.stderr +
729
+ '. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — stage and commit the restored docs manually.');
730
+ }
731
+ const hasStaged = !_adhocGit(planningRoot, ['diff', '--cached', '--quiet']).ok;
732
+ if (hasStaged) {
733
+ const author = formatAuthorString(requireGitIdentity(cwd));
734
+ const commitRes = _adhocGit(planningRoot, ['commit', '--author', author, '-m',
735
+ 'revert(milestone): abandon ad-hoc ' + slug + ' — restore project docs to pre-milestone state']);
736
+ reverted.committed = commitRes.ok;
737
+ if (!commitRes.ok) {
738
+ error('Docs restored + staged but commit failed: ' + commitRes.stderr +
739
+ '. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — commit the staged docs manually.');
740
+ }
741
+ } else {
742
+ // No diff to commit — restore was a no-op (base == current). Treated as success.
743
+ reverted.committed = true;
744
+ }
745
+ } else {
746
+ reverted.committed = true;
747
+ }
748
+
749
+ // ── Step 6d — ADH-21 lifecycle cleanup: drop the base ref + any stale entry.
750
+ // Called LAST so the doc restore above (which reads entry.adhoc_base_ref) ran first.
751
+ const cleanup = cleanupAdhoc(cwd, slug);
752
+
753
+ // ── Step 7 — surface pushed-branch warnings, then report. ──
754
+ for (const w of warnings) {
755
+ process.stderr.write(w + '\n');
756
+ }
757
+
758
+ output({
759
+ abandoned: true,
760
+ slug,
761
+ base_ref: baseRef,
762
+ restored: present,
763
+ reverted,
764
+ base_ref_deleted: cleanup.ref_deleted,
765
+ warnings,
766
+ }, raw);
767
+ }
768
+
769
+ /**
770
+ * ADH-21 — Idempotent teardown of ad-hoc lifecycle bookkeeping. Called from
771
+ * BOTH complete-milestone (after worktrees remove) and abandon (after the
772
+ * doc restore). Safe no-op for non-ad-hoc / already-clean slugs.
773
+ *
774
+ * 1. delete the snapshot base ref refs/dgs/adhoc/{slug}/base (no-op if absent)
775
+ * 2. defensively remove a lingering worktrees[slug] entry that still carries
776
+ * adhoc keys (the normal path already removed it via `worktrees remove`)
777
+ * 3. clear execution.active_context if it still points at this slug
778
+ *
779
+ * @param {string} cwd - planning root
780
+ * @param {string} slug
781
+ * @returns {{ slug: string, ref_deleted: boolean, entry_removed: boolean }}
782
+ */
783
+ function cleanupAdhoc(cwd, slug) {
784
+ const planningRoot = getPlanningRoot(cwd);
785
+ const config = loadConfig(cwd);
786
+ const project = config.current_project;
787
+ const baseRef = 'refs/dgs/adhoc/' + slug + '/base';
788
+
789
+ // 1. base ref — delete only if present (mirror rollback update-ref -d at create-adhoc).
790
+ const refExisted = _adhocGit(planningRoot, ['rev-parse', '--verify', '--quiet', baseRef]).ok;
791
+ if (refExisted) {
792
+ _adhocGit(planningRoot, ['update-ref', '-d', baseRef]);
793
+ }
794
+
795
+ // 2/3. defensive config cleanup — only the lingering-entry case.
796
+ let entryRemoved = false;
797
+ const cfgPath = path.join(planningRoot, 'config.local.json');
798
+ if (fs.existsSync(cfgPath)) {
799
+ let cfg;
800
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); } catch { cfg = null; }
801
+ if (cfg) {
802
+ const wt = cfg.projects && cfg.projects[project] && cfg.projects[project].worktrees;
803
+ const entry = wt && wt[slug];
804
+ if (entry && (entry.adhoc === true || entry.adhoc_base_ref)) {
805
+ delete wt[slug];
806
+ entryRemoved = true;
807
+ if (cfg.execution && cfg.execution.active_context === slug) {
808
+ cfg.execution.active_context = null;
809
+ }
810
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
811
+ }
812
+ }
813
+ }
814
+
815
+ return { slug, ref_deleted: refExisted, entry_removed: entryRemoved };
816
+ }
817
+
818
+ /**
819
+ * CLI wrapper for ADH-21 cleanup. `dgs-tools milestone cleanup-adhoc <slug> [--raw]`.
820
+ */
821
+ function cmdMilestoneCleanupAdhoc(cwd, slug, raw) {
822
+ if (!slug) error('Usage: milestone cleanup-adhoc <slug>');
823
+ output(cleanupAdhoc(cwd, slug), raw);
824
+ }
825
+
362
826
  module.exports = {
363
827
  cmdRequirementsMarkComplete,
364
828
  requirementsMarkCompleteInternal,
365
829
  cmdMilestoneComplete,
830
+ cmdMilestoneCreateAdhoc,
831
+ cmdAbandonMilestone,
832
+ cleanupAdhoc,
833
+ cmdMilestoneCleanupAdhoc,
366
834
  };