@ktpartners/dgs-platform 3.3.0 → 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.
- package/CHANGELOG.md +32 -0
- package/README.md +4 -1
- package/bin/install.js +1 -1
- package/commands/dgs/abandon-milestone.md +28 -0
- package/commands/dgs/new-milestone.md +3 -1
- package/deliver-great-systems/bin/dgs-tools.cjs +22 -4
- package/deliver-great-systems/bin/lib/context.cjs +45 -15
- package/deliver-great-systems/bin/lib/core.cjs +2 -6
- package/deliver-great-systems/bin/lib/docs.cjs +95 -41
- package/deliver-great-systems/bin/lib/docs.test.cjs +49 -6
- package/deliver-great-systems/bin/lib/init.cjs +60 -13
- package/deliver-great-systems/bin/lib/init.test.cjs +61 -3
- package/deliver-great-systems/bin/lib/milestone.cjs +470 -2
- package/deliver-great-systems/bin/lib/milestone.test.cjs +653 -0
- package/deliver-great-systems/bin/lib/search.cjs +5 -16
- package/deliver-great-systems/bin/lib/state.cjs +152 -1
- package/deliver-great-systems/bin/lib/sync.cjs +2 -6
- package/deliver-great-systems/bin/lib/verify.cjs +2 -1
- package/deliver-great-systems/bin/lib/worktrees.cjs +182 -1
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +409 -0
- package/deliver-great-systems/templates/claude-md.md +2 -0
- package/deliver-great-systems/templates/state.md +16 -0
- package/deliver-great-systems/workflows/abandon-milestone.md +120 -0
- package/deliver-great-systems/workflows/complete-milestone.md +58 -4
- package/deliver-great-systems/workflows/create-milestone-job.md +15 -0
- package/deliver-great-systems/workflows/help.md +7 -0
- package/deliver-great-systems/workflows/new-milestone.md +69 -0
- package/deliver-great-systems/workflows/progress.md +5 -1
- package/deliver-great-systems/workflows/run-job.md +23 -1
- package/hooks/dist/dgs-enforce-discipline.js +34 -1
- 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
|
};
|