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/docs/web-ui.md
CHANGED
|
@@ -111,27 +111,47 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
111
111
|
**Promote to rule** control turns a recurring lesson into a trial rule in
|
|
112
112
|
`user-rules.md` — provenance recorded, the source fact deleted so it isn't loaded
|
|
113
113
|
twice — landing you on the Rules page.
|
|
114
|
-
- **Loops** (`#/loops`) — the loop catalogue `geneseed loop` runs from, read-only
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
114
|
+
- **Loops** (`#/loops`) — the loop catalogue `geneseed loop` runs from, read-only, laid out
|
|
115
|
+
like the Library: a rail of sections (**Templates**, **Bricks**, **Active**, each with its
|
|
116
|
+
count; `#/loops/<section>/<item>` opens an entry), with the selected section's groups
|
|
117
|
+
nested under it in the rail (template categories, brick origins, run statuses — pick one to
|
|
118
|
+
narrow the list), a filterable list, and a reader.
|
|
119
|
+
**Templates** are grouped by their `category` (architecture, tests, development,
|
|
120
|
+
refactoring, day-to-day, then *Other*), a *human gate* pill on any that stops for you. The
|
|
121
|
+
reader draws the selected template as a ring: the iteration turning clockwise from its head,
|
|
122
|
+
each inner retry loop hung outside on its head node, the ⛨ *validate* gate the engine adds
|
|
123
|
+
before the first mutate step, setup entering from the left and `done → close` leaving from
|
|
124
|
+
the head; its `rules` follow, then a compact row per brick (effect, agent or skill, outcomes,
|
|
125
|
+
human gate), and clicking a node scrolls to and lights its row. A graph a ring cannot draw
|
|
126
|
+
cleanly (more than eight iteration steps, a node in more than three loops) falls back to
|
|
127
|
+
left-to-right boxes. **Bricks** are grouped by origin (project / global / shipped), pilled
|
|
128
|
+
for *mutate*, *human gate*, what they override and *unavailable*; the reader shows the
|
|
129
|
+
frontmatter facts (outcomes as pills, availability with its reason), then the body rendered as
|
|
130
|
+
markdown, with a **View source** toggle for the file as parsed, frontmatter and all. Templates and
|
|
131
|
+
Bricks read `GET /api/loops`, resolved from the selected install's root, so a project
|
|
132
|
+
install shows its `.geneseed/` bricks and templates. **Active** is every loop
|
|
133
|
+
`geneseed loop init` registered on this machine (`GET /api/loops/active`, polled every
|
|
134
|
+
5 s), grouped awaiting, running, done/stopped, finished/unreadable; the reader shows its
|
|
135
|
+
branch, `iteration N / max`, current node and a preset picker (`POST /api/loops/preset`,
|
|
136
|
+
the page's only write), what an awaiting loop waits on (launch / declared / actual / a
|
|
137
|
+
human gate, with its node) — the answer is given in the agent's session, never here — and
|
|
138
|
+
its ring with the current node filled, with its iteration history under it.
|
|
125
139
|
- **Profile** (`#/profile`) — `PROFILE.md` beside the deployed AGENT.md: who you are and
|
|
126
140
|
how you like to work. Opens rendered; **Edit** is a whole-file editor. The save carries
|
|
127
141
|
the fingerprint it loaded — if an agent session changed the file first, your edit stays
|
|
128
142
|
on screen and a second Save replaces the newer version.
|
|
129
143
|
- **Skills** (`#/skills`) and **Agents** (`#/agents`) — the deployed catalogue, each entry
|
|
130
|
-
with its purpose line and full spec.
|
|
144
|
+
with its purpose line and full spec. While Skills is selected, its skill types sit nested
|
|
145
|
+
under it in the rail, each with its count, above a thin bar of their shares; pick one to
|
|
146
|
+
narrow the list.
|
|
131
147
|
- **Docs** (`#/docs`) — rendered documentation: markdown pages, concept pages, a CLI
|
|
132
|
-
reference (generated from the harness argument parser), and a glossary,
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
reference (generated from the harness argument parser), and a glossary, in the Library's
|
|
149
|
+
three panes: the parts (Understand / Guides / Concepts / Reference) with their page counts,
|
|
150
|
+
the open part's sections nested under it in the rail (pick one to narrow the list), the
|
|
151
|
+
part's pages under their sections with a filter, and the page with its breadcrumb,
|
|
152
|
+
outline and Previous / Next within the section. Filtered for one host (OpenCode, Claude
|
|
153
|
+
Code, Bob or OpenClaude; Claude-family pages show to all three Claude-engine hosts, a page
|
|
154
|
+
tagged with a list of hosts only to those); with no
|
|
135
155
|
choice stored it follows the deployed install.
|
|
136
156
|
|
|
137
157
|
### 🩺 Care
|
package/harness.config.json
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
"_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
|
|
3
3
|
"theme": "neutral",
|
|
4
4
|
"product": "Geneseed",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.13.0"
|
|
6
6
|
}
|
package/js/README.md
CHANGED
|
@@ -140,12 +140,20 @@ transition, score and stop is computed here.
|
|
|
140
140
|
| `catalog.mjs` | `loadCatalog` — bricks and loop templates from shipped/global/project origins, later overrides earlier BY NAME |
|
|
141
141
|
| `state.mjs` | The engine itself: `initState`, `nextStep`, `scoreDeclared`/`scoreDiff`, `recordOutcome`, `decideAwaiting`, `renderLoopFile`/`parseLoopFile` |
|
|
142
142
|
| `cli.mjs` | `geneseed loop check\|init\|next\|score\|record\|decide` — the only face either a model or a human reaches this engine through |
|
|
143
|
+
| `registry.mjs` | `loops.json` under XDG — the launched-loop identity list `loop init` records into and the Active tab's `activeLoops`/`setLoopPreset` read and rewrite |
|
|
143
144
|
|
|
144
145
|
**Before editing:** `score.mjs` is imported by the build driver (the `--trust` choices), so it must
|
|
145
146
|
stay free of every import but node builtins and `graph.mjs`. `state.mjs` excludes `LOOP.md` itself
|
|
146
|
-
from every score and every read-brick porcelain
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
(and its own atomic-write sibling, `LOOP.md.tmp`) from every score and every read-brick porcelain
|
|
148
|
+
check — it is the engine's own bookkeeping, never part of the work being scored. Every LOOP.md
|
|
149
|
+
write goes through `state.mjs`'s `writeLoopFile`, never a bare `writeText`, so a killed process
|
|
150
|
+
cannot leave a torn file. `cli.mjs` may not spawn: git output is piped in on stdin by the model,
|
|
151
|
+
never read by this entry. `registry.mjs` swallows its own errors on `recordLoop`/`activeLoops` on
|
|
152
|
+
purpose, like `js/inspect/registry.mjs` — a registry hiccup must never fail `loop init` or a
|
|
153
|
+
read — but `setLoopPreset` does not: a preset change the caller asked for must be reported when it
|
|
154
|
+
fails. `activeLoops` saves the registry back only when the kept set actually changed (a pruned
|
|
155
|
+
root, a loop newly seen finished or seen running again) — a routine poll from the Active tab must
|
|
156
|
+
never be a write.
|
|
149
157
|
|
|
150
158
|
## `js/maintain/` — lifecycle
|
|
151
159
|
|
package/js/cli-table.json
CHANGED
|
@@ -734,6 +734,19 @@
|
|
|
734
734
|
"type": null,
|
|
735
735
|
"hidden": false
|
|
736
736
|
},
|
|
737
|
+
{
|
|
738
|
+
"names": ["--contracts"],
|
|
739
|
+
"dest": "contracts",
|
|
740
|
+
"metavar": null,
|
|
741
|
+
"help": "comma-separated contract globs appended to the template's own (init)",
|
|
742
|
+
"choices": null,
|
|
743
|
+
"default": null,
|
|
744
|
+
"required": false,
|
|
745
|
+
"nargs": null,
|
|
746
|
+
"is_flag": false,
|
|
747
|
+
"type": null,
|
|
748
|
+
"hidden": false
|
|
749
|
+
},
|
|
737
750
|
{
|
|
738
751
|
"names": ["--declared"],
|
|
739
752
|
"dest": "declared",
|
|
@@ -846,7 +859,20 @@
|
|
|
846
859
|
"names": ["--note"],
|
|
847
860
|
"dest": "note",
|
|
848
861
|
"metavar": null,
|
|
849
|
-
"help": "a note attached to
|
|
862
|
+
"help": "a note attached to a record or a decide verdict, written into LOOP.md so it survives the unit",
|
|
863
|
+
"choices": null,
|
|
864
|
+
"default": null,
|
|
865
|
+
"required": false,
|
|
866
|
+
"nargs": null,
|
|
867
|
+
"is_flag": false,
|
|
868
|
+
"type": null,
|
|
869
|
+
"hidden": false
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
"names": ["--note-file"],
|
|
873
|
+
"dest": "note_file",
|
|
874
|
+
"metavar": null,
|
|
875
|
+
"help": "record's note read from this file instead of the command line (CRLF folded, trimmed) — for a multi-line note or one that mentions git commit/push",
|
|
850
876
|
"choices": null,
|
|
851
877
|
"default": null,
|
|
852
878
|
"required": false,
|
package/js/hosts/mcp.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export const MCP_PRESETS = {
|
|
|
68
68
|
+ 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
|
|
69
69
|
+ 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
|
|
70
70
|
+ 'copy this block by hand under a new name and point it at the other URL — '
|
|
71
|
-
+ 'docs/guides/mcp.md has the pattern.',
|
|
71
|
+
+ 'docs/guides/mcp-gitlab.md has the pattern.',
|
|
72
72
|
block: {
|
|
73
73
|
type: 'local',
|
|
74
74
|
command: ['npx', '-y', '@zereight/mcp-gitlab'],
|
package/js/hosts/native.mjs
CHANGED
|
@@ -149,12 +149,50 @@ function triggerOf(text) {
|
|
|
149
149
|
* 900-char cap the catalogue weighed 22k chars (~5.6k tokens) across 51 skills, three times
|
|
150
150
|
* the figure the footprint page quoted. The first sentence says WHEN; the later sentences
|
|
151
151
|
* disambiguate, and the body — read as soon as the skill is chosen — still carries them.
|
|
152
|
-
* Cut at the last sentence end inside the cap, never mid-word.
|
|
152
|
+
* Cut at the last sentence end inside the cap, never mid-word. The `…` fallback (no sentence
|
|
153
|
+
* end inside the cap) is a truncated routing signal: `checkBuild` reports it as a doctor
|
|
154
|
+
* failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
|
|
153
155
|
*/
|
|
154
156
|
const DESCRIPTION_CAP = 320;
|
|
155
157
|
function skillDescription(text) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
return withWhen(descOf(text), triggerOf(text));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `## When to dispatch` section's FIRST bullet, flattened like `triggerOf` — the agent's
|
|
163
|
+
* WHEN. Claude Code delegates on the subagent `description:` alone, and a purpose line
|
|
164
|
+
* ("Writes, runs, and diagnoses tests.") says what an agent does but never when to hand it
|
|
165
|
+
* work; the dispatch list carried that, in a body the router never reads.
|
|
166
|
+
*/
|
|
167
|
+
function dispatchOf(text) {
|
|
168
|
+
const lines = text.split('\n');
|
|
169
|
+
const start = lines.findIndex((ln) => /^##\s+When to dispatch\b/.test(ln.trim()));
|
|
170
|
+
if (start < 0) return '';
|
|
171
|
+
const para = [];
|
|
172
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
173
|
+
const s = lines[i].trim();
|
|
174
|
+
if (!para.length) {
|
|
175
|
+
if (s.startsWith('- ')) para.push(s.slice(2));
|
|
176
|
+
else if (s.startsWith('#')) break;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (s === '' || s.startsWith('- ') || s.startsWith('#')) break;
|
|
180
|
+
para.push(s);
|
|
181
|
+
}
|
|
182
|
+
return para.join(' ')
|
|
183
|
+
.replace(HTML_COMMENT_RE, '')
|
|
184
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
185
|
+
.replace(/\s+/g, ' ')
|
|
186
|
+
.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The emitted `description:` of an agent — purpose + its first dispatch condition. */
|
|
190
|
+
export function agentDescription(text) {
|
|
191
|
+
return withWhen(descOf(text), dispatchOf(text));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
|
|
195
|
+
function withWhen(desc, trig) {
|
|
158
196
|
if (!trig) return desc;
|
|
159
197
|
const first = trig.match(/^.*?[.;](?=\s|$)/);
|
|
160
198
|
const full = `${desc} Use when: ${first ? first[0] : trig}`;
|
|
@@ -302,7 +340,7 @@ const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
|
|
|
302
340
|
* Bash keeps the unmarked line byte-identical to what it was before the webfetch marker existed.
|
|
303
341
|
*/
|
|
304
342
|
function claudeAgentFrontmatter(stem, text, overrides) {
|
|
305
|
-
const fm = [`name: ${stem}`, `description: ${jsonDumps(
|
|
343
|
+
const fm = [`name: ${stem}`, `description: ${jsonDumps(agentDescription(text))}`];
|
|
306
344
|
const ov = agentOverride(overrides, stem);
|
|
307
345
|
if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
|
|
308
346
|
if (isReadonly(text)) {
|
|
@@ -316,7 +354,7 @@ function claudeAgentFrontmatter(stem, text, overrides) {
|
|
|
316
354
|
|
|
317
355
|
/** `_build_emit._opencode_agent_frontmatter`. */
|
|
318
356
|
function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
|
|
319
|
-
const fm = [`description: ${jsonDumps(
|
|
357
|
+
const fm = [`description: ${jsonDumps(agentDescription(text))}`, 'mode: subagent'];
|
|
320
358
|
fm.push(`color: ${agentColor(stem, theme)}`);
|
|
321
359
|
pushOverrideLines(fm, agentOverride(overrides, stem));
|
|
322
360
|
if (isReadonly(text)) {
|
|
@@ -81,6 +81,34 @@ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
|
|
|
81
81
|
problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
|
|
82
82
|
}
|
|
83
83
|
for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
|
|
84
|
+
for (const p of descriptionProblems(text, rel)) problems.push(`[${themeName}] ${p}`);
|
|
85
|
+
}
|
|
86
|
+
return problems;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The `description:` of an emitted native skill or agent, held to Anthropic's skill-authoring
|
|
91
|
+
* rules — it is the ONLY text a host routes on, so a defect here is a skill that silently
|
|
92
|
+
* never fires. Three arms: a `…` ending is `withWhen`'s mid-sentence fallback (the trigger
|
|
93
|
+
* was cut, cure it with a shorter first trigger sentence); an XML-looking tag is rejected
|
|
94
|
+
* outright by the Agent Skills spec; a first/second-person pronoun is the point-of-view drift
|
|
95
|
+
* the spec warns degrades discovery, since the text is injected into the system prompt.
|
|
96
|
+
* The pronoun arm reads only the `Use when:` half, and skips quoted user phrases ("teach me
|
|
97
|
+
* X" is what the user types, not the description's voice): the purpose half is the theme's
|
|
98
|
+
* persona copy ("Make your own OpenCode theme"), authored per theme on purpose.
|
|
99
|
+
* Files without frontmatter (every bundle `.md`, AGENT.md) carry no description and pass.
|
|
100
|
+
*/
|
|
101
|
+
const DESC_LINE_RE = /^---\n(?:.*\n)*?description: (".*")\n(?:.*\n)*?---\n/;
|
|
102
|
+
export function descriptionProblems(text, rel) {
|
|
103
|
+
const m = text.match(DESC_LINE_RE);
|
|
104
|
+
if (!m) return [];
|
|
105
|
+
const desc = parseJson(m[1]);
|
|
106
|
+
const problems = [];
|
|
107
|
+
if (desc.endsWith('…')) problems.push(`description cut mid-sentence in ${rel} — shorten the first trigger sentence`);
|
|
108
|
+
if (/<\/?[A-Za-z][^>]*>/.test(desc)) problems.push(`description carries an XML-like tag in ${rel}`);
|
|
109
|
+
const when = desc.split(' Use when: ')[1] ?? '';
|
|
110
|
+
if (/\b(I|I'm|me|my|you|you're|your)\b/i.test(when.replace(/"[^"]*"|\bI\/O\b/g, ''))) {
|
|
111
|
+
problems.push(`description's "Use when" is not in the third person in ${rel}`);
|
|
84
112
|
}
|
|
85
113
|
return problems;
|
|
86
114
|
}
|
package/js/loop/catalog.mjs
CHANGED
|
@@ -47,8 +47,19 @@ export function parseBrick(text, origin) {
|
|
|
47
47
|
if (!agent === !skill) problems.push(`${name}: exactly one of agent or skill`);
|
|
48
48
|
const outcomes = get('outcomes').split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
49
|
if (!outcomes.length) problems.push(`${name}: outcomes is empty`);
|
|
50
|
+
// `gate: human` holds the transition after this brick until the user answers (state.mjs).
|
|
51
|
+
// Present on the brick only when declared, so an ungated brick's shape is unchanged.
|
|
52
|
+
const gate = get('gate');
|
|
53
|
+
if (gate && gate !== 'human') problems.push(`${name}: gate must be human, not ${JSON.stringify(gate)}`);
|
|
54
|
+
// `gateOn` narrows the gate to the outcomes it lists; any other outcome follows its edge ungated.
|
|
55
|
+
const gateOn = get('gateOn').split(',').map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
if (gateOn.length && gate !== 'human') problems.push(`${name}: gateOn needs gate: human`);
|
|
57
|
+
for (const o of gateOn) if (!outcomes.includes(o)) problems.push(`${name}: gateOn ${o} is not one of its outcomes`);
|
|
50
58
|
return {
|
|
51
|
-
brick: {
|
|
59
|
+
brick: {
|
|
60
|
+
name, description, effect, agent, skill, outcomes, ...(gate ? { gate } : {}),
|
|
61
|
+
...(gateOn.length ? { gateOn } : {}), body: body.trim(), origin,
|
|
62
|
+
},
|
|
52
63
|
problems,
|
|
53
64
|
};
|
|
54
65
|
}
|
package/js/loop/cli.mjs
CHANGED
|
@@ -22,8 +22,9 @@ import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
|
|
|
22
22
|
import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
|
|
23
23
|
import {
|
|
24
24
|
initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
|
|
25
|
-
|
|
25
|
+
writeLoopFile, parseLoopFile, LOOP_FILE,
|
|
26
26
|
} from './state.mjs';
|
|
27
|
+
import { recordLoop } from './registry.mjs';
|
|
27
28
|
|
|
28
29
|
const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
|
|
29
30
|
const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
|
|
@@ -64,7 +65,7 @@ function withState(fn) {
|
|
|
64
65
|
const state = parseLoopFile(readText(file));
|
|
65
66
|
const { bricks } = loadCatalog({ projectRoot: root });
|
|
66
67
|
const result = fn(state, bricks);
|
|
67
|
-
|
|
68
|
+
writeLoopFile(file, state);
|
|
68
69
|
return result;
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -110,8 +111,14 @@ const ACTIONS = {
|
|
|
110
111
|
const graph = resolveGraph(args.graph, catalog);
|
|
111
112
|
const problems = checkGraph(graph, catalog.bricks);
|
|
112
113
|
if (problems.length) return { error: 'the graph does not pass loop check', problems };
|
|
113
|
-
const state = initState({
|
|
114
|
-
|
|
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 });
|
|
115
122
|
return { file, graph: graph.name, preset: state.preset, status: state.status };
|
|
116
123
|
},
|
|
117
124
|
next: () => withState((s, b) => nextStep(s, b)),
|
|
@@ -130,9 +137,24 @@ const ACTIONS = {
|
|
|
130
137
|
},
|
|
131
138
|
record(args) {
|
|
132
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
|
+
}
|
|
133
153
|
const card = args.card ? JSON.parse(args.card) : null;
|
|
134
|
-
const
|
|
135
|
-
return withState((s, b) => recordOutcome(s, b, args.outcome, {
|
|
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
|
+
}));
|
|
136
158
|
},
|
|
137
159
|
decide(args) {
|
|
138
160
|
// X6: same ordering as `score --diff` above.
|
package/js/loop/graph.mjs
CHANGED
|
@@ -23,6 +23,8 @@ import { WEIGHTS } from './score.mjs';
|
|
|
23
23
|
|
|
24
24
|
export 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,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
|
+
}
|