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
@@ -0,0 +1,322 @@
1
+ /**
2
+ * The loop engine: a state machine over a brick graph, persisted as a JSON block in LOOP.md.
3
+ *
4
+ * WHY THE MODEL NEVER DECIDES. Every transition, ceiling, score and stop is computed here; the
5
+ * `loop` skill only runs the node it is handed and reports the brick's outcome. That is what
6
+ * makes four hosts behave identically, and what makes a stopped loop explainable afterwards.
7
+ *
8
+ * WHY A BLOCKING STOP ENDS THE RUN. A background task cannot wait for a person. So "blocking"
9
+ * is `status: awaiting` written to LOOP.md and the run ending; the user's answer relaunches it
10
+ * through `decide`. Crash recovery is the same path: relaunch from the branch and LOOP.md.
11
+ *
12
+ * UNITS. Work closes into a commit whenever the graph re-enters the iteration loop's head, so
13
+ * setup work before the loop (a reproduction test) is its own unit — iteration 0 — and never
14
+ * pollutes iteration 1's write set. Every cycle inside a unit is bounded by re-entry counting:
15
+ * `counters` is keyed by node name (not loop name), incremented on every transition into a
16
+ * non-terminal, non-head node, and capped at `1 + the largest max of a non-iteration loop
17
+ * containing that node` (or just `1` — no re-entry at all — when no such loop covers it). That
18
+ * bounds a cycle through a node the model forgot to put in an inner loop (a sibling brick
19
+ * looping back into a retry node it isn't declared inside), not only the loop the author
20
+ * remembered to declare. LOOP.md itself — the engine's own state file, rewritten by every CLI
21
+ * action — is excluded from every score and every read-brick porcelain check: it is never part
22
+ * of the work being scored, and including it would make every action's own bookkeeping look
23
+ * like an undeclared file touch.
24
+ */
25
+ import { declaredRisk, actualRisk, decide, worst, PRESETS, DEFAULT_PRESET } from './score.mjs';
26
+ import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
27
+
28
+ const BEGIN = '<!-- loop-state:begin -->';
29
+ const END = '<!-- loop-state:end -->';
30
+ export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
31
+ export const LOOP_FILE = 'LOOP.md';
32
+ const slash = (p) => String(p).replaceAll('\\', '/');
33
+
34
+ export function renderLoopFile(state) {
35
+ return `# Loop — ${state.title}\n\n${state.requirement}\n\n`
36
+ + 'The block below belongs to the engine. You may change `preset` (prudent, balanced, '
37
+ + 'aggressive) or `contracts`: they apply from the next score on.\n\n'
38
+ + `${BEGIN}\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\`\n${END}\n`;
39
+ }
40
+
41
+ export function parseLoopFile(text) {
42
+ const t = String(text).replace(/\r\n/g, '\n');
43
+ const a = t.indexOf(BEGIN); const b = t.lastIndexOf(END);
44
+ if (a < 0 || b < a) throw new Error('LOOP.md has no loop-state block');
45
+ const inner = t.slice(a + BEGIN.length, b).replace(/^\s*```json\s*/, '').replace(/\s*```\s*$/, '');
46
+ return JSON.parse(inner);
47
+ }
48
+
49
+ const head = (state) => iterationLoop(state.graph).nodes[0];
50
+ const fmt = (n) => Number(n).toFixed(1);
51
+
52
+ export function initState({ title, requirement, graph, preset = DEFAULT_PRESET }) {
53
+ if (!PRESETS[preset]) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
54
+ const loop = iterationLoop(graph);
55
+ const state = {
56
+ v: 1, title, requirement, preset, contracts: [], graph, node: graph.start,
57
+ status: 'running', awaiting: null, reason: null,
58
+ iteration: loop.nodes.includes(graph.start) ? 1 : 0,
59
+ card: null, validated: false, pendingVerify: false, closing: false, counters: {},
60
+ resplit: false, emptyStreak: 0, snapshot: '', visited: [], tests: 'n/a', current: {},
61
+ notes: [], history: [],
62
+ };
63
+ if (preset === 'prudent') { state.status = 'awaiting'; state.awaiting = { kind: 'launch' }; }
64
+ return state;
65
+ }
66
+
67
+ function stop(state, reason) {
68
+ state.status = 'stopped'; state.reason = reason;
69
+ return { stopped: reason };
70
+ }
71
+
72
+ function resetUnit(state) {
73
+ state.card = null; state.validated = false; state.counters = {}; state.visited = [];
74
+ state.current = {}; state.tests = 'n/a';
75
+ }
76
+
77
+ function summary(state) {
78
+ return {
79
+ iterations: state.history.length,
80
+ review: state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent })),
81
+ notes: state.notes,
82
+ };
83
+ }
84
+
85
+ export function trailers(state) {
86
+ const [silent, soft] = PRESETS[state.preset];
87
+ const declared = state.current.declared ?? 0;
88
+ return [
89
+ `Loop-Iteration: ${state.iteration}`,
90
+ `Loop-Bricks: ${[...new Set(state.visited)].join(',')}`,
91
+ `Loop-Risk-Declared: ${fmt(declared)}`,
92
+ `Loop-Risk-Actual: ${fmt(state.current.actual ?? declared)}`,
93
+ `Loop-Threshold: ${state.preset} ${fmt(silent)}/${fmt(soft)}`,
94
+ `Loop-Decision: ${state.current.decision ?? 'silent'}${state.current.amended ? ' (amended)' : ''}`,
95
+ `Loop-Tests: ${state.tests}`,
96
+ ].join('\n');
97
+ }
98
+
99
+ function finishUnit(state, committed) {
100
+ if (committed) {
101
+ state.history.push({
102
+ iteration: state.iteration, bricks: [...new Set(state.visited)],
103
+ declared: state.current.declared ?? 0, actual: state.current.actual ?? state.current.declared ?? 0,
104
+ decision: state.current.decision ?? 'silent', tests: state.tests, intent: state.card?.intent ?? '',
105
+ });
106
+ }
107
+ // The unit is committed (or was empty): the tree is clean again, so the read-brick check
108
+ // compares the next node against a clean status, not the last pre-commit one.
109
+ state.pendingVerify = false; state.resplit = false; state.snapshot = '';
110
+ resetUnit(state);
111
+ state.iteration += 1;
112
+ if (state.closing) { state.status = 'done'; state.node = '$close'; return; }
113
+ state.node = head(state);
114
+ const ceiling = Math.min(iterationLoop(state.graph).max, ENGINE_MAX_ITERATIONS);
115
+ if (state.iteration > ceiling) stop(state, `iteration ceiling of ${ceiling} reached`);
116
+ }
117
+
118
+ function commitUnit(state) {
119
+ const t = trailers(state); const iteration = state.iteration;
120
+ // Read before `finishUnit` resets the card for the next unit — the CLI needs it to name
121
+ // the commit message it writes to LOOP_COMMIT_MSG.
122
+ const intent = state.card?.intent ?? null;
123
+ finishUnit(state, true);
124
+ return { commit: true, trailers: t, iteration, intent };
125
+ }
126
+
127
+ function assertRunning(state) {
128
+ if (state.status !== 'running') throw new Error(`the loop is ${state.status}, not running`);
129
+ }
130
+
131
+ export function nextStep(state, bricks) {
132
+ if (state.status === 'awaiting') return { awaiting: state.awaiting, preset: state.preset };
133
+ if (state.status === 'done') return { terminal: '$close', summary: summary(state) };
134
+ if (state.status === 'stopped') return { terminal: '$stop', reason: state.reason, summary: summary(state) };
135
+ if (state.pendingVerify) return { verify: true, iteration: state.iteration, run: VERIFY_COMMAND };
136
+ const brick = bricks.get(state.node);
137
+ if (!brick) throw new Error(`no brick named ${state.node} in the catalogue`);
138
+ return {
139
+ node: state.node, iteration: state.iteration, effect: brick.effect, agent: brick.agent,
140
+ skill: brick.skill, outcomes: brick.outcomes, prompt: brick.body, card: state.card,
141
+ notes: state.notes, validate: brick.effect === 'mutate' && !state.validated,
142
+ };
143
+ }
144
+
145
+ export function scoreDeclared(state, { actions = null, writeSet = null, intent = null } = {}) {
146
+ assertRunning(state);
147
+ if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
148
+ if (actions) {
149
+ state.card = {
150
+ ...(state.card ?? {}), intent: intent ?? state.card?.intent ?? state.node,
151
+ actions, writeSet: writeSet ?? state.card?.writeSet ?? [],
152
+ };
153
+ }
154
+ if (!state.card?.actions?.length) {
155
+ throw new Error('no declared actions: the identify brick records a card, or pass --actions');
156
+ }
157
+ const score = declaredRisk(state.card.actions, state.graph.weights);
158
+ const decision = decide(score, state.preset);
159
+ state.validated = true;
160
+ state.current.declared = score;
161
+ state.current.decision = worst(state.current.decision, decision);
162
+ if (decision === 'blocking') {
163
+ state.status = 'awaiting';
164
+ state.awaiting = { kind: 'declared', iteration: state.iteration, score, threshold: PRESETS[state.preset], card: state.card };
165
+ }
166
+ return { score, preset: state.preset, threshold: [...PRESETS[state.preset]], decision };
167
+ }
168
+
169
+ export function scoreDiff(state, files) {
170
+ assertRunning(state);
171
+ if (!state.pendingVerify) throw new Error('no iteration to verify: `geneseed loop next` has not asked for one');
172
+ if (!Array.isArray(state.contracts) || state.contracts.some((c) => typeof c !== 'string')) {
173
+ throw new Error('LOOP.md: contracts must be a list of file paths');
174
+ }
175
+ const scored = files.filter((f) => slash(f.file) !== LOOP_FILE);
176
+ if (!scored.length) {
177
+ // A closing unit (reached $close with a mutate already visited) finishes as done even
178
+ // when this is its second empty diff in a row: there is nothing left to retry into.
179
+ if (state.closing) { finishUnit(state, false); return { empty: true, commit: false }; }
180
+ state.emptyStreak += 1;
181
+ if (state.emptyStreak >= 2) return stop(state, 'two consecutive iterations produced no diff');
182
+ finishUnit(state, false);
183
+ return { empty: true, commit: false };
184
+ }
185
+ state.emptyStreak = 0;
186
+ const { score, reasons } = actualRisk(state.current.declared ?? 0, scored, {
187
+ writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [], overrides: state.graph.weights,
188
+ });
189
+ const decision = decide(score, state.preset);
190
+ state.current.actual = score;
191
+ state.current.decision = worst(state.current.decision, decision);
192
+ if (decision === 'blocking') {
193
+ state.status = 'awaiting';
194
+ state.awaiting = { kind: 'actual', iteration: state.iteration, score, reasons, threshold: PRESETS[state.preset] };
195
+ return { score, decision, reasons, commit: false };
196
+ }
197
+ return commitUnit(state);
198
+ }
199
+
200
+ function exhaust(state, loop) {
201
+ // Setup has no head to re-split into: the head belongs to the iterations after it.
202
+ if (isSetupUnit(state)) return stop(state, `setup ${loop.name} exhausted its max of ${loop.max}`);
203
+ if (state.resplit) {
204
+ return stop(state, `${loop.name} exhausted its max of ${loop.max} twice in iteration ${state.iteration}`);
205
+ }
206
+ state.notes.push(`iteration ${state.iteration}: ${loop.name} exhausted its max of ${loop.max}; re-split once`);
207
+ resetUnit(state);
208
+ state.resplit = true; state.snapshot = ''; // the skill stashes the attempt: clean tree
209
+ state.node = head(state);
210
+ return { discard: true, resplit: true };
211
+ }
212
+
213
+ // How many times node `v` may be entered in one unit: 1, plus the largest `max` of any
214
+ // non-iteration loop that declares it — or just 1 (no re-entry) when no loop covers it.
215
+ function allowedEntries(graph, v) {
216
+ const iteration = iterationLoop(graph);
217
+ let widest = null;
218
+ for (const m of graph.loops) {
219
+ if (m === iteration || !m.nodes.includes(v)) continue;
220
+ if (!widest || m.max > widest.max) widest = m;
221
+ }
222
+ return { allowed: widest ? 1 + widest.max : 1, loop: widest };
223
+ }
224
+
225
+ // Porcelain lines are `XY <path>`; strip the 2-char status + space, slash-normalise, drop
226
+ // LOOP.md rows (the engine rewrites it on every action — it is never part of the scored work).
227
+ function filterPorcelain(porcelain) {
228
+ const lines = porcelain.split(/\r?\n/).filter((line) => {
229
+ if (!line) return false;
230
+ const path = line.slice(3).replace(/^"|"$/g, '');
231
+ return slash(path) !== LOOP_FILE;
232
+ });
233
+ return lines.join('\n').trimEnd();
234
+ }
235
+
236
+ // Setup work (iteration 0, graph.start outside the iteration loop) is its own unit; rejecting
237
+ // it has nowhere to retry back into, so it stops the loop instead of looping to the head.
238
+ function isSetupUnit(state) {
239
+ return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
240
+ }
241
+
242
+ export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null } = {}) {
243
+ assertRunning(state);
244
+ if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
245
+ const from = state.node;
246
+ const brick = bricks.get(from);
247
+ if (!brick) throw new Error(`no brick named ${from} in the catalogue`);
248
+ if (!brick.outcomes.includes(outcome)) {
249
+ throw new Error(`${from} cannot report ${JSON.stringify(outcome)} — one of ${brick.outcomes.join(', ')}`);
250
+ }
251
+ if (brick.effect === 'mutate' && !state.validated) {
252
+ throw new Error(`${from} modifies code: run \`geneseed loop score --declared\` before it, then record`);
253
+ }
254
+ const filteredPorcelain = porcelain === null ? null : filterPorcelain(porcelain);
255
+ if (brick.effect === 'read' && filteredPorcelain !== null && filteredPorcelain !== state.snapshot) {
256
+ return stop(state, `read-brick-wrote: ${from} changed the working tree`);
257
+ }
258
+ if (filteredPorcelain !== null) state.snapshot = filteredPorcelain;
259
+ if (card) state.card = card;
260
+ if (from === 'test') state.tests = outcome;
261
+ state.visited.push(from);
262
+
263
+ const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome);
264
+ if (!edge) throw new Error(`no edge from ${from} on ${outcome}`);
265
+ if (edge.to === '$stop') return stop(state, `${from} reported ${outcome}`);
266
+ const mutated = state.visited.some((n) => bricks.get(n)?.effect === 'mutate');
267
+ if (edge.to === '$close') {
268
+ if (mutated) { state.closing = true; state.pendingVerify = true; return { verify: true }; }
269
+ state.status = 'done'; state.node = '$close';
270
+ return { done: true };
271
+ }
272
+ const h = head(state);
273
+ if (edge.to === h) {
274
+ if (mutated) { state.pendingVerify = true; state.node = h; return { verify: true }; }
275
+ // A read-only setup brick (no mutate visited) closes without asking for a diff score.
276
+ // `finishUnit` can itself hit the iteration ceiling and stop the loop — report that, not
277
+ // the head it was about to land on.
278
+ finishUnit(state, false);
279
+ return state.status === 'stopped' ? { stopped: state.reason } : { node: state.node };
280
+ }
281
+ state.counters[edge.to] = (state.counters[edge.to] ?? 0) + 1;
282
+ const { allowed, loop } = allowedEntries(state.graph, edge.to);
283
+ if (state.counters[edge.to] > allowed) return exhaust(state, loop ?? { name: edge.to, max: 0 });
284
+ state.node = edge.to;
285
+ return { node: edge.to };
286
+ }
287
+
288
+ export function decideAwaiting(state, bricks, verdict, note = '') {
289
+ if (state.status !== 'awaiting') throw new Error('nothing is awaiting a decision');
290
+ if (!['ok', 'no', 'amend'].includes(verdict)) throw new Error(`verdict must be ok, no or amend, not ${JSON.stringify(verdict)}`);
291
+ const { kind } = state.awaiting;
292
+ state.awaiting = null; state.status = 'running';
293
+ if (note) state.notes.push(`iteration ${state.iteration} (${kind}, ${verdict}): ${note}`);
294
+ if (kind === 'launch') {
295
+ return verdict === 'ok' ? { resumed: true } : stop(state, `graph ${verdict === 'no' ? 'rejected' : 'to amend'}${note ? `: ${note}` : ''}`);
296
+ }
297
+ if (kind === 'declared') {
298
+ if (verdict === 'ok') return { resumed: true };
299
+ if (verdict === 'amend') { state.card = { ...state.card, amend: note }; state.current.amended = true; return { resumed: true }; }
300
+ if (isSetupUnit(state)) return stop(state, `setup rejected${note ? `: ${note}` : ''}`);
301
+ resetUnit(state); state.node = head(state);
302
+ return { dropped: true };
303
+ }
304
+ // kind === 'actual': the diff exists, uncommitted
305
+ if (verdict === 'ok') return commitUnit(state);
306
+ state.pendingVerify = false; state.closing = false;
307
+ if (verdict === 'amend') {
308
+ state.card = { ...state.card, amend: note };
309
+ // The user intervened: the decision stands as blocking, said so, and the retry budget
310
+ // for this unit restarts (reset counters) now that the amendment changes what it does.
311
+ state.current = { declared: state.current.declared, decision: 'blocking', amended: true };
312
+ state.counters = {};
313
+ // A setup unit re-runs its own brick (graph.start), never the iteration's fix brick.
314
+ state.node = isSetupUnit(state)
315
+ ? state.graph.start
316
+ : iterationLoop(state.graph).nodes.find((n) => bricks.get(n)?.effect === 'mutate') ?? head(state);
317
+ return { resumed: true };
318
+ }
319
+ if (isSetupUnit(state)) return stop(state, `setup rejected${note ? `: ${note}` : ''}`);
320
+ resetUnit(state); state.node = head(state); state.snapshot = '';
321
+ return { discard: true };
322
+ }
@@ -35,12 +35,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:
35
35
  import path from 'node:path';
