@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
|
@@ -201,3 +201,656 @@ describe('cmdMilestoneComplete quick dir archival', () => {
|
|
|
201
201
|
assert.equal(typeof result.archived.quick, 'boolean', 'archived.quick should be boolean');
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
|
+
|
|
205
|
+
// ─── milestone abandon (Phase 160, ADH-07/08/09/12/20) ───────────────────────
|
|
206
|
+
//
|
|
207
|
+
// These tests drive the `milestone abandon` CLI verb through a subprocess
|
|
208
|
+
// (the verb calls output()/error() which exit the process), mirroring the
|
|
209
|
+
// canonical harness in worktrees.test.cjs. Each test stands up a real planning
|
|
210
|
+
// git repo + one registered code repo, creates an ad-hoc milestone via
|
|
211
|
+
// `milestone create-adhoc`, then exercises abandon.
|
|
212
|
+
|
|
213
|
+
const { execSync: _execSync } = require('child_process');
|
|
214
|
+
const _os = require('os');
|
|
215
|
+
const { resetPaths: _resetPaths, initPaths: _initPaths } = require('./paths.cjs');
|
|
216
|
+
|
|
217
|
+
const _DGS_TOOLS = path.resolve(__dirname, '..', 'dgs-tools.cjs');
|
|
218
|
+
|
|
219
|
+
// Deterministic git identity for attributed-commit assertions (ADH-20).
|
|
220
|
+
const ABANDON_GIT_ENV = {
|
|
221
|
+
GIT_AUTHOR_NAME: 'Abandon Tester',
|
|
222
|
+
GIT_AUTHOR_EMAIL: 'abandon@test.com',
|
|
223
|
+
GIT_COMMITTER_NAME: 'Abandon Tester',
|
|
224
|
+
GIT_COMMITTER_EMAIL: 'abandon@test.com',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Stand up a planning repo + one registered code repo, both real git repos.
|
|
229
|
+
* Layout:
|
|
230
|
+
* {tmpDir}/planning/ <- DGS planning root (git)
|
|
231
|
+
* config.json, config.local.json, REPOS.md, PROJECTS.md
|
|
232
|
+
* projects/tp/{PROJECT,STATE,ROADMAP,REQUIREMENTS}.md
|
|
233
|
+
* {tmpDir}/code-repo/ <- registered code repo (git, on main)
|
|
234
|
+
*/
|
|
235
|
+
function createAbandonEnv() {
|
|
236
|
+
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(_os.tmpdir(), 'dgs-ab-')));
|
|
237
|
+
const planDir = path.join(tmpDir, 'planning');
|
|
238
|
+
const codeDir = path.join(tmpDir, 'code-repo');
|
|
239
|
+
const fullEnv = { ...process.env, ...ABANDON_GIT_ENV };
|
|
240
|
+
|
|
241
|
+
// Planning repo
|
|
242
|
+
fs.mkdirSync(planDir, { recursive: true });
|
|
243
|
+
_execSync('git init -b main', { cwd: planDir, stdio: 'pipe', env: fullEnv });
|
|
244
|
+
_execSync('git config user.email "abandon@test.com"', { cwd: planDir, stdio: 'pipe' });
|
|
245
|
+
_execSync('git config user.name "Abandon Tester"', { cwd: planDir, stdio: 'pipe' });
|
|
246
|
+
|
|
247
|
+
// Code repo
|
|
248
|
+
fs.mkdirSync(codeDir, { recursive: true });
|
|
249
|
+
_execSync('git init -b main', { cwd: codeDir, stdio: 'pipe', env: fullEnv });
|
|
250
|
+
_execSync('git config user.email "abandon@test.com"', { cwd: codeDir, stdio: 'pipe' });
|
|
251
|
+
_execSync('git config user.name "Abandon Tester"', { cwd: codeDir, stdio: 'pipe' });
|
|
252
|
+
fs.writeFileSync(path.join(codeDir, '.gitkeep'), '');
|
|
253
|
+
_execSync('git add .', { cwd: codeDir, stdio: 'pipe' });
|
|
254
|
+
_execSync('git commit -m "initial"', { cwd: codeDir, stdio: 'pipe', env: fullEnv });
|
|
255
|
+
|
|
256
|
+
// DGS config
|
|
257
|
+
fs.writeFileSync(path.join(planDir, 'config.json'), JSON.stringify({ git: { base_branch: 'main' } }, null, 2));
|
|
258
|
+
fs.writeFileSync(path.join(planDir, 'config.local.json'), JSON.stringify({ current_project: 'tp' }, null, 2));
|
|
259
|
+
fs.writeFileSync(path.join(planDir, 'PROJECTS.md'), '# Projects\n');
|
|
260
|
+
fs.writeFileSync(path.join(planDir, 'REPOS.md'),
|
|
261
|
+
'# Repos\n\n' +
|
|
262
|
+
'| Name | Path | GitHub URL | Description |\n' +
|
|
263
|
+
'|------|------|------------|-------------|\n' +
|
|
264
|
+
'| code-repo | ' + path.relative(planDir, codeDir) + ' | | Test repo |\n'
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Full project doc set so the path-scoped restore has all 5 docs present at base.
|
|
268
|
+
fs.mkdirSync(path.join(planDir, 'projects', 'tp'), { recursive: true });
|
|
269
|
+
fs.writeFileSync(path.join(planDir, 'projects', 'tp', 'PROJECT.md'), '# Project tp\n\nPre-milestone project doc.\n');
|
|
270
|
+
fs.writeFileSync(path.join(planDir, 'projects', 'tp', 'STATE.md'), '# State\nStatus: planning\nPre-milestone state.\n');
|
|
271
|
+
fs.writeFileSync(path.join(planDir, 'projects', 'tp', 'ROADMAP.md'), '# Roadmap\n\nPre-milestone roadmap.\n');
|
|
272
|
+
fs.writeFileSync(path.join(planDir, 'projects', 'tp', 'REQUIREMENTS.md'), '# Requirements\n\nPre-milestone requirements.\n');
|
|
273
|
+
|
|
274
|
+
_execSync('git add .', { cwd: planDir, stdio: 'pipe' });
|
|
275
|
+
_execSync('git commit -m "setup"', { cwd: planDir, stdio: 'pipe', env: fullEnv });
|
|
276
|
+
|
|
277
|
+
_initPaths(planDir);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
tmpDir, planDir, codeDir, fullEnv,
|
|
281
|
+
cleanup: function () {
|
|
282
|
+
_resetPaths();
|
|
283
|
+
try {
|
|
284
|
+
const parent = path.dirname(codeDir);
|
|
285
|
+
for (const e of fs.readdirSync(parent)) {
|
|
286
|
+
if (e.startsWith('code-repo--')) {
|
|
287
|
+
fs.rmSync(path.join(parent, e), { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch { /* ignore */ }
|
|
291
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Run a dgs-tools command from the planning dir; returns parsed JSON. */
|
|
297
|
+
function abandonRun(planDir, args, env) {
|
|
298
|
+
const out = _execSync(
|
|
299
|
+
'node ' + JSON.stringify(_DGS_TOOLS) + ' ' + args,
|
|
300
|
+
{ cwd: planDir, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env: { ...process.env, ...ABANDON_GIT_ENV, ...(env || {}) } }
|
|
301
|
+
);
|
|
302
|
+
return JSON.parse(out.trim());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Run a dgs-tools command expecting a non-zero exit; returns combined stdout+stderr. */
|
|
306
|
+
function abandonRunFail(planDir, args, env) {
|
|
307
|
+
try {
|
|
308
|
+
_execSync(
|
|
309
|
+
'node ' + JSON.stringify(_DGS_TOOLS) + ' ' + args,
|
|
310
|
+
{ cwd: planDir, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env: { ...process.env, ...ABANDON_GIT_ENV, ...(env || {}) } }
|
|
311
|
+
);
|
|
312
|
+
return null; // unexpected success
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return (err.stdout || '').toString() + (err.stderr || err.message || '').toString();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Create an ad-hoc milestone named 'abandon target'; returns { slug, baseRef }. */
|
|
319
|
+
function setupAdhoc(planDir) {
|
|
320
|
+
const res = abandonRun(planDir, 'milestone create-adhoc ' + JSON.stringify('abandon target') + ' --version v0.1');
|
|
321
|
+
return { slug: res.slug, baseRef: res.base_ref || ('refs/dgs/adhoc/' + res.slug + '/base') };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function readLocal(planDir) {
|
|
325
|
+
return JSON.parse(fs.readFileSync(path.join(planDir, 'config.local.json'), 'utf-8'));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** True if a git ref resolves in the given repo. */
|
|
329
|
+
function refResolves(repoDir, ref) {
|
|
330
|
+
try {
|
|
331
|
+
_execSync('git show-ref --verify --quiet ' + JSON.stringify(ref), { cwd: repoDir, stdio: 'pipe' });
|
|
332
|
+
return true;
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** True if `doc` (relative to planDir) differs from its content at `ref`. */
|
|
339
|
+
function docDiffersFromRef(planDir, ref, doc) {
|
|
340
|
+
try {
|
|
341
|
+
_execSync('git diff --quiet ' + JSON.stringify(ref) + ' -- ' + JSON.stringify(doc), { cwd: planDir, stdio: 'pipe' });
|
|
342
|
+
return false; // no diff
|
|
343
|
+
} catch {
|
|
344
|
+
return true; // diff present (or pathspec issue)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
describe('milestone abandon', () => {
|
|
349
|
+
let env;
|
|
350
|
+
beforeEach(() => { env = createAbandonEnv(); });
|
|
351
|
+
afterEach(() => { if (env) env.cleanup(); });
|
|
352
|
+
|
|
353
|
+
it('ad-hoc create composes a version-prefixed structural slug (not the bare name)', () => {
|
|
354
|
+
const res = abandonRun(env.planDir, 'milestone create-adhoc ' + JSON.stringify('My Adhoc Thing') + ' --version v3.4');
|
|
355
|
+
assert.ok(res.created && res.slug, 'should create with a slug');
|
|
356
|
+
// Structural composition: [<version>-]<name-slug>; v3.4 -> v3-4.
|
|
357
|
+
assert.ok(res.slug.startsWith('v3-4-'), 'slug should embed the version segment: ' + res.slug);
|
|
358
|
+
assert.ok(res.slug.includes('my-adhoc-thing'), 'slug should include the name: ' + res.slug);
|
|
359
|
+
assert.notEqual(res.slug, 'my-adhoc-thing', 'slug must NOT be the bare name');
|
|
360
|
+
// The code-repo milestone branch follows the composed slug.
|
|
361
|
+
const branches = _execSync('git branch', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
362
|
+
assert.ok(branches.includes('milestone/' + res.slug), 'milestone branch should use the composed slug');
|
|
363
|
+
// create + abandon still complete end-to-end.
|
|
364
|
+
const ab = abandonRun(env.planDir, 'milestone abandon --confirmed');
|
|
365
|
+
assert.equal(ab.abandoned, true, 'abandon should still complete with the new slug');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('ADH-07 happy path: restores docs to base ref, removes worktrees/branches, clears context, commits reversion', () => {
|
|
369
|
+
const { slug, baseRef } = setupAdhoc(env.planDir);
|
|
370
|
+
|
|
371
|
+
// Mutate a restored doc on base_branch during the milestone window, then commit it.
|
|
372
|
+
const statePath = path.join(env.planDir, 'projects', 'tp', 'STATE.md');
|
|
373
|
+
fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8') + '\nMilestone-window edit row.\n');
|
|
374
|
+
_execSync('git add . && git commit -m "milestone-window STATE edit"', { cwd: env.planDir, stdio: 'pipe', env: env.fullEnv });
|
|
375
|
+
|
|
376
|
+
// Sanity: STATE.md currently differs from base ref.
|
|
377
|
+
assert.ok(docDiffersFromRef(env.planDir, baseRef, 'projects/tp/STATE.md'), 'precondition: STATE.md differs from base');
|
|
378
|
+
|
|
379
|
+
// ADH-21: abandon DELETES the base ref as part of lifecycle cleanup, so resolve
|
|
380
|
+
// it to an immutable commit SHA now to diff against after abandon.
|
|
381
|
+
const baseSha = _execSync('git rev-parse ' + JSON.stringify(baseRef), { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
382
|
+
|
|
383
|
+
const result = abandonRun(env.planDir, 'milestone abandon --confirmed');
|
|
384
|
+
assert.equal(result.abandoned, true, 'should report abandoned:true');
|
|
385
|
+
assert.equal(result.slug, slug);
|
|
386
|
+
|
|
387
|
+
// STATE.md restored to base-ref content (no diff vs the captured base SHA).
|
|
388
|
+
assert.ok(!docDiffersFromRef(env.planDir, baseSha, 'projects/tp/STATE.md'), 'STATE.md should match base ref after abandon');
|
|
389
|
+
|
|
390
|
+
// Code repo milestone branch + worktree removed.
|
|
391
|
+
const branches = _execSync('git branch', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
392
|
+
assert.ok(!branches.includes('milestone/' + slug), 'milestone/{slug} branch should be removed');
|
|
393
|
+
|
|
394
|
+
// active_context cleared.
|
|
395
|
+
const cfg = readLocal(env.planDir);
|
|
396
|
+
assert.ok(!cfg.execution || cfg.execution.active_context === null, 'active_context should be null after abandon');
|
|
397
|
+
|
|
398
|
+
// A reversion commit exists on base_branch HEAD.
|
|
399
|
+
const head = _execSync('git log -1 --format=%s', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
400
|
+
assert.match(head, /revert|abandon/i, 'HEAD commit should be the reversion: ' + head);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('ADH-08 guard: non-ad-hoc milestone is refused with guidance and makes no changes', () => {
|
|
404
|
+
// No create-adhoc — drive abandon on a plain (non-ad-hoc) planning repo.
|
|
405
|
+
// Set an active_context that is NOT an ad-hoc milestone entry.
|
|
406
|
+
const cfg = readLocal(env.planDir);
|
|
407
|
+
cfg.projects = { tp: { worktrees: { 'plain-ms': { type: 'milestone' } } } };
|
|
408
|
+
cfg.execution = { active_context: 'plain-ms' };
|
|
409
|
+
fs.writeFileSync(path.join(env.planDir, 'config.local.json'), JSON.stringify(cfg, null, 2) + '\n');
|
|
410
|
+
|
|
411
|
+
const headBefore = _execSync('git rev-parse HEAD', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
412
|
+
|
|
413
|
+
const out = abandonRunFail(env.planDir, 'milestone abandon --confirmed');
|
|
414
|
+
assert.ok(out, 'abandon should fail (non-zero) on a non-ad-hoc milestone');
|
|
415
|
+
assert.match(out, /ad-hoc|complete-milestone/i, 'should surface ad-hoc-only guidance: ' + out);
|
|
416
|
+
|
|
417
|
+
// NO changes: worktree entry still present, active_context unchanged, no new commit.
|
|
418
|
+
const after = readLocal(env.planDir);
|
|
419
|
+
assert.ok(after.projects.tp.worktrees['plain-ms'], 'worktree entry must remain');
|
|
420
|
+
assert.equal(after.execution.active_context, 'plain-ms', 'active_context unchanged');
|
|
421
|
+
const headAfter = _execSync('git rev-parse HEAD', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
422
|
+
assert.equal(headAfter, headBefore, 'no new commit on refusal');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('ADH-09 restore scope: only the named docs change; a sentinel outside the set is untouched (never whole-tree)', () => {
|
|
426
|
+
const { slug, baseRef } = setupAdhoc(env.planDir);
|
|
427
|
+
|
|
428
|
+
// Add a sentinel tracked file AFTER the base ref was captured (proves path-scoped).
|
|
429
|
+
const sentinelRel = 'projects/tp/SENTINEL.txt';
|
|
430
|
+
const sentinelPath = path.join(env.planDir, sentinelRel);
|
|
431
|
+
fs.writeFileSync(sentinelPath, 'sentinel content added during milestone window\n');
|
|
432
|
+
// Also edit STATE.md in the window.
|
|
433
|
+
const statePath = path.join(env.planDir, 'projects', 'tp', 'STATE.md');
|
|
434
|
+
fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8') + '\nwindow edit\n');
|
|
435
|
+
_execSync('git add . && git commit -m "window: sentinel + state edit"', { cwd: env.planDir, stdio: 'pipe', env: env.fullEnv });
|
|
436
|
+
|
|
437
|
+
// ADH-21: abandon deletes the base ref — capture its commit SHA first.
|
|
438
|
+
const baseSha = _execSync('git rev-parse ' + JSON.stringify(baseRef), { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
439
|
+
|
|
440
|
+
abandonRun(env.planDir, 'milestone abandon --confirmed');
|
|
441
|
+
|
|
442
|
+
// Sentinel must be UNCHANGED (a whole-tree reset to base would have deleted it).
|
|
443
|
+
assert.ok(fs.existsSync(sentinelPath), 'sentinel file must survive a path-scoped restore');
|
|
444
|
+
assert.equal(fs.readFileSync(sentinelPath, 'utf-8'), 'sentinel content added during milestone window\n', 'sentinel content unchanged');
|
|
445
|
+
|
|
446
|
+
// STATE.md was restored to base (compare against the captured base SHA).
|
|
447
|
+
assert.ok(!docDiffersFromRef(env.planDir, baseSha, 'projects/tp/STATE.md'), 'STATE.md restored to base');
|
|
448
|
+
void slug;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('ADH-12 confirmation: without --confirmed, abandon refuses with the loss message and makes no changes', () => {
|
|
452
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
453
|
+
const headBefore = _execSync('git rev-parse HEAD', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
454
|
+
|
|
455
|
+
const out = abandonRunFail(env.planDir, 'milestone abandon');
|
|
456
|
+
assert.ok(out, 'abandon without --confirmed should fail');
|
|
457
|
+
assert.match(out, /all uncommitted and committed milestone changes will be lost/i, 'loss message required: ' + out);
|
|
458
|
+
|
|
459
|
+
// NO changes — context still active, branch still present, no new commit.
|
|
460
|
+
const cfg = readLocal(env.planDir);
|
|
461
|
+
assert.equal(cfg.execution.active_context, slug, 'active_context unchanged without --confirmed');
|
|
462
|
+
const branches = _execSync('git branch', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
463
|
+
assert.ok(branches.includes('milestone/' + slug), 'milestone branch still present');
|
|
464
|
+
const headAfter = _execSync('git rev-parse HEAD', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
465
|
+
assert.equal(headAfter, headBefore, 'no new commit without --confirmed');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('ADH-20a precondition: a staged/uncommitted edit to a restored doc blocks abandon with remediation, no mutation', () => {
|
|
469
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
470
|
+
|
|
471
|
+
// Dirty a restored doc without committing.
|
|
472
|
+
const statePath = path.join(env.planDir, 'projects', 'tp', 'STATE.md');
|
|
473
|
+
fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8') + '\nuncommitted dirty edit\n');
|
|
474
|
+
|
|
475
|
+
const headBefore = _execSync('git rev-parse HEAD', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
476
|
+
|
|
477
|
+
const out = abandonRunFail(env.planDir, 'milestone abandon --confirmed');
|
|
478
|
+
assert.ok(out, 'abandon should refuse on a dirty restored-doc tree');
|
|
479
|
+
assert.match(out, /commit|discard|uncommitted|staged/i, 'should give remediation: ' + out);
|
|
480
|
+
|
|
481
|
+
// NO mutation: context still active, branch present, no new commit, dirty edit preserved.
|
|
482
|
+
const cfg = readLocal(env.planDir);
|
|
483
|
+
assert.equal(cfg.execution.active_context, slug, 'active_context unchanged');
|
|
484
|
+
const branches = _execSync('git branch', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
485
|
+
assert.ok(branches.includes('milestone/' + slug), 'milestone branch still present');
|
|
486
|
+
const headAfter = _execSync('git rev-parse HEAD', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
487
|
+
assert.equal(headAfter, headBefore, 'no new commit on refusal');
|
|
488
|
+
assert.match(fs.readFileSync(statePath, 'utf-8'), /uncommitted dirty edit/, 'dirty edit preserved');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('ADH-20 attributed commit: the reversion commit is authored by the configured git identity', () => {
|
|
492
|
+
const { baseRef } = setupAdhoc(env.planDir);
|
|
493
|
+
|
|
494
|
+
// Make a committed window edit so the restore produces a real reversion commit.
|
|
495
|
+
const statePath = path.join(env.planDir, 'projects', 'tp', 'STATE.md');
|
|
496
|
+
fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8') + '\nwindow edit for attribution\n');
|
|
497
|
+
_execSync('git add . && git commit -m "window edit"', { cwd: env.planDir, stdio: 'pipe', env: env.fullEnv });
|
|
498
|
+
|
|
499
|
+
abandonRun(env.planDir, 'milestone abandon --confirmed');
|
|
500
|
+
|
|
501
|
+
const author = _execSync('git log -1 "--format=%an|%ae"', { cwd: env.planDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
502
|
+
assert.equal(author, 'Abandon Tester|abandon@test.com', 'reversion commit must be attributed to the invoking identity: ' + author);
|
|
503
|
+
void baseRef;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('ADH-20 pushed-branch teardown: pushed milestone branch → local removal + owned remote branch deleted', () => {
|
|
507
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
508
|
+
|
|
509
|
+
// Stand up a bare remote for the code repo and push the milestone branch to it.
|
|
510
|
+
const remoteDir = path.join(env.tmpDir, 'code-remote.git');
|
|
511
|
+
_execSync('git init --bare ' + JSON.stringify(remoteDir), { cwd: env.tmpDir, stdio: 'pipe', env: env.fullEnv });
|
|
512
|
+
_execSync('git remote add origin ' + JSON.stringify(remoteDir), { cwd: env.codeDir, stdio: 'pipe' });
|
|
513
|
+
_execSync('git push origin ' + JSON.stringify('milestone/' + slug), { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
514
|
+
// Refresh remote-tracking refs in the main checkout.
|
|
515
|
+
_execSync('git fetch origin', { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
516
|
+
|
|
517
|
+
assert.ok(refResolves(env.codeDir, 'refs/remotes/origin/milestone/' + slug), 'precondition: remote ref exists');
|
|
518
|
+
assert.ok(refResolves(remoteDir, 'refs/heads/milestone/' + slug), 'precondition: remote branch exists');
|
|
519
|
+
|
|
520
|
+
const out = _execSync(
|
|
521
|
+
'node ' + JSON.stringify(_DGS_TOOLS) + ' milestone abandon --confirmed 2>&1',
|
|
522
|
+
{ cwd: env.planDir, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env: { ...process.env, ...ABANDON_GIT_ENV } }
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Local branch removed; owned remote branch DELETED during teardown.
|
|
526
|
+
const branches = _execSync('git branch', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
527
|
+
assert.ok(!branches.includes('milestone/' + slug), 'local milestone branch removed');
|
|
528
|
+
assert.ok(!refResolves(remoteDir, 'refs/heads/milestone/' + slug), 'owned remote milestone branch should be deleted');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('ADH-20 remote-delete failure is non-fatal: abandon still reports abandoned:true', () => {
|
|
532
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
533
|
+
|
|
534
|
+
// Add an origin remote that points at a non-existent / unreachable path so a
|
|
535
|
+
// delete push fails, but the milestone branch ref is locally present.
|
|
536
|
+
const deadRemote = path.join(env.tmpDir, 'does-not-exist.git');
|
|
537
|
+
_execSync('git remote add origin ' + JSON.stringify(deadRemote), { cwd: env.codeDir, stdio: 'pipe' });
|
|
538
|
+
// Fabricate a remote-tracking ref so the teardown attempts a delete.
|
|
539
|
+
const sha = _execSync('git rev-parse ' + JSON.stringify('milestone/' + slug), { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
540
|
+
_execSync('git update-ref ' + JSON.stringify('refs/remotes/origin/milestone/' + slug) + ' ' + sha, { cwd: env.codeDir, stdio: 'pipe' });
|
|
541
|
+
assert.ok(refResolves(env.codeDir, 'refs/remotes/origin/milestone/' + slug), 'precondition: remote-tracking ref exists');
|
|
542
|
+
|
|
543
|
+
const result = abandonRun(env.planDir, 'milestone abandon --confirmed');
|
|
544
|
+
assert.equal(result.abandoned, true, 'a failed remote delete must NOT fail the abandon');
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ─── state adhoc-readiness (Phase 161, ADH-10/11/13) ─────────────────────────
|
|
549
|
+
//
|
|
550
|
+
// Drives the `state adhoc-readiness --raw` predicate (Plan 02) through the same
|
|
551
|
+
// subprocess harness as the abandon suite. The predicate gates the relaxed
|
|
552
|
+
// completion path in complete-milestone.md (Plan 03): an ad-hoc milestone with
|
|
553
|
+
// at least one merged unit of work (a completed quick row OR a completed phase)
|
|
554
|
+
// is "ready"; with zero merged work it is refused (ADH-13). A non-ad-hoc
|
|
555
|
+
// milestone always reports adhoc:false so the strict gate governs (ADH-11).
|
|
556
|
+
|
|
557
|
+
const _STATE_PATH_REL = path.join('projects', 'tp', 'STATE.md');
|
|
558
|
+
const _ROADMAP_PATH_REL = path.join('projects', 'tp', 'ROADMAP.md');
|
|
559
|
+
|
|
560
|
+
// Path to the workflow file whose exact ADH-13 refusal string Test 5 pins.
|
|
561
|
+
// Resolved relative to THIS repo root (NOT the temp planDir).
|
|
562
|
+
const _COMPLETE_MILESTONE_WF = path.resolve(
|
|
563
|
+
__dirname, '..', '..', 'workflows', 'complete-milestone.md'
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// The exact ADH-13 refusal string — byte-for-byte (em-dash + backticks).
|
|
567
|
+
const ADH13_REFUSAL =
|
|
568
|
+
'Nothing to ship — run a quick/fast or execute a phase first, or `/dgs:abandon-milestone`';
|
|
569
|
+
|
|
570
|
+
/** Commit everything in planDir with the deterministic abandon identity. */
|
|
571
|
+
function _commitFixture(planDir, msg) {
|
|
572
|
+
_execSync('git add -A && git commit -m ' + JSON.stringify(msg), {
|
|
573
|
+
cwd: planDir, stdio: 'pipe', env: { ...process.env, ...ABANDON_GIT_ENV },
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Append a well-formed "Quick Tasks Completed" data row to the active STATE.md.
|
|
579
|
+
* full=true → 6-col layout (with Status); full=false → 5-col layout (no Status).
|
|
580
|
+
* Inserts the section header + column header + separator when absent.
|
|
581
|
+
*/
|
|
582
|
+
function addQuickRow(planDir, { id, commit, full }) {
|
|
583
|
+
const statePath = path.join(planDir, _STATE_PATH_REL);
|
|
584
|
+
let content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
|
585
|
+
if (!/###\s*Quick Tasks Completed/i.test(content)) {
|
|
586
|
+
const header = full
|
|
587
|
+
? '| # | Description | Date | Commit | Status | Directory |'
|
|
588
|
+
: '| # | Description | Date | Commit | Directory |';
|
|
589
|
+
const sep = full
|
|
590
|
+
? '|---|---|---|---|---|---|'
|
|
591
|
+
: '|---|---|---|---|---|';
|
|
592
|
+
content += '\n### Quick Tasks Completed\n\n' + header + '\n' + sep + '\n';
|
|
593
|
+
}
|
|
594
|
+
const row = full
|
|
595
|
+
? `| ${id} | desc | 2026-06-09 | ${commit} | Verified | dir |`
|
|
596
|
+
: `| ${id} | desc | 2026-06-09 | ${commit} | dir |`;
|
|
597
|
+
content += row + '\n';
|
|
598
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
599
|
+
_commitFixture(planDir, 'fixture: quick row');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Append a malformed/partial "Quick Tasks Completed" row (empty id AND empty
|
|
604
|
+
* commit) — must be skipped by the row counter.
|
|
605
|
+
*/
|
|
606
|
+
function addMalformedQuickRow(planDir) {
|
|
607
|
+
const statePath = path.join(planDir, _STATE_PATH_REL);
|
|
608
|
+
let content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
|
609
|
+
if (!/###\s*Quick Tasks Completed/i.test(content)) {
|
|
610
|
+
content += '\n### Quick Tasks Completed\n\n' +
|
|
611
|
+
'| # | Description | Date | Commit | Directory |\n' +
|
|
612
|
+
'|---|---|---|---|---|\n';
|
|
613
|
+
}
|
|
614
|
+
content += '| | broken | 2026-06-09 | | dir |\n';
|
|
615
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
616
|
+
_commitFixture(planDir, 'fixture: malformed quick row');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Create a phase dir under projects/tp/phases/ and list it in ROADMAP.md so
|
|
621
|
+
* `roadmap analyze` enumerates it. complete=true → also write a SUMMARY.md so
|
|
622
|
+
* disk_status==='complete'; complete=false → PLAN.md only (planned, not counted).
|
|
623
|
+
*/
|
|
624
|
+
function addCompletedPhase(planDir, { complete }) {
|
|
625
|
+
const phaseDir = path.join(planDir, 'projects', 'tp', 'phases', '01-foo');
|
|
626
|
+
fs.mkdirSync(phaseDir, { recursive: true });
|
|
627
|
+
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
628
|
+
if (complete) {
|
|
629
|
+
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
|
630
|
+
}
|
|
631
|
+
const roadmapPath = path.join(planDir, _ROADMAP_PATH_REL);
|
|
632
|
+
fs.writeFileSync(
|
|
633
|
+
roadmapPath,
|
|
634
|
+
'# Roadmap\n\n## v0.1\n\n- [ ] **Phase 1: Foo**\n\n## Phase 1: Foo\n\n**Goal:** test phase\n'
|
|
635
|
+
);
|
|
636
|
+
_commitFixture(planDir, 'fixture: phase');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Test 6/7 fixture: a NON-ad-hoc milestone. STATE.md has NO `adhoc` key (so
|
|
641
|
+
* stateReadAdhoc → false). ROADMAP lists 2 phases, only 1 with a SUMMARY (<100%).
|
|
642
|
+
* Does NOT call create-adhoc.
|
|
643
|
+
*/
|
|
644
|
+
function addNonAdhocMilestone(planDir) {
|
|
645
|
+
const statePath = path.join(planDir, _STATE_PATH_REL);
|
|
646
|
+
fs.writeFileSync(statePath, '# State\nStatus: in_progress\nNon-adhoc milestone.\n');
|
|
647
|
+
// Phase 1 complete, Phase 2 planned only → <100%.
|
|
648
|
+
const p1 = path.join(planDir, 'projects', 'tp', 'phases', '01-alpha');
|
|
649
|
+
const p2 = path.join(planDir, 'projects', 'tp', 'phases', '02-beta');
|
|
650
|
+
fs.mkdirSync(p1, { recursive: true });
|
|
651
|
+
fs.mkdirSync(p2, { recursive: true });
|
|
652
|
+
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n');
|
|
653
|
+
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary\n');
|
|
654
|
+
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n');
|
|
655
|
+
const roadmapPath = path.join(planDir, _ROADMAP_PATH_REL);
|
|
656
|
+
fs.writeFileSync(
|
|
657
|
+
roadmapPath,
|
|
658
|
+
'# Roadmap\n\n## v0.1\n\n- [x] **Phase 1: Alpha**\n- [ ] **Phase 2: Beta**\n\n' +
|
|
659
|
+
'## Phase 1: Alpha\n\n**Goal:** a\n\n## Phase 2: Beta\n\n**Goal:** b\n'
|
|
660
|
+
);
|
|
661
|
+
_commitFixture(planDir, 'fixture: non-adhoc milestone');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
describe('state adhoc-readiness', () => {
|
|
665
|
+
let env;
|
|
666
|
+
beforeEach(() => { env = createAbandonEnv(); });
|
|
667
|
+
afterEach(() => { if (env) env.cleanup(); });
|
|
668
|
+
|
|
669
|
+
it('Test 1 (ADH-10 happy path, quicks-only): one well-formed quick row → ready', () => {
|
|
670
|
+
setupAdhoc(env.planDir);
|
|
671
|
+
addQuickRow(env.planDir, { id: '1', commit: 'abc1234', full: false });
|
|
672
|
+
|
|
673
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
674
|
+
assert.deepEqual(r, {
|
|
675
|
+
adhoc: true, completedQuickRows: 1, completedPhases: 0, ready: true, reason: 'ok',
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('Test 2 (ADH-10 completed-phase path): one completed phase → ready', () => {
|
|
680
|
+
setupAdhoc(env.planDir);
|
|
681
|
+
addCompletedPhase(env.planDir, { complete: true });
|
|
682
|
+
|
|
683
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
684
|
+
assert.equal(r.adhoc, true);
|
|
685
|
+
assert.equal(r.completedQuickRows, 0);
|
|
686
|
+
assert.equal(r.completedPhases, 1);
|
|
687
|
+
assert.equal(r.ready, true);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('Test 3 (ADH-10 edge): a planned-but-incomplete phase does NOT count', () => {
|
|
691
|
+
setupAdhoc(env.planDir);
|
|
692
|
+
addCompletedPhase(env.planDir, { complete: false });
|
|
693
|
+
|
|
694
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
695
|
+
assert.equal(r.completedPhases, 0, 'planned-incomplete phase must not be counted');
|
|
696
|
+
assert.equal(r.ready, false);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('Test 4 (ADH-10 edge): a malformed quick row is ignored', () => {
|
|
700
|
+
setupAdhoc(env.planDir);
|
|
701
|
+
addMalformedQuickRow(env.planDir);
|
|
702
|
+
|
|
703
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
704
|
+
assert.equal(r.completedQuickRows, 0, 'malformed quick row must be skipped');
|
|
705
|
+
assert.equal(r.ready, false);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('Test 5 (ADH-13 zero-work refusal): no quicks, no phases → not ready + exact string pinned', () => {
|
|
709
|
+
setupAdhoc(env.planDir);
|
|
710
|
+
|
|
711
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
712
|
+
assert.equal(r.adhoc, true);
|
|
713
|
+
assert.equal(r.ready, false);
|
|
714
|
+
assert.equal(r.reason, 'no_merged_work');
|
|
715
|
+
|
|
716
|
+
// The exact ADH-13 refusal string must live in the workflow (Plan 03 surface).
|
|
717
|
+
const wf = fs.readFileSync(_COMPLETE_MILESTONE_WF, 'utf-8');
|
|
718
|
+
assert.ok(
|
|
719
|
+
wf.includes(ADH13_REFUSAL),
|
|
720
|
+
'complete-milestone.md must contain the exact ADH-13 refusal string'
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('Test 6 (ADH-11 regression): non-ad-hoc milestone → adhoc:false', () => {
|
|
725
|
+
addNonAdhocMilestone(env.planDir);
|
|
726
|
+
|
|
727
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
728
|
+
assert.equal(r.adhoc, false, 'non-ad-hoc milestone must report adhoc:false');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('Test 7 (ADH-11 regression): strict gate untouched for a normal milestone', () => {
|
|
732
|
+
// For the non-ad-hoc fixture the predicate relaxes nothing: adhoc:false, so
|
|
733
|
+
// the workflow takes the unchanged strict 100%-phase gate (verified manually
|
|
734
|
+
// in Plan 03). The `ready` field is irrelevant in this branch.
|
|
735
|
+
addNonAdhocMilestone(env.planDir);
|
|
736
|
+
|
|
737
|
+
const r = abandonRun(env.planDir, 'state adhoc-readiness --raw');
|
|
738
|
+
assert.equal(r.adhoc, false);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// ─── milestone cleanup-adhoc / lifecycle cleanup (Phase 162, ADH-21/14/15) ───
|
|
743
|
+
//
|
|
744
|
+
// ADH-21: BOTH complete-milestone and abandon-milestone must leave no residual
|
|
745
|
+
// ad-hoc bookkeeping. The only real residue today is the snapshot base ref
|
|
746
|
+
// refs/dgs/adhoc/{slug}/base (created at milestone.cjs create-adhoc, never
|
|
747
|
+
// deleted by either exit). Plan 02 adds an idempotent `milestone cleanup-adhoc`
|
|
748
|
+
// verb + wires it into both lifecycle exits. Tests 1-5 drive that contract
|
|
749
|
+
// through the existing subprocess harness. Tests 6-7 (ADH-14) and 8-11
|
|
750
|
+
// (ADH-15) pin the status-surface and docs literals against the repo-root
|
|
751
|
+
// workflow/doc files so the Plan 03/04 prose cannot silently drift.
|
|
752
|
+
|
|
753
|
+
// Repo root, resolved from THIS file at deliver-great-systems/bin/lib/.
|
|
754
|
+
const _REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
755
|
+
|
|
756
|
+
/** Run a git command in `dir`; returns trimmed stdout. */
|
|
757
|
+
function gitOut(dir, gitArgs) {
|
|
758
|
+
return _execSync('git ' + gitArgs.join(' '), { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Parse config.local.json from a planning dir; {} if absent. */
|
|
762
|
+
function readLocalCfg(planDir) {
|
|
763
|
+
const p = path.join(planDir, 'config.local.json');
|
|
764
|
+
if (!fs.existsSync(p)) return {};
|
|
765
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
describe('milestone cleanup-adhoc / lifecycle cleanup', () => {
|
|
769
|
+
let env;
|
|
770
|
+
beforeEach(() => { env = createAbandonEnv(); });
|
|
771
|
+
afterEach(() => { if (env) env.cleanup(); });
|
|
772
|
+
|
|
773
|
+
// ── ADH-21: cleanup verb + abandon post-state ──
|
|
774
|
+
|
|
775
|
+
it('Test 1 (fixture sanity): base ref exists after create-adhoc', () => {
|
|
776
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
777
|
+
const refs = gitOut(env.planDir, ['for-each-ref', 'refs/dgs/adhoc/' + slug]);
|
|
778
|
+
assert.notEqual(refs, '', 'create-adhoc must leave a base ref for cleanup to remove');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('Test 2 (ADH-21): cleanup-adhoc removes the base ref', () => {
|
|
782
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
783
|
+
const r = abandonRun(env.planDir, 'milestone cleanup-adhoc ' + JSON.stringify(slug) + ' --raw');
|
|
784
|
+
assert.equal(r.slug, slug);
|
|
785
|
+
assert.equal(r.ref_deleted, true, 'first cleanup must delete the base ref');
|
|
786
|
+
assert.equal(gitOut(env.planDir, ['for-each-ref', 'refs/dgs/adhoc/' + slug]), '', 'no residual ref after cleanup');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('Test 3 (ADH-21): cleanup-adhoc is idempotent', () => {
|
|
790
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
791
|
+
abandonRun(env.planDir, 'milestone cleanup-adhoc ' + JSON.stringify(slug) + ' --raw');
|
|
792
|
+
const r2 = abandonRun(env.planDir, 'milestone cleanup-adhoc ' + JSON.stringify(slug) + ' --raw');
|
|
793
|
+
assert.equal(r2.ref_deleted, false, 'second run is a no-op for the ref');
|
|
794
|
+
assert.equal(gitOut(env.planDir, ['for-each-ref', 'refs/dgs/adhoc/' + slug]), '', 'ref still empty');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('Test 4 (ADH-21): cleanup-adhoc is a safe no-op for a non-ad-hoc slug', () => {
|
|
798
|
+
const r = abandonRun(env.planDir, 'milestone cleanup-adhoc ' + JSON.stringify('does-not-exist') + ' --raw');
|
|
799
|
+
assert.equal(r.ref_deleted, false);
|
|
800
|
+
assert.equal(r.entry_removed, false);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('Test 5 (ADH-21 acceptance): after abandon, no residual ref AND no worktree entry', () => {
|
|
804
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
805
|
+
abandonRun(env.planDir, 'milestone abandon --confirmed --raw');
|
|
806
|
+
|
|
807
|
+
// (a) no residual base ref
|
|
808
|
+
assert.equal(gitOut(env.planDir, ['for-each-ref', 'refs/dgs/adhoc/' + slug]), '', 'abandon must leave no residual base ref');
|
|
809
|
+
|
|
810
|
+
// (b) no residual worktree entry / adhoc keys
|
|
811
|
+
const cfg = readLocalCfg(env.planDir);
|
|
812
|
+
const wt = cfg.projects && cfg.projects.tp && cfg.projects.tp.worktrees;
|
|
813
|
+
assert.ok(!wt || wt[slug] === undefined, 'abandon must leave no residual worktrees[slug] entry');
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// ── ADH-14: status surfaces (grep assertions at repo root) ──
|
|
817
|
+
|
|
818
|
+
it('Test 6 (ADH-14): progress.md reads the marker + renders the ad-hoc container line', () => {
|
|
819
|
+
// Live render is Manual-Only; this pins the literal so Plan 03 cannot drift.
|
|
820
|
+
const wf = fs.readFileSync(path.join(_REPO_ROOT, 'deliver-great-systems', 'workflows', 'progress.md'), 'utf-8');
|
|
821
|
+
assert.ok(wf.includes('state read-adhoc'), 'progress.md must read the ad-hoc marker');
|
|
822
|
+
assert.ok(/[Aa]d-hoc container/.test(wf), 'progress.md must render an ad-hoc container line');
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('Test 7 (ADH-14): complete-milestone.md preamble references the ad-hoc marker', () => {
|
|
826
|
+
const wf = fs.readFileSync(path.join(_REPO_ROOT, 'deliver-great-systems', 'workflows', 'complete-milestone.md'), 'utf-8');
|
|
827
|
+
assert.ok(wf.includes('state read-adhoc'), 'complete-milestone.md must read the ad-hoc marker');
|
|
828
|
+
assert.ok(/[Aa]d-hoc/.test(wf), 'complete-milestone.md must mention the ad-hoc marker');
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// ── ADH-15: docs (grep assertions at repo root) ──
|
|
832
|
+
|
|
833
|
+
it('Test 8 (ADH-15): state.md documents the optional adhoc frontmatter field', () => {
|
|
834
|
+
const doc = fs.readFileSync(path.join(_REPO_ROOT, 'deliver-great-systems', 'templates', 'state.md'), 'utf-8');
|
|
835
|
+
assert.ok(/adhoc/.test(doc), 'state.md must mention the adhoc field');
|
|
836
|
+
assert.ok(/frontmatter/i.test(doc), 'state.md must describe it as frontmatter');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('Test 9 (ADH-15): help.md documents --adhoc and abandon-milestone', () => {
|
|
840
|
+
const doc = fs.readFileSync(path.join(_REPO_ROOT, 'deliver-great-systems', 'workflows', 'help.md'), 'utf-8');
|
|
841
|
+
assert.ok(doc.includes('--adhoc'), 'help.md must mention --adhoc');
|
|
842
|
+
assert.ok(doc.includes('abandon-milestone'), 'help.md must mention abandon-milestone');
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('Test 10 (ADH-15): COMMAND-REFERENCE.md documents --adhoc and abandon-milestone', () => {
|
|
846
|
+
const doc = fs.readFileSync(path.join(_REPO_ROOT, 'docs', 'COMMAND-REFERENCE.md'), 'utf-8');
|
|
847
|
+
assert.ok(doc.includes('--adhoc'), 'COMMAND-REFERENCE.md must mention --adhoc');
|
|
848
|
+
assert.ok(doc.includes('abandon-milestone'), 'COMMAND-REFERENCE.md must mention abandon-milestone');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('Test 11 (ADH-15): claude-md.md routing note mentions the ad-hoc container + abandon-milestone', () => {
|
|
852
|
+
const doc = fs.readFileSync(path.join(_REPO_ROOT, 'deliver-great-systems', 'templates', 'claude-md.md'), 'utf-8');
|
|
853
|
+
assert.ok(/ad-hoc/i.test(doc), 'claude-md.md must mention the ad-hoc container');
|
|
854
|
+
assert.ok(/abandon-milestone/.test(doc), 'claude-md.md must mention abandon-milestone');
|
|
855
|
+
});
|
|
856
|
+
});
|