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,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;
@@ -50,9 +50,10 @@ import { discoverNames, PACK_ORDER } from '../build/source.mjs';
50
50
  import { exportImprovements } from '../inspect/diff.mjs';
51
51
  import { cmdDoctor } from '../inspect/doctor.mjs';
52
52
  import { setupBuildArgs } from '../build/generate.mjs';
53
- import { GLOBAL_MANIFEST, opencodeConfigDir } from '../hosts/hosts.mjs';
53
+ import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir } from '../hosts/hosts.mjs';
54
54
  import {
55
- defaultMode, defaultPosture, defaultTheme, installedDefaults, themeFiles,
55
+ claudeCfg, defaultMode, defaultPosture, defaultTheme, EMIT_HOST_SCOPE, installedDefaults,
56
+ readJsonMaybe, themeFiles,
56
57
  } from '../hosts/installs.mjs';
57
58
  import { printOut, printErr } from '../lib/fs.mjs';
58
59
  import { which } from '../lib/paths.mjs';
@@ -457,39 +458,77 @@ export function lspPrereqs() {
457
458
  + "SDKMAN `sdk install java 21-tem`, or your distro's package"]];
458
459
  }
459
460
 
461
+ /** The name each host goes by on screen — the same four labels `hostTools` prints. */
462
+ const TOOL_NAME = {
463
+ opencode: 'OpenCode', claude: 'Claude Code', bob: 'IBM Bob', openclaude: 'OpenClaude',
464
+ };
465
+
466
+ /**
467
+ * Where an emit put its root file, and — for the Claude-shaped hosts — its hooks.
468
+ *
469
+ * READ BACK, NOT RE-SPELLED: the placements differ by host and scope (Bob global's preamble is
470
+ * `rules/geneseed.md`, a Claude project's CLAUDE.md sits beside `.claude/`, a project's hooks go
471
+ * to `settings.local.json`, Bob global's to `settings/settings.json`), and the emit already
472
+ * records each one in the install's manifest (`managed.claude_md`, `settings_file`,
473
+ * `settings_hooks`). A summary that recomputed them would be a second owner of every rule.
474
+ */
475
+ function emitFacts(emit, out) {
476
+ const [host, scope] = EMIT_HOST_SCOPE.get(emit) ?? ['files', 'project'];
477
+ if (host === 'files' || host === 'opencode') {
478
+ const dir = emit === 'opencode-global' ? opencodeConfigDir() : resolveOut(out || 'Harness');
479
+ return { host, rootFile: path.join(dir, 'AGENT.md') };
480
+ }
481
+ const spec = HOSTS.find((h) => h.host === host);
482
+ const cfg = claudeCfg(scope === 'global' ? spec.configDir() : resolveOut(out || '.'), scope, host);
483
+ const managed = readJsonMaybe(path.join(cfg, GLOBAL_MANIFEST))?.managed ?? {};
484
+ const rootFile = managed.claude_md?.rel
485
+ ? path.resolve(cfg, managed.claude_md.rel)
486
+ : path.join(cfg, host === 'bob' ? path.join('rules', 'geneseed.md') : spec.agentFile);
487
+ return {
488
+ host,
489
+ rootFile,
490
+ settings: managed.settings_file ? path.join(cfg, managed.settings_file) : null,
491
+ hookGroups: Array.isArray(managed.settings_hooks) ? managed.settings_hooks.length : 0,
492
+ };
493
+ }
494
+
460
495
  /**
461
496
  * `_harness_setup._setup_summary_lines` — the post-build report as `[kind, text]` rows,
462
497
  * `kind` being ok | warn | info.
463
498
  *
464
499
  * `ok` is the build's own success, so the first row is the only one that can say "build
465
- * failed"; every later row assumes a tree on disk. The global-install warning is the row
466
- * with real behaviour in it: a bundle or project emit made while an OpenCode GLOBAL install
467
- * exists is loaded by nothing, and saying so is the difference between a wizard and a form.
500
+ * failed"; every later row assumes a tree on disk. HOST-AWARE since the install-hardening
501
+ * pass: it used to know only OpenCode's AGENT.md, so a successful claude-global install ended
502
+ * on "expected AGENT.md … but it is not there" and "start a NEW OpenCode session". The
503
+ * global-install warning is the row with real behaviour in it: a bundle or project emit made
504
+ * while an OpenCode GLOBAL install exists is loaded by nothing, and saying so is the
505
+ * difference between a wizard and a form.
468
506
  */
