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
package/src/summary.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
5
|
+
import { resolveDocArg } from './index.mjs';
|
|
6
|
+
import { summarizeDocBody } from './ai.mjs';
|
|
7
|
+
import { bold, dim } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
export function runSummary(argv, config) {
|
|
10
|
+
const positional = [];
|
|
11
|
+
let model;
|
|
12
|
+
let maxTokens;
|
|
13
|
+
let json = false;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
|
|
17
|
+
if (argv[i] === '--max-tokens' && argv[i + 1]) { maxTokens = Number.parseInt(argv[++i], 10); continue; }
|
|
18
|
+
if (argv[i] === '--config') { i++; continue; }
|
|
19
|
+
if (argv[i] === '--json') { json = true; continue; }
|
|
20
|
+
if (argv[i].startsWith('-')) continue;
|
|
21
|
+
positional.push(argv[i]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const input = positional[0];
|
|
25
|
+
if (!input) { die('Usage: runlist summary <file> [--model <name>] [--json]'); }
|
|
26
|
+
|
|
27
|
+
const filePath = resolveDocArg(input, config);
|
|
28
|
+
|
|
29
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
30
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
31
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
32
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
33
|
+
const title = asString(parsed.title) ?? path.basename(filePath, '.md');
|
|
34
|
+
const status = asString(parsed.status) ?? 'unknown';
|
|
35
|
+
|
|
36
|
+
const meta = { title, status, path: repoPath };
|
|
37
|
+
const opts = {};
|
|
38
|
+
if (model) opts.model = model;
|
|
39
|
+
if (maxTokens) opts.maxTokens = maxTokens;
|
|
40
|
+
const previewSkipped = Boolean(config._execution?.suppressSideEffects);
|
|
41
|
+
|
|
42
|
+
let summary;
|
|
43
|
+
try {
|
|
44
|
+
summary = previewSkipped
|
|
45
|
+
? null
|
|
46
|
+
: config.hooks.summarizeDoc
|
|
47
|
+
? config.hooks.summarizeDoc(body, meta)
|
|
48
|
+
: summarizeDocBody(body, meta, opts);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
warn(`Hook 'summarizeDoc' threw: ${err.message}`);
|
|
51
|
+
summary = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (json) {
|
|
55
|
+
process.stdout.write(JSON.stringify({
|
|
56
|
+
path: repoPath,
|
|
57
|
+
title,
|
|
58
|
+
status,
|
|
59
|
+
summary: summary ?? null,
|
|
60
|
+
summaryStatus: previewSkipped ? 'skipped-preview' : summary ? 'generated' : 'unavailable',
|
|
61
|
+
}, null, 2) + '\n');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.stdout.write(`${bold(title)} ${dim(`(${status})`)}\n`);
|
|
66
|
+
process.stdout.write(`${dim(repoPath)}\n\n`);
|
|
67
|
+
if (previewSkipped) {
|
|
68
|
+
process.stdout.write(dim('[preview] Summary generation skipped; models and custom summarizeDoc hooks are not invoked.') + '\n');
|
|
69
|
+
} else if (summary) {
|
|
70
|
+
process.stdout.write(`${summary}\n`);
|
|
71
|
+
} else {
|
|
72
|
+
process.stdout.write(dim('Summary unavailable (no model available; `runlist model` says why).') + '\n');
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/surfaces.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `runlist surfaces` — print the configured surface taxonomy.
|
|
2
|
+
//
|
|
3
|
+
// The surface taxonomy (`config.taxonomy.surfaces`) gates which `surfaces:`
|
|
4
|
+
// values the validator accepts. Before this command existed the only way to
|
|
5
|
+
// discover the valid set was to grep sibling plans or open the config file —
|
|
6
|
+
// which sent agents into a retry loop of "guess a surface, run check, get
|
|
7
|
+
// flagged, guess again." See issue #12 trap 1.
|
|
8
|
+
import { dim } from './color.mjs';
|
|
9
|
+
|
|
10
|
+
export function runSurfaces(argv, config) {
|
|
11
|
+
const json = argv.includes('--json');
|
|
12
|
+
// Read from raw user config (preserves declaration order) — `config.taxonomy`
|
|
13
|
+
// isn't exposed on the resolved object; only the derived `validSurfaces` Set is.
|
|
14
|
+
const surfaces = config.raw?.taxonomy?.surfaces ?? null;
|
|
15
|
+
|
|
16
|
+
if (json) {
|
|
17
|
+
process.stdout.write(JSON.stringify({ surfaces: surfaces ?? [] }, null, 2) + '\n');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!surfaces || surfaces.length === 0) {
|
|
22
|
+
process.stdout.write(dim('No surface taxonomy configured. Any surface value is accepted.\n'));
|
|
23
|
+
process.stdout.write(dim('To restrict, set `taxonomy.surfaces` in runlist.config.mjs.\n'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const s of surfaces) process.stdout.write(s + '\n');
|
|
28
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
|
+
import { die, isArchivedPath, toRepoPath } from './util.mjs';
|
|
5
|
+
import { cyan, dim, green, red, yellow } from './color.mjs';
|
|
6
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
8
|
+
import {
|
|
9
|
+
MARKER_CLOSE,
|
|
10
|
+
MARKER_OPEN,
|
|
11
|
+
applyStatusCase,
|
|
12
|
+
isHubDoc,
|
|
13
|
+
readPositionalToken,
|
|
14
|
+
scanHubStatusRows,
|
|
15
|
+
} from './hub.mjs';
|
|
16
|
+
|
|
17
|
+
// Hub status drift: a hub rows its children in a table and prints each child's
|
|
18
|
+
// status by hand. Nothing keeps that word honest — the child goes `archived`,
|
|
19
|
+
// the hub still says `active`, and every later reader plans against a status
|
|
20
|
+
// that stopped being true.
|
|
21
|
+
//
|
|
22
|
+
// runlist already reads those rows to compute next-pickup, so the guard is
|
|
23
|
+
// standing next to the data it needs. Everything it needs is already owned:
|
|
24
|
+
// `config.types[<type>].statuses` for the vocabulary (type-aware, so a `doc`
|
|
25
|
+
// rowed in a plan hub is judged by the doc vocabulary), the strict Markdown
|
|
26
|
+
// body-link resolver, the index for the child's real status, and
|
|
27
|
+
// archive-dir-outranks-frontmatter for archived children.
|
|
28
|
+
//
|
|
29
|
+
// The three-way split below is the part that is easy to get wrong. A row with
|
|
30
|
+
// no readable status token is NOT automatically a finding:
|
|
31
|
+
//
|
|
32
|
+
// pointer — the table has no status column at all. Legitimate; many hubs
|
|
33
|
+
// row a child just to say "related". SILENT.
|
|
34
|
+
// unreadable — the table HAS a `Status`/`State` column but no status word can
|
|
35
|
+
// be read from the cell. WARNING: the row opts out of the
|
|
36
|
+
// invariant invisibly, and that is exactly where real drift hides.
|
|
37
|
+
// manageable — a token was found (positionally or in a marker). Compare it.
|
|
38
|
+
//
|
|
39
|
+
// Collapsing those (treating every unmatched row as a finding) floods perfectly
|
|
40
|
+
// correct hubs with false positives.
|
|
41
|
+
|
|
42
|
+
// Warning when inferred, error when marked — no config knob. `runlist check`
|
|
43
|
+
// exits 0 on warnings and 1 on errors, and that asymmetry is the whole point: a
|
|
44
|
+
// positional match is runlist INFERRING from prose, so it nudges; a marked span is
|
|
45
|
+
// the author declaring "runlist owns this word", so drifting it fails the check.
|
|
46
|
+
// No repo that never asked for this feature starts failing builds over an
|
|
47
|
+
// inference.
|
|
48
|
+
const DRIFT_KIND = 'hub-status-drift';
|
|
49
|
+
const UNREADABLE_KIND = 'hub-status-unreadable';
|
|
50
|
+
|
|
51
|
+
// Mirror `isValidStatus` in validate.mjs: a doc's own type owns its vocabulary,
|
|
52
|
+
// falling back to the root set and then the global union for untyped docs.
|
|
53
|
+
function statusVocabulary(doc, config) {
|
|
54
|
+
if (doc.type) {
|
|
55
|
+
const typeSet = config.typeStatuses?.get(doc.type);
|
|
56
|
+
if (typeSet?.size) return typeSet;
|
|
57
|
+
}
|
|
58
|
+
return config.rootValidStatuses?.get(doc.root) ?? config.validStatuses ?? new Set();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A doc physically living under the archive dir is archived, whatever its
|
|
62
|
+
// frontmatter says — the same precedence the rest of runlist applies. (The
|
|
63
|
+
// frontmatter itself is already an error from `validateDoc`; the hub row should
|
|
64
|
+
// still be told the truth rather than agreeing with the stale word.)
|
|
65
|
+
function effectiveStatus(doc, config) {
|
|
66
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
67
|
+
if (isArchivedPath(doc.path, config) && !archiveStatuses.has(doc.status)) {
|
|
68
|
+
return [...archiveStatuses][0] ?? 'archived';
|
|
69
|
+
}
|
|
70
|
+
return doc.status ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function truncate(text, max = 60) {
|
|
74
|
+
const squeezed = text.trim().replace(/\s+/g, ' ');
|
|
75
|
+
return squeezed.length > max ? `${squeezed.slice(0, max - 1)}…` : squeezed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read every hub's rows and classify each one. `hubPaths` narrows the sweep to
|
|
79
|
+
// named hubs; when given, quiet/terminal hubs are included too (the user asked
|
|
80
|
+
// for that hub by name, so the noise-control rule doesn't apply).
|
|
81
|
+
export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
82
|
+
const docByPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
83
|
+
// The filesystem resolver is case-fold-aware, so on a case-folding filesystem a row
|
|
84
|
+
// linking `BILLING-A.md` resolves to the file that is indexed as
|
|
85
|
+
// `billing-a.md` — a path string the exact map can't answer. Fold as a
|
|
86
|
+
// fallback, and only when the fold is unambiguous: on a case-SENSITIVE
|
|
87
|
+
// filesystem those really are two different docs.
|
|
88
|
+
const docByFoldedPath = new Map();
|
|
89
|
+
for (const doc of docs) {
|
|
90
|
+
const key = doc.path.toLowerCase();
|
|
91
|
+
docByFoldedPath.set(key, docByFoldedPath.has(key) ? null : doc);
|
|
92
|
+
}
|
|
93
|
+
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
94
|
+
// type stays loud for another that declared the same name.
|
|
95
|
+
const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
|
|
96
|
+
?? config.lifecycle?.terminalStatuses?.has(d.status))
|
|
97
|
+
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
98
|
+
const out = [];
|
|
99
|
+
|
|
100
|
+
for (const hub of docs) {
|
|
101
|
+
if (!isHubDoc(hub)) continue;
|
|
102
|
+
if (hubPaths) { if (!hubPaths.has(hub.path)) continue; }
|
|
103
|
+
else if (quiet(hub)) continue;
|
|
104
|
+
|
|
105
|
+
let raw;
|
|
106
|
+
try { raw = readFileSync(path.join(config.repoRoot, hub.path), 'utf8'); } catch { continue; }
|
|
107
|
+
const { body, bodyLineOffset } = extractFrontmatter(raw);
|
|
108
|
+
const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
109
|
+
|
|
110
|
+
const rows = [];
|
|
111
|
+
for (const row of scanHubStatusRows(body)) {
|
|
112
|
+
const href = row.ref.replace(/#.*$/, '');
|
|
113
|
+
const resolution = resolveBodyLinkTarget(href, hubDir, config.repoRoot);
|
|
114
|
+
// A row whose link is broken is already a body-link finding. Reporting it
|
|
115
|
+
// again as unreadable status would double-report one problem.
|
|
116
|
+
if (!resolution.ok) continue;
|
|
117
|
+
const repoPath = toRepoPath(resolution.path, config.repoRoot);
|
|
118
|
+
const target = docByPath.get(repoPath) ?? docByFoldedPath.get(repoPath.toLowerCase()) ?? null;
|
|
119
|
+
if (!target || target.path === hub.path) continue;
|
|
120
|
+
const actual = effectiveStatus(target, config);
|
|
121
|
+
if (!actual) continue; // missing `status:` is the child's own error
|
|
122
|
+
|
|
123
|
+
const vocabulary = statusVocabulary(target, config);
|
|
124
|
+
const span = row.marked
|
|
125
|
+
?? readPositionalToken(row.statusCell, word => vocabulary.has(word.toLowerCase()));
|
|
126
|
+
const base = {
|
|
127
|
+
lineIndex: row.lineIndex,
|
|
128
|
+
line: bodyLineOffset + row.lineIndex + 1,
|
|
129
|
+
target: target.path,
|
|
130
|
+
actual,
|
|
131
|
+
};
|
|
132
|
+
if (!span) {
|
|
133
|
+
// No status column → pointer row → silent. Status column → the row opts
|
|
134
|
+
// out of the invariant invisibly, which is a finding.
|
|
135
|
+
if (row.hasStatusColumn) {
|
|
136
|
+
rows.push({ ...base, state: 'unreadable', marked: false, span: null, printed: null,
|
|
137
|
+
cell: row.statusCell ? truncate(row.statusCell.raw) : '' });
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
rows.push({
|
|
142
|
+
...base,
|
|
143
|
+
state: span.text.toLowerCase() === String(actual).toLowerCase() ? 'ok' : 'drift',
|
|
144
|
+
marked: Boolean(row.marked),
|
|
145
|
+
span,
|
|
146
|
+
printed: span.text,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (rows.length) out.push({ hub, rows, bodyLineOffset });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The check-pipeline pass. Returns index-level warnings/errors; `src/index.mjs`
|
|
155
|
+
// pushes them and attaches them to the owning hub doc.
|
|
156
|
+
export function checkHubStatusDrift(docs, config) {
|
|
157
|
+
const warnings = [];
|
|
158
|
+
const errors = [];
|
|
159
|
+
for (const { hub, rows } of collectHubStatusRows(docs, config)) {
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
if (row.state === 'drift') {
|
|
162
|
+
const entry = {
|
|
163
|
+
path: hub.path,
|
|
164
|
+
level: row.marked ? 'error' : 'warning',
|
|
165
|
+
message: row.marked
|
|
166
|
+
? `line ${row.line} rows \`${row.target}\` as \`${row.printed}\` inside a \`${MARKER_OPEN}…${MARKER_CLOSE}\` marker, but that doc's status is \`${row.actual}\`. The marker means runlist owns that word — run \`runlist sync-status\` to rewrite it, or change the doc's status.`
|
|
167
|
+
: `line ${row.line} rows \`${row.target}\` as \`${row.printed}\`, but that doc's status is \`${row.actual}\`. Run \`runlist sync-status\` to rewrite the row, or change the doc's status.`,
|
|
168
|
+
meta: { kind: DRIFT_KIND, target: row.target, printed: row.printed, actual: row.actual, line: row.line, marked: row.marked },
|
|
169
|
+
};
|
|
170
|
+
(row.marked ? errors : warnings).push(entry);
|
|
171
|
+
} else if (row.state === 'unreadable') {
|
|
172
|
+
warnings.push({
|
|
173
|
+
path: hub.path,
|
|
174
|
+
level: 'warning',
|
|
175
|
+
message: `line ${row.line} rows \`${row.target}\` under a status column, but no status word could be read from the cell (\`${row.cell}\`). Lead the cell with the status, or wrap the status in \`${MARKER_OPEN}…${MARKER_CLOSE}\`, so drift in this row can be caught.`,
|
|
176
|
+
meta: { kind: UNREADABLE_KIND, target: row.target, line: row.line },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { warnings, errors };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Rewrite drifted status words, and (with `adopt`) wrap managed positional
|
|
185
|
+
// tokens in markers. `adopt` is separate because wrapping a word in markers is a
|
|
186
|
+
// content edit to prose the user wrote: `check --fix` / `doctor` rewrite status
|
|
187
|
+
// TOKENS, marked or positional; only `--adopt` ADDS markers.
|
|
188
|
+
export function syncHubStatuses(config, { docs, dryRun = false, adopt = false, hubPaths = null, quiet = false } = {}) {
|
|
189
|
+
const hubs = collectHubStatusRows(docs, config, { hubPaths });
|
|
190
|
+
if (hubs.length) {
|
|
191
|
+
authorizeManagedSweep(hubs.map(h => path.join(config.repoRoot, h.hub.path)), config, { kind: 'Hub status sync source' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
195
|
+
const result = { fixed: 0, adopted: 0, unreadable: 0, hubs: [], skippedLineStart: 0 };
|
|
196
|
+
|
|
197
|
+
for (const { hub, bodyLineOffset, rows } of hubs) {
|
|
198
|
+
const absPath = path.join(config.repoRoot, hub.path);
|
|
199
|
+
const raw = normalizeEol(readFileSync(absPath, 'utf8'));
|
|
200
|
+
const lines = raw.split('\n');
|
|
201
|
+
const applied = [];
|
|
202
|
+
let unreadable = 0;
|
|
203
|
+
|
|
204
|
+
for (const row of rows) {
|
|
205
|
+
if (row.state === 'unreadable') { unreadable++; continue; }
|
|
206
|
+
const drifted = row.state === 'drift';
|
|
207
|
+
let wrap = adopt && !row.marked;
|
|
208
|
+
// A marker must never begin a line: `reference-planner.mjs` returns any
|
|
209
|
+
// line starting with `<!--` unmodified (CommonMark HTML-block rule), so a
|
|
210
|
+
// link sharing that line would stop being rewritten by moves. A table row
|
|
211
|
+
// always starts with `|`, so this cannot bite in the intended use — the
|
|
212
|
+
// guard is here so a "markers on their own line" idea fails loudly.
|
|
213
|
+
if (wrap && !lines[bodyLineOffset + row.lineIndex].slice(0, row.span.start).trim()) {
|
|
214
|
+
wrap = false;
|
|
215
|
+
result.skippedLineStart++;
|
|
216
|
+
}
|
|
217
|
+
if (!drifted && !wrap) continue;
|
|
218
|
+
|
|
219
|
+
const replacement = drifted ? applyStatusCase(row.printed, row.actual) : row.printed;
|
|
220
|
+
const lineIdx = bodyLineOffset + row.lineIndex;
|
|
221
|
+
const line = lines[lineIdx];
|
|
222
|
+
lines[lineIdx] = line.slice(0, row.span.start)
|
|
223
|
+
+ (wrap ? MARKER_OPEN : '') + replacement + (wrap ? MARKER_CLOSE : '')
|
|
224
|
+
+ line.slice(row.span.end);
|
|
225
|
+
applied.push({ line: row.line, target: row.target, from: row.printed, to: replacement, drifted, wrapped: wrap, marked: row.marked });
|
|
226
|
+
if (drifted) result.fixed++;
|
|
227
|
+
if (wrap) result.adopted++;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
result.unreadable += unreadable;
|
|
231
|
+
if (applied.length === 0) {
|
|
232
|
+
if (unreadable > 0) result.hubs.push({ path: hub.path, changes: [], unreadable });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!dryRun) writeFileSync(absPath, lines.join('\n'), 'utf8');
|
|
236
|
+
result.hubs.push({ path: hub.path, changes: applied, unreadable });
|
|
237
|
+
|
|
238
|
+
if (!quiet) {
|
|
239
|
+
process.stdout.write(`${prefix}${green('Synced')}: ${hub.path} (${applied.length} row${applied.length === 1 ? '' : 's'})\n`);
|
|
240
|
+
for (const change of applied) {
|
|
241
|
+
const what = change.drifted
|
|
242
|
+
? `${red(change.from)} → ${green(change.to)}`
|
|
243
|
+
: `${dim('marked')} ${cyan(change.to)}`;
|
|
244
|
+
const wrapNote = change.wrapped && change.drifted ? dim(' + marker') : '';
|
|
245
|
+
process.stdout.write(`${prefix} ${dim(`line ${change.line}`)} ${what}${wrapNote} ${dim(`(${change.target})`)}\n`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!quiet) {
|
|
251
|
+
if (result.fixed > 0 || result.adopted > 0) {
|
|
252
|
+
const parts = [];
|
|
253
|
+
if (result.fixed) parts.push(`${result.fixed} row${result.fixed === 1 ? '' : 's'} rewritten`);
|
|
254
|
+
if (result.adopted) parts.push(`${result.adopted} marker${result.adopted === 1 ? '' : 's'} added`);
|
|
255
|
+
process.stdout.write(`\n${prefix}${parts.join(', ')}.\n`);
|
|
256
|
+
}
|
|
257
|
+
if (result.unreadable > 0) {
|
|
258
|
+
process.stdout.write(yellow(`${result.unreadable} row(s) sit under a status column with no readable status word`)
|
|
259
|
+
+ ' — lead the cell with the status, or wrap it in '
|
|
260
|
+
+ `${MARKER_OPEN}…${MARKER_CLOSE}. Run \`runlist check\` to list them.\n`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Resolve `runlist sync-status <hub...>` arguments against the index. Matching by
|
|
267
|
+
// path, path+`.md`, or basename slug — the same handles every other verb takes —
|
|
268
|
+
// with a hub-aware miss message (a plain plan named here is a mistake worth
|
|
269
|
+
// naming, not a silent no-op).
|
|
270
|
+
function resolveHubArgs(args, docs) {
|
|
271
|
+
const paths = new Set();
|
|
272
|
+
for (const arg of args) {
|
|
273
|
+
const slug = arg.replace(/\.md$/, '');
|
|
274
|
+
const matches = docs.filter(doc =>
|
|
275
|
+
doc.path === arg || doc.path === `${slug}.md`
|
|
276
|
+
|| path.basename(doc.path, '.md') === path.basename(slug));
|
|
277
|
+
if (matches.length === 0) die(`No doc matches "${arg}".`);
|
|
278
|
+
if (matches.length > 1) {
|
|
279
|
+
die(`Multiple docs match "${arg}":\n${matches.map(m => ` ${m.path}`).join('\n')}`);
|
|
280
|
+
}
|
|
281
|
+
const [match] = matches;
|
|
282
|
+
if (!isHubDoc(match)) {
|
|
283
|
+
die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
|
|
284
|
+
+ 'Run `runlist sync-status` with no argument to sweep every hub.');
|
|
285
|
+
}
|
|
286
|
+
paths.add(match.path);
|
|
287
|
+
}
|
|
288
|
+
return paths;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function runSyncStatus(argv, config, opts = {}) {
|
|
292
|
+
// Dynamic so this module stays importable FROM index.mjs (which needs
|
|
293
|
+
// `checkHubStatusDrift`) without closing an import cycle.
|
|
294
|
+
const { buildIndex } = await import('./index.mjs');
|
|
295
|
+
|
|
296
|
+
const adopt = argv.includes('--adopt');
|
|
297
|
+
const json = argv.includes('--json');
|
|
298
|
+
const hubArgs = argv.filter(arg => !arg.startsWith('-'));
|
|
299
|
+
const docs = buildIndex(config).docs;
|
|
300
|
+
const hubPaths = hubArgs.length ? resolveHubArgs(hubArgs, docs) : null;
|
|
301
|
+
|
|
302
|
+
const result = syncHubStatuses(config, { docs, dryRun: opts.dryRun, adopt, hubPaths, quiet: json });
|
|
303
|
+
if (json) {
|
|
304
|
+
process.stdout.write(`${JSON.stringify({ dryRun: Boolean(opts.dryRun), adopt, ...result }, null, 2)}\n`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (result.fixed === 0 && result.adopted === 0 && result.unreadable === 0) {
|
|
308
|
+
process.stdout.write(green('Hub status rows are in sync.') + '\n');
|
|
309
|
+
}
|
|
310
|
+
if (adopt && result.skippedLineStart > 0) {
|
|
311
|
+
process.stdout.write(dim(`${result.skippedLineStart} token(s) left unmarked: a marker may never begin a line.\n`));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export { MARKER_CLOSE, MARKER_OPEN };
|
|
316
|
+
export const HUB_STATUS_KINDS = Object.freeze({ drift: DRIFT_KIND, unreadable: UNREADABLE_KIND });
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
5
|
+
import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
6
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
7
|
+
|
|
8
|
+
export const templateFile = 'runlist.templates.json';
|
|
9
|
+
export const templateNames = ['doc','plan','prompt'];
|
|
10
|
+
const tokens = new Set(['title','status','date','body','version']);
|
|
11
|
+
const revision = text => createHash('sha256').update(text).digest('hex');
|
|
12
|
+
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
|
13
|
+
export function validateTemplate(name, source) {
|
|
14
|
+
if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan or prompt template.');
|
|
15
|
+
if (typeof source !== 'string' || Buffer.byteLength(source) > 128 * 1024) fail('invalid-template','Template must be Markdown smaller than 128 KB.');
|
|
16
|
+
const parts = extractFrontmatter(source), metadata=parseSimpleFrontmatter(parts.frontmatter);
|
|
17
|
+
if (!source.startsWith('---\n') || metadata.type !== name) fail('invalid-template',`Keep the frontmatter and type: ${name}.`);
|
|
18
|
+
for (const match of source.matchAll(/\{\{([^{}]+)\}\}/g)) if (!tokens.has(match[1])) fail('invalid-template',`Unknown placeholder: {{${match[1]}}}.`);
|
|
19
|
+
if (!/\{\{title\}\}/.test(parts.body) && name !== 'prompt') fail('invalid-template','Keep {{title}} in the document body.');
|
|
20
|
+
if (metadata.status !== '{{status}}' || metadata.created !== '{{date}}' || metadata.updated !== '{{date}}' || ['type','status','created','updated'].some(key=>(parts.frontmatter.match(new RegExp(`^${key}:`, 'gm'))??[]).length!==1)) fail('invalid-template','Keep status: {{status}}, created: {{date}} and updated: {{date}}.');
|
|
21
|
+
if (!parts.body.includes('{{body}}')) fail('invalid-template','Keep {{body}} so authored content is preserved.');
|
|
22
|
+
return parts;
|
|
23
|
+
}
|
|
24
|
+
export function readTemplateStore(config) {
|
|
25
|
+
const file = authorizeRepoGeneratedPath(path.join(config.repoRoot,templateFile),config).path;
|
|
26
|
+
let source = null;
|
|
27
|
+
if (existsSync(file)) {
|
|
28
|
+
if (!statSync(file).isFile() || statSync(file).size > 512 * 1024) fail('invalid-template','Template settings must be a regular file smaller than 512 KB.');
|
|
29
|
+
source = readFileSync(file,'utf8');
|
|
30
|
+
}
|
|
31
|
+
let data = {version:1,templates:{}};
|
|
32
|
+
if (source !== null) {
|
|
33
|
+
try { data = JSON.parse(source); } catch { fail('invalid-template','runlist.templates.json contains invalid JSON.'); }
|
|
34
|
+
if (data.version !== 1 || !data.templates || typeof data.templates !== 'object' || Array.isArray(data.templates) || Object.keys(data).some(k=>!['version','templates'].includes(k))) fail('invalid-template','Unsupported template settings format.');
|
|
35
|
+
for (const [name,value] of Object.entries(data.templates)) {
|
|
36
|
+
if (!value || Object.keys(value).some(k=>k!=='source')) fail('invalid-template','Template settings accept a Markdown source field.');
|
|
37
|
+
validateTemplate(name,value.source);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {file,source,data,revision:source === null ? null : revision(source)};
|
|
41
|
+
}
|
|
42
|
+
export function saveTemplate(config,{name,source,expectedRevision,reset=false}) {
|
|
43
|
+
if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan or prompt template.');
|
|
44
|
+
if (!reset) validateTemplate(name,source);
|
|
45
|
+
const store = readTemplateStore(config);
|
|
46
|
+
if (expectedRevision !== store.revision) fail('template-conflict','Templates changed in another tab or process. Your draft is kept; reload the saved templates before trying again.');
|
|
47
|
+
if (reset) delete store.data.templates[name]; else store.data.templates[name] = {source};
|
|
48
|
+
const content = JSON.stringify(store.data,null,2)+'\n';
|
|
49
|
+
try { mutateFileSet(store.source === null ? {creations:[{path:store.file,content}]} : {updates:[{path:store.file,expectedContent:store.source,content}]},{repoRoot:config.repoRoot}); }
|
|
50
|
+
catch (error) { if (error instanceof MutationConflictError) fail('template-conflict','Templates changed while saving. Your draft is kept.'); throw error; }
|
|
51
|
+
return {revision:revision(content)};
|
|
52
|
+
}
|
|
53
|
+
export function applyTemplateOverride(name,base,config,version) {
|
|
54
|
+
const source = readTemplateStore(config).data.templates[name]?.source;
|
|
55
|
+
if (source === undefined) return base;
|
|
56
|
+
const parts = validateTemplate(name,source);
|
|
57
|
+
const fill = (text, values) => text.replace(/\{\{(title|status|date|body|version)\}\}/g,(_,key)=>values[key] ?? '');
|
|
58
|
+
const values = (title,ctx) => ({title,status:ctx.status,date:ctx.today,body:ctx.bodyInput?.trim() ?? '',version:ctx.version ?? version ?? ''});
|
|
59
|
+
return {...base,_overridesBuiltin:true,acceptsBody:true,
|
|
60
|
+
frontmatter:(status,date,ctx)=>fill(parts.frontmatter,{...values(ctx.title,ctx),title:JSON.stringify(ctx.title??'').slice(1,-1),status,date}),
|
|
61
|
+
body:(title,ctx)=>fill(parts.body,values(title,ctx))};
|
|
62
|
+
}
|