geneseed 3.11.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 (101) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/SHIPPED.md +2 -2
  4. package/adapters/opencode/plugins/geneseed-guard.js +157 -2
  5. package/bin/geneseed-cli.mjs +4 -0
  6. package/docs/concepts/foreman-mode.md +1 -1
  7. package/docs/concepts/loops.md +165 -0
  8. package/docs/extending.md +5 -0
  9. package/docs/guides/choose-your-setup.md +14 -0
  10. package/docs/guides/install.md +1 -1
  11. package/docs/reference/glossary.md +4 -0
  12. package/docs/reference/opencode-plugins.md +3 -1
  13. package/docs/web-ui.md +12 -1
  14. package/harness.config.json +1 -1
  15. package/js/README.md +24 -2
  16. package/js/build/driver.mjs +14 -8
  17. package/js/build/generate.mjs +10 -6
  18. package/js/build/render.mjs +5 -0
  19. package/js/build/source.mjs +5 -1
  20. package/js/cli-table.json +235 -0
  21. package/js/hosts/gitref.mjs +99 -0
  22. package/js/hosts/hooks.mjs +88 -1
  23. package/js/hosts/installs.mjs +21 -0
  24. package/js/inspect/checks-authoring.mjs +10 -0
  25. package/js/inspect/diff.mjs +3 -0
  26. package/js/inspect/doctor.mjs +2 -1
  27. package/js/inspect/inventory.mjs +1 -0
  28. package/js/inspect/status.mjs +1 -1
  29. package/js/loop/catalog.mjs +116 -0
  30. package/js/loop/cli.mjs +151 -0
  31. package/js/loop/graph.mjs +145 -0
  32. package/js/loop/score.mjs +143 -0
  33. package/js/loop/state.mjs +322 -0
  34. package/js/maintain/migrate.mjs +4 -2
  35. package/js/web/actions.mjs +9 -3
  36. package/js/web/api.mjs +38 -2
  37. package/js/web/daemon.mjs +43 -5
  38. package/js/web/handler.mjs +1 -0
  39. package/js/web/jobs.mjs +3 -1
  40. package/js/web/server.mjs +5 -3
  41. package/package.json +1 -1
  42. package/registry.json +14 -0
  43. package/src/AGENT.md.tmpl +2 -0
  44. package/src/bricks/README.md +32 -0
  45. package/src/bricks/_template.md +32 -0
  46. package/src/bricks/apply.md +17 -0
  47. package/src/bricks/baseline-green.md +17 -0
  48. package/src/bricks/bruno-test.md +16 -0
  49. package/src/bricks/ci-fix.md +17 -0
  50. package/src/bricks/deps-audit.md +14 -0
  51. package/src/bricks/docs-update.md +15 -0
  52. package/src/bricks/identify.md +20 -0
  53. package/src/bricks/lint.md +14 -0
  54. package/src/bricks/plan.md +17 -0
  55. package/src/bricks/reproduce.md +16 -0
  56. package/src/bricks/review.md +17 -0
  57. package/src/bricks/security-scan.md +17 -0
  58. package/src/bricks/test.md +14 -0
  59. package/src/doctrines/process.md +11 -8
  60. package/src/loops/bugfix.json +22 -0
  61. package/src/loops/feature.json +21 -0
  62. package/src/loops/refactor.json +20 -0
  63. package/src/skills/brick-forge.md +46 -0
  64. package/src/skills/geneseed.md +1 -1
  65. package/src/skills/loop.md +99 -0
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-C52LEb-2.js → Activity-GaaZWXTY.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-CjNLRvXg.js → ActivityDetail-4t7d45Du.js} +1 -1
  83. package/web/dist/assets/{Dashboard-B6_sSCHi.js → Dashboard-CYn7Tp16.js} +2 -2
  84. package/web/dist/assets/{Docs-Dj7BmCSA.js → Docs-BeFKLg2T.js} +1 -1
  85. package/web/dist/assets/{GreenhouseView-BALwjQsA.js → GreenhouseView-C6ibc8IE.js} +1 -1
  86. package/web/dist/assets/Installs-DRYjYnxs.js +2 -0
  87. package/web/dist/assets/{JournalView-JpAQ8Hqe.js → JournalView-1HbU1mfg.js} +1 -1
  88. package/web/dist/assets/{Laws-DK7eCWSm.js → Laws-DFoG8JWB.js} +1 -1
  89. package/web/dist/assets/{Library-CoamHelE.js → Library-DDjgNwz_.js} +1 -1
  90. package/web/dist/assets/Loops-D9VEoO0F.js +1 -0
  91. package/web/dist/assets/{OperatorHudView-DVx1HlBx.js → OperatorHudView-Bs7paWmh.js} +1 -1
  92. package/web/dist/assets/{Personal-eaXb1zcj.js → Personal-BY5n4gTw.js} +1 -1
  93. package/web/dist/assets/Tabs-ho-EYD8i.js +1 -0
  94. package/web/dist/assets/{api-Dc8f3ca-.js → api-CyMRbU2x.js} +1 -1
  95. package/web/dist/assets/{drawable-Du_PZrQj.js → drawable-CLS9drMb.js} +1 -1
  96. package/web/dist/assets/{index-Bz_KMRDH.css → index-CX_hXMi5.css} +1 -1
  97. package/web/dist/assets/index-Cjr7XCA3.js +2 -0
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/Installs-BbffDQm3.js +0 -2
  100. package/web/dist/assets/Tabs-BmGtt8E2.js +0 -1
  101. package/web/dist/assets/index-CNFH7o55.js +0 -2
