explainer-video 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/cordis.patch.yml +7 -0
- package/lib/index.js +35 -0
- package/package.json +56 -4
- package/plugin.json +13 -0
- package/skills/coursework-author/.synced-from +5 -0
- package/skills/coursework-author/SKILL.md +209 -0
- package/skills/craft-codeflow-flows/SKILL.md +116 -0
- package/skills/craft-codeflow-mine/SKILL.md +207 -0
- package/skills/craft-codeflow-plan/SKILL.md +65 -0
- package/skills/craft-codeflow-scene/SKILL.md +168 -0
- package/skills/craft-video-align/SKILL.md +109 -0
- package/skills/craft-video-assemble/SKILL.md +69 -0
- package/skills/craft-video-audio/SKILL.md +59 -0
- package/skills/craft-video-enrich/SKILL.md +46 -0
- package/skills/craft-video-plan/SKILL.md +57 -0
- package/skills/craft-video-publish/SKILL.md +79 -0
- package/skills/craft-video-render/SKILL.md +68 -0
- package/skills/craft-video-scene/SKILL.md +112 -0
- package/skills/craft-video-script/SKILL.md +80 -0
- package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
- package/skills/explainer-video-from-codebase/SKILL.md +659 -0
- package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
- package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
- package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
- package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
- package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
- package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
- package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
- package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
- package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
- package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
- package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
- package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
- package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
- package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
- package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
- package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
- package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
- package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
- package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
- package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
- package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
- package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
- package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
- package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
- package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
- package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
- package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
- package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
- package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
- package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
- package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
- package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
- package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
- package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
- package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
- package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
- package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
- package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
- package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
- package/skills/explainer-video-from-coursework/SERIES.md +232 -0
- package/skills/explainer-video-from-coursework/SETUP.md +105 -0
- package/skills/explainer-video-from-coursework/SKILL.md +858 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
- package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
- package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
- package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
- package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
- package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
- package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
- package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
- package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
- package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
- package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
- package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
- package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
- package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
- package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
- package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
- package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
- package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
- package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
- package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
- package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
- package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
- package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
- package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
- package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
- package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
- package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
- package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
- package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
- package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
- package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
- package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
apply-flows.mjs — validate the agent's series proposal (flows.json) against
|
|
4
|
+
the code map, the candidates and the ranking, and print it for approval.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
node tools/apply-flows.mjs --candidates out/<name>/candidates.json --flows out/<name>/flows.json
|
|
8
|
+
|
|
9
|
+
Reads codemap.json and ranking.json beside candidates.json. Writes
|
|
10
|
+
flows.validated.json and flows.md beside flows.json. The checks (design §4.4):
|
|
11
|
+
- every flow, core, hop, segment, variant, skip, promotion, teach target
|
|
12
|
+
and cited test exists;
|
|
13
|
+
- every series item has a reason, and so does every high-ranked row the
|
|
14
|
+
series does not include (C1′: rank <= 10 prod / 5 eval, or the ranking's larger
|
|
15
|
+
--explain-top / --explain-top-eval, or at or above the lowest-ranked row the series includes);
|
|
16
|
+
a glob never explains one;
|
|
17
|
+
- every non-test entry is triaged (C1); eval rows are never implicit;
|
|
18
|
+
- every segment of a kept candidate is kept, mentioned or excluded (C2);
|
|
19
|
+
- every hop is connected to the hops before it (C3) and is a dispatch
|
|
20
|
+
target its receiver and config select; other alternatives are taught;
|
|
21
|
+
- every cited test directly pins its hop and does not mock it (C3′);
|
|
22
|
+
- production flows have no test hops; eval and dev flows do not re-narrate
|
|
23
|
+
production code, they name the production flow they exercise.
|
|
24
|
+
On any problem it writes flows.md with the problems, removes a stale
|
|
25
|
+
flows.validated.json, and exits 2. No series is ever chosen here.
|
|
26
|
+
|
|
27
|
+
Exit: 0 ok · 1 bad flags, unreadable or mismatched inputs, or output inside the repo
|
|
28
|
+
· 2 validation problems
|
|
29
|
+
========================================================================== */
|
|
30
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync, rmSync } from 'node:fs';
|
|
31
|
+
import { join, resolve, dirname, sep, relative, basename } from 'node:path';
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { CROSSING } from './lib/walk.mjs';
|
|
34
|
+
import { planVideos } from './lib/decompose.mjs';
|
|
35
|
+
import { renderFlows } from './flows-md.mjs';
|
|
36
|
+
|
|
37
|
+
const fail = (code, msg) => { console.error(`[apply-flows] ${msg}`); process.exit(code); };
|
|
38
|
+
|
|
39
|
+
/* ---------------- args ---------------- */
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const VALUED = new Set(['--candidates', '--flows']);
|
|
42
|
+
const flags = new Map();
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
if (VALUED.has(argv[i])) { if (argv[i + 1] == null) fail(1, `${argv[i]} needs a value`); flags.set(argv[i], argv[++i]); }
|
|
45
|
+
else fail(1, `unknown argument ${argv[i]}`);
|
|
46
|
+
}
|
|
47
|
+
if (!flags.has('--candidates') || !flags.has('--flows')) fail(1, 'usage: apply-flows.mjs --candidates out/<name>/candidates.json --flows out/<name>/flows.json');
|
|
48
|
+
const CANDIDATES = resolve(flags.get('--candidates')), FLOWS = resolve(flags.get('--flows'));
|
|
49
|
+
const IN = dirname(CANDIDATES), OUT = dirname(FLOWS);
|
|
50
|
+
const readText = (f, what) => { try { return readFileSync(f, 'utf8'); } catch (e) { fail(1, `cannot read ${what} ${f}: ${e.message}`); } };
|
|
51
|
+
const readJson = (f, what) => { const t = readText(f, what); try { return { text: t, json: JSON.parse(t) }; } catch (e) { fail(1, `${what} ${f} is not valid JSON: ${e.message}`); } };
|
|
52
|
+
const sha = t => createHash('sha256').update(t).digest('hex');
|
|
53
|
+
const C = readJson(CANDIDATES, 'the candidates'), M = readJson(join(IN, 'codemap.json'), 'the code map'), K = readJson(join(IN, 'ranking.json'), 'the ranking');
|
|
54
|
+
const F = readJson(FLOWS, 'the flows');
|
|
55
|
+
const r = C.json, m = M.json, k = K.json, f = F.json;
|
|
56
|
+
if (m.schema !== 1 || !Array.isArray(m.symbols)) fail(1, 'codemap.json is not a mine.mjs code map (schema 1)');
|
|
57
|
+
if (r.schema !== 1 || !r.candidates) fail(1, `${CANDIDATES} is not a propose.mjs candidates file (schema 1)`);
|
|
58
|
+
if (k.schema !== 1 || !k.ranked) fail(1, 'ranking.json is not a rank.mjs ranking (schema 1)');
|
|
59
|
+
if (r.treeHash !== m.treeHash || k.treeHash !== m.treeHash) fail(1, 'codemap.json, candidates.json and ranking.json come from different trees: rerun mine, propose and rank');
|
|
60
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) fail(1, `${FLOWS} is not a flows object`);
|
|
61
|
+
if (m.root && existsSync(m.root)) { const root = realpathSync(m.root), o = realpathSync(OUT); if (o === root || o.startsWith(root + sep)) fail(1, `refusing to write inside the repo: ${OUT}`); }
|
|
62
|
+
|
|
63
|
+
/* ---------------- indexes ---------------- */
|
|
64
|
+
const MAX_PATH = 4; // a hop is connected when it is at most 4 calls from a hop before it
|
|
65
|
+
const sym = new Map(m.symbols.map(s => [s.id, s]));
|
|
66
|
+
const entry = new Map(m.entries.map(e => [e.id, e]));
|
|
67
|
+
const testById = new Map((m.tests || []).map(t => [t.id, t]));
|
|
68
|
+
const reach = new Map((r.testLinks || []).map(t => [t.test, new Set(t.exercisedIds)]));
|
|
69
|
+
const G = id => sym.get(id)?.group || (id.startsWith('ext:') ? 'ext' : null);
|
|
70
|
+
const rankOf = new Map();
|
|
71
|
+
for (const [grp, rows] of Object.entries(k.ranked)) for (const x of rows) rankOf.set(x.entry, { group: grp, rank: x.rank });
|
|
72
|
+
const rankText = id => rankOf.has(id) ? `${rankOf.get(id).group} #${rankOf.get(id).rank}` : null;
|
|
73
|
+
const out = new Map(), handedBy = new Map();
|
|
74
|
+
for (const e of m.edges) {
|
|
75
|
+
if (e.rule === 'py.callback') (handedBy.get(e.passedBy) || handedBy.set(e.passedBy, []).get(e.passedBy)).push(e);
|
|
76
|
+
else (out.get(e.from) || out.set(e.from, []).get(e.from)).push(e);
|
|
77
|
+
}
|
|
78
|
+
const configSite = new Map((m.config?.sites || []).map(s => [s.factory, s]));
|
|
79
|
+
const fileLines = new Map();
|
|
80
|
+
const linesOf = file => { if (!fileLines.has(file)) { let n = null; const p = m.root && join(m.root, file);
|
|
81
|
+
try { if (p && realpathSync(p).startsWith(realpathSync(m.root) + sep)) n = readFileSync(p, 'utf8').split('\n').length; } catch { n = null; }
|
|
82
|
+
fileLines.set(file, n); } return fileLines.get(file); };
|
|
83
|
+
|
|
84
|
+
const problems = [];
|
|
85
|
+
const problem = (where, msg) => problems.push(`${where}: ${msg}`);
|
|
86
|
+
const PLACEHOLDER = /(^|[^a-z])(tbd|tba|todo|fixme|xxx|placeholder|lorem)([^a-z]|$)|…|\.\.\.|^<.*>$/i;
|
|
87
|
+
// a reason is a sentence fragment someone can argue with: at least three words, not a placeholder
|
|
88
|
+
const reasonProblem = (text, what = 'why') => {
|
|
89
|
+
if (typeof text !== 'string' || !text.trim()) return `needs a "${what}"`;
|
|
90
|
+
if (PLACEHOLDER.test(text.trim())) return `"${what}" is a placeholder: "${text.trim()}"`;
|
|
91
|
+
if (text.trim().split(/\s+/).filter(w => /[a-z0-9]/i.test(w)).length < 3) return `"${what}" is too short to be a reason (at least 3 words): "${text.trim()}"`;
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
const needReason = (where, text, what) => { const p = reasonProblem(text, what); if (p) problem(where, p); };
|
|
95
|
+
const isObj = x => x && typeof x === 'object' && !Array.isArray(x);
|
|
96
|
+
const keys = (where, o, allowed) => { for (const x of Object.keys(o)) if (!allowed.includes(x)) problem(where, `unknown field "${x}" (allowed: ${allowed.join(', ')})`); };
|
|
97
|
+
const list = (where, x) => { if (x == null) return []; if (!Array.isArray(x)) { problem(where, 'must be a list'); return []; } return x; };
|
|
98
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)+$/;
|
|
99
|
+
const titleProblem = t => typeof t !== 'string' || !t.trim() ? 'needs a title' : t.length > 70 ? `title too long (${t.length})` : /\.\.\.|…/.test(t) ? 'title contains an ellipsis'
|
|
100
|
+
: /^part\s+\d+$/i.test(t.trim()) ? 'title is a placeholder' : SLUG_RE.test(t.trim()) ? `title "${t}" is a slug: write a real title` : null;
|
|
101
|
+
const globRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
|
102
|
+
const parseLines = (where, text, lo, hi) => {
|
|
103
|
+
if (text == null) return null;
|
|
104
|
+
const ranges = String(text).split(',').map(x => x.trim().match(/^(\d+)(?:-(\d+))?$/));
|
|
105
|
+
if (ranges.some(x => !x)) { problem(where, `lines "${text}" must look like 12-20 or 12-20,31`); return null; }
|
|
106
|
+
const out = ranges.map(x => [Number(x[1]), Number(x[2] || x[1])]);
|
|
107
|
+
for (const [a, b] of out) if (a > b || a < lo || b > hi) problem(where, `lines ${a}-${b} fall outside ${lo}-${hi}`);
|
|
108
|
+
return out;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/* ---------------- top level ---------------- */
|
|
112
|
+
keys('flows.json', f, ['codemap', 'candidates', 'trace', 'series', 'skipped', 'promote', 'flows', 'cores', 'triage', 'outOfRepo']);
|
|
113
|
+
const flows = list('flows', f.flows).filter((x, i) => isObj(x) || problem(`flows[${i}]`, 'must be an object'));
|
|
114
|
+
const cores = list('cores', f.cores).filter((x, i) => isObj(x) || problem(`cores[${i}]`, 'must be an object'));
|
|
115
|
+
const items = new Map(); // id -> { kind, def }
|
|
116
|
+
const ITEM_FIELDS = { flow: ['id', 'title', 'from', 'group', 'hops', 'segments', 'variants', 'teach', 'links'], core: ['id', 'title', 'hops', 'pinnedBy', 'variants', 'teach', 'links'] };
|
|
117
|
+
for (const [kind, defs] of [['flow', flows], ['core', cores]]) defs.forEach((d, i) => {
|
|
118
|
+
const hasId = typeof d.id === 'string' && d.id.trim();
|
|
119
|
+
keys(hasId ? `${kind} ${d.id}` : `${kind}s[${i}]`, d, ITEM_FIELDS[kind]);
|
|
120
|
+
if (!hasId) return problem(`${kind}s[${i}]`, 'needs an id');
|
|
121
|
+
if (items.has(d.id)) return problem(`${kind} ${d.id}`, 'id is used twice');
|
|
122
|
+
items.set(d.id, { kind, def: d });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// series: the agent's proposal, in video order, each item with a reason
|
|
126
|
+
const series = [];
|
|
127
|
+
const seriesList = list('series', f.series);
|
|
128
|
+
if (!seriesList.length) problem('series', 'is empty: propose at least one flow or core');
|
|
129
|
+
seriesList.forEach((s, i) => {
|
|
130
|
+
const where = `series[${i}]`;
|
|
131
|
+
if (!isObj(s)) return problem(where, 'must be an object');
|
|
132
|
+
keys(where, s, ['flow', 'core', 'why']);
|
|
133
|
+
const kind = s.flow != null ? 'flow' : s.core != null ? 'core' : null, id = s.flow ?? s.core;
|
|
134
|
+
if (!kind || (s.flow != null && s.core != null)) return problem(where, 'names exactly one "flow" or "core"');
|
|
135
|
+
const it = items.get(id);
|
|
136
|
+
if (!it || it.kind !== kind) return problem(where, `${kind} "${id}" is not defined in ${kind}s`);
|
|
137
|
+
if (series.some(x => x.id === id)) return problem(where, `${id} is in the series twice`);
|
|
138
|
+
needReason(`series ${id}`, s.why);
|
|
139
|
+
series.push({ kind, id, why: s.why });
|
|
140
|
+
});
|
|
141
|
+
for (const [id, it] of items) if (!series.some(x => x.id === id)) problem(`${it.kind} ${id}`, 'is defined but not in the series');
|
|
142
|
+
const titles = new Map();
|
|
143
|
+
for (const [id, it] of items) { const t = it.def.title, p = titleProblem(t);
|
|
144
|
+
if (p) problem(`${it.kind} ${id}`, p); else if (titles.has(t.trim())) problem(`${it.kind} ${id}`, `duplicate title "${t}" (also ${titles.get(t.trim())})`); else titles.set(t.trim(), id); }
|
|
145
|
+
|
|
146
|
+
/* ---------------- entry decisions: flows, variants, skips, promotions, exclusions ---------------- */
|
|
147
|
+
const decided = new Map(); // entry id -> { status, by, why? }
|
|
148
|
+
const decide = (where, id, status, extra) => {
|
|
149
|
+
if (decided.has(id)) return problem(where, `${id} is already decided (${decided.get(id).status} in ${decided.get(id).by})`);
|
|
150
|
+
decided.set(id, { status, by: where, ...extra });
|
|
151
|
+
};
|
|
152
|
+
const realEntry = (where, id) => {
|
|
153
|
+
if (typeof id !== 'string' || !id) { problem(where, 'needs an entry id'); return false; }
|
|
154
|
+
if (/[*?]/.test(id)) { problem(where, `"${id}" is a glob: name one entry (globs belong in triage.excluded)`); return false; }
|
|
155
|
+
const e = entry.get(id);
|
|
156
|
+
if (!e) { problem(where, `${id} is not an entry in the code map`); return false; }
|
|
157
|
+
if (e.group === 'test') { problem(where, `${id} is a test-group entry: tests are evidence, not flows`); return false; }
|
|
158
|
+
if (!r.candidates[id]) { problem(where, `${id} has no candidate`); return false; }
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
for (const [id, it] of items) if (it.kind === 'flow' && realEntry(`flow ${id} from`, it.def.from)) {
|
|
162
|
+
decide(`flow ${id}`, it.def.from, 'flow', { flow: id });
|
|
163
|
+
const g = entry.get(it.def.from).group;
|
|
164
|
+
if (it.def.group != null && it.def.group !== g) problem(`flow ${id}`, `says group "${it.def.group}" but its entry ${it.def.from} is in the ${g} group`);
|
|
165
|
+
}
|
|
166
|
+
const variantsOf = new Map();
|
|
167
|
+
for (const [id, it] of items) variantsOf.set(id, list(`${it.kind} ${id} variants`, it.def.variants).flatMap((v, i) => {
|
|
168
|
+
const where = `${it.kind} ${id} variants[${i}]`;
|
|
169
|
+
if (!isObj(v)) { problem(where, 'must be {entry, why}'); return []; }
|
|
170
|
+
keys(where, v, ['entry', 'why']);
|
|
171
|
+
needReason(where, v.why);
|
|
172
|
+
if (!realEntry(where, v.entry)) return [];
|
|
173
|
+
decide(where, v.entry, 'variant', { of: id, why: v.why });
|
|
174
|
+
return [{ entry: v.entry, why: v.why, rank: rankText(v.entry) }];
|
|
175
|
+
}));
|
|
176
|
+
const skipped = list('skipped', f.skipped).flatMap((s, i) => {
|
|
177
|
+
const where = `skipped[${i}]`;
|
|
178
|
+
if (!isObj(s)) { problem(where, 'must be {entry, rank?, why}'); return []; }
|
|
179
|
+
keys(where, s, ['entry', 'rank', 'why']);
|
|
180
|
+
needReason(`skipped ${s.entry}`, s.why);
|
|
181
|
+
if (!realEntry(where, s.entry)) return [];
|
|
182
|
+
const actual = rankText(s.entry);
|
|
183
|
+
if (s.rank != null && String(s.rank) !== actual) problem(`skipped ${s.entry}`, `says ${s.rank} but ranks ${actual || 'nowhere (not a top-level row)'}`);
|
|
184
|
+
decide(where, s.entry, 'skipped', { why: s.why });
|
|
185
|
+
return [{ entry: s.entry, group: entry.get(s.entry).group, rank: actual, why: s.why }];
|
|
186
|
+
});
|
|
187
|
+
const promoted = new Set();
|
|
188
|
+
const promote = list('promote', f.promote).flatMap((p, i) => {
|
|
189
|
+
const where = `promote[${i}]`;
|
|
190
|
+
if (!isObj(p)) { problem(where, 'must be {entry, why}'); return []; }
|
|
191
|
+
keys(where, p, ['entry', 'why']);
|
|
192
|
+
needReason(`promote ${p.entry}`, p.why);
|
|
193
|
+
if (!realEntry(where, p.entry)) return [];
|
|
194
|
+
if (entry.get(p.entry).group !== 'dev') { problem(`promote ${p.entry}`, `is in the ${entry.get(p.entry).group} group: only dev rows are promoted`); return []; }
|
|
195
|
+
promoted.add(p.entry);
|
|
196
|
+
return [{ entry: p.entry, rank: rankText(p.entry), why: p.why }];
|
|
197
|
+
});
|
|
198
|
+
if (f.triage != null && !isObj(f.triage)) problem('triage', 'must be {excluded: [...]}');
|
|
199
|
+
if (isObj(f.triage)) keys('triage', f.triage, ['excluded']);
|
|
200
|
+
const excluded = list('triage.excluded', f.triage?.excluded).flatMap((x, i) => {
|
|
201
|
+
const where = `triage.excluded[${i}]`;
|
|
202
|
+
if (!isObj(x) || typeof x.match !== 'string' || !x.match) { problem(where, 'must be {match, why}'); return []; }
|
|
203
|
+
keys(where, x, ['match', 'why']);
|
|
204
|
+
needReason(`triage.excluded ${x.match}`, x.why);
|
|
205
|
+
return [{ match: x.match, why: x.why, re: globRe(x.match), matched: [] }];
|
|
206
|
+
});
|
|
207
|
+
const excludedBy = id => excluded.find(x => x.re.test(id));
|
|
208
|
+
|
|
209
|
+
// out-of-repo claims are checked for shape only and stay unverified; a claim may name the config sites it decides
|
|
210
|
+
const outOfRepo = list('outOfRepo', f.outOfRepo).flatMap((x, i) => {
|
|
211
|
+
const where = `outOfRepo[${i}]`;
|
|
212
|
+
if (!isObj(x)) { problem(where, 'must be {claim, evidence, selects?}'); return []; }
|
|
213
|
+
keys(where, x, ['claim', 'evidence', 'selects']);
|
|
214
|
+
needReason(where, x.claim, 'claim');
|
|
215
|
+
if (typeof x.evidence !== 'string' || !x.evidence.trim()) problem(where, 'needs "evidence" (where the claim can be checked)');
|
|
216
|
+
return [{ claim: x.claim, evidence: x.evidence, selects: list(`${where} selects`, x.selects), verified: false }];
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/* ---------------- hops: existence, groups, lines, citations ---------------- */
|
|
220
|
+
const flowFroms = new Map([...items].filter(([, it]) => it.kind === 'flow' && entry.has(it.def.from)).map(([id, it]) => [it.def.from, id]));
|
|
221
|
+
const itemGroup = (id, it) => it.kind === 'flow' ? entry.get(it.def.from)?.group : null;
|
|
222
|
+
const CITATION_FIELDS = ['test', 'say', 'lines', 'strength'];
|
|
223
|
+
const citation = (where, hopSym, c, fields = CITATION_FIELDS) => {
|
|
224
|
+
if (!isObj(c)) { problem(where, 'a citation is {test, say, lines?, strength?}'); return null; }
|
|
225
|
+
keys(where, c, fields);
|
|
226
|
+
needReason(`${where} ${c.test}`, c.say, 'say');
|
|
227
|
+
const t = testById.get(c.test);
|
|
228
|
+
if (!t) { problem(where, `test ${c.test} is not in the code map`); return null; }
|
|
229
|
+
if (c.strength != null && c.strength !== 'direct' && c.strength !== 'reachable') problem(where, `strength must be "direct" or "reachable", not "${c.strength}"`);
|
|
230
|
+
const strength = c.strength === 'reachable' ? 'reachable' : 'direct';
|
|
231
|
+
const link = t.direct.find(l => l.to === hopSym);
|
|
232
|
+
if (t.mocked.includes(hopSym)) problem(where, `test ${c.test} mocks ${hopSym}: a test that replaces a hop cannot pin it`);
|
|
233
|
+
else if (strength === 'direct' && !link) problem(where, `test ${c.test} has no direct link to ${hopSym}${reach.get(c.test)?.has(hopSym) ? ' (it only reaches it: cite it with "strength": "reachable", which narration may not call tested)' : ''}`);
|
|
234
|
+
else if (strength === 'reachable' && !link && !reach.get(c.test)?.has(hopSym)) problem(where, `test ${c.test} does not reach ${hopSym}`);
|
|
235
|
+
const lines = parseLines(`${where} ${c.test}`, c.lines, t.line, t.end);
|
|
236
|
+
return { test: c.test, file: t.file, line: t.line, end: t.end, asserts: t.asserts, assertLines: t.assertLines, lines, say: c.say,
|
|
237
|
+
strength, via: link?.via || null, observed: null };
|
|
238
|
+
};
|
|
239
|
+
function readHops(it, id) {
|
|
240
|
+
const where = `${it.kind} ${id}`, grp = itemGroup(id, it);
|
|
241
|
+
const raw = list(`${where} hops`, it.def.hops);
|
|
242
|
+
if (!raw.length) problem(where, 'has no hops');
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
return raw.flatMap((h0, i) => {
|
|
245
|
+
const h = typeof h0 === 'string' ? { symbol: h0 } : h0, at = `${where} hop ${i + 1}`;
|
|
246
|
+
if (!isObj(h) || typeof h.symbol !== 'string') { problem(at, 'must be a symbol id or {symbol, …}'); return []; }
|
|
247
|
+
keys(at, h, ['symbol', 'lines', 'pinnedBy', 'exercises', 'uses', 'break']);
|
|
248
|
+
const s = sym.get(h.symbol);
|
|
249
|
+
if (!s) { problem(at, `${h.symbol} is not a symbol in the code map`); return []; }
|
|
250
|
+
if (seen.has(h.symbol)) problem(at, `${h.symbol} is already a hop of this ${it.kind}`);
|
|
251
|
+
seen.add(h.symbol);
|
|
252
|
+
if (s.group === 'test') problem(at, `${h.symbol} is test-group code: tests are cited with pinnedBy, never narrated as hops`);
|
|
253
|
+
else if ((grp === 'eval' || grp === 'dev') && s.group === 'prod') problem(at, `${h.symbol} is production code: an ${grp} flow names the production flow it drives with "exercises" on its own hop instead of re-narrating it`);
|
|
254
|
+
if (h.break != null && typeof h.break !== 'boolean') problem(at, '"break" is true or false');
|
|
255
|
+
return [{ index: i + 1, symbol: h.symbol, name: h.symbol.split(':').slice(1).join(':'), group: s.group, kind: s.kind, file: s.file, start: s.start, end: s.end, sha256: s.sha256,
|
|
256
|
+
lines: parseLines(at, h.lines, s.start, s.end), break: h.break === true, uses: h.uses ?? null, exercises: h.exercises ?? null,
|
|
257
|
+
pinnedBy: list(`${at} pinnedBy`, h.pinnedBy).map((c, j) => citation(`${at} pinnedBy[${j}]`, h.symbol, c)).filter(Boolean) }];
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const hopsOf = new Map([...items].map(([id, it]) => [id, readHops(it, id)]));
|
|
261
|
+
for (const [id, it] of items) if (it.kind === 'core') list(`core ${id} pinnedBy`, it.def.pinnedBy).forEach((c, j) => {
|
|
262
|
+
const where = `core ${id} pinnedBy[${j}]`, hop = isObj(c) && hopsOf.get(id).find(h => h.symbol === c.symbol);
|
|
263
|
+
if (!hop) return problem(where, `${isObj(c) ? c.symbol : c} is not a hop of this core`);
|
|
264
|
+
const x = citation(where, c.symbol, c, ['symbol', ...CITATION_FIELDS]); if (x) hop.pinnedBy.push(x);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/* ---------------- connectivity (C3) and dispatch consistency ---------------- */
|
|
268
|
+
// the walk trees an item's hops may follow: a flow's own candidate and its variants'; a core's variants' and every
|
|
269
|
+
// series flow's (a core is code the flows share)
|
|
270
|
+
const treeOf = new Map();
|
|
271
|
+
const tree = cid => { if (!treeOf.has(cid)) { const parents = new Map(), children = new Map(), down = new Map(), ids = new Set();
|
|
272
|
+
const add = (m2, a, b) => (m2.get(a) || m2.set(a, new Set()).get(a)).add(b);
|
|
273
|
+
for (const n of r.candidates[cid]?.nodes || []) { ids.add(n.id); if (n.parent) { add(children, n.parent, n.id); add(parents, n.id, n.parent);
|
|
274
|
+
const kk = `${n.parent}>${n.id}`; if (!down.has(kk)) down.set(kk, n.via); } }
|
|
275
|
+
treeOf.set(cid, { id: cid, parents, children, down, ids, nodes: r.candidates[cid]?.nodes || [] }); } return treeOf.get(cid); };
|
|
276
|
+
const treesFor = (id, it) => {
|
|
277
|
+
const vs = (variantsOf.get(id) || []).map(v => v.entry);
|
|
278
|
+
const own = it.kind === 'flow' ? [it.def.from] : [...flowFroms.keys()];
|
|
279
|
+
return [...new Set([...own, ...vs])].filter(c => r.candidates[c]).map(tree);
|
|
280
|
+
};
|
|
281
|
+
// the shortest walk-tree path between two hops, at most MAX_PATH steps: back up the call stack (returns), then down
|
|
282
|
+
// (calls). Never down then up: two callers of one shared callee are not connected through it
|
|
283
|
+
const treePath = (t, a, b) => {
|
|
284
|
+
if (!t.ids.has(a) || !t.ids.has(b)) return null;
|
|
285
|
+
const key = (id, dn) => `${dn ? 'v' : '^'}${id}`;
|
|
286
|
+
const prev = new Map([[key(a, false), null]]); let frontier = [[a, false]], hit = null;
|
|
287
|
+
for (let d = 0; d < MAX_PATH && frontier.length && !hit; d++) { const next = [];
|
|
288
|
+
for (const [x, dn] of frontier) {
|
|
289
|
+
const moves = [...(dn ? [] : [...(t.parents.get(x) || [])].map(y => [y, false])), ...[...(t.children.get(x) || [])].map(y => [y, true])];
|
|
290
|
+
for (const [y, ydn] of moves) { const k = key(y, ydn); if (prev.has(k)) continue; prev.set(k, key(x, dn)); next.push([y, ydn]); if (y === b && !hit) hit = k; }
|
|
291
|
+
}
|
|
292
|
+
frontier = next; }
|
|
293
|
+
if (!hit) return null;
|
|
294
|
+
const keys = []; for (let k = hit; k != null; k = prev.get(k)) keys.unshift(k);
|
|
295
|
+
const path = keys.map(k => k.slice(1));
|
|
296
|
+
const steps = path.slice(1).map((y, i) => keys[i + 1][0] === 'v' ? { from: path[i], to: y, kind: t.down.get(`${path[i]}>${y}`), down: true } : { from: path[i], to: y, kind: 'return', down: false });
|
|
297
|
+
return { how: 'candidate', candidate: t.id, path, steps };
|
|
298
|
+
};
|
|
299
|
+
// the shortest directed path over recorded edges of any kind, never into test code; a callback runs where it was handed over
|
|
300
|
+
const edgePath = (a, b) => {
|
|
301
|
+
const prev = new Map([[a, null]]); let frontier = [a];
|
|
302
|
+
for (let d = 0; d < MAX_PATH && frontier.length && !prev.has(b); d++) { const next = [];
|
|
303
|
+
for (const x of frontier) {
|
|
304
|
+
const es = [...(out.get(x) || []), ...(handedBy.get(x) || [])];
|
|
305
|
+
for (const e of es) if (!prev.has(e.to) && G(e.to) !== 'test') { prev.set(e.to, { x, e }); next.push(e.to); }
|
|
306
|
+
}
|
|
307
|
+
frontier = next; }
|
|
308
|
+
if (!prev.has(b)) return null;
|
|
309
|
+
const steps = []; for (let y = b; prev.get(y); y = prev.get(y).x) { const { x, e } = prev.get(y);
|
|
310
|
+
steps.unshift({ from: x, to: y, kind: e.kind, rule: e.rule || null, evidence: e.evidence || (e.line != null ? `${sym.get(x)?.file}:${e.line}` : null), site: e.site || null, down: true }); }
|
|
311
|
+
return { how: 'edges', path: [a, ...steps.map(s => s.to)], steps };
|
|
312
|
+
};
|
|
313
|
+
const linksOf = new Map([...items].map(([id, it]) => [id, list(`${it.kind} ${id} links`, it.def.links).flatMap((l, i) => {
|
|
314
|
+
const where = `${it.kind} ${id} links[${i}]`;
|
|
315
|
+
if (!isObj(l)) { problem(where, 'must be {from, to, kind, evidence}'); return []; }
|
|
316
|
+
keys(where, l, ['from', 'to', 'kind', 'evidence']);
|
|
317
|
+
let ok = true;
|
|
318
|
+
for (const e of ['from', 'to']) if (!sym.has(l[e])) { problem(where, `${e} ${l[e]} is not a symbol in the code map`); ok = false; }
|
|
319
|
+
if (typeof l.kind !== 'string' || !l.kind) { problem(where, 'needs a kind (spawn, http, queue, static, …)'); ok = false; }
|
|
320
|
+
const ev = typeof l.evidence === 'string' && l.evidence.match(/^([^\s:]+):(\d+)(?:-(\d+))?/);
|
|
321
|
+
if (!ev) { problem(where, 'evidence must start with file:line or file:a-b in the repo'); ok = false; }
|
|
322
|
+
else { const n = linesOf(ev[1]), a = Number(ev[2]), b = Number(ev[3] || ev[2]);
|
|
323
|
+
if (n == null) { problem(where, `evidence names ${ev[1]}, which is not a file in the repo`); ok = false; }
|
|
324
|
+
else if (a > b || b > n) { problem(where, `evidence ${ev[1]}:${a}-${b} is past the end of the file (${n} lines)`); ok = false; } }
|
|
325
|
+
return ok ? [{ from: l.from, to: l.to, kind: l.kind, evidence: l.evidence }] : [];
|
|
326
|
+
})]));
|
|
327
|
+
// a hop on a config alternative that no profile selects is a branch to teach, not the story
|
|
328
|
+
const unselected = (hop, trees) => {
|
|
329
|
+
for (const t of trees) for (const d of r.candidates[t.id].dispatch || []) {
|
|
330
|
+
if (!d.targets.includes(hop.symbol) || !d.config || !d.narrowedBy) continue;
|
|
331
|
+
const site = configSite.get(d.narrowedBy), cls = sym.get(hop.symbol)?.cls;
|
|
332
|
+
const alt = site?.alternatives.find(a => a.cls === cls || a.cls === hop.symbol);
|
|
333
|
+
if (!alt || !Object.keys(alt.sel || {}).length || !Object.values(alt.sel).every(v => v === 'no')) continue;
|
|
334
|
+
const claim = outOfRepo.find(o => o.selects.includes(d.narrowedBy));
|
|
335
|
+
if (claim) { hop.selectedBy = { outOfRepo: claim.claim, verified: false }; continue; }
|
|
336
|
+
return `${hop.symbol} is the ${alt.cls.split(':').pop()} alternative at ${d.site}, which no profile selects (${Object.entries(site.decidedByProfile || {}).map(([p, c]) => `${p} → ${String(c).split(':').pop()}`).join(', ')}): teach it instead, or name ${d.narrowedBy} in the "selects" of an outOfRepo claim`;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
};
|
|
340
|
+
for (const [id, it] of items) {
|
|
341
|
+
const hops = hopsOf.get(id), trees = treesFor(id, it), links = linksOf.get(id), where = `${it.kind} ${id}`;
|
|
342
|
+
const restart = new Set([...(it.kind === 'flow' ? [it.def.from] : []), ...(variantsOf.get(id) || []).map(v => v.entry)]);
|
|
343
|
+
let chainStart = 0;
|
|
344
|
+
hops.forEach((h, i) => {
|
|
345
|
+
if (i === 0) { h.connection = { how: it.kind === 'flow' ? 'entry' : 'start', path: [h.symbol], steps: [] };
|
|
346
|
+
if (it.kind === 'flow' && h.symbol !== it.def.from) problem(`${where} hop 1`, `a flow opens on its entry: the first hop must be ${it.def.from}, not ${h.symbol}`);
|
|
347
|
+
return; }
|
|
348
|
+
if (restart.has(h.symbol)) { h.connection = { how: 'variant', path: [h.symbol], steps: [] }; chainStart = i; return; }
|
|
349
|
+
// from the previous hop, else (returning up the narrated stack) the nearest earlier hop that connects; shortest first
|
|
350
|
+
let found = null;
|
|
351
|
+
for (let j = i - 1; j >= chainStart && !found; j--) {
|
|
352
|
+
const a = hops[j].symbol, l = links.find(x => x.from === a && x.to === h.symbol);
|
|
353
|
+
for (const c of [...trees.map(t => treePath(t, a, h.symbol)), edgePath(a, h.symbol),
|
|
354
|
+
l && { how: 'link', path: [a, h.symbol], steps: [{ from: a, to: h.symbol, kind: l.kind, evidence: l.evidence, down: true, declared: true }] }])
|
|
355
|
+
if (c && (!found || c.steps.length < found.steps.length)) found = c;
|
|
356
|
+
}
|
|
357
|
+
if (!found) return problem(`${where}`, `hop ${i}→${i + 1} not connected: ${h.symbol} is not within ${MAX_PATH} calls of ${hops[i - 1].symbol} or an earlier hop (candidate walk, recorded edges or a declared link)`);
|
|
358
|
+
h.connection = found;
|
|
359
|
+
const alt = unselected(h, trees);
|
|
360
|
+
if (alt) problem(`${where} hop ${i + 1}`, alt);
|
|
361
|
+
else if (found.how === 'edges' && found.steps.some(s => s.kind === 'virtual' || s.kind === 'dispatch') && !trees.some(t => t.ids.has(h.symbol)))
|
|
362
|
+
problem(`${where} hop ${i + 1}`, `${h.symbol} is reached only through a dispatch the walk's receiver does not select (${found.steps.find(s => s.kind === 'virtual' || s.kind === 'dispatch').site || 'CHA'}): teach it instead`);
|
|
363
|
+
});
|
|
364
|
+
for (const h of hops) h.crossing = !!h.exercises || (h.connection?.steps || []).some(s => s.down && CROSSING.has(s.kind));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* ---------------- uses, exercises, teach, segments ---------------- */
|
|
368
|
+
for (const [id, it] of items) for (const h of hopsOf.get(id)) {
|
|
369
|
+
const at = `${it.kind} ${id} hop ${h.index}`;
|
|
370
|
+
if (h.uses != null && items.get(h.uses)?.kind !== 'core') problem(at, `uses "${h.uses}", which is not a core in this file`);
|
|
371
|
+
if (h.exercises == null) continue;
|
|
372
|
+
const grp = itemGroup(id, it), target = items.get(h.exercises);
|
|
373
|
+
if (grp !== 'eval' && grp !== 'dev') { problem(at, '"exercises" marks the hop of an eval or dev flow that drives a production flow'); continue; }
|
|
374
|
+
if (!target || itemGroup(h.exercises, target) !== 'prod') { problem(at, `exercises "${h.exercises}", which is not a production flow in this file`); continue; }
|
|
375
|
+
const code = new Set([target.def.from, ...(variantsOf.get(h.exercises) || []).map(v => v.entry)].flatMap(c => [...(r.candidates[c]?.ownIds || []), c]));
|
|
376
|
+
for (const x of hopsOf.get(h.exercises)) code.add(x.symbol);
|
|
377
|
+
// how it drives it: the walk's crossing, else a declared link, else a direct call folded as a uses-prod leaf
|
|
378
|
+
const c = r.candidates[it.def.from];
|
|
379
|
+
const cross = (c?.crossings || []).find(x => x.from === h.symbol && code.has(x.to));
|
|
380
|
+
const link = linksOf.get(id).find(l => l.from === h.symbol && code.has(l.to));
|
|
381
|
+
const call = (c?.nodes || []).find(n => n.parent === h.symbol && n.folded === 'uses-prod' && code.has(n.id));
|
|
382
|
+
h.drive = cross ? { kind: cross.kind, to: cross.to, evidence: cross.evidence || null } : link ? { kind: link.kind, to: link.to, evidence: link.evidence }
|
|
383
|
+
: call ? { kind: 'call', to: call.id, evidence: null } : null;
|
|
384
|
+
if (!h.drive) problem(at, `${h.symbol} exercises ${h.exercises}, but it calls none of that flow's code`);
|
|
385
|
+
}
|
|
386
|
+
const usesGraph = new Map([...items].filter(([, it]) => it.kind === 'core').map(([id]) => [id, hopsOf.get(id).map(h => h.uses).filter(u => items.get(u)?.kind === 'core')]));
|
|
387
|
+
const cycle = (id, stack = []) => stack.includes(id) ? [...stack, id] : (usesGraph.get(id) || []).map(u => cycle(u, [...stack, id])).find(Boolean) || null;
|
|
388
|
+
for (const id of usesGraph.keys()) { const c = cycle(id); if (c && c[0] === id) problem(`core ${id}`, `uses itself through ${c.join(' → ')}`); }
|
|
389
|
+
|
|
390
|
+
// a taught branch is narrated with the hop that holds its point: the hop itself, else its nearest narrated ancestor
|
|
391
|
+
// in the walk, else the opening hop
|
|
392
|
+
const teachHop = (id, it, points) => {
|
|
393
|
+
const hops = hopsOf.get(id), at = new Map(hops.map(h => [h.symbol, h.index]));
|
|
394
|
+
for (const p of points) if (at.has(p)) return at.get(p);
|
|
395
|
+
for (const t of treesFor(id, it)) for (const n of t.nodes) if (points.includes(n.id)) {
|
|
396
|
+
const byId = new Map(t.nodes.map(x => [x.id, x]));
|
|
397
|
+
for (let x = byId.get(n.parent), seen = new Set(); x && !seen.has(x.id); seen.add(x.id), x = byId.get(x.parent)) if (at.has(x.id)) return at.get(x.id);
|
|
398
|
+
}
|
|
399
|
+
return hops[0]?.index ?? 1;
|
|
400
|
+
};
|
|
401
|
+
const guardEdges = new Map();
|
|
402
|
+
for (const e of m.edges) if (e.cfg) guardEdges.set(`${e.from}@${e.line}`, e);
|
|
403
|
+
const teachOf = new Map([...items].map(([id, it]) => [id, list(`${it.kind} ${id} teach`, it.def.teach).flatMap((t, i) => {
|
|
404
|
+
const where = `${it.kind} ${id} teach[${i}]`, hops = hopsOf.get(id);
|
|
405
|
+
if (!isObj(t)) { problem(where, 'must be {dispatch|guard, say}'); return []; }
|
|
406
|
+
keys(where, t, ['dispatch', 'guard', 'say']);
|
|
407
|
+
needReason(where, t.say, 'say');
|
|
408
|
+
let target = null, at = null, alternatives = null;
|
|
409
|
+
if (t.dispatch != null) {
|
|
410
|
+
const site = configSite.get(t.dispatch) || (m.factories?.[t.dispatch] && { factory: t.dispatch });
|
|
411
|
+
const ds = (m.dispatchSites || []).find(d => d.site === t.dispatch) || Object.values(r.candidates).flatMap(c => c.dispatch).find(d => d.site === t.dispatch);
|
|
412
|
+
if (!site && !ds) { problem(where, `${t.dispatch} is not a recorded dispatch point (a config site, a factory or a dispatch site)`); return []; }
|
|
413
|
+
target = site ? t.dispatch : t.dispatch.split('@')[0]; at = site ? t.dispatch : ds.from || target;
|
|
414
|
+
alternatives = site && configSite.get(t.dispatch) ? configSite.get(t.dispatch).alternatives.map(a => ({ cls: a.cls, guards: a.guards, sel: a.sel })) : ds?.targets || null;
|
|
415
|
+
} else if (t.guard != null) {
|
|
416
|
+
const g = guardEdges.get(t.guard);
|
|
417
|
+
if (!g) { problem(where, `${t.guard} is not a recorded config guard (<symbol>@<line> of a call under a config read)`); return []; }
|
|
418
|
+
target = g.to; at = g.from; alternatives = [{ to: g.to, guards: g.cfg }];
|
|
419
|
+
} else { problem(where, 'names a "dispatch" point or a "guard"'); return []; }
|
|
420
|
+
return [{ ...(t.dispatch != null ? { dispatch: t.dispatch } : { guard: t.guard }), say: t.say, hop: teachHop(id, it, t.guard != null ? [target, at] : [at, target]), alternatives }];
|
|
421
|
+
})]));
|
|
422
|
+
|
|
423
|
+
const segmentsOf = new Map();
|
|
424
|
+
for (const [id, it] of items) {
|
|
425
|
+
if (it.kind !== 'flow' || !r.candidates[it.def.from]) continue;
|
|
426
|
+
const where = `flow ${id} segments`, c = r.candidates[it.def.from], s = it.def.segments ?? {};
|
|
427
|
+
if (!isObj(s)) { problem(where, 'must be {keep, mention, exclude}'); continue; }
|
|
428
|
+
keys(where, s, ['keep', 'mention', 'exclude']);
|
|
429
|
+
const roots = new Map(c.segments.map(x => [x.root, x]));
|
|
430
|
+
const decision = new Map([[it.def.from, { decision: 'entry' }]]);
|
|
431
|
+
const set = (root, d, at) => {
|
|
432
|
+
if (!roots.has(root)) return problem(at, `${root} is not a segment of ${it.def.from} (segments: ${[...roots.keys()].join(', ')})`);
|
|
433
|
+
if (decision.has(root)) return problem(at, `${root} is already ${decision.get(root).decision}`);
|
|
434
|
+
decision.set(root, d);
|
|
435
|
+
};
|
|
436
|
+
list(`${where}.keep`, s.keep).forEach((x, i) => set(x, { decision: 'keep' }, `${where}.keep[${i}]`));
|
|
437
|
+
list(`${where}.mention`, s.mention).forEach((x, i) => { const at = `${where}.mention[${i}]`;
|
|
438
|
+
if (!isObj(x)) return problem(at, 'must be {root, say}'); keys(at, x, ['root', 'say']); needReason(`${at} ${x.root}`, x.say, 'say'); set(x.root, { decision: 'mention', say: x.say }, at); });
|
|
439
|
+
list(`${where}.exclude`, s.exclude).forEach((x, i) => { const at = `${where}.exclude[${i}]`;
|
|
440
|
+
if (!isObj(x)) return problem(at, 'must be {root, why}'); keys(at, x, ['root', 'why']); needReason(`${at} ${x.root}`, x.why); set(x.root, { decision: 'exclude', why: x.why }, at); });
|
|
441
|
+
for (const root of roots.keys()) if (!decision.has(root)) {
|
|
442
|
+
const rule = excludedBy(root);
|
|
443
|
+
if (rule) { rule.matched.push(root); decision.set(root, { decision: 'exclude', why: rule.why, rule: rule.match }); }
|
|
444
|
+
else problem(`flow ${id}`, `segment ${root} (reached from ${roots.get(root).from} by ${roots.get(root).kind}) is untriaged: keep it, mention it with a "say", or exclude it with a "why"`);
|
|
445
|
+
}
|
|
446
|
+
segmentsOf.set(id, c.segments.map(x => ({ root: x.root, from: x.from, kind: x.kind || null, own: x.own, folded: x.folded, ...(decision.get(x.root) || { decision: null }) })));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* ---------------- entry triage (C1) and high-ranked rows (C1′) ---------------- */
|
|
450
|
+
const nonTest = m.entries.filter(e => e.group !== 'test');
|
|
451
|
+
const triage = nonTest.map(e => {
|
|
452
|
+
const d = decided.get(e.id), c = r.candidates[e.id], parents = (c?.continues || []).filter(p => flowFroms.has(p));
|
|
453
|
+
const rule = excludedBy(e.id);
|
|
454
|
+
let status = null, reason = null, by = null;
|
|
455
|
+
if (d?.status === 'flow') { status = 'flow'; by = d.flow; }
|
|
456
|
+
else if (parents.length) { status = 'continuation'; by = parents.map(p => flowFroms.get(p)).join(', '); }
|
|
457
|
+
else if (d) { status = d.status; reason = d.why; by = d.of || null; }
|
|
458
|
+
else if (rule) { status = 'excluded'; reason = rule.why; by = rule.match; }
|
|
459
|
+
else if (e.group === 'dev' && !promoted.has(e.id) && c?.continues?.length) { status = 'continuation'; by = c.continues.join(', '); reason = 'continues a dev row, filtered with it'; }
|
|
460
|
+
else if (e.group === 'dev' && !promoted.has(e.id)) status = 'dev-filtered';
|
|
461
|
+
if (rule && status === 'excluded') rule.matched.push(e.id);
|
|
462
|
+
return { id: e.id, group: e.group, kind: e.kind, route: e.path ? [e.method, e.path].filter(Boolean).join(' ') : e.key || null, rank: rankOf.get(e.id)?.rank ?? null,
|
|
463
|
+
continues: c?.continues || null, promoted: promoted.has(e.id), status, reason, by };
|
|
464
|
+
});
|
|
465
|
+
for (const t of triage) if (!t.status) problem(`entry ${t.id}`, `(${t.group}${t.rank ? ` #${t.rank}` : ''}) is untriaged: make it a flow, a variant, skip it with a reason, or exclude it with a reason${t.group === 'eval' ? ' (eval rows are never triaged implicitly)' : t.promoted ? ' (it is promoted)' : ''}`);
|
|
466
|
+
for (const x of excluded) if (!x.matched.length) problem(`triage.excluded ${x.match}`, 'matches no entry or segment');
|
|
467
|
+
|
|
468
|
+
const K_OF = { prod: Math.max(10, k.params.explainTop), eval: Math.max(5, k.params.explainTopEval), dev: 0 };
|
|
469
|
+
const highRanked = Object.fromEntries(Object.entries(k.ranked).map(([grp, rows]) => {
|
|
470
|
+
const included = rows.filter(x => flowFroms.has(x.entry)).map(x => x.rank);
|
|
471
|
+
const lowest = included.length ? Math.max(...included) : 0;
|
|
472
|
+
const high = rows.filter(x => x.rank <= K_OF[grp] || x.rank <= lowest).map(x => {
|
|
473
|
+
const d = decided.get(x.entry);
|
|
474
|
+
const status = d ? d.status : null;
|
|
475
|
+
if (!d) problem(`${grp} #${x.rank} ${x.entry}`, `is a high-ranked row (rank <= ${K_OF[grp]}${lowest > K_OF[grp] ? ` or at or above #${lowest}, the lowest-ranked row the series includes` : ''}) and is unexplained: include it, list it as a variant, or skip it with a reason${excludedBy(x.entry) ? ' (an excluded glob does not explain a high-ranked row)' : ''}`);
|
|
476
|
+
return { rank: x.rank, entry: x.entry, status, why: d?.why || null };
|
|
477
|
+
});
|
|
478
|
+
const n = s => high.filter(x => x.status === s).length;
|
|
479
|
+
return [grp, { k: K_OF[grp], lowestIncluded: lowest || null, rows: high, summary: { highRanked: high.length, series: n('flow'), variant: n('variant'), skipped: n('skipped'), unexplained: n(null) } }];
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
/* ---------------- test configuration ---------------- */
|
|
483
|
+
const autouse = new Set((m.fixtures || []).filter(x => x.autouse).map(x => x.id));
|
|
484
|
+
const tests = {
|
|
485
|
+
sessionMocks: (m.mocks || []).filter(x => x.scope === 'session').map(x => ({ target: x.target || x.raw, conftest: x.conftest, line: x.line })),
|
|
486
|
+
autouseMocks: (m.mocks || []).filter(x => autouse.has(x.fn)).map(x => ({ target: x.target || x.raw, fixture: x.fn, line: x.line })),
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/* ---------------- write ---------------- */
|
|
490
|
+
const itemOut = (id, it) => {
|
|
491
|
+
const g = it.kind === 'flow' ? itemGroup(id, it) : [...new Set(hopsOf.get(id).map(h => h.group))].sort().join('+') || null;
|
|
492
|
+
const s = series.find(x => x.id === id);
|
|
493
|
+
const e = it.kind === 'flow' ? entry.get(it.def.from) : null;
|
|
494
|
+
return { id, kind: it.kind, title: it.def.title, group: g, why: s?.why ?? null,
|
|
495
|
+
...(e ? { from: e.id, rank: rankText(e.id), entry: { kind: e.kind, method: e.method, path: e.path, key: e.key, file: e.file, line: e.line } } : {}),
|
|
496
|
+
hops: hopsOf.get(id), segments: segmentsOf.get(id) || [], variants: variantsOf.get(id) || [], teach: teachOf.get(id) || [], links: linksOf.get(id) || [] };
|
|
497
|
+
};
|
|
498
|
+
const validated = {
|
|
499
|
+
schema: 1, tool: { name: 'apply-flows.mjs' }, ok: problems.length === 0, root: m.root, commit: m.commit, treeHash: m.treeHash,
|
|
500
|
+
inputs: { flows: { file: basename(FLOWS), sha256: sha(F.text) }, candidates: { file: relative(OUT, CANDIDATES), sha256: sha(C.text) },
|
|
501
|
+
codemap: { file: relative(OUT, join(IN, 'codemap.json')), sha256: sha(M.text) }, ranking: { file: relative(OUT, join(IN, 'ranking.json')), sha256: sha(K.text) } },
|
|
502
|
+
params: { explainTop: K_OF.prod, explainTopEval: K_OF.eval, maxPath: MAX_PATH },
|
|
503
|
+
series: series.map(s => { const it = items.get(s.id); return { kind: s.kind, id: s.id, title: it.def.title, why: s.why, rank: it.kind === 'flow' ? rankText(it.def.from) : null }; }),
|
|
504
|
+
flows: [...items].filter(([, it]) => it.kind === 'flow').map(([id, it]) => itemOut(id, it)),
|
|
505
|
+
cores: [...items].filter(([, it]) => it.kind === 'core').map(([id, it]) => itemOut(id, it)),
|
|
506
|
+
skipped, promote, excluded: excluded.map(({ match, why, matched }) => ({ match, why, matched })),
|
|
507
|
+
triage: { entries: triage, testGroupEntries: m.entries.filter(e => e.group === 'test').map(e => e.id), highRanked },
|
|
508
|
+
outOfRepo, tests,
|
|
509
|
+
};
|
|
510
|
+
const estimate = problems.length ? null : planVideos(validated, {});
|
|
511
|
+
writeFileSync(join(OUT, 'flows.md'), renderFlows(validated, problems, estimate, r));
|
|
512
|
+
const VALIDATED = join(OUT, 'flows.validated.json');
|
|
513
|
+
const byGroup = grp => triage.filter(t => t.group === grp);
|
|
514
|
+
const count = (xs, s) => xs.filter(t => t.status === s).length;
|
|
515
|
+
const hopsAll = [...hopsOf.values()].flat(), cited = hopsAll.flatMap(h => h.pinnedBy);
|
|
516
|
+
for (const [grp, h] of Object.entries(highRanked)) if (h.rows.length) console.log(`[apply-flows] ${grp}: ${h.summary.highRanked} high-ranked · ${h.summary.series} in series · ${h.summary.variant} variant · ${h.summary.skipped} skipped · ${h.summary.unexplained} unexplained`);
|
|
517
|
+
console.log(`[apply-flows] ${m.entries.length} entries · prod ${byGroup('prod').length} · eval ${byGroup('eval').length} · dev ${byGroup('dev').length} (${count(byGroup('dev'), 'dev-filtered')} filtered, ${count(byGroup('dev'), 'continuation')} continuations) · ${triage.filter(t => !t.status).length} untriaged · test-group entries: ${validated.triage.testGroupEntries.length} (listed)`);
|
|
518
|
+
const segs = [...segmentsOf.values()].flat().filter(x => x.decision !== 'entry');
|
|
519
|
+
const decisions = d => segs.filter(x => x.decision === d).length;
|
|
520
|
+
console.log(`[apply-flows] segments of kept candidates ${segs.length}: ${decisions('keep')} kept · ${decisions('mention')} mentioned · ${decisions('exclude')} excluded`);
|
|
521
|
+
console.log(`[apply-flows] series ${series.length} (${series.filter(s => s.kind === 'flow').length} flows, ${series.filter(s => s.kind === 'core').length} cores) · ${hopsAll.length} hops · ${hopsAll.filter(h => h.crossing).length} crossings · ${cited.length} cited tests (${cited.filter(c => c.strength === 'reachable').length} reachable only)`);
|
|
522
|
+
if (problems.length) {
|
|
523
|
+
if (existsSync(VALIDATED)) rmSync(VALIDATED);
|
|
524
|
+
console.error(`[apply-flows] ${problems.length} problem(s); flows.validated.json not written:`);
|
|
525
|
+
for (const p of problems) console.error(` - ${p}`);
|
|
526
|
+
console.log(`[apply-flows] wrote ${relative(process.cwd(), join(OUT, 'flows.md'))}`);
|
|
527
|
+
process.exit(2);
|
|
528
|
+
}
|
|
529
|
+
writeFileSync(VALIDATED, JSON.stringify(validated, null, 1) + '\n');
|
|
530
|
+
console.log(`[apply-flows] estimate: ${estimate.videos.length} videos, ${estimate.totals.seriesMinutes} min, longest ${estimate.totals.longestVideoMin} min (cap ${estimate.totals.capMinutes})`);
|
|
531
|
+
console.log(`[apply-flows] 0 problems · wrote ${relative(process.cwd(), OUT) || '.'}/flows.{validated.json,md}: show the user the approval table in flows.md before approve-flows`);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
approve-flows.mjs — record the user's approval of a validated flow list.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
node tools/approve-flows.mjs --flows out/<name>/flows.validated.json --by user --note "<the user's words>"
|
|
7
|
+
|
|
8
|
+
Run it only after the user said yes to flows.md's approval table as shown
|
|
9
|
+
(design §4.4b): code cannot prove a human said yes, so the skill requires it.
|
|
10
|
+
Writes flows.approved.json beside the validated flows:
|
|
11
|
+
{ flowsHash: sha256(flows.validated.json), series, approvedBy, at, note }
|
|
12
|
+
The hash binds the approval to this list: any later edit to flows.json,
|
|
13
|
+
revalidated or not, makes flow-plan refuse until the user approves again.
|
|
14
|
+
|
|
15
|
+
Exit: 0 ok · 1 bad flags · 2 the validated flows are missing, invalid, or stale
|
|
16
|
+
(flows.json changed since apply-flows validated it)
|
|
17
|
+
========================================================================== */
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join, resolve, dirname, relative } from 'node:path';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const fail = (code, msg) => { console.error(`[approve-flows] ${msg}`); process.exit(code); };
|
|
23
|
+
|
|
24
|
+
/* ---------------- args ---------------- */
|
|
25
|
+
const argv = process.argv.slice(2);
|
|
26
|
+
const VALUED = new Set(['--flows', '--by', '--note']);
|
|
27
|
+
const flags = new Map();
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
if (VALUED.has(argv[i])) { if (argv[i + 1] == null) fail(1, `${argv[i]} needs a value`); flags.set(argv[i], argv[++i]); }
|
|
30
|
+
else fail(1, `unknown argument ${argv[i]}`);
|
|
31
|
+
}
|
|
32
|
+
const USAGE = 'usage: approve-flows.mjs --flows out/<name>/flows.validated.json --by user --note "<the user\'s words>"';
|
|
33
|
+
for (const x of VALUED) if (!String(flags.get(x) ?? '').trim()) fail(1, `${x} is required · ${USAGE}`);
|
|
34
|
+
const VALIDATED = resolve(flags.get('--flows')), OUT = dirname(VALIDATED);
|
|
35
|
+
const sha = t => createHash('sha256').update(t).digest('hex');
|
|
36
|
+
|
|
37
|
+
/* ---------------- the validated flows ---------------- */
|
|
38
|
+
if (!existsSync(VALIDATED)) fail(2, `${VALIDATED} does not exist: run apply-flows until it exits 0 first`);
|
|
39
|
+
const text = readFileSync(VALIDATED, 'utf8');
|
|
40
|
+
let v;
|
|
41
|
+
try { v = JSON.parse(text); } catch (e) { fail(2, `${VALIDATED} is not valid JSON: ${e.message}`); }
|
|
42
|
+
if (v?.schema !== 1 || v.tool?.name !== 'apply-flows.mjs' || v.ok !== true || !Array.isArray(v.series) || !v.series.length)
|
|
43
|
+
fail(2, `${VALIDATED} is not a passing apply-flows.mjs output (schema 1)`);
|
|
44
|
+
const flowsFile = join(OUT, v.inputs.flows.file);
|
|
45
|
+
if (existsSync(flowsFile) && sha(readFileSync(flowsFile, 'utf8')) !== v.inputs.flows.sha256)
|
|
46
|
+
fail(2, `${v.inputs.flows.file} changed since apply-flows validated it: rerun apply-flows and show the user the new approval table`);
|
|
47
|
+
|
|
48
|
+
/* ---------------- write ---------------- */
|
|
49
|
+
const approval = {
|
|
50
|
+
schema: 1, tool: { name: 'approve-flows.mjs' }, flowsFile: relative(OUT, VALIDATED), flowsHash: sha(text),
|
|
51
|
+
series: v.series.map(({ kind, id, title, why }) => ({ kind, id, title, why })),
|
|
52
|
+
approvedBy: flags.get('--by').trim(), at: new Date().toISOString(), note: flags.get('--note').trim(),
|
|
53
|
+
};
|
|
54
|
+
writeFileSync(join(OUT, 'flows.approved.json'), JSON.stringify(approval, null, 1) + '\n');
|
|
55
|
+
console.log(`[approve-flows] approved by ${approval.approvedBy} at ${approval.at}: ${approval.series.length} items (${approval.series.map(s => s.id).join(', ')})`);
|
|
56
|
+
console.log(`[approve-flows] bound to flows.validated.json sha256 ${approval.flowsHash.slice(0, 12)}… · wrote ${relative(process.cwd(), join(OUT, 'flows.approved.json'))}`);
|