dotmd-cli 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lifecycle.mjs +27 -14
- package/src/new.mjs +79 -6
- package/src/pickup-card.mjs +250 -0
- package/src/section.mjs +96 -0
- package/src/util.mjs +4 -0
package/package.json
CHANGED
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
|
|
5
5
|
import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
migrateLease,
|
|
18
18
|
} from './lease.mjs';
|
|
19
19
|
import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
|
|
20
|
+
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
20
21
|
|
|
21
22
|
function findFileRoot(filePath, config) {
|
|
22
23
|
const roots = config.docsRoots || [config.docsRoot];
|
|
@@ -71,7 +72,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
const today =
|
|
75
|
+
const today = nowIso();
|
|
75
76
|
const archiveDir = path.join(fileRoot, config.archiveDir);
|
|
76
77
|
const relFromRoot = path.relative(fileRoot, filePath);
|
|
77
78
|
const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
|
|
@@ -135,6 +136,7 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
135
136
|
const { dryRun } = opts;
|
|
136
137
|
const json = argv.includes('--json');
|
|
137
138
|
const takeover = argv.includes('--takeover');
|
|
139
|
+
const fullBody = argv.includes('--full');
|
|
138
140
|
let input = argv.find(a => !a.startsWith('-'));
|
|
139
141
|
|
|
140
142
|
// Interactive: pick from active/planned plans + anything with a queued handoff
|
|
@@ -185,7 +187,7 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
185
187
|
const pickupable = new Set(['active', 'planned', 'in-session']);
|
|
186
188
|
if (oldStatus && !pickupable.has(oldStatus) && !handoffQueued) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
|
|
187
189
|
|
|
188
|
-
const today =
|
|
190
|
+
const today = nowIso();
|
|
189
191
|
const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
|
|
190
192
|
let leaseOutcome = 'acquired';
|
|
191
193
|
|
|
@@ -217,12 +219,14 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
if (json) {
|
|
222
|
+
const card = handoffBody ? null : buildCard(filePath, raw, config);
|
|
220
223
|
process.stdout.write(JSON.stringify({
|
|
221
224
|
path: repoPath, oldStatus, newStatus: 'in-session', title,
|
|
222
225
|
reattached: leaseOutcome === 'reattached',
|
|
223
226
|
takenOver: leaseOutcome === 'taken-over',
|
|
224
227
|
handoffConsumed: handoffBody !== null,
|
|
225
228
|
body: (handoffBody ?? body)?.trim() ?? '',
|
|
229
|
+
card,
|
|
226
230
|
}, null, 2) + '\n');
|
|
227
231
|
} else {
|
|
228
232
|
if (leaseOutcome === 'reattached') {
|
|
@@ -232,12 +236,21 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
232
236
|
} else {
|
|
233
237
|
process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
234
238
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
239
|
+
if (handoffBody) {
|
|
240
|
+
const header = `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`;
|
|
241
|
+
process.stdout.write(header);
|
|
242
|
+
const content = handoffBody.trim();
|
|
243
|
+
if (content) process.stdout.write(content + '\n');
|
|
244
|
+
} else if (fullBody) {
|
|
245
|
+
const header = `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
|
|
246
|
+
process.stdout.write(header);
|
|
247
|
+
const content = (body ?? '').trim();
|
|
248
|
+
if (content) process.stdout.write(content + '\n');
|
|
249
|
+
} else {
|
|
250
|
+
// Default: card view
|
|
251
|
+
const card = buildCard(filePath, raw, config);
|
|
252
|
+
process.stdout.write(renderCard(card));
|
|
253
|
+
}
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
|
|
@@ -302,7 +315,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
302
315
|
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
303
316
|
const cur = asString(parsedFm.status);
|
|
304
317
|
if (cur === 'in-session') {
|
|
305
|
-
const today =
|
|
318
|
+
const today = nowIso();
|
|
306
319
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
307
320
|
}
|
|
308
321
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
@@ -412,7 +425,7 @@ export async function runFinish(argv, config, opts = {}) {
|
|
|
412
425
|
|
|
413
426
|
if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
|
|
414
427
|
|
|
415
|
-
const today =
|
|
428
|
+
const today = nowIso();
|
|
416
429
|
|
|
417
430
|
if (dryRun) {
|
|
418
431
|
process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
|
|
@@ -451,7 +464,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
451
464
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
452
465
|
const oldStatus = asString(parsed.status) ?? 'unknown';
|
|
453
466
|
|
|
454
|
-
const today =
|
|
467
|
+
const today = nowIso();
|
|
455
468
|
const targetDir = path.join(archiveFileRoot, config.archiveDir);
|
|
456
469
|
const targetPath = path.join(targetDir, path.basename(filePath));
|
|
457
470
|
const oldRepoPath = toRepoPath(filePath, config.repoRoot);
|
|
@@ -608,7 +621,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
608
621
|
const filePath = resolveDocPath(input, config);
|
|
609
622
|
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
610
623
|
|
|
611
|
-
const today =
|
|
624
|
+
const today = nowIso();
|
|
612
625
|
|
|
613
626
|
if (dryRun) {
|
|
614
627
|
process.stdout.write(`${dim('[dry-run]')} Would touch: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
|
|
@@ -790,7 +803,7 @@ export async function runHandoff(argv, config, opts = {}) {
|
|
|
790
803
|
die(`Not held by this session: ${repoPath}\n Run \`dotmd pickup ${repoPath}\` first.`);
|
|
791
804
|
}
|
|
792
805
|
|
|
793
|
-
const today =
|
|
806
|
+
const today = nowIso();
|
|
794
807
|
const targetStatus = lease.oldStatus || 'active';
|
|
795
808
|
|
|
796
809
|
if (dryRun) {
|
package/src/new.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { toRepoPath, die, warn } from './util.mjs';
|
|
3
|
+
import { toRepoPath, die, warn, nowIso } from './util.mjs';
|
|
4
4
|
import { green, dim, bold } from './color.mjs';
|
|
5
5
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
6
6
|
|
|
@@ -11,9 +11,82 @@ const BUILTIN_TEMPLATES = {
|
|
|
11
11
|
body: (t) => `\n# ${t}\n`,
|
|
12
12
|
},
|
|
13
13
|
plan: {
|
|
14
|
-
description: 'Execution plan with
|
|
15
|
-
frontmatter: (s, d) =>
|
|
16
|
-
|
|
14
|
+
description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
|
|
15
|
+
frontmatter: (s, d) => [
|
|
16
|
+
'type: plan',
|
|
17
|
+
`status: ${s}`,
|
|
18
|
+
`created: ${d}`,
|
|
19
|
+
`updated: ${d}`,
|
|
20
|
+
'surfaces: []',
|
|
21
|
+
'modules: []',
|
|
22
|
+
'domain:',
|
|
23
|
+
'audience: internal',
|
|
24
|
+
'parent_plan:',
|
|
25
|
+
'related_plans: []',
|
|
26
|
+
'related_docs: []',
|
|
27
|
+
'current_state:',
|
|
28
|
+
'next_step:',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
body: (t, ctx) => `
|
|
31
|
+
# ${t}
|
|
32
|
+
|
|
33
|
+
> One-paragraph problem statement: what this plan is for, why now.
|
|
34
|
+
|
|
35
|
+
## Problem
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Goals
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Non-Goals
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
## What Exists Today
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Constraints
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Decisions
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Open Questions
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Phases
|
|
64
|
+
|
|
65
|
+
<!--
|
|
66
|
+
Status markers (put in heading text):
|
|
67
|
+
⬜ not started
|
|
68
|
+
🟡 in progress (pickup targets this)
|
|
69
|
+
✅ shipped (history; pickup skips)
|
|
70
|
+
⏭ skipped (with reason in body)
|
|
71
|
+
🚧 blocked (link to blocker)
|
|
72
|
+
-->
|
|
73
|
+
|
|
74
|
+
### Phase 1 — <title> ⬜
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## Deferred
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Version History
|
|
83
|
+
|
|
84
|
+
- **${ctx?.today ?? ''}** Created.
|
|
85
|
+
|
|
86
|
+
## Closeout
|
|
87
|
+
|
|
88
|
+
<!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
|
|
89
|
+
`,
|
|
17
90
|
},
|
|
18
91
|
adr: {
|
|
19
92
|
description: 'Architecture Decision Record',
|
|
@@ -115,7 +188,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
115
188
|
die(`File already exists: ${repoPath}`);
|
|
116
189
|
}
|
|
117
190
|
|
|
118
|
-
const today =
|
|
191
|
+
const today = nowIso();
|
|
119
192
|
|
|
120
193
|
// Generate content
|
|
121
194
|
let content;
|
|
@@ -123,7 +196,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
123
196
|
content = template(name, { status, title: docTitle, today });
|
|
124
197
|
} else {
|
|
125
198
|
const fm = template.frontmatter(status, today);
|
|
126
|
-
const body = template.body(docTitle);
|
|
199
|
+
const body = template.body(docTitle, { today, status });
|
|
127
200
|
content = `---\n${fm}\n---\n${body}`;
|
|
128
201
|
}
|
|
129
202
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, resolveDocPath } from './util.mjs';
|
|
5
|
+
import { walkSections, findSection, findActivePhase, summarizePhases, isPhaseHeading, detectMarker } from './section.mjs';
|
|
6
|
+
import { dim, green } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
const CAPS = {
|
|
9
|
+
blurb: 200,
|
|
10
|
+
currentState: 500,
|
|
11
|
+
nextStep: 300,
|
|
12
|
+
versionHistoryEntry: 200,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function countBullets(body) {
|
|
16
|
+
if (!body) return 0;
|
|
17
|
+
return body.split('\n').filter(l => /^[-*]\s+\S/.test(l)).length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncate(s, cap) {
|
|
21
|
+
if (!s) return '';
|
|
22
|
+
if (s.length <= cap) return s;
|
|
23
|
+
return s.slice(0, cap - 3).trimEnd() + '...';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function statusSummary(counts) {
|
|
27
|
+
const order = ['shipped', 'skipped', 'in-progress', 'blocked', 'todo'];
|
|
28
|
+
const icons = { shipped: '✅', skipped: '⏭', 'in-progress': '🟡', blocked: '🚧', todo: '⬜' };
|
|
29
|
+
return order.filter(k => counts[k]).map(k => `${counts[k]}${icons[k]}`).join(' ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readRelatedSummary(rawList, config) {
|
|
33
|
+
const list = Array.isArray(rawList) ? rawList : (typeof rawList === 'string' && rawList.trim() ? [rawList] : []);
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const ref of list) {
|
|
36
|
+
if (!ref) continue;
|
|
37
|
+
const refStr = String(ref).trim();
|
|
38
|
+
if (!refStr) continue;
|
|
39
|
+
let abs = null;
|
|
40
|
+
try { abs = resolveDocPath(refStr, config); } catch { abs = null; }
|
|
41
|
+
if (!abs || !existsSync(abs)) {
|
|
42
|
+
out.push({ ref: refStr, status: null, missing: true });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(abs, 'utf8');
|
|
47
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
48
|
+
const fm = parseSimpleFrontmatter(frontmatter);
|
|
49
|
+
out.push({
|
|
50
|
+
ref: toRepoPath(abs, config.repoRoot),
|
|
51
|
+
status: asString(fm.status) ?? null,
|
|
52
|
+
missing: false,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
out.push({ ref: refStr, status: null, missing: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build the structured card object. Pure: no IO beyond what's passed in.
|
|
62
|
+
export function buildCard(filePath, raw, config) {
|
|
63
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
64
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
65
|
+
const sections = walkSections(body);
|
|
66
|
+
|
|
67
|
+
// Title + blurb
|
|
68
|
+
const titleSection = sections.find(s => s.level === 1) ?? null;
|
|
69
|
+
const title = titleSection ? titleSection.heading : path.basename(filePath, '.md');
|
|
70
|
+
// Blurb = first blockquote line(s) after the H1, or first paragraph
|
|
71
|
+
const lines = body.split('\n');
|
|
72
|
+
let blurb = '';
|
|
73
|
+
if (titleSection) {
|
|
74
|
+
for (let i = titleSection.bodyLineStart - 1; i < lines.length; i++) {
|
|
75
|
+
const ln = lines[i];
|
|
76
|
+
if (!ln.trim()) {
|
|
77
|
+
if (blurb) break;
|
|
78
|
+
else continue;
|
|
79
|
+
}
|
|
80
|
+
if (ln.startsWith('## ') || ln.startsWith('# ')) break;
|
|
81
|
+
blurb += (blurb ? '\n' : '') + ln;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
blurb = truncate(blurb.replace(/^> ?/gm, '').trim(), CAPS.blurb);
|
|
85
|
+
|
|
86
|
+
// Frontmatter pointers. The simple parser doesn't handle YAML multiline blocks
|
|
87
|
+
// (`>`, `|`); when only the marker char survives, treat as empty.
|
|
88
|
+
const cleanInline = (v) => {
|
|
89
|
+
const s = asString(v);
|
|
90
|
+
if (!s) return '';
|
|
91
|
+
if (/^[>|][+-]?$/.test(s.trim())) return '';
|
|
92
|
+
return s;
|
|
93
|
+
};
|
|
94
|
+
const status = asString(fm.status) ?? null;
|
|
95
|
+
const updated = asString(fm.updated) ?? null;
|
|
96
|
+
const currentState = truncate(cleanInline(fm.current_state), CAPS.currentState);
|
|
97
|
+
const nextStep = truncate(cleanInline(fm.next_step), CAPS.nextStep);
|
|
98
|
+
|
|
99
|
+
// Related plans (compressed: slug + status only — show all, don't cap count)
|
|
100
|
+
const related = [
|
|
101
|
+
...readRelatedSummary(fm.parent_plan, config).map(r => ({ ...r, kind: 'parent' })),
|
|
102
|
+
...readRelatedSummary(fm.related_plans, config).map(r => ({ ...r, kind: 'related' })),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
// Phases summary + active phase (pointer only, no body)
|
|
106
|
+
const phaseSummary = summarizePhases(sections);
|
|
107
|
+
const activePhase = findActivePhase(sections);
|
|
108
|
+
let activePhasePointer = null;
|
|
109
|
+
if (activePhase) {
|
|
110
|
+
activePhasePointer = {
|
|
111
|
+
heading: activePhase.heading,
|
|
112
|
+
lineStart: activePhase.lineStart,
|
|
113
|
+
lineEnd: activePhase.lineEnd,
|
|
114
|
+
marker: detectMarker(activePhase.heading),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Old-plan fallback: no ## Phases section → point to last H2 as "active content"
|
|
119
|
+
let fallbackPointer = null;
|
|
120
|
+
if (!findSection(sections, 'Phases') && !activePhase) {
|
|
121
|
+
const h2s = sections.filter(s => s.level === 2);
|
|
122
|
+
const lastH2 = h2s[h2s.length - 1] ?? null;
|
|
123
|
+
if (lastH2) {
|
|
124
|
+
fallbackPointer = {
|
|
125
|
+
heading: lastH2.heading,
|
|
126
|
+
lineStart: lastH2.lineStart,
|
|
127
|
+
lineEnd: lastH2.lineEnd,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Open Questions — count + pointer only, no body
|
|
133
|
+
const oqSection = findSection(sections, 'Open Questions') ?? findSection(sections, 'Open questions');
|
|
134
|
+
let openQuestions = null;
|
|
135
|
+
if (oqSection && oqSection.body.trim()) {
|
|
136
|
+
openQuestions = {
|
|
137
|
+
heading: oqSection.heading,
|
|
138
|
+
lineStart: oqSection.lineStart,
|
|
139
|
+
lineEnd: oqSection.lineEnd,
|
|
140
|
+
count: countBullets(oqSection.body),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Last Version History entry (newest = first bullet under the heading)
|
|
145
|
+
const vhSection = findSection(sections, 'Version History');
|
|
146
|
+
let lastVersion = null;
|
|
147
|
+
if (vhSection && vhSection.body.trim()) {
|
|
148
|
+
const firstBullet = vhSection.body.split('\n').find(l => /^[-*]\s/.test(l));
|
|
149
|
+
if (firstBullet) lastVersion = truncate(firstBullet.replace(/^[-*]\s+/, ''), CAPS.versionHistoryEntry);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Outline = all H2 headings with line ranges; phase summary inline if Phases present
|
|
153
|
+
const outline = sections.filter(s => s.level === 2).map(s => {
|
|
154
|
+
const isPhases = /^phases?$/i.test(s.heading.replace(/[^\w\s]+$/, '').trim());
|
|
155
|
+
let suffix = `lines ${s.lineStart}-${s.lineEnd}`;
|
|
156
|
+
if (isPhases && phaseSummary.total > 0) {
|
|
157
|
+
suffix = `(${phaseSummary.total}: ${statusSummary(phaseSummary.counts)}) ${suffix}`;
|
|
158
|
+
}
|
|
159
|
+
return { heading: s.heading, lineStart: s.lineStart, lineEnd: s.lineEnd, suffix };
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
path: toRepoPath(filePath, config.repoRoot),
|
|
164
|
+
title,
|
|
165
|
+
status,
|
|
166
|
+
updated,
|
|
167
|
+
blurb,
|
|
168
|
+
currentState,
|
|
169
|
+
nextStep,
|
|
170
|
+
related,
|
|
171
|
+
phases: phaseSummary,
|
|
172
|
+
activePhase: activePhasePointer,
|
|
173
|
+
fallbackContent: fallbackPointer,
|
|
174
|
+
openQuestions,
|
|
175
|
+
lastVersion,
|
|
176
|
+
outline,
|
|
177
|
+
bodyBytes: body.length,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Render the card to human-format string.
|
|
182
|
+
export function renderCard(card) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
lines.push(`[dotmd] holding ${card.path} — release with: dotmd release ${card.path}`);
|
|
185
|
+
lines.push('---');
|
|
186
|
+
lines.push(`# ${card.title}`);
|
|
187
|
+
const meta = [card.status, card.updated && `updated ${card.updated}`].filter(Boolean).join(' · ');
|
|
188
|
+
if (meta) lines.push(dim(meta));
|
|
189
|
+
if (card.blurb) {
|
|
190
|
+
lines.push('');
|
|
191
|
+
lines.push(`> ${card.blurb.replace(/\n/g, '\n> ')}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (card.currentState || card.nextStep) {
|
|
195
|
+
lines.push('');
|
|
196
|
+
if (card.currentState) lines.push(`${green('Current:')} ${card.currentState}`);
|
|
197
|
+
if (card.nextStep) lines.push(`${green('Next:')} ${card.nextStep}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (card.related.length > 0) {
|
|
201
|
+
lines.push('');
|
|
202
|
+
lines.push(green('Related:'));
|
|
203
|
+
for (const r of card.related) {
|
|
204
|
+
const tag = r.kind === 'parent' ? '↑ parent' : '↔';
|
|
205
|
+
if (r.missing) {
|
|
206
|
+
lines.push(` ${tag} ${r.ref} ${dim('(missing)')}`);
|
|
207
|
+
} else {
|
|
208
|
+
lines.push(` ${tag} ${r.ref}${r.status ? ` ${dim(`(${r.status})`)}` : ''}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (card.activePhase) {
|
|
214
|
+
lines.push('');
|
|
215
|
+
lines.push(`${green('Active phase:')} ${card.activePhase.heading} ${dim(`(lines ${card.activePhase.lineStart}-${card.activePhase.lineEnd})`)}`);
|
|
216
|
+
} else if (card.fallbackContent) {
|
|
217
|
+
lines.push('');
|
|
218
|
+
lines.push(`${green('Active section:')} ${card.fallbackContent.heading} ${dim(`(lines ${card.fallbackContent.lineStart}-${card.fallbackContent.lineEnd})`)}`);
|
|
219
|
+
} else if (card.phases.total > 0) {
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push(dim(`All ${card.phases.total} phases shipped/skipped — ready for archive?`));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (card.openQuestions) {
|
|
225
|
+
lines.push(`${green('Open Questions:')} ${card.openQuestions.count} ${dim(`(lines ${card.openQuestions.lineStart}-${card.openQuestions.lineEnd})`)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (card.lastVersion) {
|
|
229
|
+
lines.push('');
|
|
230
|
+
lines.push(`${green('Last change:')} ${card.lastVersion}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (card.outline.length > 0) {
|
|
234
|
+
lines.push('');
|
|
235
|
+
lines.push(green('Outline:'));
|
|
236
|
+
for (const o of card.outline) {
|
|
237
|
+
lines.push(` ## ${o.heading} ${dim(o.suffix)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
lines.push('');
|
|
242
|
+
lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`dotmd pickup ${card.path} --full\` for everything, or Read the file with offset/limit to target a section by line number.`));
|
|
243
|
+
return lines.join('\n') + '\n';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatBytes(n) {
|
|
247
|
+
if (n < 1024) return `${n}B`;
|
|
248
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
249
|
+
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
250
|
+
}
|
package/src/section.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Pure markdown section walker. Regex-walks H1-H6 headings respecting fenced
|
|
2
|
+
// code blocks (``` and ~~~). Returns flat list of sections with body content
|
|
3
|
+
// and absolute line numbers (1-indexed, matches Read tool's `offset`).
|
|
4
|
+
|
|
5
|
+
export function walkSections(body) {
|
|
6
|
+
const lines = body.split('\n');
|
|
7
|
+
const fenceRe = /^(`{3,}|~{3,})/;
|
|
8
|
+
const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
|
|
9
|
+
const sections = [];
|
|
10
|
+
let fenceChar = null;
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
const line = lines[i];
|
|
14
|
+
const fence = line.match(fenceRe);
|
|
15
|
+
if (fence) {
|
|
16
|
+
const tok = fence[1][0]; // ` or ~
|
|
17
|
+
if (fenceChar === null) fenceChar = tok;
|
|
18
|
+
else if (fenceChar === tok) fenceChar = null;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (fenceChar !== null) continue;
|
|
22
|
+
const h = line.match(headingRe);
|
|
23
|
+
if (!h) continue;
|
|
24
|
+
sections.push({
|
|
25
|
+
level: h[1].length,
|
|
26
|
+
heading: h[2],
|
|
27
|
+
lineStart: i + 1, // 1-indexed
|
|
28
|
+
lineEnd: lines.length, // patched below
|
|
29
|
+
bodyLineStart: i + 2,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < sections.length; i++) {
|
|
34
|
+
const next = sections.find((s, j) => j > i && s.level <= sections[i].level);
|
|
35
|
+
sections[i].lineEnd = next ? next.lineStart - 1 : lines.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const s of sections) {
|
|
39
|
+
s.body = lines.slice(s.bodyLineStart - 1, s.lineEnd).join('\n').trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return sections;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find a section by heading text, case-insensitive, trims trailing markers.
|
|
46
|
+
// Returns the matching section or null.
|
|
47
|
+
export function findSection(sections, name) {
|
|
48
|
+
const norm = (s) => s.toLowerCase().replace(/[^\w\s]+$/, '').trim();
|
|
49
|
+
const target = norm(name);
|
|
50
|
+
return sections.find(s => norm(s.heading) === target) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Status marker detection for phase headings. Returns one of:
|
|
54
|
+
// 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
|
|
55
|
+
const MARKER_PATTERNS = [
|
|
56
|
+
{ kind: 'shipped', re: /(✅|☑|✔|\bshipped\b|\bdone\b|\bcomplete\b)/i },
|
|
57
|
+
{ kind: 'skipped', re: /(⏭|\bskip(?:ped)?\b)/i },
|
|
58
|
+
{ kind: 'in-progress', re: /(🟡|🔄|\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
|
|
59
|
+
{ kind: 'blocked', re: /(🚧|🔴|\bblocked\b)/i },
|
|
60
|
+
{ kind: 'todo', re: /(⬜|⬛|◻|☐|\btodo\b|\bnot[-_ ]?started\b)/i },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export function detectMarker(heading) {
|
|
64
|
+
for (const { kind, re } of MARKER_PATTERNS) {
|
|
65
|
+
if (re.test(heading)) return kind;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isPhaseHeading(section) {
|
|
71
|
+
return section.level === 3 && /^phase\b/i.test(section.heading);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Summarize a phase set: { 'shipped': 2, 'in-progress': 1, 'todo': 2 }
|
|
75
|
+
export function summarizePhases(sections) {
|
|
76
|
+
const phases = sections.filter(isPhaseHeading);
|
|
77
|
+
const counts = {};
|
|
78
|
+
for (const p of phases) {
|
|
79
|
+
const k = detectMarker(p.heading) ?? 'todo';
|
|
80
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
81
|
+
}
|
|
82
|
+
return { total: phases.length, counts, phases };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Active phase = first phase whose marker is NOT shipped/skipped.
|
|
86
|
+
// Priority within active: in-progress > blocked > todo.
|
|
87
|
+
export function findActivePhase(sections) {
|
|
88
|
+
const phases = sections.filter(isPhaseHeading);
|
|
89
|
+
const active = phases.filter(p => {
|
|
90
|
+
const m = detectMarker(p.heading);
|
|
91
|
+
return m !== 'shipped' && m !== 'skipped';
|
|
92
|
+
});
|
|
93
|
+
if (active.length === 0) return null;
|
|
94
|
+
const rank = (m) => ({ 'in-progress': 0, 'blocked': 1, 'todo': 2, [null]: 3 })[m] ?? 3;
|
|
95
|
+
return active.sort((a, b) => rank(detectMarker(a.heading)) - rank(detectMarker(b.heading)))[0];
|
|
96
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -55,6 +55,10 @@ export function toRepoPath(absolutePath, repoRoot) {
|
|
|
55
55
|
return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export function nowIso() {
|
|
59
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
60
|
+
}
|
|
61
|
+
|
|
58
62
|
export function warn(message) {
|
|
59
63
|
process.stderr.write(`${dim(message)}\n`);
|
|
60
64
|
}
|