469
507
  export function setupSummaryLines(theme, emit, out, root, ok) {
470
- const agentMd = emit === 'opencode-global'
471
- ? path.join(opencodeConfigDir(), 'AGENT.md')
472
- : path.join(resolveOut(out || 'Harness'), 'AGENT.md');
508
+ const f = emitFacts(emit, out ?? root);
509
+ const name = path.basename(f.rootFile);
473
510
  const lines = [];
474
- if (ok && existsSync(agentMd)) {
475
- lines.push(['ok', `AGENT.md written to ${agentMd}`]);
511
+ if (ok && existsSync(f.rootFile)) {
512
+ lines.push(['ok', `${name} written to ${f.rootFile}`]);
476
513
  } else if (ok) {
477
- lines.push(['warn', `expected AGENT.md at ${agentMd} but it is not there`]);
514
+ lines.push(['warn', `expected ${name} at ${f.rootFile} but it is not there`]);
478
515
  } else {
479
516
  lines.push(['warn', 'build failed — see the output above']);
480
517
  }
481
- if (emit === 'opencode-global') {
482
- const cfgDir = opencodeConfigDir();
483
- const hint = process.platform === 'win32'
484
- ? `learn plugin: $env:GENESEED_HARNESS = "${cfgDir}" (persist: setx GENESEED_HARNESS "${cfgDir}")`
485
- : 'learn plugin: export GENESEED_HARNESS="$HOME/.config/opencode"';
486
- lines.push(['info', hint]);
487
- } else if (emit === 'files') {
488
- lines.push(['info', `point your tool's instructions at ${agentMd}`]);
518
+ if (ok && f.settings) {
519
+ lines.push(['ok', `${f.hookGroups} hook groups wired in ${f.settings}`]);
520
+ if (!process.env.GENESEED_LLM) {
521
+ lines.push(['info', 'memory learning is off until you set GENESEED_LLM '
522
+ + '(see docs/reference/environment.md)']);
523
+ }
524
+ }
525
+ if (emit === 'files') {
526
+ lines.push(['info', `point your tool's instructions at ${f.rootFile}`]);
489
527
  }
490
528
  try {
491
529
  const cfg = opencodeConfigDir();
492
- if (emit !== 'opencode-global' && existsSync(path.join(cfg, GLOBAL_MANIFEST))) {
530
+ // Only for an emit OpenCode itself would load — a Claude or Bob install is not shadowed.
531
+ if (['opencode', 'files'].includes(emit) && existsSync(path.join(cfg, GLOBAL_MANIFEST))) {
493
532
  lines.push(['warn', `a global install exists at ${cfg} — OpenCode loads THAT, `
494
533
  + `not this build; re-run with 'opencode-global' to change it`]);
495
534
  }
@@ -500,7 +539,12 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
500
539
  lines.push(present ? ['ok', `${label} present`] : ['warn', `${label} missing — ${hint}`]);
501
540
  }
502
541
  }
503
- lines.push(['info', `theme is now '${theme}' — start a NEW OpenCode session for the new voice`]);
542
+ const tool = TOOL_NAME[f.host];
543
+ lines.push(['info', tool
544
+ ? `theme is now '${theme}' — start a NEW ${tool} session to load it`
545
+ : `theme is now '${theme}' — start a NEW session in your tool to load it`]);
546
+ lines.push(['info', 'what was installed and what each piece does: '
547
+ + 'https://github.com/Arylmera/Geneseed/blob/main/docs/understand/on-your-machine.md']);
504
548
  return lines;
505
549
  }
506
550
 
@@ -593,5 +637,49 @@ export function cmdSetup() {
593
637
  + ' geneseed build --emit opencode-global --theme neutral\n');
594
638
  return 1;
595
639
  }
640
+ if (!toolsReport()) return 1;
596
641
  return setupLines();
597
642
  }
