dotmd-cli 0.69.0 → 0.70.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/README.md +144 -964
- package/bin/dotmd.mjs +251 -202
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -114
- package/src/bulk-tag.mjs +7 -7
- package/src/check-collapse.mjs +2 -2
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +25 -15
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +9 -7
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +722 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +21 -13
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +596 -294
- package/src/lint.mjs +117 -56
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +134 -75
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +24 -11
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +53 -17
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/baton.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn
|
|
4
|
+
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { runSet, updateFrontmatter } from './lifecycle.mjs';
|
|
9
|
-
import { resolvePromptInput } from './prompts.mjs';
|
|
6
|
+
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
|
|
7
|
+
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
|
|
10
8
|
import { green, dim } from './color.mjs';
|
|
9
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
10
|
+
import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
|
|
11
11
|
|
|
12
12
|
// `dotmd baton` is the one-command handoff: save the resume prompt AND release
|
|
13
13
|
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
@@ -16,63 +16,14 @@ import { green, dim } from './color.mjs';
|
|
|
16
16
|
// tangled in what to commit. Baton does exactly one plan, one prompt, one
|
|
17
17
|
// status flip, and then *tells* the agent the exact commit command.
|
|
18
18
|
|
|
19
|
-
// Does a journal argv doc reference point at this index doc? References come
|
|
20
|
-
// from `use <x>` / `set in-session <x>` invocations, so they may be a repo
|
|
21
|
-
// path, a bare basename, or a slug without .md.
|
|
22
|
-
function matchesDocRef(doc, ref) {
|
|
23
|
-
if (typeof ref !== 'string' || !ref) return false;
|
|
24
|
-
const cleaned = ref.replace(/^\.\//, '');
|
|
25
|
-
if (doc.path === cleaned) return true;
|
|
26
|
-
const base = path.basename(doc.path, '.md');
|
|
27
|
-
if (cleaned === base || cleaned === `${base}.md`) return true;
|
|
28
|
-
return doc.path.endsWith(`/${cleaned}`) || doc.path.endsWith(`/${cleaned}.md`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Resolve which in-session plan belongs to THIS session. There is no checkout
|
|
32
|
-
// or lock — in-session is just frontmatter — so ownership is reconstructed
|
|
33
|
-
// from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
|
|
34
|
-
// this sid ran whose target is still in-session. Falls back to "the only
|
|
35
|
-
// in-session plan" when the journal is *silent* for this session (disabled, or
|
|
36
|
-
// another tool flipped the status). Returns { plan, via, inSession }; plan is
|
|
37
|
-
// null when there's no defensible answer (caller decides how to ask).
|
|
38
19
|
export function findOwnedPlan(config, index = null) {
|
|
39
20
|
const idx = index ?? buildIndex(config);
|
|
40
21
|
const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// Did THIS session issue any ownership command at all (`use` / `set in-session`
|
|
47
|
-
// / `status … in-session`)? If it did but none matched an in-session plan, this
|
|
48
|
-
// session's work lives elsewhere (e.g. it consumed a `use <prompt>`, or worked
|
|
49
|
-
// a plan that's since been released) — so the lone in-session plan is
|
|
50
|
-
// presumptively *another* session's, and auto-selecting it would flip a
|
|
51
|
-
// stranger's status. Track that to gate the single-in-session fast path below.
|
|
52
|
-
let sawOwnershipRef = false;
|
|
53
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
54
|
-
const e = entries[i];
|
|
55
|
-
if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
|
|
56
|
-
const a = e.argv;
|
|
57
|
-
let ref = null;
|
|
58
|
-
if (a[0] === 'use') ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
59
|
-
else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
60
|
-
else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
|
|
61
|
-
if (!ref) continue;
|
|
62
|
-
sawOwnershipRef = true;
|
|
63
|
-
const doc = inSession.find(d => matchesDocRef(d, ref));
|
|
64
|
-
if (doc) return { plan: doc, via: 'journal', inSession };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Single-in-session fast path — ONLY when the journal is silent about this
|
|
68
|
-
// session's ownership intent. If this sid *did* run an ownership command that
|
|
69
|
-
// pointed somewhere other than the lone in-session plan, refuse and let the
|
|
70
|
-
// caller demand an explicit slug/plan rather than hand off a plan this session
|
|
71
|
-
// never touched (the cross-session baton misfire).
|
|
72
|
-
if (inSession.length === 1 && !sawOwnershipRef) {
|
|
73
|
-
return { plan: inSession[0], via: 'single-in-session', inSession };
|
|
74
|
-
}
|
|
75
|
-
return { plan: null, via: null, inSession };
|
|
22
|
+
let records = [];
|
|
23
|
+
try { records = listOwnedPlans(config, authoritativeSessionId()); } catch { return { plan: null, via: null, inSession }; }
|
|
24
|
+
const owned = records.map(record => inSession.find(doc => doc.path === record.plan)).filter(Boolean);
|
|
25
|
+
const clean = (records.diagnostics?.length ?? 0) === 0;
|
|
26
|
+
return { plan: clean && owned.length === 1 ? owned[0] : null, via: clean && owned.length === 1 ? 'ownership' : null, inSession, owned, diagnostics: records.diagnostics ?? [] };
|
|
76
27
|
}
|
|
77
28
|
|
|
78
29
|
const BODY_USAGE = `dotmd baton needs the resume draft as its body. Write 10–20 lines first — the next concrete decision plus any gotchas, NOT a recap of the plan — then:
|
|
@@ -89,17 +40,21 @@ function looksLikePath(arg) {
|
|
|
89
40
|
|
|
90
41
|
export async function runBaton(argv, config, opts = {}) {
|
|
91
42
|
const { dryRun } = opts;
|
|
43
|
+
const json = argv.includes('--json');
|
|
92
44
|
|
|
93
45
|
let status = 'active';
|
|
94
46
|
let statusFlag = false;
|
|
95
47
|
let note = null;
|
|
96
48
|
let bodyFlag = null;
|
|
49
|
+
let force = false;
|
|
97
50
|
const positionals = [];
|
|
98
51
|
for (let i = 0; i < argv.length; i++) {
|
|
99
52
|
const a = argv[i];
|
|
100
53
|
if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
|
|
101
54
|
if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
|
|
102
55
|
if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
|
|
56
|
+
if (a === '--force') { force = true; continue; }
|
|
57
|
+
if (a === '--json') continue;
|
|
103
58
|
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
104
59
|
die(`Unknown flag for \`dotmd baton\`: ${a}`);
|
|
105
60
|
}
|
|
@@ -119,13 +74,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
119
74
|
else if (bodyArg !== null) body = readBodyInput(bodyArg);
|
|
120
75
|
else {
|
|
121
76
|
// Auto-consume piped/redirected stdin, same probe as `dotmd new`.
|
|
122
|
-
|
|
123
|
-
const stat = fstatSync(0);
|
|
124
|
-
if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
|
|
125
|
-
const piped = readFileSync(0, 'utf8');
|
|
126
|
-
if (piped.length > 0) body = piped;
|
|
127
|
-
}
|
|
128
|
-
} catch { /* stdin not introspectable */ }
|
|
77
|
+
body = readPipedBodyInput();
|
|
129
78
|
}
|
|
130
79
|
if (!body || !body.trim()) die(BODY_USAGE);
|
|
131
80
|
|
|
@@ -157,19 +106,19 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
157
106
|
const owned = findOwnedPlan(config);
|
|
158
107
|
if (owned.plan) {
|
|
159
108
|
planPath = path.resolve(config.repoRoot, owned.plan.path);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
} else if (owned.inSession.length > 1) {
|
|
164
|
-
die(`Multiple plans are in-session and the journal can't tell which is this session's — pass yours explicitly:\n${owned.inSession.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}\nNot about a plan? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`);
|
|
109
|
+
} else if (owned.owned?.length > 1) {
|
|
110
|
+
die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
|
|
165
111
|
} else {
|
|
166
|
-
|
|
112
|
+
const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
|
|
113
|
+
die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n dotmd baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? dotmd baton <plan-file> @/tmp/draft.md${diagnostics}`);
|
|
167
114
|
}
|
|
168
115
|
}
|
|
169
116
|
|
|
170
117
|
let repoPath = null;
|
|
171
118
|
let oldStatus = null;
|
|
119
|
+
let ownershipPath = null;
|
|
172
120
|
if (planPath) {
|
|
121
|
+
planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
|
|
173
122
|
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
174
123
|
const raw = readFileSync(planPath, 'utf8');
|
|
175
124
|
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
@@ -187,64 +136,108 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
187
136
|
if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
|
|
188
137
|
die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
|
|
189
138
|
}
|
|
139
|
+
if (status === 'in-session') {
|
|
140
|
+
die('`dotmd baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
|
|
141
|
+
}
|
|
142
|
+
assertPlanMutationAuthorized(repoPath, config, { sessionId: authoritativeSessionId(), force });
|
|
143
|
+
ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
|
|
144
|
+
if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
|
|
145
|
+
else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
|
|
190
146
|
} else {
|
|
191
147
|
if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
|
|
192
148
|
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
193
149
|
}
|
|
194
150
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
// and bodies are not mergeable).
|
|
151
|
+
// Plan mode publishes the already-stamped prompt, status/history update, and
|
|
152
|
+
// ownership release in one transaction. Slug mode has no plan transaction.
|
|
198
153
|
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
199
154
|
const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
|
|
200
155
|
let createdSlug = null;
|
|
201
|
-
for (let n = 1; n <= 9 && !createdSlug; n++) {
|
|
202
|
-
const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
|
|
203
|
-
try {
|
|
204
|
-
await runNew(['prompt', slug, '--body', body], config, { dryRun });
|
|
205
|
-
createdSlug = slug;
|
|
206
|
-
} catch (err) {
|
|
207
|
-
if (!/File already exists/.test(String(err?.message))) throw err;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
211
|
-
|
|
212
|
-
// Link the prompt back to its plan so the next session's `dotmd use` re-claims
|
|
213
|
-
// it (consume = claim). The resume-<slug> filename is a lossy link under -N
|
|
214
|
-
// suffixing / plan renames; the explicit `plan:` field is the durable one.
|
|
215
|
-
// Best-effort: a resolve/write hiccup must never fail an otherwise-good handoff.
|
|
216
|
-
if (planPath && !dryRun) {
|
|
217
|
-
try {
|
|
218
|
-
const promptPath = resolvePromptInput(createdSlug, config, { dieOnMiss: false });
|
|
219
|
-
if (promptPath) updateFrontmatter(promptPath, { plan: repoPath });
|
|
220
|
-
} catch { /* stamping is best-effort */ }
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// 2. Release the plan — exactly one status flip. Skipped entirely in slug
|
|
224
|
-
// mode: with no plan involved there is nothing to release.
|
|
225
156
|
let archiveResult = null;
|
|
226
157
|
let statusChanged = false;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
158
|
+
let promptRepoPath = null;
|
|
159
|
+
let newResult = null;
|
|
160
|
+
const muted = [];
|
|
161
|
+
const originalStdoutWrite = process.stdout.write;
|
|
162
|
+
if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
|
|
163
|
+
try {
|
|
164
|
+
if (!planPath) {
|
|
165
|
+
for (let n = 1; n <= 9 && !createdSlug; n++) {
|
|
166
|
+
const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
|
|
167
|
+
try {
|
|
168
|
+
const prepared = preparePromptDocument(slug, body, config, { dryRun });
|
|
169
|
+
newResult = await runNew(['prompt', slug, '--body', body], config, { dryRun, deferIndex: true });
|
|
170
|
+
createdSlug = slug;
|
|
171
|
+
promptRepoPath = prepared.repoPath;
|
|
172
|
+
}
|
|
173
|
+
catch (err) { if (!/File already exists/.test(String(err?.message))) throw err; }
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
for (let n = 1; n <= 9 && !createdSlug; n++) {
|
|
177
|
+
const candidate = n === 1 ? slugBase : `${slugBase}-${n}`;
|
|
178
|
+
const prepared = preparePromptDocument(candidate, body, config, { plan: repoPath, dryRun });
|
|
179
|
+
if (existsSync(prepared.filePath)) continue;
|
|
231
180
|
const setArgs = [status, planPath];
|
|
181
|
+
if (force) setArgs.push('--force');
|
|
232
182
|
if (note) setArgs.push('--note', note);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
183
|
+
try {
|
|
184
|
+
if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
|
|
185
|
+
archiveResult = await runSet(setArgs, config, {
|
|
186
|
+
dryRun,
|
|
187
|
+
viaBaton: true,
|
|
188
|
+
testHooks: opts.testHooks,
|
|
189
|
+
creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
|
|
190
|
+
deferIndex: true,
|
|
191
|
+
});
|
|
192
|
+
createdSlug = prepared.slug;
|
|
193
|
+
promptRepoPath = prepared.repoPath;
|
|
194
|
+
statusChanged = oldStatus !== status;
|
|
195
|
+
if (!dryRun) {
|
|
196
|
+
try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
|
|
197
|
+
catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (!/Destination already exists|File already exists/.test(String(err?.message))) throw err;
|
|
201
|
+
}
|
|
237
202
|
}
|
|
238
203
|
}
|
|
204
|
+
} finally {
|
|
205
|
+
if (json) process.stdout.write = originalStdoutWrite;
|
|
206
|
+
}
|
|
207
|
+
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
208
|
+
|
|
209
|
+
const normalizeRepoPath = candidate => {
|
|
210
|
+
if (!candidate) return null;
|
|
211
|
+
return path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate;
|
|
212
|
+
};
|
|
213
|
+
const touched = (archiveResult?.touched ?? (planPath && statusChanged ? [repoPath] : [])).map(normalizeRepoPath);
|
|
214
|
+
const ownershipRepoPath = normalizeRepoPath(ownershipPath);
|
|
215
|
+
const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
|
|
216
|
+
const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, ownershipRepoPath].filter(Boolean))];
|
|
217
|
+
const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
|
|
218
|
+
const operationResult = {
|
|
219
|
+
operation: 'baton',
|
|
220
|
+
dryRun: Boolean(dryRun),
|
|
221
|
+
disposition: dryRun ? 'would-change' : 'applied',
|
|
222
|
+
wouldChange: Boolean(dryRun),
|
|
223
|
+
mode: planPath ? 'plan' : 'slug',
|
|
224
|
+
status: planPath ? { from: oldStatus, to: status, changed: statusChanged } : null,
|
|
225
|
+
repositoryFiles,
|
|
226
|
+
sessionFiles,
|
|
227
|
+
generatedFiles: [],
|
|
228
|
+
deferredGeneratedFiles,
|
|
229
|
+
prompt: promptRepoPath,
|
|
230
|
+
plan: archiveResult?.newRepoPath ?? repoPath,
|
|
231
|
+
};
|
|
239
232
|
|
|
240
|
-
// 3. Tell the agent exactly what to commit — and what NOT to. The prompt is
|
|
241
|
-
// session-local (often gitignored); only the plan's frontmatter change is
|
|
242
|
-
// repo state.
|
|
243
233
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
234
|
+
if (json) {
|
|
235
|
+
process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
|
|
236
|
+
return operationResult;
|
|
237
|
+
}
|
|
244
238
|
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
245
239
|
if (statusChanged) {
|
|
246
|
-
const
|
|
247
|
-
const pathspec = newRepoPath && newRepoPath !== repoPath ? `${repoPath} ${newRepoPath}` : repoPath;
|
|
240
|
+
const pathspec = operationResult.repositoryFiles.join(' ');
|
|
248
241
|
let gitignored = false;
|
|
249
242
|
try {
|
|
250
243
|
const { isGitIgnored } = await import('./git.mjs');
|
|
@@ -253,8 +246,10 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
253
246
|
if (gitignored) {
|
|
254
247
|
process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
|
|
255
248
|
} else {
|
|
256
|
-
process.stderr.write(`${prefix}Commit the
|
|
249
|
+
process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
|
|
257
250
|
process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
|
|
258
251
|
}
|
|
259
252
|
}
|
|
253
|
+
if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
|
|
254
|
+
return operationResult;
|
|
260
255
|
}
|
package/src/bulk-tag.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { collectDocFiles } from './index.mjs';
|
|
|
5
5
|
import { toRepoPath, die, warn, resolveDocPath } from './util.mjs';
|
|
6
6
|
import { writeFrontmatter } from './lifecycle.mjs';
|
|
7
7
|
import { green, dim, yellow } from './color.mjs';
|
|
8
|
+
import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
|
|
8
9
|
|
|
9
10
|
// Per-type default status for bulk-tagging pre-existing untagged markdown.
|
|
10
11
|
// These intentionally lean conservative (draft / planned) rather than active —
|
|
@@ -25,6 +26,9 @@ const DEFAULT_STATUS_BY_TYPE = {
|
|
|
25
26
|
// Centralizes the inline `rootLabel.includes('plan')` heuristic from lint.mjs
|
|
26
27
|
// and extends it for prompts.
|
|
27
28
|
export function inferTypeFromPath(filePath, docsRoot) {
|
|
29
|
+
const rootKind = path.basename(docsRoot);
|
|
30
|
+
if (rootKind === 'plans') return 'plan';
|
|
31
|
+
if (rootKind === 'prompts') return 'prompt';
|
|
28
32
|
const rel = path.relative(docsRoot, filePath);
|
|
29
33
|
const segments = rel.split(path.sep);
|
|
30
34
|
if (segments.length >= 2) {
|
|
@@ -48,11 +52,6 @@ function parseArgs(argv) {
|
|
|
48
52
|
return opts;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
function findFileRoot(filePath, config) {
|
|
52
|
-
const roots = config.docsRoots || [config.docsRoot];
|
|
53
|
-
return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
55
|
export function runBulkTag(argv, config, opts = {}) {
|
|
57
56
|
const { dryRun } = opts;
|
|
58
57
|
const args = parseArgs(argv);
|
|
@@ -69,12 +68,13 @@ export function runBulkTag(argv, config, opts = {}) {
|
|
|
69
68
|
} else {
|
|
70
69
|
pool = allFiles;
|
|
71
70
|
}
|
|
71
|
+
authorizeManagedSweep(pool, config, { kind: 'Bulk tag source' });
|
|
72
72
|
|
|
73
73
|
// Skip already-archived files (mirrors bulk archive's policy at
|
|
74
74
|
// lifecycle.mjs:569–573) — settled docs shouldn't be retroactively tagged.
|
|
75
75
|
const archiveDir = config.archiveDir;
|
|
76
76
|
const inArchive = (f) => {
|
|
77
|
-
const root =
|
|
77
|
+
const root = findLexicalDocsRoot(f, config) ?? config.docsRoot;
|
|
78
78
|
const rel = path.relative(root, f);
|
|
79
79
|
return rel.startsWith(archiveDir + '/') || rel.startsWith(archiveDir + path.sep);
|
|
80
80
|
};
|
|
@@ -92,7 +92,7 @@ export function runBulkTag(argv, config, opts = {}) {
|
|
|
92
92
|
// skipped silently (bulk-tag's job is to fill gaps, not nag).
|
|
93
93
|
if (hasType && hasStatus) continue;
|
|
94
94
|
|
|
95
|
-
const root =
|
|
95
|
+
const root = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
|
|
96
96
|
const inferredType = args.typeOverride ?? (hasType ? parsed.type : inferTypeFromPath(filePath, root));
|
|
97
97
|
const defaultStatus = DEFAULT_STATUS_BY_TYPE[inferredType] ?? 'draft';
|
|
98
98
|
const inferredStatus = args.statusOverride ?? (hasStatus ? parsed.status : defaultStatus);
|
package/src/check-collapse.mjs
CHANGED
|
@@ -18,13 +18,13 @@ const CATEGORIES = [
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
key: 'singular-module',
|
|
21
|
-
match: /^`module:` \(singular\) is deprecated
|
|
21
|
+
match: /^`module:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
|
|
22
22
|
label: 'docs use deprecated singular `module:`',
|
|
23
23
|
fix: 'dotmd lint --fix',
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
key: 'singular-surface',
|
|
27
|
-
match: /^`surface:` \(singular\) is deprecated
|
|
27
|
+
match: /^`surface:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
|
|
28
28
|
label: 'docs use deprecated singular `surface:`',
|
|
29
29
|
fix: 'dotmd lint --fix',
|
|
30
30
|
},
|