geneseed 3.10.0 → 3.12.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.
Files changed (227) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/DESIGN.md +1 -1
  3. package/README.md +27 -45
  4. package/SHIPPED.md +2 -2
  5. package/adapters/bob/README.md +1 -1
  6. package/adapters/claude-code/README.md +17 -12
  7. package/adapters/openclaude/README.md +1 -1
  8. package/adapters/opencode/README.md +4 -4
  9. package/adapters/opencode/plugins/geneseed-guard.js +157 -2
  10. package/bin/build-driver.mjs +2 -0
  11. package/bin/geneseed-cli.mjs +21 -0
  12. package/bin/geneseed-hook.mjs +2 -0
  13. package/docs/README.md +42 -0
  14. package/docs/_groups.json +18 -0
  15. package/docs/concepts/agents.md +44 -0
  16. package/docs/concepts/collaboration.md +62 -0
  17. package/docs/concepts/exclusions.md +55 -0
  18. package/docs/concepts/footprint.md +52 -0
  19. package/docs/concepts/foreman-mode.md +69 -0
  20. package/docs/concepts/hooks.md +101 -0
  21. package/docs/concepts/hosts.md +55 -0
  22. package/docs/concepts/loops.md +165 -0
  23. package/docs/concepts/lsp.md +50 -0
  24. package/docs/{web → concepts}/memory.md +2 -2
  25. package/docs/{web → concepts}/notebook.md +2 -2
  26. package/docs/concepts/rules.md +52 -0
  27. package/docs/concepts/skills.md +49 -0
  28. package/docs/concepts/themes.md +50 -0
  29. package/docs/extending.md +32 -21
  30. package/docs/guides/autostart.md +70 -0
  31. package/docs/guides/choose-your-setup.md +134 -0
  32. package/docs/guides/headless.md +22 -0
  33. package/docs/guides/install.md +161 -0
  34. package/docs/guides/mcp.md +195 -0
  35. package/docs/guides/migrate.md +41 -0
  36. package/docs/guides/project-context.md +87 -0
  37. package/docs/guides/run-anywhere.md +45 -0
  38. package/docs/guides/uninstall.md +51 -0
  39. package/docs/guides/upgrade.md +65 -0
  40. package/docs/guides/verify.md +65 -0
  41. package/docs/guides/wiki.md +69 -0
  42. package/docs/guides/worktree.md +47 -0
  43. package/docs/reference/about.md +7 -0
  44. package/docs/reference/cli.md +7 -0
  45. package/docs/reference/environment.md +99 -0
  46. package/docs/reference/glossary.md +37 -0
  47. package/docs/reference/opencode-plugins.md +133 -0
  48. package/docs/reference/troubleshooting.md +350 -0
  49. package/docs/understand/a-day.md +77 -0
  50. package/docs/understand/enforced-vs-asked.md +64 -0
  51. package/docs/understand/harness.md +36 -0
  52. package/docs/understand/on-your-machine.md +76 -0
  53. package/docs/web-ui.md +13 -2
  54. package/harness.config.json +1 -1
  55. package/js/README.md +27 -4
  56. package/js/build/driver.mjs +14 -8
  57. package/js/build/generate.mjs +22 -7
  58. package/js/build/render.mjs +5 -0
  59. package/js/build/source.mjs +11 -1
  60. package/js/cli-table.json +236 -1
  61. package/js/hosts/gitref.mjs +99 -0
  62. package/js/hosts/hooks.mjs +88 -1
  63. package/js/hosts/installs.mjs +21 -0
  64. package/js/hosts/mcp.mjs +1 -1
  65. package/js/hosts/settings.mjs +13 -1
  66. package/js/inspect/checks-authoring.mjs +15 -3
  67. package/js/inspect/diff.mjs +3 -0
  68. package/js/inspect/doctor.mjs +3 -2
  69. package/js/inspect/inventory.mjs +2 -1
  70. package/js/inspect/registry.mjs +20 -3
  71. package/js/inspect/status.mjs +8 -3
  72. package/js/lib/node-floor.mjs +30 -0
  73. package/js/loop/catalog.mjs +116 -0
  74. package/js/loop/cli.mjs +151 -0
  75. package/js/loop/graph.mjs +145 -0
  76. package/js/loop/score.mjs +143 -0
  77. package/js/loop/state.mjs +322 -0
  78. package/js/maintain/migrate.mjs +4 -2
  79. package/js/maintain/setup.mjs +109 -21
  80. package/js/maintain/update.mjs +4 -15
  81. package/js/ui/cli.mjs +2 -2
  82. package/js/web/actions.mjs +9 -3
  83. package/js/web/api.mjs +41 -2
  84. package/js/web/daemon.mjs +94 -4
  85. package/js/web/docs.mjs +149 -65
  86. package/js/web/handler.mjs +1 -0
  87. package/js/web/jobs.mjs +3 -1
  88. package/js/web/server.mjs +10 -5
  89. package/package.json +6 -4
  90. package/registry.json +21 -0
  91. package/src/AGENT.md.tmpl +6 -0
  92. package/src/bricks/README.md +32 -0
  93. package/src/bricks/_template.md +32 -0
  94. package/src/bricks/apply.md +17 -0
  95. package/src/bricks/baseline-green.md +17 -0
  96. package/src/bricks/bruno-test.md +16 -0
  97. package/src/bricks/ci-fix.md +17 -0
  98. package/src/bricks/deps-audit.md +14 -0
  99. package/src/bricks/docs-update.md +15 -0
  100. package/src/bricks/identify.md +20 -0
  101. package/src/bricks/lint.md +14 -0
  102. package/src/bricks/plan.md +17 -0
  103. package/src/bricks/reproduce.md +16 -0
  104. package/src/bricks/review.md +17 -0
  105. package/src/bricks/security-scan.md +17 -0
  106. package/src/bricks/test.md +14 -0
  107. package/src/doctrines/process.md +11 -8
  108. package/src/loops/bugfix.json +22 -0
  109. package/src/loops/feature.json +21 -0
  110. package/src/loops/refactor.json +20 -0
  111. package/src/postures/mentor.md +1 -0
  112. package/src/skills/brick-forge.md +46 -0
  113. package/src/skills/geneseed.md +1 -1
  114. package/src/skills/learn-mode.md +89 -0
  115. package/src/skills/loop.md +99 -0
  116. package/src/skills/pipeline.md +2 -2
  117. package/src/skills/quiz.md +2 -1
  118. package/src/skills/teach.md +2 -1
  119. package/themes/_TEMPLATE.json +3 -0
  120. package/themes/biker.json +3 -0
  121. package/themes/commentator.json +3 -0
  122. package/themes/cyberpunk.json +3 -0
  123. package/themes/gamer.json +3 -0
  124. package/themes/imperial.json +3 -0
  125. package/themes/joker.json +3 -0
  126. package/themes/marvin.json +3 -0
  127. package/themes/mean.json +3 -0
  128. package/themes/military.json +3 -0
  129. package/themes/neutral.json +3 -0
  130. package/themes/pirate.json +3 -0
  131. package/themes/sports.json +3 -0
  132. package/themes/verstappen.json +3 -0
  133. package/themes/wizard.json +3 -0
  134. package/web/dist/assets/Activity-GaaZWXTY.js +1 -0
  135. package/web/dist/assets/ActivityDetail-4t7d45Du.js +1 -0
  136. package/web/dist/assets/Dashboard-CYn7Tp16.js +2 -0
  137. package/web/dist/assets/Docs-BeFKLg2T.js +5 -0
  138. package/web/dist/assets/{GreenhouseView-D0cbCJ5m.js → GreenhouseView-C6ibc8IE.js} +1 -1
  139. package/web/dist/assets/Installs-DRYjYnxs.js +2 -0
  140. package/web/dist/assets/JournalView-1HbU1mfg.js +1 -0
  141. package/web/dist/assets/Laws-DFoG8JWB.js +3 -0
  142. package/web/dist/assets/Library-DDjgNwz_.js +1 -0
  143. package/web/dist/assets/Loops-D9VEoO0F.js +1 -0
  144. package/web/dist/assets/{Markdown-CkFyfgNf.js → Markdown-qjM2_ofS.js} +5 -5
  145. package/web/dist/assets/{OperatorHudView-CiAkAkQL.js → OperatorHudView-Bs7paWmh.js} +1 -1
  146. package/web/dist/assets/Personal-BY5n4gTw.js +1 -0
  147. package/web/dist/assets/Tabs-ho-EYD8i.js +1 -0
  148. package/web/dist/assets/api-CyMRbU2x.js +1 -0
  149. package/web/dist/assets/drawable-CLS9drMb.js +1 -0
  150. package/web/dist/assets/index-CX_hXMi5.css +1 -0
  151. package/web/dist/assets/index-Cjr7XCA3.js +2 -0
  152. package/web/dist/index.html +3 -2
  153. package/QUICKSTART.md +0 -50
  154. package/SETUP.md +0 -1148
  155. package/docs/web/_groups.json +0 -45
  156. package/docs/web/about.md +0 -6
  157. package/docs/web/adapters-claude-code.md +0 -8
  158. package/docs/web/adapters-opencode-loads.md +0 -8
  159. package/docs/web/adapters-opencode-spec.md +0 -8
  160. package/docs/web/adapters-opencode.md +0 -8
  161. package/docs/web/agents.md +0 -8
  162. package/docs/web/authoring.md +0 -11
  163. package/docs/web/autostart.md +0 -9
  164. package/docs/web/claude-hooks.md +0 -37
  165. package/docs/web/cli.md +0 -6
  166. package/docs/web/collaboration.md +0 -27
  167. package/docs/web/configure-wiki.md +0 -9
  168. package/docs/web/design.md +0 -7
  169. package/docs/web/env-knobs.md +0 -9
  170. package/docs/web/exclusions.md +0 -26
  171. package/docs/web/first-session.md +0 -28
  172. package/docs/web/footprint.md +0 -38
  173. package/docs/web/foreman-mode.md +0 -33
  174. package/docs/web/glossary.md +0 -6
  175. package/docs/web/harnesses.md +0 -30
  176. package/docs/web/headless.md +0 -9
  177. package/docs/web/ingest-docs.md +0 -9
  178. package/docs/web/install-paths.md +0 -9
  179. package/docs/web/install-quick.md +0 -35
  180. package/docs/web/lsp-overview.md +0 -37
  181. package/docs/web/mcp-claude-code.md +0 -10
  182. package/docs/web/mcp-filesystem.md +0 -9
  183. package/docs/web/mcp-gitlab.md +0 -9
  184. package/docs/web/mcp-markitdown.md +0 -9
  185. package/docs/web/mcp-overview.md +0 -34
  186. package/docs/web/mcp-trouble.md +0 -9
  187. package/docs/web/mcp-verify.md +0 -9
  188. package/docs/web/migrate.md +0 -39
  189. package/docs/web/model.md +0 -20
  190. package/docs/web/pipelines.md +0 -24
  191. package/docs/web/plugin-activity.md +0 -23
  192. package/docs/web/plugin-context.md +0 -26
  193. package/docs/web/plugin-guard.md +0 -24
  194. package/docs/web/plugin-learn.md +0 -27
  195. package/docs/web/plugin-notify.md +0 -24
  196. package/docs/web/plugin-ponytail.md +0 -23
  197. package/docs/web/plugin-setup.md +0 -7
  198. package/docs/web/plugin-workflow.md +0 -25
  199. package/docs/web/plugins.md +0 -17
  200. package/docs/web/project-context.md +0 -9
  201. package/docs/web/rules.md +0 -30
  202. package/docs/web/run-anywhere.md +0 -9
  203. package/docs/web/self-improve.md +0 -8
  204. package/docs/web/setup-choices.md +0 -64
  205. package/docs/web/skills.md +0 -8
  206. package/docs/web/themes-author.md +0 -20
  207. package/docs/web/themes.md +0 -9
  208. package/docs/web/token-footprint.md +0 -7
  209. package/docs/web/trouble.md +0 -38
  210. package/docs/web/upgrade.md +0 -9
  211. package/docs/web/verify.md +0 -30
  212. package/docs/web/wiki.md +0 -7
  213. package/docs/web/worktree.md +0 -10
  214. package/docs/wiki.md +0 -43
  215. package/web/dist/assets/Activity-BjVp522q.js +0 -1
  216. package/web/dist/assets/ActivityDetail-8ep1bspW.js +0 -1
  217. package/web/dist/assets/Dashboard-OkC_PCH6.js +0 -2
  218. package/web/dist/assets/Docs-CKPWz00o.js +0 -2
  219. package/web/dist/assets/Installs-BtGGdoTt.js +0 -2
  220. package/web/dist/assets/JournalView-Ckl4dmzR.js +0 -1
  221. package/web/dist/assets/Laws-CLpdRRk8.js +0 -3
  222. package/web/dist/assets/Library-wPPuKQrI.js +0 -1
  223. package/web/dist/assets/Personal-D8CnDo9v.js +0 -1
  224. package/web/dist/assets/Tabs-B4HNrrCs.js +0 -1
  225. package/web/dist/assets/drawable-BiwaOo6x.js +0 -1
  226. package/web/dist/assets/index-C1yil5j1.js +0 -2
  227. package/web/dist/assets/index-CDPDk2vj.css +0 -1
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `geneseed loop <action>` — the loop engine's only face. Every action prints exactly one JSON
3
+ * object: the loop skill is a model reading stdout, and a JSON error it can quote beats a
4
+ * stack trace it has to interpret. Exit 1 on any refusal so a script can still branch on it.
5
+ *
6
+ * NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
7
+ * `git diff --cached --numstat` into `score --diff` and `git status --porcelain` into `record`.
8
+ * stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang.
9
+ *
10
+ * NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
11
+ * out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
12
+ * for that filter to mean anything. This entry finds that root itself — walking up from the cwd
13
+ * to the first directory holding `.git` (a directory for a normal clone, a FILE for a worktree
14
+ * or submodule) — rather than taking a path the caller could point anywhere.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { readText, writeText, isFile, printOut } from '../lib/fs.mjs';
19
+ import { loadCatalog, catalogProblems, parseBrick } from './catalog.mjs';
20
+ import { checkGraph } from './graph.mjs';
21
+ import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
22
+ import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
23
+ import {
24
+ initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
25
+ renderLoopFile, parseLoopFile, LOOP_FILE,
26
+ } from './state.mjs';
27
+
28
+ const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
29
+ const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
30
+ const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
31
+
32
+ function requireRoot() {
33
+ const root = gitRootOf(process.cwd());
34
+ if (!root) throw new Error('not inside a git repository');
35
+ return root;
36
+ }
37
+
38
+ /**
39
+ * When `score --diff`/`decide` closes a unit (`commit: true`), write the commit message to
40
+ * `<gitdir>/LOOP_COMMIT_MSG` — never tracked, never part of any diff — so the loop's own
41
+ * automation never has to quote a message on a command line (`js/hosts/hooks.mjs`'s
42
+ * `loopExempt` only whitelists `git commit -F <path>`).
43
+ *
44
+ * `gitdir` is RESOLVED BY THE CALLER, before `withState` ran the state change (X6, fix round
45
+ * 2): a malformed `.git` FILE makes `gitDirOf` throw, and that throw must abort the action
46
+ * before anything about the loop's state has moved, not after. Adds `message_file` to the
47
+ * result for the skill to read back, with forward slashes (`C:/...`) even on Windows — Git
48
+ * Bash and git itself both accept that form on every platform, and a backslash-laden path
49
+ * breaks when a later shell command tries to use it unquoted.
50
+ */
51
+ function writeCommitMessage(root, gitdir, result) {
52
+ if (!result || result.commit !== true) return result;
53
+ const branch = currentBranch(root);
54
+ const slug = branch && branch.startsWith('loop/') ? branch.slice('loop/'.length) : 'loop';
55
+ const file = path.join(gitdir, 'LOOP_COMMIT_MSG');
56
+ writeText(file, `loop(${slug}): iteration ${result.iteration} — ${result.intent || 'setup'}\n\n${result.trailers}\n`);
57
+ return { ...result, message_file: file.replaceAll('\\', '/') };
58
+ }
59
+
60
+ function withState(fn) {
61
+ const root = requireRoot();
62
+ const file = path.join(root, LOOP_FILE);
63
+ if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
64
+ const state = parseLoopFile(readText(file));
65
+ const { bricks } = loadCatalog({ projectRoot: root });
66
+ const result = fn(state, bricks);
67
+ writeText(file, renderLoopFile(state));
68
+ return result;
69
+ }
70
+
71
+ function resolveGraph(spec, catalog) {
72
+ if (spec && spec.endsWith('.json')) return JSON.parse(readText(path.resolve(spec)));
73
+ const g = catalog.templates.get(spec);
74
+ if (!g) throw new Error(`no loop template named ${JSON.stringify(spec)}`);
75
+ const { origin, ...graph } = g;
76
+ return graph;
77
+ }
78
+
79
+ const ACTIONS = {
80
+ check(args) {
81
+ if (args.brick) {
82
+ const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
83
+ return { ok: !problems.length, problems };
84
+ }
85
+ if (args.graph) {
86
+ const catalog = loadCatalog({ projectRoot: process.cwd() });
87
+ const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
88
+ return { ok: !problems.length, problems };
89
+ }
90
+ // The bare check doubles as the catalogue LISTING the loop skill composes a free graph
91
+ // from — frontmatter only: a brick's body reaches the model through `next`, for the node
92
+ // the engine chose, never as a menu.
93
+ const problems = catalogProblems({ projectRoot: process.cwd() });
94
+ const { bricks, templates, overridden } = loadCatalog({ projectRoot: process.cwd() });
95
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
96
+ return {
97
+ ok: !problems.length, problems,
98
+ templates: [...templates.keys()].sort(byName),
99
+ bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
100
+ .map(({ body, available, reason, ...b }) => ({ ...b, available, ...(reason ? { reason } : {}) })),
101
+ overridden,
102
+ };
103
+ },
104
+ init(args) {
105
+ const root = requireRoot();
106
+ const file = path.join(root, LOOP_FILE);
107
+ if (isFile(file)) throw new Error(`${file} already exists — a loop is already running here`);
108
+ if (!args.title || !args.requirement || !args.graph) throw new Error('init needs --title, --requirement and --graph');
109
+ const catalog = loadCatalog({ projectRoot: root });
110
+ const graph = resolveGraph(args.graph, catalog);
111
+ const problems = checkGraph(graph, catalog.bricks);
112
+ if (problems.length) return { error: 'the graph does not pass loop check', problems };
113
+ const state = initState({ title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET });
114
+ writeText(file, renderLoopFile(state));
115
+ return { file, graph: graph.name, preset: state.preset, status: state.status };
116
+ },
117
+ next: () => withState((s, b) => nextStep(s, b)),
118
+ score(args) {
119
+ if (args.diff) {
120
+ // X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
121
+ // malformed `.git` file errors without advancing the loop.
122
+ const root = requireRoot();
123
+ const gitdir = gitDirOf(root);
124
+ return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
125
+ }
126
+ if (!args.declared) throw new Error('score needs --declared or --diff');
127
+ return withState((s) => scoreDeclared(s, {
128
+ actions: list(args.actions), writeSet: list(args.writeSet), intent: args.intent ?? null,
129
+ }));
130
+ },
131
+ record(args) {
132
+ if (!args.outcome) throw new Error('record needs --outcome');
133
+ const card = args.card ? JSON.parse(args.card) : null;
134
+ const raw = stdin();
135
+ return withState((s, b) => recordOutcome(s, b, args.outcome, { card, porcelain: raw ? raw.replace(/\r\n/g, '\n').trimEnd() : null }));
136
+ },
137
+ decide(args) {
138
+ // X6: same ordering as `score --diff` above.
139
+ const root = requireRoot();
140
+ const gitdir = gitDirOf(root);
141
+ return writeCommitMessage(root, gitdir, withState((s, b) => decideAwaiting(s, b, args.verdict, args.note ?? '')));
142
+ },
143
+ };
144
+
145
+ export function cmdLoop(args) {
146
+ try {
147
+ return emit(ACTIONS[args.action](args));
148
+ } catch (e) {
149
+ return emit({ error: e.message });
150
+ }
151
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The rules a loop graph must satisfy before a single node runs. ONE function, three callers:
3
+ * `geneseed loop check`, the doctor over the shipped templates, and `loop init` over a graph the
4
+ * model composed at run time — the composed graph never meets the doctor, so the engine has to
5
+ * carry the same check or free composition would be unchecked composition.
6
+ *
7
+ * WHY EVERY CYCLE NEEDS A DECLARED LOOP. Edges are free, so a graph can loop anywhere. A loop
8
+ * that terminates only because the model eventually reports a different outcome is a loop that
9
+ * may not terminate; a declared `max` is what turns "should stop" into "will stop". The check is
10
+ * structural (strongly connected components), so it needs no simulation and cannot miss a path.
11
+ *
12
+ * Problems are returned in a fixed order — per node, per edge, per outcome, then loops — so a
13
+ * test can write the expected list out instead of sorting it.
14
+ *
15
+ * WHY A CYCLE INSIDE AN ITERATION NEEDS ITS OWN LOOP. The whole-SCC check (rule 4) is satisfied
16
+ * by the iteration loop alone, since every node in an iteration is reachable from every other —
17
+ * but the runtime gives a node outside every *inner* loop exactly one entry per iteration (no
18
+ * re-entry at all), so a cycle that never reaches the iteration head and is not inside its own
19
+ * declared inner loop would hit that "max of 0" path by construction, not by a model's mistake.
20
+ * The static check has to refuse that graph, not just the ones the runtime would loop forever on.
21
+ */
22
+ import { WEIGHTS } from './score.mjs';
23
+
24
+ export const TERMINALS = new Set(['$close', '$stop']);
25
+ export const ENGINE_MAX_ITERATIONS = 20;
26
+
27
+ export function iterationLoop(graph) {
28
+ return (graph.loops ?? []).find((l) => l.iteration === true);
29
+ }
30
+
31
+ /** Tarjan — the strongly connected components of the node graph (terminals excluded). */
32
+ function components(nodes, edges) {
33
+ const succ = new Map(nodes.map((n) => [n, []]));
34
+ for (const e of edges) if (succ.has(e.from) && succ.has(e.to)) succ.get(e.from).push(e.to);
35
+ let index = 0;
36
+ const idx = new Map(); const low = new Map(); const stack = []; const on = new Set(); const out = [];
37
+ const visit = (v) => {
38
+ idx.set(v, index); low.set(v, index); index += 1; stack.push(v); on.add(v);
39
+ for (const w of succ.get(v)) {
40
+ if (!idx.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
41
+ else if (on.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
42
+ }
43
+ if (low.get(v) === idx.get(v)) {
44
+ const comp = [];
45
+ for (;;) { const w = stack.pop(); on.delete(w); comp.push(w); if (w === v) break; }
46
+ const selfLoop = comp.length === 1 && succ.get(v).includes(v);
47
+ if (comp.length > 1 || selfLoop) out.push(comp.sort());
48
+ }
49
+ };
50
+ for (const n of nodes) if (!idx.has(n)) visit(n);
51
+ return out;
52
+ }
53
+
54
+ export function checkGraph(graph, bricks) {
55
+ const problems = [];
56
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
57
+ const edges = Array.isArray(graph.edges) ? graph.edges : [];
58
+ const loops = Array.isArray(graph.loops) ? graph.loops : [];
59
+ const nodeSet = new Set(nodes);
60
+
61
+ // rule 1 — nodes name available bricks
62
+ for (const n of nodes) {
63
+ const brick = bricks.get(n);
64
+ if (!brick) problems.push(`node ${n}: no brick named ${n}`);
65
+ else if (!brick.available) problems.push(`node ${n}: brick unavailable (${brick.reason})`);
66
+ }
67
+ // rule 1 — edge endpoints; rule 2 — edges use declared outcomes
68
+ for (const e of edges) {
69
+ const label = `edge ${e.from} --${e.on}--> ${e.to}`;
70
+ if (!nodeSet.has(e.from)) { problems.push(`${label}: ${e.from} is not a node`); continue; }
71
+ if (!nodeSet.has(e.to) && !TERMINALS.has(e.to)) {
72
+ problems.push(`${label}: ${e.to} is not a node, $close or $stop`);
73
+ continue;
74
+ }
75
+ const brick = bricks.get(e.from);
76
+ if (brick && !brick.outcomes.includes(e.on)) problems.push(`${label}: ${e.on} is not an outcome of ${e.from}`);
77
+ }
78
+ // rule 2 — exactly one edge per declared outcome (nodes in sorted order)
79
+ for (const n of [...nodes].sort()) {
80
+ const brick = bricks.get(n);
81
+ if (!brick) continue;
82
+ for (const o of brick.outcomes) {
83
+ const count = edges.filter((e) => e.from === n && e.on === o).length;
84
+ if (count === 0) problems.push(`node ${n}: no edge for outcome ${o}`);
85
+ else if (count > 1) problems.push(`node ${n}: ${count} edges for outcome ${o}`);
86
+ }
87
+ }
88
+ // rule 3 — start, and $close reachable
89
+ if (!nodeSet.has(graph.start)) {
90
+ problems.push(`start ${graph.start} is not a node`);
91
+ } else {
92
+ const seen = new Set([graph.start]); const queue = [graph.start];
93
+ while (queue.length) {
94
+ const v = queue.shift();
95
+ for (const e of edges) {
96
+ if (e.from === v && !seen.has(e.to)) { seen.add(e.to); if (nodeSet.has(e.to)) queue.push(e.to); }
97
+ }
98
+ }
99
+ if (!seen.has('$close')) problems.push(`$close is not reachable from ${graph.start}`);
100
+ }
101
+ // rule 5 — loops are well formed
102
+ for (const l of loops) {
103
+ if (!Number.isInteger(l.max) || l.max < 1) problems.push(`loop ${l.name}: max must be a positive integer`);
104
+ else if (l.iteration === true && l.max > ENGINE_MAX_ITERATIONS) {
105
+ problems.push(`loop ${l.name}: max ${l.max} is above the engine ceiling of ${ENGINE_MAX_ITERATIONS}`);
106
+ }
107
+ for (const n of l.nodes ?? []) if (!nodeSet.has(n)) problems.push(`loop ${l.name}: ${n} is not a node`);
108
+ }
109
+ const iterations = loops.filter((l) => l.iteration === true).length;
110
+ if (iterations !== 1) problems.push(`${iterations} loops are marked iteration; exactly one must be`);
111
+ // rule 4 — every cycle is inside a declared loop
112
+ for (const comp of components(nodes, edges)) {
113
+ const covered = loops.some((l) => comp.every((n) => (l.nodes ?? []).includes(n)));
114
+ if (!covered) problems.push(`cycle ${comp.join(', ')} has no declared loop around it`);
115
+ }
116
+ // rule 4b — a cycle that never reaches the iteration head needs its own non-iteration loop
117
+ const iterationLoops = loops.filter((l) => l.iteration === true);
118
+ if (iterationLoops.length === 1) {
119
+ const [iteration] = iterationLoops;
120
+ const head = iteration.nodes?.[0];
121
+ const innerEdges = edges.filter((e) => e.to !== head);
122
+ for (const comp of components(nodes, innerEdges)) {
123
+ const covered = loops.some((l) => l !== iteration && comp.every((n) => (l.nodes ?? []).includes(n)));
124
+ if (!covered) problems.push(`cycle ${comp.join(', ')} inside an iteration has no inner loop around it`);
125
+ }
126
+ // rule 4c — the iteration is entered only at its head: the runtime opens and closes units
127
+ // on re-entering nodes[0], so entering anywhere else runs a unit with no head behind it.
128
+ const inside = new Set(iteration.nodes ?? []);
129
+ if (head !== undefined) {
130
+ if (inside.has(graph.start) && graph.start !== head) {
131
+ problems.push(`start ${graph.start} is inside the iteration but is not its head ${head}`);
132
+ }
133
+ for (const e of edges) {
134
+ if (!inside.has(e.from) && inside.has(e.to) && e.to !== head) {
135
+ problems.push(`edge ${e.from} --${e.on}--> ${e.to} enters the iteration at ${e.to}, not at its head ${head}`);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ // template weight overrides
141
+ for (const k of Object.keys(graph.weights ?? {})) {
142
+ if (!Object.hasOwn(WEIGHTS, k)) problems.push(`weights: ${k} is not an action`);
143
+ }
144
+ return problems;
145
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The loop's risk arithmetic — pure, and imported by the build driver (the `--trust` choices),
3
+ * so it must stay free of every import but node builtins: the driver's closure bans
4
+ * child_process outright.
5
+ *
6
+ * WHY MAX AND NOT A SUM. A block that renames ten things and changes one public contract is a
7
+ * contract change; adding the renames up would let ten trivial edits outvote one critical one
8
+ * in the wrong direction — or, with a sum, let a pile of trivia trip a blocking stop that no
9
+ * single edit deserves. One critical action makes the block critical, nothing else does.
10
+ *
11
+ * WHY THE DIFF CAN ONLY RAISE THE SCORE. The declared score is the identify brick's account of
12
+ * what it is about to do; the diff is what it did. The account may be optimistic, never the
13
+ * other way round, so the actual score is max(declared, escalations) — an iteration never earns
14
+ * a lower tier by under-delivering.
15
+ */
16
+ import { Buffer } from 'node:buffer';
17
+
18
+ export const WEIGHTS = Object.freeze({
19
+ format: 0.1, imports: 0.1, rename: 0.2, logic: 0.4, 'new-file': 0.4,
20
+ api: 0.8, delete: 0.8, architecture: 0.9,
21
+ });
22
+
23
+ /** [silent ceiling, soft ceiling] — above the second is blocking. */
24
+ export const PRESETS = Object.freeze({
25
+ prudent: Object.freeze([0.1, 0.3]),
26
+ balanced: Object.freeze([0.2, 0.6]),
27
+ aggressive: Object.freeze([0.4, 0.8]),
28
+ });
29
+ export const DEFAULT_PRESET = 'balanced';
30
+
31
+ /** More deleted lines than this in one iteration counts as a code deletion. */
32
+ export const DELETION_LINES = 20;
33
+
34
+ const RANK = { silent: 0, soft: 1, blocking: 2 };
35
+
36
+ export function declaredRisk(actions, overrides = {}) {
37
+ const w = { ...WEIGHTS, ...overrides };
38
+ let score = 0;
39
+ for (const a of actions) {
40
+ if (!Object.hasOwn(w, a)) {
41
+ throw new Error(`unknown action ${JSON.stringify(a)} — one of ${Object.keys(w).join(', ')}`);
42
+ }
43
+ score = Math.max(score, w[a]);
44
+ }
45
+ return score;
46
+ }
47
+
48
+ const C_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
49
+
50
+ /**
51
+ * Undo git's C-quoting (core.quotepath, on by default): `"caf\303\251.js"` is `café.js`, its
52
+ * octal escapes being the UTF-8 bytes. Decoded here rather than by passing `-c core.quotepath=off`
53
+ * in the skill's verify command, so every caller of `score --diff` is covered, not just the one
54
+ * the skill types.
55
+ */
56
+ function unquote(s) {
57
+ if (s.length < 2 || !s.startsWith('"') || !s.endsWith('"')) return s;
58
+ const body = s.slice(1, -1);
59
+ const bytes = [];
60
+ for (let i = 0; i < body.length; i += 1) {
61
+ if (body[i] !== '\\') { bytes.push(...Buffer.from(body[i])); continue; }
62
+ const octal = /^[0-7]{3}/.exec(body.slice(i + 1));
63
+ if (octal) { bytes.push(Number.parseInt(octal[0], 8)); i += 3; continue; }
64
+ i += 1;
65
+ bytes.push(C_ESCAPES[body[i]] ?? body.charCodeAt(i));
66
+ }
67
+ return Buffer.from(bytes).toString('utf8');
68
+ }
69
+
70
+ /**
71
+ * A numstat path, as [new, old] for a rename or [path] otherwise. Git prints a rename as
72
+ * `pre/{a => b}/post` (either side may be empty, leaving a `//` to collapse), or as `old => new`
73
+ * — always that form, each side quoted, when either side needs quoting.
74
+ */
75
+ function numstatPaths(raw) {
76
+ const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw);
77
+ if (brace) {
78
+ const [, pre, a, b, post] = brace;
79
+ const join = (mid) => `${pre}${mid}${post}`.replace(/\/{2,}/g, '/').replace(/^\//, '');
80
+ return [join(b), join(a)];
81
+ }
82
+ const arrow = raw.indexOf(' => ');
83
+ if (arrow >= 0) return [unquote(raw.slice(arrow + 4)), unquote(raw.slice(0, arrow))];
84
+ return [unquote(raw)];
85
+ }
86
+
87
+ /**
88
+ * `git diff --numstat` rows. A binary file reports `-` for both counts; it counts as 0. A rename
89
+ * yields BOTH paths — the new one with the counts, the old one with 0/0 — so a rename out of or
90
+ * into the write set is checked on each side without counting its lines twice.
91
+ */
92
+ export function parseNumstat(text) {
93
+ const rows = [];
94
+ for (const line of String(text).split(/\r?\n/)) {
95
+ const m = /^(\S+)\t(\S+)\t(.+)$/.exec(line);
96
+ if (!m) continue;
97
+ const n = (s) => (s === '-' ? 0 : Number.parseInt(s, 10) || 0);
98
+ const [file, old] = numstatPaths(m[3]);
99
+ rows.push({ file, added: n(m[1]), deleted: n(m[2]) });
100
+ if (old !== undefined) rows.push({ file: old, added: 0, deleted: 0 });
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ const slash = (p) => p.replaceAll('\\', '/');
106
+
107
+ export function actualRisk(declared, files, { writeSet = [], contracts = [], overrides = {} } = {}) {
108
+ const w = { ...WEIGHTS, ...overrides };
109
+ let score = declared;
110
+ const reasons = [];
111
+ const inSet = new Set(writeSet.map(slash));
112
+ const outside = files.filter((f) => !inSet.has(slash(f.file)));
113
+ if (outside.length) {
114
+ score = Math.max(score, 0.8);
115
+ reasons.push(`outside the write set: ${outside.map((f) => slash(f.file)).join(', ')}`);
116
+ }
117
+ const deleted = files.reduce((n, f) => n + f.deleted, 0);
118
+ if (deleted > DELETION_LINES) {
119
+ score = Math.max(score, w.delete);
120
+ reasons.push(`${deleted} lines deleted`);
121
+ }
122
+ const contractSet = new Set(contracts.map(slash));
123
+ const hit = files.filter((f) => contractSet.has(slash(f.file)));
124
+ if (hit.length) {
125
+ score = Math.max(score, w.api);
126
+ reasons.push(`contract files: ${hit.map((f) => slash(f.file)).join(', ')}`);
127
+ }
128
+ return { score, reasons };
129
+ }
130
+
131
+ export function decide(score, preset) {
132
+ const t = PRESETS[preset];
133
+ if (!t) throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
134
+ if (score <= t[0]) return 'silent';
135
+ if (score <= t[1]) return 'soft';
136
+ return 'blocking';
137
+ }
138
+
139
+ export function worst(a, b) {
140
+ if (a === undefined || a === null) return b;
141
+ if (b === undefined || b === null) return a;
142
+ return RANK[a] >= RANK[b] ? a : b;
143
+ }