runlist 0.0.0 → 0.94.1
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/LICENSE +21 -0
- package/README.md +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const HTML_FALLBACK_DIR = '__runlist';
|
|
5
|
+
// Exports written by dotmd-cli 0.78.0 and earlier put fallbacks under this
|
|
6
|
+
// directory, and an old export tree may still hold it. It stays reserved so a
|
|
7
|
+
// document's own path can never land inside a stale fallback directory.
|
|
8
|
+
export const LEGACY_HTML_FALLBACK_DIR = '__dotmd';
|
|
9
|
+
const RESERVED_FALLBACK_DIRS = [HTML_FALLBACK_DIR, LEGACY_HTML_FALLBACK_DIR];
|
|
10
|
+
|
|
11
|
+
function normalizeRelative(value, kind) {
|
|
12
|
+
if (typeof value !== 'string' || value.includes('\0')) {
|
|
13
|
+
throw new Error(`${kind} must be a string without NUL bytes`);
|
|
14
|
+
}
|
|
15
|
+
const normalized = path.posix.normalize(value || '.');
|
|
16
|
+
if (path.posix.isAbsolute(normalized)) throw new Error(`${kind} must be relative: ${value}`);
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isTraversal(value) {
|
|
21
|
+
return value === '..' || value.startsWith('../');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pathsConflict(a, b) {
|
|
25
|
+
const left = a.toLowerCase();
|
|
26
|
+
const right = b.toLowerCase();
|
|
27
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateHtmlPath(value) {
|
|
31
|
+
const normalized = normalizeRelative(value, 'HTML output path');
|
|
32
|
+
if (normalized === '.' || isTraversal(normalized) || normalized !== value) {
|
|
33
|
+
throw new Error(`Unsafe HTML output path: ${value}`);
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function outputPathToUrl(value) {
|
|
39
|
+
return validateHtmlPath(value).split('/').map(encodeURIComponent).join('/');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function allocateOutputIdentities(docs) {
|
|
43
|
+
const entries = [];
|
|
44
|
+
const logicalIds = new Set();
|
|
45
|
+
|
|
46
|
+
for (const doc of docs) {
|
|
47
|
+
const logicalId = normalizeRelative(doc.path, 'Document path');
|
|
48
|
+
if (logicalIds.has(logicalId)) throw new Error(`Duplicate document path: ${logicalId}`);
|
|
49
|
+
logicalIds.add(logicalId);
|
|
50
|
+
|
|
51
|
+
const root = normalizeRelative(doc.root || '.', 'Document root');
|
|
52
|
+
const relativeSource = path.posix.relative(root, logicalId);
|
|
53
|
+
if (!relativeSource || isTraversal(relativeSource) || path.posix.isAbsolute(relativeSource)) {
|
|
54
|
+
throw new Error(`Document path is not owned by its root: ${logicalId} (root: ${root})`);
|
|
55
|
+
}
|
|
56
|
+
if (!relativeSource.toLowerCase().endsWith('.md')) {
|
|
57
|
+
throw new Error(`Document path must end in .md: ${logicalId}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const preferred = validateHtmlPath(relativeSource.slice(0, -3) + '.html');
|
|
61
|
+
entries.push({
|
|
62
|
+
logicalId,
|
|
63
|
+
label: path.posix.basename(logicalId, path.posix.extname(logicalId)),
|
|
64
|
+
preferred,
|
|
65
|
+
conflict: preferred.toLowerCase() === 'index.html'
|
|
66
|
+
|| preferred.toLowerCase().startsWith('index.html/')
|
|
67
|
+
|| RESERVED_FALLBACK_DIRS.some(dir => preferred.toLowerCase().startsWith(`${dir.toLowerCase()}/`)),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
entries.sort((a, b) => a.logicalId.localeCompare(b.logicalId));
|
|
72
|
+
for (let i = 0; i < entries.length; i++) {
|
|
73
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
74
|
+
if (!pathsConflict(entries[i].preferred, entries[j].preferred)) continue;
|
|
75
|
+
entries[i].conflict = true;
|
|
76
|
+
entries[j].conflict = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const digestOwners = new Map();
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (!entry.conflict) {
|
|
83
|
+
entry.htmlPath = entry.preferred;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const digest = createHash('sha256').update(entry.logicalId).digest('hex');
|
|
87
|
+
const owner = digestOwners.get(digest);
|
|
88
|
+
if (owner && owner !== entry.logicalId) {
|
|
89
|
+
throw new Error(`Output identity hash collision: ${owner} and ${entry.logicalId}`);
|
|
90
|
+
}
|
|
91
|
+
digestOwners.set(digest, entry.logicalId);
|
|
92
|
+
entry.htmlPath = `${HTML_FALLBACK_DIR}/${digest}.html`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const finalEntries = [...entries].sort((a, b) => a.htmlPath.localeCompare(b.htmlPath));
|
|
96
|
+
for (let i = 0; i < finalEntries.length; i++) {
|
|
97
|
+
validateHtmlPath(finalEntries[i].htmlPath);
|
|
98
|
+
for (let j = i + 1; j < finalEntries.length; j++) {
|
|
99
|
+
if (pathsConflict(finalEntries[i].htmlPath, finalEntries[j].htmlPath)) {
|
|
100
|
+
throw new Error(`Conflicting HTML output paths: ${finalEntries[i].htmlPath} and ${finalEntries[j].htmlPath}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Map(entries.map(entry => [entry.logicalId, {
|
|
106
|
+
logicalId: entry.logicalId,
|
|
107
|
+
label: entry.label,
|
|
108
|
+
htmlPath: entry.htmlPath,
|
|
109
|
+
htmlUrl: outputPathToUrl(entry.htmlPath),
|
|
110
|
+
}]));
|
|
111
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { MutationConflictError } from './atomic-mutation.mjs';
|
|
5
|
+
import { escapeRegex, normalizeStringList, nowIso, resolveRefPath, toRepoPath } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
// Replace a top-level frontmatter field (its `key:` line + any indented
|
|
8
|
+
// continuation block) with `serialized`, or append it when absent. Shared by
|
|
9
|
+
// runlist mutations and the membership back-reference fixer so both paths
|
|
10
|
+
// serialize `parent_plan:` identically.
|
|
11
|
+
export function upsertFrontmatterField(fm, key, serialized) {
|
|
12
|
+
const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
|
|
13
|
+
if (re.test(fm)) return fm.replace(re, serialized);
|
|
14
|
+
return fm.replace(/\s*$/, '') + '\n' + serialized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Plan an atomic child-side `parent_plan:` update. The caller owns the evidence
|
|
18
|
+
// that authorizes the relationship (a runlist entry or body-order row); this
|
|
19
|
+
// helper owns the byte-level invariant: never clobber another parent, write a
|
|
20
|
+
// child-relative forward-slash ref, bump `updated:`, and compare-and-swap the
|
|
21
|
+
// exact child snapshot used to make the decision.
|
|
22
|
+
export function planChildParentUpdate(childAbs, hubAbs, config) {
|
|
23
|
+
const raw = readFileSync(childAbs, 'utf8');
|
|
24
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
25
|
+
if (fmRaw == null) return { wrote: false, reason: 'missing-frontmatter', raw };
|
|
26
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
27
|
+
const childDir = path.dirname(childAbs);
|
|
28
|
+
const existingRefs = normalizeStringList(fm.parent_plan);
|
|
29
|
+
if (existingRefs.length > 0) {
|
|
30
|
+
const resolved = existingRefs.map(ref => resolveRefPath(ref, childDir, config.repoRoot));
|
|
31
|
+
if (existingRefs.length === 1 && resolved[0] === hubAbs) {
|
|
32
|
+
return { wrote: false, reason: 'already-set', raw };
|
|
33
|
+
}
|
|
34
|
+
return { wrote: false, reason: 'different-parent', existing: existingRefs.join(', '), raw };
|
|
35
|
+
}
|
|
36
|
+
const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
|
|
37
|
+
return {
|
|
38
|
+
wrote: true,
|
|
39
|
+
ref,
|
|
40
|
+
raw,
|
|
41
|
+
update: {
|
|
42
|
+
path: childAbs,
|
|
43
|
+
expectedContent: raw,
|
|
44
|
+
render: current => {
|
|
45
|
+
const { frontmatter: currentFm } = extractFrontmatter(current);
|
|
46
|
+
const currentParsed = parseSimpleFrontmatter(currentFm);
|
|
47
|
+
const currentParents = normalizeStringList(currentParsed.parent_plan);
|
|
48
|
+
if (currentParents.length > 0
|
|
49
|
+
&& !(currentParents.length === 1 && resolveRefPath(currentParents[0], childDir, config.repoRoot) === hubAbs)) {
|
|
50
|
+
throw new MutationConflictError(`Child parent_plan changed while the membership mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
|
|
51
|
+
}
|
|
52
|
+
let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', `parent_plan: ${ref}`);
|
|
53
|
+
updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
|
|
54
|
+
return replaceFrontmatter(current, updatedFm);
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Lexical preflight only. Callers must also check canonical filesystem paths
|
|
4
|
+
// when authorizing real files, so symlink/junction parents cannot escape.
|
|
5
|
+
export function isPathInside(root, file, paths = path) {
|
|
6
|
+
if (!paths.isAbsolute(root) || !paths.isAbsolute(file)) return false;
|
|
7
|
+
const relative = paths.relative(root, file);
|
|
8
|
+
return relative === '' || (relative !== '..'
|
|
9
|
+
&& !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function containingRoot(file, roots, paths = path) {
|
|
13
|
+
return roots.filter(root => isPathInside(root, file, paths))
|
|
14
|
+
.sort((a, b) => paths.resolve(b).length - paths.resolve(a).length)[0] ?? null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { referenceValues } from './reference-values.mjs';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
|
+
import { asString, toRepoPath, resolveDocPath, resolveRefPath, isArchivedPath } from './util.mjs';
|
|
6
|
+
import { compareYardstick, readYardstick, renderYardstick } from './yardstick.mjs';
|
|
7
|
+
import { walkSections, findSection, findActivePhase, summarizePhases, isPhaseHeading, detectMarker } from './section.mjs';
|
|
8
|
+
import { dim, green } from './color.mjs';
|
|
9
|
+
|
|
10
|
+
const CAPS = {
|
|
11
|
+
blurb: 200,
|
|
12
|
+
currentState: 500,
|
|
13
|
+
nextStep: 300,
|
|
14
|
+
versionHistoryEntry: 200,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function countBullets(body) {
|
|
18
|
+
if (!body) return 0;
|
|
19
|
+
return body.split('\n').filter(l => /^[-*]\s+\S/.test(l)).length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function truncate(s, cap) {
|
|
23
|
+
if (!s) return '';
|
|
24
|
+
if (s.length <= cap) return s;
|
|
25
|
+
return s.slice(0, cap - 3).trimEnd() + '...';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statusSummary(counts) {
|
|
29
|
+
const order = ['shipped', 'skipped', 'in-progress', 'blocked', 'todo'];
|
|
30
|
+
const icons = { shipped: '✅', skipped: '⏭', 'in-progress': '🟡', blocked: '🚧', todo: '⬜' };
|
|
31
|
+
return order.filter(k => counts[k]).map(k => `${counts[k]}${icons[k]}`).join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `docDir` is the directory of the doc whose frontmatter we're reading.
|
|
35
|
+
// Pre-fix: this used `resolveDocPath` which only tries repo-root and docsRoots-
|
|
36
|
+
// relative, NOT doc-relative — so a bare basename like `sibling-plan.md` written
|
|
37
|
+
// in `docs/plans/foo.md`'s `related_plans:` always showed `(missing)`, even
|
|
38
|
+
// though graph/validate resolve the same ref fine via `resolveRefPath`. Now
|
|
39
|
+
// matches graph's resolver semantics: doc-relative first, then repo-relative;
|
|
40
|
+
// docsRoots-relative is kept as a final fallback for legacy refs.
|
|
41
|
+
function readRelatedSummary(rawList, config, docDir) {
|
|
42
|
+
const list = referenceValues(rawList).paths;
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const ref of list) {
|
|
45
|
+
if (!ref) continue;
|
|
46
|
+
const refStr = String(ref).trim();
|
|
47
|
+
if (!refStr) continue;
|
|
48
|
+
let abs = null;
|
|
49
|
+
try {
|
|
50
|
+
abs = resolveRefPath(refStr, docDir, config.repoRoot)
|
|
51
|
+
?? resolveDocPath(refStr, config);
|
|
52
|
+
} catch { abs = null; }
|
|
53
|
+
if (!abs || !existsSync(abs)) {
|
|
54
|
+
out.push({ ref: refStr, status: null, missing: true });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(abs, 'utf8');
|
|
59
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
60
|
+
const fm = parseSimpleFrontmatter(frontmatter);
|
|
61
|
+
out.push({
|
|
62
|
+
ref: toRepoPath(abs, config.repoRoot),
|
|
63
|
+
status: asString(fm.status) ?? null,
|
|
64
|
+
missing: false,
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
out.push({ ref: refStr, status: null, missing: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Build the structured card object. Pure: no IO beyond what's passed in.
|
|
74
|
+
export function buildCard(filePath, raw, config) {
|
|
75
|
+
const { frontmatter: fmRaw, body, bodyLineOffset } = extractFrontmatter(raw);
|
|
76
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
77
|
+
const sections = walkSections(body);
|
|
78
|
+
|
|
79
|
+
// Title + blurb
|
|
80
|
+
const titleSection = sections.find(s => s.level === 1) ?? null;
|
|
81
|
+
const title = titleSection ? titleSection.heading : path.basename(filePath, '.md');
|
|
82
|
+
// Blurb = first blockquote line(s) after the H1, or first paragraph
|
|
83
|
+
const lines = body.split('\n');
|
|
84
|
+
let blurb = '';
|
|
85
|
+
if (titleSection) {
|
|
86
|
+
for (let i = titleSection.bodyLineStart - 1; i < lines.length; i++) {
|
|
87
|
+
const ln = lines[i];
|
|
88
|
+
if (!ln.trim()) {
|
|
89
|
+
if (blurb) break;
|
|
90
|
+
else continue;
|
|
91
|
+
}
|
|
92
|
+
if (ln.startsWith('## ') || ln.startsWith('# ')) break;
|
|
93
|
+
blurb += (blurb ? '\n' : '') + ln;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
blurb = truncate(blurb.replace(/^> ?/gm, '').trim(), CAPS.blurb);
|
|
97
|
+
|
|
98
|
+
// Frontmatter pointers. The simple parser doesn't handle YAML multiline blocks
|
|
99
|
+
// (`>`, `|`); when only the marker char survives, treat as empty.
|
|
100
|
+
const cleanInline = (v) => {
|
|
101
|
+
const s = asString(v);
|
|
102
|
+
if (!s) return '';
|
|
103
|
+
if (/^[>|][+-]?$/.test(s.trim())) return '';
|
|
104
|
+
return s;
|
|
105
|
+
};
|
|
106
|
+
const status = asString(fm.status) ?? null;
|
|
107
|
+
const updated = asString(fm.updated) ?? null;
|
|
108
|
+
const currentState = truncate(cleanInline(fm.current_state), CAPS.currentState);
|
|
109
|
+
const nextStep = truncate(cleanInline(fm.next_step), CAPS.nextStep);
|
|
110
|
+
const delivers = typeof fm.delivers === 'string' ? fm.delivers.trim() : null;
|
|
111
|
+
const goal = asString(fm.type) === 'plan' ? readYardstick(config) : { enabled: false };
|
|
112
|
+
const yardstick = goal.enabled ? compareYardstick(fm, config, goal,
|
|
113
|
+
isArchivedPath(toRepoPath(filePath, config.repoRoot), config) || config.lifecycle?.archiveStatuses?.has(status)) : null;
|
|
114
|
+
|
|
115
|
+
// Related plans (compressed: slug + status only — show all, don't cap count).
|
|
116
|
+
// docDir lets the resolver try same-dir basenames first — graph/validate do this
|
|
117
|
+
// already; pickup-card now matches.
|
|
118
|
+
const docDir = path.dirname(filePath);
|
|
119
|
+
const related = [
|
|
120
|
+
...readRelatedSummary(fm.parent_plan, config, docDir).map(r => ({ ...r, kind: 'parent' })),
|
|
121
|
+
...readRelatedSummary(fm.related_plans, config, docDir).map(r => ({ ...r, kind: 'related' })),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
// Phases summary + active phase (pointer only, no body)
|
|
125
|
+
const phaseSummary = summarizePhases(sections);
|
|
126
|
+
const toFileLine = line => line + bodyLineOffset;
|
|
127
|
+
const phaseSummaryForCard = {
|
|
128
|
+
...phaseSummary,
|
|
129
|
+
phases: phaseSummary.phases.map(phase => ({
|
|
130
|
+
...phase,
|
|
131
|
+
lineStart: toFileLine(phase.lineStart),
|
|
132
|
+
lineEnd: toFileLine(phase.lineEnd),
|
|
133
|
+
bodyLineStart: toFileLine(phase.bodyLineStart),
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
const activePhase = findActivePhase(sections);
|
|
137
|
+
let activePhasePointer = null;
|
|
138
|
+
if (activePhase) {
|
|
139
|
+
activePhasePointer = {
|
|
140
|
+
heading: activePhase.heading,
|
|
141
|
+
lineStart: toFileLine(activePhase.lineStart),
|
|
142
|
+
lineEnd: toFileLine(activePhase.lineEnd),
|
|
143
|
+
marker: detectMarker(activePhase.heading),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Old-plan fallback: no ## Phases section → point to last H2 as "active content"
|
|
148
|
+
let fallbackPointer = null;
|
|
149
|
+
if (!findSection(sections, 'Phases') && !activePhase) {
|
|
150
|
+
const h2s = sections.filter(s => s.level === 2);
|
|
151
|
+
const lastH2 = h2s[h2s.length - 1] ?? null;
|
|
152
|
+
if (lastH2) {
|
|
153
|
+
fallbackPointer = {
|
|
154
|
+
heading: lastH2.heading,
|
|
155
|
+
lineStart: toFileLine(lastH2.lineStart),
|
|
156
|
+
lineEnd: toFileLine(lastH2.lineEnd),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Open Questions — count + pointer only, no body
|
|
162
|
+
const oqSection = findSection(sections, 'Open Questions') ?? findSection(sections, 'Open questions');
|
|
163
|
+
let openQuestions = null;
|
|
164
|
+
if (oqSection && oqSection.body.trim()) {
|
|
165
|
+
openQuestions = {
|
|
166
|
+
heading: oqSection.heading,
|
|
167
|
+
lineStart: toFileLine(oqSection.lineStart),
|
|
168
|
+
lineEnd: toFileLine(oqSection.lineEnd),
|
|
169
|
+
count: countBullets(oqSection.body),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Last Version History entry (newest = first bullet under the heading)
|
|
174
|
+
const vhSection = findSection(sections, 'Version History');
|
|
175
|
+
let lastVersion = null;
|
|
176
|
+
if (vhSection && vhSection.body.trim()) {
|
|
177
|
+
const firstBullet = vhSection.body.split('\n').find(l => /^[-*]\s/.test(l));
|
|
178
|
+
if (firstBullet) lastVersion = truncate(firstBullet.replace(/^[-*]\s+/, ''), CAPS.versionHistoryEntry);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Outline = all H2 headings with line ranges; phase summary inline if Phases present
|
|
182
|
+
const outline = sections.filter(s => s.level === 2).map(s => {
|
|
183
|
+
const isPhases = /^phases?$/i.test(s.heading.replace(/[^\w\s]+$/, '').trim());
|
|
184
|
+
const lineStart = toFileLine(s.lineStart);
|
|
185
|
+
const lineEnd = toFileLine(s.lineEnd);
|
|
186
|
+
let suffix = `lines ${lineStart}-${lineEnd}`;
|
|
187
|
+
if (isPhases && phaseSummary.total > 0) {
|
|
188
|
+
suffix = `(${phaseSummary.total}: ${statusSummary(phaseSummary.counts)}) ${suffix}`;
|
|
189
|
+
}
|
|
190
|
+
return { heading: s.heading, lineStart, lineEnd, suffix };
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
path: toRepoPath(filePath, config.repoRoot),
|
|
195
|
+
title,
|
|
196
|
+
status,
|
|
197
|
+
updated,
|
|
198
|
+
blurb,
|
|
199
|
+
currentState,
|
|
200
|
+
nextStep,
|
|
201
|
+
delivers,
|
|
202
|
+
yardstick,
|
|
203
|
+
related,
|
|
204
|
+
phases: phaseSummaryForCard,
|
|
205
|
+
activePhase: activePhasePointer,
|
|
206
|
+
fallbackContent: fallbackPointer,
|
|
207
|
+
openQuestions,
|
|
208
|
+
lastVersion,
|
|
209
|
+
outline,
|
|
210
|
+
bodyBytes: body.length,
|
|
211
|
+
bodyLineOffset,
|
|
212
|
+
coordinateSpace: 'file',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Render the card to human-format string.
|
|
217
|
+
export function renderCard(card) {
|
|
218
|
+
const lines = [];
|
|
219
|
+
lines.push(`[runlist] in-session: ${card.path} — close with: runlist set <status> ${card.path}`);
|
|
220
|
+
lines.push('---');
|
|
221
|
+
lines.push(`# ${card.title}`);
|
|
222
|
+
const meta = [card.status, card.updated && `updated ${card.updated}`].filter(Boolean).join(' · ');
|
|
223
|
+
if (meta) lines.push(dim(meta));
|
|
224
|
+
if (card.blurb) {
|
|
225
|
+
lines.push('');
|
|
226
|
+
lines.push(`> ${card.blurb.replace(/\n/g, '\n> ')}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (card.currentState || card.nextStep) {
|
|
230
|
+
lines.push('');
|
|
231
|
+
if (card.currentState) lines.push(`${green('Current:')} ${card.currentState}`);
|
|
232
|
+
if (card.nextStep) lines.push(`${green('Next:')} ${card.nextStep}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (card.yardstick) {
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push(renderYardstick(card.yardstick, { compact: true }).trimEnd());
|
|
238
|
+
} else if (card.delivers) {
|
|
239
|
+
lines.push('');
|
|
240
|
+
lines.push(`This plan delivers: ${truncate(card.delivers, 400)}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (card.related.length > 0) {
|
|
244
|
+
lines.push('');
|
|
245
|
+
lines.push(green('Related:'));
|
|
246
|
+
for (const r of card.related) {
|
|
247
|
+
const tag = r.kind === 'parent' ? '↑ parent' : '↔';
|
|
248
|
+
if (r.missing) {
|
|
249
|
+
lines.push(` ${tag} ${r.ref} ${dim('(missing)')}`);
|
|
250
|
+
} else {
|
|
251
|
+
lines.push(` ${tag} ${r.ref}${r.status ? ` ${dim(`(${r.status})`)}` : ''}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (card.activePhase) {
|
|
257
|
+
lines.push('');
|
|
258
|
+
lines.push(`${green('Active phase:')} ${card.activePhase.heading} ${dim(`(lines ${card.activePhase.lineStart}-${card.activePhase.lineEnd})`)}`);
|
|
259
|
+
} else if (card.fallbackContent) {
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push(`${green('Active section:')} ${card.fallbackContent.heading} ${dim(`(lines ${card.fallbackContent.lineStart}-${card.fallbackContent.lineEnd})`)}`);
|
|
262
|
+
} else if (card.phases.total > 0) {
|
|
263
|
+
lines.push('');
|
|
264
|
+
lines.push(dim(`All ${card.phases.total} phases shipped/skipped — ready for archive?`));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (card.openQuestions) {
|
|
268
|
+
lines.push(`${green('Open Questions:')} ${card.openQuestions.count} ${dim(`(lines ${card.openQuestions.lineStart}-${card.openQuestions.lineEnd})`)}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (card.lastVersion) {
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push(`${green('Last change:')} ${card.lastVersion}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (card.outline.length > 0) {
|
|
277
|
+
lines.push('');
|
|
278
|
+
lines.push(green('Outline:'));
|
|
279
|
+
for (const o of card.outline) {
|
|
280
|
+
lines.push(` ## ${o.heading} ${dim(o.suffix)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
lines.push('');
|
|
285
|
+
lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`runlist set in-session ${card.path} --full\` for everything, or Read the file with offset/limit to target a section by line number.`));
|
|
286
|
+
return lines.join('\n') + '\n';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function formatBytes(n) {
|
|
290
|
+
if (n < 1024) return `${n}B`;
|
|
291
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
292
|
+
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
293
|
+
}
|