643
+
644
+ /**
645
+ * The AI coding tools Geneseed installs into, as `[label, present, url]` rows in HOSTS order.
646
+ *
647
+ * PRESENT MEANS THE COMMAND IS ON PATH, and nothing else. The config dir looked like a second
648
+ * signal — a desktop-only install has one and no command — but GENESEED ITSELF CREATES IT: a
649
+ * `bob-global` emit writes `~/.bob` on a machine that never had Bob, and the first run of this
650
+ * check reported Bob present on exactly such a machine. A wrong "ok" sends the user to an
651
+ * install mode for a tool they cannot open; a wrong "not found" costs one line they can ignore.
652
+ *
653
+ * NO SPAWN, deliberately. `which` walks PATH by hand, so asking costs nothing on the CLI's
654
+ * child_process allow-list — a `claude --version` probe would have been a third entry there to
655
+ * learn nothing `which` does not already say. `lookup` is the seam for the test, which cannot
656
+ * rely on what this machine happens to have installed.
657
+ */
658
+ export function hostTools({ lookup = which } = {}) {
659
+ return [
660
+ ['OpenCode', 'opencode', 'https://opencode.ai/docs/'],
661
+ ['Claude Code', 'claude', 'https://code.claude.com/docs/en/setup'],
662
+ ['IBM Bob', 'bob', 'https://bob.ibm.com/'],
663
+ ['OpenClaude', 'openclaude', 'https://openclaude.gitlawb.com/'],
664
+ ].map(([label, cmd, url]) => [label, Boolean(lookup(cmd)), url]);
665
+ }
666
+
667
+ /**
668
+ * Show which tools are here before asking anything — the install-mode question is easier to
669
+ * answer knowing it — and when NONE is, ask whether to stop and install one first. It never
670
+ * installs one: on a managed machine that is IT's call, and each tool has its own installer.
671
+ * A missing tool among several present is a line, not a question; nobody needs all four.
672
+ * `false` is the user choosing to stop.
673
+ */
674
+ export function toolsReport(tools = hostTools()) {
675
+ printOut('AI coding tools on this machine:\n');
676
+ for (const [label, present, url] of tools) {
677
+ printOut(present ? ` ok ${label}\n` : ` -- ${label} not on PATH — install: ${url}\n`);
678
+ }
679
+ if (tools.some(([, present]) => present)) { printOut('\n'); return true; }
680
+ printOut('\nNone of them is installed. Install the one you use (links above), then run setup '
681
+ + 'again — or go on and pick the plain bundle, which any AGENT.md tool can read.\n');
682
+ if (confirm('Continue without one?', false)) { printOut('\n'); return true; }
683
+ printOut('Stopped. Run setup again once a tool is installed.\n');
684
+ return false;
685
+ }
@@ -50,7 +50,7 @@ import { appendText, copyFile, printOut, printErr, readText, writeText } from '.
50
50
  import { toPlatformPath, which } from '../lib/paths.mjs';
51
51
  import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
52
  import { NO_WINDOW } from '../lib/proc.mjs';
53
- import { restartDaemon } from '../web/daemon.mjs';
53
+ import { bounceDaemonIfRunning } from '../web/daemon.mjs';
54
54
 
55
55
  /**
56
56
  * `_CREDS_RE` / `_redact_url_creds` — strip a `user[:token]@` userinfo out of any URL in
@@ -578,20 +578,9 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
578
578
  { cwd: String(here), stdio: 'inherit' });
579
579
  if (proc.status !== 0) return proc.status === null ? 1 : proc.status;
580
580
  // If a web daemon is running, bounce it so the new source and the freshly rebuilt web/dist
581
- // take effect — otherwise the open PWA keeps hitting the old code. EXCEPT when this
582
- // upgrade IS a web-daemon job: restarting the daemon here kills the process tracking this
583
- // very job (console stuck on 'running' forever); in that case the server restarts itself
584
- // once the job is recorded as finished. `js/web/jobs.mjs` sets the variable; this is the
585
- // side that reads it, and `upgrade/the-web-job-marker-...` is what gates the pair.
586
- if (process.env.GENESEED_WEB_JOB) {
587
- log('[geneseed] web daemon will restart itself after this job to load the new code.');
588
- return 0;
589
- }
590
- try {
591
- await restartDaemon(theme, 4747, false, true);
592
- } catch (e) { // never fail an upgrade on this
593
- log(`[geneseed] ⚠️ could not refresh the web daemon (${e && e.message ? e.message : e}) — \`geneseed web restart\` manually if it was running.`);
594
- }
581
+ // take effect — otherwise the open PWA keeps hitting the old code. The web-job exception
582
+ // (`upgrade/the-web-job-marker-...` gates it) lives with the helper.
583
+ await bounceDaemonIfRunning(theme, log);
595
584
  return 0;
596
585
  }
597
586
 
package/js/ui/cli.mjs CHANGED
@@ -69,7 +69,7 @@ function load() {
69
69
  try {
70
70
  return JSON.parse(readFileSync(CLI_JSON, 'utf-8'));
71
71
  } catch {
72
- return { prog: 'harness', commands: [] };
72
+ return { prog: 'geneseed', commands: [] };
73
73
  }
74
74
  }
75
75
 
@@ -88,7 +88,7 @@ const pageArgs = (args) => (args ?? [])
88
88
  export function cliReference() {
89
89
  const data = load();
90
90
  return {
91
- prog: data.prog ?? 'harness',
91
+ prog: data.prog ?? 'geneseed',
92
92
  commands: (data.commands ?? []).map((c) => ({
93
93
  name: c.name ?? '',
94
94
  help: c.help ?? '',
@@ -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
  }