36
36
 
37
37
  import { main as driverMain } from '../build/driver.mjs';
38
- import { ROOT } from '../build/source.mjs';
38
+ import { PACK_ORDER, ROOT } from '../build/source.mjs';
39
39
  import {
40
40
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
41
41
  footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
42
+ trustOfDir,
42
43
  } from '../hosts/installs.mjs';
43
44
  import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
45
+ import { DEFAULT_PRESET } from '../loop/score.mjs';
44
46
  import {
45
47
  autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
46
48
  } from '../hosts/settings.mjs';
@@ -222,7 +224,7 @@ export function cmdMigrate(args = {}) {
222
224
  const out = r.scope === 'global' ? null : r.root;
223
225
  const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
224
226
  postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
225
- doctrinesForBuild(r.root));
227
+ doctrinesForBuild(r.root), PACK_ORDER, null, trustOfDir(r.root) || DEFAULT_PRESET);
226
228
  const label = `${r.host}:${r.scope} (${r.root})`;
227
229
  printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
228
230
  let rc = 1;
@@ -39,6 +39,7 @@ import {
39
39
  import { withStdoutSwallowed } from '../inspect/diff.mjs';
40
40
  import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
41
  import { setupBuildArgs } from '../build/generate.mjs';
42
+ import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
42
43
  import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
43
44
  import {
44
45
  HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
@@ -46,7 +47,7 @@ import {
46
47
  } from '../hosts/hosts.mjs';
47
48
  import {
48
49
  EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState,
49
- installTargets, modeOfDir, postureOfDir, readMaybe,
50
+ installTargets, modeOfDir, postureOfDir, readMaybe, trustOfDir,
50
51
  } from '../hosts/installs.mjs';
51
52
  import {
52
53
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
@@ -777,6 +778,7 @@ export function apiRestore(state, files) {
777
778
  footprint: state.footprint,
778
779
  posture: state.posture,
779
780
  mode: state.mode,
781
+ trust: state.trust,
780
782
  // THE PACK SELECTION IS THE FIFTH, and it is the one that makes this verb a BOUNDARY
781
783
  // question rather than a cosmetic one. `expected` rendered at all four packs, and this
782
784
  // verb COPIES OUT OF IT: restoring AGENT.md onto an install built `--doctrines craft`
@@ -858,6 +860,8 @@ export function apiInstallCmd(state, body) {
858
860
  const bmode = bget(body, 'mode');
859
861
  const mode = discoverNames('modes', 'direct').includes(bmode)
860
862
  ? bmode : (modeOfDir(root) || 'direct');
863
+ const btrust = bget(body, 'trust');
864
+ const trust = Object.keys(PRESETS).includes(btrust) ? btrust : (trustOfDir(root) || DEFAULT_PRESET);
861
865
  // Unspecified means "keep what this install already has", exactly as theme, footprint,
862
866
  // posture and mode above do — a rebuild through the console is not a place to silently
863
867
  // re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
@@ -872,7 +876,7 @@ export function apiInstallCmd(state, body) {
872
876
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
873
877
  const out = scope === 'global' ? null : String(root);
874
878
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
875
- PACK_ORDER, excludeRules);
879
+ PACK_ORDER, excludeRules, trust);
876
880
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
877
881
  }
878
882
 
@@ -969,6 +973,8 @@ export function apiDeployCmd(state, body) {
969
973
  const pos = discoverNames('postures', 'peer').includes(bpos) ? bpos : 'peer';
970
974
  const bmode = bget(body, 'mode');
971
975
  const mode = discoverNames('modes', 'direct').includes(bmode) ? bmode : 'direct';
976
+ const btrust = bget(body, 'trust');
977
+ const trust = Object.keys(PRESETS).includes(btrust) ? btrust : DEFAULT_PRESET;
972
978
  // Same resolution as `apiInstallCmd` above, and for the same reason: the console's Deploy
973
979
  // form sends host/path/theme/footprint/posture/mode and NO pack selection, and nothing stops
974
980
  // it landing on a directory that already holds an install. Taking `bodyDoctrines` alone left
@@ -979,6 +985,6 @@ export function apiDeployCmd(state, body) {
979
985
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
980
986
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
981
987
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
982
- PACK_ORDER, excludeRules);
988
+ PACK_ORDER, excludeRules, trust);
983
989
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
984
990
  }
package/js/web/api.mjs CHANGED
@@ -21,10 +21,12 @@ import { excludesSnapshot } from '../inspect/excludes.mjs';
21
21
  import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
22
22
  import {
23
23
  doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
24
- installedDefaults, modeOfDir,
24
+ installedDefaults, modeOfDir, trustOfDir,
25
25
  postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
26
26
  } from '../hosts/installs.mjs';
27
27
  import { frontmatter } from '../hosts/hooks.mjs';
28
+ import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
29
+ import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
28
30
  import {
29
31
  SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
30
32
  } from '../inspect/inventory.mjs';
@@ -87,6 +89,7 @@ export function webState(theme = null, target = null) {
87
89
  footprint: null,
88
90
  posture: null,
89
91
  mode: null,
92
+ trust: null,
90
93
  _inv: null,
91
94
  _doctor: null,
92
95
  };
@@ -98,6 +101,7 @@ export function webState(theme = null, target = null) {
98
101
  st.footprint = footprintOfDir(st.target); // 'full' when no marker
99
102
  st.posture = postureOfDir(st.target) || 'peer';
100
103
  st.mode = modeOfDir(st.target) || 'direct';
104
+ st.trust = trustOfDir(st.target) || DEFAULT_PRESET;
101
105
 
102
106
  Object.defineProperty(st, 'inventory', {
103
107
  get() {
@@ -154,6 +158,7 @@ export function webState(theme = null, target = null) {
154
158
  st.footprint = footprintOfDir(st.root);
155
159
  st.posture = postureOfDir(st.root) || 'peer';
156
160
  st.mode = modeOfDir(st.root) || 'direct';
161
+ st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
157
162
  st._inv = null;
158
163
  st._doctor = null;
159
164
  };
@@ -165,6 +170,7 @@ export function webState(theme = null, target = null) {
165
170
  st.footprint = footprintOfDir(st.root);
166
171
  st.posture = postureOfDir(st.root) || 'peer';
167
172
  st.mode = modeOfDir(st.root) || 'direct';
173
+ st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
168
174
  };
169
175
  return st;
170
176
  }
@@ -741,11 +747,12 @@ export function apiInstalls(state) {
741
747
  footprint: footprintOfDir(root),
742
748
  posture: postureOfDir(root),
743
749
  mode: modeOfDir(root),
750
+ trust: trustOfDir(root),
744
751
  selected: samePath(viewCfg(host, scope, root), state.target),
745
752
  });
746
753
  }
747
754
  return { installs: out, postures: discoverNames('postures', 'peer'),
748
- modes: discoverNames('modes', 'direct') };
755
+ modes: discoverNames('modes', 'direct'), trusts: Object.keys(PRESETS) };
749
756
  }
750
757
 
751
758
  /** Beside the deployed AGENT.md. */
@@ -848,6 +855,9 @@ export function apiOverview(state) {
848
855
  notebook: notebookItems(state).length,
849
856
  wiki: wikiItems(state).length,
850
857
  config: configItems(state).length,
858
+ // The rail's Loops badge: templates only, from the same catalogue `/api/loops` reads —
859
+ // a brick is a part, the template is what a person runs.
860
+ loops: loadCatalog({ projectRoot: state.root }).templates.size,
851
861
  },
852
862
  doctor: state.doctor,
853
863
  diff,
@@ -933,6 +943,31 @@ export function apiRecent(state) {
933
943
  return { items: items.slice(0, RECENT_LIMIT), skipped, limit: RECENT_LIMIT };
934
944
  }
935
945
 
946
+ /**
947
+ * The Loops page: the brick and template catalogue the `geneseed loop` CLI reads, resolved from
948
+ * the install ROOT — for a project install that is the repo, where `<repo>/.geneseed/` lives; for
949
+ * a global one it is the config dir, which has no `.geneseed/` and so adds nothing. Read-only:
950
+ * the page draws templates and lists bricks, it never writes one.
951
+ *
952
+ * A template's graph is passed whole (`graph`) rather than flattened, because the page's ring
953
+ * layout needs `nodes`, `edges` and `loops` exactly as the engine reads them.
954
+ */
955
+ export function apiLoops(state) {
956
+ const { bricks, templates, overridden } = loadCatalog({ projectRoot: state.root });
957
+ const byName = ([a], [b]) => (a < b ? -1 : 1);
958
+ return {
959
+ templates: [...templates].sort(byName).map(([name, { origin, ...graph }]) => ({
960
+ name, description: graph.description || '', origin, graph,
961
+ })),
962
+ bricks: [...bricks].sort(byName).map(([, b]) => ({ reason: null, ...b })),
963
+ overridden,
964
+ // A brick or template the catalogue skipped (bad frontmatter, not JSON, misnamed), and a
965
+ // template that fails the graph rules: the page says so — the same list `loop check`
966
+ // prints — or a team's broken override would just silently not be there.
967
+ problems: catalogProblems({ projectRoot: state.root }),
968
+ };
969
+ }
970
+
936
971
  /** The source's release label, or null when it cannot be read. */
937
972
  function releaseLabel() {
938
973
  const v = sourceReleaseVersion({ config: CONFIG });
@@ -982,4 +1017,5 @@ export const STATE_ROUTES = {
982
1017
  // answers — a daemon whose POST worked and whose GET 501'd would have no way to obtain one.
983
1018
  '/api/rules': apiRules,
984
1019
  '/api/mcp': apiMcp,
1020
+ '/api/loops': apiLoops,
985
1021
  };
package/js/web/daemon.mjs CHANGED
@@ -267,9 +267,42 @@ export async function startDaemon(theme, port, openBrowser = true) {
267
267
  return 1;
268
268
  }
269
269
 
270
- export async function stopDaemon(theme = null) {
270
+ /**
271
+ * `--port` NAMES WHICH DAEMON, and the record holds only one. With no `--port` (`port` null)
272
+ * the verbs act on whatever the record names, as they always have. With one, they act only if
273
+ * the record is on that port: `stop --port 4791` once POSTed shutdown to the recorded url and
274
+ * stopped the console on 4747. So a record on another port is refused — no request sent, record
275
+ * left in place — and a server answering on the named port that is NOT the record is explained,
276
+ * because its token is not on record and there is nothing to guess it from.
277
+ *
278
+ * Null when the verb may go ahead; otherwise the exit code, already explained on stdout.
279
+ */
280
+ async function portRefusal(st, port, verb) {
281
+ if (port === null || port === undefined) return null;
282
+ const recorded = isTruthy(st) && isTruthy(st.url) ? Number(st.port) : null;
283
+ if (recorded === port) return null;
284
+ if (recorded !== null) {
285
+ if (verb === 'status') {
286
+ printOut(`[web] not running on port ${port} (the recorded daemon is on port ${recorded}).\n`);
287
+ } else {
288
+ printOut(`[web] the recorded daemon is on port ${recorded}, not ${port} — not touching it.\n`);
289
+ printOut(` \`geneseed web ${verb} --port ${recorded}\` (or no --port) ${verb}s that one.\n`);
290
+ }
291
+ }
292
+ if (await probe(`http://127.0.0.1:${port}`)) {
293
+ printOut(`[web] a server answers on port ${port} but is not the recorded daemon, so its `
294
+ + 'token is unknown — stop it from its own page (Stop) or end its process.\n');
295
+ return 1;
296
+ }
297
+ // Nothing recorded and nothing on the named port: the verb's own no-daemon arm says so.
298
+ return recorded === null ? null : 1;
299
+ }
300
+
301
+ export async function stopDaemon(theme = null, port = null) {
271
302
  const { target } = webState(theme);
272
303
  const st = readDaemon(target);
304
+ const refused = await portRefusal(st, port, 'stop');
305
+ if (refused !== null) return refused;
273
306
  if (!isTruthy(st) || !isTruthy(st.url)) {
274
307
  // NO `clearDaemon` HERE — deliberately asymmetric with `liveDaemon`/`statusDaemon`,
275
308
  // which DO delete a record naming no url. Unifying the two would delete a file this
@@ -287,8 +320,10 @@ export async function stopDaemon(theme = null) {
287
320
  return 0;
288
321
  }
289
322
 
290
- export async function statusDaemon(theme = null) {
323
+ export async function statusDaemon(theme = null, port = null) {
291
324
  const { target } = webState(theme);
325
+ const refused = await portRefusal(readDaemon(target), port, 'status');
326
+ if (refused !== null) return refused;
292
327
  const st = await liveDaemon(target);
293
328
  if (st) {
294
329
  printOut(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
@@ -322,19 +357,22 @@ export async function bounceDaemonIfRunning(theme, log, { onlyIfStale = false }
322
357
  return;
323
358
  }
324
359
  try {
325
- await restartDaemon(theme, 4747, false, true);
360
+ await restartDaemon(theme, null, false, true);
326
361
  } catch (e) {
327
362
  log(`[geneseed] ⚠️ could not refresh the web daemon (${e && e.message ? e.message : e}) — \`geneseed web restart\` manually if it was running.`);
328
363
  }
329
364
  }
330
365
 
331
- export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
366
+ /** `port` null keeps the recorded daemon's port (4747 if none); see `portRefusal`. */
367
+ export async function restartDaemon(theme = null, port = null, openBrowser = true,
332
368
  onlyIfRunning = false) {
333
369
  const { target } = webState(theme);
334
370
  const st = readDaemon(target);
371
+ const refused = await portRefusal(st, port, 'restart');
372
+ if (refused !== null) return refused;
335
373
  const live = (await liveDaemon(target)) !== null;
336
374
  if (onlyIfRunning && !live) return 0;
337
- const usePort = (st && isTruthy(st.port) ? st.port : null) || port;
375
+ const usePort = port ?? ((st && isTruthy(st.port) ? st.port : null) || 4747);
338
376
  let open = openBrowser;
339
377
  if (live) {
340
378
  // A live daemon means a tab is already open on this (preserved) port and will
@@ -295,6 +295,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
295
295
  // packs — never to `harness.config.json`, which would drop the consent gate.
296
296
  const cmds = actionCommands(action, {
297
297
  theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
298
+ trust: state.trust,
298
299
  doctrines: doctrinesForBuild(state.target),
299
300
  });
300
301
  if (!isTruthy(cmds)) {
package/js/web/jobs.mjs CHANGED
@@ -88,6 +88,7 @@ import path from 'node:path';
88
88
 
89
89
  import { ROOT, PACK_ORDER } from '../build/source.mjs';
90
90
  import { setupBuildArgs } from '../build/generate.mjs';
91
+ import { DEFAULT_PRESET } from '../loop/score.mjs';
91
92
  import { isDict } from '../hosts/mcp.mjs';
92
93
  import { readText, writeText, isFile } from '../lib/fs.mjs';
93
94
  import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
@@ -402,9 +403,10 @@ export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
402
403
  function actionTable({
403
404
  theme = 'neutral', emit = 'opencode-global', footprint = 'full',
404
405
  posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER], excludeRules = [],
406
+ trust = DEFAULT_PRESET,
405
407
  } = {}) {
406
408
  const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode,
407
- doctrines, PACK_ORDER, excludeRules);
409
+ doctrines, PACK_ORDER, excludeRules, trust);
408
410
  return {
409
411
  doctor: [[NODE(), CLI(), 'doctor']],
410
412
  build: [[NODE(), GEN(), ...buildArgv]],
package/js/web/server.mjs CHANGED
@@ -200,9 +200,11 @@ export async function cmdWeb(args) {
200
200
  const port = args.port === null ? 4747 : parseIntStrict(args.port);
201
201
  const openBrowser = !args.noBrowser;
202
202
  if (args.action === 'start') return startDaemon(args.theme, port, openBrowser);
203
- if (args.action === 'stop') return stopDaemon(args.theme);
204
- if (args.action === 'restart') return restartDaemon(args.theme, port, openBrowser);
205
- if (args.action === 'status') return statusDaemon(args.theme);
203
+ // Only an EXPLICIT --port scopes these three to one daemon; absent, they act on the record.
204
+ const named = args.port === null ? null : port;
205
+ if (args.action === 'stop') return stopDaemon(args.theme, named);
206
+ if (args.action === 'restart') return restartDaemon(args.theme, named, openBrowser);
207
+ if (args.action === 'status') return statusDaemon(args.theme, named);
206
208
  return serve({ theme: args.theme, port, openBrowser, daemon: args.daemonInternal });
207
209
  }
208
210
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.11.0",
4
+ "version": "3.12.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",