geneseed 3.11.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 +160 -0
- package/README.md +5 -5
- package/SHIPPED.md +2 -2
- 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-guard.js +157 -2
- package/bin/geneseed-cli.mjs +4 -0
- 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 +4 -2
- 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 +73 -0
- 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 -8
- package/docs/guides/autostart.md +3 -1
- package/docs/guides/choose-your-setup.md +5 -66
- 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 +7 -1
- package/docs/reference/loop-actions.md +20 -0
- package/docs/reference/opencode-plugins.md +14 -116
- 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 +36 -5
- package/harness.config.json +1 -1
- package/js/README.md +32 -2
- package/js/build/driver.mjs +14 -8
- package/js/build/generate.mjs +10 -6
- package/js/build/render.mjs +5 -0
- package/js/build/source.mjs +5 -1
- package/js/cli-table.json +261 -0
- package/js/hosts/gitref.mjs +99 -0
- package/js/hosts/hooks.mjs +88 -1
- package/js/hosts/installs.mjs +21 -0
- package/js/hosts/mcp.mjs +1 -1
- package/js/hosts/native.mjs +43 -5
- package/js/inspect/checks-authoring.mjs +10 -0
- package/js/inspect/checks-build.mjs +28 -0
- package/js/inspect/diff.mjs +3 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -0
- package/js/inspect/status.mjs +1 -1
- package/js/loop/catalog.mjs +127 -0
- package/js/loop/cli.mjs +173 -0
- package/js/loop/graph.mjs +163 -0
- package/js/loop/registry.mjs +196 -0
- package/js/loop/score.mjs +182 -0
- package/js/loop/state.mjs +436 -0
- package/js/maintain/migrate.mjs +4 -2
- package/js/maintain/setup.mjs +1 -1
- package/js/web/actions.mjs +34 -3
- package/js/web/api.mjs +51 -2
- package/js/web/daemon.mjs +43 -5
- package/js/web/docs.mjs +64 -12
- package/js/web/handler.mjs +1 -0
- package/js/web/jobs.mjs +3 -1
- package/js/web/routes.mjs +5 -1
- package/js/web/server.mjs +5 -3
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +2 -0
- package/src/agents/explorer.md +2 -2
- package/src/agents/researcher.md +3 -3
- package/src/bricks/README.md +34 -0
- package/src/bricks/_template.md +32 -0
- package/src/bricks/adr-challenge.md +20 -0
- package/src/bricks/adr-draft.md +15 -0
- package/src/bricks/apply.md +17 -0
- package/src/bricks/baseline-green.md +17 -0
- package/src/bricks/bruno-test.md +16 -0
- package/src/bricks/characterize.md +17 -0
- package/src/bricks/ci-fix.md +18 -0
- package/src/bricks/ci-triage.md +20 -0
- package/src/bricks/compat-check.md +16 -0
- package/src/bricks/deps-audit.md +14 -0
- package/src/bricks/docs-update.md +15 -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 +23 -0
- package/src/bricks/lint.md +14 -0
- package/src/bricks/migration-plan.md +21 -0
- package/src/bricks/mutation-check.md +18 -0
- package/src/bricks/plan.md +17 -0
- package/src/bricks/red-test.md +16 -0
- package/src/bricks/reproduce.md +16 -0
- package/src/bricks/review.md +19 -0
- package/src/bricks/security-scan.md +17 -0
- package/src/bricks/spec.md +18 -0
- package/src/bricks/test.md +14 -0
- package/src/bricks/upgrade-scout.md +16 -0
- package/src/doctrines/process.md +11 -8
- package/src/loops/api-endpoint.json +31 -0
- package/src/loops/architecture-decision.json +22 -0
- package/src/loops/bugfix.json +23 -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 +24 -0
- 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 +24 -0
- 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 +48 -0
- 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/geneseed.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 +111 -0
- 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/themes/_TEMPLATE.json +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- 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-Dc8f3ca-.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-C52LEb-2.js +0 -1
- package/web/dist/assets/ActivityDetail-CjNLRvXg.js +0 -1
- package/web/dist/assets/Dashboard-B6_sSCHi.js +0 -2
- package/web/dist/assets/Docs-Dj7BmCSA.js +0 -5
- package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
- package/web/dist/assets/GreenhouseView-BALwjQsA.js +0 -2
- package/web/dist/assets/Installs-BbffDQm3.js +0 -2
- package/web/dist/assets/JournalView-JpAQ8Hqe.js +0 -1
- package/web/dist/assets/Laws-DK7eCWSm.js +0 -3
- package/web/dist/assets/Library-CoamHelE.js +0 -1
- package/web/dist/assets/OperatorHudView-DVx1HlBx.js +0 -2
- package/web/dist/assets/Personal-eaXb1zcj.js +0 -1
- package/web/dist/assets/Tabs-BmGtt8E2.js +0 -1
- package/web/dist/assets/drawable-Du_PZrQj.js +0 -1
- package/web/dist/assets/index-Bz_KMRDH.css +0 -1
- package/web/dist/assets/index-CNFH7o55.js +0 -2
- package/web/dist/assets/sections-GalMS0Zv.js +0 -1
- package/web/dist/assets/usePoll-O0sJEw1Y.js +0 -1
package/js/loop/cli.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `geneseed loop <action>` — the loop engine's only face. Every action prints exactly one JSON
|
|
3
|
+
* object: the loop skill is a model reading stdout, and a JSON error it can quote beats a
|
|
4
|
+
* stack trace it has to interpret. Exit 1 on any refusal so a script can still branch on it.
|
|
5
|
+
*
|
|
6
|
+
* NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
|
|
7
|
+
* `git diff --cached --numstat` into `score --diff` and `git status --porcelain` into `record`.
|
|
8
|
+
* stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang.
|
|
9
|
+
*
|
|
10
|
+
* NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
|
|
11
|
+
* out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
|
|
12
|
+
* for that filter to mean anything. This entry finds that root itself — walking up from the cwd
|
|
13
|
+
* to the first directory holding `.git` (a directory for a normal clone, a FILE for a worktree
|
|
14
|
+
* or submodule) — rather than taking a path the caller could point anywhere.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { readText, writeText, isFile, printOut } from '../lib/fs.mjs';
|
|
19
|
+
import { loadCatalog, catalogProblems, parseBrick } from './catalog.mjs';
|
|
20
|
+
import { checkGraph } from './graph.mjs';
|
|
21
|
+
import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
|
|
22
|
+
import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
|
|
23
|
+
import {
|
|
24
|
+
initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
|
|
25
|
+
writeLoopFile, parseLoopFile, LOOP_FILE,
|
|
26
|
+
} from './state.mjs';
|
|
27
|
+
import { recordLoop } from './registry.mjs';
|
|
28
|
+
|
|
29
|
+
const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
|
|
30
|
+
const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
|
|
31
|
+
const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
|
|
32
|
+
|
|
33
|
+
function requireRoot() {
|
|
34
|
+
const root = gitRootOf(process.cwd());
|
|
35
|
+
if (!root) throw new Error('not inside a git repository');
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* When `score --diff`/`decide` closes a unit (`commit: true`), write the commit message to
|
|
41
|
+
* `<gitdir>/LOOP_COMMIT_MSG` — never tracked, never part of any diff — so the loop's own
|
|
42
|
+
* automation never has to quote a message on a command line (`js/hosts/hooks.mjs`'s
|
|
43
|
+
* `loopExempt` only whitelists `git commit -F <path>`).
|
|
44
|
+
*
|
|
45
|
+
* `gitdir` is RESOLVED BY THE CALLER, before `withState` ran the state change (X6, fix round
|
|
46
|
+
* 2): a malformed `.git` FILE makes `gitDirOf` throw, and that throw must abort the action
|
|
47
|
+
* before anything about the loop's state has moved, not after. Adds `message_file` to the
|
|
48
|
+
* result for the skill to read back, with forward slashes (`C:/...`) even on Windows — Git
|
|
49
|
+
* Bash and git itself both accept that form on every platform, and a backslash-laden path
|
|
50
|
+
* breaks when a later shell command tries to use it unquoted.
|
|
51
|
+
*/
|
|
52
|
+
function writeCommitMessage(root, gitdir, result) {
|
|
53
|
+
if (!result || result.commit !== true) return result;
|
|
54
|
+
const branch = currentBranch(root);
|
|
55
|
+
const slug = branch && branch.startsWith('loop/') ? branch.slice('loop/'.length) : 'loop';
|
|
56
|
+
const file = path.join(gitdir, 'LOOP_COMMIT_MSG');
|
|
57
|
+
writeText(file, `loop(${slug}): iteration ${result.iteration} — ${result.intent || 'setup'}\n\n${result.trailers}\n`);
|
|
58
|
+
return { ...result, message_file: file.replaceAll('\\', '/') };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withState(fn) {
|
|
62
|
+
const root = requireRoot();
|
|
63
|
+
const file = path.join(root, LOOP_FILE);
|
|
64
|
+
if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
|
|
65
|
+
const state = parseLoopFile(readText(file));
|
|
66
|
+
const { bricks } = loadCatalog({ projectRoot: root });
|
|
67
|
+
const result = fn(state, bricks);
|
|
68
|
+
writeLoopFile(file, state);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveGraph(spec, catalog) {
|
|
73
|
+
if (spec && spec.endsWith('.json')) return JSON.parse(readText(path.resolve(spec)));
|
|
74
|
+
const g = catalog.templates.get(spec);
|
|
75
|
+
if (!g) throw new Error(`no loop template named ${JSON.stringify(spec)}`);
|
|
76
|
+
const { origin, ...graph } = g;
|
|
77
|
+
return graph;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ACTIONS = {
|
|
81
|
+
check(args) {
|
|
82
|
+
if (args.brick) {
|
|
83
|
+
const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
|
|
84
|
+
return { ok: !problems.length, problems };
|
|
85
|
+
}
|
|
86
|
+
if (args.graph) {
|
|
87
|
+
const catalog = loadCatalog({ projectRoot: process.cwd() });
|
|
88
|
+
const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
|
|
89
|
+
return { ok: !problems.length, problems };
|
|
90
|
+
}
|
|
91
|
+
// The bare check doubles as the catalogue LISTING the loop skill composes a free graph
|
|
92
|
+
// from — frontmatter only: a brick's body reaches the model through `next`, for the node
|
|
93
|
+
// the engine chose, never as a menu.
|
|
94
|
+
const problems = catalogProblems({ projectRoot: process.cwd() });
|
|
95
|
+
const { bricks, templates, overridden } = loadCatalog({ projectRoot: process.cwd() });
|
|
96
|
+
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
97
|
+
return {
|
|
98
|
+
ok: !problems.length, problems,
|
|
99
|
+
templates: [...templates.keys()].sort(byName),
|
|
100
|
+
bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
|
|
101
|
+
.map(({ body, available, reason, ...b }) => ({ ...b, available, ...(reason ? { reason } : {}) })),
|
|
102
|
+
overridden,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
init(args) {
|
|
106
|
+
const root = requireRoot();
|
|
107
|
+
const file = path.join(root, LOOP_FILE);
|
|
108
|
+
if (isFile(file)) throw new Error(`${file} already exists — a loop is already running here`);
|
|
109
|
+
if (!args.title || !args.requirement || !args.graph) throw new Error('init needs --title, --requirement and --graph');
|
|
110
|
+
const catalog = loadCatalog({ projectRoot: root });
|
|
111
|
+
const graph = resolveGraph(args.graph, catalog);
|
|
112
|
+
const problems = checkGraph(graph, catalog.bricks);
|
|
113
|
+
if (problems.length) return { error: 'the graph does not pass loop check', problems };
|
|
114
|
+
const state = initState({
|
|
115
|
+
title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET,
|
|
116
|
+
contracts: list(args.contracts) ?? [],
|
|
117
|
+
});
|
|
118
|
+
writeLoopFile(file, state);
|
|
119
|
+
// Best-effort: the registry is how the Active tab discovers this loop, but a registry
|
|
120
|
+
// hiccup must never fail the init the loop itself just succeeded at.
|
|
121
|
+
recordLoop({ root, branch: currentBranch(root), title: args.title });
|
|
122
|
+
return { file, graph: graph.name, preset: state.preset, status: state.status };
|
|
123
|
+
},
|
|
124
|
+
next: () => withState((s, b) => nextStep(s, b)),
|
|
125
|
+
score(args) {
|
|
126
|
+
if (args.diff) {
|
|
127
|
+
// X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
|
|
128
|
+
// malformed `.git` file errors without advancing the loop.
|
|
129
|
+
const root = requireRoot();
|
|
130
|
+
const gitdir = gitDirOf(root);
|
|
131
|
+
return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
|
|
132
|
+
}
|
|
133
|
+
if (!args.declared) throw new Error('score needs --declared or --diff');
|
|
134
|
+
return withState((s) => scoreDeclared(s, {
|
|
135
|
+
actions: list(args.actions), writeSet: list(args.writeSet), intent: args.intent ?? null,
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
record(args) {
|
|
139
|
+
if (!args.outcome) throw new Error('record needs --outcome');
|
|
140
|
+
if (args.note && args.noteFile) throw new Error('record takes --note or --note-file, not both');
|
|
141
|
+
let note = args.note ?? '';
|
|
142
|
+
if (args.noteFile) {
|
|
143
|
+
// A note too long for a shell argument, or one that mentions `git … commit`/`push` (and
|
|
144
|
+
// would otherwise sit inside the command line GIT_GATE_RE matches whole), goes through a
|
|
145
|
+
// file instead — never through the shell at all. CRLF-folded and trimmed the same way the
|
|
146
|
+
// porcelain stdin below is, so a Windows-authored file round-trips identically.
|
|
147
|
+
let raw;
|
|
148
|
+
try { raw = readText(path.resolve(args.noteFile)); } catch (e) {
|
|
149
|
+
throw new Error(`cannot read --note-file ${args.noteFile}: ${e.message}`, { cause: e });
|
|
150
|
+
}
|
|
151
|
+
note = raw.replace(/\r\n/g, '\n').trimEnd();
|
|
152
|
+
}
|
|
153
|
+
const card = args.card ? JSON.parse(args.card) : null;
|
|
154
|
+
const porcelain = stdin();
|
|
155
|
+
return withState((s, b) => recordOutcome(s, b, args.outcome, {
|
|
156
|
+
card, porcelain: porcelain ? porcelain.replace(/\r\n/g, '\n').trimEnd() : null, note,
|
|
157
|
+
}));
|
|
158
|
+
},
|
|
159
|
+
decide(args) {
|
|
160
|
+
// X6: same ordering as `score --diff` above.
|
|
161
|
+
const root = requireRoot();
|
|
162
|
+
const gitdir = gitDirOf(root);
|
|
163
|
+
return writeCommitMessage(root, gitdir, withState((s, b) => decideAwaiting(s, b, args.verdict, args.note ?? '')));
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export function cmdLoop(args) {
|
|
168
|
+
try {
|
|
169
|
+
return emit(ACTIONS[args.action](args));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return emit({ error: e.message });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules a loop graph must satisfy before a single node runs. ONE function, three callers:
|
|
3
|
+
* `geneseed loop check`, the doctor over the shipped templates, and `loop init` over a graph the
|
|
4
|
+
* model composed at run time — the composed graph never meets the doctor, so the engine has to
|
|
5
|
+
* carry the same check or free composition would be unchecked composition.
|
|
6
|
+
*
|
|
7
|
+
* WHY EVERY CYCLE NEEDS A DECLARED LOOP. Edges are free, so a graph can loop anywhere. A loop
|
|
8
|
+
* that terminates only because the model eventually reports a different outcome is a loop that
|
|
9
|
+
* may not terminate; a declared `max` is what turns "should stop" into "will stop". The check is
|
|
10
|
+
* structural (strongly connected components), so it needs no simulation and cannot miss a path.
|
|
11
|
+
*
|
|
12
|
+
* Problems are returned in a fixed order — per node, per edge, per outcome, then loops — so a
|
|
13
|
+
* test can write the expected list out instead of sorting it.
|
|
14
|
+
*
|
|
15
|
+
* WHY A CYCLE INSIDE AN ITERATION NEEDS ITS OWN LOOP. The whole-SCC check (rule 4) is satisfied
|
|
16
|
+
* by the iteration loop alone, since every node in an iteration is reachable from every other —
|
|
17
|
+
* but the runtime gives a node outside every *inner* loop exactly one entry per iteration (no
|
|
18
|
+
* re-entry at all), so a cycle that never reaches the iteration head and is not inside its own
|
|
19
|
+
* declared inner loop would hit that "max of 0" path by construction, not by a model's mistake.
|
|
20
|
+
* The static check has to refuse that graph, not just the ones the runtime would loop forever on.
|
|
21
|
+
*/
|
|
22
|
+
import { WEIGHTS } from './score.mjs';
|
|
23
|
+
|
|
24
|
+
export const TERMINALS = new Set(['$close', '$stop']);
|
|
25
|
+
export const ENGINE_MAX_ITERATIONS = 20;
|
|
26
|
+
/** 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'];
|
|
28
|
+
|
|
29
|
+
export function iterationLoop(graph) {
|
|
30
|
+
return (graph.loops ?? []).find((l) => l.iteration === true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Tarjan — the strongly connected components of the node graph (terminals excluded). */
|
|
34
|
+
function components(nodes, edges) {
|
|
35
|
+
const succ = new Map(nodes.map((n) => [n, []]));
|
|
36
|
+
for (const e of edges) if (succ.has(e.from) && succ.has(e.to)) succ.get(e.from).push(e.to);
|
|
37
|
+
let index = 0;
|
|
38
|
+
const idx = new Map(); const low = new Map(); const stack = []; const on = new Set(); const out = [];
|
|
39
|
+
const visit = (v) => {
|
|
40
|
+
idx.set(v, index); low.set(v, index); index += 1; stack.push(v); on.add(v);
|
|
41
|
+
for (const w of succ.get(v)) {
|
|
42
|
+
if (!idx.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
|
43
|
+
else if (on.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
|
|
44
|
+
}
|
|
45
|
+
if (low.get(v) === idx.get(v)) {
|
|
46
|
+
const comp = [];
|
|
47
|
+
for (;;) { const w = stack.pop(); on.delete(w); comp.push(w); if (w === v) break; }
|
|
48
|
+
const selfLoop = comp.length === 1 && succ.get(v).includes(v);
|
|
49
|
+
if (comp.length > 1 || selfLoop) out.push(comp.sort());
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
for (const n of nodes) if (!idx.has(n)) visit(n);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function checkGraph(graph, bricks) {
|
|
57
|
+
const problems = [];
|
|
58
|
+
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
59
|
+
const edges = Array.isArray(graph.edges) ? graph.edges : [];
|
|
60
|
+
const loops = Array.isArray(graph.loops) ? graph.loops : [];
|
|
61
|
+
const nodeSet = new Set(nodes);
|
|
62
|
+
|
|
63
|
+
// rule 1 — nodes name available bricks
|
|
64
|
+
for (const n of nodes) {
|
|
65
|
+
const brick = bricks.get(n);
|
|
66
|
+
if (!brick) problems.push(`node ${n}: no brick named ${n}`);
|
|
67
|
+
else if (!brick.available) problems.push(`node ${n}: brick unavailable (${brick.reason})`);
|
|
68
|
+
}
|
|
69
|
+
// rule 1 — edge endpoints; rule 2 — edges use declared outcomes
|
|
70
|
+
for (const e of edges) {
|
|
71
|
+
const label = `edge ${e.from} --${e.on}--> ${e.to}`;
|
|
72
|
+
if (!nodeSet.has(e.from)) { problems.push(`${label}: ${e.from} is not a node`); continue; }
|
|
73
|
+
if (!nodeSet.has(e.to) && !TERMINALS.has(e.to)) {
|
|
74
|
+
problems.push(`${label}: ${e.to} is not a node, $close or $stop`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const brick = bricks.get(e.from);
|
|
78
|
+
if (brick && !brick.outcomes.includes(e.on)) problems.push(`${label}: ${e.on} is not an outcome of ${e.from}`);
|
|
79
|
+
}
|
|
80
|
+
// rule 2 — exactly one edge per declared outcome (nodes in sorted order)
|
|
81
|
+
for (const n of [...nodes].sort()) {
|
|
82
|
+
const brick = bricks.get(n);
|
|
83
|
+
if (!brick) continue;
|
|
84
|
+
for (const o of brick.outcomes) {
|
|
85
|
+
const count = edges.filter((e) => e.from === n && e.on === o).length;
|
|
86
|
+
if (count === 0) problems.push(`node ${n}: no edge for outcome ${o}`);
|
|
87
|
+
else if (count > 1) problems.push(`node ${n}: ${count} edges for outcome ${o}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// rule 3 — start, and $close reachable
|
|
91
|
+
if (!nodeSet.has(graph.start)) {
|
|
92
|
+
problems.push(`start ${graph.start} is not a node`);
|
|
93
|
+
} else {
|
|
94
|
+
const seen = new Set([graph.start]); const queue = [graph.start];
|
|
95
|
+
while (queue.length) {
|
|
96
|
+
const v = queue.shift();
|
|
97
|
+
for (const e of edges) {
|
|
98
|
+
if (e.from === v && !seen.has(e.to)) { seen.add(e.to); if (nodeSet.has(e.to)) queue.push(e.to); }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!seen.has('$close')) problems.push(`$close is not reachable from ${graph.start}`);
|
|
102
|
+
}
|
|
103
|
+
// rule 5 — loops are well formed
|
|
104
|
+
for (const l of loops) {
|
|
105
|
+
if (!Number.isInteger(l.max) || l.max < 1) problems.push(`loop ${l.name}: max must be a positive integer`);
|
|
106
|
+
else if (l.iteration === true && l.max > ENGINE_MAX_ITERATIONS) {
|
|
107
|
+
problems.push(`loop ${l.name}: max ${l.max} is above the engine ceiling of ${ENGINE_MAX_ITERATIONS}`);
|
|
108
|
+
}
|
|
109
|
+
for (const n of l.nodes ?? []) if (!nodeSet.has(n)) problems.push(`loop ${l.name}: ${n} is not a node`);
|
|
110
|
+
}
|
|
111
|
+
const iterations = loops.filter((l) => l.iteration === true).length;
|
|
112
|
+
if (iterations !== 1) problems.push(`${iterations} loops are marked iteration; exactly one must be`);
|
|
113
|
+
// rule 4 — every cycle is inside a declared loop
|
|
114
|
+
for (const comp of components(nodes, edges)) {
|
|
115
|
+
const covered = loops.some((l) => comp.every((n) => (l.nodes ?? []).includes(n)));
|
|
116
|
+
if (!covered) problems.push(`cycle ${comp.join(', ')} has no declared loop around it`);
|
|
117
|
+
}
|
|
118
|
+
// rule 4b — a cycle that never reaches the iteration head needs its own non-iteration loop
|
|
119
|
+
const iterationLoops = loops.filter((l) => l.iteration === true);
|
|
120
|
+
if (iterationLoops.length === 1) {
|
|
121
|
+
const [iteration] = iterationLoops;
|
|
122
|
+
const head = iteration.nodes?.[0];
|
|
123
|
+
const innerEdges = edges.filter((e) => e.to !== head);
|
|
124
|
+
for (const comp of components(nodes, innerEdges)) {
|
|
125
|
+
const covered = loops.some((l) => l !== iteration && comp.every((n) => (l.nodes ?? []).includes(n)));
|
|
126
|
+
if (!covered) problems.push(`cycle ${comp.join(', ')} inside an iteration has no inner loop around it`);
|
|
127
|
+
}
|
|
128
|
+
// rule 4c — the iteration is entered only at its head: the runtime opens and closes units
|
|
129
|
+
// on re-entering nodes[0], so entering anywhere else runs a unit with no head behind it.
|
|
130
|
+
const inside = new Set(iteration.nodes ?? []);
|
|
131
|
+
if (head !== undefined) {
|
|
132
|
+
if (inside.has(graph.start) && graph.start !== head) {
|
|
133
|
+
problems.push(`start ${graph.start} is inside the iteration but is not its head ${head}`);
|
|
134
|
+
}
|
|
135
|
+
for (const e of edges) {
|
|
136
|
+
if (!inside.has(e.from) && inside.has(e.to) && e.to !== head) {
|
|
137
|
+
problems.push(`edge ${e.from} --${e.on}--> ${e.to} enters the iteration at ${e.to}, not at its head ${head}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// template weight overrides
|
|
143
|
+
for (const k of Object.keys(graph.weights ?? {})) {
|
|
144
|
+
if (!Object.hasOwn(WEIGHTS, k)) problems.push(`weights: ${k} is not an action`);
|
|
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
|
+
}
|
|
162
|
+
return problems;
|
|
163
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
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` + `renameSync` so a crash mid-write cannot leave `loops.json` half-written where
|
|
11
|
+
* the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
|
|
12
|
+
* one; and every public function swallows its own errors — a registry hiccup must never fail
|
|
13
|
+
* `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
|
|
14
|
+
* difference from `installs.json`: this file keys rows by object (`{root, branch, title,
|
|
15
|
+
* started, finishedAt}`), not by a bare path, because identity alone is not enough to render a
|
|
16
|
+
* card.
|
|
17
|
+
*
|
|
18
|
+
* NO `lastSeen`. The Active tab POLLS `activeLoops` on an interval, and a field refreshed on
|
|
19
|
+
* every read would make every poll a write — fine for `installs.json` (`record` fires once per
|
|
20
|
+
* emit), wrong here (the page's own `usePoll` fires every few seconds, for as long as the tab
|
|
21
|
+
* is open, on a machine that may be a laptop on battery). So `activeLoops` saves the registry
|
|
22
|
+
* back ONLY when the kept set actually differs from what it loaded — a root pruned, a loop
|
|
23
|
+
* newly seen finished, or a loop seen running again after having been finished — never merely
|
|
24
|
+
* to record that a poll happened. `finishedAt` replaces `lastSeen` for the one thing a
|
|
25
|
+
* timestamp was doing here: it is set ONCE, the first time a row is found WITHOUT a LOOP.md
|
|
26
|
+
* (the loop finished), and the 7-day prune window counts from there — not from a last-poll time
|
|
27
|
+
* nothing refreshes any more. A loop seen running again afterwards (the same worktree
|
|
28
|
+
* re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
|
|
29
|
+
* again.
|
|
30
|
+
*
|
|
31
|
+
* `recordLoop` and `activeLoops`/`setLoopPreset` never call each other's normalisation by
|
|
32
|
+
* accident: both resolve a root through `realpathSync.native` (falling back to `path.resolve`
|
|
33
|
+
* for a root that no longer exists) and compare with `normcase` — the same case-folding
|
|
34
|
+
* `js/lib/paths.mjs` uses everywhere a Windows path is compared — so a loop launched from a
|
|
35
|
+
* differently-cased spelling of the same worktree is still one row, not two.
|
|
36
|
+
*/
|
|
37
|
+
import { readFileSync, mkdirSync, realpathSync, renameSync } from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { userLoopsDir } from './catalog.mjs';
|
|
41
|
+
import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
|
|
42
|
+
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
43
|
+
import { normcase } from '../lib/paths.mjs';
|
|
44
|
+
import { parseLoopFile, writeLoopFile, LOOP_FILE } from './state.mjs';
|
|
45
|
+
import { PRESETS } from './score.mjs';
|
|
46
|
+
|
|
47
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
export function registryPath() {
|
|
50
|
+
return path.join(userLoopsDir(), 'loops.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function load() {
|
|
54
|
+
try {
|
|
55
|
+
const data = parseJson(readFileSync(registryPath(), 'utf8'));
|
|
56
|
+
return Array.isArray(data?.loops) ? data.loops.filter((l) => l && typeof l.root === 'string') : [];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function save(loops) {
|
|
63
|
+
try {
|
|
64
|
+
const file = registryPath();
|
|
65
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
+
const tmp = `${file}.tmp`;
|
|
67
|
+
writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
|
|
68
|
+
renameSync(tmp, file);
|
|
69
|
+
} catch {
|
|
70
|
+
/* best-effort: a registry hiccup must never fail init or a read */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalize(dir) {
|
|
75
|
+
try { return realpathSync.native(dir); } catch { return path.resolve(dir); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sameRoot = (a, b) => normcase(a) === normcase(b);
|
|
79
|
+
|
|
80
|
+
/** The soft-decision iterations of `state.history` — the same shape `state.mjs`'s own
|
|
81
|
+
* end-of-run `summary()` reports, so a card and a finished-loop review read the same fields. */
|
|
82
|
+
function reviewOf(state) {
|
|
83
|
+
return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runningRow(entry, state) {
|
|
87
|
+
return {
|
|
88
|
+
root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
|
|
89
|
+
status: state.status, iteration: state.iteration, node: state.node, preset: state.preset,
|
|
90
|
+
threshold: PRESETS[state.preset], current: state.current, history: state.history,
|
|
91
|
+
review: reviewOf(state), reason: state.reason, awaiting: state.awaiting, graph: state.graph,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Upsert `root`'s identity row. Called by `geneseed loop init` right after `LOOP.md` is
|
|
97
|
+
* written — `started` is set once, on insert, and never moved by a later call. An update never
|
|
98
|
+
* touches `finishedAt` either way: `activeLoops` is the only writer of that field, and it will
|
|
99
|
+
* clear it on the very next poll once it sees LOOP.md present again. Never throws: an `init`
|
|
100
|
+
* must succeed even when the registry itself cannot be written.
|
|
101
|
+
*/
|
|
102
|
+
export function recordLoop({ root, branch, title, now = new Date() }) {
|
|
103
|
+
try {
|
|
104
|
+
const key = normalize(root);
|
|
105
|
+
const loops = load();
|
|
106
|
+
const idx = loops.findIndex((l) => sameRoot(l.root, key));
|
|
107
|
+
if (idx >= 0) {
|
|
108
|
+
loops[idx] = { ...loops[idx], root: key, branch, title };
|
|
109
|
+
} else {
|
|
110
|
+
loops.push({ root: key, branch, title, started: now.toISOString() });
|
|
111
|
+
}
|
|
112
|
+
save(loops);
|
|
113
|
+
} catch {
|
|
114
|
+
/* a registry hiccup must never fail loop init */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The rows the Active tab renders, read fresh off every registered worktree's `LOOP.md` —
|
|
120
|
+
* never off the registry's own stale copy of a loop's state. Four outcomes per row; the
|
|
121
|
+
* write-back at the end fires ONLY when one of the first three actually changed something
|
|
122
|
+
* (see the module docblock on why a routine poll must not be a write):
|
|
123
|
+
* - the worktree root no longer exists: dropped, silently (a removed clone or worktree);
|
|
124
|
+
* - `<root>/LOOP.md` exists but fails to parse (corrupt, or written mid-crash before this
|
|
125
|
+
* module's own atomic writer existed): the entry is KEPT exactly as read — dropping it here
|
|
126
|
+
* would be indistinguishable from a removed worktree, and a card the user can still see
|
|
127
|
+
* beats one that silently vanishes — and the row reports `status: 'unreadable'`;
|
|
128
|
+
* - `<root>/LOOP.md` exists and parses: the row carries the state fields the page needs, and
|
|
129
|
+
* if the entry still carried a `finishedAt` from a previous finish it is cleared (the loop
|
|
130
|
+
* is running again — that counts as a change, and is saved);
|
|
131
|
+
* - `LOOP.md` is gone (the loop finished): the first time this is seen, `finishedAt` is set
|
|
132
|
+
* to `now` and saved; after that it is read-only here until `now` is more than 7 days past
|
|
133
|
+
* that frozen `finishedAt`, at which point the row is dropped (and the drop is saved).
|
|
134
|
+
*/
|
|
135
|
+
export function activeLoops({ now = new Date() } = {}) {
|
|
136
|
+
const loops = load();
|
|
137
|
+
const kept = [];
|
|
138
|
+
const rows = [];
|
|
139
|
+
let changed = false;
|
|
140
|
+
for (const entry of loops) {
|
|
141
|
+
if (!isDir(entry.root)) { changed = true; continue; }
|
|
142
|
+
const file = path.join(entry.root, LOOP_FILE);
|
|
143
|
+
const identity = { root: entry.root, branch: entry.branch, title: entry.title, started: entry.started };
|
|
144
|
+
if (isFile(file)) {
|
|
145
|
+
let state;
|
|
146
|
+
try { state = parseLoopFile(readText(file)); } catch {
|
|
147
|
+
kept.push(entry);
|
|
148
|
+
rows.push({ ...identity, status: 'unreadable' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
let next = entry;
|
|
152
|
+
if (entry.finishedAt !== undefined) {
|
|
153
|
+
next = { ...entry }; delete next.finishedAt; changed = true;
|
|
154
|
+
}
|
|
155
|
+
kept.push(next);
|
|
156
|
+
rows.push(runningRow(next, state));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (entry.finishedAt === undefined) {
|
|
160
|
+
const next = { ...entry, finishedAt: now.toISOString() };
|
|
161
|
+
changed = true;
|
|
162
|
+
kept.push(next);
|
|
163
|
+
rows.push({ ...identity, status: 'finished' });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const age = now.getTime() - new Date(entry.finishedAt).getTime();
|
|
167
|
+
if (age > SEVEN_DAYS_MS) { changed = true; continue; }
|
|
168
|
+
kept.push(entry);
|
|
169
|
+
rows.push({ ...identity, status: 'finished' });
|
|
170
|
+
}
|
|
171
|
+
if (changed) save(kept);
|
|
172
|
+
return rows;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rewrite `root`'s `LOOP.md` `preset` field and nothing else — the per-loop preset picker on
|
|
177
|
+
* the Active tab. Refuses a `root` the registry does not know about (no arbitrary path
|
|
178
|
+
* writes — `POST /api/loops/preset`'s whole safety argument) and a `preset` outside `PRESETS`,
|
|
179
|
+
* each with its own message so the web action can tell the two refusals apart. Unlike
|
|
180
|
+
* `recordLoop`/`activeLoops`, this one DOES throw: a preset change the caller asked for and did
|
|
181
|
+
* not happen must be reported, not swallowed. Writes through `writeLoopFile` (atomic, like
|
|
182
|
+
* every other LOOP.md persist — see `state.mjs`'s docblock).
|
|
183
|
+
*/
|
|
184
|
+
export function setLoopPreset(root, preset) {
|
|
185
|
+
if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
|
|
186
|
+
const key = normalize(root);
|
|
187
|
+
const loops = load();
|
|
188
|
+
const entry = loops.find((l) => sameRoot(l.root, key));
|
|
189
|
+
if (!entry || !isDir(entry.root)) throw new Error('unknown loop');
|
|
190
|
+
const file = path.join(entry.root, LOOP_FILE);
|
|
191
|
+
if (!isFile(file)) throw new Error('unknown loop');
|
|
192
|
+
const state = parseLoopFile(readText(file));
|
|
193
|
+
state.preset = preset;
|
|
194
|
+
writeLoopFile(file, state);
|
|
195
|
+
return runningRow(entry, state);
|
|
196
|
+
}
|