dotmd-cli 0.82.0 → 0.84.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 +34 -0
- package/bin/dotmd.mjs +26 -5
- package/package.json +1 -1
- package/src/config.mjs +10 -0
- package/src/decision.mjs +220 -0
- package/src/extractors.mjs +2 -1
- package/src/index.mjs +42 -13
- package/src/markdown-code-spans.mjs +21 -10
- package/src/new.mjs +49 -0
- package/src/parse-cache.mjs +99 -0
- package/src/pickup.mjs +42 -1
package/README.md
CHANGED
|
@@ -213,6 +213,9 @@ runlist new plan platform-work --coordination
|
|
|
213
213
|
runlist runlists
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
`runlist new hub platform-work` makes the same coordination hub; add
|
|
217
|
+
`--runlist a,b,c` or `--roadmap` for the other two shapes.
|
|
218
|
+
|
|
216
219
|
For progress across several runlists, create a roadmap:
|
|
217
220
|
|
|
218
221
|
```bash
|
|
@@ -225,6 +228,37 @@ Roadmaps roll up progress recursively and choose the first startable plan across
|
|
|
225
228
|
their child runlists. Runlists and roadmaps are held out of actionable plan
|
|
226
229
|
counts so dashboards do not double-count their children.
|
|
227
230
|
|
|
231
|
+
## Decisions
|
|
232
|
+
|
|
233
|
+
A decision is an entry in its plan, not a document of its own. Write the record
|
|
234
|
+
(the situation, what exists today, what each answer leaves in place) to a file
|
|
235
|
+
and add it:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
runlist new decision auth-revamp --question "Which token store?" @record.md
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
It takes the next id (`D1`, `D2`, …) that no decision item in that plan uses,
|
|
242
|
+
lands at the end of the plan's top-level decisions section (created before
|
|
243
|
+
`## Version History` when there is none) with a `Disposition: OPEN.` line, and
|
|
244
|
+
refuses an empty record. `--disposition held` parks it instead.
|
|
245
|
+
|
|
246
|
+
A corpus that indexes its decisions in one register numbers them in one
|
|
247
|
+
sequence. Name the register, and the id follows the highest the register or the
|
|
248
|
+
plan uses, and the register gets the entry's row in the same locked write:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
export const decisions = {
|
|
252
|
+
section: 'Decisions',
|
|
253
|
+
prefix: 'D',
|
|
254
|
+
register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The register block is the fenced block whose first line carries `statusLine`.
|
|
259
|
+
The row is the question plus `--answers` (what each answer leaves in place),
|
|
260
|
+
which is required when a register is configured.
|
|
261
|
+
|
|
228
262
|
## Safety Model
|
|
229
263
|
|
|
230
264
|
- Mutation commands support `--dry-run` / `-n`.
|
package/bin/dotmd.mjs
CHANGED
|
@@ -1001,6 +1001,21 @@ Examples:
|
|
|
1001
1001
|
EOF
|
|
1002
1002
|
runlist new prompt cleanup-tomorrow "look at remaining lint warnings"
|
|
1003
1003
|
|
|
1004
|
+
Hubs and decisions:
|
|
1005
|
+
runlist new hub <slug> # coordination hub (a plan)
|
|
1006
|
+
runlist new hub <slug> --runlist a,b,c # sprint hub plus child plans
|
|
1007
|
+
runlist new hub <slug> --roadmap # roadmap hub
|
|
1008
|
+
runlist new decision <plan> --question "<question>" [--answers "<…>"] @record.md
|
|
1009
|
+
Adds the next numbered entry to the plan's top-level
|
|
1010
|
+
decisions section with a \`Disposition: OPEN.\` line (or
|
|
1011
|
+
\`--disposition held\`) and the record, which is required;
|
|
1012
|
+
a plan with no such section gets \`## Decisions\`. With a
|
|
1013
|
+
register in config, the id is numbered across the corpus
|
|
1014
|
+
and the register gets its row, question plus --answers
|
|
1015
|
+
(required then), in the same locked write. Config:
|
|
1016
|
+
\`export const decisions = { section, prefix,
|
|
1017
|
+
register: { file, statusLine } }\`.
|
|
1018
|
+
|
|
1004
1019
|
Scaffolding runlists (plans only):
|
|
1005
1020
|
--runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
|
|
1006
1021
|
The hub carries \`runlist: [<hub>-01-a.md, <hub>-02-b.md, …]\`
|
|
@@ -1752,12 +1767,18 @@ async function main() {
|
|
|
1752
1767
|
process.stderr.write(`Repo root: ${config.repoRoot}\n`);
|
|
1753
1768
|
}
|
|
1754
1769
|
|
|
1770
|
+
// A printed list shows status, title, age and next step, none of which the
|
|
1771
|
+
// validating passes produce, so it reads the index the way `prompts` and
|
|
1772
|
+
// `hud` do. `--json` emits each document's warnings and errors, so it still
|
|
1773
|
+
// pays for the full pass.
|
|
1774
|
+
const listIndexOptions = listArgs => (listArgs.includes('--json') ? {} : { fast: true });
|
|
1775
|
+
|
|
1755
1776
|
// Preset aliases (user config can override built-in commands below)
|
|
1756
1777
|
if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
|
|
1757
1778
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1758
1779
|
const { runQuery } = await import('../src/query.mjs');
|
|
1759
1780
|
const { statusMetadataFor } = await import('../src/status-metadata.mjs');
|
|
1760
|
-
const index = buildIndex(config);
|
|
1781
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1761
1782
|
applyIndexFilters(index);
|
|
1762
1783
|
const docs = index.docs.filter(doc => {
|
|
1763
1784
|
const metadata = statusMetadataFor(config, doc.type, doc.status);
|
|
@@ -1774,7 +1795,7 @@ async function main() {
|
|
|
1774
1795
|
if (config.presets[command]) {
|
|
1775
1796
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1776
1797
|
const { runQuery } = await import('../src/query.mjs');
|
|
1777
|
-
const index = buildIndex(config);
|
|
1798
|
+
const index = buildIndex(config, listIndexOptions([...config.presets[command], ...restArgs]));
|
|
1778
1799
|
applyIndexFilters(index);
|
|
1779
1800
|
runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
|
|
1780
1801
|
return;
|
|
@@ -1787,7 +1808,7 @@ async function main() {
|
|
|
1787
1808
|
if (command === 'plans') {
|
|
1788
1809
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1789
1810
|
const { runQuery } = await import('../src/query.mjs');
|
|
1790
|
-
const index = buildIndex(config);
|
|
1811
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1791
1812
|
applyIndexFilters(index);
|
|
1792
1813
|
const sub = restArgs[0];
|
|
1793
1814
|
let defaults;
|
|
@@ -1807,7 +1828,7 @@ async function main() {
|
|
|
1807
1828
|
if (command === 'runlists') {
|
|
1808
1829
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1809
1830
|
const { runRunlists } = await import('../src/query.mjs');
|
|
1810
|
-
const index = buildIndex(config);
|
|
1831
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1811
1832
|
applyIndexFilters(index);
|
|
1812
1833
|
runRunlists(index, restArgs, config);
|
|
1813
1834
|
return;
|
|
@@ -1818,7 +1839,7 @@ async function main() {
|
|
|
1818
1839
|
if (command === 'roadmaps') {
|
|
1819
1840
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1820
1841
|
const { runRoadmaps } = await import('../src/roadmap.mjs');
|
|
1821
|
-
const index = buildIndex(config);
|
|
1842
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1822
1843
|
applyIndexFilters(index);
|
|
1823
1844
|
runRoadmaps(index, restArgs, config);
|
|
1824
1845
|
return;
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -137,6 +137,8 @@ const DEFAULTS = {
|
|
|
137
137
|
templates: {},
|
|
138
138
|
|
|
139
139
|
glossary: null,
|
|
140
|
+
// `runlist new decision`: { section, prefix, register: { file, statusLine } }.
|
|
141
|
+
decisions: null,
|
|
140
142
|
|
|
141
143
|
// Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
|
|
142
144
|
// and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
|
|
@@ -417,6 +419,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
|
|
|
417
419
|
}
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
if (userConfig.decisions != null) {
|
|
423
|
+
const d = userConfig.decisions;
|
|
424
|
+
if (typeof d !== 'object' || Array.isArray(d)) warnings.push('Config: decisions must be an object.');
|
|
425
|
+
else if (d.register != null && (typeof d.register?.file !== 'string' || typeof d.register?.statusLine !== 'string')) {
|
|
426
|
+
warnings.push('Config: decisions.register needs a file and a statusLine.');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
420
430
|
// Unknown top-level user config keys
|
|
421
431
|
for (const key of Object.keys(userConfig)) {
|
|
422
432
|
if (!VALID_CONFIG_KEYS.has(key)) {
|
package/src/decision.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
4
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
5
|
+
import { walkSections } from './section.mjs';
|
|
6
|
+
import { die, nowIso, resolveDocPath, toRepoPath } from './util.mjs';
|
|
7
|
+
import { green, dim } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
// `runlist new decision <plan> --question "…" @record.md`
|
|
10
|
+
//
|
|
11
|
+
// A decision is not a document of its own: it is an entry in the owning plan's
|
|
12
|
+
// decisions section, carrying an id, a disposition and a written record. This
|
|
13
|
+
// adds one, numbered after the highest id of its prefix the plan (and the
|
|
14
|
+
// register, when there is one) already uses, at the end of the plan's top-level
|
|
15
|
+
// decisions section, creating `## Decisions` when the plan has none. A
|
|
16
|
+
// decisions heading nested inside a workstream is that workstream's record and
|
|
17
|
+
// is left alone.
|
|
18
|
+
//
|
|
19
|
+
// Config (`runlist.config.mjs`), all optional:
|
|
20
|
+
// export const decisions = {
|
|
21
|
+
// section: 'Decisions', // the heading the entry lands under
|
|
22
|
+
// prefix: 'D', // what a new id is written under
|
|
23
|
+
// register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
|
|
24
|
+
// };
|
|
25
|
+
// With a register, ids are one sequence across the corpus: the next id is
|
|
26
|
+
// numbered after the highest the register or the plan uses, and the register
|
|
27
|
+
// gets the entry's index row in the same locked write as the plan. Without
|
|
28
|
+
// one, ids are plan-local.
|
|
29
|
+
|
|
30
|
+
const DISPOSITIONS = new Set(['open', 'held']);
|
|
31
|
+
const DECISION_HEADING = /\bdecisions?\b/i;
|
|
32
|
+
|
|
33
|
+
function splitDoc(raw) {
|
|
34
|
+
if (!raw.startsWith('---\n')) return null;
|
|
35
|
+
const end = raw.indexOf('\n---\n', 4);
|
|
36
|
+
if (end === -1) return null;
|
|
37
|
+
return { frontmatter: raw.slice(4, end), body: raw.slice(end + 5) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripFences(text) {
|
|
41
|
+
return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const escapeRe = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
45
|
+
|
|
46
|
+
// Ids are read where a decision item starts (a heading, a list or bold lead,
|
|
47
|
+
// a table row or a register row), never from prose, where the same shape is
|
|
48
|
+
// as likely to be a job code or a citation of another plan's decision.
|
|
49
|
+
function highestId(text, prefix) {
|
|
50
|
+
const re = new RegExp(`^\\s*(?:#{1,6}\\s+|[-*]\\s+(?:\\[[ xX]\\]\\s+)?|\\|\\s*)?\\**${escapeRe(prefix)}(\\d{1,4})\\b`);
|
|
51
|
+
let max = 0;
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
const m = line.match(re);
|
|
54
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
55
|
+
}
|
|
56
|
+
return max;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nextDecisionId(body, prefix, register = null) {
|
|
60
|
+
let max = highestId(stripFences(body), prefix);
|
|
61
|
+
if (register) {
|
|
62
|
+
const rows = register.rows.map(row => row.match(new RegExp(`^${escapeRe(prefix)}(\\d{1,4})\\b`))?.[1]);
|
|
63
|
+
for (const n of rows) if (n) max = Math.max(max, Number(n));
|
|
64
|
+
}
|
|
65
|
+
return `${prefix}${max + 1}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The register block: a fence whose first line carries the configured status
|
|
69
|
+
// line. Returns its rows and where the closing fence sits, or null.
|
|
70
|
+
export function findRegister(text, statusLine) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
const wanted = statusLine.trim().toLowerCase();
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const open = lines[i].match(/^(`{3,}|~{3,})/);
|
|
75
|
+
if (!open) continue;
|
|
76
|
+
let close = i + 1;
|
|
77
|
+
while (close < lines.length && !lines[close].startsWith(open[1])) close++;
|
|
78
|
+
if (close >= lines.length) return null;
|
|
79
|
+
const first = (lines[i + 1] ?? '').trim().toLowerCase();
|
|
80
|
+
if (first.includes(wanted)) {
|
|
81
|
+
return { closeLine: close, rows: lines.slice(i + 2, close).filter(line => line.trim()) };
|
|
82
|
+
}
|
|
83
|
+
i = close;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function insertRegisterRow(text, register, row) {
|
|
89
|
+
const lines = text.split('\n');
|
|
90
|
+
let at = register.closeLine;
|
|
91
|
+
while (at > 0 && lines[at - 1].trim() === '') at--;
|
|
92
|
+
lines.splice(at, 0, row);
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Returns the new body and where the entry went. Pure, for tests.
|
|
97
|
+
export function insertDecision(body, { id, question, disposition, record, section: heading = 'Decisions' }) {
|
|
98
|
+
const lines = body.split('\n');
|
|
99
|
+
const sections = walkSections(body);
|
|
100
|
+
const named = s => s.heading.replace(/[^\w\s]+$/, '').trim().toLowerCase() === heading.toLowerCase();
|
|
101
|
+
const section = sections.find(s => s.level === 2 && named(s))
|
|
102
|
+
?? sections.find(s => s.level === 2 && DECISION_HEADING.test(s.heading));
|
|
103
|
+
const level = section ? Math.min(section.level + 1, 6) : 3;
|
|
104
|
+
const entry = [
|
|
105
|
+
`${'#'.repeat(level)} ${id} ${question.trim()}`,
|
|
106
|
+
'',
|
|
107
|
+
`Disposition: ${disposition.toUpperCase()}.`,
|
|
108
|
+
'',
|
|
109
|
+
record.trim(),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
if (section) {
|
|
113
|
+
// After the section's last non-blank line, subsections included.
|
|
114
|
+
let at = section.lineEnd;
|
|
115
|
+
while (at > section.lineStart && lines[at - 1].trim() === '') at--;
|
|
116
|
+
const tail = at < lines.length && lines[at].trim() !== '' ? [''] : [];
|
|
117
|
+
lines.splice(at, 0, '', ...entry, ...tail);
|
|
118
|
+
return { body: lines.join('\n'), placement: `under \`${section.heading}\``, heading: section.heading };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const block = [`## ${heading}`, '', ...entry];
|
|
122
|
+
const before = sections.find(s => s.level === 2 && /^(version history|closeout)\b/i.test(s.heading));
|
|
123
|
+
if (before) {
|
|
124
|
+
lines.splice(before.lineStart - 1, 0, ...block, '');
|
|
125
|
+
return { body: lines.join('\n'), placement: `in a new \`## ${heading}\` before \`${before.heading}\``, heading };
|
|
126
|
+
}
|
|
127
|
+
const trimmed = body.replace(/\n+$/, '');
|
|
128
|
+
return { body: `${trimmed}\n\n${block.join('\n')}\n`, placement: `in a new \`## ${heading}\` at the end`, heading };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function runNewDecision({ planArg, question, disposition, record, answers = null }, config, { dryRun = false } = {}) {
|
|
132
|
+
const usage = 'Usage: runlist new decision <plan> --question "<the question>" @record.md';
|
|
133
|
+
if (!planArg) die(`${usage}\nThe plan is the file whose decisions section gets the entry.`);
|
|
134
|
+
if (!question || !question.trim()) die(`--question is required.\n${usage}`);
|
|
135
|
+
if (/\n/.test(question)) die('--question is one line; the record carries the rest.');
|
|
136
|
+
if (!record || !record.trim()) {
|
|
137
|
+
die('A decision needs its record: the situation, what exists today, and what each answer leaves in place.\n'
|
|
138
|
+
+ `Write it to a file and pass @path, pipe it in, or pass --body "...".\n${usage}`);
|
|
139
|
+
}
|
|
140
|
+
disposition = (disposition ?? 'open').toLowerCase();
|
|
141
|
+
if (!DISPOSITIONS.has(disposition)) {
|
|
142
|
+
die(`--disposition must be open or held; a ruled or closed decision is edited in place, not added.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const resolved = resolveDocPath(planArg, config)
|
|
146
|
+
?? (planArg.endsWith('.md') ? null : resolveDocPath(`${planArg}.md`, config))
|
|
147
|
+
?? (planArg.includes('/') ? null : resolveDocPath(path.join('plans', `${planArg}.md`), config));
|
|
148
|
+
if (!resolved) die(`No such plan: ${planArg}`);
|
|
149
|
+
const planPath = authorizeManagedSource(resolved, config, { kind: 'Decision target' }).path;
|
|
150
|
+
const planRepoPath = toRepoPath(planPath, config.repoRoot);
|
|
151
|
+
|
|
152
|
+
const settings = config.raw?.decisions ?? {};
|
|
153
|
+
const prefix = settings.prefix ?? 'D';
|
|
154
|
+
const section = settings.section ?? 'Decisions';
|
|
155
|
+
let registerPath = null;
|
|
156
|
+
if (settings.register?.file) {
|
|
157
|
+
if (!settings.register.statusLine) die('decisions.register needs a statusLine: the first line of the register block.');
|
|
158
|
+
const registerResolved = resolveDocPath(settings.register.file, config);
|
|
159
|
+
if (!registerResolved) die(`The decisions register in config does not exist: ${settings.register.file}`);
|
|
160
|
+
registerPath = authorizeManagedSource(registerResolved, config, { kind: 'Decision register' }).path;
|
|
161
|
+
}
|
|
162
|
+
const sameFile = registerPath === planPath;
|
|
163
|
+
if (registerPath && (!answers || !answers.trim())) {
|
|
164
|
+
die('--answers is required with a register: the row says what each answer leaves in place and what it costs.\n'
|
|
165
|
+
+ `${usage} --answers "Yes: … No: …"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const today = nowIso();
|
|
169
|
+
const planRaw = readFileSync(planPath, 'utf8');
|
|
170
|
+
const registerRaw = registerPath && !sameFile ? readFileSync(registerPath, 'utf8') : null;
|
|
171
|
+
|
|
172
|
+
const planDoc = splitDoc(planRaw.replace(/\r\n/g, '\n'));
|
|
173
|
+
if (!planDoc) die(`${planRepoPath} has no frontmatter block; runlist only adds decisions to managed documents.`);
|
|
174
|
+
const registerText = sameFile ? planDoc.body : registerRaw?.replace(/\r\n/g, '\n');
|
|
175
|
+
const register = registerPath ? findRegister(registerText, settings.register.statusLine) : null;
|
|
176
|
+
if (registerPath && !register) {
|
|
177
|
+
die(`${toRepoPath(registerPath, config.repoRoot)} has no block whose first line carries "${settings.register.statusLine}".`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const id = nextDecisionId(planDoc.body, prefix, register);
|
|
181
|
+
const inserted = insertDecision(planDoc.body, { id, question, disposition, record, section });
|
|
182
|
+
let planBody = inserted.body;
|
|
183
|
+
let row = null;
|
|
184
|
+
if (register && sameFile) {
|
|
185
|
+
row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link: path.basename(planPath) });
|
|
186
|
+
planBody = insertRegisterRow(planBody, findRegister(planBody, settings.register.statusLine), row);
|
|
187
|
+
}
|
|
188
|
+
const bump = fm => (/^updated:/m.test(fm) ? fm.replace(/^updated:.*$/m, `updated: ${today}`) : fm);
|
|
189
|
+
const planOut = `---\n${bump(planDoc.frontmatter)}\n---\n${planBody}`;
|
|
190
|
+
|
|
191
|
+
const updates = [{ path: planPath, expectedContent: planRaw, content: planOut }];
|
|
192
|
+
if (register && !sameFile) {
|
|
193
|
+
const link = path.relative(path.dirname(registerPath), planPath).split(path.sep).join('/');
|
|
194
|
+
row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link });
|
|
195
|
+
const registerOut = insertRegisterRow(registerText, register, row);
|
|
196
|
+
const registerDoc = splitDoc(registerOut);
|
|
197
|
+
updates.push({
|
|
198
|
+
path: registerPath,
|
|
199
|
+
expectedContent: registerRaw,
|
|
200
|
+
content: registerDoc ? `---\n${bump(registerDoc.frontmatter)}\n---\n${registerDoc.body}` : registerOut,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const registerRepoPath = registerPath ? toRepoPath(registerPath, config.repoRoot) : null;
|
|
205
|
+
if (dryRun) {
|
|
206
|
+
process.stdout.write(`[dry-run] Would add ${id} to ${planRepoPath} ${inserted.placement}\n`);
|
|
207
|
+
if (row) process.stdout.write(`[dry-run] Would add its row to the register in ${registerRepoPath}:\n ${row}\n`);
|
|
208
|
+
return { id, row };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
mutateFileSet({ updates }, { repoRoot: config.repoRoot });
|
|
212
|
+
process.stdout.write(`${green('Added')} ${id} to ${planRepoPath} ${dim(inserted.placement)}\n`);
|
|
213
|
+
if (row) process.stdout.write(`${green('Added')} its row to the register in ${registerRepoPath}\n`);
|
|
214
|
+
return { id, row };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function registerRow({ id, disposition, today, question, answers, heading, link }) {
|
|
218
|
+
const text = `${question.trim()} ${answers.trim()}`;
|
|
219
|
+
return `${id} ${disposition.toUpperCase()} ${today.slice(0, 10)}: ${text} Record: [${link} § ${heading} ${id}](${link}).`;
|
|
220
|
+
}
|
package/src/extractors.mjs
CHANGED
|
@@ -42,7 +42,8 @@ export function extractNextStep(body) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function extractBodyLinks(body) {
|
|
45
|
-
|
|
45
|
+
// Every inline link contains `](`, and masking never creates one.
|
|
46
|
+
if (!body || !body.includes('](')) return [];
|
|
46
47
|
// Strip fenced code blocks, then MASK inline code rather than delete it.
|
|
47
48
|
// Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
|
|
48
49
|
// has its link TEXT as a code span, so removing the span left `[](plan.md)`,
|
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
|
|
6
6
|
import { findLexicalDocsRoot } from './managed-path.mjs';
|
|
7
|
+
import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
|
|
7
8
|
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
8
9
|
import { checkIndex } from './index-file.mjs';
|
|
9
10
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
@@ -30,7 +31,9 @@ export function buildIndex(config, opts = {}) {
|
|
|
30
31
|
const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
|
|
31
32
|
const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
|
|
32
33
|
const skipWarningOnlyChecks = fast || errorsOnly;
|
|
33
|
-
const
|
|
34
|
+
const cache = openParseCache(config);
|
|
35
|
+
const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast, cache }));
|
|
36
|
+
if (cache && !config._execution?.suppressSideEffects) cache.save();
|
|
34
37
|
if (!fast) {
|
|
35
38
|
// Per-file validation (validateDoc) ran during parse without sibling
|
|
36
39
|
// visibility. Now that the full index is materialized, enrich
|
|
@@ -271,16 +274,45 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
|
|
|
271
274
|
}
|
|
272
275
|
}
|
|
273
276
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
278
|
-
const { frontmatter, body } = extractFrontmatter(raw);
|
|
277
|
+
// Everything parseDocFile takes from the file's text alone, with no config and
|
|
278
|
+
// no clock, which is what the parse cache may keep.
|
|
279
|
+
function extractDocText(frontmatter, body) {
|
|
279
280
|
const fmWarnings = [];
|
|
280
281
|
const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
|
|
281
|
-
|
|
282
|
+
return {
|
|
283
|
+
parsedFrontmatter,
|
|
284
|
+
fmWarnings: fmWarnings.map(w => ({ message: w.message })),
|
|
285
|
+
headingTitle: extractFirstHeading(body),
|
|
286
|
+
bodySummary: extractSummary(body),
|
|
287
|
+
bodyStatusSnapshot: extractStatusSnapshot(body),
|
|
288
|
+
bodyNextStep: extractNextStep(body),
|
|
289
|
+
checklist: extractChecklistCounts(body),
|
|
290
|
+
bodyLinks: extractBodyLinks(body),
|
|
291
|
+
hasCloseout: /^##\s+Closeout/m.test(body),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function parseDocFile(filePath, config, opts = {}) {
|
|
296
|
+
const { fast = false, cache = null } = opts;
|
|
297
|
+
const relativePath = toRepoPath(filePath, config.repoRoot);
|
|
298
|
+
const stamp = cache ? fileStamp(filePath) : null;
|
|
299
|
+
let text = stamp ? cache.get(relativePath, stamp) : null;
|
|
300
|
+
// Validation reads the body itself, so only a fast build can skip the read.
|
|
301
|
+
let body = null;
|
|
302
|
+
if (!text || !fast) {
|
|
303
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
304
|
+
const extracted = extractFrontmatter(raw);
|
|
305
|
+
body = extracted.body;
|
|
306
|
+
// A file rewritten between the stat and the read no longer matches its stamp.
|
|
307
|
+
if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
|
|
308
|
+
if (!text) {
|
|
309
|
+
text = extractDocText(extracted.frontmatter, body);
|
|
310
|
+
if (stamp) cache.set(relativePath, stamp, text);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
|
|
282
314
|
const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
|
|
283
|
-
const summary = asString(parsedFrontmatter.summary) ??
|
|
315
|
+
const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
|
|
284
316
|
// For terminal-status docs (archived / reference / deprecated by default),
|
|
285
317
|
// skip the body-scrape and the "No current_state set" fallback when the user
|
|
286
318
|
// didn't set `current_state:` in frontmatter explicitly. Body text on a
|
|
@@ -305,7 +337,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
305
337
|
} else if (isTerminalDoc) {
|
|
306
338
|
currentState = null;
|
|
307
339
|
} else {
|
|
308
|
-
const scraped =
|
|
340
|
+
const scraped = text.bodyStatusSnapshot;
|
|
309
341
|
if (scraped) {
|
|
310
342
|
currentState = scraped;
|
|
311
343
|
currentStateOrigin = 'body';
|
|
@@ -313,7 +345,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
313
345
|
currentState = 'No current_state set';
|
|
314
346
|
}
|
|
315
347
|
}
|
|
316
|
-
const nextStep = asString(parsedFrontmatter.next_step) ??
|
|
348
|
+
const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
|
|
317
349
|
// `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
|
|
318
350
|
// filing tickets naturally reach for the JIRA/Linear name. If both are set,
|
|
319
351
|
// they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
|
|
@@ -328,9 +360,6 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
328
360
|
const domain = asString(parsedFrontmatter.domain) ?? null;
|
|
329
361
|
const audience = asString(parsedFrontmatter.audience) ?? null;
|
|
330
362
|
const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
|
|
331
|
-
const checklist = extractChecklistCounts(body);
|
|
332
|
-
const bodyLinks = extractBodyLinks(body);
|
|
333
|
-
const hasCloseout = /^##\s+Closeout/m.test(body);
|
|
334
363
|
|
|
335
364
|
// Dynamic reference field extraction. A leading `>` on a value (e.g.
|
|
336
365
|
// `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
function
|
|
2
|
-
return
|
|
3
|
-
+ value.slice(start, end).replace(/[^\n]/g, 'x')
|
|
4
|
-
+ value.slice(end);
|
|
1
|
+
function mask(text) {
|
|
2
|
+
return text.includes('\n') ? text.replace(/[^\n]/g, 'x') : 'x'.repeat(text.length);
|
|
5
3
|
}
|
|
6
4
|
|
|
7
5
|
// Markdown code spans close only on a backtick run of the same length as their
|
|
@@ -11,13 +9,19 @@ function maskRange(value, start, end) {
|
|
|
11
9
|
// rewriter survive: after an unmatched opener, nothing is treated as prose
|
|
12
10
|
// until a compatible closer appears.
|
|
13
11
|
export function maskInlineCodeLine(line, state = { run: null }) {
|
|
14
|
-
|
|
12
|
+
if (!line.includes('`')) return state.run === null ? line : mask(line);
|
|
13
|
+
|
|
15
14
|
const runs = [...line.matchAll(/`+/g)];
|
|
15
|
+
const findCloser = (from, length) => {
|
|
16
|
+
for (let i = from; i < runs.length; i++) if (runs[i][0].length === length) return i;
|
|
17
|
+
return -1;
|
|
18
|
+
};
|
|
19
|
+
const ranges = [];
|
|
16
20
|
let index = 0;
|
|
17
21
|
|
|
18
22
|
if (state.run !== null) {
|
|
19
|
-
const closingIndex =
|
|
20
|
-
if (closingIndex === -1) return
|
|
23
|
+
const closingIndex = findCloser(0, state.run);
|
|
24
|
+
if (closingIndex === -1) return mask(line);
|
|
21
25
|
const closing = runs[closingIndex];
|
|
22
26
|
ranges.push([0, closing.index + closing[0].length]);
|
|
23
27
|
index = closingIndex + 1;
|
|
@@ -26,8 +30,7 @@ export function maskInlineCodeLine(line, state = { run: null }) {
|
|
|
26
30
|
|
|
27
31
|
for (; index < runs.length; index++) {
|
|
28
32
|
const opening = runs[index];
|
|
29
|
-
const closingIndex =
|
|
30
|
-
candidateIndex > index && candidate[0].length === opening[0].length);
|
|
33
|
+
const closingIndex = findCloser(index + 1, opening[0].length);
|
|
31
34
|
if (closingIndex === -1) {
|
|
32
35
|
ranges.push([opening.index, line.length]);
|
|
33
36
|
state.run = opening[0].length;
|
|
@@ -38,7 +41,15 @@ export function maskInlineCodeLine(line, state = { run: null }) {
|
|
|
38
41
|
index = closingIndex;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
// Ranges are ascending and disjoint, so one left-to-right pass builds the
|
|
45
|
+
// result instead of re-copying the whole line once per span.
|
|
46
|
+
let out = '';
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
for (const [start, end] of ranges) {
|
|
49
|
+
out += line.slice(cursor, start) + mask(line.slice(start, end));
|
|
50
|
+
cursor = end;
|
|
51
|
+
}
|
|
52
|
+
return out + line.slice(cursor);
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export function maskInlineCodeSpans(value) {
|
package/src/new.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runNewDecision } from './decision.mjs';
|
|
5
6
|
import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
6
7
|
import { green, dim, bold } from './color.mjs';
|
|
7
8
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
@@ -652,9 +653,14 @@ next_step:
|
|
|
652
653
|
export async function runNew(argv, config, opts = {}) {
|
|
653
654
|
const { dryRun } = opts;
|
|
654
655
|
|
|
656
|
+
if (argv.find(a => !a.startsWith('-')) === 'decision') return runNewDecisionArgs(argv, config, opts);
|
|
657
|
+
|
|
655
658
|
const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
|
|
656
659
|
// Also include any custom templates from config
|
|
657
660
|
for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
|
|
661
|
+
// `hub` is a plan with a hub body; `decision` is handled above.
|
|
662
|
+
knownTypes.add('hub');
|
|
663
|
+
knownTypes.add('decision');
|
|
658
664
|
|
|
659
665
|
const hasNameForBody = args => {
|
|
660
666
|
if (args.length >= 2 && knownTypes.has(args[0])) return true;
|
|
@@ -713,11 +719,27 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
713
719
|
name = positional[1];
|
|
714
720
|
if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
|
|
715
721
|
} else {
|
|
722
|
+
// `new <name> <inline body>` is a doc with the type left out, but a single
|
|
723
|
+
// bare word in the body slot is almost always a mistyped type and a slug
|
|
724
|
+
// (`new decison foo`), which used to create `<type-name>.md` with the slug
|
|
725
|
+
// as its whole body.
|
|
726
|
+
const [first, second] = positional;
|
|
727
|
+
if (positional.length === 2 && !/[/\\]|\.md$/.test(first) && /^[A-Za-z][\w.-]*$/.test(second)) {
|
|
728
|
+
die(`Unknown type \`${first}\`. Types: ${[...knownTypes].join(', ')}.\n`
|
|
729
|
+
+ `For a doc named ${first} with "${second}" as its body, run: runlist new doc ${first} "${second}"`);
|
|
730
|
+
}
|
|
716
731
|
typeName = 'doc';
|
|
717
732
|
name = positional[0];
|
|
718
733
|
if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
|
|
719
734
|
}
|
|
720
735
|
|
|
736
|
+
// A hub is a plan with a hub body. Coordination is the default shape, being
|
|
737
|
+
// the one a hub without children or tiers has.
|
|
738
|
+
if (typeName === 'hub') {
|
|
739
|
+
typeName = 'plan';
|
|
740
|
+
if (runlistArg === null && !roadmap) coordination = true;
|
|
741
|
+
}
|
|
742
|
+
|
|
721
743
|
if (!name) {
|
|
722
744
|
if (isInteractive()) {
|
|
723
745
|
name = await promptText(`${typeName} name: `);
|
|
@@ -1145,6 +1167,8 @@ export function newHelpForRepo(config) {
|
|
|
1145
1167
|
if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
|
|
1146
1168
|
}
|
|
1147
1169
|
const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
|
|
1170
|
+
rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
|
|
1171
|
+
rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
|
|
1148
1172
|
return `This repo:
|
|
1149
1173
|
${rows.join('\n')}
|
|
1150
1174
|
roots (for --root): ${roots.join(', ')}`;
|
|
@@ -1206,4 +1230,29 @@ function listTemplates(config) {
|
|
|
1206
1230
|
if (desc) process.stdout.write(` ${dim(desc)}\n`);
|
|
1207
1231
|
process.stdout.write('\n');
|
|
1208
1232
|
}
|
|
1233
|
+
process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
|
|
1234
|
+
process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function runNewDecisionArgs(argv, config, opts) {
|
|
1238
|
+
const positional = [];
|
|
1239
|
+
let question = null;
|
|
1240
|
+
let disposition = null;
|
|
1241
|
+
let answers = null;
|
|
1242
|
+
let bodyFlag = null;
|
|
1243
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1244
|
+
const a = argv[i];
|
|
1245
|
+
if (a === '--question' && argv[i + 1] !== undefined) { question = argv[++i]; continue; }
|
|
1246
|
+
if (a === '--disposition' && argv[i + 1] !== undefined) { disposition = argv[++i]; continue; }
|
|
1247
|
+
if (a === '--answers' && argv[i + 1] !== undefined) { answers = argv[++i]; continue; }
|
|
1248
|
+
if ((a === '--body' || a === '--message') && argv[i + 1] !== undefined) { bodyFlag = argv[++i]; continue; }
|
|
1249
|
+
if (a === '--config' || a === '--root') { i++; continue; }
|
|
1250
|
+
if (!a.startsWith('-') || a === '-') positional.push(a);
|
|
1251
|
+
}
|
|
1252
|
+
const [, planArg, ...rest] = positional;
|
|
1253
|
+
let record = null;
|
|
1254
|
+
if (bodyFlag !== null) record = readBodyInput(bodyFlag);
|
|
1255
|
+
else if (rest.length) record = readBodyInput(rest.join(' '));
|
|
1256
|
+
else record = readPipedBodyInput();
|
|
1257
|
+
return runNewDecision({ planArg, question, disposition, record, answers }, config, { dryRun: opts.dryRun });
|
|
1209
1258
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { commitRename } from './durable-rename.mjs';
|
|
5
|
+
import { readEnv, stateDir } from './naming.mjs';
|
|
6
|
+
|
|
7
|
+
// What every build of the index used to redo from scratch: read each document
|
|
8
|
+
// and pull its frontmatter, links, checklist and summary out of the body. None
|
|
9
|
+
// of that depends on the config or the clock, so it is kept per file under the
|
|
10
|
+
// state directory and reused while the file's size, times and inode are
|
|
11
|
+
// unchanged. Everything that does depend on them (terminal statuses, staleness,
|
|
12
|
+
// reference fields, validation) is still computed on every run.
|
|
13
|
+
//
|
|
14
|
+
// One line per document, `path \t stamp \t json`, so a hit parses only its own
|
|
15
|
+
// line and hands back fresh objects: nothing a caller does to a document can
|
|
16
|
+
// leak into what is saved. The cache is an optimisation and never an input:
|
|
17
|
+
// a missing, unreadable or foreign-version file is treated as empty, a failed
|
|
18
|
+
// write is dropped, and `RUNLIST_NO_PARSE_CACHE=1` turns it off.
|
|
19
|
+
|
|
20
|
+
const SCHEMA = 1;
|
|
21
|
+
const FILE_NAME = 'parse-cache';
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const VERSION = (() => {
|
|
24
|
+
try { return JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
|
|
25
|
+
catch { return 'unknown'; }
|
|
26
|
+
})();
|
|
27
|
+
const HEADER = JSON.stringify({ schema: SCHEMA, version: VERSION });
|
|
28
|
+
|
|
29
|
+
export function fileStamp(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const s = statSync(filePath, { bigint: true });
|
|
32
|
+
return `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function stampSize(stamp) {
|
|
39
|
+
return Number(stamp.slice(0, stamp.indexOf(':')));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openParseCache(config) {
|
|
43
|
+
if (readEnv('NO_PARSE_CACHE') === '1') return null;
|
|
44
|
+
const dir = stateDir(config.repoRoot);
|
|
45
|
+
// The state directory is created, and ignored by git, by `init` and the
|
|
46
|
+
// commands that own it; a repo without one gets no cache rather than a new
|
|
47
|
+
// untracked folder from a read-only command.
|
|
48
|
+
if (!existsSync(dir)) return null;
|
|
49
|
+
const cachePath = path.join(dir, FILE_NAME);
|
|
50
|
+
const lines = new Map();
|
|
51
|
+
try {
|
|
52
|
+
const text = readFileSync(cachePath, 'utf8');
|
|
53
|
+
const rows = text.split('\n');
|
|
54
|
+
if (rows[0] === HEADER) {
|
|
55
|
+
for (let i = 1; i < rows.length; i++) {
|
|
56
|
+
const row = rows[i];
|
|
57
|
+
const a = row.indexOf('\t');
|
|
58
|
+
const b = row.indexOf('\t', a + 1);
|
|
59
|
+
if (a < 0 || b < 0) continue;
|
|
60
|
+
lines.set(row.slice(0, a), { stamp: row.slice(a + 1, b), row });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* no cache yet */ }
|
|
64
|
+
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
let dirty = false;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
get(key, stamp) {
|
|
70
|
+
seen.add(key);
|
|
71
|
+
const entry = lines.get(key);
|
|
72
|
+
if (!entry || entry.stamp !== stamp) return null;
|
|
73
|
+
try { return JSON.parse(entry.row.slice(entry.row.indexOf('\t', entry.row.indexOf('\t') + 1) + 1)); }
|
|
74
|
+
catch { return null; }
|
|
75
|
+
},
|
|
76
|
+
set(key, stamp, value) {
|
|
77
|
+
seen.add(key);
|
|
78
|
+
if (/[\t\n]/.test(key)) return;
|
|
79
|
+
lines.set(key, { stamp, row: `${key}\t${stamp}\t${JSON.stringify(value)}` });
|
|
80
|
+
dirty = true;
|
|
81
|
+
},
|
|
82
|
+
save({ complete = true } = {}) {
|
|
83
|
+
if (complete) {
|
|
84
|
+
for (const key of lines.keys()) {
|
|
85
|
+
if (!seen.has(key)) { lines.delete(key); dirty = true; }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!dirty) return;
|
|
89
|
+
const temp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(temp, [HEADER, ...[...lines.values()].map(entry => entry.row)].join('\n'), 'utf8');
|
|
92
|
+
commitRename(temp, cachePath);
|
|
93
|
+
dirty = false;
|
|
94
|
+
} catch {
|
|
95
|
+
try { unlinkSync(temp); } catch { /* already gone */ }
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/pickup.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
5
5
|
import os from 'node:os';
|
|
@@ -233,7 +233,48 @@ function validateBinding(record, identity, config) {
|
|
|
233
233
|
return record;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// Working out a plan's canonical identity lists every directory on its path,
|
|
237
|
+
// three times over, and listing plans asks it of every plan in the repo. A
|
|
238
|
+
// record can only belong to a plan whose file name it carries, so the names in
|
|
239
|
+
// the ownership folder rule most plans out first. The summary is rebuilt
|
|
240
|
+
// whenever the folder changes, and any record it cannot read turns the check
|
|
241
|
+
// off, so the full identity check still decides every case it could get wrong.
|
|
242
|
+
let ownershipNamesCache = null;
|
|
243
|
+
|
|
244
|
+
function ownershipNames(config) {
|
|
245
|
+
const dir = ownershipRoot(config);
|
|
246
|
+
let stamp;
|
|
247
|
+
try { stamp = `${dir}\0${statSync(dir, { bigint: true }).mtimeNs}`; }
|
|
248
|
+
catch { return new Set(); }
|
|
249
|
+
if (ownershipNamesCache?.stamp === stamp) return ownershipNamesCache.names;
|
|
250
|
+
let names = new Set();
|
|
251
|
+
try {
|
|
252
|
+
for (const entry of readdirSync(dir)) {
|
|
253
|
+
if (!entry.endsWith('.json')) continue;
|
|
254
|
+
const record = parseOwnership(readFileSync(path.join(dir, entry), 'utf8'), entry);
|
|
255
|
+
if (record.corrupt) { names = null; break; }
|
|
256
|
+
names.add(path.basename(record.canonicalPath).toLowerCase());
|
|
257
|
+
names.add(path.basename(record.plan).toLowerCase());
|
|
258
|
+
}
|
|
259
|
+
} catch { names = null; }
|
|
260
|
+
ownershipNamesCache = { stamp, names };
|
|
261
|
+
return names;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mayHaveOwnershipRecord(absolutePath, config) {
|
|
265
|
+
const names = ownershipNames(config);
|
|
266
|
+
if (names === null || names.has(path.basename(absolutePath).toLowerCase())) return true;
|
|
267
|
+
try {
|
|
268
|
+
if (lstatSync(absolutePath).isSymbolicLink()) return true;
|
|
269
|
+
// A record whose fields were rewritten still sits at its identity's file
|
|
270
|
+
// name, and has to be found to be reported corrupt.
|
|
271
|
+
const guess = createHash('sha256').update(realpathSync(absolutePath)).digest('hex');
|
|
272
|
+
return existsSync(path.join(ownershipRoot(config), `${guess}.json`));
|
|
273
|
+
} catch { return true; }
|
|
274
|
+
}
|
|
275
|
+
|
|
236
276
|
export function readPlanOwnership(repoPath, config) {
|
|
277
|
+
if (!mayHaveOwnershipRecord(path.resolve(config.repoRoot, repoPath), config)) return null;
|
|
237
278
|
let identity;
|
|
238
279
|
try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
|
|
239
280
|
catch { return null; }
|