geneseed 3.12.0 → 3.14.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 +160 -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/adapters/opencode/plugins/geneseed-learn.js +10 -1
- package/bin/geneseed-cli.mjs +10 -14
- package/bin/geneseed-hook.mjs +22 -15
- 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 +28 -13
- 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 +30 -13
- package/js/build/args.mjs +374 -0
- package/js/build/driver.mjs +137 -553
- package/js/build/emit-claude.mjs +2 -4
- package/js/build/generate.mjs +6 -3
- package/js/build/render.mjs +5 -9
- package/js/build/source.mjs +3 -11
- package/js/cli-table.json +27 -1
- package/js/hosts/hooks-context.mjs +431 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +12 -4
- package/js/hosts/installs.mjs +5 -6
- package/js/hosts/mcp.mjs +13 -49
- package/js/hosts/memory-files.mjs +181 -0
- package/js/hosts/native.mjs +43 -5
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +373 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +32 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +1 -1
- package/js/inspect/validate.mjs +48 -35
- package/js/loop/catalog.mjs +17 -4
- package/js/loop/cli.mjs +52 -16
- package/js/loop/graph.mjs +19 -1
- package/js/loop/registry.mjs +191 -0
- package/js/loop/score.mjs +46 -7
- package/js/loop/state.mjs +157 -19
- package/js/maintain/memory.mjs +1 -1
- package/js/maintain/migrate.mjs +18 -30
- package/js/maintain/setup.mjs +1 -1
- package/js/maintain/uninstall.mjs +47 -88
- package/js/ui/cli.mjs +12 -12
- package/js/web/actions.mjs +39 -4
- package/js/web/api.mjs +34 -21
- package/js/web/docs.mjs +87 -78
- 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-CjRqZnr-.js +1 -0
- package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
- package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
- package/web/dist/assets/{GreenhouseView-C6ibc8IE.js → GreenhouseView-6KDjX8da.js} +1 -1
- package/web/dist/assets/{Installs-DRYjYnxs.js → Installs-MWuhNjSm.js} +2 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
- package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
- package/web/dist/assets/Library-DOkO0TtB.js +1 -0
- package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
- package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-X2a3qvOh.js} +14 -14
- package/web/dist/assets/{OperatorHudView-Bs7paWmh.js → OperatorHudView-BPP1dtLz.js} +1 -1
- package/web/dist/assets/{Personal-BY5n4gTw.js → Personal-26VPYkK0.js} +1 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
- package/web/dist/assets/{usePoll-O0sJEw1Y.js → activity-Dr7ZMBI3.js} +1 -1
- package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-KHt3wE_K.js +2 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +3 -3
- package/web/src/pages/Laws.jsx +0 -1
- 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/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/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/js/loop/cli.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
|
|
7
7
|
* `git diff --cached --numstat` into `score --diff` and `git status --porcelain` into `record`.
|
|
8
|
-
* stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang
|
|
8
|
+
* stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang —
|
|
9
|
+
* and a TTY reads as `null`, "no porcelain given", never as `''`: an empty pipe is a CLEAN
|
|
10
|
+
* tree, and a clean tree is exactly the snapshot a read brick's check must compare against.
|
|
9
11
|
*
|
|
10
12
|
* NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
|
|
11
13
|
* out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
|
|
@@ -22,11 +24,12 @@ import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
|
|
|
22
24
|
import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
|
|
23
25
|
import {
|
|
24
26
|
initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
|
|
25
|
-
|
|
27
|
+
writeLoopFile, parseLoopFile, LOOP_FILE,
|
|
26
28
|
} from './state.mjs';
|
|
29
|
+
import { recordLoop } from './registry.mjs';
|
|
27
30
|
|
|
28
31
|
const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
|
|
29
|
-
const stdin = () => (process.stdin.isTTY ?
|
|
32
|
+
const stdin = () => (process.stdin.isTTY ? null : readFileSync(0, 'utf8'));
|
|
30
33
|
const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
|
|
31
34
|
|
|
32
35
|
function requireRoot() {
|
|
@@ -57,14 +60,17 @@ function writeCommitMessage(root, gitdir, result) {
|
|
|
57
60
|
return { ...result, message_file: file.replaceAll('\\', '/') };
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
63
|
+
/** `write: false` for a read-only action (`next`): rewriting LOOP.md there would change
|
|
64
|
+
* nothing, and would race the Active tab's preset picker (`registry.mjs`'s `setLoopPreset`) —
|
|
65
|
+
* a preset it saved between this read and the rewrite would be silently undone. */
|
|
66
|
+
function withState(fn, { write = true } = {}) {
|
|
61
67
|
const root = requireRoot();
|
|
62
68
|
const file = path.join(root, LOOP_FILE);
|
|
63
69
|
if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
|
|
64
70
|
const state = parseLoopFile(readText(file));
|
|
65
71
|
const { bricks } = loadCatalog({ projectRoot: root });
|
|
66
72
|
const result = fn(state, bricks);
|
|
67
|
-
|
|
73
|
+
if (write) writeLoopFile(file, state);
|
|
68
74
|
return result;
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -78,26 +84,30 @@ function resolveGraph(spec, catalog) {
|
|
|
78
84
|
|
|
79
85
|
const ACTIONS = {
|
|
80
86
|
check(args) {
|
|
87
|
+
// The project level of the catalogue is `<git root>/.geneseed/`, as for `init` and `next`,
|
|
88
|
+
// so a check run from a subdirectory sees the same bricks and templates they will.
|
|
89
|
+
const root = gitRootOf(process.cwd()) ?? process.cwd();
|
|
81
90
|
if (args.brick) {
|
|
82
91
|
const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
|
|
83
92
|
return { ok: !problems.length, problems };
|
|
84
93
|
}
|
|
85
94
|
if (args.graph) {
|
|
86
|
-
const catalog = loadCatalog({ projectRoot:
|
|
95
|
+
const catalog = loadCatalog({ projectRoot: root });
|
|
87
96
|
const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
|
|
88
97
|
return { ok: !problems.length, problems };
|
|
89
98
|
}
|
|
90
99
|
// The bare check doubles as the catalogue LISTING the loop skill composes a free graph
|
|
91
100
|
// from — frontmatter only: a brick's body reaches the model through `next`, for the node
|
|
92
101
|
// the engine chose, never as a menu.
|
|
93
|
-
const
|
|
94
|
-
const
|
|
102
|
+
const catalog = loadCatalog({ projectRoot: root });
|
|
103
|
+
const problems = catalogProblems({ projectRoot: root }, catalog);
|
|
104
|
+
const { bricks, templates, overridden } = catalog;
|
|
95
105
|
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
96
106
|
return {
|
|
97
107
|
ok: !problems.length, problems,
|
|
98
108
|
templates: [...templates.keys()].sort(byName),
|
|
99
109
|
bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
|
|
100
|
-
.map(({ body,
|
|
110
|
+
.map(({ body, reason, ...b }) => ({ ...b, ...(reason ? { reason } : {}) })),
|
|
101
111
|
overridden,
|
|
102
112
|
};
|
|
103
113
|
},
|
|
@@ -110,18 +120,24 @@ const ACTIONS = {
|
|
|
110
120
|
const graph = resolveGraph(args.graph, catalog);
|
|
111
121
|
const problems = checkGraph(graph, catalog.bricks);
|
|
112
122
|
if (problems.length) return { error: 'the graph does not pass loop check', problems };
|
|
113
|
-
const state = initState({
|
|
114
|
-
|
|
123
|
+
const state = initState({
|
|
124
|
+
title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET,
|
|
125
|
+
contracts: list(args.contracts) ?? [],
|
|
126
|
+
});
|
|
127
|
+
writeLoopFile(file, state);
|
|
128
|
+
// Best-effort: the registry is how the Active tab discovers this loop, but a registry
|
|
129
|
+
// hiccup must never fail the init the loop itself just succeeded at.
|
|
130
|
+
recordLoop({ root, branch: currentBranch(root), title: args.title });
|
|
115
131
|
return { file, graph: graph.name, preset: state.preset, status: state.status };
|
|
116
132
|
},
|
|
117
|
-
next: () => withState((s, b) => nextStep(s, b)),
|
|
133
|
+
next: () => withState((s, b) => nextStep(s, b), { write: false }),
|
|
118
134
|
score(args) {
|
|
119
135
|
if (args.diff) {
|
|
120
136
|
// X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
|
|
121
137
|
// malformed `.git` file errors without advancing the loop.
|
|
122
138
|
const root = requireRoot();
|
|
123
139
|
const gitdir = gitDirOf(root);
|
|
124
|
-
return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
|
|
140
|
+
return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin() ?? ''))));
|
|
125
141
|
}
|
|
126
142
|
if (!args.declared) throw new Error('score needs --declared or --diff');
|
|
127
143
|
return withState((s) => scoreDeclared(s, {
|
|
@@ -130,9 +146,29 @@ const ACTIONS = {
|
|
|
130
146
|
},
|
|
131
147
|
record(args) {
|
|
132
148
|
if (!args.outcome) throw new Error('record needs --outcome');
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
if (args.note && args.noteFile) throw new Error('record takes --note or --note-file, not both');
|
|
150
|
+
let note = args.note ?? '';
|
|
151
|
+
if (args.noteFile) {
|
|
152
|
+
// A note too long for a shell argument, or one that mentions `git … commit`/`push` (and
|
|
153
|
+
// would otherwise sit inside the command line GIT_GATE_RE matches whole), goes through a
|
|
154
|
+
// file instead — never through the shell at all. CRLF-folded and trimmed the same way the
|
|
155
|
+
// porcelain stdin below is, so a Windows-authored file round-trips identically.
|
|
156
|
+
let raw;
|
|
157
|
+
try { raw = readText(path.resolve(args.noteFile)); } catch (e) {
|
|
158
|
+
throw new Error(`cannot read --note-file ${args.noteFile}: ${e.message}`, { cause: e });
|
|
159
|
+
}
|
|
160
|
+
note = raw.replace(/\r\n/g, '\n').trimEnd();
|
|
161
|
+
}
|
|
162
|
+
let card = null;
|
|
163
|
+
if (args.card) {
|
|
164
|
+
try { card = JSON.parse(args.card); } catch (e) {
|
|
165
|
+
throw new Error(`--card is not valid JSON: ${e.message}`, { cause: e });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const porcelain = stdin();
|
|
169
|
+
return withState((s, b) => recordOutcome(s, b, args.outcome, {
|
|
170
|
+
card, porcelain: porcelain === null ? null : porcelain.replace(/\r\n/g, '\n').trimEnd(), note,
|
|
171
|
+
}));
|
|
136
172
|
},
|
|
137
173
|
decide(args) {
|
|
138
174
|
// X6: same ordering as `score --diff` above.
|
package/js/loop/graph.mjs
CHANGED
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { WEIGHTS } from './score.mjs';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
const TERMINALS = new Set(['$close', '$stop']);
|
|
25
25
|
export const ENGINE_MAX_ITERATIONS = 20;
|
|
26
|
+
/** A template's optional `category`, in the order the console's Loops list shows them. */
|
|
27
|
+
export const CATEGORIES = ['architecture', 'tests', 'development', 'refactoring', 'day-to-day'];
|
|
26
28
|
|
|
27
29
|
export function iterationLoop(graph) {
|
|
28
30
|
return (graph.loops ?? []).find((l) => l.iteration === true);
|
|
@@ -141,5 +143,21 @@ export function checkGraph(graph, bricks) {
|
|
|
141
143
|
for (const k of Object.keys(graph.weights ?? {})) {
|
|
142
144
|
if (!Object.hasOwn(WEIGHTS, k)) problems.push(`weights: ${k} is not an action`);
|
|
143
145
|
}
|
|
146
|
+
// optional glob lists (score.mjs's `globMatch` reads them)
|
|
147
|
+
for (const k of ['contracts', 'ignoreDeletions']) {
|
|
148
|
+
const v = graph[k];
|
|
149
|
+
if (v !== undefined && !(Array.isArray(v) && v.every((g) => typeof g === 'string' && g.trim()))) {
|
|
150
|
+
problems.push(`${k}: must be a list of non-empty glob strings`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// optional instruction list every brick receives through `loop next`
|
|
154
|
+
const rules = graph.rules;
|
|
155
|
+
if (rules !== undefined && !(Array.isArray(rules) && rules.every((r) => typeof r === 'string' && r.trim()))) {
|
|
156
|
+
problems.push('rules: must be a list of non-empty strings');
|
|
157
|
+
}
|
|
158
|
+
// optional catalogue shelf: the console groups templates by it, an absent one under OTHER
|
|
159
|
+
if (graph.category !== undefined && !CATEGORIES.includes(graph.category)) {
|
|
160
|
+
problems.push(`category: must be one of ${CATEGORIES.join(', ')}`);
|
|
161
|
+
}
|
|
144
162
|
return problems;
|
|
145
163
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop registry — `<userLoopsDir()>/loops.json` — the list of worktrees `geneseed loop init`
|
|
3
|
+
* has launched a loop in. `LOOP.md` stays the only source of loop STATE; this file stores
|
|
4
|
+
* identity only (root, branch, title, started, finishedAt), so the Active tab can discover a
|
|
5
|
+
* loop without scanning every worktree on the machine.
|
|
6
|
+
*
|
|
7
|
+
* WRITER CONVENTIONS, mirrored from `js/inspect/registry.mjs` (`installs.json`'s own registry)
|
|
8
|
+
* on purpose rather than invented fresh: `jsonDumpsIndent` because these rows are absolute
|
|
9
|
+
* paths and a username carrying an accent writes one here (ensure_ascii matters); a
|
|
10
|
+
* `<file>.tmp` + `renameWithRetry` (LOOP.md's own atomic rename, retried on a transient Windows
|
|
11
|
+
* lock) so a crash mid-write cannot leave `loops.json` half-written where
|
|
12
|
+
* the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
|
|
13
|
+
* one; and every public function swallows its own errors — a registry hiccup must never fail
|
|
14
|
+
* `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
|
|
15
|
+
* difference from `installs.json`: this file keys rows by object (`{root, branch, title,
|
|
16
|
+
* started, finishedAt}`), not by a bare path, because identity alone is not enough to render a
|
|
17
|
+
* card.
|
|
18
|
+
*
|
|
19
|
+
* NO `lastSeen`. The Active tab POLLS `activeLoops` on an interval, and a field refreshed on
|
|
20
|
+
* every read would make every poll a write — fine for `installs.json` (`record` fires once per
|
|
21
|
+
* emit), wrong here (the page's own `usePoll` fires every few seconds, for as long as the tab
|
|
22
|
+
* is open, on a machine that may be a laptop on battery). So `activeLoops` saves the registry
|
|
23
|
+
* back ONLY when the kept set actually differs from what it loaded — a root pruned, a loop
|
|
24
|
+
* newly seen finished, or a loop seen running again after having been finished — never merely
|
|
25
|
+
* to record that a poll happened. `finishedAt` replaces `lastSeen` for the one thing a
|
|
26
|
+
* timestamp was doing here: it is set ONCE, the first time a row is found WITHOUT a LOOP.md
|
|
27
|
+
* (the loop finished), and the 7-day prune window counts from there — not from a last-poll time
|
|
28
|
+
* nothing refreshes any more. A loop seen running again afterwards (the same worktree
|
|
29
|
+
* re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
|
|
30
|
+
* again.
|
|
31
|
+
*
|
|
32
|
+
* `recordLoop` and `setLoopPreset` resolve the root they are given the same way — `normalize`:
|
|
33
|
+
* `realpathSync.native`, falling back to `path.resolve` for a root that no longer exists — and
|
|
34
|
+
* compare it with `normcase`, the same case-folding `js/lib/paths.mjs` uses everywhere a Windows
|
|
35
|
+
* path is compared, so a loop launched from a differently-cased spelling of the same worktree is
|
|
36
|
+
* still one row, not two. `activeLoops` compares nothing: it reads each row's root as stored.
|
|
37
|
+
*/
|
|
38
|
+
import { readFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
|
|
41
|
+
import { userLoopsDir } from './catalog.mjs';
|
|
42
|
+
import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
|
|
43
|
+
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
44
|
+
import { normcase } from '../lib/paths.mjs';
|
|
45
|
+
import { parseLoopFile, writeLoopFile, renameWithRetry, reviewOf, LOOP_FILE } from './state.mjs';
|
|
46
|
+
import { PRESETS } from './score.mjs';
|
|
47
|
+
|
|
48
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
export function registryPath() {
|
|
51
|
+
return path.join(userLoopsDir(), 'loops.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function load() {
|
|
55
|
+
try {
|
|
56
|
+
const data = parseJson(readFileSync(registryPath(), 'utf8'));
|
|
57
|
+
return Array.isArray(data?.loops) ? data.loops.filter((l) => l && typeof l.root === 'string') : [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function save(loops) {
|
|
64
|
+
try {
|
|
65
|
+
const file = registryPath();
|
|
66
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
+
const tmp = `${file}.tmp`;
|
|
68
|
+
writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
|
|
69
|
+
renameWithRetry(tmp, file);
|
|
70
|
+
} catch {
|
|
71
|
+
/* best-effort: a registry hiccup must never fail init or a read */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalize(dir) {
|
|
76
|
+
try { return realpathSync.native(dir); } catch { return path.resolve(dir); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sameRoot = (a, b) => normcase(a) === normcase(b);
|
|
80
|
+
|
|
81
|
+
function runningRow(entry, state) {
|
|
82
|
+
return {
|
|
83
|
+
root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
|
|
84
|
+
status: state.status, iteration: state.iteration, node: state.node, preset: state.preset,
|
|
85
|
+
threshold: PRESETS[state.preset], current: state.current, history: state.history,
|
|
86
|
+
review: reviewOf(state), reason: state.reason, awaiting: state.awaiting, graph: state.graph,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Upsert `root`'s identity row. Called by `geneseed loop init` right after `LOOP.md` is
|
|
92
|
+
* written — `started` is set once, on insert, and never moved by a later call. An update never
|
|
93
|
+
* touches `finishedAt` either way: `activeLoops` is the only writer of that field, and it will
|
|
94
|
+
* clear it on the very next poll once it sees LOOP.md present again. Never throws: an `init`
|
|
95
|
+
* must succeed even when the registry itself cannot be written.
|
|
96
|
+
*/
|
|
97
|
+
export function recordLoop({ root, branch, title, now = new Date() }) {
|
|
98
|
+
try {
|
|
99
|
+
const key = normalize(root);
|
|
100
|
+
const loops = load();
|
|
101
|
+
const idx = loops.findIndex((l) => sameRoot(l.root, key));
|
|
102
|
+
if (idx >= 0) {
|
|
103
|
+
loops[idx] = { ...loops[idx], root: key, branch, title };
|
|
104
|
+
} else {
|
|
105
|
+
loops.push({ root: key, branch, title, started: now.toISOString() });
|
|
106
|
+
}
|
|
107
|
+
save(loops);
|
|
108
|
+
} catch {
|
|
109
|
+
/* a registry hiccup must never fail loop init */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The rows the Active tab renders, read fresh off every registered worktree's `LOOP.md` —
|
|
115
|
+
* never off the registry's own stale copy of a loop's state. Four outcomes per row; the
|
|
116
|
+
* write-back at the end fires ONLY when one of the first three actually changed something
|
|
117
|
+
* (see the module docblock on why a routine poll must not be a write):
|
|
118
|
+
* - the worktree root no longer exists: dropped, silently (a removed clone or worktree);
|
|
119
|
+
* - `<root>/LOOP.md` exists but fails to parse (corrupt, or written mid-crash before this
|
|
120
|
+
* module's own atomic writer existed): the entry is KEPT exactly as read — dropping it here
|
|
121
|
+
* would be indistinguishable from a removed worktree, and a card the user can still see
|
|
122
|
+
* beats one that silently vanishes — and the row reports `status: 'unreadable'`;
|
|
123
|
+
* - `<root>/LOOP.md` exists and parses: the row carries the state fields the page needs, and
|
|
124
|
+
* if the entry still carried a `finishedAt` from a previous finish it is cleared (the loop
|
|
125
|
+
* is running again — that counts as a change, and is saved);
|
|
126
|
+
* - `LOOP.md` is gone (the loop finished): the first time this is seen, `finishedAt` is set
|
|
127
|
+
* to `now` and saved; after that it is read-only here until `now` is more than 7 days past
|
|
128
|
+
* that frozen `finishedAt`, at which point the row is dropped (and the drop is saved).
|
|
129
|
+
*/
|
|
130
|
+
export function activeLoops({ now = new Date() } = {}) {
|
|
131
|
+
const loops = load();
|
|
132
|
+
const kept = [];
|
|
133
|
+
const rows = [];
|
|
134
|
+
let changed = false;
|
|
135
|
+
for (const entry of loops) {
|
|
136
|
+
if (!isDir(entry.root)) { changed = true; continue; }
|
|
137
|
+
const file = path.join(entry.root, LOOP_FILE);
|
|
138
|
+
const identity = { root: entry.root, branch: entry.branch, title: entry.title, started: entry.started };
|
|
139
|
+
if (isFile(file)) {
|
|
140
|
+
let state;
|
|
141
|
+
try { state = parseLoopFile(readText(file)); } catch {
|
|
142
|
+
kept.push(entry);
|
|
143
|
+
rows.push({ ...identity, status: 'unreadable' });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let next = entry;
|
|
147
|
+
if (entry.finishedAt !== undefined) {
|
|
148
|
+
next = { ...entry }; delete next.finishedAt; changed = true;
|
|
149
|
+
}
|
|
150
|
+
kept.push(next);
|
|
151
|
+
rows.push(runningRow(next, state));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (entry.finishedAt === undefined) {
|
|
155
|
+
const next = { ...entry, finishedAt: now.toISOString() };
|
|
156
|
+
changed = true;
|
|
157
|
+
kept.push(next);
|
|
158
|
+
rows.push({ ...identity, status: 'finished' });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const age = now.getTime() - new Date(entry.finishedAt).getTime();
|
|
162
|
+
if (age > SEVEN_DAYS_MS) { changed = true; continue; }
|
|
163
|
+
kept.push(entry);
|
|
164
|
+
rows.push({ ...identity, status: 'finished' });
|
|
165
|
+
}
|
|
166
|
+
if (changed) save(kept);
|
|
167
|
+
return rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rewrite `root`'s `LOOP.md` `preset` field and nothing else — the per-loop preset picker on
|
|
172
|
+
* the Active tab. Refuses a `root` the registry does not know about (no arbitrary path
|
|
173
|
+
* writes — `POST /api/loops/preset`'s whole safety argument) and a `preset` outside `PRESETS`,
|
|
174
|
+
* each with its own message so the web action can tell the two refusals apart. Unlike
|
|
175
|
+
* `recordLoop`/`activeLoops`, this one DOES throw: a preset change the caller asked for and did
|
|
176
|
+
* not happen must be reported, not swallowed. Writes through `writeLoopFile` (atomic, like
|
|
177
|
+
* every other LOOP.md persist — see `state.mjs`'s docblock).
|
|
178
|
+
*/
|
|
179
|
+
export function setLoopPreset(root, preset) {
|
|
180
|
+
if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
|
|
181
|
+
const key = normalize(root);
|
|
182
|
+
const loops = load();
|
|
183
|
+
const entry = loops.find((l) => sameRoot(l.root, key));
|
|
184
|
+
if (!entry || !isDir(entry.root)) throw new Error('unknown loop');
|
|
185
|
+
const file = path.join(entry.root, LOOP_FILE);
|
|
186
|
+
if (!isFile(file)) throw new Error('unknown loop');
|
|
187
|
+
const state = parseLoopFile(readText(file));
|
|
188
|
+
state.preset = preset;
|
|
189
|
+
writeLoopFile(file, state);
|
|
190
|
+
return runningRow(entry, state);
|
|
191
|
+
}
|
package/js/loop/score.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export const PRESETS = Object.freeze({
|
|
|
29
29
|
export const DEFAULT_PRESET = 'balanced';
|
|
30
30
|
|
|
31
31
|
/** More deleted lines than this in one iteration counts as a code deletion. */
|
|
32
|
-
|
|
32
|
+
const DELETION_LINES = 20;
|
|
33
33
|
|
|
34
34
|
const RANK = { silent: 0, soft: 1, blocking: 2 };
|
|
35
35
|
|
|
@@ -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';
|