geneseed 3.12.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/README.md +2 -2
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/openclaude/README.md +1 -1
- package/adapters/opencode/README.md +3 -3
- package/docs/README.md +41 -22
- package/docs/concepts/agents.md +3 -1
- package/docs/concepts/collaboration.md +3 -1
- package/docs/concepts/exclusions.md +4 -2
- package/docs/concepts/footprint.md +3 -1
- package/docs/concepts/foreman-mode.md +3 -1
- package/docs/concepts/hook-gates.md +61 -0
- package/docs/concepts/hooks.md +8 -56
- package/docs/concepts/host-matrix.md +38 -0
- package/docs/concepts/hosts.md +6 -32
- package/docs/concepts/loop-branch.md +66 -0
- package/docs/concepts/loop-bricks.md +48 -0
- package/docs/concepts/loop-contracts.md +33 -0
- package/docs/concepts/loop-gates.md +30 -0
- package/docs/concepts/loop-notes.md +43 -0
- package/docs/concepts/loop-risk.md +36 -0
- package/docs/concepts/loop-templates.md +50 -0
- package/docs/concepts/loops.md +24 -116
- package/docs/concepts/lsp.md +2 -0
- package/docs/concepts/memory.md +3 -1
- package/docs/concepts/notebook.md +3 -1
- package/docs/concepts/rules.md +4 -2
- package/docs/concepts/skills.md +3 -1
- package/docs/concepts/themes.md +3 -1
- package/docs/extending.md +23 -8
- package/docs/guides/autostart.md +3 -1
- package/docs/guides/choose-your-setup.md +5 -80
- package/docs/guides/headless.md +5 -3
- package/docs/guides/install-agent-md.md +37 -0
- package/docs/guides/install-bob.md +20 -0
- package/docs/guides/install-claude-code.md +22 -0
- package/docs/guides/install-clone.md +34 -0
- package/docs/guides/install-openclaude.md +25 -0
- package/docs/guides/install-opencode.md +35 -0
- package/docs/guides/install-scripted.md +21 -0
- package/docs/guides/install.md +9 -118
- package/docs/guides/loop-finish.md +54 -0
- package/docs/guides/loop-running.md +66 -0
- package/docs/guides/loop-which-template.md +35 -0
- package/docs/guides/mcp-filesystem.md +41 -0
- package/docs/guides/mcp-gitlab.md +66 -0
- package/docs/guides/mcp-markitdown.md +36 -0
- package/docs/guides/mcp.md +8 -137
- package/docs/guides/migrate.md +3 -1
- package/docs/guides/project-context.md +4 -2
- package/docs/guides/read-non-markdown.md +17 -0
- package/docs/guides/run-a-loop.md +64 -0
- package/docs/guides/run-anywhere.md +2 -0
- package/docs/guides/setup-doctrines.md +55 -0
- package/docs/guides/setup-footprint.md +48 -0
- package/docs/guides/uninstall.md +3 -1
- package/docs/guides/upgrade.md +3 -1
- package/docs/guides/verify.md +3 -1
- package/docs/guides/wiki.md +3 -1
- package/docs/guides/worktree.md +3 -1
- package/docs/reference/about.md +3 -1
- package/docs/reference/cli.md +2 -0
- package/docs/reference/env-context.md +40 -0
- package/docs/reference/environment.md +10 -59
- package/docs/reference/glossary.md +3 -1
- package/docs/reference/loop-actions.md +20 -0
- package/docs/reference/opencode-plugins.md +14 -118
- package/docs/reference/plugin-activity.md +20 -0
- package/docs/reference/plugin-context.md +27 -0
- package/docs/reference/plugin-guard.md +27 -0
- package/docs/reference/plugin-learn.md +27 -0
- package/docs/reference/plugin-notify.md +20 -0
- package/docs/reference/plugin-ponytail.md +19 -0
- package/docs/reference/plugin-workflow.md +25 -0
- package/docs/reference/troubleshoot-doctor.md +50 -0
- package/docs/reference/troubleshoot-hooks.md +99 -0
- package/docs/reference/troubleshoot-install.md +108 -0
- package/docs/reference/troubleshoot-tools.md +56 -0
- package/docs/reference/troubleshoot-update.md +23 -0
- package/docs/reference/troubleshoot-web.md +66 -0
- package/docs/reference/troubleshooting.md +13 -337
- package/docs/understand/a-day.md +3 -1
- package/docs/understand/enforced-vs-asked.md +3 -1
- package/docs/understand/harness.md +2 -0
- package/docs/understand/machine-bob.md +17 -0
- package/docs/understand/machine-claude.md +35 -0
- package/docs/understand/machine-opencode.md +36 -0
- package/docs/understand/on-your-machine.md +12 -55
- package/docs/understand/take-it-out.md +13 -0
- package/docs/web-ui.md +35 -15
- package/harness.config.json +1 -1
- package/js/README.md +11 -3
- package/js/cli-table.json +27 -1
- package/js/hosts/mcp.mjs +1 -1
- package/js/hosts/native.mjs +43 -5
- package/js/inspect/checks-build.mjs +28 -0
- package/js/loop/catalog.mjs +12 -1
- package/js/loop/cli.mjs +28 -6
- package/js/loop/graph.mjs +18 -0
- package/js/loop/registry.mjs +196 -0
- package/js/loop/score.mjs +45 -6
- package/js/loop/state.mjs +125 -11
- package/js/maintain/setup.mjs +1 -1
- package/js/web/actions.mjs +25 -0
- package/js/web/api.mjs +13 -0
- package/js/web/docs.mjs +64 -12
- package/js/web/routes.mjs +5 -1
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -2
- package/src/agents/researcher.md +3 -3
- package/src/bricks/README.md +3 -1
- package/src/bricks/adr-challenge.md +20 -0
- package/src/bricks/adr-draft.md +15 -0
- package/src/bricks/characterize.md +17 -0
- package/src/bricks/ci-fix.md +2 -1
- package/src/bricks/ci-triage.md +20 -0
- package/src/bricks/compat-check.md +16 -0
- package/src/bricks/done-check.md +16 -0
- package/src/bricks/fitness-define.md +22 -0
- package/src/bricks/flake-check.md +17 -0
- package/src/bricks/flake-repro.md +18 -0
- package/src/bricks/identify.md +6 -3
- package/src/bricks/migration-plan.md +21 -0
- package/src/bricks/mutation-check.md +18 -0
- package/src/bricks/plan.md +6 -6
- package/src/bricks/red-test.md +16 -0
- package/src/bricks/review.md +5 -3
- package/src/bricks/spec.md +18 -0
- package/src/bricks/upgrade-scout.md +16 -0
- package/src/loops/api-endpoint.json +31 -0
- package/src/loops/architecture-decision.json +22 -0
- package/src/loops/bugfix.json +1 -0
- package/src/loops/ci-repair.json +19 -0
- package/src/loops/deps-upgrade.json +29 -0
- package/src/loops/enforce-architecture-rule.json +28 -0
- package/src/loops/feature.json +8 -5
- package/src/loops/fix-flaky-tests.json +30 -0
- package/src/loops/legacy-refactor.json +26 -0
- package/src/loops/legacy-tests.json +29 -0
- package/src/loops/refactor.json +5 -1
- package/src/loops/remove-dead-code.json +30 -0
- package/src/loops/spec-first-feature.json +29 -0
- package/src/loops/tdd.json +25 -0
- package/src/loops/update-contract.json +33 -0
- package/src/skills/brick-forge.md +3 -1
- package/src/skills/bruno-collection-generator.md +3 -3
- package/src/skills/bruno-test-writer.md +3 -3
- package/src/skills/ci-fix.md +3 -3
- package/src/skills/consolidate-memory.md +4 -4
- package/src/skills/council.md +1 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/git-archaeology.md +1 -1
- package/src/skills/git-rescue.md +4 -3
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/learn-mode.md +2 -2
- package/src/skills/loop.md +22 -10
- package/src/skills/opencode-theme.md +3 -3
- package/src/skills/ponytail.md +1 -1
- package/src/skills/profile.md +3 -4
- package/src/skills/prose.md +2 -3
- package/src/skills/quiz.md +1 -1
- package/src/skills/react-view-transitions/SKILL.md +1 -1
- package/src/skills/react-view-transitions/VENDOR.md +8 -3
- package/src/skills/research.md +3 -3
- package/src/skills/review-response.md +2 -2
- package/src/skills/rule.md +3 -3
- package/src/skills/security-audit.md +4 -4
- package/src/skills/teach.md +1 -1
- package/src/skills/wiki.md +3 -3
- package/src/skills/workflow.md +4 -4
- package/src/skills/worktree.md +2 -2
- package/web/dist/assets/Activity-Ca2Aruu7.js +1 -0
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +1 -0
- package/web/dist/assets/Dashboard-BuMq46aT.js +2 -0
- package/web/dist/assets/Docs-BzccCllG.js +5 -0
- package/web/dist/assets/FilterInput-CBcJBQPI.js +1 -0
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +2 -0
- package/web/dist/assets/Installs-DMFJvq7s.js +2 -0
- package/web/dist/assets/JournalView-o1rmd1_1.js +1 -0
- package/web/dist/assets/Laws-BANxq_I_.js +3 -0
- package/web/dist/assets/Library-DW4x0fbh.js +1 -0
- package/web/dist/assets/Loops-DEBibbBP.js +2 -0
- package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-CdTX0kLe.js} +14 -14
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +2 -0
- package/web/dist/assets/Personal-6uMJvyBG.js +1 -0
- package/web/dist/assets/Tabs-BubnyZhF.js +1 -0
- package/web/dist/assets/activity-CObqyxkL.js +1 -0
- package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
- package/web/dist/assets/drawable-C_x5xncq.js +1 -0
- package/web/dist/assets/index-CMPgezFm.js +2 -0
- package/web/dist/assets/index-DMsT4Aa6.css +1 -0
- package/web/dist/assets/{react-B9Z_tXRV.js → react-Bbzhmq4v.js} +1 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/web/dist/assets/sections-Dg23Hel5.js +1 -0
- package/web/dist/assets/usePoll-BOqeunzF.js +1 -0
- package/web/dist/index.html +6 -5
- package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
- package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
- package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
- package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
- package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
- package/web/dist/assets/GreenhouseView-C6ibc8IE.js +0 -2
- package/web/dist/assets/Installs-DRYjYnxs.js +0 -2
- package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
- package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
- package/web/dist/assets/Library-DDjgNwz_.js +0 -1
- package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
- package/web/dist/assets/OperatorHudView-Bs7paWmh.js +0 -2
- package/web/dist/assets/Personal-BY5n4gTw.js +0 -1
- package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
- package/web/dist/assets/drawable-CLS9drMb.js +0 -1
- package/web/dist/assets/index-CX_hXMi5.css +0 -1
- package/web/dist/assets/index-Cjr7XCA3.js +0 -2
- package/web/dist/assets/sections-GalMS0Zv.js +0 -1
- package/web/dist/assets/usePoll-O0sJEw1Y.js +0 -1
package/js/loop/score.mjs
CHANGED
|
@@ -104,23 +104,60 @@ export function parseNumstat(text) {
|
|
|
104
104
|
|
|
105
105
|
const slash = (p) => p.replaceAll('\\', '/');
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
// The one glob dialect of the loop — a template's `contracts` and `ignoreDeletions`, and LOOP.md's
|
|
108
|
+
// own `contracts`. `*` and `?` stay inside a path segment; `**` crosses segments, and `**/` may
|
|
109
|
+
// match no directory at all, so `**/yarn.lock` covers the root one too. Both sides are
|
|
110
|
+
// slash-normalised first, so a backslash is a separator, never an escape. Hand-rolled because
|
|
111
|
+
// this module may import nothing but node builtins (see the docblock above); a plain path is a
|
|
112
|
+
// glob that matches only itself, so exact-path contracts keep working.
|
|
113
|
+
// A leading `./` or `/` is dropped on both sides: every path here is repo-relative.
|
|
114
|
+
// ponytail: runs of `**/` and `*` collapse first, so stacked wildcards cannot compound; what
|
|
115
|
+
// remains is the regex engine's backtracking over several separate `**` (O(n^k) on a near miss).
|
|
116
|
+
// Patterns come from the template author or the user, never from untrusted input — a linear
|
|
117
|
+
// matcher is the upgrade if that ever changes.
|
|
118
|
+
const globPath = (p) => slash(String(p)).replace(/^(?:\.?\/)+/, '');
|
|
119
|
+
|
|
120
|
+
export function globMatch(pattern, file) {
|
|
121
|
+
const g = globPath(pattern).replace(/\*{3,}/g, '**').replace(/(?:\*\*\/){2,}/g, '**/');
|
|
122
|
+
let re = '';
|
|
123
|
+
for (let i = 0; i < g.length; i += 1) {
|
|
124
|
+
const c = g[i];
|
|
125
|
+
if (c === '*' && g[i + 1] === '*') {
|
|
126
|
+
i += 1;
|
|
127
|
+
if (g[i + 1] === '/') { i += 1; re += '(?:.*/)?'; } else re += '.*';
|
|
128
|
+
} else if (c === '*') re += '[^/]*';
|
|
129
|
+
else if (c === '?') re += '[^/]';
|
|
130
|
+
else re += c.replace(/[.+^${}()|[\]]/g, '\\$&');
|
|
131
|
+
}
|
|
132
|
+
return new RegExp(`^${re}$`).test(globPath(file));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const matchesAny = (globs, file) => globs.some((g) => globMatch(g, file));
|
|
136
|
+
|
|
137
|
+
export function actualRisk(declared, files, {
|
|
138
|
+
writeSet = [], contracts = [], ignoreDeletions = [], overrides = {},
|
|
139
|
+
} = {}) {
|
|
108
140
|
const w = { ...WEIGHTS, ...overrides };
|
|
109
141
|
let score = declared;
|
|
110
142
|
const reasons = [];
|
|
143
|
+
// A write-set entry is a glob too (a plain path matches itself): a tool's generated store,
|
|
144
|
+
// ArchUnit's archunit_store/**, cannot be named file by file before the tool writes it. Only an
|
|
145
|
+
// entry with a literal segment globs: a wildcard-only one (`**`, `**/*.java`) would declare the
|
|
146
|
+
// whole repo, so it stays exact-match — matching nothing, the diff fails closed.
|
|
111
147
|
const inSet = new Set(writeSet.map(slash));
|
|
112
|
-
const
|
|
148
|
+
const globs = writeSet.filter((g) => globPath(g).split('/').some((seg) => seg && !/[*?]/.test(seg)));
|
|
149
|
+
const outside = files.filter((f) => !inSet.has(slash(f.file)) && !matchesAny(globs, f.file));
|
|
113
150
|
if (outside.length) {
|
|
114
151
|
score = Math.max(score, 0.8);
|
|
115
152
|
reasons.push(`outside the write set: ${outside.map((f) => slash(f.file)).join(', ')}`);
|
|
116
153
|
}
|
|
117
|
-
|
|
154
|
+
// ignoreDeletions only leaves the deletion count: the write-set check above still saw the file.
|
|
155
|
+
const deleted = files.filter((f) => !matchesAny(ignoreDeletions, f.file)).reduce((n, f) => n + f.deleted, 0);
|
|
118
156
|
if (deleted > DELETION_LINES) {
|
|
119
157
|
score = Math.max(score, w.delete);
|
|
120
158
|
reasons.push(`${deleted} lines deleted`);
|
|
121
159
|
}
|
|
122
|
-
const
|
|
123
|
-
const hit = files.filter((f) => contractSet.has(slash(f.file)));
|
|
160
|
+
const hit = files.filter((f) => matchesAny(contracts, f.file));
|
|
124
161
|
if (hit.length) {
|
|
125
162
|
score = Math.max(score, w.api);
|
|
126
163
|
reasons.push(`contract files: ${hit.map((f) => slash(f.file)).join(', ')}`);
|
|
@@ -129,8 +166,10 @@ export function actualRisk(declared, files, { writeSet = [], contracts = [], ove
|
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
export function decide(score, preset) {
|
|
169
|
+
if (!Object.hasOwn(PRESETS, preset)) {
|
|
170
|
+
throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
|
|
171
|
+
}
|
|
132
172
|
const t = PRESETS[preset];
|
|
133
|
-
if (!t) throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
|
|
134
173
|
if (score <= t[0]) return 'silent';
|
|
135
174
|
if (score <= t[1]) return 'soft';
|
|
136
175
|
return 'blocking';
|
package/js/loop/state.mjs
CHANGED
|
@@ -21,7 +21,23 @@
|
|
|
21
21
|
* action — is excluded from every score and every read-brick porcelain check: it is never part
|
|
22
22
|
* of the work being scored, and including it would make every action's own bookkeeping look
|
|
23
23
|
* like an undeclared file touch.
|
|
24
|
+
*
|
|
25
|
+
* `writeLoopFile` IS THE ONLY WRITER. Every call site that persists LOOP.md — `cli.mjs`'s
|
|
26
|
+
* `withState` and `init`, and `registry.mjs`'s `setLoopPreset` — writes through it rather than
|
|
27
|
+
* through a bare `writeText(file, renderLoopFile(state))`, so a process killed mid-write can
|
|
28
|
+
* never leave a torn, half-written LOOP.md for the next `loop next`/`score`/`record` to choke
|
|
29
|
+
* on. `LOOP_FILE_TMP` sits BESIDE `LOOP_FILE` in the worktree, not inside the gitdir: the gitdir
|
|
30
|
+
* is resolved by `gitDirOf`, which can THROW on a malformed worktree link (X6 in `cli.mjs`), and
|
|
31
|
+
* every one of these three call sites must still be able to rewrite LOOP.md when that resolution
|
|
32
|
+
* fails or was never attempted (`setLoopPreset` has no gitdir concept at all). A temp file beside
|
|
33
|
+
* LOOP.md needs no new failure mode to reason about instead — it is exposed to `git status`, but
|
|
34
|
+
* only for the instant between the write and the rename (ordinarily unobservable; a crash in
|
|
35
|
+
* that window is the one case it lingers), and `LOOP_FILE_TMP` joins `LOOP_FILE` in both
|
|
36
|
+
* `filterPorcelain` and `scoreDiff`'s exclusion below for exactly that case: a lingering
|
|
37
|
+
* `LOOP.md.tmp` is the engine's own bookkeeping too, never scored work.
|
|
24
38
|
*/
|
|
39
|
+
import { renameSync, unlinkSync } from 'node:fs';
|
|
40
|
+
import { writeText } from '../lib/fs.mjs';
|
|
25
41
|
import { declaredRisk, actualRisk, decide, worst, PRESETS, DEFAULT_PRESET } from './score.mjs';
|
|
26
42
|
import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
|
|
27
43
|
|
|
@@ -29,6 +45,7 @@ const BEGIN = '<!-- loop-state:begin -->';
|
|
|
29
45
|
const END = '<!-- loop-state:end -->';
|
|
30
46
|
export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
|
|
31
47
|
export const LOOP_FILE = 'LOOP.md';
|
|
48
|
+
export const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
|
|
32
49
|
const slash = (p) => String(p).replaceAll('\\', '/');
|
|
33
50
|
|
|
34
51
|
export function renderLoopFile(state) {
|
|
@@ -38,6 +55,41 @@ export function renderLoopFile(state) {
|
|
|
38
55
|
+ `${BEGIN}\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\`\n${END}\n`;
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
59
|
+
|
|
60
|
+
/** Synchronous back-off between rename attempts — `Atomics.wait` on a throwaway
|
|
61
|
+
* `SharedArrayBuffer`, the stdlib's own blocking sleep with no dependency. */
|
|
62
|
+
function backoff(ms) {
|
|
63
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rename `tmp` to `file`, retrying up to 3 attempts total on a transient Windows lock
|
|
68
|
+
* (EPERM/EACCES/EBUSY — an AV scanner or editor briefly holding the handle open) with a short
|
|
69
|
+
* back-off between attempts. A non-retryable error, or the 3rd retryable failure, removes the
|
|
70
|
+
* temp file (best-effort — a cleanup failure must not mask the real error) and rethrows.
|
|
71
|
+
* `renameFn` is an injectable seam for tests; production never passes it.
|
|
72
|
+
*/
|
|
73
|
+
export function renameWithRetry(tmp, file, renameFn = renameSync) {
|
|
74
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
75
|
+
try {
|
|
76
|
+
renameFn(tmp, file);
|
|
77
|
+
return;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (attempt < 3 && RETRYABLE_RENAME_CODES.has(e.code)) { backoff(25 * attempt); continue; }
|
|
80
|
+
try { unlinkSync(tmp); } catch { /* best-effort cleanup; the rename error is what matters */ }
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `<file>.tmp` then `renameWithRetry` — the atomic write every LOOP.md persist goes through. */
|
|
87
|
+
export function writeLoopFile(file, state) {
|
|
88
|
+
const tmp = `${file}.tmp`;
|
|
89
|
+
writeText(tmp, renderLoopFile(state));
|
|
90
|
+
renameWithRetry(tmp, file);
|
|
91
|
+
}
|
|
92
|
+
|
|
41
93
|
export function parseLoopFile(text) {
|
|
42
94
|
const t = String(text).replace(/\r\n/g, '\n');
|
|
43
95
|
const a = t.indexOf(BEGIN); const b = t.lastIndexOf(END);
|
|
@@ -49,11 +101,12 @@ export function parseLoopFile(text) {
|
|
|
49
101
|
const head = (state) => iterationLoop(state.graph).nodes[0];
|
|
50
102
|
const fmt = (n) => Number(n).toFixed(1);
|
|
51
103
|
|
|
52
|
-
|
|
53
|
-
|
|
104
|
+
/** `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. */
|
|
105
|
+
export function initState({ title, requirement, graph, preset = DEFAULT_PRESET, contracts = [] }) {
|
|
106
|
+
if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
|
|
54
107
|
const loop = iterationLoop(graph);
|
|
55
108
|
const state = {
|
|
56
|
-
v: 1, title, requirement, preset, contracts: [], graph, node: graph.start,
|
|
109
|
+
v: 1, title, requirement, preset, contracts: [...(graph.contracts ?? []), ...contracts], graph, node: graph.start,
|
|
57
110
|
status: 'running', awaiting: null, reason: null,
|
|
58
111
|
iteration: loop.nodes.includes(graph.start) ? 1 : 0,
|
|
59
112
|
card: null, validated: false, pendingVerify: false, closing: false, counters: {},
|
|
@@ -93,6 +146,7 @@ export function trailers(state) {
|
|
|
93
146
|
`Loop-Threshold: ${state.preset} ${fmt(silent)}/${fmt(soft)}`,
|
|
94
147
|
`Loop-Decision: ${state.current.decision ?? 'silent'}${state.current.amended ? ' (amended)' : ''}`,
|
|
95
148
|
`Loop-Tests: ${state.tests}`,
|
|
149
|
+
...(state.current.gates ? [`Loop-Gates: ${state.current.gates.join(',')}`] : []),
|
|
96
150
|
].join('\n');
|
|
97
151
|
}
|
|
98
152
|
|
|
@@ -102,6 +156,7 @@ function finishUnit(state, committed) {
|
|
|
102
156
|
iteration: state.iteration, bricks: [...new Set(state.visited)],
|
|
103
157
|
declared: state.current.declared ?? 0, actual: state.current.actual ?? state.current.declared ?? 0,
|
|
104
158
|
decision: state.current.decision ?? 'silent', tests: state.tests, intent: state.card?.intent ?? '',
|
|
159
|
+
...(state.current.gates ? { gates: state.current.gates } : {}),
|
|
105
160
|
});
|
|
106
161
|
}
|
|
107
162
|
// The unit is committed (or was empty): the tree is clean again, so the read-brick check
|
|
@@ -139,6 +194,10 @@ export function nextStep(state, bricks) {
|
|
|
139
194
|
node: state.node, iteration: state.iteration, effect: brick.effect, agent: brick.agent,
|
|
140
195
|
skill: brick.skill, outcomes: brick.outcomes, prompt: brick.body, card: state.card,
|
|
141
196
|
notes: state.notes, validate: brick.effect === 'mutate' && !state.validated,
|
|
197
|
+
// The template's own rules (a cited proof before a deletion, no retries as a flake fix) are
|
|
198
|
+
// its `rules` list; every brick gets them, not only the head that reads LOOP.md. Never the
|
|
199
|
+
// description: that is the catalogue blurb, and sending it would dilute the rules.
|
|
200
|
+
...(state.graph.rules?.length ? { rules: state.graph.rules } : {}),
|
|
142
201
|
};
|
|
143
202
|
}
|
|
144
203
|
|
|
@@ -170,9 +229,9 @@ export function scoreDiff(state, files) {
|
|
|
170
229
|
assertRunning(state);
|
|
171
230
|
if (!state.pendingVerify) throw new Error('no iteration to verify: `geneseed loop next` has not asked for one');
|
|
172
231
|
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');
|
|
232
|
+
throw new Error('LOOP.md: contracts must be a list of file paths or globs');
|
|
174
233
|
}
|
|
175
|
-
const scored = files.filter((f) => slash(f.file) !== LOOP_FILE);
|
|
234
|
+
const scored = files.filter((f) => slash(f.file) !== LOOP_FILE && slash(f.file) !== LOOP_FILE_TMP);
|
|
176
235
|
if (!scored.length) {
|
|
177
236
|
// A closing unit (reached $close with a mutate already visited) finishes as done even
|
|
178
237
|
// when this is its second empty diff in a row: there is nothing left to retry into.
|
|
@@ -184,7 +243,8 @@ export function scoreDiff(state, files) {
|
|
|
184
243
|
}
|
|
185
244
|
state.emptyStreak = 0;
|
|
186
245
|
const { score, reasons } = actualRisk(state.current.declared ?? 0, scored, {
|
|
187
|
-
writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [],
|
|
246
|
+
writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [],
|
|
247
|
+
ignoreDeletions: state.graph.ignoreDeletions ?? [], overrides: state.graph.weights,
|
|
188
248
|
});
|
|
189
249
|
const decision = decide(score, state.preset);
|
|
190
250
|
state.current.actual = score;
|
|
@@ -223,12 +283,13 @@ function allowedEntries(graph, v) {
|
|
|
223
283
|
}
|
|
224
284
|
|
|
225
285
|
// 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)
|
|
286
|
+
// LOOP.md rows (the engine rewrites it on every action — it is never part of the scored work)
|
|
287
|
+
// and a lingering LOOP.md.tmp (the atomic writer's own sibling — see `writeLoopFile`'s docblock).
|
|
227
288
|
function filterPorcelain(porcelain) {
|
|
228
289
|
const lines = porcelain.split(/\r?\n/).filter((line) => {
|
|
229
290
|
if (!line) return false;
|
|
230
291
|
const path = line.slice(3).replace(/^"|"$/g, '');
|
|
231
|
-
return slash(path) !== LOOP_FILE;
|
|
292
|
+
return slash(path) !== LOOP_FILE && slash(path) !== LOOP_FILE_TMP;
|
|
232
293
|
});
|
|
233
294
|
return lines.join('\n').trimEnd();
|
|
234
295
|
}
|
|
@@ -239,7 +300,7 @@ function isSetupUnit(state) {
|
|
|
239
300
|
return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
|
|
240
301
|
}
|
|
241
302
|
|
|
242
|
-
export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null } = {}) {
|
|
303
|
+
export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
|
|
243
304
|
assertRunning(state);
|
|
244
305
|
if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
|
|
245
306
|
const from = state.node;
|
|
@@ -258,11 +319,33 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
|
|
|
258
319
|
if (filteredPorcelain !== null) state.snapshot = filteredPorcelain;
|
|
259
320
|
if (card) state.card = card;
|
|
260
321
|
if (from === 'test') state.tests = outcome;
|
|
322
|
+
// A finding a brick needs to survive the unit (the ordered plan `plan` produced, `review`'s
|
|
323
|
+
// `fail` findings) — pushed before the transition below, so it lands in `notes` even when
|
|
324
|
+
// this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
|
|
325
|
+
// (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
|
|
326
|
+
// `resetUnit`/`finishUnit` the same way the research's fact 1 says a card does not.
|
|
327
|
+
if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
|
|
261
328
|
state.visited.push(from);
|
|
262
329
|
|
|
263
330
|
const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome);
|
|
264
331
|
if (!edge) throw new Error(`no edge from ${from} on ${outcome}`);
|
|
265
|
-
if (
|
|
332
|
+
if (brick.gate === 'human' && (!brick.gateOn || brick.gateOn.includes(outcome))) {
|
|
333
|
+
state.status = 'awaiting';
|
|
334
|
+
state.awaiting = { kind: 'gate', node: from, outcome, ...(note ? { note } : {}) };
|
|
335
|
+
return { awaiting: state.awaiting };
|
|
336
|
+
}
|
|
337
|
+
return transition(state, bricks, from, edge);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Follow `edge` out of `from` — the second half of `recordOutcome`, and what `decide ok` runs on
|
|
342
|
+
* a held gate. THE GATE HOLDS THE WHOLE TRANSITION: whatever the edge would do (ask for the diff
|
|
343
|
+
* score, close the unit, stop, count a re-entry and exhaust a ring) happens only here, after the
|
|
344
|
+
* user's `ok` — never before it, so nothing is scored, committed, re-split or stopped by the
|
|
345
|
+
* transition while the gate waits. A held `$stop` edge still waits: the user may `amend` it.
|
|
346
|
+
*/
|
|
347
|
+
function transition(state, bricks, from, edge) {
|
|
348
|
+
if (edge.to === '$stop') return stop(state, `${from} reported ${edge.on}`);
|
|
266
349
|
const mutated = state.visited.some((n) => bricks.get(n)?.effect === 'mutate');
|
|
267
350
|
if (edge.to === '$close') {
|
|
268
351
|
if (mutated) { state.closing = true; state.pendingVerify = true; return { verify: true }; }
|
|
@@ -288,8 +371,10 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
|
|
|
288
371
|
export function decideAwaiting(state, bricks, verdict, note = '') {
|
|
289
372
|
if (state.status !== 'awaiting') throw new Error('nothing is awaiting a decision');
|
|
290
373
|
if (!['ok', 'no', 'amend'].includes(verdict)) throw new Error(`verdict must be ok, no or amend, not ${JSON.stringify(verdict)}`);
|
|
291
|
-
const
|
|
374
|
+
const held = state.awaiting;
|
|
375
|
+
const { kind } = held;
|
|
292
376
|
state.awaiting = null; state.status = 'running';
|
|
377
|
+
if (kind === 'gate') return decideGate(state, bricks, verdict, note, held);
|
|
293
378
|
if (note) state.notes.push(`iteration ${state.iteration} (${kind}, ${verdict}): ${note}`);
|
|
294
379
|
if (kind === 'launch') {
|
|
295
380
|
return verdict === 'ok' ? { resumed: true } : stop(state, `graph ${verdict === 'no' ? 'rejected' : 'to amend'}${note ? `: ${note}` : ''}`);
|
|
@@ -320,3 +405,32 @@ export function decideAwaiting(state, bricks, verdict, note = '') {
|
|
|
320
405
|
resetUnit(state); state.node = head(state); state.snapshot = '';
|
|
321
406
|
return { discard: true };
|
|
322
407
|
}
|
|
408
|
+
|
|
409
|
+
const GATE_MAX_AMENDS = 3;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The answer to a held `gate: human` transition. Every verdict leaves a note — with or without
|
|
413
|
+
* the user's words — so the run's notes say each gate was answered and how. `ok` follows the held
|
|
414
|
+
* edge and lists the node under the unit's `Loop-Gates`; `no` stops; `amend` re-runs the same
|
|
415
|
+
* node with the note in `notes` (its `validated` untouched — a mutate gate need not re-score —
|
|
416
|
+
* and its `counters` reset),
|
|
417
|
+
* `GATE_MAX_AMENDS` times per node per iteration (`gateAmends` is keyed `node@iteration` and never
|
|
418
|
+
* reset, so the next iteration's card — a new ADR, say — gets its own budget), and the next
|
|
419
|
+
* one stops: three rounds of review that did not converge are a conversation, not a loop.
|
|
420
|
+
*/
|
|
421
|
+
function decideGate(state, bricks, verdict, note, { node, outcome }) {
|
|
422
|
+
state.notes.push(`iteration ${state.iteration} (gate at ${node}, ${verdict})${note ? `: ${note}` : ''}`);
|
|
423
|
+
if (verdict === 'no') return stop(state, `gate rejected at ${node}${note ? `: ${note}` : ''}`);
|
|
424
|
+
if (verdict === 'amend') {
|
|
425
|
+
const key = `${node}@${state.iteration}`;
|
|
426
|
+
state.gateAmends = { ...state.gateAmends, [key]: (state.gateAmends?.[key] ?? 0) + 1 };
|
|
427
|
+
if (state.gateAmends[key] > GATE_MAX_AMENDS) return stop(state, `gate amended ${GATE_MAX_AMENDS} times at ${node}`);
|
|
428
|
+
// As an `actual` amend: the amendment changes what the unit does, so the ring budget
|
|
429
|
+
// restarts; the amend cap above still bounds the unit.
|
|
430
|
+
state.counters = {};
|
|
431
|
+
state.node = node;
|
|
432
|
+
return { resumed: true };
|
|
433
|
+
}
|
|
434
|
+
state.current.gates = [...new Set([...(state.current.gates ?? []), node])]; // once each, as Loop-Bricks
|
|
435
|
+
return transition(state, bricks, node, state.graph.edges.find((e) => e.from === node && e.on === outcome));
|
|
436
|
+
}
|
package/js/maintain/setup.mjs
CHANGED
|
@@ -519,7 +519,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
|
|
|
519
519
|
lines.push(['ok', `${f.hookGroups} hook groups wired in ${f.settings}`]);
|
|
520
520
|
if (!process.env.GENESEED_LLM) {
|
|
521
521
|
lines.push(['info', 'memory learning is off until you set GENESEED_LLM '
|
|
522
|
-
+ '(see docs/reference/
|
|
522
|
+
+ '(see docs/reference/env-context.md)']);
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
525
|
if (emit === 'files') {
|
package/js/web/actions.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
|
40
40
|
import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
|
|
41
41
|
import { setupBuildArgs } from '../build/generate.mjs';
|
|
42
42
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
43
|
+
import { setLoopPreset } from '../loop/registry.mjs';
|
|
43
44
|
import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
|
|
44
45
|
import {
|
|
45
46
|
HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
|
|
@@ -577,6 +578,30 @@ export function apiExcludesMutate(state, body) {
|
|
|
577
578
|
return action === 'add' ? excludeAdd(p) : excludeRemove(p);
|
|
578
579
|
}
|
|
579
580
|
|
|
581
|
+
// ---- loop preset picker ---------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The Active tab's one write: rewrite a registered worktree's `LOOP.md` `preset` field.
|
|
585
|
+
* `setLoopPreset` throws for both refusals the brief requires `POST /api/loops/preset` to make —
|
|
586
|
+
* a `root` the registry does not know (no arbitrary path writes) and a `preset` outside
|
|
587
|
+
* `PRESETS` — and this is where those two throws are turned into the `NotFound` convention
|
|
588
|
+
* every other mutating route here already answers in, so the handler's catch (`4xx`, not the
|
|
589
|
+
* 500 a bare `Error` would fall to) covers this route the same way it covers `apiRulesMutate`'s
|
|
590
|
+
* unknown id. Anything else `setLoopPreset` throws — a corrupt `LOOP.md` that fails to
|
|
591
|
+
* parse, say — is not one of those two refusals and is rethrown as-is, so it falls through to
|
|
592
|
+
* the handler's 500 instead of being misreported as "not found".
|
|
593
|
+
*/
|
|
594
|
+
export function apiLoopsPresetMutate(state, body) {
|
|
595
|
+
const root = strOr(bget(body, 'root'));
|
|
596
|
+
const preset = strOr(bget(body, 'preset'));
|
|
597
|
+
try {
|
|
598
|
+
return { ok: true, loop: setLoopPreset(root, preset) };
|
|
599
|
+
} catch (e) {
|
|
600
|
+
if (e.message === 'unknown loop' || e.message.startsWith('unknown preset ')) throw new NotFound(e.message);
|
|
601
|
+
throw e;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
580
605
|
// ---- MCP ----------------------------------------------------------------------------------
|
|
581
606
|
|
|
582
607
|
export function apiMcp() {
|
package/js/web/api.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { frontmatter } from '../hosts/hooks.mjs';
|
|
28
28
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
29
29
|
import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
|
|
30
|
+
import { activeLoops } from '../loop/registry.mjs';
|
|
30
31
|
import {
|
|
31
32
|
SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
|
|
32
33
|
} from '../inspect/inventory.mjs';
|
|
@@ -968,6 +969,17 @@ export function apiLoops(state) {
|
|
|
968
969
|
};
|
|
969
970
|
}
|
|
970
971
|
|
|
972
|
+
/**
|
|
973
|
+
* The Active tab's read: every worktree `geneseed loop init` has registered, with its live
|
|
974
|
+
* `LOOP.md` state re-read on every call (`activeLoops` itself, not a cached copy — the registry
|
|
975
|
+
* stores identity only, see `js/loop/registry.mjs`). Takes no `state`-derived filtering: the
|
|
976
|
+
* registry is machine-wide, not scoped to this install's root, so a loop launched from a
|
|
977
|
+
* different project still shows up here.
|
|
978
|
+
*/
|
|
979
|
+
export function apiLoopsActive() {
|
|
980
|
+
return { loops: activeLoops() };
|
|
981
|
+
}
|
|
982
|
+
|
|
971
983
|
/** The source's release label, or null when it cannot be read. */
|
|
972
984
|
function releaseLabel() {
|
|
973
985
|
const v = sourceReleaseVersion({ config: CONFIG });
|
|
@@ -1018,4 +1030,5 @@ export const STATE_ROUTES = {
|
|
|
1018
1030
|
'/api/rules': apiRules,
|
|
1019
1031
|
'/api/mcp': apiMcp,
|
|
1020
1032
|
'/api/loops': apiLoops,
|
|
1033
|
+
'/api/loops/active': apiLoopsActive,
|
|
1021
1034
|
};
|
package/js/web/docs.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* THE `?harness=` QUERY PARAM IS the Docs selector, and it is the ONLY input to these
|
|
15
15
|
* endpoints that is not the checkout itself — the one thing a test can vary. Every
|
|
16
|
-
* filtering rule below is exercised by sending
|
|
16
|
+
* filtering rule below is exercised by sending each host over one page.
|
|
17
17
|
*/
|
|
18
18
|
import { readdirSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
@@ -105,7 +105,7 @@ export function docGroups() {
|
|
|
105
105
|
if (group === undefined) continue;
|
|
106
106
|
const order = Object.hasOwn(meta, 'order') ? meta.order : 0;
|
|
107
107
|
delete meta.order;
|
|
108
|
-
const page = { id, rel, ...meta };
|
|
108
|
+
const page = { id, rel, ...meta, section: sectionOf(meta) };
|
|
109
109
|
if (body.trim()) page.body = body.replace(/\n+$/, '');
|
|
110
110
|
group.pages.push([order, page]);
|
|
111
111
|
}
|
|
@@ -113,15 +113,44 @@ export function docGroups() {
|
|
|
113
113
|
for (const g of groups) {
|
|
114
114
|
const entry = byId.get(g.id);
|
|
115
115
|
// STABLE sort: equal orders keep filename order.
|
|
116
|
-
entry.pages = entry.pages
|
|
116
|
+
entry.pages = bySection(entry.pages
|
|
117
117
|
.map((pair, i) => [pair[0], i, pair[1]])
|
|
118
118
|
.sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]))
|
|
119
|
-
.map((t) => t[2]);
|
|
119
|
+
.map((t) => t[2]));
|
|
120
120
|
out.push(entry);
|
|
121
121
|
}
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** The heading a page without a `section:` is listed under, inside its part. */
|
|
126
|
+
export const GENERAL_SECTION = 'General';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A page's `section:` frontmatter, or `General`. Anything but a non-empty string falls back
|
|
130
|
+
* rather than throwing — `tests/unit/docs_tree.test.mjs` is where a bad value goes red.
|
|
131
|
+
*/
|
|
132
|
+
export function sectionOf(meta) {
|
|
133
|
+
const s = meta.section;
|
|
134
|
+
return typeof s === 'string' && s.trim() ? s.trim() : GENERAL_SECTION;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Pages already in `order:` order, regrouped so each section's pages sit together.
|
|
139
|
+
*
|
|
140
|
+
* SECTION ORDER IS THE SMALLEST `order:` AMONG ITS PAGES — which, on a list already sorted by
|
|
141
|
+
* order, is simply the order in which sections first appear. Within a section the pages keep
|
|
142
|
+
* their `order:` order. One rule for the list, the breadcrumb and prev/next alike, so the
|
|
143
|
+
* console never shows two orders for one part.
|
|
144
|
+
*/
|
|
145
|
+
export function bySection(pages) {
|
|
146
|
+
const runs = new Map();
|
|
147
|
+
for (const p of pages) {
|
|
148
|
+
if (!runs.has(p.section)) runs.set(p.section, []);
|
|
149
|
+
runs.get(p.section).push(p);
|
|
150
|
+
}
|
|
151
|
+
return [...runs.values()].flat();
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
// ---- markdown tables as data ------------------------------------------------------------
|
|
126
155
|
|
|
127
156
|
/** One table row's cells; a `\|` inside a cell is kept as a literal bar. */
|
|
@@ -259,8 +288,27 @@ function subCounts(state, body) {
|
|
|
259
288
|
|
|
260
289
|
// ---- harness filtering -----------------------------------------------------------------
|
|
261
290
|
|
|
262
|
-
|
|
263
|
-
|
|
291
|
+
/**
|
|
292
|
+
* The Docs selector names a HOST; a tag names a host or a FAMILY. Bob and OpenClaude emit
|
|
293
|
+
* through the Claude engine, so a `claude` tag (page or block) is the Claude family and shows
|
|
294
|
+
* to all three of them, while `bob` / `openclaude` narrow to the one host. The same mapping as
|
|
295
|
+
* the console's host table (`web/src/lib/hosts.js`, its `docs` column).
|
|
296
|
+
*/
|
|
297
|
+
export const HOST_FAMILY = { opencode: 'opencode', claude: 'claude', openclaude: 'claude', bob: 'claude' };
|
|
298
|
+
export const HARNESSES = Object.keys(HOST_FAMILY);
|
|
299
|
+
/**
|
|
300
|
+
* A page or block tagged `tag` shows to `host`. Untagged content shows to every host. One tag
|
|
301
|
+
* names a host or a family; a LIST names hosts exactly, no family widening, because picking
|
|
302
|
+
* some hosts of a family is the only thing a list says that one tag cannot:
|
|
303
|
+
* `["claude", "openclaude"]` is Claude Code and OpenClaude, not Bob.
|
|
304
|
+
*/
|
|
305
|
+
export const harnessShows = (tag, host) => (Array.isArray(tag)
|
|
306
|
+
? tag.includes(host)
|
|
307
|
+
: !tag || tag === host || tag === HOST_FAMILY[host]);
|
|
308
|
+
/** A block's tag: `claude` alone, or `claude,openclaude` — the list, read as above. */
|
|
309
|
+
const harnessTag = (raw) => (raw.includes(',') ? raw.split(/\s*,\s*/) : raw);
|
|
310
|
+
const HOST_ID = '(?:opencode|claude|openclaude|bob)';
|
|
311
|
+
const HARNESS_OPEN_RE = new RegExp(String.raw`^\s*<!--\s*harness:(${HOST_ID}(?:\s*,\s*${HOST_ID})*)\s*-->\s*$`);
|
|
264
312
|
const HARNESS_CLOSE_RE = /^\s*<!--\s*\/harness\s*-->\s*$/;
|
|
265
313
|
/**
|
|
266
314
|
* The cheap presence test for the early-out. It must never be NARROWER than the open
|
|
@@ -272,7 +320,9 @@ const HARNESS_HINT_RE = /<!--\s*harness:/;
|
|
|
272
320
|
export function normHarness(value, state) {
|
|
273
321
|
const v = (value || '').trim().toLowerCase();
|
|
274
322
|
if (HARNESSES.includes(v)) return v;
|
|
275
|
-
|
|
323
|
+
// Emit names start with their host id (`claude-global`, `bob`, …); `files` reads OpenCode's.
|
|
324
|
+
const emit = String(state.emit || '');
|
|
325
|
+
return HARNESSES.find((h) => emit.startsWith(h)) || 'opencode';
|
|
276
326
|
}
|
|
277
327
|
|
|
278
328
|
/**
|
|
@@ -309,7 +359,8 @@ export function harnessBlocksBalanced(lines) {
|
|
|
309
359
|
* the marker; the console has already filtered the block to the reader's host, so there the
|
|
310
360
|
* label only repeats what the host selector says, and it goes with the marker.
|
|
311
361
|
*/
|
|
312
|
-
const
|
|
362
|
+
const HOST_NAME = '(?:OpenCode|Claude Code|OpenClaude|IBM Bob)';
|
|
363
|
+
const HOST_LABEL_RE = new RegExp(String.raw`^\s*\*\(${HOST_NAME}(?: and ${HOST_NAME})* only\)\*\s*$`);
|
|
313
364
|
|
|
314
365
|
export function stripHarnessBlocks(body, harnessName) {
|
|
315
366
|
if (!HARNESS_HINT_RE.test(body)) return body;
|
|
@@ -329,7 +380,7 @@ export function stripHarnessBlocks(body, harnessName) {
|
|
|
329
380
|
}
|
|
330
381
|
if (!inFence) {
|
|
331
382
|
const m = HARNESS_OPEN_RE.exec(line);
|
|
332
|
-
if (m) { keep = m[1]
|
|
383
|
+
if (m) { keep = harnessShows(harnessTag(m[1]), harnessName); afterOpen = true; continue; }
|
|
333
384
|
if (HARNESS_CLOSE_RE.test(line)) { keep = true; continue; }
|
|
334
385
|
if (labelSlot && HOST_LABEL_RE.test(line)) continue;
|
|
335
386
|
}
|
|
@@ -353,8 +404,8 @@ function splitLines(s) {
|
|
|
353
404
|
function visibleGroups(harnessName) {
|
|
354
405
|
const groups = [];
|
|
355
406
|
for (const g of docGroups()) {
|
|
356
|
-
if (g.harness
|
|
357
|
-
const pages = g.pages.filter((p) =>
|
|
407
|
+
if (!harnessShows(g.harness, harnessName)) continue;
|
|
408
|
+
const pages = g.pages.filter((p) => harnessShows(p.harness, harnessName));
|
|
358
409
|
if (pages.length) groups.push({ ...g, pages });
|
|
359
410
|
}
|
|
360
411
|
return groups;
|
|
@@ -510,7 +561,8 @@ export function apiDocs(state, harnessName = null) {
|
|
|
510
561
|
const groups = visibleGroups(hn).map((g) => ({
|
|
511
562
|
id: g.id,
|
|
512
563
|
label: g.label,
|
|
513
|
-
pages: g.pages.map((p) => ({ id: p.id, title: p.title, kind: p.kind
|
|
564
|
+
pages: g.pages.map((p) => ({ id: p.id, title: p.title, kind: p.kind, section: p.section,
|
|
565
|
+
...(typeof p.description === 'string' ? { description: p.description } : {}) })),
|
|
514
566
|
}));
|
|
515
567
|
return { groups, harness: hn };
|
|
516
568
|
}
|
package/js/web/routes.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* expose X?", which has nothing to do with sockets, daemons or gzip.
|
|
16
16
|
*/
|
|
17
17
|
import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
|
|
18
|
-
import { apiExcludesMutate, apiInstallToggle, apiMcpToggle, apiMemoryDelete, apiProfileSave, apiRulesMutate, apiRulesPromote, apiSelectView } from './actions.mjs';
|
|
18
|
+
import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcpToggle, apiMemoryDelete, apiProfileSave, apiRulesMutate, apiRulesPromote, apiSelectView } from './actions.mjs';
|
|
19
19
|
import { apiActivityToggle } from './activity.mjs';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -45,6 +45,10 @@ export const POST_ROUTES = new Map([
|
|
|
45
45
|
['/api/rules', [apiRulesMutate, true]],
|
|
46
46
|
['/api/rules/promote', [apiRulesPromote, true]],
|
|
47
47
|
['/api/profile', [apiProfileSave, true]],
|
|
48
|
+
// No `ok: false` arm: success returns `{ok: true, loop}`, and both refusals (unknown root,
|
|
49
|
+
// unknown preset) throw `NotFound`, which `doPost`'s catch answers 404 before this column is
|
|
50
|
+
// ever consulted — the same shape as `/api/memory/delete` and `/api/activity` above.
|
|
51
|
+
['/api/loops/preset', [apiLoopsPresetMutate, false]],
|
|
48
52
|
]);
|
|
49
53
|
|
|
50
54
|
/**
|
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.
|
|
4
|
+
"version": "3.13.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",
|
package/src/agents/explorer.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
> {{DESC_EXPLORER}}
|
|
4
4
|
|
|
5
5
|
## When to dispatch
|
|
6
|
-
- A question needs sweeping many files or directories, but
|
|
7
|
-
|
|
6
|
+
- A question needs sweeping many files or directories, but only the conclusion
|
|
7
|
+
belongs in the main context — not the file contents.
|
|
8
8
|
- Locating where something lives, how a subsystem fits together, or gathering
|
|
9
9
|
facts scattered across the repo.
|
|
10
10
|
- Your main context is small and the expensive reading should happen elsewhere.
|
package/src/agents/researcher.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
> {{DESC_RESEARCHER}}
|
|
4
4
|
|
|
5
5
|
## When to dispatch
|
|
6
|
-
- A question needs answers from *outside* the repository
|
|
7
|
-
vendor's docs, an upgrade guide, a specification
|
|
8
|
-
findings, not the pages
|
|
6
|
+
- A question needs verified answers from *outside* the repository. The open web, a
|
|
7
|
+
vendor's docs, an upgrade guide, a specification: the main context gets the verified
|
|
8
|
+
findings, not the pages ({{DOCTRINE:context-economy}}).
|
|
9
9
|
- The research {{SKILL}} is running and the host can run subagents: this seat
|
|
10
10
|
does the fetching and cross-checking so the main context stays small.
|
|
11
11
|
- A migrate, forge-mcp, or ingest run needs a spec or changelog read before
|
package/src/bricks/README.md
CHANGED
|
@@ -29,4 +29,6 @@ override rather than hiding it.
|
|
|
29
29
|
Start from `_template.md`, or ask the `brick-forge` skill to draft one for an agent or skill you
|
|
30
30
|
already have. Keep the body to 4-10 lines of direct instruction, and end it with how the model
|
|
31
31
|
should choose among the outcomes you declared — that sentence is what turns a vague brief into a
|
|
32
|
-
loop that actually terminates.
|
|
32
|
+
loop that actually terminates. If the brick finds something the next brick needs, write it to a
|
|
33
|
+
file in the OS temp directory and report that file's path with the outcome — the loop skill
|
|
34
|
+
records it as a note.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adr-challenge
|
|
3
|
+
description: Attack the ADR this iteration drafted; the user reviews the ADR that survives.
|
|
4
|
+
effect: read
|
|
5
|
+
agent: skeptic
|
|
6
|
+
gate: human
|
|
7
|
+
gateOn: pass
|
|
8
|
+
outcomes: pass, fail
|
|
9
|
+
---
|
|
10
|
+
Read the ADR this iteration wrote as a skeptic reading it cold. Is a decision driver missing? Is
|
|
11
|
+
a rejected option strawmanned? Is a consequence — cost, risk, the way back — left unstated? Does
|
|
12
|
+
it contradict an accepted ADR in the same folder, or the code as it stands? A gate amendment in
|
|
13
|
+
LOOP.md's notes for this ADR is a finding: report `fail` until the ADR answers it.
|
|
14
|
+
|
|
15
|
+
Never edit any file — findings go back to `adr-draft`, which revises the ADR.
|
|
16
|
+
|
|
17
|
+
Report `fail` only on findings that would change the decision or its consequences, never on
|
|
18
|
+
wording or style: write each one to a file in the OS temp directory and report that file's path
|
|
19
|
+
with the outcome. Report `pass` otherwise — the user then reviews the ADR before the loop
|
|
20
|
+
continues: write its path and the decision in one line to a temp file and report that path.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adr-draft
|
|
3
|
+
description: Write or revise one architecture decision record for the card's decision.
|
|
4
|
+
effect: mutate
|
|
5
|
+
agent: architect
|
|
6
|
+
outcomes: pass
|
|
7
|
+
---
|
|
8
|
+
Write or revise the ADR file the card's write set names, in Nygard form: Status: Proposed,
|
|
9
|
+
Context, Decision, Consequences — the costs as well as the gains — and at least two options
|
|
10
|
+
considered, each with why it lost. Touch only that file; never change code.
|
|
11
|
+
|
|
12
|
+
If LOOP.md's notes carry `adr-challenge` findings or a gate amendment for this ADR, answer each
|
|
13
|
+
one: change the decision, or record it under Consequences.
|
|
14
|
+
|
|
15
|
+
Report `pass` once the ADR is written.
|