dotmd-cli 0.80.0 → 0.81.0
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/baton.mjs +30 -2
- package/src/git.mjs +15 -3
- package/src/lifecycle.mjs +9 -1
- package/src/prompts.mjs +14 -4
- package/src/util.mjs +48 -2
- package/src/validate.mjs +13 -2
package/package.json
CHANGED
package/src/baton.mjs
CHANGED
|
@@ -161,6 +161,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
161
161
|
let repoPath = null;
|
|
162
162
|
let oldStatus = null;
|
|
163
163
|
let ownershipPath = null;
|
|
164
|
+
let planGuard = null;
|
|
164
165
|
if (planPath) {
|
|
165
166
|
planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
|
|
166
167
|
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
@@ -183,7 +184,29 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
183
184
|
if (status === 'in-session') {
|
|
184
185
|
die('`runlist baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
+
const sessionId = authoritativeSessionId();
|
|
188
|
+
const ownership = assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
|
|
189
|
+
const ownedHere = ownership?.state === 'owned' && ownership.sessionId === sessionId;
|
|
190
|
+
// Baton's release only means something when there is a claim to release. A
|
|
191
|
+
// plan that is neither in-session nor owned here carries a status someone
|
|
192
|
+
// chose on purpose (`awaiting`, `blocked`, a repo's own `awaiting-testing`),
|
|
193
|
+
// and flipping it to the default `active` as a side effect of saving a
|
|
194
|
+
// prompt overwrites that reason — silently, since baton's headline is the
|
|
195
|
+
// prompt it saved. That is not hypothetical: a prompt-refresh pass named a
|
|
196
|
+
// plan slug, got a status flip it never asked for, and had to notice and
|
|
197
|
+
// undo it. So the prompt still lands with its `plan:` link, and the plan's
|
|
198
|
+
// status stays put unless the caller states the transition with --status.
|
|
199
|
+
const currentStatus = asString(fm.status);
|
|
200
|
+
if (!statusFlag && oldStatus !== 'in-session' && !ownedHere) {
|
|
201
|
+
if (!currentStatus || (validStatuses?.size > 0 && !validStatuses.has(currentStatus))) {
|
|
202
|
+
die(`${repoPath} is not in-session and its status (\`${oldStatus}\`) is not one baton can leave in place.\n`
|
|
203
|
+
+ `Say what it should become: runlist baton ${repoPath} @<draft-file> --status <status>\n`
|
|
204
|
+
+ `Or save the prompt without touching the plan: runlist baton ${path.basename(planPath, '.md')} @<draft-file>`);
|
|
205
|
+
}
|
|
206
|
+
status = currentStatus;
|
|
207
|
+
planGuard = { path: planPath, expectedContent: raw };
|
|
208
|
+
if (note) warn('--note ignored — the plan\'s status is unchanged, so there is no transition to record.');
|
|
209
|
+
}
|
|
187
210
|
// Before the plan-completion step: a refusal must leave nothing changed.
|
|
188
211
|
refuseIfPending();
|
|
189
212
|
ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
|
|
@@ -221,7 +244,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
221
244
|
const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
|
|
222
245
|
const setArgs = [status, planPath];
|
|
223
246
|
if (force) setArgs.push('--force');
|
|
224
|
-
if (note) setArgs.push('--note', note);
|
|
247
|
+
if (note && !planGuard) setArgs.push('--note', note);
|
|
225
248
|
try {
|
|
226
249
|
if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
|
|
227
250
|
archiveResult = await runSet(setArgs, config, {
|
|
@@ -229,6 +252,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
229
252
|
viaBaton: true,
|
|
230
253
|
testHooks: opts.testHooks,
|
|
231
254
|
creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
|
|
255
|
+
guards: planGuard && !dryRun ? [planGuard] : [],
|
|
232
256
|
deferIndex: true,
|
|
233
257
|
});
|
|
234
258
|
} catch (err) {
|
|
@@ -297,6 +321,10 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
297
321
|
return operationResult;
|
|
298
322
|
}
|
|
299
323
|
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
324
|
+
if (planGuard) {
|
|
325
|
+
process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
|
|
326
|
+
process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
|
|
327
|
+
}
|
|
300
328
|
if (statusChanged) {
|
|
301
329
|
const pathspec = operationResult.repositoryFiles.join(' ');
|
|
302
330
|
let gitignored = false;
|
package/src/git.mjs
CHANGED
|
@@ -122,8 +122,7 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
|
122
122
|
assertSafeGitPaths(paths);
|
|
123
123
|
if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
|
|
124
124
|
// Full-tree callers can supply a small set of configured root pathspecs for
|
|
125
|
-
// diff extraction.
|
|
126
|
-
// so excluded or unrelated documents cannot consume the commit bound.
|
|
125
|
+
// diff extraction.
|
|
127
126
|
const scanPaths = options.pathspecs?.length ? [...new Set(options.pathspecs)] : paths;
|
|
128
127
|
assertSafeGitPaths(scanPaths);
|
|
129
128
|
const expectedPaths = new Set(paths);
|
|
@@ -133,10 +132,23 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
|
133
132
|
const commitsByPath = new Map();
|
|
134
133
|
const history = new Map();
|
|
135
134
|
let reason = null;
|
|
135
|
+
// Revision selection uses the root pathspecs when the caller supplied them,
|
|
136
|
+
// not the full requested list: Git matches every pathspec against every
|
|
137
|
+
// commit's diff, so a full-tree scan of a ~5k-doc corpus spent ~14s here
|
|
138
|
+
// walking history 5k pathspecs at a time (the same walk costs ~0.25s against
|
|
139
|
+
// one root). The window can only widen, never narrow, per requested path —
|
|
140
|
+
// every requested path lives under a root, so a commit touching one matches
|
|
141
|
+
// the root too, and the window stays a newest-first prefix of history. What
|
|
142
|
+
// it costs is depth: commits touching only non-requested docs now consume the
|
|
143
|
+
// bound, so a path whose latest commit falls outside the window resolves to
|
|
144
|
+
// no date at all rather than to a stale one. That case is already accounted
|
|
145
|
+
// for below — an unresolved TRACKED path keeps `commit-limit`, which callers
|
|
146
|
+
// surface as "Git metadata is incomplete".
|
|
147
|
+
const revisionPathspecs = scanPaths.length < paths.length ? scanPaths : paths;
|
|
136
148
|
const revisions = spawnSync('git', ['rev-list', '--stdin', `--max-count=${maxCommits + 1}`, revision], {
|
|
137
149
|
cwd: repoRoot,
|
|
138
150
|
encoding: 'utf8',
|
|
139
|
-
input: `--\n${
|
|
151
|
+
input: `--\n${revisionPathspecs.map(literalGitPathspec).join('\n')}\n`,
|
|
140
152
|
maxBuffer,
|
|
141
153
|
});
|
|
142
154
|
if (revisions.error?.code === 'ENOBUFS') {
|
package/src/lifecycle.mjs
CHANGED
|
@@ -417,7 +417,15 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
417
417
|
|
|
418
418
|
if (oldStatus === newStatus) {
|
|
419
419
|
if (!dryRun && (opts.additionalUpdates?.length || opts.creations?.length)) {
|
|
420
|
-
|
|
420
|
+
// Guards belong here as much as on the mutating paths below: a caller that
|
|
421
|
+
// asked for the status it already has (baton's prompt-only handoff) decided
|
|
422
|
+
// that from this file's status and then writes only its creations, so the
|
|
423
|
+
// read and the write need something holding the gap.
|
|
424
|
+
mutateFileSet({
|
|
425
|
+
updates: opts.additionalUpdates ?? [],
|
|
426
|
+
creations: opts.creations ?? [],
|
|
427
|
+
guards: opts.guards ?? [],
|
|
428
|
+
}, {
|
|
421
429
|
repoRoot: config.repoRoot,
|
|
422
430
|
testHooks: opts.testHooks,
|
|
423
431
|
});
|
package/src/prompts.mjs
CHANGED
|
@@ -56,7 +56,13 @@ export async function runPrompts(argv, config, opts = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function runPromptsList(argv, config, opts = {}) {
|
|
59
|
-
|
|
59
|
+
// Listing prompts renders slug / status / updated / target — nothing any
|
|
60
|
+
// validating pass produces, and `runQuery` never reads warnings either. A
|
|
61
|
+
// full `buildIndex` here parsed every body in the repo through the ref,
|
|
62
|
+
// git-staleness and hub passes (~25s on a 5k-doc corpus) to print ten lines,
|
|
63
|
+
// and then `pendingPromptsOldestFirst` scanned the tree a second time. One
|
|
64
|
+
// fast index, shared, on the same terms hud reads on.
|
|
65
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
60
66
|
const hasStatusFlag = argv.includes('--status');
|
|
61
67
|
const includeArchived = argv.includes('--include-archived');
|
|
62
68
|
const sub = argv[0];
|
|
@@ -87,7 +93,7 @@ function runPromptsList(argv, config, opts = {}) {
|
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
function renderPromptQueueList(index, config) {
|
|
90
|
-
const queue = pendingPromptsOldestFirst(config);
|
|
96
|
+
const queue = pendingPromptsOldestFirst(config, index);
|
|
91
97
|
const queuedPaths = new Set(queue.map(q => q.doc.path));
|
|
92
98
|
const others = index.docs
|
|
93
99
|
.filter(d => d.type === 'prompt' && !queuedPaths.has(d.path) && !isArchivedPath(d.path, config) && d.status !== 'archived')
|
|
@@ -175,8 +181,12 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
// `index` lets a caller that already has a fast index (the prompt listings)
|
|
185
|
+
// share it rather than re-walking the tree. It must be a full index of the
|
|
186
|
+
// repo: the filter below narrows it, so a pre-filtered one would drop prompts
|
|
187
|
+
// from the queue.
|
|
188
|
+
export function pendingPromptsOldestFirst(config, index = null) {
|
|
189
|
+
index = index ?? buildIndex(config, { fast: true, invokeHooks: false });
|
|
180
190
|
const actionable = actionablePromptStatuses(config);
|
|
181
191
|
const prompts = index.docs.filter(d =>
|
|
182
192
|
d.type === 'prompt'
|
package/src/util.mjs
CHANGED
|
@@ -221,6 +221,48 @@ export function levenshtein(a, b) {
|
|
|
221
221
|
return matrix[b.length][a.length];
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Edit distance, but only as far as the caller cares. Returns the distance when
|
|
225
|
+
// it is <= limit and null when it provably exceeds it. The suggester asks
|
|
226
|
+
// "within 3 edits?" of every candidate in the index, and the full matrix
|
|
227
|
+
// computes an exact answer it then throws away: ~900 cells and two allocations
|
|
228
|
+
// per pair, several hundred thousand pairs per unresolved reference on a large
|
|
229
|
+
// corpus. Only cells within `limit` of the diagonal can hold a value <= limit,
|
|
230
|
+
// so this walks that band in two flat rows and abandons a row whose cheapest
|
|
231
|
+
// cell is already past the limit. Distances <= limit are identical to
|
|
232
|
+
// `levenshtein`'s — test/util.test.mjs checks that against it directly.
|
|
233
|
+
export function levenshteinWithin(a, b, limit) {
|
|
234
|
+
if (Math.abs(a.length - b.length) > limit) return null;
|
|
235
|
+
if (a.length === 0) return b.length;
|
|
236
|
+
if (b.length === 0) return a.length;
|
|
237
|
+
const over = limit + 1;
|
|
238
|
+
let prev = new Int32Array(a.length + 2).fill(over);
|
|
239
|
+
let cur = new Int32Array(a.length + 2).fill(over);
|
|
240
|
+
for (let j = 0; j <= Math.min(a.length, limit); j++) prev[j] = j;
|
|
241
|
+
for (let i = 1; i <= b.length; i++) {
|
|
242
|
+
const lo = Math.max(1, i - limit);
|
|
243
|
+
const hi = Math.min(a.length, i + limit);
|
|
244
|
+
// Reset the cells this row will write plus the two the next row reads as
|
|
245
|
+
// band neighbours, so nothing stale survives the row swap.
|
|
246
|
+
cur.fill(over, Math.max(0, lo - 1), hi + 2);
|
|
247
|
+
cur[0] = i <= limit ? i : over;
|
|
248
|
+
let rowMin = over;
|
|
249
|
+
for (let j = lo; j <= hi; j++) {
|
|
250
|
+
const substitution = prev[j - 1] + (b.charCodeAt(i - 1) === a.charCodeAt(j - 1) ? 0 : 1);
|
|
251
|
+
const deletion = prev[j] + 1;
|
|
252
|
+
const insertion = cur[j - 1] + 1;
|
|
253
|
+
let best = substitution < deletion ? substitution : deletion;
|
|
254
|
+
if (insertion < best) best = insertion;
|
|
255
|
+
if (best > limit) best = over;
|
|
256
|
+
cur[j] = best;
|
|
257
|
+
if (best < rowMin) rowMin = best;
|
|
258
|
+
}
|
|
259
|
+
if (rowMin > limit) return null;
|
|
260
|
+
const swap = prev; prev = cur; cur = swap;
|
|
261
|
+
}
|
|
262
|
+
const distance = prev[a.length];
|
|
263
|
+
return distance <= limit ? distance : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
224
266
|
// Top-N candidates from a list, ranked for "did you mean" hints. Substring
|
|
225
267
|
// match wins (cheap and intent-revealing for typos that share a prefix or
|
|
226
268
|
// stem); Levenshtein distance ≤3 catches transpositions and small edits.
|
|
@@ -246,8 +288,12 @@ export function suggestCandidates(query, candidates, max = 3) {
|
|
|
246
288
|
if (!cand || scored.has(cand)) continue;
|
|
247
289
|
const candLower = String(cand).toLowerCase();
|
|
248
290
|
if (candLower === lower) continue;
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
// The candidate list here is every basename AND every repo path in the
|
|
292
|
+
// index, so on a large corpus this loop ran hundreds of thousands of
|
|
293
|
+
// distance computations per unresolved reference. Only "within 3 edits"
|
|
294
|
+
// matters, so it asks for exactly that.
|
|
295
|
+
const dist = levenshteinWithin(lower, candLower, 3);
|
|
296
|
+
if (dist !== null) scored.set(cand, 1000 + dist);
|
|
251
297
|
}
|
|
252
298
|
}
|
|
253
299
|
|
package/src/validate.mjs
CHANGED
|
@@ -338,14 +338,25 @@ function candidatePathsForType(docs, type) {
|
|
|
338
338
|
// can't see siblings). Filters candidates by ref-field type when the field
|
|
339
339
|
// name implies one (e.g. `related_plans` → plans only).
|
|
340
340
|
export function enrichRefErrorSuggestions(docs, config) {
|
|
341
|
+
// One candidate list per inferred type, not per broken ref. The list is a
|
|
342
|
+
// filter plus a ~2-per-doc Set build over the whole index, and it does not
|
|
343
|
+
// depend on the entry — rebuilding it for each entry made this pass quadratic
|
|
344
|
+
// in (docs × broken refs), which on a 4.8k-doc corpus with a couple hundred
|
|
345
|
+
// unresolved refs was ~9s of every `check`, `briefing` and `plans` run.
|
|
346
|
+
const candidateCache = new Map();
|
|
347
|
+
const candidatesFor = (type) => {
|
|
348
|
+
const key = type ?? '';
|
|
349
|
+
if (!candidateCache.has(key)) candidateCache.set(key, candidatePathsForType(docs, type));
|
|
350
|
+
return candidateCache.get(key);
|
|
351
|
+
};
|
|
352
|
+
|
|
341
353
|
const enrich = (entry) => {
|
|
342
354
|
if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
|
|
343
355
|
if (entry.meta.kind === 'body-link-resolution'
|
|
344
356
|
&& (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
|
|
345
357
|
if (entry._suggested) return;
|
|
346
358
|
const inferred = inferRefFieldType(entry.meta.field);
|
|
347
|
-
const
|
|
348
|
-
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidates);
|
|
359
|
+
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidatesFor(inferred));
|
|
349
360
|
entry._suggested = true;
|
|
350
361
|
if (suggestions.length === 0) return;
|
|
351
362
|
entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
|