dotmd-cli 0.73.0 → 0.74.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/package.json +1 -1
- package/src/atomic-mutation.mjs +35 -5
- package/src/hub-membership.mjs +151 -0
- package/src/hub.mjs +53 -0
- package/src/index.mjs +8 -0
- package/src/lifecycle.mjs +5 -1
- package/src/pickup.mjs +11 -2
- package/src/prompts.mjs +4 -0
- package/src/runlist.mjs +1 -51
package/package.json
CHANGED
package/src/atomic-mutation.mjs
CHANGED
|
@@ -1279,9 +1279,9 @@ export function createFileExclusive(filePath, content, options) {
|
|
|
1279
1279
|
}
|
|
1280
1280
|
|
|
1281
1281
|
export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
1282
|
-
const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [] } = options;
|
|
1282
|
+
const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
|
|
1283
1283
|
recoverAbandonedTransactions(repoRoot, options);
|
|
1284
|
-
return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path)], options, () => {
|
|
1284
|
+
return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
|
|
1285
1285
|
testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
|
|
1286
1286
|
testHooks?.beforeMoveSnapshot?.({ sourcePath, targetPath });
|
|
1287
1287
|
const source = snapshotFile(sourcePath);
|
|
@@ -1304,6 +1304,15 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1304
1304
|
}
|
|
1305
1305
|
return { ...item, snapshot };
|
|
1306
1306
|
});
|
|
1307
|
+
// Read-only participants (see `mutateFileSet`). Checked before the
|
|
1308
|
+
// transaction manifest exists, so a guard conflict can never leave a
|
|
1309
|
+
// transaction to recover from.
|
|
1310
|
+
for (const guard of guards) {
|
|
1311
|
+
const snapshot = snapshotFile(guard.path);
|
|
1312
|
+
if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
|
|
1313
|
+
throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1307
1316
|
for (const item of creations) {
|
|
1308
1317
|
if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
|
|
1309
1318
|
}
|
|
@@ -1627,9 +1636,30 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1627
1636
|
});
|
|
1628
1637
|
}
|
|
1629
1638
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1639
|
+
// `guards` are read-only participants: files the mutation's VALIDITY depends on
|
|
1640
|
+
// but that it never writes. They take part in locking and in the same
|
|
1641
|
+
// compare-and-swap as `updates`, so a mutation decided from a file it doesn't
|
|
1642
|
+
// modify cannot land after that file changed underneath it.
|
|
1643
|
+
//
|
|
1644
|
+
// Without this, a decision read and the write it justifies are two separate
|
|
1645
|
+
// steps with nothing holding the gap. A claim that adopts an already-in-session
|
|
1646
|
+
// plan writes only the ownership record — so a concurrent `set` releasing that
|
|
1647
|
+
// plan could win the status write while the claim still took ownership, leaving
|
|
1648
|
+
// a record that owns a plan the file says is `active`. A no-op update on the
|
|
1649
|
+
// plan file would close the gap too, but it would rewrite bytes and report the
|
|
1650
|
+
// plan as changed to anything counting touched files. A guard says what is meant.
|
|
1651
|
+
export function mutateFileSet({ updates = [], creations = [], guards = [] }, options) {
|
|
1652
|
+
const paths = [...updates.map(item => item.path), ...creations.map(item => item.path), ...guards.map(item => item.path)];
|
|
1632
1653
|
return withPathLocks(paths, options, () => {
|
|
1654
|
+
// Guards are pure preconditions, so they are checked before anything at all
|
|
1655
|
+
// is created — a guard conflict must leave the tree byte-identical, not even
|
|
1656
|
+
// an empty directory behind.
|
|
1657
|
+
for (const guard of guards) {
|
|
1658
|
+
const snapshot = snapshotFile(guard.path);
|
|
1659
|
+
if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
|
|
1660
|
+
throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1633
1663
|
const createdDirectories = [];
|
|
1634
1664
|
for (const item of creations) {
|
|
1635
1665
|
const directory = path.dirname(item.path);
|
|
@@ -1654,7 +1684,7 @@ export function mutateFileSet({ updates = [], creations = [] }, options) {
|
|
|
1654
1684
|
for (const item of creations) {
|
|
1655
1685
|
if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
|
|
1656
1686
|
}
|
|
1657
|
-
options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations });
|
|
1687
|
+
options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations, guards });
|
|
1658
1688
|
|
|
1659
1689
|
const committedUpdates = [];
|
|
1660
1690
|
const committedCreations = [];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { resolveRefPath, toRepoPath } from './util.mjs';
|
|
5
|
+
import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
|
|
6
|
+
|
|
7
|
+
// Membership drift: a hub's list of children and the plans that claim it via
|
|
8
|
+
// `parent_plan:` are two halves of one relationship, and either half can go
|
|
9
|
+
// stale on its own. dotmd already checks one direction — `checkRunlistBackPointers`
|
|
10
|
+
// warns when a `runlist:` child lacks the back-ref. These are the two arrows it
|
|
11
|
+
// doesn't cover.
|
|
12
|
+
//
|
|
13
|
+
// Membership only. The row is NEVER generated — same constraint as the status
|
|
14
|
+
// guard: the prose beside a row is why the row exists.
|
|
15
|
+
//
|
|
16
|
+
// ── What counts as a membership claim (measured, not assumed) ───────────────
|
|
17
|
+
//
|
|
18
|
+
// A body table row is NOT one. dotmd's own estate has an aggregator hub whose
|
|
19
|
+
// ranked queue draws plans from three other programs, and whose other tables row
|
|
20
|
+
// children purely to say "related" — the same pointer-row shape the status guard
|
|
21
|
+
// already declines to judge. Treating every rowed link as membership would flood
|
|
22
|
+
// correct hubs.
|
|
23
|
+
//
|
|
24
|
+
// What IS a claim: `runlist:` (frontmatter order) and the hub's BODY ORDER
|
|
25
|
+
// (`## Ranked queue` / `## Order of operations`) — the list `dotmd runlist next`
|
|
26
|
+
// actually walks. A plan there is one this hub would hand a session.
|
|
27
|
+
//
|
|
28
|
+
// ── Why "points at a different hub" is deliberately silent ──────────────────
|
|
29
|
+
//
|
|
30
|
+
// The scaffolded plan wanted a warning when a rowed child "moved to another
|
|
31
|
+
// hub". Measured against a real estate, sharing is legitimate and common: an
|
|
32
|
+
// aggregator hub ranks plans whose `parent_plan:` names the program hub that
|
|
33
|
+
// owns them, and demanding exclusivity would fire on every one of those rows
|
|
34
|
+
// with no fix that doesn't break the other hub's claim. So the guard fires only
|
|
35
|
+
// on the unambiguous half — a ranked plan claiming NO parent at all.
|
|
36
|
+
|
|
37
|
+
const ORPHAN_KIND = 'hub-membership-orphan';
|
|
38
|
+
const BACKREF_KIND = 'hub-membership-backref';
|
|
39
|
+
|
|
40
|
+
export function checkHubMembershipDrift(docs, config) {
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const quiet = new Set([
|
|
43
|
+
...(config.lifecycle?.terminalStatuses ?? []),
|
|
44
|
+
...(config.lifecycle?.skipWarningsFor ?? []),
|
|
45
|
+
]);
|
|
46
|
+
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
47
|
+
const refFields = [
|
|
48
|
+
...(config.referenceFields?.bidirectional ?? []),
|
|
49
|
+
...(config.referenceFields?.unidirectional ?? []),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const dirOf = (doc) => path.dirname(path.join(config.repoRoot, doc.path));
|
|
53
|
+
const resolve = (ref, dir) => {
|
|
54
|
+
const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
|
|
55
|
+
return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Everything a hub says about other docs: every configured reference field
|
|
59
|
+
// plus every body link. Config-driven rather than a hardcoded field list, so a
|
|
60
|
+
// repo that renames its reference fields keeps working.
|
|
61
|
+
const knownTo = (hub) => {
|
|
62
|
+
const dir = dirOf(hub);
|
|
63
|
+
const known = new Set();
|
|
64
|
+
for (const field of refFields) {
|
|
65
|
+
for (const ref of (hub.refFields?.[field] ?? [])) {
|
|
66
|
+
const target = resolve(ref, dir);
|
|
67
|
+
if (target) known.add(target.path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const link of (hub.bodyLinks ?? [])) {
|
|
71
|
+
const target = resolve(link.href, dir);
|
|
72
|
+
if (target) known.add(target.path);
|
|
73
|
+
}
|
|
74
|
+
return known;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── Arrow 1: a claim only the child makes ────────────────────────────────
|
|
78
|
+
// The plan says `parent_plan: <hub>` and the hub references it NOWHERE — not
|
|
79
|
+
// in a reference field, not as a body link. One side of the relationship
|
|
80
|
+
// silently dropped the other, and every hub view (fold, rollup, next-pickup)
|
|
81
|
+
// is computed from the hub's side, so the child is invisible where it thinks
|
|
82
|
+
// it lives. Warns on the HUB: the hub's list is the half that lost the entry.
|
|
83
|
+
const knownCache = new Map();
|
|
84
|
+
for (const child of docs) {
|
|
85
|
+
if (quiet.has(child.status)) continue;
|
|
86
|
+
const parents = child.refFields?.parent_plan ?? [];
|
|
87
|
+
if (parents.length === 0) continue;
|
|
88
|
+
const dir = dirOf(child);
|
|
89
|
+
for (const ref of parents) {
|
|
90
|
+
const hub = resolve(ref, dir);
|
|
91
|
+
// Only hubs are asked to carry a membership list. A plain plan named as a
|
|
92
|
+
// parent rows nothing, and demanding a link back there would be a new
|
|
93
|
+
// opinion rather than a drift check.
|
|
94
|
+
if (!hub || hub.path === child.path || !isHubDoc(hub)) continue;
|
|
95
|
+
if (quiet.has(hub.status)) continue;
|
|
96
|
+
if (!knownCache.has(hub.path)) knownCache.set(hub.path, knownTo(hub));
|
|
97
|
+
if (knownCache.get(hub.path).has(child.path)) continue;
|
|
98
|
+
warnings.push({
|
|
99
|
+
path: hub.path,
|
|
100
|
+
level: 'warning',
|
|
101
|
+
message: `\`${child.path}\` claims \`parent_plan: ${ref}\` but this hub references it nowhere — no reference field, no body link. Add it to the hub's list, or fix the child's \`parent_plan:\`. A membership only one side records is invisible to every hub view (fold, rollup, next-pickup), which all read the hub's half.`,
|
|
102
|
+
meta: { kind: ORPHAN_KIND, child: child.path, ref },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Arrow 2: a claim only the hub makes ──────────────────────────────────
|
|
108
|
+
// The hub's BODY ORDER ranks a plan — the list `dotmd runlist next <hub>`
|
|
109
|
+
// walks, so this hub would hand a session that plan — and the plan carries no
|
|
110
|
+
// `parent_plan:` at all. This is the same finding `checkRunlistBackPointers`
|
|
111
|
+
// makes for frontmatter `runlist:` children, extended to the body-order hubs
|
|
112
|
+
// it can't see; children already covered there are skipped, so one missing
|
|
113
|
+
// back-ref is never reported twice. Warns on the CHILD, matching that check:
|
|
114
|
+
// it's the file that needs the edit.
|
|
115
|
+
for (const hub of docs) {
|
|
116
|
+
if (!isHubDoc(hub) || quiet.has(hub.status)) continue;
|
|
117
|
+
let body;
|
|
118
|
+
try { ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hub.path), 'utf8'))); }
|
|
119
|
+
catch { continue; }
|
|
120
|
+
const ranked = detectBodyRunlistRefs(body);
|
|
121
|
+
if (ranked.length === 0) continue;
|
|
122
|
+
const dir = dirOf(hub);
|
|
123
|
+
const inFrontmatterRunlist = new Set();
|
|
124
|
+
for (const ref of (hub.refFields?.runlist ?? [])) {
|
|
125
|
+
const target = resolve(ref, dir);
|
|
126
|
+
if (target) inFrontmatterRunlist.add(target.path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const ref of ranked) {
|
|
131
|
+
const child = resolve(ref, dir);
|
|
132
|
+
if (!child || child.path === hub.path || seen.has(child.path)) continue;
|
|
133
|
+
seen.add(child.path);
|
|
134
|
+
if (quiet.has(child.status)) continue; // closed work is normal history
|
|
135
|
+
if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
|
|
136
|
+
if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
|
|
137
|
+
if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
|
|
138
|
+
if ((child.refFields?.parent_plan ?? []).length > 0) continue;
|
|
139
|
+
warnings.push({
|
|
140
|
+
path: child.path,
|
|
141
|
+
level: 'warning',
|
|
142
|
+
message: `is ranked in the body order of \`${hub.path}\` (the list \`dotmd runlist next\` walks) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
|
|
143
|
+
meta: { kind: BACKREF_KIND, hub: hub.path },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return warnings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const HUB_MEMBERSHIP_KINDS = Object.freeze({ orphan: ORPHAN_KIND, backref: BACKREF_KIND });
|
package/src/hub.mjs
CHANGED
|
@@ -215,3 +215,56 @@ export function applyStatusCase(sample, replacement) {
|
|
|
215
215
|
if (/^[A-Z]/.test(sample)) return replacement.charAt(0).toUpperCase() + replacement.slice(1);
|
|
216
216
|
return replacement;
|
|
217
217
|
}
|
|
218
|
+
|
|
219
|
+
// Extract ordered plan refs from a hub's body prose. Two shapes:
|
|
220
|
+
// - link-list sections (`## Order of operations`, `## Runlist`, …) — every
|
|
221
|
+
// `.md` link or checklist item, in document order.
|
|
222
|
+
// - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
|
|
223
|
+
// table row (the ranked plan); header/separator rows contribute none.
|
|
224
|
+
// Coordination hubs encode their next-pickup order in the table shape; sprint-
|
|
225
|
+
// ish hubs use the link list. Deduped, first occurrence wins, order preserved.
|
|
226
|
+
//
|
|
227
|
+
// This is a hub's BODY MEMBERSHIP claim, not merely a set of links: it is the
|
|
228
|
+
// order `dotmd runlist <hub>` / `runlist next <hub>` walk, so a plan listed here
|
|
229
|
+
// is one this hub would hand a session. The membership guard leans on exactly
|
|
230
|
+
// that — a plan in some other table is a pointer, a plan in this order is a claim.
|
|
231
|
+
export function detectBodyRunlistRefs(body) {
|
|
232
|
+
if (!body) return [];
|
|
233
|
+
const refs = [];
|
|
234
|
+
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
235
|
+
const sliceSection = (start) => {
|
|
236
|
+
const rest = body.slice(start);
|
|
237
|
+
const next = rest.search(/^##\s+/m);
|
|
238
|
+
return next >= 0 ? rest.slice(0, next) : rest;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
|
|
242
|
+
let match;
|
|
243
|
+
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
244
|
+
const section = sliceSection(match.index + match[0].length);
|
|
245
|
+
const allLinks = new RegExp(linkRe.source, 'g');
|
|
246
|
+
let link;
|
|
247
|
+
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
248
|
+
|
|
249
|
+
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
250
|
+
let item;
|
|
251
|
+
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Ranked-queue tables: the first `.md` link per row is the ranked plan. A
|
|
255
|
+
// header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
|
|
256
|
+
// skipped naturally. Heading may carry trailing text (`## Ranked queue (next
|
|
257
|
+
// pickup)`), so match the leading words, not an exact line.
|
|
258
|
+
const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
|
|
259
|
+
while ((match = queueSectionRe.exec(body)) !== null) {
|
|
260
|
+
const section = sliceSection(match.index + match[0].length);
|
|
261
|
+
for (const rawLine of section.split('\n')) {
|
|
262
|
+
const line = rawLine.trim();
|
|
263
|
+
if (!line.startsWith('|')) continue;
|
|
264
|
+
const link = firstRowLink(line);
|
|
265
|
+
if (link) refs.push(link);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return [...new Set(refs)];
|
|
270
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { checkClaudeCommands } from './claude-commands.mjs';
|
|
|
10
10
|
import { checkGlossaryConfig } from './glossary-check.mjs';
|
|
11
11
|
import { checkSkillDrift } from './skill-drift.mjs';
|
|
12
12
|
import { checkHubStatusDrift } from './sync-status.mjs';
|
|
13
|
+
import { checkHubMembershipDrift } from './hub-membership.mjs';
|
|
13
14
|
|
|
14
15
|
// `fast: true` skips every pass that produces warnings/errors — the rendered
|
|
15
16
|
// index file consumes only status/title/snapshot/etc., not the validation
|
|
@@ -143,6 +144,13 @@ export function buildIndex(config, opts = {}) {
|
|
|
143
144
|
if (child) child.warnings.push(w);
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
const membershipWarnings = checkHubMembershipDrift(transformedDocs, config);
|
|
148
|
+
warnings.push(...membershipWarnings);
|
|
149
|
+
for (const w of membershipWarnings) {
|
|
150
|
+
const owner = transformedDocs.find(d => d.path === w.path);
|
|
151
|
+
if (owner) owner.warnings.push(w);
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
|
|
147
155
|
warnings.push(...coordHubWarnings);
|
|
148
156
|
for (const w of coordHubWarnings) {
|
package/src/lifecycle.mjs
CHANGED
|
@@ -107,6 +107,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
|
|
|
107
107
|
}),
|
|
108
108
|
})), ...additionalUpdates],
|
|
109
109
|
creations: options.creations ?? [],
|
|
110
|
+
guards: options.guards ?? [],
|
|
110
111
|
gitMove: tracked,
|
|
111
112
|
gitIndex,
|
|
112
113
|
operation: 'lifecycle-move',
|
|
@@ -115,12 +116,13 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
|
|
|
115
116
|
});
|
|
116
117
|
return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
|
|
117
118
|
}
|
|
118
|
-
if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0) {
|
|
119
|
+
if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0 || (options.guards?.length ?? 0) > 0) {
|
|
119
120
|
const sourceContent = readFileSync(filePath, 'utf8');
|
|
120
121
|
const result = render(sourceContent);
|
|
121
122
|
mutateFileSet({
|
|
122
123
|
updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
|
|
123
124
|
creations: options.creations ?? [],
|
|
125
|
+
guards: options.guards ?? [],
|
|
124
126
|
}, { repoRoot: config.repoRoot, testHooks: options.testHooks });
|
|
125
127
|
return { ...result, sourceContent, updatedPaths: [] };
|
|
126
128
|
}
|
|
@@ -767,6 +769,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
767
769
|
createSection: Boolean(note),
|
|
768
770
|
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
769
771
|
creations: opts.creations,
|
|
772
|
+
guards: opts.guards,
|
|
770
773
|
testHooks: opts.testHooks,
|
|
771
774
|
});
|
|
772
775
|
if (!noIndex && !opts.deferIndex) regenIndex(config);
|
|
@@ -833,6 +836,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
833
836
|
testHooks: opts.testHooks,
|
|
834
837
|
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
835
838
|
creations: opts.creations,
|
|
839
|
+
guards: opts.guards,
|
|
836
840
|
skipInboundRefs: opts.skipInboundRefs,
|
|
837
841
|
bodyTransform: closeoutTemplate ? currentBody => {
|
|
838
842
|
committedCloseoutAction = planCloseoutInjection(currentBody);
|
package/src/pickup.mjs
CHANGED
|
@@ -258,19 +258,28 @@ export function preparePlanClaim({ filePath, sourceContent, renderedContent, own
|
|
|
258
258
|
const content = recordContent({ identity, sessionId, state: 'owned', now,
|
|
259
259
|
claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null, operation });
|
|
260
260
|
const updates = [];
|
|
261
|
+
const guards = [];
|
|
261
262
|
if (renderedContent !== null && renderedContent !== sourceContent) {
|
|
262
263
|
updates.push({ path: filePath, expectedContent: sourceContent, content: renderedContent });
|
|
264
|
+
} else {
|
|
265
|
+
// An `adopt` claim writes only the ownership record — the plan is already
|
|
266
|
+
// `in-session`, so there is nothing to render. The DECISION still rests on
|
|
267
|
+
// the plan's status, which was read before this mutation was prepared, so
|
|
268
|
+
// the plan file joins as a read-only guard. Without it, a concurrent
|
|
269
|
+
// `set active` could win the status write while this claim takes ownership,
|
|
270
|
+
// leaving a record that owns a plan the file calls `active`.
|
|
271
|
+
guards.push({ path: filePath, expectedContent: sourceContent });
|
|
263
272
|
}
|
|
264
273
|
const creations = [];
|
|
265
274
|
if (ownership) updates.push({ path: recordPath, expectedContent: ownership.raw, content });
|
|
266
275
|
else creations.push({ path: recordPath, content });
|
|
267
|
-
return { identity, recordPath, updates, creations, operationId };
|
|
276
|
+
return { identity, recordPath, updates, creations, guards, operationId };
|
|
268
277
|
}
|
|
269
278
|
|
|
270
279
|
export function commitPlanClaim(args) {
|
|
271
280
|
const prepared = preparePlanClaim(args);
|
|
272
281
|
mkdirSync(path.dirname(prepared.recordPath), { recursive: true });
|
|
273
|
-
mutateFileSet({ updates: prepared.updates, creations: prepared.creations }, {
|
|
282
|
+
mutateFileSet({ updates: prepared.updates, creations: prepared.creations, guards: prepared.guards }, {
|
|
274
283
|
repoRoot: args.config.repoRoot,
|
|
275
284
|
testHooks: args.testHooks,
|
|
276
285
|
});
|
package/src/prompts.mjs
CHANGED
|
@@ -289,6 +289,10 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
289
289
|
skipInboundRefs: true,
|
|
290
290
|
additionalUpdates: linkedClaim?.prepared?.updates,
|
|
291
291
|
creations: linkedClaim?.prepared?.creations,
|
|
292
|
+
// An adopt-shaped claim writes no plan content, so the plan file rides
|
|
293
|
+
// along as a read-only guard — the claim is only valid while the plan still
|
|
294
|
+
// says what the disposition was read from.
|
|
295
|
+
guards: linkedClaim?.prepared?.guards,
|
|
292
296
|
});
|
|
293
297
|
const consumedBody = archiveResult?.consumedBody ?? body;
|
|
294
298
|
|
package/src/runlist.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
toSlug,
|
|
15
15
|
warn,
|
|
16
16
|
} from './util.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { detectBodyRunlistRefs, isCoordinationHub, isRoadmapHub } from './hub.mjs';
|
|
18
18
|
import { resolveDocArg } from './index.mjs';
|
|
19
19
|
import { runlistChildContent, slugify, titleize } from './new.mjs';
|
|
20
20
|
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
@@ -312,56 +312,6 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
|
312
312
|
return out;
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
// Extract ordered plan refs from a hub's body prose. Two shapes:
|
|
316
|
-
// - link-list sections (`## Order of operations`, `## Runlist`, …) — every
|
|
317
|
-
// `.md` link or checklist item, in document order.
|
|
318
|
-
// - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
|
|
319
|
-
// table row (the ranked plan); header/separator rows contribute none.
|
|
320
|
-
// Coordination hubs encode their next-pickup order in the table shape; sprint-
|
|
321
|
-
// ish hubs use the link list. Deduped, first occurrence wins, order preserved.
|
|
322
|
-
function detectBodyRunlistRefs(body) {
|
|
323
|
-
if (!body) return [];
|
|
324
|
-
const refs = [];
|
|
325
|
-
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
326
|
-
const sliceSection = (start) => {
|
|
327
|
-
const rest = body.slice(start);
|
|
328
|
-
const next = rest.search(/^##\s+/m);
|
|
329
|
-
return next >= 0 ? rest.slice(0, next) : rest;
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
|
|
333
|
-
let match;
|
|
334
|
-
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
335
|
-
const section = sliceSection(match.index + match[0].length);
|
|
336
|
-
const allLinks = new RegExp(linkRe.source, 'g');
|
|
337
|
-
let link;
|
|
338
|
-
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
339
|
-
|
|
340
|
-
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
341
|
-
let item;
|
|
342
|
-
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Ranked-queue tables: the first `.md` link per row is the ranked plan. A
|
|
346
|
-
// header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
|
|
347
|
-
// skipped naturally. Heading may carry trailing text (`## Ranked queue (next
|
|
348
|
-
// pickup)`), so match the leading words, not an exact line.
|
|
349
|
-
const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
|
|
350
|
-
while ((match = queueSectionRe.exec(body)) !== null) {
|
|
351
|
-
const section = sliceSection(match.index + match[0].length);
|
|
352
|
-
for (const rawLine of section.split('\n')) {
|
|
353
|
-
const line = rawLine.trim();
|
|
354
|
-
if (!line.startsWith('|')) continue;
|
|
355
|
-
// `firstRowLink` is the shared row→target anchor: the hub-status guard
|
|
356
|
-
// reads the same link out of the same row, so next-pickup and drift
|
|
357
|
-
// detection can never disagree about which plan a row names.
|
|
358
|
-
const link = firstRowLink(line);
|
|
359
|
-
if (link) refs.push(link);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return [...new Set(refs)];
|
|
364
|
-
}
|
|
365
315
|
|
|
366
316
|
// Label for a hub's next-pickup child: its slug with the hub's leading module
|
|
367
317
|
// segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
|