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,171 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { toRepoPath, nowIso } from './util.mjs';
|
|
5
|
+
import { getGitFirstAdded } from './git.mjs';
|
|
6
|
+
import { bold, green, dim } from './color.mjs';
|
|
7
|
+
import { readFileSync as rfs } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkg = JSON.parse(rfs(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
function findPromptCandidates(config) {
|
|
15
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
16
|
+
// Build the set of directories to search: each root, plus each root's parent
|
|
17
|
+
// (catches `docs/prompts/` when roots are like `docs/plans/`, `docs/modules/`),
|
|
18
|
+
// plus the repo root itself.
|
|
19
|
+
const candidates = new Set();
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
candidates.add(path.join(root, 'prompts'));
|
|
22
|
+
candidates.add(path.join(path.dirname(root), 'prompts'));
|
|
23
|
+
}
|
|
24
|
+
candidates.add(path.join(config.repoRoot, 'prompts'));
|
|
25
|
+
|
|
26
|
+
const out = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const dir of candidates) {
|
|
29
|
+
if (!existsSync(dir) || seen.has(dir)) continue;
|
|
30
|
+
seen.add(dir);
|
|
31
|
+
walkDir(dir, out);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkDir(dir, out) {
|
|
37
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
38
|
+
if (entry.name.startsWith('.')) continue;
|
|
39
|
+
const full = path.join(dir, entry.name);
|
|
40
|
+
if (entry.isDirectory()) { walkDir(full, out); continue; }
|
|
41
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
42
|
+
out.push(full);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deriveContext(body, filePath) {
|
|
47
|
+
// Use first heading or filename as the context label.
|
|
48
|
+
const firstH1 = body.match(/^#\s+(.+?)\s*$/m);
|
|
49
|
+
if (firstH1) return firstH1[1].slice(0, 100);
|
|
50
|
+
// Fall back to slug, title-cased.
|
|
51
|
+
const slug = path.basename(filePath, '.md');
|
|
52
|
+
return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).slice(0, 100);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function migrateOnePrompt(raw, opts = {}) {
|
|
56
|
+
const { frontmatter, body: rawBody } = extractFrontmatter(raw);
|
|
57
|
+
const created = opts.created || nowIso();
|
|
58
|
+
|
|
59
|
+
// Case 1: no frontmatter at all → wrap with full fresh frontmatter.
|
|
60
|
+
if (!frontmatter || frontmatter.length === 0) {
|
|
61
|
+
const body = raw.trimStart();
|
|
62
|
+
const context = deriveContext(body, opts.filePath || 'unknown');
|
|
63
|
+
const fm = [
|
|
64
|
+
'type: prompt',
|
|
65
|
+
'status: pending',
|
|
66
|
+
`created: ${created}`,
|
|
67
|
+
`runlist_version: ${pkg.version}`,
|
|
68
|
+
`context: "${context.replace(/"/g, '\\"')}"`,
|
|
69
|
+
'related_plans: []',
|
|
70
|
+
].join('\n');
|
|
71
|
+
return {
|
|
72
|
+
changes: [{ kind: 'add-frontmatter', detail: `type=prompt, created=${created}, context="${context}"` }],
|
|
73
|
+
newRaw: `---\n${fm}\n---\n\n${body.trim()}\n`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Case 2: frontmatter exists. If it already has `type: prompt`, leave alone.
|
|
78
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
79
|
+
if (parsed.type === 'prompt') {
|
|
80
|
+
return { changes: [], newRaw: raw, skipped: 'already-prompt' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Case 3: partial/ad-hoc frontmatter (e.g., `title:` + `purpose:` only).
|
|
84
|
+
// Add missing required fields while preserving existing keys.
|
|
85
|
+
const needed = [];
|
|
86
|
+
if (!parsed.type) needed.push(['type', 'prompt']);
|
|
87
|
+
if (!parsed.status) needed.push(['status', 'pending']);
|
|
88
|
+
if (!parsed.created) needed.push(['created', created]);
|
|
89
|
+
if (!parsed.runlist_version && !parsed.dotmd_version) needed.push(['runlist_version', pkg.version]);
|
|
90
|
+
if (!parsed.context) {
|
|
91
|
+
// Prefer existing `title:` if present, else derive from body/filename.
|
|
92
|
+
const ctx = typeof parsed.title === 'string' ? parsed.title : deriveContext(rawBody || raw, opts.filePath || 'unknown');
|
|
93
|
+
needed.push(['context', `"${ctx.replace(/"/g, '\\"')}"`]);
|
|
94
|
+
}
|
|
95
|
+
if (!parsed.related_plans) needed.push(['related_plans', '[]']);
|
|
96
|
+
|
|
97
|
+
if (needed.length === 0) {
|
|
98
|
+
return { changes: [], newRaw: raw, skipped: 'frontmatter-complete' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Prepend the missing fields (so `type` ends up at the top for grep-friendliness).
|
|
102
|
+
const addedLines = needed.map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
103
|
+
const newFrontmatter = `${addedLines}\n${frontmatter}`;
|
|
104
|
+
const newRaw = `---\n${newFrontmatter}\n---\n${rawBody}`;
|
|
105
|
+
return {
|
|
106
|
+
changes: [{ kind: 'merge-frontmatter', detail: `added: ${needed.map(([k]) => k).join(', ')}` }],
|
|
107
|
+
newRaw,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function runMigratePrompts(argv, config, opts = {}) {
|
|
112
|
+
const { dryRun } = opts;
|
|
113
|
+
const json = argv.includes('--json');
|
|
114
|
+
const fileArg = argv.find(a => !a.startsWith('-') && a !== 'doctor');
|
|
115
|
+
|
|
116
|
+
let files;
|
|
117
|
+
if (fileArg) {
|
|
118
|
+
const target = fileArg.endsWith('.md') ? fileArg : `${fileArg}.md`;
|
|
119
|
+
files = findPromptCandidates(config).filter(f => f.endsWith(target) || f === target);
|
|
120
|
+
if (files.length === 0) {
|
|
121
|
+
process.stderr.write(`File not found in any prompts/ subdir: ${fileArg}\n`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
files = findPromptCandidates(config);
|
|
127
|
+
}
|
|
128
|
+
authorizeManagedSweep(files, config, { kind: 'Prompt migration source' });
|
|
129
|
+
|
|
130
|
+
const results = [];
|
|
131
|
+
let touched = 0;
|
|
132
|
+
|
|
133
|
+
for (const filePath of files) {
|
|
134
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
135
|
+
const created = getGitFirstAdded(toRepoPath(filePath, config.repoRoot), config.repoRoot)
|
|
136
|
+
|| new Date(statSync(filePath).birthtimeMs).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
137
|
+
const result = migrateOnePrompt(raw, { created, filePath });
|
|
138
|
+
if (result.changes.length === 0) continue;
|
|
139
|
+
|
|
140
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
141
|
+
results.push({ path: repoPath, changes: result.changes });
|
|
142
|
+
touched++;
|
|
143
|
+
|
|
144
|
+
if (!dryRun) writeFileSync(filePath, result.newRaw, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (json) {
|
|
148
|
+
process.stdout.write(JSON.stringify({
|
|
149
|
+
dryRun: Boolean(dryRun),
|
|
150
|
+
filesScanned: files.length,
|
|
151
|
+
filesTouched: touched,
|
|
152
|
+
results,
|
|
153
|
+
}, null, 2) + '\n');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (results.length === 0) {
|
|
158
|
+
process.stdout.write(green('No prompts need migration.') + dim(` (${files.length} scanned)`) + '\n');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
163
|
+
process.stdout.write(bold(`${prefix}${touched} prompt${touched === 1 ? '' : 's'} ${dryRun ? 'would be' : 'were'} migrated:\n\n`));
|
|
164
|
+
for (const r of results) {
|
|
165
|
+
process.stdout.write(` ${r.path}\n`);
|
|
166
|
+
for (const c of r.changes) {
|
|
167
|
+
process.stdout.write(dim(` [${c.kind}] ${c.detail}\n`));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (dryRun) process.stdout.write(`\nRun ${bold('runlist doctor --migrate-prompts')} without --dry-run to apply.\n`);
|
|
171
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, nowIso } from './util.mjs';
|
|
5
|
+
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
8
|
+
|
|
9
|
+
const HEADING_RENAMES = [
|
|
10
|
+
{ from: /^##\s+Open questions\s*$/gm, to: '## Open Questions' },
|
|
11
|
+
{ from: /^##\s+open questions\s*$/gm, to: '## Open Questions' },
|
|
12
|
+
{ from: /^##\s+Out of [Ss]cope\s*$/gm, to: '## Non-Goals' },
|
|
13
|
+
{ from: /^##\s+out of scope\s*$/gm, to: '## Non-Goals' },
|
|
14
|
+
{ from: /^##\s+Non-goals\s*$/gm, to: '## Non-Goals' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Detect "both surface and surfaces are populated" — applies same logic to module/modules.
|
|
18
|
+
// Returns the singular-line text to delete, or null if no fix needed.
|
|
19
|
+
function findRedundantSingular(rawFrontmatter, singularKey, pluralKey, parsed) {
|
|
20
|
+
const singularVal = asString(parsed[singularKey]);
|
|
21
|
+
const pluralVal = parsed[pluralKey];
|
|
22
|
+
const pluralHasValues = Array.isArray(pluralVal) && pluralVal.length > 0;
|
|
23
|
+
if (!singularVal || !pluralHasValues) return null;
|
|
24
|
+
// Find the exact line: `<singularKey>: <value>` (single inline, not a block start)
|
|
25
|
+
const lineRe = new RegExp(`^${singularKey}:\\s*[^\\n]+$`, 'm');
|
|
26
|
+
const match = rawFrontmatter.match(lineRe);
|
|
27
|
+
return match ? match[0] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureVersionHistory(body, timestamp) {
|
|
31
|
+
if (/^##\s+Version History\s*$/m.test(body)) return null;
|
|
32
|
+
const newSection = `## Version History\n\n- **${timestamp}** Migrated to v0.21 template.\n`;
|
|
33
|
+
// Insert before ## Closeout if it exists; else append at end.
|
|
34
|
+
const closeoutMatch = body.match(/^##\s+Closeout\s*$/m);
|
|
35
|
+
if (closeoutMatch) {
|
|
36
|
+
const idx = body.indexOf(closeoutMatch[0]);
|
|
37
|
+
return body.slice(0, idx) + newSection + '\n' + body.slice(idx);
|
|
38
|
+
}
|
|
39
|
+
return body.trimEnd() + '\n\n' + newSection;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run plan-shape migrations on one file. Pure-ish: returns { changes, newRaw }
|
|
43
|
+
// without writing. Caller handles IO.
|
|
44
|
+
export function migrateOne(raw) {
|
|
45
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
46
|
+
if (!frontmatter || !body) return { changes: [], newRaw: raw };
|
|
47
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
48
|
+
if (asString(parsed.type) !== 'plan') return { changes: [], newRaw: raw, skipped: 'not-plan' };
|
|
49
|
+
|
|
50
|
+
let newFrontmatter = frontmatter;
|
|
51
|
+
let newBody = body;
|
|
52
|
+
const changes = [];
|
|
53
|
+
|
|
54
|
+
// 1. Drop redundant singular surface/module when array form is populated.
|
|
55
|
+
const dropSurface = findRedundantSingular(newFrontmatter, 'surface', 'surfaces', parsed);
|
|
56
|
+
if (dropSurface) {
|
|
57
|
+
newFrontmatter = newFrontmatter.replace(new RegExp(`\\n?${dropSurface.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\n?`), '\n').replace(/\n+/g, '\n');
|
|
58
|
+
changes.push({ kind: 'drop-singular', detail: dropSurface });
|
|
59
|
+
}
|
|
60
|
+
const dropModule = findRedundantSingular(newFrontmatter, 'module', 'modules', parsed);
|
|
61
|
+
if (dropModule) {
|
|
62
|
+
newFrontmatter = newFrontmatter.replace(new RegExp(`\\n?${dropModule.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\n?`), '\n').replace(/\n+/g, '\n');
|
|
63
|
+
changes.push({ kind: 'drop-singular', detail: dropModule });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Heading renames in body
|
|
67
|
+
for (const { from, to } of HEADING_RENAMES) {
|
|
68
|
+
const matches = [...newBody.matchAll(from)];
|
|
69
|
+
if (matches.length > 0) {
|
|
70
|
+
newBody = newBody.replace(from, to);
|
|
71
|
+
for (const m of matches) {
|
|
72
|
+
changes.push({ kind: 'rename-heading', detail: `\`${m[0].trim()}\` → \`${to}\`` });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Add ## Version History section if missing.
|
|
78
|
+
// Use the file's `updated` timestamp if present, else nowIso(), so the
|
|
79
|
+
// seed entry reads truthfully ("when this plan was last touched") rather
|
|
80
|
+
// than dating itself to the migration moment.
|
|
81
|
+
const seedTs = asString(parsed.updated) || nowIso();
|
|
82
|
+
const withVh = ensureVersionHistory(newBody, seedTs);
|
|
83
|
+
if (withVh !== null) {
|
|
84
|
+
newBody = withVh;
|
|
85
|
+
changes.push({ kind: 'add-version-history', detail: `seeded with ${seedTs}` });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (changes.length === 0) return { changes: [], newRaw: raw };
|
|
89
|
+
|
|
90
|
+
const newRaw = `---\n${newFrontmatter.trim()}\n---\n${newBody}`;
|
|
91
|
+
return { changes, newRaw };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isInArchive(filePath, config) {
|
|
95
|
+
const archiveDir = config.archiveDir || 'archived';
|
|
96
|
+
const sep = path.sep;
|
|
97
|
+
return filePath.includes(`${sep}${archiveDir}${sep}`) || filePath.endsWith(`${sep}${archiveDir}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function runMigrateTemplate(argv, config, opts = {}) {
|
|
101
|
+
const { dryRun } = opts;
|
|
102
|
+
const json = argv.includes('--json');
|
|
103
|
+
const includeArchived = argv.includes('--include-archived');
|
|
104
|
+
// First positional anywhere in argv (skip 'doctor' subcommand if present at idx 0).
|
|
105
|
+
const fileArg = argv.find(a => !a.startsWith('-') && a !== 'doctor');
|
|
106
|
+
|
|
107
|
+
// Allow targeting one file, else sweep all plans (excluding archived by default).
|
|
108
|
+
let files;
|
|
109
|
+
if (fileArg) {
|
|
110
|
+
const target = fileArg.endsWith('.md') ? fileArg : `${fileArg}.md`;
|
|
111
|
+
files = collectDocFiles(config).filter(f => f.endsWith(target) || f === target);
|
|
112
|
+
if (files.length === 0) {
|
|
113
|
+
process.stderr.write(`File not found: ${fileArg}\n`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
files = collectDocFiles(config);
|
|
119
|
+
if (!includeArchived) files = files.filter(f => !isInArchive(f, config));
|
|
120
|
+
}
|
|
121
|
+
authorizeManagedSweep(files, config, { kind: 'Template migration source' });
|
|
122
|
+
|
|
123
|
+
const results = [];
|
|
124
|
+
let totalChanges = 0;
|
|
125
|
+
let touched = 0;
|
|
126
|
+
|
|
127
|
+
for (const filePath of files) {
|
|
128
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
129
|
+
const result = migrateOne(raw);
|
|
130
|
+
if (result.changes.length === 0) continue;
|
|
131
|
+
|
|
132
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
133
|
+
results.push({ path: repoPath, changes: result.changes });
|
|
134
|
+
totalChanges += result.changes.length;
|
|
135
|
+
touched++;
|
|
136
|
+
|
|
137
|
+
if (!dryRun) writeFileSync(filePath, result.newRaw, 'utf8');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (json) {
|
|
141
|
+
process.stdout.write(JSON.stringify({
|
|
142
|
+
dryRun: Boolean(dryRun),
|
|
143
|
+
filesTouched: touched,
|
|
144
|
+
totalChanges,
|
|
145
|
+
results,
|
|
146
|
+
}, null, 2) + '\n');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (results.length === 0) {
|
|
151
|
+
process.stdout.write(green('No template migrations needed.') + '\n');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
156
|
+
process.stdout.write(bold(`${prefix}${touched} plan${touched === 1 ? '' : 's'} ${dryRun ? 'would be' : 'were'} migrated (${totalChanges} change${totalChanges === 1 ? '' : 's'}):\n\n`));
|
|
157
|
+
|
|
158
|
+
for (const r of results) {
|
|
159
|
+
process.stdout.write(` ${r.path}\n`);
|
|
160
|
+
for (const c of r.changes) {
|
|
161
|
+
process.stdout.write(dim(` [${c.kind}] ${c.detail}\n`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (dryRun) {
|
|
166
|
+
process.stdout.write(`\nRun ${bold('runlist doctor --migrate-template')} without --dry-run to apply.\n`);
|
|
167
|
+
} else {
|
|
168
|
+
process.stdout.write(`\n${green('Done.')} Re-run ${bold('runlist check')} to see remaining issues.\n`);
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/migrate.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, resolveDocPath, die } from './util.mjs';
|
|
5
|
+
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
|
+
import { bold, green, dim } from './color.mjs';
|
|
8
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
9
|
+
|
|
10
|
+
export function runMigrate(argv, config, opts = {}) {
|
|
11
|
+
const { dryRun } = opts;
|
|
12
|
+
|
|
13
|
+
// Parse positional args (skip flags)
|
|
14
|
+
const positional = [];
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
if (argv[i].startsWith('-')) continue;
|
|
17
|
+
positional.push(argv[i]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const field = positional[0];
|
|
21
|
+
const oldValue = positional[1];
|
|
22
|
+
const newValue = positional[2];
|
|
23
|
+
const fileArgs = positional.slice(3);
|
|
24
|
+
|
|
25
|
+
if (!field || !oldValue || !newValue) {
|
|
26
|
+
die('Usage: runlist migrate <field> <old-value> <new-value> [files...]');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const allFiles = collectDocFiles(config);
|
|
30
|
+
|
|
31
|
+
// When file args are passed, resolve them to a filter set (mirrors runBulkArchive).
|
|
32
|
+
let fileFilter = null;
|
|
33
|
+
if (fileArgs.length > 0) {
|
|
34
|
+
const matched = [];
|
|
35
|
+
const unresolved = [];
|
|
36
|
+
for (const input of fileArgs) {
|
|
37
|
+
const filePath = resolveDocPath(input, config);
|
|
38
|
+
if (filePath) {
|
|
39
|
+
matched.push(filePath);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const normalizedInput = input.replaceAll('\\', '/');
|
|
43
|
+
const hits = allFiles.filter(f => {
|
|
44
|
+
const repoPath = toRepoPath(f, config.repoRoot);
|
|
45
|
+
return repoPath.includes(normalizedInput) || path.basename(f).includes(input);
|
|
46
|
+
});
|
|
47
|
+
if (hits.length === 0) {
|
|
48
|
+
unresolved.push(input);
|
|
49
|
+
} else {
|
|
50
|
+
matched.push(...hits);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (unresolved.length > 0) {
|
|
54
|
+
die(`No matching file(s) for: ${unresolved.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
fileFilter = new Set(matched);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const matches = [];
|
|
60
|
+
authorizeManagedSweep(fileFilter ? [...fileFilter] : allFiles, config, { kind: 'Migration source' });
|
|
61
|
+
|
|
62
|
+
for (const filePath of allFiles) {
|
|
63
|
+
if (fileFilter && !fileFilter.has(filePath)) continue;
|
|
64
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
65
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
66
|
+
if (!frontmatter) continue;
|
|
67
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
68
|
+
const current = asString(parsed[field]);
|
|
69
|
+
if (current === oldValue) {
|
|
70
|
+
matches.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (matches.length === 0) {
|
|
75
|
+
const scope = fileFilter ? ` in the specified file(s)` : '';
|
|
76
|
+
process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}${scope}\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
81
|
+
|
|
82
|
+
for (const { filePath, repoPath } of matches) {
|
|
83
|
+
if (!dryRun) {
|
|
84
|
+
updateFrontmatter(filePath, { [field]: newValue });
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(`${prefix}${green('Updated')}: ${repoPath} (${field}: ${oldValue} → ${newValue})\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.stdout.write(`\n${prefix}${matches.length} file(s) ${dryRun ? 'would be ' : ''}updated.\n`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readMisuseEntries, globalMisuseLogPaths } from './journal.mjs';
|
|
3
|
+
import { dim, red, yellow } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
// `runlist misuse` — read the cross-repo guard log. Every wrong-move the
|
|
6
|
+
// PreToolUse guard intercepts lands here; this is the operator's window into
|
|
7
|
+
// "what are sessions getting wrong, and how often."
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const opts = { tail: null, byRule: false, asJson: false, repo: null };
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const a = argv[i];
|
|
12
|
+
if (a === '--tail') { const n = parseInt(argv[++i], 10); opts.tail = Number.isFinite(n) && n > 0 ? n : 20; }
|
|
13
|
+
else if (a === '--by-rule') opts.byRule = true;
|
|
14
|
+
else if (a === '--json') opts.asJson = true;
|
|
15
|
+
else if (a === '--repo' && argv[i + 1]) opts.repo = argv[++i];
|
|
16
|
+
}
|
|
17
|
+
if (opts.tail === null && !opts.byRule) opts.tail = 20;
|
|
18
|
+
return opts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function runMisuse(argv, _config) {
|
|
22
|
+
if (!globalMisuseLogPaths().some(file => existsSync(file))) {
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
'No misuse log yet. The PreToolUse guard (`runlist guard`) writes here when it intercepts a wrong move.\n' +
|
|
25
|
+
'Wire it up: add a PreToolUse hook that runs `runlist guard` (see `runlist help guard`).\n',
|
|
26
|
+
);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const opts = parseArgs(argv);
|
|
31
|
+
let entries = readMisuseEntries();
|
|
32
|
+
if (opts.repo) entries = entries.filter(e => (e.repo || '').includes(opts.repo));
|
|
33
|
+
|
|
34
|
+
if (opts.byRule) {
|
|
35
|
+
const groups = new Map();
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
const key = e.rule || '(unknown)';
|
|
38
|
+
if (!groups.has(key)) groups.set(key, { rule: key, count: 0, deny: 0, warn: 0 });
|
|
39
|
+
const g = groups.get(key);
|
|
40
|
+
g.count++;
|
|
41
|
+
if (e.decision === 'deny') g.deny++; else if (e.decision === 'warn') g.warn++;
|
|
42
|
+
}
|
|
43
|
+
const rows = [...groups.values()].sort((a, b) => b.count - a.count);
|
|
44
|
+
if (opts.asJson) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
|
|
45
|
+
if (!rows.length) { process.stdout.write('No misuse events recorded.\n'); return; }
|
|
46
|
+
for (const r of rows) {
|
|
47
|
+
process.stdout.write(`${r.rule.padEnd(16)} ${String(r.count).padStart(4)}× ${red(`${r.deny} deny`)} / ${yellow(`${r.warn} warn`)}\n`);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (opts.tail) entries = entries.slice(-opts.tail);
|
|
53
|
+
if (opts.asJson) { process.stdout.write(JSON.stringify(entries, null, 2) + '\n'); return; }
|
|
54
|
+
if (!entries.length) { process.stdout.write('No misuse events recorded.\n'); return; }
|
|
55
|
+
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const tag = e.decision === 'deny' ? red('DENY') : yellow('warn');
|
|
58
|
+
const repo = e.repo ? dim(` ${e.repo.split('/').pop()}`) : '';
|
|
59
|
+
process.stdout.write(`[${e.ts}] ${tag} ${e.rule || '?'}${repo} ${dim(e.detail || '')}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// HTTP requests to the local model server, run as a child process so the
|
|
2
|
+
// synchronous callers (summaries, lint's status inference) can wait on them
|
|
3
|
+
// without every render path turning async. Reads a list of
|
|
4
|
+
// {url, method, body, timeoutMs} as JSON on stdin, sends them in order, and
|
|
5
|
+
// writes the list of {ok, status, json, error} as JSON on stdout.
|
|
6
|
+
|
|
7
|
+
const input = JSON.parse(await new Promise(resolve => {
|
|
8
|
+
let data = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
11
|
+
process.stdin.on('end', () => resolve(data));
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
async function send(input) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(input.url, {
|
|
17
|
+
method: input.method ?? 'GET',
|
|
18
|
+
headers: input.body ? { 'content-type': 'application/json' } : undefined,
|
|
19
|
+
body: input.body ? JSON.stringify(input.body) : undefined,
|
|
20
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 10000),
|
|
21
|
+
});
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
let json = null;
|
|
24
|
+
try { json = JSON.parse(text); } catch { /* non-JSON body; status says enough */ }
|
|
25
|
+
return { ok: res.ok, status: res.status, json, error: res.ok ? null : (json?.error ?? text.slice(0, 200)) };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return { ok: false, status: 0, json: null, error: err.name === 'TimeoutError' ? 'timed out' : (err.cause?.code ?? err.message) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const req of [input].flat()) out.push(await send(req));
|
|
33
|
+
process.stdout.write(JSON.stringify(Array.isArray(input) ? out : out[0]));
|