dotmd-cli 0.67.0 → 0.69.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/bin/dotmd.mjs +4 -3
- package/package.json +2 -2
- package/src/baton.mjs +35 -6
- package/src/lifecycle.mjs +61 -12
- package/src/lint.mjs +54 -6
- package/src/notion.mjs +1 -1
- package/src/prompts.mjs +51 -4
package/bin/dotmd.mjs
CHANGED
|
@@ -1078,9 +1078,10 @@ Examples:
|
|
|
1078
1078
|
The "save a resume prompt" verb. Works mid-anything:
|
|
1079
1079
|
|
|
1080
1080
|
Plan mode (a plan is in-session, or you pass one):
|
|
1081
|
-
1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …)
|
|
1082
|
-
|
|
1083
|
-
|
|
1081
|
+
1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …),
|
|
1082
|
+
stamped with a plan: link so consuming it re-claims the plan (see \`dotmd
|
|
1083
|
+
use\`). The prompt is session-local — the next session's hud surfaces it;
|
|
1084
|
+
never paste resume text into chat.
|
|
1084
1085
|
2. Releases the plan: one status flip, in-session → active by default
|
|
1085
1086
|
(--status to override, --note to record why in ## Version History).
|
|
1086
1087
|
3. Prints the exact \`git commit\` for the plan's frontmatter change — the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.0",
|
|
4
4
|
"description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"engines": {
|
|
49
49
|
"node": ">=20"
|
|
50
50
|
},
|
|
51
|
-
"
|
|
51
|
+
"optionalDependencies": {
|
|
52
52
|
"@notionhq/client": "^5.13.0",
|
|
53
53
|
"notion-to-md": "^3.1.9"
|
|
54
54
|
}
|
package/src/baton.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { asString, toRepoPath, die, warn, currentSessionId } from './util.mjs';
|
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { readJournalEntries } from './journal.mjs';
|
|
7
7
|
import { runNew, readBodyInput } from './new.mjs';
|
|
8
|
-
import { runSet } from './lifecycle.mjs';
|
|
8
|
+
import { runSet, updateFrontmatter } from './lifecycle.mjs';
|
|
9
|
+
import { resolvePromptInput } from './prompts.mjs';
|
|
9
10
|
import { green, dim } from './color.mjs';
|
|
10
11
|
|
|
11
12
|
// `dotmd baton` is the one-command handoff: save the resume prompt AND release
|
|
@@ -31,9 +32,9 @@ function matchesDocRef(doc, ref) {
|
|
|
31
32
|
// or lock — in-session is just frontmatter — so ownership is reconstructed
|
|
32
33
|
// from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
|
|
33
34
|
// this sid ran whose target is still in-session. Falls back to "the only
|
|
34
|
-
// in-session plan" when the journal
|
|
35
|
-
// flipped the status). Returns { plan, via, inSession }; plan is
|
|
36
|
-
// there's no defensible answer (caller decides how to ask).
|
|
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).
|
|
37
38
|
export function findOwnedPlan(config, index = null) {
|
|
38
39
|
const idx = index ?? buildIndex(config);
|
|
39
40
|
const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
|
|
@@ -42,6 +43,13 @@ export function findOwnedPlan(config, index = null) {
|
|
|
42
43
|
const sid = currentSessionId();
|
|
43
44
|
let entries = [];
|
|
44
45
|
try { entries = readJournalEntries(config); } catch { entries = []; }
|
|
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;
|
|
45
53
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
46
54
|
const e = entries[i];
|
|
47
55
|
if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
|
|
@@ -51,11 +59,19 @@ export function findOwnedPlan(config, index = null) {
|
|
|
51
59
|
else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
52
60
|
else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
|
|
53
61
|
if (!ref) continue;
|
|
62
|
+
sawOwnershipRef = true;
|
|
54
63
|
const doc = inSession.find(d => matchesDocRef(d, ref));
|
|
55
64
|
if (doc) return { plan: doc, via: 'journal', inSession };
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
|
|
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
|
+
}
|
|
59
75
|
return { plan: null, via: null, inSession };
|
|
60
76
|
}
|
|
61
77
|
|
|
@@ -193,6 +209,17 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
193
209
|
}
|
|
194
210
|
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
195
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
|
+
|
|
196
223
|
// 2. Release the plan — exactly one status flip. Skipped entirely in slug
|
|
197
224
|
// mode: with no plan involved there is nothing to release.
|
|
198
225
|
let archiveResult = null;
|
|
@@ -203,7 +230,9 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
203
230
|
} else {
|
|
204
231
|
const setArgs = [status, planPath];
|
|
205
232
|
if (note) setArgs.push('--note', note);
|
|
206
|
-
|
|
233
|
+
// viaBaton: this release IS the handoff — don't let runSet nudge "leave a
|
|
234
|
+
// baton" on top of the baton we're in the middle of.
|
|
235
|
+
archiveResult = await runSet(setArgs, config, { dryRun, viaBaton: true });
|
|
207
236
|
statusChanged = true;
|
|
208
237
|
}
|
|
209
238
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
|
|
5
|
+
import { readJournalEntries } from './journal.mjs';
|
|
5
6
|
import { gitMv, getGitLastModifiedBatch } from './git.mjs';
|
|
6
7
|
import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
|
|
7
8
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
@@ -598,6 +599,24 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
598
599
|
// `dotmd set in-session <path>` is refused — acquiring a lease is asymmetric
|
|
599
600
|
// enough to deserve its own verb (`dotmd pickup`), and silently routing here
|
|
600
601
|
// would skip the lease-acquisition path entirely.
|
|
602
|
+
|
|
603
|
+
// Did THIS session already hand off via `dotmd baton`? The journal records the
|
|
604
|
+
// top-level argv of every invocation; a successful `baton …` means a resume
|
|
605
|
+
// prompt was saved this session, so the closure nudge would be redundant. Best
|
|
606
|
+
// effort — a disabled journal degrades to "might nudge," which is harmless for
|
|
607
|
+
// an advisory line. (baton's own internal `runSet` is suppressed by `viaBaton`,
|
|
608
|
+
// since its journal entry isn't written until the process exits.)
|
|
609
|
+
function batonSavedThisSession(config) {
|
|
610
|
+
const sid = currentSessionId();
|
|
611
|
+
let entries = [];
|
|
612
|
+
try { entries = readJournalEntries(config); } catch { return false; }
|
|
613
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
614
|
+
const e = entries[i];
|
|
615
|
+
if (e?.sid === sid && (e.exit ?? 0) === 0 && Array.isArray(e.argv) && e.argv[0] === 'baton') return true;
|
|
616
|
+
}
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
|
|
601
620
|
export async function runSet(argv, config, opts = {}) {
|
|
602
621
|
const { dryRun } = opts;
|
|
603
622
|
const noIndex = argv.includes('--no-index');
|
|
@@ -631,26 +650,56 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
631
650
|
return runArchive(archiveArgs, config, { dryRun, note, archiveStatus: newStatus });
|
|
632
651
|
}
|
|
633
652
|
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
// only
|
|
653
|
+
// Two advisory reminders, both computed from one pre-transition frontmatter
|
|
654
|
+
// read (a non-archive `set` never moves the file, so the path stays valid).
|
|
655
|
+
// Advisory only — never block the transition.
|
|
637
656
|
let partialReminder = false;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
657
|
+
let batonNudge = false;
|
|
658
|
+
try {
|
|
659
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(readFileSync(filePath, 'utf8'));
|
|
660
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
661
|
+
|
|
662
|
+
// `partial` promises a successor tracking the deferred tail. When neither a
|
|
663
|
+
// --note nor any doc reference exists to point at it, remind.
|
|
664
|
+
if (newStatus === 'partial' && !note) {
|
|
665
|
+
const related = fm.related_plans;
|
|
642
666
|
const hasRelated = Array.isArray(related) ? related.length > 0 : Boolean(asString(related)?.trim());
|
|
643
667
|
partialReminder = !hasRelated && !/[\w./-]+\.md\b/.test(body);
|
|
644
|
-
}
|
|
645
|
-
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Baton-on-exit backstop (the one core-loop step with no other mechanical
|
|
671
|
+
// catch). Fire ONLY on the baton-less in-session release: a plan you were
|
|
672
|
+
// actively working (old status `in-session`) is being parked at a
|
|
673
|
+
// non-terminal stop status while a known next pickup (`next_step`) remains,
|
|
674
|
+
// and no baton was saved this session. `archived` never reaches here (routed
|
|
675
|
+
// to runArchive above), so this can't fire on a fully-done close. Gating on
|
|
676
|
+
// `in-session` keeps it off pure triage of never-started plans — where no
|
|
677
|
+
// session work is owed a handoff — which is how the nag-fatigue risk is
|
|
678
|
+
// managed. Suppressed for baton's own internal release via `opts.viaBaton`.
|
|
679
|
+
if (!opts.viaBaton) {
|
|
680
|
+
const docType = asString(fm.type) ?? 'plan';
|
|
681
|
+
const oldStatus = asString(fm.status);
|
|
682
|
+
const nextStep = asString(fm.next_step)?.trim();
|
|
683
|
+
batonNudge = docType === 'plan'
|
|
684
|
+
&& oldStatus === 'in-session'
|
|
685
|
+
&& newStatus !== 'in-session'
|
|
686
|
+
&& Boolean(nextStep)
|
|
687
|
+
&& !batonSavedThisSession(config);
|
|
688
|
+
}
|
|
689
|
+
} catch { /* advisory only */ }
|
|
646
690
|
|
|
647
691
|
const statusArgs = [filePath, newStatus];
|
|
648
692
|
if (noIndex) statusArgs.push('--no-index');
|
|
649
693
|
if (showFiles) statusArgs.push('--show-files');
|
|
650
694
|
await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true, note });
|
|
651
695
|
|
|
652
|
-
if (
|
|
653
|
-
|
|
696
|
+
if (!dryRun) {
|
|
697
|
+
if (partialReminder) {
|
|
698
|
+
warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
|
|
699
|
+
}
|
|
700
|
+
if (batonNudge) {
|
|
701
|
+
warn(`wrapping up? leave a baton so the next session picks up cleanly — \`dotmd baton ${path.basename(filePath, '.md')} @draft\` saves a resume prompt (no copy-paste into chat).`);
|
|
702
|
+
}
|
|
654
703
|
}
|
|
655
704
|
}
|
|
656
705
|
|
package/src/lint.mjs
CHANGED
|
@@ -73,12 +73,26 @@ export function runLint(argv, config, opts = {}) {
|
|
|
73
73
|
// existing plural array happens at apply-time so the message reflects
|
|
74
74
|
// just what's being introduced from the singular form.
|
|
75
75
|
for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
const rawVal = parsed[singular];
|
|
77
|
+
const val = asString(rawVal);
|
|
78
|
+
if (val) {
|
|
79
|
+
// Inline value (`surface: foo`) → migrate into the plural array.
|
|
80
|
+
const values = val.includes(',')
|
|
81
|
+
? val.split(',').map(s => s.trim()).filter(Boolean)
|
|
82
|
+
: [val];
|
|
83
|
+
fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
|
|
84
|
+
} else if (singular in parsed && (!Array.isArray(rawVal) || rawVal.length === 0)) {
|
|
85
|
+
// Empty deprecated key (`surface:` with nothing after it, which the
|
|
86
|
+
// parser yields as `[]`) — usually sitting right above a populated
|
|
87
|
+
// `surfaces:`. There's no value to migrate; the deprecation warning
|
|
88
|
+
// just wants the dead line gone. `validate` treats the empty `[]` as
|
|
89
|
+
// present (so it warns), while the old `asString` gate here saw it as
|
|
90
|
+
// absent and skipped — the exact no-op reported in issue #17. Emit a
|
|
91
|
+
// drop fix so `lint --fix` does what the warning advertises. A
|
|
92
|
+
// populated block-form singular (rawVal.length > 0) is left untouched:
|
|
93
|
+
// too rare to risk mis-editing.
|
|
94
|
+
fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
|
|
95
|
+
}
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
// Trailing whitespace in values
|
|
@@ -102,8 +116,22 @@ export function runLint(argv, config, opts = {}) {
|
|
|
102
116
|
// Also get non-fixable issues from index, excluding issues we can already fix
|
|
103
117
|
const index = buildIndex(config);
|
|
104
118
|
const fixablePaths = new Set(fixable.map(f => f.repoPath));
|
|
119
|
+
// Singular-key deprecation warnings whose fix `lint --fix` will actually make.
|
|
120
|
+
// Without this, those warnings landed in BOTH the "fixable" preview and the
|
|
121
|
+
// "non-fixable" list, so the header claimed "N non-fixable" while every line
|
|
122
|
+
// told you to run `lint --fix` (issue #17, item 8).
|
|
123
|
+
const fixableSingularKeys = new Set();
|
|
124
|
+
for (const { repoPath, fixes } of fixable) {
|
|
125
|
+
for (const f of fixes) {
|
|
126
|
+
if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
|
|
127
|
+
fixableSingularKeys.add(`${repoPath}::${f.field}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
105
131
|
const nonFixable = [...index.errors, ...index.warnings].filter(issue => {
|
|
106
132
|
if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
|
|
133
|
+
const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
|
|
134
|
+
if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
|
|
107
135
|
return true;
|
|
108
136
|
});
|
|
109
137
|
|
|
@@ -120,6 +148,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
120
148
|
process.stdout.write(dim(` missing status (fixable via AI)\n`));
|
|
121
149
|
} else if (f.type === 'singular-to-plural') {
|
|
122
150
|
process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]\n`));
|
|
151
|
+
} else if (f.type === 'drop-singular') {
|
|
152
|
+
process.stdout.write(dim(` remove deprecated \`${f.field}:\` (empty; \`${f.pluralKey}:\` is the live key)\n`));
|
|
123
153
|
} else if (f.type === 'eof') {
|
|
124
154
|
process.stdout.write(dim(` missing newline at end of file\n`));
|
|
125
155
|
} else if (f.type === 'add') {
|
|
@@ -155,6 +185,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
155
185
|
let needsEofFix = false;
|
|
156
186
|
const trimFixes = [];
|
|
157
187
|
const singularToPlural = [];
|
|
188
|
+
const dropSingular = [];
|
|
158
189
|
|
|
159
190
|
for (const f of fixes) {
|
|
160
191
|
if (f.type === 'rename-key') {
|
|
@@ -165,6 +196,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
165
196
|
trimFixes.push(f);
|
|
166
197
|
} else if (f.type === 'singular-to-plural') {
|
|
167
198
|
singularToPlural.push(f);
|
|
199
|
+
} else if (f.type === 'drop-singular') {
|
|
200
|
+
dropSingular.push(f);
|
|
168
201
|
} else {
|
|
169
202
|
updates[f.field] = f.newValue;
|
|
170
203
|
}
|
|
@@ -212,6 +245,19 @@ export function runLint(argv, config, opts = {}) {
|
|
|
212
245
|
writeFileSync(filePath, raw, 'utf8');
|
|
213
246
|
}
|
|
214
247
|
|
|
248
|
+
// Drop empty deprecated singular keys. The value is known-empty (the fix
|
|
249
|
+
// is only emitted for `[]`/`''`), so this matches the bare `key:` line
|
|
250
|
+
// exactly — never a line carrying content — and removes it.
|
|
251
|
+
for (const ds of dropSingular) {
|
|
252
|
+
let raw = readFileSync(filePath, 'utf8');
|
|
253
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
254
|
+
const newFm = fm
|
|
255
|
+
.replace(new RegExp(`^${escapeRegex(ds.field)}:[ \\t]*$`, 'm'), '')
|
|
256
|
+
.replace(/\n{2,}/g, '\n');
|
|
257
|
+
raw = replaceFrontmatter(raw, newFm.trim());
|
|
258
|
+
writeFileSync(filePath, raw, 'utf8');
|
|
259
|
+
}
|
|
260
|
+
|
|
215
261
|
// Apply key renames and trim fixes via raw string manipulation
|
|
216
262
|
if (keyRenames.length > 0 || trimFixes.length > 0) {
|
|
217
263
|
let raw = readFileSync(filePath, 'utf8');
|
|
@@ -259,6 +305,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
259
305
|
}
|
|
260
306
|
} else if (f.type === 'singular-to-plural') {
|
|
261
307
|
process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]`)}\n`);
|
|
308
|
+
} else if (f.type === 'drop-singular') {
|
|
309
|
+
process.stdout.write(`${prefix} ${dim(`removed deprecated \`${f.field}:\` (empty)`)}\n`);
|
|
262
310
|
} else if (f.type === 'add') {
|
|
263
311
|
process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
|
|
264
312
|
} else {
|
package/src/notion.mjs
CHANGED
|
@@ -16,7 +16,7 @@ async function loadDeps() {
|
|
|
16
16
|
notionClient = Client;
|
|
17
17
|
notionToMd = NotionToMarkdown;
|
|
18
18
|
} catch {
|
|
19
|
-
die('Notion
|
|
19
|
+
die('Notion integration needs its optional dependencies. Run: npm install @notionhq/client notion-to-md\n(These ship as optionalDependencies — a plain `npm i -g dotmd-cli` installs them unless you passed `--omit=optional`.)');
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
package/src/prompts.mjs
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
|
|
5
|
-
import { buildIndex } from './index.mjs';
|
|
5
|
+
import { asString, toRepoPath, die, resolveDocPath, isArchivedPath, currentSessionId } from './util.mjs';
|
|
6
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
7
|
import { runQuery } from './query.mjs';
|
|
7
|
-
import { runArchive, runStatus } from './lifecycle.mjs';
|
|
8
|
+
import { runArchive, runStatus, updateFrontmatter } from './lifecycle.mjs';
|
|
9
|
+
import { appendJournalEntry } from './journal.mjs';
|
|
8
10
|
import { runNew } from './new.mjs';
|
|
9
11
|
import { green, dim } from './color.mjs';
|
|
10
12
|
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
15
|
+
|
|
11
16
|
// `resume` is an alias for `use` — agents reach for "resume" when continuing a
|
|
12
17
|
// session; `use` reads as internal mechanics. Both names stay valid; the
|
|
13
18
|
// canonical output ("Consumed: …") is unchanged.
|
|
@@ -283,6 +288,48 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
283
288
|
|
|
284
289
|
const consumedPath = archiveResult?.newRepoPath ?? repoPath;
|
|
285
290
|
process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
|
|
291
|
+
|
|
292
|
+
// Consume = claim: a baton-created resume prompt carries `plan: <path>`.
|
|
293
|
+
// Adopt that plan for THIS session so the next `dotmd baton` (with no arg)
|
|
294
|
+
// hands it off — closing the cross-session ownership loop that otherwise dies
|
|
295
|
+
// at the prompt boundary.
|
|
296
|
+
claimPromptPlan(asString(parsed.plan), config);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Flip the resume prompt's linked plan to in-session for this session and
|
|
300
|
+
// record the ownership in the journal. The journal entry matters: baton's
|
|
301
|
+
// `findOwnedPlan` reconstructs ownership from journaled `set in-session <plan>`
|
|
302
|
+
// commands, and the outer `use <prompt>` argv can't tie a prompt ref to a plan
|
|
303
|
+
// — so without this synthetic entry the claim would be invisible to baton (and,
|
|
304
|
+
// with the misfire gate, would even make the next baton refuse).
|
|
305
|
+
function claimPromptPlan(planRef, config) {
|
|
306
|
+
if (!planRef) return;
|
|
307
|
+
let planPath = null;
|
|
308
|
+
try { planPath = resolveDocPath(planRef, config) ?? resolveDocArg(planRef, config, { dieOnMiss: false }); }
|
|
309
|
+
catch { planPath = null; }
|
|
310
|
+
if (!planPath || !existsSync(planPath)) return; // link went stale (plan renamed/removed) — the resume body already printed, so stay quiet
|
|
311
|
+
|
|
312
|
+
let planFm;
|
|
313
|
+
try { planFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(planPath, 'utf8')).frontmatter); }
|
|
314
|
+
catch { return; }
|
|
315
|
+
const cur = asString(planFm.status);
|
|
316
|
+
// Only claim a startable plan. Already in-session → someone's on it (don't
|
|
317
|
+
// steal); archived/terminal → the link is stale. Either way, leave it be.
|
|
318
|
+
if (!cur || cur === 'in-session' || cur === 'archived') return;
|
|
319
|
+
|
|
320
|
+
const repoPath = toRepoPath(planPath, config.repoRoot);
|
|
321
|
+
try { updateFrontmatter(planPath, { status: 'in-session' }); }
|
|
322
|
+
catch { return; }
|
|
323
|
+
try {
|
|
324
|
+
// `v` MUST be the real CLI version: the journal rotates when a new entry's
|
|
325
|
+
// version differs from the file's first entry, so a sentinel here would shove
|
|
326
|
+
// our just-written claim into the backup file where findOwnedPlan can't see it.
|
|
327
|
+
appendJournalEntry(config, {
|
|
328
|
+
ts: new Date().toISOString(), sid: currentSessionId(), pid: process.pid,
|
|
329
|
+
argv: ['set', 'in-session', repoPath], exit: 0, ms: 0, v: pkg.version,
|
|
330
|
+
});
|
|
331
|
+
} catch { /* journal is best-effort — the status flip already landed */ }
|
|
332
|
+
process.stderr.write(`${green('→ Claimed')}: ${repoPath} (in-session)\n`);
|
|
286
333
|
}
|
|
287
334
|
|
|
288
335
|
// Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
|