dotmd-cli 0.68.0 → 0.70.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/README.md +144 -964
- package/bin/dotmd.mjs +241 -197
- 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 -87
- package/src/bulk-tag.mjs +7 -7
- 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 +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -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 +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +57 -9
- 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 +138 -32
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- 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 +14 -6
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/baton.mjs
CHANGED
|
@@ -1,12 +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 } from './lifecycle.mjs';
|
|
6
|
+
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
|
|
7
|
+
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
|
|
9
8
|
import { green, dim } from './color.mjs';
|
|
9
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
10
|
+
import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
|
|
10
11
|
|
|
11
12
|
// `dotmd baton` is the one-command handoff: save the resume prompt AND release
|
|
12
13
|
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
@@ -15,48 +16,14 @@ import { green, dim } from './color.mjs';
|
|
|
15
16
|
// tangled in what to commit. Baton does exactly one plan, one prompt, one
|
|
16
17
|
// status flip, and then *tells* the agent the exact commit command.
|
|
17
18
|
|
|
18
|
-
// Does a journal argv doc reference point at this index doc? References come
|
|
19
|
-
// from `use <x>` / `set in-session <x>` invocations, so they may be a repo
|
|
20
|
-
// path, a bare basename, or a slug without .md.
|
|
21
|
-
function matchesDocRef(doc, ref) {
|
|
22
|
-
if (typeof ref !== 'string' || !ref) return false;
|
|
23
|
-
const cleaned = ref.replace(/^\.\//, '');
|
|
24
|
-
if (doc.path === cleaned) return true;
|
|
25
|
-
const base = path.basename(doc.path, '.md');
|
|
26
|
-
if (cleaned === base || cleaned === `${base}.md`) return true;
|
|
27
|
-
return doc.path.endsWith(`/${cleaned}`) || doc.path.endsWith(`/${cleaned}.md`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Resolve which in-session plan belongs to THIS session. There is no checkout
|
|
31
|
-
// or lock — in-session is just frontmatter — so ownership is reconstructed
|
|
32
|
-
// from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
|
|
33
|
-
// this sid ran whose target is still in-session. Falls back to "the only
|
|
34
|
-
// in-session plan" when the journal can't answer (disabled, or another tool
|
|
35
|
-
// flipped the status). Returns { plan, via, inSession }; plan is null when
|
|
36
|
-
// there's no defensible answer (caller decides how to ask).
|
|
37
19
|
export function findOwnedPlan(config, index = null) {
|
|
38
20
|
const idx = index ?? buildIndex(config);
|
|
39
21
|
const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
46
|
-
const e = entries[i];
|
|
47
|
-
if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
|
|
48
|
-
const a = e.argv;
|
|
49
|
-
let ref = null;
|
|
50
|
-
if (a[0] === 'use') ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
51
|
-
else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
52
|
-
else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
|
|
53
|
-
if (!ref) continue;
|
|
54
|
-
const doc = inSession.find(d => matchesDocRef(d, ref));
|
|
55
|
-
if (doc) return { plan: doc, via: 'journal', inSession };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (inSession.length === 1) return { plan: inSession[0], via: 'single-in-session', inSession };
|
|
59
|
-
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 ?? [] };
|
|
60
27
|
}
|
|
61
28
|
|
|
62
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:
|
|
@@ -73,17 +40,21 @@ function looksLikePath(arg) {
|
|
|
73
40
|
|
|
74
41
|
export async function runBaton(argv, config, opts = {}) {
|
|
75
42
|
const { dryRun } = opts;
|
|
43
|
+
const json = argv.includes('--json');
|
|
76
44
|
|
|
77
45
|
let status = 'active';
|
|
78
46
|
let statusFlag = false;
|
|
79
47
|
let note = null;
|
|
80
48
|
let bodyFlag = null;
|
|
49
|
+
let force = false;
|
|
81
50
|
const positionals = [];
|
|
82
51
|
for (let i = 0; i < argv.length; i++) {
|
|
83
52
|
const a = argv[i];
|
|
84
53
|
if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
|
|
85
54
|
if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
|
|
86
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;
|
|
87
58
|
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
88
59
|
die(`Unknown flag for \`dotmd baton\`: ${a}`);
|
|
89
60
|
}
|
|
@@ -103,13 +74,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
103
74
|
else if (bodyArg !== null) body = readBodyInput(bodyArg);
|
|
104
75
|
else {
|
|
105
76
|
// Auto-consume piped/redirected stdin, same probe as `dotmd new`.
|
|
106
|
-
|
|
107
|
-
const stat = fstatSync(0);
|
|
108
|
-
if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
|
|
109
|
-
const piped = readFileSync(0, 'utf8');
|
|
110
|
-
if (piped.length > 0) body = piped;
|
|
111
|
-
}
|
|
112
|
-
} catch { /* stdin not introspectable */ }
|
|
77
|
+
body = readPipedBodyInput();
|
|
113
78
|
}
|
|
114
79
|
if (!body || !body.trim()) die(BODY_USAGE);
|
|
115
80
|
|
|
@@ -141,19 +106,19 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
141
106
|
const owned = findOwnedPlan(config);
|
|
142
107
|
if (owned.plan) {
|
|
143
108
|
planPath = path.resolve(config.repoRoot, owned.plan.path);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
} else if (owned.inSession.length > 1) {
|
|
148
|
-
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')}`);
|
|
149
111
|
} else {
|
|
150
|
-
|
|
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}`);
|
|
151
114
|
}
|
|
152
115
|
}
|
|
153
116
|
|
|
154
117
|
let repoPath = null;
|
|
155
118
|
let oldStatus = null;
|
|
119
|
+
let ownershipPath = null;
|
|
156
120
|
if (planPath) {
|
|
121
|
+
planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
|
|
157
122
|
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
158
123
|
const raw = readFileSync(planPath, 'utf8');
|
|
159
124
|
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
@@ -171,53 +136,108 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
171
136
|
if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
|
|
172
137
|
die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
|
|
173
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`);
|
|
174
146
|
} else {
|
|
175
147
|
if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
|
|
176
148
|
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
177
149
|
}
|
|
178
150
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// 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.
|
|
182
153
|
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
183
154
|
const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
|
|
184
155
|
let createdSlug = null;
|
|
185
|
-
for (let n = 1; n <= 9 && !createdSlug; n++) {
|
|
186
|
-
const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
|
|
187
|
-
try {
|
|
188
|
-
await runNew(['prompt', slug, '--body', body], config, { dryRun });
|
|
189
|
-
createdSlug = slug;
|
|
190
|
-
} catch (err) {
|
|
191
|
-
if (!/File already exists/.test(String(err?.message))) throw err;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
195
|
-
|
|
196
|
-
// 2. Release the plan — exactly one status flip. Skipped entirely in slug
|
|
197
|
-
// mode: with no plan involved there is nothing to release.
|
|
198
156
|
let archiveResult = null;
|
|
199
157
|
let statusChanged = false;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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;
|
|
204
180
|
const setArgs = [status, planPath];
|
|
181
|
+
if (force) setArgs.push('--force');
|
|
205
182
|
if (note) setArgs.push('--note', note);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
202
|
}
|
|
211
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
|
+
};
|
|
212
232
|
|
|
213
|
-
// 3. Tell the agent exactly what to commit — and what NOT to. The prompt is
|
|
214
|
-
// session-local (often gitignored); only the plan's frontmatter change is
|
|
215
|
-
// repo state.
|
|
216
233
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
234
|
+
if (json) {
|
|
235
|
+
process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
|
|
236
|
+
return operationResult;
|
|
237
|
+
}
|
|
217
238
|
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
218
239
|
if (statusChanged) {
|
|
219
|
-
const
|
|
220
|
-
const pathspec = newRepoPath && newRepoPath !== repoPath ? `${repoPath} ${newRepoPath}` : repoPath;
|
|
240
|
+
const pathspec = operationResult.repositoryFiles.join(' ');
|
|
221
241
|
let gitignored = false;
|
|
222
242
|
try {
|
|
223
243
|
const { isGitIgnored } = await import('./git.mjs');
|
|
@@ -226,8 +246,10 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
226
246
|
if (gitignored) {
|
|
227
247
|
process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
|
|
228
248
|
} else {
|
|
229
|
-
process.stderr.write(`${prefix}Commit the
|
|
249
|
+
process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
|
|
230
250
|
process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
|
|
231
251
|
}
|
|
232
252
|
}
|
|
253
|
+
if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
|
|
254
|
+
return operationResult;
|
|
233
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);
|