dotmd-cli 0.29.3 → 0.31.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 +5 -30
- package/bin/dotmd.mjs +7 -45
- package/package.json +1 -1
- package/src/claude-commands.mjs +2 -42
- package/src/hud.mjs +1 -6
- package/src/init.mjs +95 -7
- package/src/lifecycle.mjs +8 -127
- package/src/render.mjs +0 -11
- package/src/handoff.mjs +0 -59
package/README.md
CHANGED
|
@@ -146,10 +146,9 @@ dotmd plans List all plans
|
|
|
146
146
|
dotmd stale List stale docs
|
|
147
147
|
dotmd actionable List docs with next steps
|
|
148
148
|
dotmd index [--write] Generate/update docs.md index block
|
|
149
|
-
dotmd hud
|
|
150
|
-
dotmd pickup <file> Pick up a plan (in-session + print body
|
|
149
|
+
dotmd hud Actionable triage (silent when clean — ideal SessionStart hook)
|
|
150
|
+
dotmd pickup <file> Pick up a plan (in-session + print body)
|
|
151
151
|
dotmd release [<file>] Release in-session lease (alias: unpickup)
|
|
152
|
-
dotmd handoff <file> [...] Queue a resume-prompt sidecar + release
|
|
153
152
|
dotmd status <file> <status> Transition document status
|
|
154
153
|
dotmd archive <file> Archive (status + move + update refs)
|
|
155
154
|
dotmd bulk archive <files> Archive multiple files at once
|
|
@@ -267,7 +266,7 @@ dotmd prompts archive <file> # archive a prompt
|
|
|
267
266
|
dotmd prompts new <name> [body] # alias for `dotmd new prompt`
|
|
268
267
|
```
|
|
269
268
|
|
|
270
|
-
`dotmd hud` surfaces pending prompts on session start (alongside held leases
|
|
269
|
+
`dotmd hud` surfaces pending prompts on session start (alongside held leases), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it.
|
|
271
270
|
|
|
272
271
|
Statuses: `pending` (drafted, awaiting a session), `claimed` (consumed by a session), `archived`.
|
|
273
272
|
|
|
@@ -448,7 +447,7 @@ dotmd bulk archive docs/old-*.md -n # preview
|
|
|
448
447
|
### Pickup & Closeout
|
|
449
448
|
|
|
450
449
|
```bash
|
|
451
|
-
dotmd pickup docs/plans/my-plan.md # set in-session + print body
|
|
450
|
+
dotmd pickup docs/plans/my-plan.md # set in-session + print body
|
|
452
451
|
dotmd archive docs/plans/my-plan.md # fully shipped: archive + auto-release lease
|
|
453
452
|
dotmd release docs/plans/my-plan.md # need more work: release lease, flip to prior status
|
|
454
453
|
dotmd status docs/plans/my-plan.md partial # shipped + tail deferred (reference successors in body)
|
|
@@ -457,30 +456,6 @@ dotmd status docs/plans/my-plan.md awaiting # stuck on a human decision
|
|
|
457
456
|
|
|
458
457
|
`finish` is a legacy command that defaults to `status: done`, which is no longer in the default plan vocabulary as of 0.16. Use `archive` (fully shipped) or `release` + `status` (anything else). If you need it back, add `done` to `types.plan.statuses` in your config.
|
|
459
458
|
|
|
460
|
-
### Handoff (resume-prompts attached to plans)
|
|
461
|
-
|
|
462
|
-
When you're stopping mid-work and the next session will need to pick up where
|
|
463
|
-
you left off, write a handoff sidecar instead of printing a resume prompt to
|
|
464
|
-
chat for copy-paste:
|
|
465
|
-
|
|
466
|
-
```bash
|
|
467
|
-
dotmd handoff docs/plans/foo.md "continue from validate(); next: write tests"
|
|
468
|
-
dotmd handoff docs/plans/foo.md - <<'EOF' # heredoc-friendly for Claude
|
|
469
|
-
…multi-line resume prompt…
|
|
470
|
-
EOF
|
|
471
|
-
dotmd handoff docs/plans/foo.md @/tmp/handoff.md # from file
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
The handoff is written to `<repoRoot>/.dotmd/handoffs/<plan-path>` as a
|
|
475
|
-
timestamped section (append mode by default; `--replace` to overwrite). The
|
|
476
|
-
lease is released and the plan flips back to its prior status. The next
|
|
477
|
-
`dotmd pickup` of that plan prints the handoff *instead of* the plan body
|
|
478
|
-
and atomically unlinks the sidecar — single-claim, can't be consumed twice.
|
|
479
|
-
|
|
480
|
-
The included `/handoff` slash command (scaffolded under
|
|
481
|
-
`.claude/commands/handoff.md` by `dotmd init` / `dotmd doctor`) instructs
|
|
482
|
-
Claude to synthesize and queue handoffs for every plan the session holds.
|
|
483
|
-
|
|
484
459
|
### Session leases & release
|
|
485
460
|
|
|
486
461
|
`dotmd pickup` records a lease at `<repoRoot>/.dotmd/in-session.json` that
|
|
@@ -544,7 +519,7 @@ either is silent.
|
|
|
544
519
|
```
|
|
545
520
|
|
|
546
521
|
- **SessionStart** runs `dotmd hud`, which prints up to three actionable
|
|
547
|
-
lines (held leases,
|
|
522
|
+
lines (held leases, pending prompts, stale leases) and stays silent when
|
|
548
523
|
nothing is queued. Use this instead of `dotmd briefing` for the hook role
|
|
549
524
|
— `briefing` dumps per-plan next_step prose that can run to many kilobytes
|
|
550
525
|
on large repos. `hud` is the zero-pollution surface.
|
package/bin/dotmd.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const HELP = {
|
|
|
14
14
|
_main: `dotmd v${pkg.version} — frontmatter markdown document manager
|
|
15
15
|
|
|
16
16
|
View & Query:
|
|
17
|
-
hud [--json]
|
|
17
|
+
hud [--json] Two-line actionable triage (held / prompts / stuck) — silent when clean
|
|
18
18
|
list [--verbose] [--json] List docs grouped by status (default command)
|
|
19
19
|
briefing [--json] Full briefing with plan status counts + next steps
|
|
20
20
|
context [--summarize] [--json] Full briefing (LLM-oriented)
|
|
@@ -44,9 +44,8 @@ Validate & Fix:
|
|
|
44
44
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
45
45
|
|
|
46
46
|
Lifecycle:
|
|
47
|
-
pickup <file> [--takeover] Pick up a plan (set in-session + print body
|
|
47
|
+
pickup <file> [--takeover] Pick up a plan (set in-session + print body)
|
|
48
48
|
release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
|
|
49
|
-
handoff <file> [text|-|@path] Queue a resume-prompt sidecar + release (append-mode)
|
|
50
49
|
finish <file> [done|active] Finish a plan (set done or active)
|
|
51
50
|
status <file> <status> Transition document status
|
|
52
51
|
archive <file> Archive (status + move + update refs)
|
|
@@ -125,10 +124,6 @@ Sets the plan to in-session and prints its content (prefixed with a
|
|
|
125
124
|
Writes a session lease to <repoRoot>/.dotmd/in-session.json so the same
|
|
126
125
|
Claude session can re-attach silently after compaction or /clear.
|
|
127
126
|
|
|
128
|
-
If a handoff sidecar is queued for the plan (\`.dotmd/handoffs/<path>\`),
|
|
129
|
-
pickup prints the handoff *instead of* the plan body and unlinks the
|
|
130
|
-
sidecar atomically — single-claim, can't be consumed twice.
|
|
131
|
-
|
|
132
127
|
If a plan is already in-session:
|
|
133
128
|
- Same session → silent re-attach (prints body, no error).
|
|
134
129
|
- Different session, live pid → refuses with "Held by …" message.
|
|
@@ -139,8 +134,7 @@ Options:
|
|
|
139
134
|
--json Output as JSON
|
|
140
135
|
--dry-run, -n Preview without writing
|
|
141
136
|
|
|
142
|
-
If no file is given, prompts with a list of active/planned plans
|
|
143
|
-
any plan with a queued handoff (sorted to the top).`,
|
|
137
|
+
If no file is given, prompts with a list of active/planned plans.`,
|
|
144
138
|
|
|
145
139
|
unpickup: `dotmd unpickup [<file>] — release a plan from in-session
|
|
146
140
|
|
|
@@ -171,37 +165,6 @@ status. With no file, releases every lease owned by the current session.
|
|
|
171
165
|
Identical behavior to \`dotmd unpickup\`; both names route to the same
|
|
172
166
|
implementation. See \`dotmd unpickup --help\` for full option list.`,
|
|
173
167
|
|
|
174
|
-
handoff: `dotmd handoff <file> [text | - | @path] — queue resume prompt + release
|
|
175
|
-
|
|
176
|
-
Writes a handoff sidecar attached to the plan, then releases the lease
|
|
177
|
-
and flips frontmatter back to the prior status. The next \`dotmd pickup\`
|
|
178
|
-
of the plan prints the handoff (instead of the plan body) and atomically
|
|
179
|
-
unlinks the sidecar — single-claim guaranteed.
|
|
180
|
-
|
|
181
|
-
Sidecars live at <repoRoot>/.dotmd/handoffs/<plan-path> and accumulate
|
|
182
|
-
timestamped sections on repeat writes. To replace the chain instead of
|
|
183
|
-
appending, pass --replace.
|
|
184
|
-
|
|
185
|
-
Sources for handoff text (pick one):
|
|
186
|
-
<text> Inline argument (best for short notes)
|
|
187
|
-
--message "<text>" Explicit --message flag (equivalent to inline)
|
|
188
|
-
- Read from stdin (heredoc-friendly for Claude)
|
|
189
|
-
@path Read from a file
|
|
190
|
-
|
|
191
|
-
Examples:
|
|
192
|
-
dotmd handoff plans/foo.md "continue from validate(); next: write tests"
|
|
193
|
-
dotmd handoff plans/foo.md - <<'EOF'
|
|
194
|
-
…multi-line handoff…
|
|
195
|
-
EOF
|
|
196
|
-
dotmd handoff plans/foo.md @/tmp/handoff.md
|
|
197
|
-
|
|
198
|
-
Options:
|
|
199
|
-
--replace Overwrite the sidecar chain instead of appending
|
|
200
|
-
--json Output as JSON
|
|
201
|
-
--dry-run, -n Preview without writing
|
|
202
|
-
|
|
203
|
-
Refuses if the plan is not currently held by this session.`,
|
|
204
|
-
|
|
205
168
|
finish: `dotmd finish <file> [done|active] — finish working on a plan
|
|
206
169
|
|
|
207
170
|
Sets the plan status to done (default) or back to active.
|
|
@@ -263,13 +226,12 @@ Options:
|
|
|
263
226
|
|
|
264
227
|
hud: `dotmd hud — actionable triage for session start
|
|
265
228
|
|
|
266
|
-
Prints up to
|
|
229
|
+
Prints up to three lines, in order:
|
|
267
230
|
▶ You hold N plans: <slugs> (leases owned by current session)
|
|
268
|
-
▶ N handoffs queued: <slugs> (resume-prompt sidecars waiting)
|
|
269
231
|
▶ N pending prompts: <slugs> (saved prompts in docs/prompts/)
|
|
270
232
|
⚠ N stuck leases >24h (suggest \`dotmd release --stale\`)
|
|
271
233
|
|
|
272
|
-
Silent when all
|
|
234
|
+
Silent when all three are empty — designed for SessionStart hooks where
|
|
273
235
|
zero noise is the right default. Distinct from \`dotmd briefing\`, which
|
|
274
236
|
dumps the full plan-status pipeline and per-plan next_step bodies (kilobytes
|
|
275
237
|
on large repos). Use hud for ergonomic session boot; use briefing for
|
|
@@ -816,7 +778,7 @@ async function main() {
|
|
|
816
778
|
if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
|
|
817
779
|
if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
|
|
818
780
|
if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
819
|
-
if (command === 'handoff') {
|
|
781
|
+
if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
|
|
820
782
|
if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
|
|
821
783
|
if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
|
|
822
784
|
if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
|
|
@@ -1076,7 +1038,7 @@ async function main() {
|
|
|
1076
1038
|
// Unknown command — suggest closest match
|
|
1077
1039
|
const allCommands = [
|
|
1078
1040
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
|
|
1079
|
-
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', '
|
|
1041
|
+
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
|
|
1080
1042
|
'unblocks', 'health', 'glossary',
|
|
1081
1043
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
1082
1044
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses',
|
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -16,9 +16,8 @@ function generatePlansCommand(config) {
|
|
|
16
16
|
lines.push('');
|
|
17
17
|
lines.push('Plan-specific commands:');
|
|
18
18
|
lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
|
|
19
|
-
lines.push('- `dotmd pickup <file>` — pick up a plan (set in-session + print body
|
|
19
|
+
lines.push('- `dotmd pickup <file>` — pick up a plan (set in-session + print body)');
|
|
20
20
|
lines.push('- `dotmd release` — release current session\'s leases (alias: unpickup)');
|
|
21
|
-
lines.push('- `dotmd handoff <file> -` — queue a resume-prompt sidecar + release (see /handoff)');
|
|
22
21
|
lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
|
|
23
22
|
lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
|
|
24
23
|
lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
|
|
@@ -47,44 +46,6 @@ function generatePlansCommand(config) {
|
|
|
47
46
|
return lines.join('\n');
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
function generateHandoffCommand(config) {
|
|
51
|
-
const lines = [VERSION_MARKER, ''];
|
|
52
|
-
lines.push('Queue a resume-prompt sidecar for every plan this session is currently holding. Use this whenever the user asks for a "resume prompt" / "handoff" / "context dump", or when you are about to stop mid-work and the next session will need to pick up where you left off. NEVER print the resume prompt into chat for copy-paste — write it as a handoff sidecar so the next `dotmd pickup` can consume it cleanly.');
|
|
53
|
-
lines.push('');
|
|
54
|
-
lines.push('Steps:');
|
|
55
|
-
lines.push('');
|
|
56
|
-
lines.push('1. Identify which plans this session is holding by running:');
|
|
57
|
-
lines.push(' ```');
|
|
58
|
-
lines.push(' dotmd plans --status in-session --json');
|
|
59
|
-
lines.push(' ```');
|
|
60
|
-
lines.push(' Filter to leases owned by the current session (compare `lease.session` against `$CLAUDE_CODE_SESSION_ID` if needed). If the user passed a specific plan path as an argument, use only that one.');
|
|
61
|
-
lines.push('');
|
|
62
|
-
lines.push('2. For each held plan, synthesize a handoff prompt covering:');
|
|
63
|
-
lines.push(' - One-line summary of where the session got to');
|
|
64
|
-
lines.push(' - What is already done (and verified working)');
|
|
65
|
-
lines.push(' - What is in flight / partial / needs verification');
|
|
66
|
-
lines.push(' - Concrete next step the resuming session should take');
|
|
67
|
-
lines.push(' - Key files touched (with paths) and any non-obvious gotchas');
|
|
68
|
-
lines.push(' - Open questions or decisions deferred');
|
|
69
|
-
lines.push('');
|
|
70
|
-
lines.push('3. Write each handoff via Bash heredoc:');
|
|
71
|
-
lines.push(' ```bash');
|
|
72
|
-
lines.push(' dotmd handoff <plan-path> - <<\'EOF\'');
|
|
73
|
-
lines.push(' <synthesized handoff body>');
|
|
74
|
-
lines.push(' EOF');
|
|
75
|
-
lines.push(' ```');
|
|
76
|
-
lines.push(' Append-mode is the default. The sidecar accumulates timestamped sections, so a session that writes multiple handoffs over time builds up a chronicle the next session reads in order.');
|
|
77
|
-
lines.push('');
|
|
78
|
-
lines.push('4. Report back which plans got handoffs queued and where their sidecars live (the `▶ Handoff queued: <path>` output line). The user closes their window; the next session runs `claude "$(dotmd pickup <plan>)"` and lands seeded with the handoff content.');
|
|
79
|
-
lines.push('');
|
|
80
|
-
lines.push('Rules:');
|
|
81
|
-
lines.push('- If a plan is not held by this session, do not write a handoff for it (dotmd will refuse anyway).');
|
|
82
|
-
lines.push('- If `dotmd handoff` errors with a recoverable message (existing sidecar conflict, etc.), read the path it cites, merge mentally, then re-run with `--replace`.');
|
|
83
|
-
lines.push('- Never print the resume text into the conversation as a copy-paste block.');
|
|
84
|
-
lines.push('');
|
|
85
|
-
return lines.join('\n');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
49
|
function generateDocsCommand(config) {
|
|
89
50
|
const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
|
|
90
51
|
const rootCount = roots.length;
|
|
@@ -156,7 +117,6 @@ export function scaffoldClaudeCommands(cwd, config) {
|
|
|
156
117
|
const files = [
|
|
157
118
|
{ name: 'plans.md', generate: () => generatePlansCommand(config) },
|
|
158
119
|
{ name: 'docs.md', generate: () => generateDocsCommand(config) },
|
|
159
|
-
{ name: 'handoff.md', generate: () => generateHandoffCommand(config) },
|
|
160
120
|
];
|
|
161
121
|
|
|
162
122
|
for (const { name, generate } of files) {
|
|
@@ -189,7 +149,7 @@ export function checkClaudeCommands(cwd) {
|
|
|
189
149
|
if (!existsSync(commandsDir)) return [];
|
|
190
150
|
|
|
191
151
|
const warnings = [];
|
|
192
|
-
for (const name of ['plans.md', 'docs.md'
|
|
152
|
+
for (const name of ['plans.md', 'docs.md']) {
|
|
193
153
|
const filePath = path.join(commandsDir, name);
|
|
194
154
|
const installedVersion = getInstalledVersion(filePath);
|
|
195
155
|
if (installedVersion && installedVersion !== pkg.version) {
|
package/src/hud.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
|
|
4
|
-
import { listQueuedHandoffs } from './handoff.mjs';
|
|
5
4
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
6
5
|
import { asString, toRepoPath } from './util.mjs';
|
|
7
6
|
import { green, yellow, dim } from './color.mjs';
|
|
@@ -69,11 +68,10 @@ export function buildHud(config) {
|
|
|
69
68
|
const session = currentSessionId();
|
|
70
69
|
const leases = readLeases(config);
|
|
71
70
|
const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
|
|
72
|
-
const queued = listQueuedHandoffs(config).map(h => h.repoPath);
|
|
73
71
|
const stale = findStaleLeases(config).map(l => l.path);
|
|
74
72
|
const prompts = findActionablePrompts(config);
|
|
75
73
|
|
|
76
|
-
return { owned,
|
|
74
|
+
return { owned, stale, prompts };
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
export function runHud(argv, config) {
|
|
@@ -89,9 +87,6 @@ export function runHud(argv, config) {
|
|
|
89
87
|
if (hud.owned.length > 0) {
|
|
90
88
|
lines.push(green(`▶ You hold ${hud.owned.length} plan${hud.owned.length === 1 ? '' : 's'}: ${previewList(hud.owned)}`));
|
|
91
89
|
}
|
|
92
|
-
if (hud.queued.length > 0) {
|
|
93
|
-
lines.push(green(`▶ ${hud.queued.length} handoff${hud.queued.length === 1 ? '' : 's'} queued: ${previewList(hud.queued)} ${dim('(resume: dotmd pickup)')}`));
|
|
94
|
-
}
|
|
95
90
|
if (hud.prompts.length > 0) {
|
|
96
91
|
lines.push(green(`▶ ${hud.prompts.length} pending prompt${hud.prompts.length === 1 ? '' : 's'}: ${previewList(hud.prompts)} ${dim('(consume: `dotmd prompts use <file>` — do not cat/read)')}`));
|
|
97
92
|
}
|
package/src/init.mjs
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { green, dim } from './color.mjs';
|
|
4
|
+
import { green, dim, yellow } from './color.mjs';
|
|
5
5
|
import { warn } from './util.mjs';
|
|
6
6
|
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
7
7
|
|
|
8
|
+
// Subdirectories scaffolded under docsRoot and tracked separately during scans.
|
|
9
|
+
// Each maps to a builtin type (plan, prompt). New types added here should also
|
|
10
|
+
// have a matching builtin template so `dotmd new <type>` lands files correctly.
|
|
11
|
+
const TYPE_SUBDIRS = ['plans', 'prompts'];
|
|
12
|
+
|
|
8
13
|
const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
|
|
9
14
|
// All exports are optional. See dotmd.config.example.mjs for full reference.
|
|
10
15
|
|
|
@@ -33,17 +38,33 @@ function scanExistingDocs(dir) {
|
|
|
33
38
|
const modules = new Set();
|
|
34
39
|
const refFieldNames = new Set();
|
|
35
40
|
let docCount = 0;
|
|
41
|
+
// Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
|
|
42
|
+
// so callers can report what's already there — including files without frontmatter,
|
|
43
|
+
// which are otherwise invisible to detection.
|
|
44
|
+
const subdirCounts = {};
|
|
45
|
+
|
|
46
|
+
function bump(subdir, hasFrontmatter) {
|
|
47
|
+
if (!subdirCounts[subdir]) subdirCounts[subdir] = { withFrontmatter: 0, withoutFrontmatter: 0 };
|
|
48
|
+
if (hasFrontmatter) subdirCounts[subdir].withFrontmatter++;
|
|
49
|
+
else subdirCounts[subdir].withoutFrontmatter++;
|
|
50
|
+
}
|
|
36
51
|
|
|
37
|
-
function walk(d) {
|
|
52
|
+
function walk(d, topSubdir) {
|
|
38
53
|
let entries;
|
|
39
54
|
try { entries = readdirSync(d, { withFileTypes: true }); } catch (err) { warn(`Could not read ${d}: ${err.message}`); return; }
|
|
40
55
|
for (const entry of entries) {
|
|
41
|
-
if (entry.isDirectory()) {
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
const nextTop = topSubdir === null ? entry.name : topSubdir;
|
|
58
|
+
walk(path.join(d, entry.name), nextTop);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
42
61
|
if (!entry.name.endsWith('.md')) continue;
|
|
43
62
|
let raw;
|
|
44
63
|
try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
|
|
45
64
|
const { frontmatter } = extractFrontmatter(raw);
|
|
46
|
-
|
|
65
|
+
const subdir = topSubdir ?? '';
|
|
66
|
+
if (!frontmatter) { bump(subdir, false); continue; }
|
|
67
|
+
bump(subdir, true);
|
|
47
68
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
48
69
|
docCount++;
|
|
49
70
|
if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
|
|
@@ -59,8 +80,25 @@ function scanExistingDocs(dir) {
|
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
walk(dir);
|
|
63
|
-
return { docCount, statuses, surfaces, modules, refFieldNames };
|
|
83
|
+
walk(dir, null);
|
|
84
|
+
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Count .md files (regardless of frontmatter) directly inside a single directory.
|
|
88
|
+
// Used to detect root-level plans/ or prompts/ siblings that aren't under docsRoot.
|
|
89
|
+
function countMarkdownFiles(dir) {
|
|
90
|
+
let withFrontmatter = 0;
|
|
91
|
+
let withoutFrontmatter = 0;
|
|
92
|
+
let entries;
|
|
93
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return { withFrontmatter, withoutFrontmatter }; }
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
96
|
+
let raw;
|
|
97
|
+
try { raw = readFileSync(path.join(dir, entry.name), 'utf8'); } catch { continue; }
|
|
98
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
99
|
+
if (frontmatter) withFrontmatter++; else withoutFrontmatter++;
|
|
100
|
+
}
|
|
101
|
+
return { withFrontmatter, withoutFrontmatter };
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
function generateDetectedConfig(scan, rootPath) {
|
|
@@ -112,10 +150,11 @@ export function runInit(cwd, config) {
|
|
|
112
150
|
|
|
113
151
|
process.stdout.write('\n');
|
|
114
152
|
|
|
153
|
+
const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
|
|
154
|
+
|
|
115
155
|
if (existsSync(configPath)) {
|
|
116
156
|
process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
|
|
117
157
|
} else {
|
|
118
|
-
const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
|
|
119
158
|
if (scan && scan.docCount > 0) {
|
|
120
159
|
writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
|
|
121
160
|
process.stdout.write(` ${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
|
|
@@ -132,6 +171,41 @@ export function runInit(cwd, config) {
|
|
|
132
171
|
process.stdout.write(` ${green('create')} docs/\n`);
|
|
133
172
|
}
|
|
134
173
|
|
|
174
|
+
// Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
|
|
175
|
+
// If a sibling already holds content, skip creating the matching docs/<sub>/
|
|
176
|
+
// so we don't quietly create a parallel dir the user has to reconcile.
|
|
177
|
+
const siblingsWithContent = [];
|
|
178
|
+
for (const sub of TYPE_SUBDIRS) {
|
|
179
|
+
const siblingPath = path.join(cwd, sub);
|
|
180
|
+
if (!existsSync(siblingPath)) continue;
|
|
181
|
+
const c = countMarkdownFiles(siblingPath);
|
|
182
|
+
const total = c.withFrontmatter + c.withoutFrontmatter;
|
|
183
|
+
if (total > 0) siblingsWithContent.push({ sub, total });
|
|
184
|
+
}
|
|
185
|
+
const siblingSet = new Set(siblingsWithContent.map(s => s.sub));
|
|
186
|
+
|
|
187
|
+
// Scaffold the canonical type subdirs (docs/plans/, docs/prompts/) so the
|
|
188
|
+
// builtin `dotmd new plan` / `dotmd new prompt` templates land somewhere
|
|
189
|
+
// sensible without extra config.
|
|
190
|
+
for (const sub of TYPE_SUBDIRS) {
|
|
191
|
+
const subPath = path.join(docsDir, sub);
|
|
192
|
+
const counts = scan?.subdirCounts?.[sub];
|
|
193
|
+
const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
|
|
194
|
+
if (siblingSet.has(sub) && !existsSync(subPath)) {
|
|
195
|
+
process.stdout.write(` ${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (existsSync(subPath)) {
|
|
199
|
+
const detail = total > 0
|
|
200
|
+
? ` (${counts.withFrontmatter} dotmd-tracked, ${counts.withoutFrontmatter} plain .md)`
|
|
201
|
+
: '';
|
|
202
|
+
process.stdout.write(` ${dim('exists')} docs/${sub}/${detail}\n`);
|
|
203
|
+
} else {
|
|
204
|
+
mkdirSync(subPath, { recursive: true });
|
|
205
|
+
process.stdout.write(` ${green('create')} docs/${sub}/\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
135
209
|
if (existsSync(indexPath)) {
|
|
136
210
|
process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
|
|
137
211
|
} else {
|
|
@@ -139,6 +213,20 @@ export function runInit(cwd, config) {
|
|
|
139
213
|
process.stdout.write(` ${green('create')} docs/docs.md\n`);
|
|
140
214
|
}
|
|
141
215
|
|
|
216
|
+
if (siblingsWithContent.length > 0) {
|
|
217
|
+
const list = siblingsWithContent
|
|
218
|
+
.map(({ sub, total }) => `${sub}/ (${total} .md file${total === 1 ? '' : 's'})`)
|
|
219
|
+
.join(', ');
|
|
220
|
+
const subs = siblingsWithContent.map(s => s.sub);
|
|
221
|
+
process.stdout.write(`\n ${yellow('notice')} found at repo root: ${list}\n`);
|
|
222
|
+
process.stdout.write(` these are NOT under docs/ and won't be tracked by the default config. Either:\n`);
|
|
223
|
+
for (const sub of subs) {
|
|
224
|
+
process.stdout.write(` • move into docs/: mv ./${sub}/* docs/${sub}/ && rmdir ./${sub}\n`);
|
|
225
|
+
}
|
|
226
|
+
process.stdout.write(` • or use a flat layout — set in dotmd.config.mjs:\n`);
|
|
227
|
+
process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
|
|
228
|
+
}
|
|
229
|
+
|
|
142
230
|
// .gitignore: ensure .dotmd/ is ignored (session leases live there)
|
|
143
231
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
144
232
|
const ignoreLine = '.dotmd/';
|
package/src/lifecycle.mjs
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
currentSessionId,
|
|
17
17
|
migrateLease,
|
|
18
18
|
} from './lease.mjs';
|
|
19
|
-
import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
|
|
20
19
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
21
20
|
import { walkSections, findSection } from './section.mjs';
|
|
22
21
|
|
|
@@ -163,17 +162,15 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
163
162
|
const fullBody = argv.includes('--full');
|
|
164
163
|
let input = argv.find(a => !a.startsWith('-'));
|
|
165
164
|
|
|
166
|
-
// Interactive: pick from active/planned plans
|
|
165
|
+
// Interactive: pick from active/planned plans
|
|
167
166
|
if (!input) {
|
|
168
167
|
if (!isInteractive()) die('Usage: dotmd pickup <file>');
|
|
169
168
|
const index = buildIndex(config);
|
|
170
|
-
const queued = new Set(listQueuedHandoffs(config).map(h => h.repoPath));
|
|
171
169
|
const candidates = index.docs.filter(d =>
|
|
172
|
-
d.type === 'plan' && (d.status === 'active' || d.status === 'planned'
|
|
170
|
+
d.type === 'plan' && (d.status === 'active' || d.status === 'planned')
|
|
173
171
|
);
|
|
174
|
-
if (candidates.length === 0) die('No active/planned plans
|
|
175
|
-
|
|
176
|
-
const labelFor = (d) => `${queued.has(d.path) ? '▶ handoff queued · ' : ''}${d.title} (${d.status}) — ${d.path}`;
|
|
172
|
+
if (candidates.length === 0) die('No active/planned plans.');
|
|
173
|
+
const labelFor = (d) => `${d.title} (${d.status}) — ${d.path}`;
|
|
177
174
|
const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
|
|
178
175
|
if (!choice) die('No plan selected.');
|
|
179
176
|
const idx = candidates.findIndex(d => choice === labelFor(d));
|
|
@@ -207,9 +204,8 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
207
204
|
}
|
|
208
205
|
}
|
|
209
206
|
|
|
210
|
-
const handoffQueued = hasHandoff(config, repoPath);
|
|
211
207
|
const pickupable = new Set(['active', 'planned', 'in-session']);
|
|
212
|
-
if (oldStatus && !pickupable.has(oldStatus)
|
|
208
|
+
if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
|
|
213
209
|
|
|
214
210
|
const today = nowIso();
|
|
215
211
|
const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
|
|
@@ -247,19 +243,13 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
247
243
|
}
|
|
248
244
|
}
|
|
249
245
|
|
|
250
|
-
let handoffBody = null;
|
|
251
|
-
if (handoffQueued && !dryRun) {
|
|
252
|
-
try { handoffBody = consumeHandoff(config, repoPath); } catch (err) { warn(`Could not consume handoff for ${repoPath}: ${err.message}`); }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
246
|
if (json) {
|
|
256
|
-
const card =
|
|
247
|
+
const card = buildCard(filePath, raw, config);
|
|
257
248
|
process.stdout.write(JSON.stringify({
|
|
258
249
|
path: repoPath, oldStatus, newStatus: 'in-session', title,
|
|
259
250
|
reattached: leaseOutcome === 'reattached',
|
|
260
251
|
takenOver: leaseOutcome === 'taken-over',
|
|
261
|
-
|
|
262
|
-
body: (handoffBody ?? body)?.trim() ?? '',
|
|
252
|
+
body: body?.trim() ?? '',
|
|
263
253
|
card,
|
|
264
254
|
}, null, 2) + '\n');
|
|
265
255
|
} else {
|
|
@@ -270,18 +260,12 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
270
260
|
} else {
|
|
271
261
|
process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
272
262
|
}
|
|
273
|
-
if (
|
|
274
|
-
const header = `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`;
|
|
275
|
-
process.stdout.write(header);
|
|
276
|
-
const content = handoffBody.trim();
|
|
277
|
-
if (content) process.stdout.write(content + '\n');
|
|
278
|
-
} else if (fullBody) {
|
|
263
|
+
if (fullBody) {
|
|
279
264
|
const header = `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
|
|
280
265
|
process.stdout.write(header);
|
|
281
266
|
const content = (body ?? '').trim();
|
|
282
267
|
if (content) process.stdout.write(content + '\n');
|
|
283
268
|
} else {
|
|
284
|
-
// Default: card view
|
|
285
269
|
const card = buildCard(filePath, raw, config);
|
|
286
270
|
process.stdout.write(renderCard(card));
|
|
287
271
|
}
|
|
@@ -771,109 +755,6 @@ function countRefsToUpdate(oldPath, newPath, config) {
|
|
|
771
755
|
return count;
|
|
772
756
|
}
|
|
773
757
|
|
|
774
|
-
function readHandoffInput(source) {
|
|
775
|
-
if (source === '-') {
|
|
776
|
-
const chunks = [];
|
|
777
|
-
try { chunks.push(readFileSync(0, 'utf8')); } catch (err) { die(`Could not read handoff from stdin: ${err.message}`); }
|
|
778
|
-
return chunks.join('');
|
|
779
|
-
}
|
|
780
|
-
if (typeof source === 'string' && source.startsWith('@')) {
|
|
781
|
-
const file = source.slice(1);
|
|
782
|
-
if (!existsSync(file)) die(`Handoff file not found: ${file}`);
|
|
783
|
-
return readFileSync(file, 'utf8');
|
|
784
|
-
}
|
|
785
|
-
return source;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
export async function runHandoff(argv, config, opts = {}) {
|
|
789
|
-
const { dryRun } = opts;
|
|
790
|
-
const json = argv.includes('--json');
|
|
791
|
-
const replace = argv.includes('--replace');
|
|
792
|
-
const messageIdx = argv.indexOf('--message');
|
|
793
|
-
const messageFlag = messageIdx >= 0 ? argv[messageIdx + 1] : null;
|
|
794
|
-
const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--message');
|
|
795
|
-
|
|
796
|
-
let input = positional[0];
|
|
797
|
-
let text = messageFlag !== null ? messageFlag : positional[1];
|
|
798
|
-
|
|
799
|
-
// Interactive: pick from in-session plans owned by current session
|
|
800
|
-
if (!input) {
|
|
801
|
-
if (!isInteractive()) die('Usage: dotmd handoff <file> [text | - | @path] (or --message "text")');
|
|
802
|
-
const leases = readLeases(config);
|
|
803
|
-
const session = currentSessionId();
|
|
804
|
-
const owned = Object.values(leases).filter(l => l.session === session);
|
|
805
|
-
if (owned.length === 0) die('No in-session plans owned by this session.');
|
|
806
|
-
if (owned.length === 1) {
|
|
807
|
-
input = owned[0].path;
|
|
808
|
-
process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
|
|
809
|
-
} else {
|
|
810
|
-
const choice = await promptChoice('Handoff which plan:', owned.map(l => l.path));
|
|
811
|
-
if (!choice) die('No plan selected.');
|
|
812
|
-
input = choice;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const filePath = resolveDocPath(input, config);
|
|
817
|
-
if (!filePath) die(`File not found: ${input}`);
|
|
818
|
-
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
819
|
-
|
|
820
|
-
// Resolve text: explicit arg/stdin/@file, else die (no $EDITOR fallback — sessions are non-interactive)
|
|
821
|
-
if (text === undefined || text === null) {
|
|
822
|
-
die('Missing handoff text. Pass inline, --message "text", - for stdin, or @path for a file.');
|
|
823
|
-
}
|
|
824
|
-
const body = readHandoffInput(text);
|
|
825
|
-
if (!body.trim()) die('Handoff text is empty.');
|
|
826
|
-
|
|
827
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
828
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
829
|
-
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
830
|
-
const oldStatus = asString(parsedFm.status);
|
|
831
|
-
|
|
832
|
-
// Must be holding this plan in current session
|
|
833
|
-
const leases = readLeases(config);
|
|
834
|
-
const lease = leases[repoPath];
|
|
835
|
-
const session = currentSessionId();
|
|
836
|
-
if (!lease || lease.session !== session) {
|
|
837
|
-
die(`Not held by this session: ${repoPath}\n Run \`dotmd pickup ${repoPath}\` first.`);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
const today = nowIso();
|
|
841
|
-
const targetStatus = lease.oldStatus || 'active';
|
|
842
|
-
|
|
843
|
-
if (dryRun) {
|
|
844
|
-
process.stderr.write(`${dim('[dry-run]')} Would ${replace ? 'replace' : 'append to'} ${toRepoPath(handoffPath(config, repoPath), config.repoRoot)}\n`);
|
|
845
|
-
process.stderr.write(`${dim('[dry-run]')} Would release lease and flip status: in-session → ${targetStatus}\n`);
|
|
846
|
-
if (json) process.stdout.write(JSON.stringify({ path: repoPath, handoff: toRepoPath(handoffPath(config, repoPath), config.repoRoot), bytes: body.length, replace, dryRun: true }, null, 2) + '\n');
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Order: write handoff first, then release. A crash between leaves a queued
|
|
851
|
-
// handoff with a held lease (recoverable) instead of a released lease with
|
|
852
|
-
// no handoff (silently lost).
|
|
853
|
-
const written = appendHandoff(config, repoPath, body, { replace });
|
|
854
|
-
|
|
855
|
-
if (oldStatus === 'in-session') {
|
|
856
|
-
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
857
|
-
}
|
|
858
|
-
appendVersionHistory(filePath, `Handoff queued (in-session → ${targetStatus}).`);
|
|
859
|
-
releaseLease(config, repoPath, { force: true });
|
|
860
|
-
|
|
861
|
-
if (json) {
|
|
862
|
-
process.stdout.write(JSON.stringify({
|
|
863
|
-
path: repoPath,
|
|
864
|
-
handoff: toRepoPath(written, config.repoRoot),
|
|
865
|
-
bytes: body.length,
|
|
866
|
-
replace,
|
|
867
|
-
newStatus: targetStatus,
|
|
868
|
-
}, null, 2) + '\n');
|
|
869
|
-
} else {
|
|
870
|
-
process.stdout.write(`${green('▶ Handoff queued')}: ${toRepoPath(written, config.repoRoot)} (${body.length} bytes${replace ? ', replaced' : ', appended'})\n`);
|
|
871
|
-
process.stdout.write(`${green('↩ Released')}: ${repoPath} (in-session → ${targetStatus})\n`);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
try { config.hooks.onUnpickup?.({ path: repoPath, oldStatus: 'in-session', newStatus: targetStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
875
|
-
}
|
|
876
|
-
|
|
877
758
|
// Append a one-line dated bullet to the file's `## Version History` section.
|
|
878
759
|
// Newest-first ordering: inserted at the top of the section, right after the
|
|
879
760
|
// heading + blank-line gap. If the section is missing, this is a silent no-op
|
package/src/render.mjs
CHANGED
|
@@ -5,7 +5,6 @@ import { extractFrontmatter } from './frontmatter.mjs';
|
|
|
5
5
|
import { summarizeDocBody } from './ai.mjs';
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
7
|
import { findStaleLeases } from './lease.mjs';
|
|
8
|
-
import { listQueuedHandoffs } from './handoff.mjs';
|
|
9
8
|
|
|
10
9
|
export function renderCompactList(index, config) {
|
|
11
10
|
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
@@ -267,16 +266,6 @@ function _renderContext(index, config, opts = {}) {
|
|
|
267
266
|
export function renderBriefing(index, config) {
|
|
268
267
|
const lines = [];
|
|
269
268
|
|
|
270
|
-
try {
|
|
271
|
-
const queued = listQueuedHandoffs(config);
|
|
272
|
-
if (queued.length > 0) {
|
|
273
|
-
const preview = queued.slice(0, 3).map(h => path.basename(h.repoPath, '.md')).join(', ');
|
|
274
|
-
const more = queued.length > 3 ? `, +${queued.length - 3} more` : '';
|
|
275
|
-
lines.push(green(`▶ ${queued.length} handoff${queued.length === 1 ? '' : 's'} queued: ${preview}${more}`));
|
|
276
|
-
lines.push(dim(` Resume: claude "$(dotmd pickup <plan>)" or just: dotmd pickup`));
|
|
277
|
-
}
|
|
278
|
-
} catch {}
|
|
279
|
-
|
|
280
269
|
const plans = index.docs.filter(d => d.type === 'plan');
|
|
281
270
|
const docs = index.docs.filter(d => d.type === 'doc');
|
|
282
271
|
const research = index.docs.filter(d => d.type === 'research');
|
package/src/handoff.mjs
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
const HANDOFF_DIR = path.join('.dotmd', 'handoffs');
|
|
5
|
-
|
|
6
|
-
export function handoffPath(config, repoPath) {
|
|
7
|
-
return path.join(config.repoRoot, HANDOFF_DIR, repoPath);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function hasHandoff(config, repoPath) {
|
|
11
|
-
return existsSync(handoffPath(config, repoPath));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function readHandoff(config, repoPath) {
|
|
15
|
-
const file = handoffPath(config, repoPath);
|
|
16
|
-
if (!existsSync(file)) return null;
|
|
17
|
-
return readFileSync(file, 'utf8');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function appendHandoff(config, repoPath, text, opts = {}) {
|
|
21
|
-
const file = handoffPath(config, repoPath);
|
|
22
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
-
const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
24
|
-
const block = `## ${stamp}\n\n${text.trimEnd()}\n`;
|
|
25
|
-
if (opts.replace || !existsSync(file)) {
|
|
26
|
-
writeFileSync(file, block + '\n', 'utf8');
|
|
27
|
-
} else {
|
|
28
|
-
const prior = readFileSync(file, 'utf8').trimEnd();
|
|
29
|
-
writeFileSync(file, `${prior}\n\n${block}\n`, 'utf8');
|
|
30
|
-
}
|
|
31
|
-
return file;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function consumeHandoff(config, repoPath) {
|
|
35
|
-
const file = handoffPath(config, repoPath);
|
|
36
|
-
if (!existsSync(file)) return null;
|
|
37
|
-
const body = readFileSync(file, 'utf8');
|
|
38
|
-
unlinkSync(file);
|
|
39
|
-
return body;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function listQueuedHandoffs(config) {
|
|
43
|
-
const root = path.join(config.repoRoot, HANDOFF_DIR);
|
|
44
|
-
if (!existsSync(root)) return [];
|
|
45
|
-
const out = [];
|
|
46
|
-
function walk(dir) {
|
|
47
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
48
|
-
const full = path.join(dir, entry.name);
|
|
49
|
-
if (entry.isDirectory()) { walk(full); continue; }
|
|
50
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
51
|
-
const repoPath = path.relative(root, full).split(path.sep).join('/');
|
|
52
|
-
const st = statSync(full);
|
|
53
|
-
out.push({ repoPath, path: full, mtimeMs: st.mtimeMs });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
walk(root);
|
|
57
|
-
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
58
|
-
return out;
|
|
59
|
-
}
|