@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
|
@@ -154,6 +154,125 @@ function writeLocalConfig(planDir, data) {
|
|
|
154
154
|
fs.writeFileSync(path.join(planDir, 'config.local.json'), JSON.stringify(data, null, 2) + '\n');
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// ─── composeMilestoneSlug (pure helper) ──────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('composeMilestoneSlug', () => {
|
|
160
|
+
const { composeMilestoneSlug } = require('./worktrees.cjs');
|
|
161
|
+
|
|
162
|
+
it('version + name -> version dots dashed, sanitized, joined', () => {
|
|
163
|
+
assert.equal(
|
|
164
|
+
composeMilestoneSlug({ version: 'v25.0', name: 'Ad-hoc Container' }),
|
|
165
|
+
'v25-0-ad-hoc-container'
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('blank name -> version alone (NO milestone collapse)', () => {
|
|
170
|
+
assert.equal(composeMilestoneSlug({ version: 'v25.0', name: '' }), 'v25-0');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("placeholder name 'milestone' -> v25-0-milestone (no bare collapse)", () => {
|
|
174
|
+
assert.equal(
|
|
175
|
+
composeMilestoneSlug({ version: 'v25.0', name: 'milestone' }),
|
|
176
|
+
'v25-0-milestone'
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('project prefix when multiProject', () => {
|
|
181
|
+
assert.equal(
|
|
182
|
+
composeMilestoneSlug({ version: 'v25.0', name: 'X', project: 'tp', multiProject: true }),
|
|
183
|
+
'tp-v25-0-x'
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('no project prefix when single-project', () => {
|
|
188
|
+
assert.equal(
|
|
189
|
+
composeMilestoneSlug({ version: 'v25.0', name: 'X', project: 'tp', multiProject: false }),
|
|
190
|
+
'v25-0-x'
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('both version AND name empty -> deterministic non-empty "milestone" last-resort', () => {
|
|
195
|
+
const a = composeMilestoneSlug({ version: '', name: '' });
|
|
196
|
+
const b = composeMilestoneSlug({ version: '', name: '' });
|
|
197
|
+
assert.ok(a && a.length > 0, 'should be non-empty');
|
|
198
|
+
assert.equal(a, 'milestone', 'last-resort should be the deterministic bare milestone');
|
|
199
|
+
assert.equal(a, b, 'should be stable across calls');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('result always passes through slug sanitization (lowercase, [a-z0-9-], <=50)', () => {
|
|
203
|
+
const out = composeMilestoneSlug({ version: 'v1.0', name: 'A Very Long !!! Name $$$ With Symbols & Spaces Everywhere Indeed Truly' });
|
|
204
|
+
assert.ok(/^[a-z0-9-]+$/.test(out), 'only lowercase alnum and dashes: ' + out);
|
|
205
|
+
assert.ok(out.length <= 50, 'slug should be <=50 chars: ' + out);
|
|
206
|
+
assert.ok(!/--/.test(out), 'no double dashes: ' + out);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ─── resolveMilestoneSlug (stamped / legacy-fallback / fresh) ─────────────────
|
|
211
|
+
|
|
212
|
+
describe('resolveMilestoneSlug', () => {
|
|
213
|
+
let env;
|
|
214
|
+
beforeEach(() => { env = createTestEnv(); });
|
|
215
|
+
afterEach(() => { env.cleanup(); });
|
|
216
|
+
|
|
217
|
+
it('returns the NEW composed slug when a worktree entry exists under it', () => {
|
|
218
|
+
const { resolveMilestoneSlug, composeMilestoneSlug } = require('./worktrees.cjs');
|
|
219
|
+
const newSlug = composeMilestoneSlug({ version: 'v25.0', name: 'Real Thing' });
|
|
220
|
+
// Seed a config entry keyed by the new slug.
|
|
221
|
+
const cfg = readLocalConfig(env.planDir);
|
|
222
|
+
cfg.projects = { tp: { worktrees: { [newSlug]: { type: 'milestone' } } } };
|
|
223
|
+
writeLocalConfig(env.planDir, cfg);
|
|
224
|
+
|
|
225
|
+
resetPaths(); initPaths(env.planDir);
|
|
226
|
+
const resolved = resolveMilestoneSlug(env.planDir, 'tp', { version: 'v25.0', name: 'Real Thing' });
|
|
227
|
+
assert.equal(resolved, newSlug);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('LEGACY FALLBACK: returns the OLD bare-name slug when only an old-style entry exists', () => {
|
|
231
|
+
const { resolveMilestoneSlug } = require('./worktrees.cjs');
|
|
232
|
+
const { generateSlugInternal } = require('./core.cjs');
|
|
233
|
+
const oldSlug = generateSlugInternal('Legacy Milestone'); // bare-name formula
|
|
234
|
+
const cfg = readLocalConfig(env.planDir);
|
|
235
|
+
cfg.projects = { tp: { worktrees: { [oldSlug]: { type: 'milestone' } } } };
|
|
236
|
+
writeLocalConfig(env.planDir, cfg);
|
|
237
|
+
|
|
238
|
+
resetPaths(); initPaths(env.planDir);
|
|
239
|
+
// No entry under the new composed slug -> must legacy-fall-back to oldSlug.
|
|
240
|
+
const resolved = resolveMilestoneSlug(env.planDir, 'tp', { version: 'v9.9', name: 'Legacy Milestone' });
|
|
241
|
+
assert.equal(resolved, oldSlug, 'pre-upgrade milestone must still resolve (no orphaning)');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns the NEW composed slug when neither entry exists (fresh creation stamps new formula)', () => {
|
|
245
|
+
const { resolveMilestoneSlug, composeMilestoneSlug } = require('./worktrees.cjs');
|
|
246
|
+
resetPaths(); initPaths(env.planDir);
|
|
247
|
+
const resolved = resolveMilestoneSlug(env.planDir, 'tp', { version: 'v3.0', name: 'Brand New' });
|
|
248
|
+
assert.equal(resolved, composeMilestoneSlug({ version: 'v3.0', name: 'Brand New' }));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('cmdWorktreesCreate — slug stamping', () => {
|
|
253
|
+
let env;
|
|
254
|
+
beforeEach(() => { env = createTestEnv(); });
|
|
255
|
+
afterEach(() => { env.cleanup(); });
|
|
256
|
+
|
|
257
|
+
it('stamps milestone_slug + slug_formula:v2 on milestone worktree entries', () => {
|
|
258
|
+
runCmd(env.planDir, 'worktrees create stamp-ms --type milestone');
|
|
259
|
+
const config = readLocalConfig(env.planDir);
|
|
260
|
+
const entry = config.projects.tp.worktrees['stamp-ms'];
|
|
261
|
+
assert.ok(entry);
|
|
262
|
+
assert.equal(entry.milestone_slug, 'stamp-ms');
|
|
263
|
+
assert.equal(entry.slug_formula, 'v2');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('does NOT stamp milestone_slug/slug_formula on quick worktree entries', () => {
|
|
267
|
+
runCmd(env.planDir, 'worktrees create stamp-quick --type quick');
|
|
268
|
+
const config = readLocalConfig(env.planDir);
|
|
269
|
+
const entry = config.projects.tp.worktrees['stamp-quick'];
|
|
270
|
+
assert.ok(entry);
|
|
271
|
+
assert.equal(entry.milestone_slug, undefined);
|
|
272
|
+
assert.equal(entry.slug_formula, undefined);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
157
276
|
// ─── REPOS.md setup field parsing ─────────────────────────────────────────────
|
|
158
277
|
|
|
159
278
|
describe('REPOS.md setup field parsing', () => {
|
|
@@ -792,6 +911,102 @@ describe('rebaseAndMerge', () => {
|
|
|
792
911
|
assert.ok(!result.success);
|
|
793
912
|
assert.ok(result.error.includes('nonexistent'));
|
|
794
913
|
});
|
|
914
|
+
|
|
915
|
+
it('deletes the owned remote milestone branch on merge success', () => {
|
|
916
|
+
const { slug, wtPath, branchName } = createWorktreeForRebase(env);
|
|
917
|
+
|
|
918
|
+
// Commit on milestone branch and push it to origin so a remote branch exists.
|
|
919
|
+
fs.writeFileSync(path.join(wtPath, 'feat.txt'), 'work');
|
|
920
|
+
execSync('git add . && git commit -m "ms work"', { cwd: wtPath, stdio: 'pipe', env: env.fullEnv });
|
|
921
|
+
execSync('git push origin ' + JSON.stringify(branchName), { cwd: wtPath, stdio: 'pipe', env: env.fullEnv });
|
|
922
|
+
|
|
923
|
+
// Precondition: remote has the milestone branch.
|
|
924
|
+
const before = execSync('git ls-remote --heads origin ' + JSON.stringify(branchName), { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe', env: env.fullEnv });
|
|
925
|
+
assert.ok(before.trim().length > 0, 'precondition: remote milestone branch exists');
|
|
926
|
+
|
|
927
|
+
const { rebaseAndMerge } = require('./worktrees.cjs');
|
|
928
|
+
const result = rebaseAndMerge(env.planDir, 'code-repo', slug, { push: true });
|
|
929
|
+
|
|
930
|
+
assert.ok(result.success, 'should succeed: ' + (result.error || ''));
|
|
931
|
+
assert.ok(result.merged);
|
|
932
|
+
assert.equal(result.remoteBranchDeleted, true, 'should report remoteBranchDeleted:true');
|
|
933
|
+
|
|
934
|
+
// Remote no longer has the milestone branch.
|
|
935
|
+
const after = execSync('git ls-remote --heads origin ' + JSON.stringify(branchName), { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe', env: env.fullEnv });
|
|
936
|
+
assert.equal(after.trim(), '', 'remote milestone branch should be deleted');
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('keeps success when there is no remote milestone branch to delete (non-fatal)', () => {
|
|
940
|
+
const { slug, wtPath } = createWorktreeForRebase(env);
|
|
941
|
+
|
|
942
|
+
// Commit on milestone branch but do NOT push it -> no remote milestone branch.
|
|
943
|
+
fs.writeFileSync(path.join(wtPath, 'feat.txt'), 'work');
|
|
944
|
+
execSync('git add . && git commit -m "ms work"', { cwd: wtPath, stdio: 'pipe', env: env.fullEnv });
|
|
945
|
+
|
|
946
|
+
const { rebaseAndMerge } = require('./worktrees.cjs');
|
|
947
|
+
const result = rebaseAndMerge(env.planDir, 'code-repo', slug, { push: true });
|
|
948
|
+
|
|
949
|
+
// Delete-failure (no such remote branch) must be non-fatal.
|
|
950
|
+
assert.ok(result.success, 'should still succeed: ' + (result.error || ''));
|
|
951
|
+
assert.ok(result.merged);
|
|
952
|
+
assert.equal(result.remoteBranchDeleted, false, 'no remote branch -> remoteBranchDeleted:false');
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('never attempts remote delete on a conflicted merge', () => {
|
|
956
|
+
const { slug, wtPath } = createWorktreeForRebase(env);
|
|
957
|
+
|
|
958
|
+
// Create a conflict.
|
|
959
|
+
fs.writeFileSync(path.join(env.codeDir, 'shared.txt'), 'main version');
|
|
960
|
+
execSync('git add . && git commit -m "main shared"', { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
961
|
+
execSync('git push origin main', { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
962
|
+
fs.writeFileSync(path.join(wtPath, 'shared.txt'), 'milestone version');
|
|
963
|
+
execSync('git add . && git commit -m "ms shared"', { cwd: wtPath, stdio: 'pipe', env: env.fullEnv });
|
|
964
|
+
|
|
965
|
+
const { rebaseAndMerge } = require('./worktrees.cjs');
|
|
966
|
+
const result = rebaseAndMerge(env.planDir, 'code-repo', slug, { push: true });
|
|
967
|
+
|
|
968
|
+
assert.ok(!result.success);
|
|
969
|
+
assert.ok(result.conflicted);
|
|
970
|
+
assert.equal(result.remoteBranchDeleted, undefined, 'remoteBranchDeleted only on success path');
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// ─── checkRemoteCollision ────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
describe('checkRemoteCollision', () => {
|
|
977
|
+
let env;
|
|
978
|
+
beforeEach(() => { env = createRebaseTestEnv(); });
|
|
979
|
+
afterEach(() => { env.cleanup(); });
|
|
980
|
+
|
|
981
|
+
it('reports collision when origin has a milestone branch with unmerged commits', () => {
|
|
982
|
+
const slug = 'squat-slug';
|
|
983
|
+
const branch = 'milestone/' + slug;
|
|
984
|
+
|
|
985
|
+
// Create a milestone branch in the main checkout with a commit NOT on main,
|
|
986
|
+
// then push it to origin so it squats the name.
|
|
987
|
+
execSync('git checkout -b ' + branch, { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
988
|
+
fs.writeFileSync(path.join(env.codeDir, 'squat.txt'), 'unmerged work');
|
|
989
|
+
execSync('git add . && git commit -m "squatting commit"', { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
990
|
+
execSync('git push origin ' + JSON.stringify(branch), { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
991
|
+
// Return main checkout to base_branch.
|
|
992
|
+
execSync('git checkout main', { cwd: env.codeDir, stdio: 'pipe', env: env.fullEnv });
|
|
993
|
+
|
|
994
|
+
const { checkRemoteCollision } = require('./worktrees.cjs');
|
|
995
|
+
const result = checkRemoteCollision(env.planDir, slug);
|
|
996
|
+
|
|
997
|
+
assert.equal(result.collision, true, 'should detect collision');
|
|
998
|
+
assert.equal(result.branch, branch);
|
|
999
|
+
assert.ok(result.message && result.message.length > 0, 'should have an actionable message');
|
|
1000
|
+
assert.ok(result.message.includes(branch), 'message should name the colliding branch');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('reports no collision when origin has no such milestone branch', () => {
|
|
1004
|
+
const { checkRemoteCollision } = require('./worktrees.cjs');
|
|
1005
|
+
const result = checkRemoteCollision(env.planDir, 'no-such-slug');
|
|
1006
|
+
|
|
1007
|
+
assert.equal(result.collision, false);
|
|
1008
|
+
assert.equal(result.message, '');
|
|
1009
|
+
});
|
|
795
1010
|
});
|
|
796
1011
|
|
|
797
1012
|
// ─── checkWorktreeHealth ─────────────────────────────────────────────────────
|
|
@@ -928,6 +1143,200 @@ describe('cmdWorktreesCreate — milestone_version', () => {
|
|
|
928
1143
|
});
|
|
929
1144
|
});
|
|
930
1145
|
|
|
1146
|
+
// ─── new-milestone --adhoc creation ──────────────────────────────────────────
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Ad-hoc creation helpers.
|
|
1150
|
+
*
|
|
1151
|
+
* The `--adhoc` flow is driven through the CLI verb `milestone create-adhoc`
|
|
1152
|
+
* (Wave 2), mirroring how the rest of this file invokes worktree commands via
|
|
1153
|
+
* subprocess. The verb performs the canonical 6-step atomic creation:
|
|
1154
|
+
* 1. assert preconditions 2. capture planning base ref
|
|
1155
|
+
* 3. create code worktrees 4. mirror adhoc marker on worktree entry
|
|
1156
|
+
* 5. commit STATE.md adhoc:true 6. set active_context LAST
|
|
1157
|
+
* with full rollback on any failure.
|
|
1158
|
+
*/
|
|
1159
|
+
|
|
1160
|
+
/** Run the ad-hoc creation verb, returning parsed JSON. */
|
|
1161
|
+
function runAdhocCreate(planDir, name, extraArgs) {
|
|
1162
|
+
return runCmd(planDir, 'milestone create-adhoc ' + JSON.stringify(name) + ' ' + (extraArgs || ''));
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Run the ad-hoc creation verb expecting an error; returns stderr string. */
|
|
1166
|
+
function runAdhocCreateError(planDir, name, extraArgs) {
|
|
1167
|
+
return runCmdError(planDir, 'milestone create-adhoc ' + JSON.stringify(name) + ' ' + (extraArgs || ''));
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** True if a git ref resolves in the given repo. */
|
|
1171
|
+
function refExists(repoDir, ref) {
|
|
1172
|
+
try {
|
|
1173
|
+
execSync('git show-ref --verify --quiet ' + JSON.stringify(ref), { cwd: repoDir, stdio: 'pipe' });
|
|
1174
|
+
return true;
|
|
1175
|
+
} catch {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Read STATE.md frontmatter (naive YAML key:value scan, adhoc/milestone only). */
|
|
1181
|
+
function readStateFrontmatter(planDir) {
|
|
1182
|
+
const statePath = path.join(planDir, 'projects', 'tp', 'STATE.md');
|
|
1183
|
+
if (!fs.existsSync(statePath)) return {};
|
|
1184
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
1185
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1186
|
+
if (!m) return {};
|
|
1187
|
+
const fm = {};
|
|
1188
|
+
for (const line of m[1].split('\n')) {
|
|
1189
|
+
const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
1190
|
+
if (kv) fm[kv[1]] = kv[2].trim();
|
|
1191
|
+
}
|
|
1192
|
+
return fm;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
describe('new-milestone --adhoc creation', () => {
|
|
1196
|
+
let env;
|
|
1197
|
+
beforeEach(() => { env = createTestEnv(); });
|
|
1198
|
+
afterEach(() => { env.cleanup(); });
|
|
1199
|
+
|
|
1200
|
+
it('creates worktrees, marker, base ref, and sets active_context last', () => {
|
|
1201
|
+
const result = runAdhocCreate(env.planDir, 'My Adhoc Thing');
|
|
1202
|
+
const slug = result.slug;
|
|
1203
|
+
assert.ok(slug, 'should return a slug');
|
|
1204
|
+
|
|
1205
|
+
// Structural composition: slug embeds the version segment (default v0.1 ->
|
|
1206
|
+
// v0-1) ahead of the name, NOT the bare name — branch-name collision fix.
|
|
1207
|
+
assert.ok(slug.startsWith('v0-1-'), 'ad-hoc slug should be version-prefixed: ' + slug);
|
|
1208
|
+
assert.ok(slug.includes('my-adhoc-thing'), 'ad-hoc slug should include the name segment: ' + slug);
|
|
1209
|
+
|
|
1210
|
+
// Each registered code repo has a milestone/{slug} branch + worktree.
|
|
1211
|
+
const branches = execSync('git branch', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
1212
|
+
assert.ok(branches.includes('milestone/' + slug), 'code repo should have milestone/{slug} branch');
|
|
1213
|
+
|
|
1214
|
+
// Worktree entry: type milestone, adhoc:true, non-empty adhoc_base_ref.
|
|
1215
|
+
const config = readLocalConfig(env.planDir);
|
|
1216
|
+
const entry = config.projects.tp.worktrees[slug];
|
|
1217
|
+
assert.ok(entry, 'worktree entry should exist');
|
|
1218
|
+
assert.equal(entry.type, 'milestone');
|
|
1219
|
+
assert.equal(entry.adhoc, true, 'entry should carry adhoc:true');
|
|
1220
|
+
assert.ok(entry.adhoc_base_ref && entry.adhoc_base_ref.length > 0, 'entry should record adhoc_base_ref');
|
|
1221
|
+
|
|
1222
|
+
// STATE.md frontmatter has adhoc: true.
|
|
1223
|
+
const fm = readStateFrontmatter(env.planDir);
|
|
1224
|
+
assert.equal(fm.adhoc, 'true', 'STATE.md frontmatter should carry adhoc: true');
|
|
1225
|
+
|
|
1226
|
+
// Base ref exists in planning repo.
|
|
1227
|
+
assert.ok(refExists(env.planDir, 'refs/dgs/adhoc/' + slug + '/base'), 'base ref should exist');
|
|
1228
|
+
|
|
1229
|
+
// active_context set to slug (set LAST).
|
|
1230
|
+
assert.equal(config.execution.active_context, slug, 'active_context should equal slug');
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it('routes a quick run after --adhoc onto the milestone branch', () => {
|
|
1234
|
+
const result = runAdhocCreate(env.planDir, 'Route Test');
|
|
1235
|
+
const slug = result.slug;
|
|
1236
|
+
|
|
1237
|
+
// detectQuickMode must report milestone-context (NOT product) for the slug.
|
|
1238
|
+
resetPaths();
|
|
1239
|
+
initPaths(env.planDir);
|
|
1240
|
+
const { detectQuickMode } = require('./quick.cjs');
|
|
1241
|
+
const mode = detectQuickMode(env.planDir, false);
|
|
1242
|
+
assert.equal(mode.mode, 'milestone-context', 'quick should route to milestone-context');
|
|
1243
|
+
assert.equal(mode.activeSlug, slug);
|
|
1244
|
+
|
|
1245
|
+
// A code commit lands on milestone/{slug}, not base_branch.
|
|
1246
|
+
const config = readLocalConfig(env.planDir);
|
|
1247
|
+
const wtPath = config.projects.tp.worktrees[slug].repos['code-repo'];
|
|
1248
|
+
fs.writeFileSync(path.join(wtPath, 'quick-work.txt'), 'work');
|
|
1249
|
+
execSync('git add . && git commit -m "quick work"', { cwd: wtPath, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
|
|
1250
|
+
const wtBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: wtPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1251
|
+
assert.equal(wtBranch, 'milestone/' + slug, 'commit should be on milestone branch');
|
|
1252
|
+
const mainLog = execSync('git log --oneline main', { cwd: env.codeDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
1253
|
+
assert.ok(!mainLog.includes('quick work'), 'base_branch must NOT have the quick commit');
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it('rolls back fully when a code-repo worktree creation fails', () => {
|
|
1257
|
+
// The structural ad-hoc slug is [<version>-]<name-slug>; 'conflict slug'
|
|
1258
|
+
// with the default v0.1 version composes to 'v0-1-conflict-slug'.
|
|
1259
|
+
const composedSlug = 'v0-1-conflict-slug';
|
|
1260
|
+
// Force a worktree-create failure by pre-creating the conflicting branch that
|
|
1261
|
+
// is already checked out in the main checkout (worktree add will refuse).
|
|
1262
|
+
execSync('git branch milestone/' + composedSlug, { cwd: env.codeDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
|
|
1263
|
+
execSync('git checkout milestone/' + composedSlug, { cwd: env.codeDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
|
|
1264
|
+
|
|
1265
|
+
const err = runAdhocCreateError(env.planDir, 'conflict slug');
|
|
1266
|
+
assert.ok(err, 'should error on worktree-create failure');
|
|
1267
|
+
|
|
1268
|
+
// No residue: no base ref, no worktree entry, no STATE marker, no active_context.
|
|
1269
|
+
assert.ok(!refExists(env.planDir, 'refs/dgs/adhoc/' + composedSlug + '/base'), 'base ref must be removed on rollback');
|
|
1270
|
+
const config = readLocalConfig(env.planDir);
|
|
1271
|
+
const wt = config.projects && config.projects.tp && config.projects.tp.worktrees;
|
|
1272
|
+
assert.ok(!wt || !wt[composedSlug], 'no worktree entry should remain');
|
|
1273
|
+
const fm = readStateFrontmatter(env.planDir);
|
|
1274
|
+
assert.notEqual(fm.adhoc, 'true', 'STATE.md must not have adhoc:true after rollback');
|
|
1275
|
+
assert.ok(!config.execution || !config.execution.active_context, 'active_context must be unset after rollback');
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it('refuses when the planning or a code working tree is dirty', () => {
|
|
1279
|
+
// Dirty a tracked file in the planning repo.
|
|
1280
|
+
fs.writeFileSync(path.join(env.planDir, 'config.json'), JSON.stringify({ git: { base_branch: 'main' }, dirty: true }, null, 2));
|
|
1281
|
+
|
|
1282
|
+
const err = runAdhocCreateError(env.planDir, 'dirty test');
|
|
1283
|
+
assert.ok(err, 'should refuse on dirty tree');
|
|
1284
|
+
|
|
1285
|
+
// ZERO mutation.
|
|
1286
|
+
assert.ok(!refExists(env.planDir, 'refs/dgs/adhoc/dirty-test/base'), 'no base ref on refusal');
|
|
1287
|
+
const config = readLocalConfig(env.planDir);
|
|
1288
|
+
const wt = config.projects && config.projects.tp && config.projects.tp.worktrees;
|
|
1289
|
+
assert.ok(!wt || !wt['dirty-test'], 'no worktree entry on refusal');
|
|
1290
|
+
assert.ok(!config.execution || !config.execution.active_context, 'no active_context on refusal');
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it('refuses when an active_context is already set', () => {
|
|
1294
|
+
// Pre-set active_context.
|
|
1295
|
+
const cfg = readLocalConfig(env.planDir);
|
|
1296
|
+
cfg.execution = { active_context: 'someone-else' };
|
|
1297
|
+
writeLocalConfig(env.planDir, cfg);
|
|
1298
|
+
|
|
1299
|
+
const err = runAdhocCreateError(env.planDir, 'second context');
|
|
1300
|
+
assert.ok(err, 'should refuse when active_context already set');
|
|
1301
|
+
|
|
1302
|
+
// ZERO mutation — active_context unchanged, no new ref/entry.
|
|
1303
|
+
const after = readLocalConfig(env.planDir);
|
|
1304
|
+
assert.equal(after.execution.active_context, 'someone-else', 'active_context must be unchanged');
|
|
1305
|
+
assert.ok(!refExists(env.planDir, 'refs/dgs/adhoc/second-context/base'), 'no base ref on refusal');
|
|
1306
|
+
const wt = after.projects && after.projects.tp && after.projects.tp.worktrees;
|
|
1307
|
+
assert.ok(!wt || !wt['second-context'], 'no worktree entry on refusal');
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it('defaults the version recommendation to minor and writes vX.Y provisionally', () => {
|
|
1311
|
+
const result = runAdhocCreate(env.planDir, 'Version Default');
|
|
1312
|
+
const slug = result.slug;
|
|
1313
|
+
const fm = readStateFrontmatter(env.planDir);
|
|
1314
|
+
// STATE.md frontmatter carries a two-component vX.Y version.
|
|
1315
|
+
const version = fm.milestone || fm.version || (result && result.version);
|
|
1316
|
+
assert.ok(version, 'a version should be recorded');
|
|
1317
|
+
assert.match(String(version), /^v?\d+\.\d+$/, 'version should be two-component vX.Y: ' + version);
|
|
1318
|
+
assert.ok(slug);
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it('prints the ADH-22 abandon advisory on successful creation', () => {
|
|
1322
|
+
// The advisory is printed on stderr. Capture both streams (2>&1) so the
|
|
1323
|
+
// advisory is visible whether the command succeeds or fails.
|
|
1324
|
+
let out = '';
|
|
1325
|
+
try {
|
|
1326
|
+
out = execSync(
|
|
1327
|
+
'node ' + JSON.stringify(DGS_TOOLS) + ' milestone create-adhoc ' + JSON.stringify('Advisory Test') + ' 2>&1',
|
|
1328
|
+
{ cwd: env.planDir, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env: { ...process.env, ...GIT_ENV } }
|
|
1329
|
+
);
|
|
1330
|
+
} catch (e) {
|
|
1331
|
+
out = (e.stdout || '') + (e.stderr || '');
|
|
1332
|
+
}
|
|
1333
|
+
const combined = out;
|
|
1334
|
+
// Advisory mentions abandon + a --main / base_branch quick STATE.md revert.
|
|
1335
|
+
assert.match(combined, /abandon/i, 'advisory should mention abandon');
|
|
1336
|
+
assert.match(combined, /--main|base_branch|STATE\.md/, 'advisory should reference the --main quick STATE.md revert');
|
|
1337
|
+
});
|
|
1338
|
+
});
|
|
1339
|
+
|
|
931
1340
|
// ─── main checkout invariant ─────────────────────────────────────────────────
|
|
932
1341
|
|
|
933
1342
|
describe('main checkout invariant', () => {
|
|
@@ -53,6 +53,8 @@ When the request involves creating, modifying, or deleting code or configuration
|
|
|
53
53
|
|
|
54
54
|
**Is it a trivial 1-line fix?** Route through `/dgs:fast` (always direct to main, zero overhead)
|
|
55
55
|
|
|
56
|
+
- **Ad-hoc container milestone:** `/dgs:new-milestone --adhoc "title"` starts a lightweight container where quicks & fasts route into the milestone; `/dgs:abandon-milestone` discards it cleanly.
|
|
57
|
+
|
|
56
58
|
**When ambiguous, default to the smaller command.** Prefer `/dgs:fast` or `/dgs:quick` over `/dgs:execute-phase`.
|
|
57
59
|
|
|
58
60
|
Before invoking, mention briefly: "Small fix, routing through `/dgs:fast`." -- command name and short reason.
|
|
@@ -75,6 +75,22 @@ Stopped at: [Description of last completed action]
|
|
|
75
75
|
Resume file: [Path to .continue-here*.md if exists, otherwise "None"]
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
## Optional Frontmatter
|
|
79
|
+
|
|
80
|
+
Ad-hoc container milestones (created via `/dgs:new-milestone --adhoc`) stamp an optional
|
|
81
|
+
YAML frontmatter block at the top of STATE.md:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
---
|
|
85
|
+
adhoc: true # marks this as an ad-hoc container milestone
|
|
86
|
+
milestone: v0.1 # provisional version
|
|
87
|
+
---
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The `adhoc` field is read by the tooling (`stateReadAdhoc`) to relax the completion
|
|
91
|
+
readiness gate and surface the ad-hoc marker in `/dgs:progress` and the completion
|
|
92
|
+
preamble. It is absent for normal spec/phase-driven milestones.
|
|
93
|
+
|
|
78
94
|
<purpose>
|
|
79
95
|
|
|
80
96
|
STATE.md is the project's short-term memory spanning all phases and sessions.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<purpose>
|
|
2
|
+
Abandon the active ad-hoc milestone: remove the milestone worktrees and local code branches, and path-scoped-restore the 5 project planning docs (PROJECT.md, STATE.md, ROADMAP.md, REQUIREMENTS.md, config.json) to their pre-milestone state from the snapshot base ref — without merging. An attributed reversion commit records the discard.
|
|
3
|
+
|
|
4
|
+
Ad-hoc-only: spec/phase-driven milestones are refused with guidance. Requires explicit confirmation before proceeding. This is destructive and cannot be undone.
|
|
5
|
+
</purpose>
|
|
6
|
+
|
|
7
|
+
<context_tier>none</context_tier>
|
|
8
|
+
|
|
9
|
+
<process>
|
|
10
|
+
|
|
11
|
+
**Step 1: Validate active ad-hoc milestone**
|
|
12
|
+
|
|
13
|
+
Resolve the active context slug:
|
|
14
|
+
```bash
|
|
15
|
+
SLUG=$(node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" config-get execution.active_context 2>/dev/null | sed 's/^"//;s/"$//')
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
If `SLUG` is empty or `null`:
|
|
19
|
+
```
|
|
20
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
21
|
+
║ ERROR ║
|
|
22
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
23
|
+
|
|
24
|
+
No active milestone to abandon.
|
|
25
|
+
```
|
|
26
|
+
End workflow.
|
|
27
|
+
|
|
28
|
+
Verify the active milestone is ad-hoc (both the STATE.md marker AND the worktree entry):
|
|
29
|
+
```bash
|
|
30
|
+
ADHOC=$(node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" state read-adhoc 2>/dev/null)
|
|
31
|
+
ENTRY_ADHOC=$(node -e "
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
try {
|
|
34
|
+
const cfg = JSON.parse(fs.readFileSync('config.local.json', 'utf-8'));
|
|
35
|
+
const p = cfg.current_project;
|
|
36
|
+
const e = cfg.projects && cfg.projects[p] && cfg.projects[p].worktrees && cfg.projects[p].worktrees['$SLUG'];
|
|
37
|
+
process.stdout.write(String(!!(e && e.adhoc === true && e.adhoc_base_ref)));
|
|
38
|
+
} catch { process.stdout.write('false'); }
|
|
39
|
+
")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If `ADHOC` is not `true` OR `ENTRY_ADHOC` is not `true`:
|
|
43
|
+
```
|
|
44
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
45
|
+
║ ERROR ║
|
|
46
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
47
|
+
|
|
48
|
+
abandon-milestone only discards ad-hoc milestones.
|
|
49
|
+
'${SLUG}' is spec/phase-driven — complete it with /dgs:complete-milestone.
|
|
50
|
+
|
|
51
|
+
No changes made.
|
|
52
|
+
```
|
|
53
|
+
End workflow. (Make NO changes.)
|
|
54
|
+
|
|
55
|
+
**Step 2: Confirm abandonment (ADH-12)**
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
AskUserQuestion(
|
|
59
|
+
header: "Abandon Milestone",
|
|
60
|
+
question: "Abandon ad-hoc milestone '${SLUG}'? All uncommitted and committed milestone changes will be lost — worktrees/branches removed and planning docs restored to their pre-milestone state.",
|
|
61
|
+
options: [
|
|
62
|
+
{ label: "Yes, abandon", description: "Remove worktrees/branches and restore planning docs — changes are lost" },
|
|
63
|
+
{ label: "No, keep", description: "Cancel — milestone remains active" }
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If "No, keep": Display `Milestone '${SLUG}' remains active.` End workflow.
|
|
69
|
+
|
|
70
|
+
**Step 3: Execute abandonment**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
74
|
+
DGS ► ABANDONING MILESTONE
|
|
75
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
76
|
+
|
|
77
|
+
◆ Removing worktrees/branches and restoring planning docs...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
RESULT=$(node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" milestone abandon --confirmed 2>&1)
|
|
82
|
+
EXIT_CODE=$?
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Step 4: Display result**
|
|
86
|
+
|
|
87
|
+
**If exit code is 0 (success):**
|
|
88
|
+
Parse the restored-docs list and any warnings from RESULT (JSON: `{ abandoned, slug, base_ref, restored, reverted, warnings }`).
|
|
89
|
+
```
|
|
90
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
91
|
+
DGS ► MILESTONE ABANDONED ✓
|
|
92
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
93
|
+
|
|
94
|
+
Milestone '${SLUG}' abandoned.
|
|
95
|
+
Worktrees and local branches removed; active context cleared.
|
|
96
|
+
Restored to pre-milestone state: <restored docs>
|
|
97
|
+
No changes merged to base_branch.
|
|
98
|
+
```
|
|
99
|
+
During teardown, abandon now also DELETES the owned remote `milestone/${SLUG}`
|
|
100
|
+
branch on origin (`git push origin --delete milestone/<slug>`), reclaiming the
|
|
101
|
+
milestone name to prevent future branch-name collisions. This replaces the prior
|
|
102
|
+
warn-only "remote ref left for you to delete" behaviour. A failed remote delete
|
|
103
|
+
is **non-fatal**: it is collected into `warnings` and the abandon still reports
|
|
104
|
+
`abandoned: true`. Surface any such warnings from RESULT verbatim.
|
|
105
|
+
|
|
106
|
+
**If error (non-zero exit):**
|
|
107
|
+
Display the error / reverted-vs-not report from RESULT verbatim (ADH-20 — it states precisely what was and was not reverted, and how to recover the remainder). Make no further changes.
|
|
108
|
+
|
|
109
|
+
</process>
|
|
110
|
+
|
|
111
|
+
<success_criteria>
|
|
112
|
+
- [ ] Active ad-hoc milestone validated (slug resolved from active_context)
|
|
113
|
+
- [ ] Non-ad-hoc milestones refused with guidance, no changes (ADH-08)
|
|
114
|
+
- [ ] Confirmation required before abandonment, with the loss-warning message (ADH-12)
|
|
115
|
+
- [ ] Worktrees and local branches removed without merging
|
|
116
|
+
- [ ] The 5 project docs path-scoped-restored to the base ref (never a whole-tree reset) (ADH-09)
|
|
117
|
+
- [ ] Attributed reversion commit recorded (ADH-20)
|
|
118
|
+
- [ ] active_context cleared
|
|
119
|
+
- [ ] Owned remote milestone branch deleted during teardown (non-fatal on failure); failures surfaced as warnings (ADH-20)
|
|
120
|
+
</success_criteria>
|