@comento/pota-harness 0.0.0-stage → 1.0.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/.agents/workflows/pota-doctor.md +16 -0
- package/.agents/workflows/pota-install.md +15 -0
- package/.agents/workflows/pota-memory.md +17 -0
- package/.agents/workflows/pota-plan.md +22 -0
- package/.agents/workflows/pota-review.md +16 -0
- package/.agents/workflows/pota-toolcheck.md +9 -0
- package/.agents/workflows/pota-uninstall.md +9 -0
- package/.agents/workflows/pota-upgrade.md +15 -0
- package/.agents/workflows/pota-work.md +24 -0
- package/.claude/agents/explorer.md +7 -0
- package/.claude/agents/worker.md +7 -0
- package/.claude/commands/pota-doctor.md +5 -0
- package/.claude/commands/pota-install.md +9 -0
- package/.claude/commands/pota-memory.md +5 -0
- package/.claude/commands/pota-plan.md +5 -0
- package/.claude/commands/pota-review.md +5 -0
- package/.claude/commands/pota-toolcheck.md +7 -0
- package/.claude/commands/pota-uninstall.md +5 -0
- package/.claude/commands/pota-upgrade.md +9 -0
- package/.claude/commands/pota-work.md +7 -0
- package/.codex-plugin/plugin.json +33 -0
- package/LICENSE +21 -0
- package/README.de.md +166 -0
- package/README.es.md +166 -0
- package/README.fr.md +166 -0
- package/README.ja.md +166 -0
- package/README.ko.md +184 -0
- package/README.md +191 -2
- package/README.pt.md +166 -0
- package/README.zh.md +166 -0
- package/agents/explorer.md +36 -0
- package/agents/worker.md +45 -0
- package/commands/pota-doctor.md +28 -0
- package/commands/pota-install.md +29 -0
- package/commands/pota-memory.md +17 -0
- package/commands/pota-plan.md +22 -0
- package/commands/pota-review.md +16 -0
- package/commands/pota-toolcheck.md +17 -0
- package/commands/pota-uninstall.md +27 -0
- package/commands/pota-upgrade.md +26 -0
- package/commands/pota-work.md +24 -0
- package/docs/model-guidance/README.md +67 -0
- package/docs/model-guidance/claude.md +49 -0
- package/docs/model-guidance/codex.md +68 -0
- package/hooks/hooks.json +65 -0
- package/package.json +47 -4
- package/scripts/pota +14 -0
- package/scripts/pota-auto-upgrade.mjs +342 -0
- package/scripts/pota-cleanup.mjs +194 -0
- package/scripts/pota-goal-cli.mjs +337 -0
- package/scripts/pota-goal-evidence.mjs +186 -0
- package/scripts/pota-goal-history.mjs +316 -0
- package/scripts/pota-goal-operations-evidence.mjs +213 -0
- package/scripts/pota-goal-operations-mutations.mjs +97 -0
- package/scripts/pota-goal-operations.mjs +281 -0
- package/scripts/pota-goal-policy.mjs +62 -0
- package/scripts/pota-goal-state-validation.mjs +361 -0
- package/scripts/pota-goal-state.mjs +136 -0
- package/scripts/pota-goal.mjs +69 -0
- package/scripts/pota-index.mjs +1052 -0
- package/scripts/pota-lifecycle-claude.mjs +173 -0
- package/scripts/pota-lifecycle-commands.mjs +418 -0
- package/scripts/pota-lifecycle-config.mjs +153 -0
- package/scripts/pota-lifecycle-hosts.mjs +98 -0
- package/scripts/pota-lifecycle-integrity.mjs +272 -0
- package/scripts/pota-lifecycle-layout.mjs +52 -0
- package/scripts/pota-lifecycle-lock.mjs +205 -0
- package/scripts/pota-lifecycle-mirror.mjs +83 -0
- package/scripts/pota-lifecycle-staging.mjs +156 -0
- package/scripts/pota-lifecycle-transaction-journal.mjs +244 -0
- package/scripts/pota-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/pota-lifecycle-transaction.mjs +12 -0
- package/scripts/pota-lifecycle.mjs +244 -0
- package/scripts/pota-paths.mjs +190 -0
- package/scripts/pota-safe-fs.mjs +277 -0
- package/scripts/pota-state-context.mjs +281 -0
- package/scripts/pota-state-hook.mjs +26 -0
- package/scripts/pota-state-init.mjs +80 -0
- package/scripts/pota-state.mjs +245 -0
- package/scripts/pota-toolcheck-detect.mjs +229 -0
- package/scripts/pota-toolcheck-runtime.mjs +178 -0
- package/scripts/pota-toolcheck.mjs +221 -0
- package/scripts/pota-update-state.mjs +219 -0
- package/scripts/pota.mjs +200 -0
- package/skills/pota-memory/SKILL.md +52 -0
- package/skills/pota-plan/SKILL.md +66 -0
- package/skills/pota-plan/delegation.md +65 -0
- package/skills/pota-plan/heavy-path.md +28 -0
- package/skills/pota-plan/planning-rules.md +78 -0
- package/skills/pota-review/SKILL.md +56 -0
- package/skills/pota-work/SKILL.md +71 -0
- package/skills/pota-work/delegation.md +65 -0
- package/skills/pota-work/heavy-path.md +18 -0
- package/skills/pota-work/recovery.md +29 -0
- package/skills/pota-work/verification.md +79 -0
- package/templates/MEMORY.md +12 -0
- package/templates/PLAN.md +9 -0
- package/templates/domain-memory.md +22 -0
- package/templates/work-plan.md +177 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { parseRootArgs } from "./pota-paths.mjs";
|
|
6
|
+
import { assertNoSymlinkComponents, safeMkdir, safeWriteFile } from "./pota-safe-fs.mjs";
|
|
7
|
+
import { INDEX_TEMPLATES } from "./pota-state-init.mjs";
|
|
8
|
+
|
|
9
|
+
export const INDEX_SCHEMA_VERSION = 1;
|
|
10
|
+
export const INDEX_LIMITS = Object.freeze({
|
|
11
|
+
summary: 120,
|
|
12
|
+
line: 200,
|
|
13
|
+
name: 64,
|
|
14
|
+
rootLines: 120,
|
|
15
|
+
rootBytes: 16_384,
|
|
16
|
+
indexBytes: 262_144,
|
|
17
|
+
completedKeep: 20,
|
|
18
|
+
hookEntries: 64,
|
|
19
|
+
noteHeadBytes: 4_096,
|
|
20
|
+
noteHeadLines: 20,
|
|
21
|
+
});
|
|
22
|
+
export const INDEX_USAGE = Object.freeze({
|
|
23
|
+
memory: "Usage: pota memory [check|index] [--root PATH] [--strict] [--dry-run]",
|
|
24
|
+
plan: "Usage: pota plan [check|index] [--root PATH] [--strict] [--dry-run] [--archive]",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const KINDS = Object.freeze({
|
|
28
|
+
memory: Object.freeze({ index: "MEMORY.md", directory: "docs/memory", noun: "notes" }),
|
|
29
|
+
plan: Object.freeze({ index: "PLAN.md", directory: "docs/plan", noun: "plans" }),
|
|
30
|
+
});
|
|
31
|
+
const ARCHIVE_PATH = "docs/plan/ARCHIVE.md";
|
|
32
|
+
const ARCHIVE_HEADER = [
|
|
33
|
+
"# Plan Archive",
|
|
34
|
+
"",
|
|
35
|
+
"Older completed plan entries moved from `PLAN.md` by `pota plan index --archive`. Newest first.",
|
|
36
|
+
];
|
|
37
|
+
const SECTIONS = Object.freeze({
|
|
38
|
+
domain: "Domain Knowledge",
|
|
39
|
+
active: "Active Plans",
|
|
40
|
+
completed: "Completed Plans",
|
|
41
|
+
rules: "Planning Rules",
|
|
42
|
+
legacyRules: "Legacy Planning Rules",
|
|
43
|
+
});
|
|
44
|
+
// Exact placeholder list lines written by earlier Pota init templates (en and ko).
|
|
45
|
+
const PLACEHOLDERS = Object.freeze({
|
|
46
|
+
memory: Object.freeze([
|
|
47
|
+
"- Link detailed notes under `docs/memory/`.",
|
|
48
|
+
"- 자세한 기록은 `docs/memory/` 아래에 연결합니다.",
|
|
49
|
+
]),
|
|
50
|
+
plan: Object.freeze([
|
|
51
|
+
"- Link detailed plans under `docs/plan/`.",
|
|
52
|
+
"- Move completed topics here with evidence links.",
|
|
53
|
+
"- 상세 계획은 `docs/plan/` 아래에 연결합니다.",
|
|
54
|
+
"- 완료된 주제는 증거 링크와 함께 이곳으로 옮깁니다.",
|
|
55
|
+
]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const NAME_CHARS = "[^\\s()[\\]<>`/\\u0000-\\u001f\\u007f-\\u009f]+?";
|
|
59
|
+
const REFERENCE = new RegExp(`(?<![\\w./-])(?:\\./)?docs/(memory|plan)/(${NAME_CHARS})\\.md(?![\\w./-])`);
|
|
60
|
+
const REFERENCES = new RegExp(REFERENCE.source, "g");
|
|
61
|
+
const ARCHIVE_REFERENCE = new RegExp(`\\]\\((?:\\./)?(${NAME_CHARS})\\.md\\)`);
|
|
62
|
+
const ARCHIVE_LINK_TARGET = new RegExp(`\\]\\((?:\\./)?docs/plan/(${NAME_CHARS}\\.md)\\)`, "g");
|
|
63
|
+
const UNSAFE_NAME = /[\s()[\]<>`\u0000-\u001f\u007f-\u009f\u{2028}\u{2029}]/u;
|
|
64
|
+
// C0, DEL, C1, and the Unicode line and paragraph separators.
|
|
65
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u{2028}\u{2029}]/u;
|
|
66
|
+
const CONTROL_CHARACTERS = new RegExp(CONTROL_CHARACTER.source, "gu");
|
|
67
|
+
const LIST_LINE = /^\s*[-*+]\s+/;
|
|
68
|
+
const TOP_LIST_LINE = /^[-*+]\s+/;
|
|
69
|
+
const CHECKBOX = /^\s*[-*+]\s+\[([ xX])\]/;
|
|
70
|
+
const FENCE_MARK = /^\s*(`{3,}|~{3,})(.*)$/;
|
|
71
|
+
const HEADING2 = /^## +(.+?)\s*$/;
|
|
72
|
+
// PLAN.md index sections: Active, Completed, and any other "... Plans" section such as Superseded Plans.
|
|
73
|
+
const PLAN_SECTION = /(?:^| )Plans$/;
|
|
74
|
+
const SECTION_BREAK = /^#{1,2} /;
|
|
75
|
+
const SUMMARY_LINE = /^(?:[-*>]\s*)?(?:\*\*)?Summary:(?:\*\*)?\s*(.+?)\s*$/i;
|
|
76
|
+
const SUMMARY_SEPARATOR = /^\s*(?::|-(?=\s)|—)\s*/;
|
|
77
|
+
const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
78
|
+
|
|
79
|
+
export function parseIndexEntries(text, { kind = null, archive = false } = {}) {
|
|
80
|
+
const entries = [];
|
|
81
|
+
let section = null;
|
|
82
|
+
const fences = fenceTracker();
|
|
83
|
+
const lines = String(text ?? "").split("\n");
|
|
84
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
85
|
+
const line = stripCarriageReturn(lines[index]);
|
|
86
|
+
if (fences.skip(line)) continue;
|
|
87
|
+
const heading = HEADING2.exec(line);
|
|
88
|
+
if (heading) {
|
|
89
|
+
section = heading[1];
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!LIST_LINE.test(line)) continue;
|
|
93
|
+
// ARCHIVE.md relative links are entries only in its entry region, before the first ## heading.
|
|
94
|
+
const reference = findReference(line, archive && section === null);
|
|
95
|
+
if (!reference) continue;
|
|
96
|
+
if (kind && reference.kind !== kind) continue;
|
|
97
|
+
if (isExcludedName(reference.kind, reference.name)) continue;
|
|
98
|
+
entries.push({
|
|
99
|
+
kind: reference.kind,
|
|
100
|
+
name: reference.name,
|
|
101
|
+
path: `docs/${reference.kind}/${reference.name}.md`,
|
|
102
|
+
line: index + 1,
|
|
103
|
+
summary: line.slice(reference.end).replace(SUMMARY_SEPARATOR, "").trim(),
|
|
104
|
+
section,
|
|
105
|
+
text: line,
|
|
106
|
+
end: reference.end,
|
|
107
|
+
references: lineReferences(line),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Entries are list lines in the index sections (## Domain Knowledge, or the PLAN.md "... Plans" sections); a file
|
|
114
|
+
// without such a section is legacy and every list line counts. References elsewhere, and later references on an entry
|
|
115
|
+
// line, are mentions: they mark a file as listed but are never rewritten or checked for duplicates and dead links.
|
|
116
|
+
function indexReferences(text, kind) {
|
|
117
|
+
const parsed = parseIndexEntries(text, { kind });
|
|
118
|
+
const managed = hasIndexSection(text, kind);
|
|
119
|
+
return splitReferences(parsed, kind, (entry) => !managed || isIndexSection(kind, entry.section));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function archiveReferences(archiveText) {
|
|
123
|
+
if (archiveText === null || archiveText === undefined) return { entries: [], mentions: new Set() };
|
|
124
|
+
return splitReferences(parseIndexEntries(archiveText, { kind: "plan", archive: true }), "plan", (entry) => entry.section === null);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function splitReferences(parsed, kind, isEntry) {
|
|
128
|
+
const entries = [];
|
|
129
|
+
const mentions = new Set();
|
|
130
|
+
for (const entry of parsed) {
|
|
131
|
+
if (isEntry(entry)) entries.push(entry);
|
|
132
|
+
else mentions.add(entry.name);
|
|
133
|
+
entry.references.forEach((reference, position) => {
|
|
134
|
+
const self = position === 0 && reference.kind === entry.kind && reference.name === entry.name;
|
|
135
|
+
if (!self && reference.kind === kind) mentions.add(reference.name);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { entries, mentions };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isIndexSection(kind, section) {
|
|
142
|
+
if (typeof section !== "string") return false;
|
|
143
|
+
return kind === "memory" ? section === SECTIONS.domain : PLAN_SECTION.test(section);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hasIndexSection(text, kind) {
|
|
147
|
+
const fences = fenceTracker();
|
|
148
|
+
for (const raw of String(text ?? "").split("\n")) {
|
|
149
|
+
const line = stripCarriageReturn(raw);
|
|
150
|
+
if (fences.skip(line)) continue;
|
|
151
|
+
const heading = HEADING2.exec(line);
|
|
152
|
+
if (heading && isIndexSection(kind, heading[1])) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function lineReferences(line) {
|
|
158
|
+
return [...line.matchAll(REFERENCES)]
|
|
159
|
+
.map((match) => ({ kind: match[1], name: match[2] }))
|
|
160
|
+
.filter((reference) => !isExcludedName(reference.kind, reference.name));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function extractNoteSummary(text, name = null) {
|
|
164
|
+
const value = String(text ?? "");
|
|
165
|
+
for (const line of noteHeadLines(value)) {
|
|
166
|
+
const match = SUMMARY_LINE.exec(line);
|
|
167
|
+
if (!match || isPlaceholderValue(match[1])) continue;
|
|
168
|
+
return { summary: clip(match[1], INDEX_LIMITS.summary), source: "summary", raw: match[1], tooLong: length(match[1]) > INDEX_LIMITS.summary };
|
|
169
|
+
}
|
|
170
|
+
const lines = value.split("\n").map(stripCarriageReturn);
|
|
171
|
+
const scope = scopeParagraphLine(lines);
|
|
172
|
+
if (scope) return fallbackSummary(scope, "scope");
|
|
173
|
+
const first = firstBodyLine(lines);
|
|
174
|
+
if (first) return fallbackSummary(first, "line");
|
|
175
|
+
const title = lines.find((line) => /^# +\S/.test(line));
|
|
176
|
+
if (title) {
|
|
177
|
+
const cleaned = title.replace(/^# +/, "").replace(/^Domain:\s*/i, "").trim();
|
|
178
|
+
if (cleaned) return fallbackSummary(cleaned, "title");
|
|
179
|
+
}
|
|
180
|
+
return fallbackSummary(name ?? "", name ? "name" : null);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function checkIndex(root, kind, { strict = false } = {}) {
|
|
184
|
+
const snapshot = await gather(root, kind);
|
|
185
|
+
if (snapshot.blocked) return finishReport(snapshot, blockedAnalysis(snapshot), strict);
|
|
186
|
+
return finishReport(snapshot, analyze(snapshot, snapshot.indexText, snapshot.archiveText), strict);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function updateIndex(root, kind, { dryRun = false, archive = false, ko = false, strict = false } = {}) {
|
|
190
|
+
const snapshot = await gather(root, kind);
|
|
191
|
+
const changes = { changed: false, dryRun, added: [], updated: [], removedPlaceholders: 0, archived: 0, movedSections: [], placed: {} };
|
|
192
|
+
if (snapshot.blocked) return { ...finishReport(snapshot, blockedAnalysis(snapshot), strict), ...changes };
|
|
193
|
+
|
|
194
|
+
let indexText = snapshot.indexText;
|
|
195
|
+
if (indexText === null && snapshot.files.length > 0) indexText = INDEX_TEMPLATES[kind][ko ? "ko" : "en"];
|
|
196
|
+
let archiveText = snapshot.archiveText;
|
|
197
|
+
let fenceIssue = null;
|
|
198
|
+
|
|
199
|
+
if (indexText !== null) {
|
|
200
|
+
try {
|
|
201
|
+
({ indexText, archiveText } = await applyIndexChanges(snapshot, kind, indexText, archiveText, archive, changes));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (!(error instanceof UnclosedFenceError)) throw error;
|
|
204
|
+
// Nothing is written: inserting inside an unclosed fence would hide the lines and grow the file on every run.
|
|
205
|
+
const path = error.path ?? KINDS[kind].index;
|
|
206
|
+
fenceIssue = issue("unclosed-fence", path,
|
|
207
|
+
`${path} has an unclosed code fence at the insertion point; close the fence, then rerun pota ${kind} index`);
|
|
208
|
+
indexText = snapshot.indexText;
|
|
209
|
+
archiveText = snapshot.archiveText;
|
|
210
|
+
Object.assign(changes, { added: [], updated: [], removedPlaceholders: 0, archived: 0, movedSections: [], placed: {} });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const indexChanged = indexText !== snapshot.indexText;
|
|
215
|
+
const archiveChanged = archiveText !== snapshot.archiveText;
|
|
216
|
+
changes.changed = indexChanged || archiveChanged;
|
|
217
|
+
if (changes.changed && !dryRun) {
|
|
218
|
+
// Write the archive first so an interruption duplicates entries instead of losing them.
|
|
219
|
+
if (archiveChanged) {
|
|
220
|
+
await safeMkdir(snapshot.root, KINDS.plan.directory);
|
|
221
|
+
await atomicWrite(snapshot.root, ARCHIVE_PATH, archiveText);
|
|
222
|
+
}
|
|
223
|
+
if (indexChanged) await atomicWrite(snapshot.root, KINDS[kind].index, indexText);
|
|
224
|
+
}
|
|
225
|
+
const analysis = analyze(snapshot, indexText, archiveText);
|
|
226
|
+
if (fenceIssue) analysis.errors.push(fenceIssue);
|
|
227
|
+
return { ...finishReport(snapshot, analysis, strict), ...changes };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function applyIndexChanges(snapshot, kind, indexText, archiveText, archive, changes) {
|
|
231
|
+
const lines = indexText.split("\n");
|
|
232
|
+
const eol = indexText.includes("\r\n") ? "\r\n" : "\n";
|
|
233
|
+
changes.removedPlaceholders = removePlaceholders(lines, kind);
|
|
234
|
+
|
|
235
|
+
if (kind === "memory") {
|
|
236
|
+
for (const entry of indexReferences(lines.join("\n"), kind).entries) {
|
|
237
|
+
// A line with several references is the user's prose; replacing its tail would drop the other references.
|
|
238
|
+
if (entry.references.length > 1) continue;
|
|
239
|
+
const expected = expectedMemorySummary(snapshot, entry.name);
|
|
240
|
+
if (expected === null || entry.summary === expected) continue;
|
|
241
|
+
// Only the summary after the reference changes; a label before it and the separator stay as written.
|
|
242
|
+
const line = stripCarriageReturn(lines[entry.line - 1]);
|
|
243
|
+
const separator = SUMMARY_SEPARATOR.exec(line.slice(entry.end))?.[0] || ": ";
|
|
244
|
+
lines[entry.line - 1] = withEol(`${line.slice(0, entry.end)}${separator}${expected}`, lines[entry.line - 1]);
|
|
245
|
+
if (!changes.updated.includes(entry.name)) changes.updated.push(entry.name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const current = indexReferences(lines.join("\n"), kind);
|
|
250
|
+
const stored = archiveReferences(archiveText);
|
|
251
|
+
const indexed = new Set([
|
|
252
|
+
...current.entries.map((entry) => entry.name),
|
|
253
|
+
...current.mentions,
|
|
254
|
+
...stored.entries.map((entry) => entry.name),
|
|
255
|
+
...stored.mentions,
|
|
256
|
+
]);
|
|
257
|
+
const missing = snapshot.files.filter((file) => !indexed.has(file.name));
|
|
258
|
+
if (kind === "memory" && missing.length > 0) {
|
|
259
|
+
const added = [];
|
|
260
|
+
for (const file of missing) {
|
|
261
|
+
const summary = extractNoteSummary(await readCapped(file.absolute, INDEX_LIMITS.indexBytes), file.name).summary;
|
|
262
|
+
added.push(entryLine(kind, file.name, clipEntrySummary(kind, file.name, summary)));
|
|
263
|
+
changes.added.push(file.name);
|
|
264
|
+
}
|
|
265
|
+
insertIntoSection(lines, SECTIONS.domain, added, { eol });
|
|
266
|
+
}
|
|
267
|
+
if (kind === "plan" && missing.length > 0) {
|
|
268
|
+
const active = [];
|
|
269
|
+
const completed = [];
|
|
270
|
+
for (const file of missing) {
|
|
271
|
+
const plan = describePlan(await readCapped(file.absolute, INDEX_LIMITS.indexBytes), file.name);
|
|
272
|
+
const line = entryLine(kind, file.name, clipEntrySummary(kind, file.name, plan.title));
|
|
273
|
+
(plan.completed ? completed : active).push(line);
|
|
274
|
+
changes.placed[file.name] = plan.completed ? "completed" : "active";
|
|
275
|
+
changes.added.push(file.name);
|
|
276
|
+
}
|
|
277
|
+
if (active.length > 0) insertIntoSection(lines, SECTIONS.active, active, { eol });
|
|
278
|
+
if (completed.length > 0) insertIntoSection(lines, SECTIONS.completed, completed, { eol, top: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (kind === "plan" && archive) {
|
|
282
|
+
const moved = moveToArchive(lines, archiveText, eol);
|
|
283
|
+
if (moved) {
|
|
284
|
+
archiveText = moved.archiveText;
|
|
285
|
+
changes.archived = moved.archived;
|
|
286
|
+
changes.movedSections = moved.movedSections;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
indexText = lines.join("\n");
|
|
290
|
+
return { indexText, archiveText };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function hookIndexSnapshot({ root, memory = "none", plans = false } = {}) {
|
|
294
|
+
const result = { memory: null, plans: null };
|
|
295
|
+
if (memory === "list" || memory === "count") {
|
|
296
|
+
try {
|
|
297
|
+
result.memory = await memorySnapshot(root, memory);
|
|
298
|
+
} catch {
|
|
299
|
+
result.memory = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (plans === true) {
|
|
303
|
+
try {
|
|
304
|
+
result.plans = await planSnapshot(root);
|
|
305
|
+
} catch {
|
|
306
|
+
result.plans = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function parseIndexArgs(kind, args = []) {
|
|
313
|
+
if (!KINDS[kind]) throw new Error(`unknown index kind: ${kind}`);
|
|
314
|
+
const { args: rest, repoRoot } = parseRootArgs(args);
|
|
315
|
+
if (rest.length === 0) return { action: "guidance", root: repoRoot };
|
|
316
|
+
const help = rest.includes("--help");
|
|
317
|
+
const version = rest.includes("--version");
|
|
318
|
+
if (help && version) throw new Error("--help and --version cannot be used together");
|
|
319
|
+
if (help) return { action: "help", root: repoRoot };
|
|
320
|
+
if (version) return { action: "version", root: repoRoot };
|
|
321
|
+
const [command, ...flags] = rest;
|
|
322
|
+
if (command.startsWith("-")) throw new Error(`unknown option: ${command}`);
|
|
323
|
+
if (command !== "check" && command !== "index") throw new Error(`unknown ${kind} command: ${command}`);
|
|
324
|
+
const allowed = command === "check" ? ["--strict"] : kind === "plan" ? ["--dry-run", "--archive"] : ["--dry-run"];
|
|
325
|
+
for (const flag of flags) {
|
|
326
|
+
if (!allowed.includes(flag)) throw new Error(`unknown option: ${flag}`);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
action: command,
|
|
330
|
+
root: repoRoot,
|
|
331
|
+
strict: flags.includes("--strict"),
|
|
332
|
+
dryRun: flags.includes("--dry-run"),
|
|
333
|
+
archive: flags.includes("--archive"),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function gather(root, kind) {
|
|
338
|
+
const spec = KINDS[kind];
|
|
339
|
+
if (!spec) throw new Error(`unknown index kind: ${kind}`);
|
|
340
|
+
const workspaceRoot = resolve(root);
|
|
341
|
+
const rootStat = await lstat(workspaceRoot);
|
|
342
|
+
if (!rootStat.isDirectory()) throw new Error(`repository root is not a directory: ${workspaceRoot}`);
|
|
343
|
+
const snapshot = {
|
|
344
|
+
kind,
|
|
345
|
+
root: workspaceRoot,
|
|
346
|
+
blocked: false,
|
|
347
|
+
errors: [],
|
|
348
|
+
warnings: [],
|
|
349
|
+
files: [],
|
|
350
|
+
fileCount: 0,
|
|
351
|
+
notes: new Map(),
|
|
352
|
+
indexText: null,
|
|
353
|
+
archiveText: null,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const directory = await inspectPath(workspaceRoot, spec.directory, "directory");
|
|
357
|
+
if (directory === "unsafe") {
|
|
358
|
+
snapshot.blocked = true;
|
|
359
|
+
snapshot.errors.push(issue("unsafe-path", spec.directory,
|
|
360
|
+
`${spec.directory} is not a regular directory inside the repository (symlink or special file); ${spec.noun} were not read`));
|
|
361
|
+
}
|
|
362
|
+
const index = await inspectPath(workspaceRoot, spec.index, "file");
|
|
363
|
+
if (index === "unsafe") {
|
|
364
|
+
snapshot.blocked = true;
|
|
365
|
+
snapshot.errors.push(issue("unsafe-path", spec.index,
|
|
366
|
+
`${spec.index} is not a regular file inside the repository (symlink, special file, or over 256 KiB); index was not read`));
|
|
367
|
+
}
|
|
368
|
+
if (kind === "plan" && directory === "ok") {
|
|
369
|
+
const archive = await inspectPath(workspaceRoot, ARCHIVE_PATH, "file");
|
|
370
|
+
if (archive === "unsafe") {
|
|
371
|
+
snapshot.blocked = true;
|
|
372
|
+
snapshot.errors.push(issue("unsafe-path", ARCHIVE_PATH,
|
|
373
|
+
`${ARCHIVE_PATH} is not a regular file inside the repository (symlink, special file, or over 256 KiB); index was not read`));
|
|
374
|
+
} else if (archive === "ok") {
|
|
375
|
+
snapshot.archiveText = await readCapped(join(workspaceRoot, ARCHIVE_PATH), INDEX_LIMITS.indexBytes, { whole: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (snapshot.blocked) return snapshot;
|
|
379
|
+
|
|
380
|
+
if (index === "ok") snapshot.indexText = await readCapped(join(workspaceRoot, spec.index), INDEX_LIMITS.indexBytes, { whole: true });
|
|
381
|
+
if (directory === "ok") {
|
|
382
|
+
const scan = await scanDirectory(workspaceRoot, kind);
|
|
383
|
+
snapshot.files = scan.files;
|
|
384
|
+
snapshot.fileCount = scan.fileCount;
|
|
385
|
+
snapshot.errors.push(...scan.errors);
|
|
386
|
+
snapshot.warnings.push(...scan.warnings);
|
|
387
|
+
}
|
|
388
|
+
if (kind === "memory") {
|
|
389
|
+
for (const file of snapshot.files) {
|
|
390
|
+
snapshot.notes.set(file.name, extractNoteSummary(await readCapped(file.absolute, INDEX_LIMITS.noteHeadBytes), file.name));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return snapshot;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function scanDirectory(root, kind) {
|
|
397
|
+
const spec = KINDS[kind];
|
|
398
|
+
const result = { files: [], fileCount: 0, errors: [], warnings: [] };
|
|
399
|
+
const entries = (await readdir(join(root, spec.directory), { withFileTypes: true }))
|
|
400
|
+
.sort((left, right) => compareNames(left.name, right.name));
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
const path = `${spec.directory}/${entry.name}`;
|
|
403
|
+
if (entry.isDirectory()) {
|
|
404
|
+
result.warnings.push(issue("nested-ignored", `${path}/`, `${path}/ is a subdirectory; only top-level ${spec.directory}/*.md files are indexed`));
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
408
|
+
const name = entry.name.slice(0, -3);
|
|
409
|
+
if (isExcludedName(kind, name)) continue;
|
|
410
|
+
if (!entry.isFile()) {
|
|
411
|
+
result.warnings.push(issue("skipped-non-regular", path, `${path} is not a regular file and was skipped`));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
result.fileCount += 1;
|
|
415
|
+
if (isUnsafeName(name)) {
|
|
416
|
+
result.errors.push(issue("unsafe-name", path,
|
|
417
|
+
`${path} cannot be indexed: rename it without spaces, brackets, or backticks (max 64 characters)`));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
result.files.push({ name, path, absolute: join(root, spec.directory, entry.name) });
|
|
421
|
+
}
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function analyze(snapshot, indexText, archiveText) {
|
|
426
|
+
const { kind } = snapshot;
|
|
427
|
+
const spec = KINDS[kind];
|
|
428
|
+
const errors = [...snapshot.errors];
|
|
429
|
+
const warnings = [...snapshot.warnings];
|
|
430
|
+
if (indexText === null) {
|
|
431
|
+
if (snapshot.fileCount > 0) {
|
|
432
|
+
errors.unshift(issue("index-missing", spec.index,
|
|
433
|
+
`${spec.index} is missing but ${spec.directory} has ${snapshot.fileCount} ${spec.noun}; run pota ${kind} index`));
|
|
434
|
+
}
|
|
435
|
+
return { errors, warnings, entries: 0 };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const root = indexReferences(indexText, kind);
|
|
439
|
+
const stored = kind === "plan" ? archiveReferences(archiveText) : archiveReferences(null);
|
|
440
|
+
const rootEntries = root.entries;
|
|
441
|
+
const storedEntries = stored.entries;
|
|
442
|
+
const fileNames = new Set(snapshot.files.map((file) => file.name));
|
|
443
|
+
const indexed = new Set([...rootEntries, ...storedEntries].map((entry) => entry.name));
|
|
444
|
+
for (const name of [...root.mentions, ...stored.mentions]) indexed.add(name);
|
|
445
|
+
|
|
446
|
+
for (const file of snapshot.files) {
|
|
447
|
+
if (!indexed.has(file.name)) {
|
|
448
|
+
errors.push(issue("unindexed", file.path, `${file.path} is not listed in ${spec.index}; run pota ${kind} index`));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const [source, entries] of [[spec.index, rootEntries], [ARCHIVE_PATH, storedEntries]]) {
|
|
452
|
+
for (const entry of entries) {
|
|
453
|
+
if (fileNames.has(entry.name)) continue;
|
|
454
|
+
errors.push(issue("dead-link", entry.path,
|
|
455
|
+
`${source} line ${entry.line} links ${entry.path}, which does not exist; fix the path or delete the line`));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const [source, entries] of [[spec.index, rootEntries], [ARCHIVE_PATH, storedEntries]]) {
|
|
459
|
+
const lines = new Map();
|
|
460
|
+
for (const entry of entries) lines.set(entry.path, [...(lines.get(entry.path) ?? []), entry.line]);
|
|
461
|
+
for (const [path, numbers] of lines) {
|
|
462
|
+
if (numbers.length < 2) continue;
|
|
463
|
+
const suffix = source === spec.index ? "" : ` of ${source}`;
|
|
464
|
+
errors.push(issue("duplicate", path, `${path} is listed on lines ${numbers.join(", ")}${suffix}`));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (kind === "memory") {
|
|
468
|
+
for (const entry of rootEntries) {
|
|
469
|
+
// index leaves lines with several references alone, so check does not ask for a refresh it cannot make.
|
|
470
|
+
if (entry.references.length > 1) continue;
|
|
471
|
+
const expected = expectedMemorySummary(snapshot, entry.name);
|
|
472
|
+
if (expected === null || entry.summary === expected) continue;
|
|
473
|
+
errors.push(issue("stale-summary", entry.path,
|
|
474
|
+
`${spec.index} line ${entry.line} is older than the Summary line of ${entry.path}; run pota memory index`));
|
|
475
|
+
}
|
|
476
|
+
for (const file of snapshot.files) {
|
|
477
|
+
const note = snapshot.notes.get(file.name);
|
|
478
|
+
if (note?.source !== "summary") {
|
|
479
|
+
warnings.push(issue("summary-missing", file.path,
|
|
480
|
+
`${file.path} has no Summary: line in its first ${INDEX_LIMITS.noteHeadLines} lines; add one so the index summary stays current`));
|
|
481
|
+
} else if (note.tooLong) {
|
|
482
|
+
warnings.push(issue("summary-too-long", file.path,
|
|
483
|
+
`${file.path} has a Summary: line over ${INDEX_LIMITS.summary} characters; the index keeps a clipped copy`));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Hooks print index summaries into the session context, so a control character or line separator is an error.
|
|
488
|
+
for (const entry of rootEntries) {
|
|
489
|
+
if (!CONTROL_CHARACTER.test(entry.summary)) continue;
|
|
490
|
+
errors.push(issue("control-character", spec.index,
|
|
491
|
+
`${spec.index} line ${entry.line} has a control character or line separator in its summary; replace it with a space`));
|
|
492
|
+
}
|
|
493
|
+
for (const entry of rootEntries) {
|
|
494
|
+
if (length(entry.summary) > INDEX_LIMITS.summary) {
|
|
495
|
+
warnings.push(issue("summary-too-long", spec.index,
|
|
496
|
+
`${spec.index} line ${entry.line} has a summary over ${INDEX_LIMITS.summary} characters`));
|
|
497
|
+
}
|
|
498
|
+
const lineLength = length(entry.text);
|
|
499
|
+
if (lineLength > INDEX_LIMITS.line) {
|
|
500
|
+
warnings.push(issue("entry-too-long", spec.index,
|
|
501
|
+
`${spec.index} line ${entry.line} is ${lineLength} characters; keep index lines at most ${INDEX_LIMITS.line} characters`));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const lineCount = indexText.split("\n").length - (indexText.endsWith("\n") ? 1 : 0);
|
|
505
|
+
const byteCount = Buffer.byteLength(indexText);
|
|
506
|
+
if (lineCount > INDEX_LIMITS.rootLines || byteCount > INDEX_LIMITS.rootBytes) {
|
|
507
|
+
warnings.push(issue("root-too-long", spec.index,
|
|
508
|
+
`${spec.index} has ${lineCount} lines and ${byteCount} bytes; keep it within ${INDEX_LIMITS.rootLines} lines and ${INDEX_LIMITS.rootBytes} bytes`));
|
|
509
|
+
}
|
|
510
|
+
if (kind === "plan") {
|
|
511
|
+
const lines = indexText.split("\n");
|
|
512
|
+
const completed = sectionRange(lines, SECTIONS.completed);
|
|
513
|
+
const items = completed ? topListItems(lines, completed).length : 0;
|
|
514
|
+
if (items > INDEX_LIMITS.completedKeep) {
|
|
515
|
+
warnings.push(issue("completed-overflow", spec.index,
|
|
516
|
+
`${spec.index} has ${items} completed entries (keep ${INDEX_LIMITS.completedKeep}); run pota plan index --archive`));
|
|
517
|
+
}
|
|
518
|
+
if (sectionRange(lines, SECTIONS.rules)) {
|
|
519
|
+
warnings.push(issue("legacy-planning-rules", spec.index,
|
|
520
|
+
`${spec.index} has a ## Planning Rules section; rules live in the pota-plan skill; pota plan index --archive moves this section to docs/plan/ARCHIVE.md`));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return { errors, warnings, entries: rootEntries.length + storedEntries.length };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function blockedAnalysis(snapshot) {
|
|
527
|
+
return { errors: [...snapshot.errors], warnings: [...snapshot.warnings], entries: 0 };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function finishReport(snapshot, analysis, strict) {
|
|
531
|
+
const spec = KINDS[snapshot.kind];
|
|
532
|
+
return {
|
|
533
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
534
|
+
kind: snapshot.kind,
|
|
535
|
+
index: spec.index,
|
|
536
|
+
directory: spec.directory,
|
|
537
|
+
ok: analysis.errors.length === 0 && (!strict || analysis.warnings.length === 0),
|
|
538
|
+
files: snapshot.fileCount,
|
|
539
|
+
entries: analysis.entries,
|
|
540
|
+
errors: analysis.errors,
|
|
541
|
+
warnings: analysis.warnings,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function expectedMemorySummary(snapshot, name) {
|
|
546
|
+
const note = snapshot.notes.get(name);
|
|
547
|
+
if (note?.source !== "summary") return null;
|
|
548
|
+
return clipEntrySummary("memory", name, note.raw);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function moveToArchive(lines, archiveText, eol) {
|
|
552
|
+
const movedLines = [];
|
|
553
|
+
const completed = sectionRange(lines, SECTIONS.completed);
|
|
554
|
+
let archived = 0;
|
|
555
|
+
if (completed) {
|
|
556
|
+
const items = topListItems(lines, completed);
|
|
557
|
+
const remove = [];
|
|
558
|
+
for (let position = INDEX_LIMITS.completedKeep; position < items.length; position += 1) {
|
|
559
|
+
const item = items[position];
|
|
560
|
+
const previous = items[position - 1];
|
|
561
|
+
// Blank lines between loose items move with the later item; the gap before the first moved item is dropped.
|
|
562
|
+
const gapStart = lines.slice(previous.end + 1, item.start).every(isBlank) ? previous.end + 1 : item.start;
|
|
563
|
+
archived += 1;
|
|
564
|
+
for (let index = gapStart; index <= item.end; index += 1) {
|
|
565
|
+
if (index >= item.start || position > INDEX_LIMITS.completedKeep) {
|
|
566
|
+
movedLines.push(lines[index].replace(ARCHIVE_LINK_TARGET, "]($1)"));
|
|
567
|
+
}
|
|
568
|
+
remove.push(index);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
for (const index of remove.reverse()) lines.splice(index, 1);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let rulesBlock = null;
|
|
575
|
+
const rules = sectionRange(lines, SECTIONS.rules);
|
|
576
|
+
if (rules) {
|
|
577
|
+
// Only the section body moves: the archive's ## Legacy Planning Rules heading replaces the original heading.
|
|
578
|
+
let first = rules.start + 1;
|
|
579
|
+
let last = rules.end - 1;
|
|
580
|
+
while (last > rules.start && isBlank(lines[last])) last -= 1;
|
|
581
|
+
while (first <= last && isBlank(lines[first])) first += 1;
|
|
582
|
+
rulesBlock = lines.slice(first, last + 1);
|
|
583
|
+
const atEnd = rules.end === lines.length;
|
|
584
|
+
lines.splice(rules.start, rules.end - rules.start);
|
|
585
|
+
if (atEnd) {
|
|
586
|
+
while (lines.length > 0 && isBlank(lines[lines.length - 1])) lines.pop();
|
|
587
|
+
lines.push("");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (movedLines.length === 0 && !rulesBlock) return null;
|
|
591
|
+
|
|
592
|
+
const archiveLines = archiveText === null ? [...ARCHIVE_HEADER.map((line) => withEol(line, null, eol)), ""] : archiveText.split("\n");
|
|
593
|
+
const archiveEol = archiveText === null ? eol : archiveText.includes("\r\n") ? "\r\n" : "\n";
|
|
594
|
+
try {
|
|
595
|
+
if (movedLines.length > 0) insertArchiveEntries(archiveLines, movedLines, archiveEol);
|
|
596
|
+
if (rulesBlock?.length > 0) {
|
|
597
|
+
const block = [];
|
|
598
|
+
if (!sectionRange(archiveLines, SECTIONS.legacyRules)) block.push(`## ${SECTIONS.legacyRules}`, "");
|
|
599
|
+
appendBlock(archiveLines, [...block.map((line) => withEol(line, null, archiveEol)), ...rulesBlock], archiveEol);
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
if (error instanceof UnclosedFenceError) error.path = ARCHIVE_PATH;
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
const movedSections = rulesBlock ? [SECTIONS.rules] : [];
|
|
606
|
+
return { archiveText: archiveLines.join("\n"), archived, movedSections };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function insertArchiveEntries(lines, moved, eol) {
|
|
610
|
+
let regionEnd = lines.length;
|
|
611
|
+
const fences = fenceTracker();
|
|
612
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
613
|
+
const line = stripCarriageReturn(lines[index]);
|
|
614
|
+
if (fences.skip(line)) continue;
|
|
615
|
+
if (HEADING2.test(line)) {
|
|
616
|
+
regionEnd = index;
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const items = topListItems(lines, { start: -1, end: regionEnd });
|
|
621
|
+
if (items.length > 0) {
|
|
622
|
+
insertLines(lines, items[0].start, moved);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
insertAfterContent(lines, { start: 0, end: regionEnd }, moved, eol);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function insertIntoSection(lines, title, newLines, { eol, top = false }) {
|
|
629
|
+
const formatted = newLines.map((line) => withEol(line, null, eol));
|
|
630
|
+
const range = sectionRange(lines, title);
|
|
631
|
+
if (!range) {
|
|
632
|
+
appendBlock(lines, [withEol(`## ${title}`, null, eol), withEol("", null, eol), ...formatted], eol);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const listLines = [];
|
|
636
|
+
const fences = fenceTracker();
|
|
637
|
+
for (let index = range.start + 1; index < range.end; index += 1) {
|
|
638
|
+
const line = stripCarriageReturn(lines[index]);
|
|
639
|
+
if (!fences.skip(line) && LIST_LINE.test(line)) listLines.push(index);
|
|
640
|
+
}
|
|
641
|
+
if (listLines.length > 0 && top) {
|
|
642
|
+
insertLines(lines, listLines[0], formatted);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (listLines.length > 0) {
|
|
646
|
+
const last = itemEnd(lines, listLines[listLines.length - 1], range.end);
|
|
647
|
+
if (last + 1 === lines.length) ensureTrailingNewline(lines, eol);
|
|
648
|
+
insertLines(lines, last + 1, formatted);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
insertAfterContent(lines, range, formatted, eol);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Inserts a blank-separated block after the last non-blank line of a range that has no list yet.
|
|
655
|
+
function insertAfterContent(lines, range, block, eol) {
|
|
656
|
+
let last = Math.max(range.start, 0);
|
|
657
|
+
for (let index = range.end - 1; index >= Math.max(range.start, 0); index -= 1) {
|
|
658
|
+
if (!isBlank(lines[index])) {
|
|
659
|
+
last = index;
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (last + 1 >= lines.length) ensureTrailingNewline(lines, eol);
|
|
664
|
+
const insert = [withEol("", null, eol), ...block];
|
|
665
|
+
if (last + 1 < lines.length && !isBlank(lines[last + 1])) insert.push(withEol("", null, eol));
|
|
666
|
+
insertLines(lines, last + 1, insert);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function appendBlock(lines, block, eol) {
|
|
670
|
+
ensureTrailingNewline(lines, eol);
|
|
671
|
+
const at = lines.length - 1;
|
|
672
|
+
const insert = at > 0 && !isBlank(lines[at - 1]) ? [withEol("", null, eol), ...block] : [...block];
|
|
673
|
+
insertLines(lines, at, insert);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
class UnclosedFenceError extends Error {
|
|
677
|
+
constructor() {
|
|
678
|
+
super("insertion point is inside an unclosed code fence");
|
|
679
|
+
this.path = null;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Every generated line goes through here so nothing lands inside a code fence, where no parser would see it.
|
|
684
|
+
function insertLines(lines, at, items) {
|
|
685
|
+
const fences = fenceTracker();
|
|
686
|
+
for (let index = 0; index < at; index += 1) fences.skip(lines[index]);
|
|
687
|
+
if (fences.open) throw new UnclosedFenceError();
|
|
688
|
+
lines.splice(at, 0, ...items);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// CommonMark fences: a fence closes only on a line of the same character, at least as long, with nothing after it.
|
|
692
|
+
// A backtick opener whose info string contains a backtick is inline code, not a fence.
|
|
693
|
+
function nextFence(line, fence) {
|
|
694
|
+
const match = FENCE_MARK.exec(line);
|
|
695
|
+
if (!match) return fence;
|
|
696
|
+
const [, marks, rest] = match;
|
|
697
|
+
if (fence) return marks[0] === fence.char && marks.length >= fence.length && rest.trim() === "" ? null : fence;
|
|
698
|
+
if (marks[0] === "`" && rest.includes("`")) return null;
|
|
699
|
+
return { char: marks[0], length: marks.length };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function fenceTracker() {
|
|
703
|
+
let fence = null;
|
|
704
|
+
return {
|
|
705
|
+
// True for fence delimiter lines and every line between them.
|
|
706
|
+
skip(line) {
|
|
707
|
+
const inside = fence !== null;
|
|
708
|
+
fence = nextFence(stripCarriageReturn(line), fence);
|
|
709
|
+
return inside || fence !== null;
|
|
710
|
+
},
|
|
711
|
+
get open() {
|
|
712
|
+
return fence !== null;
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function ensureTrailingNewline(lines, eol) {
|
|
718
|
+
if (lines.length === 0) {
|
|
719
|
+
lines.push("");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (lines[lines.length - 1] === "") return;
|
|
723
|
+
if (eol === "\r\n" && !lines[lines.length - 1].endsWith("\r")) lines[lines.length - 1] += "\r";
|
|
724
|
+
lines.push("");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function sectionRange(lines, title) {
|
|
728
|
+
const fences = fenceTracker();
|
|
729
|
+
let start = -1;
|
|
730
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
731
|
+
const line = stripCarriageReturn(lines[index]);
|
|
732
|
+
if (fences.skip(line)) continue;
|
|
733
|
+
if (start === -1) {
|
|
734
|
+
const heading = HEADING2.exec(line);
|
|
735
|
+
if (heading && heading[1] === title) start = index;
|
|
736
|
+
} else if (SECTION_BREAK.test(line)) {
|
|
737
|
+
return { start, end: index };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return start === -1 ? null : { start, end: lines.length };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function topListItems(lines, range) {
|
|
744
|
+
const items = [];
|
|
745
|
+
const fences = fenceTracker();
|
|
746
|
+
for (let index = range.start + 1; index < range.end; index += 1) {
|
|
747
|
+
const line = stripCarriageReturn(lines[index]);
|
|
748
|
+
if (fences.skip(line) || !TOP_LIST_LINE.test(line)) continue;
|
|
749
|
+
const end = itemEnd(lines, index, range.end);
|
|
750
|
+
for (let inner = index + 1; inner <= end; inner += 1) fences.skip(lines[inner]);
|
|
751
|
+
items.push({ start: index, end });
|
|
752
|
+
index = end;
|
|
753
|
+
}
|
|
754
|
+
return items;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// A list item runs through its indented continuation lines, including those after blank lines (loose lists);
|
|
758
|
+
// a top-level list item, a heading, or any other unindented line ends it. Trailing blank lines are not part of it.
|
|
759
|
+
function itemEnd(lines, start, rangeEnd) {
|
|
760
|
+
let end = start;
|
|
761
|
+
for (let index = start + 1; index < rangeEnd; index += 1) {
|
|
762
|
+
if (isBlank(lines[index])) continue;
|
|
763
|
+
if (!isContinuation(lines[index])) break;
|
|
764
|
+
end = index;
|
|
765
|
+
}
|
|
766
|
+
return end;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Placeholders are removed only from the index sections, so prose sections such as ## Policy stay byte-for-byte.
|
|
770
|
+
function removePlaceholders(lines, kind) {
|
|
771
|
+
const managed = hasIndexSection(lines.join("\n"), kind);
|
|
772
|
+
let removed = 0;
|
|
773
|
+
let section = null;
|
|
774
|
+
const fences = fenceTracker();
|
|
775
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
776
|
+
const line = stripCarriageReturn(lines[index]);
|
|
777
|
+
if (fences.skip(line)) continue;
|
|
778
|
+
const heading = HEADING2.exec(line);
|
|
779
|
+
if (heading) {
|
|
780
|
+
section = heading[1];
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (!PLACEHOLDERS[kind].includes(line) || (managed && !isIndexSection(kind, section))) continue;
|
|
784
|
+
lines.splice(index, 1);
|
|
785
|
+
index -= 1;
|
|
786
|
+
removed += 1;
|
|
787
|
+
}
|
|
788
|
+
return removed;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function describePlan(text, name) {
|
|
792
|
+
let checked = 0;
|
|
793
|
+
let unchecked = 0;
|
|
794
|
+
let title = null;
|
|
795
|
+
const fences = fenceTracker();
|
|
796
|
+
for (const raw of text.split("\n")) {
|
|
797
|
+
const line = stripCarriageReturn(raw);
|
|
798
|
+
if (fences.skip(line)) continue;
|
|
799
|
+
if (title === null && /^# +\S/.test(line)) title = line.replace(/^# +/, "").replace(/^\S+:\s*/, "").trim();
|
|
800
|
+
const box = CHECKBOX.exec(line);
|
|
801
|
+
if (box) {
|
|
802
|
+
if (box[1] === " ") unchecked += 1;
|
|
803
|
+
else checked += 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { completed: checked > 0 && unchecked === 0, title: clip(title || name, INDEX_LIMITS.summary) };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function memorySnapshot(root, mode) {
|
|
810
|
+
const workspaceRoot = resolve(root);
|
|
811
|
+
if (await inspectPath(workspaceRoot, KINDS.memory.directory, "directory") !== "ok") return null;
|
|
812
|
+
const names = await hookFileNames(workspaceRoot, "memory");
|
|
813
|
+
if (names.length === 0) return null;
|
|
814
|
+
if (mode === "count") return { count: names.length, entries: [], drift: false };
|
|
815
|
+
|
|
816
|
+
const index = await inspectPath(workspaceRoot, KINDS.memory.index, "file");
|
|
817
|
+
if (index === "unsafe") return null;
|
|
818
|
+
const fileNames = new Set(names);
|
|
819
|
+
const seen = new Set();
|
|
820
|
+
const entries = [];
|
|
821
|
+
let drift = false;
|
|
822
|
+
let mentions = new Set();
|
|
823
|
+
if (index === "ok") {
|
|
824
|
+
const text = await readCapped(join(workspaceRoot, KINDS.memory.index), INDEX_LIMITS.indexBytes, { whole: true });
|
|
825
|
+
const references = indexReferences(text, "memory");
|
|
826
|
+
mentions = references.mentions;
|
|
827
|
+
for (const entry of references.entries) {
|
|
828
|
+
if (!fileNames.has(entry.name)) {
|
|
829
|
+
drift = true;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (seen.has(entry.name)) continue;
|
|
833
|
+
seen.add(entry.name);
|
|
834
|
+
entries.push({ name: entry.name, summary: entry.summary || null });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
for (const name of names) {
|
|
838
|
+
if (seen.has(name)) continue;
|
|
839
|
+
// A note mentioned outside ## Domain Knowledge is listed, as in pota memory check, but has no summary.
|
|
840
|
+
if (!mentions.has(name)) drift = true;
|
|
841
|
+
entries.push({ name, summary: null });
|
|
842
|
+
}
|
|
843
|
+
return { count: names.length, entries: entries.slice(0, INDEX_LIMITS.hookEntries), drift };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function planSnapshot(root) {
|
|
847
|
+
const workspaceRoot = resolve(root);
|
|
848
|
+
const directory = await inspectPath(workspaceRoot, KINDS.plan.directory, "directory");
|
|
849
|
+
if (directory === "unsafe") return null;
|
|
850
|
+
const files = directory === "ok" ? (await hookFileNames(workspaceRoot, "plan")).length : 0;
|
|
851
|
+
let entries = [];
|
|
852
|
+
if (await inspectPath(workspaceRoot, KINDS.plan.index, "file") === "ok") {
|
|
853
|
+
try {
|
|
854
|
+
entries = parseIndexEntries(await readCapped(join(workspaceRoot, KINDS.plan.index), INDEX_LIMITS.indexBytes, { whole: true }), { kind: "plan" });
|
|
855
|
+
} catch {
|
|
856
|
+
entries = [];
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (files === 0 && entries.length === 0) return null;
|
|
860
|
+
const namesIn = (section) => [...new Set(entries.filter((entry) => entry.section === section).map((entry) => entry.name))];
|
|
861
|
+
const archive = directory === "ok" && await inspectPath(workspaceRoot, ARCHIVE_PATH, "file") === "ok";
|
|
862
|
+
return { files, active: namesIn(SECTIONS.active), latest: namesIn(SECTIONS.completed).slice(0, 3), archive };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function hookFileNames(root, kind) {
|
|
866
|
+
const names = [];
|
|
867
|
+
for (const entry of await readdir(join(root, KINDS[kind].directory), { withFileTypes: true })) {
|
|
868
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
869
|
+
const name = entry.name.slice(0, -3);
|
|
870
|
+
if (isExcludedName(kind, name) || isUnsafeName(name)) continue;
|
|
871
|
+
names.push(name);
|
|
872
|
+
}
|
|
873
|
+
return names.sort(compareNames);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Returns "missing", "ok", or "unsafe" without following symlinks or opening special files.
|
|
877
|
+
async function inspectPath(root, relativePath, type) {
|
|
878
|
+
const target = join(root, relativePath);
|
|
879
|
+
try {
|
|
880
|
+
await assertNoSymlinkComponents(root, target);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (error?.code === "POTA_SYMLINK" || error?.code === "ENOTDIR") return "unsafe";
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
let stat;
|
|
886
|
+
try {
|
|
887
|
+
stat = await lstat(target);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (error?.code === "ENOENT") return "missing";
|
|
890
|
+
if (error?.code === "ENOTDIR") return "unsafe";
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
if (type === "directory") return stat.isDirectory() ? "ok" : "unsafe";
|
|
894
|
+
return stat.isFile() && stat.size <= INDEX_LIMITS.indexBytes ? "ok" : "unsafe";
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function readCapped(path, maxBytes, { whole = false } = {}) {
|
|
898
|
+
const handle = await open(path, READ_FLAGS);
|
|
899
|
+
try {
|
|
900
|
+
const stat = await handle.stat();
|
|
901
|
+
if (!stat.isFile()) throw new Error(`not a regular file: ${path}`);
|
|
902
|
+
if (whole && stat.size > maxBytes) throw new Error(`file is larger than ${maxBytes} bytes: ${path}`);
|
|
903
|
+
const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
|
|
904
|
+
let offset = 0;
|
|
905
|
+
while (offset < buffer.length) {
|
|
906
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
907
|
+
if (bytesRead === 0) break;
|
|
908
|
+
offset += bytesRead;
|
|
909
|
+
}
|
|
910
|
+
let text = buffer.subarray(0, offset).toString("utf8");
|
|
911
|
+
if (!whole && stat.size > maxBytes) {
|
|
912
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
913
|
+
text = lastNewline === -1 ? "" : text.slice(0, lastNewline + 1);
|
|
914
|
+
}
|
|
915
|
+
return text;
|
|
916
|
+
} finally {
|
|
917
|
+
await handle.close();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function atomicWrite(root, relativePath, text) {
|
|
922
|
+
const target = join(root, relativePath);
|
|
923
|
+
const temporary = join(dirname(target), `.${basename(target)}.pota-${randomUUID()}.tmp`);
|
|
924
|
+
await safeWriteFile(root, temporary, text, { encoding: "utf8", flag: "wx" });
|
|
925
|
+
try {
|
|
926
|
+
if (await inspectPath(root, relativePath, "file") === "unsafe") {
|
|
927
|
+
throw new Error(`${relativePath} is not a regular file inside the repository; index was not written`);
|
|
928
|
+
}
|
|
929
|
+
await rename(temporary, target);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
await rm(temporary, { force: true });
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function findReference(line, archive) {
|
|
937
|
+
const match = REFERENCE.exec(line);
|
|
938
|
+
if (match) {
|
|
939
|
+
const start = match.index;
|
|
940
|
+
let end = start + match[0].length;
|
|
941
|
+
if (line.slice(0, start).endsWith("](") && line[end] === ")") end += 1;
|
|
942
|
+
else if (line[start - 1] === "`" && line[end] === "`") end += 1;
|
|
943
|
+
return { kind: match[1], name: match[2], end };
|
|
944
|
+
}
|
|
945
|
+
if (!archive) return null;
|
|
946
|
+
const relative = ARCHIVE_REFERENCE.exec(line);
|
|
947
|
+
if (!relative) return null;
|
|
948
|
+
return { kind: "plan", name: relative[1], end: relative.index + relative[0].length };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function noteHeadLines(text) {
|
|
952
|
+
let head = text;
|
|
953
|
+
if (Buffer.byteLength(head) > INDEX_LIMITS.noteHeadBytes) {
|
|
954
|
+
head = Buffer.from(head).subarray(0, INDEX_LIMITS.noteHeadBytes).toString("utf8");
|
|
955
|
+
const lastNewline = head.lastIndexOf("\n");
|
|
956
|
+
head = lastNewline === -1 ? "" : head.slice(0, lastNewline + 1);
|
|
957
|
+
}
|
|
958
|
+
return head.split("\n").slice(0, INDEX_LIMITS.noteHeadLines).map(stripCarriageReturn);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function scopeParagraphLine(lines) {
|
|
962
|
+
const fences = fenceTracker();
|
|
963
|
+
let inScope = false;
|
|
964
|
+
for (const line of lines) {
|
|
965
|
+
if (fences.skip(line)) continue;
|
|
966
|
+
if (/^#{1,6} /.test(line)) {
|
|
967
|
+
if (inScope) return null;
|
|
968
|
+
inScope = /^## +Scope\s*$/i.test(line);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (inScope && line.trim()) return cleanBodyLine(line);
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function firstBodyLine(lines) {
|
|
977
|
+
const fences = fenceTracker();
|
|
978
|
+
for (const line of lines) {
|
|
979
|
+
if (fences.skip(line) || !line.trim() || /^#{1,6} /.test(line) || SUMMARY_LINE.test(line)) continue;
|
|
980
|
+
const cleaned = cleanBodyLine(line);
|
|
981
|
+
if (cleaned) return cleaned;
|
|
982
|
+
}
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function cleanBodyLine(line) {
|
|
987
|
+
return line.trim().replace(/^(?:[-*+>]\s+)+/, "").trim();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function fallbackSummary(value, source) {
|
|
991
|
+
return { summary: clip(value, INDEX_LIMITS.summary), source, raw: null, tooLong: false };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function entryLine(kind, name, summary) {
|
|
995
|
+
return `- [${name}](docs/${kind}/${name}.md): ${summary}`;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Keeps generated lines within the line limit so check and index agree on the same clipped summary.
|
|
999
|
+
// Control characters become spaces so generated lines never fail the control-character check.
|
|
1000
|
+
function clipEntrySummary(kind, name, summary) {
|
|
1001
|
+
const budget = INDEX_LIMITS.line - length(entryLine(kind, name, ""));
|
|
1002
|
+
return clip(String(summary ?? "").replace(CONTROL_CHARACTERS, " ").trim(), Math.max(1, Math.min(INDEX_LIMITS.summary, budget)));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function clip(value, limit) {
|
|
1006
|
+
const characters = Array.from(String(value ?? "").trim());
|
|
1007
|
+
if (characters.length <= limit) return characters.join("");
|
|
1008
|
+
return `${characters.slice(0, limit - 1).join("")}…`;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function length(value) {
|
|
1012
|
+
return Array.from(String(value ?? "")).length;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function isPlaceholderValue(value) {
|
|
1016
|
+
return /^<.*>$/.test(value.trim());
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function isUnsafeName(name) {
|
|
1020
|
+
return name.length === 0 || length(name) > INDEX_LIMITS.name || UNSAFE_NAME.test(name);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function isExcludedName(kind, name) {
|
|
1024
|
+
return name === "README" || (kind === "plan" && name === "ARCHIVE");
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function isBlank(line) {
|
|
1028
|
+
return stripCarriageReturn(line).trim() === "";
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function isContinuation(line) {
|
|
1032
|
+
const value = stripCarriageReturn(line);
|
|
1033
|
+
return value.trim() !== "" && /^\s/.test(value) && !TOP_LIST_LINE.test(value);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function stripCarriageReturn(line) {
|
|
1037
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Appends the carriage return of a CRLF file; `previous` supplies the style when `eol` is not given.
|
|
1041
|
+
function withEol(line, previous, eol = previous?.endsWith("\r") ? "\r\n" : "\n") {
|
|
1042
|
+
return eol === "\r\n" ? `${line}\r` : line;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function compareNames(left, right) {
|
|
1046
|
+
if (left < right) return -1;
|
|
1047
|
+
return left > right ? 1 : 0;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function issue(code, path, message) {
|
|
1051
|
+
return { code, path, message };
|
|
1052
|
+
}
|