@@ -230,6 +230,27 @@ function leadOfDir(d, names) {
230
230
  export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
231
231
  export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
232
232
 
233
+ /**
234
+ * The loop skill's default trust preset, read back off a deployed install — `null` when no
235
+ * loop skill is deployed there.
236
+ *
237
+ * CARRIED BY THE SKILL, NOT BY A CARRIER: `--trust` is the one build axis with zero always-on
238
+ * footprint, so it is not in AGENT.md to be found. The skill renders a literal
239
+ * `Default trust preset: **<Label>**` line, and this scans the places an emit lands it: the
240
+ * native layer (`skills/loop/SKILL.md`, in the root itself for a global install and under the
241
+ * host's `projectMarker` for a project one) and the plain bundle (`skills/loop.md`).
242
+ */
243
+ const TRUST_RE = /^Default trust preset: \*\*(Prudent|Balanced|Aggressive)\*\*/m;
244
+ export function trustOfDir(d) {
245
+ for (const base of ['', ...HOSTS.map((h) => h.projectMarker)]) {
246
+ for (const rel of [['skills', 'loop', 'SKILL.md'], ['skills', 'loop.md']]) {
247
+ const m = TRUST_RE.exec(readMaybe(path.join(d, base, ...rel)) ?? '');
248
+ if (m) return m[1].toLowerCase();
249
+ }
250
+ }
251
+ return null;
252
+ }
253
+
233
254
  /** The `Active packs:` marker line the template emits, in one of the five carriers. */
234
255
  const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
235
256
 
@@ -25,6 +25,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
25
25
  import {
26
26
  folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
27
27
  } from './checks-repo.mjs';
28
+ import { catalogProblems } from '../loop/catalog.mjs';
28
29
  import { LAW_CLASS, LAW_CLASSES, SKILL_CLASS } from './inventory.mjs';
29
30
  import { NOTE, globSorted, has, isDir, isFile, rglob, srcStems } from './scan.mjs';
30
31
  import { spawnSync } from 'node:child_process';
@@ -836,6 +837,15 @@ function loadLearnPromptHead() {
836
837
  + 'When in doubt, output exactly: NOTHING.';
837
838
  }
838
839
 
840
+ /**
841
+ * The shipped loop catalogue parses and every shipped template passes the engine's own graph
842
+ * rules. Global and project bricks are deliberately out of scope: doctor judges what this
843
+ * checkout ships, not what a user wrote in their config.
844
+ */
845
+ export function loopProblems() {
846
+ return catalogProblems({ projectRoot: null, globalLevel: false });
847
+ }
848
+
839
849
  /**
840
850
  * `_harness_build._authoring_problems` — author-time gates on the source specs and plugins.
841
851
  *
@@ -46,6 +46,7 @@ import {
46
46
  } from '../hosts/hosts.mjs';
47
47
  import {
48
48
  EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
49
+ trustOfDir,
49
50
  readJsonMaybe, readMaybe, themeOfDir,
50
51
  } from '../hosts/installs.mjs';
51
52
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
@@ -175,6 +176,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
175
176
  // `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
176
177
  // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
177
178
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
179
+ // The loop skill's preset, or its `Default trust preset:` line reads as edited.
180
+ trust: trustOfDir(dir),
178
181
  }));
179
182
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
180
183
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -36,7 +36,7 @@ import { resolvePath } from '../hosts/hosts.mjs';
36
36
  import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
37
37
  import { validateIsVendored } from '../hosts/native.mjs';
38
38
  import { printOut } from '../lib/fs.mjs';
39
- import { authoringProblems } from './checks-authoring.mjs';
39
+ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
40
40
  import {
41
41
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
42
42
  } from './checks-build.mjs';
@@ -217,6 +217,7 @@ export function doctorCollect({
217
217
  problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
218
218
  problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
219
219
  problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
220
+ problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
220
221
  problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
221
222
  problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
222
223
  // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
@@ -91,6 +91,7 @@ export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context',
91
91
  export const SKILL_CLASS = {
92
92
  brainstorm: 'design', plan: 'design', council: 'design',
93
93
  workflow: 'design', 'parallel-agents': 'design', pipeline: 'design',
94
+ loop: 'design', 'brick-forge': 'design',
94
95
  'codebase-design': 'design', 'domain-modeling': 'design', wayfinder: 'design',
95
96
  tickets: 'design',
96
97
  develop: 'build', refactor: 'build', debug: 'build', migrate: 'build',
@@ -372,7 +372,7 @@ export function statusLines(d, color = false) {
372
372
  }
373
373
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
374
374
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
375
- rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}`,
375
+ rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
376
376
  packs, excl].join(` ${DOT} `) + where]);
377
377
  }
378
378
 
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Where bricks and loop templates come from, and which one wins.
3
+ *
4
+ * THREE ORIGINS, ONE LADDER. Shipped (this package's own `src/`), global (the user's, beside
5
+ * `installs.json` under XDG) and project (`<repo>/.geneseed/`, committed, shared with a team).
6
+ * A later origin replaces an earlier one BY NAME — that is the whole point: a team adapts the
7
+ * shipped `reproduce` to its stack without copying every template that uses it. The cost is
8
+ * that a fixed shipped brick stays masked by the override, so the override is reported
9
+ * (`overridden`), never silent.
10
+ *
11
+ * WHY SHIPPED BRICKS ARE READ FROM `src/`, NOT FROM AN INSTALL. The model never reads a brick
12
+ * file: `geneseed loop next` hands it the brick's prompt. So the CLI reads its own source and no
13
+ * host emit has to learn a new directory — four hosts get the catalogue for free and cannot drift.
14
+ *
15
+ * Templates are parsed with JSON.parse on purpose: they are read, never written back into a
16
+ * user's file, so the int/float distinction `parseJson` preserves has nowhere to matter.
17
+ */
18
+ import { readdirSync } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { readText, isFile, isDir } from '../lib/fs.mjs';
22
+ import { frontmatter } from '../hosts/hooks.mjs';
23
+ import { SRC } from '../build/source.mjs';
24
+ import { checkGraph } from './graph.mjs';
25
+
26
+ export function userLoopsDir() {
27
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
28
+ return path.join(base, 'geneseed');
29
+ }
30
+
31
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
32
+
33
+ export function parseBrick(text, origin) {
34
+ const [fm, body] = frontmatter(text);
35
+ const get = (k) => (fm.get(k) ?? '').trim();
36
+ const name = get('name');
37
+ const problems = [];
38
+ if (!KEBAB.test(name)) problems.push(`name ${JSON.stringify(name)} must be kebab-case`);
39
+ const description = get('description');
40
+ if (!description) problems.push(`${name}: description is empty`);
41
+ const effect = get('effect');
42
+ if (effect !== 'read' && effect !== 'mutate') {
43
+ problems.push(`${name}: effect must be read or mutate, not ${JSON.stringify(effect)}`);
44
+ }
45
+ const agent = get('agent') || null;
46
+ const skill = get('skill') || null;
47
+ if (!agent === !skill) problems.push(`${name}: exactly one of agent or skill`);
48
+ const outcomes = get('outcomes').split(',').map((s) => s.trim()).filter(Boolean);
49
+ if (!outcomes.length) problems.push(`${name}: outcomes is empty`);
50
+ return {
51
+ brick: { name, description, effect, agent, skill, outcomes, body: body.trim(), origin },
52
+ problems,
53
+ };
54
+ }
55
+
56
+ const listFiles = (dir, ext) => (isDir(dir)
57
+ ? readdirSync(dir).filter((n) => n.endsWith(ext) && !n.startsWith('_') && n !== 'README.md').sort()
58
+ : []);
59
+
60
+ function availability(brick, srcRoot) {
61
+ if (brick.agent && !isFile(path.join(srcRoot, 'agents', `${brick.agent}.md`))) {
62
+ return { available: false, reason: `agent ${brick.agent} is not shipped` };
63
+ }
64
+ if (brick.skill && !isFile(path.join(srcRoot, 'skills', `${brick.skill}.md`))
65
+ && !isDir(path.join(srcRoot, 'skills', brick.skill))) {
66
+ return { available: false, reason: `skill ${brick.skill} is not shipped` };
67
+ }
68
+ return { available: true };
69
+ }
70
+
71
+ export function loadCatalog({
72
+ projectRoot = process.cwd(), srcRoot = SRC, globalLevel = true,
73
+ } = {}) {
74
+ const levels = [
75
+ ['shipped', path.join(srcRoot, 'bricks'), path.join(srcRoot, 'loops')],
76
+ ];
77
+ if (globalLevel) {
78
+ levels.push(['global', path.join(userLoopsDir(), 'bricks'), path.join(userLoopsDir(), 'loops')]);
79
+ }
80
+ if (projectRoot) {
81
+ levels.push(['project', path.join(projectRoot, '.geneseed', 'bricks'), path.join(projectRoot, '.geneseed', 'loops')]);
82
+ }
83
+ const bricks = new Map(); const templates = new Map(); const problems = []; const overridden = [];
84
+ for (const [origin, brickDir, loopDir] of levels) {
85
+ for (const f of listFiles(brickDir, '.md')) {
86
+ const { brick, problems: p } = parseBrick(readText(path.join(brickDir, f)), origin);
87
+ problems.push(...p.map((x) => `bricks/${f} (${origin}): ${x}`));
88
+ if (p.length) continue;
89
+ const prior = bricks.get(brick.name);
90
+ if (prior) overridden.push(`${brick.name} (${origin}, overrides ${prior.origin})`);
91
+ bricks.set(brick.name, { ...brick, ...availability(brick, srcRoot) });
92
+ }
93
+ for (const f of listFiles(loopDir, '.json')) {
94
+ let graph;
95
+ try { graph = JSON.parse(readText(path.join(loopDir, f))); } catch {
96
+ problems.push(`loops/${f}: not JSON`); continue;
97
+ }
98
+ const stem = f.slice(0, -'.json'.length);
99
+ if (graph.name !== stem) {
100
+ problems.push(`loops/${f}: name ${JSON.stringify(graph.name)} does not match the file name`);
101
+ continue;
102
+ }
103
+ templates.set(stem, { ...graph, origin });
104
+ }
105
+ }
106
+ return { bricks, templates, problems, overridden: overridden.sort() };
107
+ }
108
+
109
+ export function catalogProblems(opts = {}) {
110
+ const { bricks, templates, problems } = loadCatalog(opts);
111
+ const out = [...problems];
112
+ for (const [name, graph] of [...templates].sort(([a], [b]) => (a < b ? -1 : 1))) {
113
+ out.push(...checkGraph(graph, bricks).map((p) => `loops/${name}: ${p}`));
114
+ }
115
+ return out.sort();
116
+ }
@@ -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
+ }