geneseed 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/adapters/claude-code/README.md +0 -21
  5. package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
  6. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  7. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  8. package/adapters/opencode/plugins/geneseed-guard.js +64 -1
  9. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  10. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  11. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  12. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  13. package/docs/guides/mcp-filesystem.md +2 -2
  14. package/docs/guides/mcp-gitlab.md +2 -2
  15. package/docs/guides/mcp-markitdown.md +3 -3
  16. package/docs/guides/mcp.md +7 -7
  17. package/docs/guides/project-context.md +1 -1
  18. package/docs/guides/uninstall.md +2 -0
  19. package/docs/guides/upgrade.md +2 -0
  20. package/docs/reference/opencode-plugins.md +9 -0
  21. package/docs/token-footprint.md +1 -1
  22. package/docs/understand/take-it-out.md +1 -1
  23. package/harness.config.json +1 -1
  24. package/js/README.md +6 -0
  25. package/js/build/driver.mjs +47 -11
  26. package/js/build/emit-claude.mjs +22 -10
  27. package/js/build/emit-common.mjs +9 -3
  28. package/js/build/emit-opencode.mjs +18 -11
  29. package/js/build/source.mjs +1 -0
  30. package/js/build/version.mjs +10 -3
  31. package/js/cli-table.json +16 -1
  32. package/js/hosts/hooks.mjs +4 -0
  33. package/js/hosts/installs.mjs +1 -1
  34. package/js/hosts/mcp.mjs +23 -152
  35. package/js/hosts/native.mjs +38 -6
  36. package/js/hosts/opencode.mjs +1 -1
  37. package/js/hosts/owned.mjs +159 -0
  38. package/js/hosts/settings.mjs +4 -0
  39. package/js/hosts/usage.mjs +62 -0
  40. package/js/inspect/checks-repo.mjs +76 -1
  41. package/js/inspect/cost.mjs +83 -0
  42. package/js/inspect/doctor.mjs +2 -1
  43. package/js/inspect/inventory.mjs +1 -1
  44. package/js/maintain/uninstall.mjs +77 -42
  45. package/js/web/actions.mjs +15 -77
  46. package/js/web/api.mjs +74 -4
  47. package/js/web/gates-api.mjs +44 -0
  48. package/js/web/handler.mjs +2 -2
  49. package/js/web/routes.mjs +9 -4
  50. package/js/web/update-check.mjs +61 -0
  51. package/js/web/usage-api.mjs +95 -0
  52. package/package.json +1 -1
  53. package/registry.json +18 -25
  54. package/src/AGENT.md.tmpl +0 -1
  55. package/src/agents/_template.md +2 -1
  56. package/src/agents/researcher.md +2 -1
  57. package/src/agents/reviewer.md +9 -4
  58. package/src/agents/tester.md +3 -3
  59. package/src/doctrines/rigor.md +8 -7
  60. package/src/skills/brainstorm/builder-check.md +16 -0
  61. package/src/skills/brainstorm.md +19 -11
  62. package/src/skills/daydream/SKILL.md +1 -1
  63. package/src/skills/daydream/VENDOR.md +4 -0
  64. package/src/skills/debug/ci.md +4 -4
  65. package/src/skills/debug.md +4 -4
  66. package/src/skills/develop.md +15 -4
  67. package/src/skills/fresh-eyes.md +1 -1
  68. package/src/skills/geneseed-code-review.md +13 -3
  69. package/src/skills/handoff.md +1 -1
  70. package/src/skills/parallel-agents.md +4 -4
  71. package/src/skills/pipeline.md +3 -3
  72. package/src/skills/plan.md +18 -6
  73. package/src/skills/review-response.md +5 -2
  74. package/src/skills/security-audit.md +7 -1
  75. package/src/skills/ship.md +2 -1
  76. package/src/skills/wayfinder.md +25 -6
  77. package/src/skills/worktree.md +7 -5
  78. package/themes/_TEMPLATE.json +0 -1
  79. package/themes/biker.json +0 -1
  80. package/themes/commentator.json +0 -1
  81. package/themes/cyberpunk.json +0 -1
  82. package/themes/gamer.json +0 -1
  83. package/themes/imperial.json +0 -1
  84. package/themes/joker.json +0 -1
  85. package/themes/marvin.json +0 -1
  86. package/themes/mean.json +0 -1
  87. package/themes/military.json +0 -1
  88. package/themes/neutral.json +0 -1
  89. package/themes/pirate.json +0 -1
  90. package/themes/sports.json +0 -1
  91. package/themes/verstappen.json +0 -1
  92. package/themes/wizard.json +0 -1
  93. package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
  94. package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
  95. package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
  96. package/web/dist/assets/Docs-CD20mrVv.js +5 -0
  97. package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
  98. package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
  99. package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
  100. package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
  101. package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
  102. package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
  103. package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
  104. package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
  105. package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
  106. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  107. package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
  108. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  109. package/web/dist/assets/index-DoD_g2CM.js +2 -0
  110. package/web/dist/index.html +3 -3
  111. package/src/skills/herdr.md +0 -154
  112. package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
  113. package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
  114. package/web/dist/assets/api-CxgfYu18.js +0 -1
  115. package/web/dist/assets/index-CiXwChvf.js +0 -2
package/js/web/api.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The web console's dashboard and settings reads — `overview`, `recent`, `themes`, `setup`,
3
- * `doctor`, `diff`, `installs`, `excludes` and the two `loops` reads.
3
+ * `doctor`, `diff`, `installs`, `fleet`, `update`, `excludes` and the two `loops` reads.
4
4
  *
5
5
  * WHAT IS NOT HERE, AND WHERE IT WENT: the Library read model (catalog sections, items, wiki,
6
6
  * memory, `NotFound`) is `catalog.mjs`; the `WebState` every endpoint reads from is `state.mjs`;
@@ -11,12 +11,14 @@
11
11
  import { statSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
 
14
- import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
14
+ import { CONFIG, ROOT, THEMES, discoverNames, makeCfg } from '../build/source.mjs';
15
+ import { readVersion, sourceFingerprint } from '../build/version.mjs';
15
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
+ import { alwaysOnCost } from '../inspect/cost.mjs';
16
18
  import { diffCollect } from '../inspect/diff.mjs';
17
19
  import { doctorCollect } from '../inspect/doctor.mjs';
18
20
  import { excludesSnapshot } from '../inspect/excludes.mjs';
19
- import { CLAUDE_STYLE, HOSTS, resolvePath } from '../hosts/hosts.mjs';
21
+ import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
20
22
  import {
21
23
  footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
22
24
  postureOfDir, readJsonMaybe, themeOfDir,
@@ -32,6 +34,7 @@ import {
32
34
  configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
33
35
  } from './catalog.mjs';
34
36
  import { stampMinute } from './state.mjs';
37
+ import { updateStatus } from './update-check.mjs';
35
38
 
36
39
  /**
37
40
  * `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
@@ -113,11 +116,18 @@ export function viewCfg(host, scope, root) {
113
116
  return root;
114
117
  }
115
118
 
119
+ /**
120
+ * The row `id` carries the ROOT, not only `host:scope`: two project installs of one host (two
121
+ * repos on Bob, say) are both `bob:project`, and the console keys its selection, its React rows
122
+ * and its per-install picks on this id — with a shared one, both rows read as selected and every
123
+ * pick resolved to whichever came first. `installTargets()` de-duplicates on (host, root), so
124
+ * this is unique by construction.
125
+ */
116
126
  export function apiInstalls(state) {
117
127
  const out = [];
118
128
  for (const [host, scope, root] of installTargets()) {
119
129
  out.push({
120
- id: `${host}:${scope}`, host, scope, path: root,
130
+ id: `${host}:${scope}:${root}`, host, scope, path: root,
121
131
  state: installState(root, host, scope),
122
132
  // Narrowed to this row's own host (host-compat B1): a shared repo otherwise answers
123
133
  // every row with whichever host's carrier the host-agnostic scan favours.
@@ -134,6 +144,63 @@ export function apiInstalls(state) {
134
144
  }
135
145
 
136
146
 
147
+ /**
148
+ * EVERY INSTALLED HARNESS ON THE MACHINE, EACH JUDGED ON ITS OWN — the Overview's fleet table.
149
+ *
150
+ * Installed means `active` or `disabled`: a host with nothing installed is not listed, because
151
+ * installing belongs to the Installs page with its other settings (user, 2026-10-13).
152
+ *
153
+ * Its own route, not more fields on `/api/installs`: the sidebar refetches that list on every
154
+ * data revision, and the drift check below re-renders a whole emit per global install.
155
+ * ponytail: one `diffCollect` render per global install per request, fine for the handful a
156
+ * machine carries; cache per (root, build mtime) if a fleet ever grows past that.
157
+ *
158
+ * Each row reads ITS OWN `.geneseed-version`. `statusData().installed_fp` is the first carrier
159
+ * a fixed walk finds, which describes the wrong install as soon as two hosts are deployed.
160
+ * Every unknown stays `null` — `verdict: 'unknown'`, `drift: null` — never a reassuring zero.
161
+ */
162
+ export function apiFleet(state) {
163
+ const sourceFp = sourceFingerprint(makeCfg());
164
+ const installs = [];
165
+ for (const [host, scope, root] of installTargets()) {
166
+ const st = installState(root, host, scope);
167
+ if (st !== 'active' && st !== 'disabled') continue;
168
+ const dir = viewCfg(host, scope, root);
169
+ const fp = readVersion(dir);
170
+ let drift = null;
171
+ if (st === 'active') {
172
+ try {
173
+ const { files } = diffCollect({ target: dir });
174
+ if (files !== null) {
175
+ drift = {
176
+ edited: files.filter((f) => f.status === 'edited').length,
177
+ added: files.filter((f) => f.status === 'added').length,
178
+ missing: files.filter((f) => f.status === 'missing').length,
179
+ };
180
+ }
181
+ } catch { /* an install that cannot be re-rendered reports no drift figure, not zero */ }
182
+ }
183
+ let built = null;
184
+ try { built = Math.floor(statSync(path.join(dir, VERSION_MARKER)).mtimeMs / 1000); } catch { /* never built */ }
185
+ installs.push({
186
+ id: `${host}:${scope}:${root}`, host, scope, path: root, state: st,
187
+ theme: themeOfDir(root, host),
188
+ footprint: footprintOfDir(root),
189
+ installed_fp: fp,
190
+ verdict: fp === null ? 'unknown' : fp === sourceFp ? 'synced' : 'behind',
191
+ // A project install has no drift check at all (`diffCollect` answers `files: null`), which
192
+ // the console must say as "not checkable" — so the fact travels apart from the figure.
193
+ diffable: drift !== null,
194
+ drift,
195
+ built_epoch: built,
196
+ // What this install injects every session, by part (characters); null when its carrier
197
+ // is missing, so the console says "not measured" rather than drawing an empty bar.
198
+ cost: alwaysOnCost({ host, root, dir, footprint: footprintOfDir(root) }),
199
+ });
200
+ }
201
+ return { installs, source_fp: sourceFp, doctor: state.doctor };
202
+ }
203
+
137
204
  /** The dashboard aggregate. */
138
205
  export function apiOverview(state) {
139
206
  const inv = state.inventory;
@@ -347,6 +414,9 @@ export function apiLoopsActive() {
347
414
  }
348
415
 
349
416
  /** The source's release label, or null when it cannot be read. */
417
+ /** Is a newer release on npm than the source this console serves? See update-check.mjs. */
418
+ export const apiUpdate = (state) => updateStatus(state, releaseLabel());
419
+
350
420
  function releaseLabel() {
351
421
  const v = sourceReleaseVersion({ config: CONFIG });
352
422
  return v && v !== '0.0.0' ? v : null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GET /api/gates — gate asks per rule per day over the last 30 days, across every installed
3
+ * harness: the Overview's "Gates" sparklines. The ledger (`notebook/gates.jsonl`, written by
4
+ * js/hosts/hooks.mjs and the OpenCode guard plugin) already stamps each ask with an ISO `ts`
5
+ * and a `rule` (`law-1`, `process-1`, `wiki`…); this only reads it. `gateSummary` keeps its
6
+ * count-only shape for the CLI's status panel.
7
+ *
8
+ * `{ days: ['YYYY-MM-DD' × 30], rules: [{ rule, n, per_day[30] }] }`, busiest rule first.
9
+ * Garbage lines are skipped; nothing here writes (the writers already trim at 1000 lines).
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { GATE_LEDGER } from '../hosts/hosts.mjs';
15
+ import { installState, installTargets } from '../hosts/installs.mjs';
16
+ import { viewCfg } from './api.mjs';
17
+
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ const WINDOW = 30;
20
+
21
+ export function gateDays({ now = Date.now(), targets = installTargets() } = {}) {
22
+ const days = Array.from({ length: WINDOW }, (_, i) =>
23
+ new Date(now - (WINDOW - 1 - i) * DAY_MS).toISOString().slice(0, 10));
24
+ const index = new Map(days.map((d, i) => [d, i]));
25
+ const by = new Map();
26
+ for (const [host, scope, root] of targets) {
27
+ if (installState(root, host, scope) !== 'active') continue;
28
+ let raw;
29
+ try { raw = readFileSync(path.join(viewCfg(host, scope, root), GATE_LEDGER), 'utf8'); } catch { continue; }
30
+ for (const line of raw.split('\n')) {
31
+ let e;
32
+ try { e = JSON.parse(line); } catch { continue; }
33
+ const i = typeof e?.ts === 'string' ? index.get(e.ts.slice(0, 10)) : undefined;
34
+ if (i === undefined || typeof e.rule !== 'string') continue;
35
+ const row = by.get(e.rule) || { rule: e.rule, n: 0, per_day: new Array(WINDOW).fill(0) };
36
+ row.n += 1;
37
+ row.per_day[i] += 1;
38
+ by.set(e.rule, row);
39
+ }
40
+ }
41
+ return { days, rules: [...by.values()].sort((a, b) => b.n - a.n || a.rule.localeCompare(b.rule)) };
42
+ }
43
+
44
+ export const apiGates = () => gateDays();
@@ -371,8 +371,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
371
371
  try {
372
372
  return doPost(req, res, path, ae, buf);
373
373
  } catch (e) {
374
- // `apiMcpToggle`, `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and
375
- // `apiRulesPromote` all raise `NotFound` for an unknown target, mapped to 404
374
+ // `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and `apiRulesPromote` all
375
+ // raise `NotFound` for an unknown target, mapped to 404
376
376
  // here; anything else falls to the 500 arm below, carrying its own message.
377
377
  if (e instanceof NotFound) {
378
378
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
package/js/web/routes.mjs CHANGED
@@ -18,13 +18,15 @@
18
18
  */
19
19
  import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
20
20
  import { percentDecode } from '../lib/text.mjs';
21
- import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMcpToggle, apiMemoryDelete, apiSelectView } from './actions.mjs';
21
+ import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMemoryDelete, apiSelectView } from './actions.mjs';
22
22
  import { apiActivity, apiActivityDetail, apiActivityToggle } from './activity.mjs';
23
23
  import {
24
- apiDiff, apiDoctor, apiExcludes, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
25
- apiRecent, apiSetup, apiThemes,
24
+ apiDiff, apiDoctor, apiExcludes, apiFleet, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
25
+ apiRecent, apiSetup, apiThemes, apiUpdate,
26
26
  } from './api.mjs';
27
27
  import { NotFound, apiCatalog, apiItem } from './catalog.mjs';
28
+ import { apiUsage } from './usage-api.mjs';
29
+ import { apiGates } from './gates-api.mjs';
28
30
  import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote } from './user-files.mjs';
29
31
 
30
32
  /**
@@ -43,7 +45,6 @@ import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote }
43
45
  */
44
46
  /** @type {Map<string, [(state: any, body: any) => any, boolean]>} */
45
47
  export const POST_ROUTES = new Map([
46
- ['/api/mcp', [apiMcpToggle, true]],
47
48
  ['/api/install', [apiInstallToggle, true]],
48
49
  ['/api/excludes', [apiExcludesMutate, true]],
49
50
  ['/api/view', [apiSelectView, true]],
@@ -147,6 +148,10 @@ export const STATE_ROUTES = {
147
148
  '/api/setup': apiSetup,
148
149
  '/api/doctor': apiDoctor,
149
150
  '/api/installs': apiInstalls,
151
+ '/api/fleet': apiFleet,
152
+ '/api/update': apiUpdate,
153
+ '/api/usage': apiUsage,
154
+ '/api/gates': apiGates,
150
155
  '/api/excludes': apiExcludes,
151
156
  '/api/profile': apiProfile,
152
157
  '/api/diff': apiDiff,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * IS A NEWER GENESEED RELEASED? — the Overview's "4.2.0 available" chip.
3
+ *
4
+ * Route handlers answer synchronously, so this never waits on the network: it answers from the
5
+ * last result it holds and, when that is missing or a day old, starts ONE background fetch whose
6
+ * answer the next poll reads. The console refetches after every mutation and on mount, so the
7
+ * chip appears within a poll or two of the daemon starting.
8
+ *
9
+ * Every failure is `latest: null` and says nothing more: a corporate proxy, an offline laptop and
10
+ * a registry outage all read "update check unavailable", never an error. `GENESEED_NO_UPDATE_CHECK`
11
+ * turns the fetch off for a machine that must not phone out at all.
12
+ *
13
+ * ponytail: the cache lives on the daemon's state, in memory. A restart costs one more request;
14
+ * a disk cache would be a file to version for a figure that is free to re-ask.
15
+ */
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ /** A failed ask is retried sooner than a good answer is refreshed: a proxy hiccup is not a day. */
18
+ const RETRY_MS = 60 * 60 * 1000;
19
+ const REGISTRY = 'https://registry.npmjs.org/geneseed/latest';
20
+
21
+ /** x.y.z only; a pre-release tag or a malformed label compares as nothing newer. */
22
+ export function semverGreater(a, b) {
23
+ const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '').trim()) || []).slice(1).map(Number);
24
+ const x = parse(a);
25
+ const y = parse(b);
26
+ if (x.length !== 3 || y.length !== 3) return false;
27
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
28
+ return false;
29
+ }
30
+
31
+ async function npmLatest() {
32
+ const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(3000) });
33
+ if (!res.ok) return null;
34
+ const body = await res.json();
35
+ return typeof body?.version === 'string' ? body.version : null;
36
+ }
37
+
38
+ /**
39
+ * `{ current, latest, available, checked_at }`. `available` is true only when `latest` is
40
+ * semver-greater than `current`, so a checkout ahead of the last release never shows one;
41
+ * null when either side is unknown. `fetcher` and `now` are injectable for the tests, which
42
+ * must never reach the network.
43
+ */
44
+ export function updateStatus(state, current, { fetcher = npmLatest, now = Date.now() } = {}) {
45
+ const c = state._update || (state._update = { latest: null, checked_at: null, inflight: false });
46
+ const stale = c.checked_at === null
47
+ || now - c.checked_at > (c.latest === null ? RETRY_MS : DAY_MS);
48
+ // Under `node --test` (NODE_TEST_CONTEXT) the real registry is never asked: the route probes in
49
+ // tests/unit/web_server.test.mjs reach this handler, and a suite must not depend on the network.
50
+ const off = process.env.GENESEED_NO_UPDATE_CHECK
51
+ || (fetcher === npmLatest && process.env.NODE_TEST_CONTEXT);
52
+ if (stale && !c.inflight && !off) {
53
+ c.inflight = true;
54
+ Promise.resolve()
55
+ .then(fetcher)
56
+ .then((v) => { c.latest = v ?? null; }, () => { c.latest = null; })
57
+ .finally(() => { c.checked_at = now; c.inflight = false; });
58
+ }
59
+ const available = current && c.latest ? semverGreater(c.latest, current) : null;
60
+ return { current: current ?? null, latest: c.latest, available, checked_at: c.checked_at };
61
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * GET /api/usage — the Overview's "What fires" chart, read off every installed harness's
3
+ * `notebook/usage.jsonl` (the writer is js/hosts/usage.mjs, and the OpenCode guard plugin's copy).
4
+ *
5
+ * `{ enabled, since, days_counted, per_day[30], rows[{install, kind, name, n}],
6
+ * never[{install, kind, name}] }`, the window being the last 30 days including today.
7
+ *
8
+ * `since` is the oldest day any ledger holds and `days_counted` how many of the 30 it covers:
9
+ * the console keeps the never-fired claim back until a full 30 are counted, so a fresh install
10
+ * is not told every skill is unused. `never` lists installed skills and agents with no call in
11
+ * the window, alias stubs included (an alias that never fires is exactly what to prune).
12
+ *
13
+ * Lines older than 90 days are dropped from the ledger when it is read — the daemon prunes, so
14
+ * the hook path only ever appends. Garbage lines are skipped, never fatal.
15
+ */
16
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import { USAGE_LEDGER, USAGE_OFF } from '../hosts/usage.mjs';
20
+ import { installState, installTargets } from '../hosts/installs.mjs';
21
+ import { viewCfg } from './api.mjs';
22
+
23
+ const DAY_MS = 24 * 60 * 60 * 1000;
24
+ const WINDOW = 30;
25
+ const KEEP_DAYS = 90;
26
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
27
+
28
+ const listOr = (dir) => { try { return readdirSync(dir); } catch { return []; } };
29
+
30
+ /** The ledger's parsed lines, pruned to `KEEP_DAYS` (rewritten only when something was dropped). */
31
+ function readLedger(file, cutoff) {
32
+ let raw;
33
+ try { raw = readFileSync(file, 'utf8'); } catch { return []; }
34
+ const lines = raw.split('\n').filter(Boolean);
35
+ const kept = [];
36
+ const out = [];
37
+ for (const line of lines) {
38
+ let e;
39
+ try { e = JSON.parse(line); } catch { continue; }
40
+ if (!e || typeof e.ts !== 'string' || typeof e.name !== 'string'
41
+ || (e.kind !== 'skill' && e.kind !== 'agent')) continue;
42
+ if (e.ts < cutoff) continue;
43
+ kept.push(line);
44
+ out.push(e);
45
+ }
46
+ if (kept.length !== lines.length) {
47
+ try { writeFileSync(file, kept.length ? `${kept.join('\n')}\n` : ''); } catch { /* read-only: keep serving */ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export function usageSummary({ now = Date.now(), targets = installTargets() } = {}) {
53
+ const days = Array.from({ length: WINDOW }, (_, i) => day(now - (WINDOW - 1 - i) * DAY_MS));
54
+ const first = days[0];
55
+ const perDay = new Array(WINDOW).fill(0);
56
+ const counts = new Map();
57
+ const never = [];
58
+ let since = null;
59
+ let enabled = !process.env.GENESEED_NO_USAGE;
60
+ for (const [host, scope, root] of targets) {
61
+ if (installState(root, host, scope) !== 'active') continue;
62
+ const dir = viewCfg(host, scope, root);
63
+ const install = `${host}:${scope}:${root}`;
64
+ if (existsSync(path.join(dir, USAGE_OFF))) enabled = false;
65
+ const entries = readLedger(path.join(dir, USAGE_LEDGER), day(now - KEEP_DAYS * DAY_MS));
66
+ const fired = new Set();
67
+ for (const e of entries) {
68
+ if (since === null || e.ts < since) since = e.ts;
69
+ if (e.ts < first) continue;
70
+ const i = days.indexOf(e.ts);
71
+ if (i >= 0) perDay[i] += 1;
72
+ const key = `${install}\u0000${e.kind}\u0000${e.name}`;
73
+ counts.set(key, (counts.get(key) || 0) + 1);
74
+ fired.add(`${e.kind}\u0000${e.name}`);
75
+ }
76
+ for (const name of listOr(path.join(dir, 'skills'))) {
77
+ if (existsSync(path.join(dir, 'skills', name, 'SKILL.md')) && !fired.has(`skill\u0000${name}`)) {
78
+ never.push({ install, kind: 'skill', name });
79
+ }
80
+ }
81
+ for (const f of listOr(path.join(dir, 'agents'))) {
82
+ const name = f.endsWith('.md') ? f.slice(0, -3) : null;
83
+ if (name && !fired.has(`agent\u0000${name}`)) never.push({ install, kind: 'agent', name });
84
+ }
85
+ }
86
+ const rows = [...counts].map(([key, n]) => {
87
+ const [install, kind, name] = key.split('\u0000');
88
+ return { install, kind, name, n };
89
+ }).sort((a, b) => b.n - a.n || a.name.localeCompare(b.name));
90
+ const daysCounted = since === null ? 0
91
+ : Math.min(WINDOW, Math.floor((Date.parse(day(now)) - Date.parse(since)) / DAY_MS) + 1);
92
+ return { enabled, since, days_counted: daysCounted, per_day: perDay, rows, never };
93
+ }
94
+
95
+ export const apiUsage = () => usageSummary();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.0.0",
4
+ "version": "4.2.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/registry.json CHANGED
@@ -72,11 +72,11 @@
72
72
  "last_verified": ""
73
73
  },
74
74
  "agents/researcher": {
75
- "status": "experimental",
75
+ "status": "approved",
76
76
  "version": "1.0.0",
77
77
  "owner": "Guillaume Lemer",
78
78
  "added": "2026-09-06",
79
- "last_verified": ""
79
+ "last_verified": "2026-10-13"
80
80
  },
81
81
  "agents/reviewer": {
82
82
  "status": "approved",
@@ -184,11 +184,11 @@
184
184
  "last_verified": "2026-09-06"
185
185
  },
186
186
  "skills/dependencies": {
187
- "status": "experimental",
187
+ "status": "approved",
188
188
  "version": "1.0.0",
189
189
  "owner": "Guillaume Lemer",
190
190
  "added": "2026-10-08",
191
- "last_verified": ""
191
+ "last_verified": "2026-10-13"
192
192
  },
193
193
  "skills/develop": {
194
194
  "status": "approved",
@@ -205,11 +205,11 @@
205
205
  "last_verified": "2026-09-06"
206
206
  },
207
207
  "skills/explain-changes": {
208
- "status": "experimental",
208
+ "status": "approved",
209
209
  "version": "1.0.0",
210
210
  "owner": "Guillaume Lemer",
211
211
  "added": "2026-09-30",
212
- "last_verified": ""
212
+ "last_verified": "2026-10-13"
213
213
  },
214
214
  "skills/forge-mcp": {
215
215
  "status": "approved",
@@ -247,11 +247,11 @@
247
247
  "last_verified": "2026-09-06"
248
248
  },
249
249
  "skills/git-history": {
250
- "status": "experimental",
250
+ "status": "approved",
251
251
  "version": "1.0.0",
252
252
  "owner": "Guillaume Lemer",
253
253
  "added": "2026-10-08",
254
- "last_verified": ""
254
+ "last_verified": "2026-10-13"
255
255
  },
256
256
  "skills/handoff": {
257
257
  "status": "approved",
@@ -260,13 +260,6 @@
260
260
  "added": "2026-06-03",
261
261
  "last_verified": "2026-09-06"
262
262
  },
263
- "skills/herdr": {
264
- "status": "approved",
265
- "version": "1.0.0",
266
- "owner": "Guillaume Lemer",
267
- "added": "2026-06-13",
268
- "last_verified": "2026-09-06"
269
- },
270
263
  "skills/ingest": {
271
264
  "status": "approved",
272
265
  "version": "1.0.0",
@@ -387,18 +380,18 @@
387
380
  "last_verified": "2026-09-06"
388
381
  },
389
382
  "skills/rule": {
390
- "status": "experimental",
383
+ "status": "approved",
391
384
  "version": "1.0.0",
392
385
  "owner": "Guillaume Lemer",
393
386
  "added": "2026-07-07",
394
- "last_verified": ""
387
+ "last_verified": "2026-10-13"
395
388
  },
396
389
  "skills/security-audit": {
397
- "status": "experimental",
390
+ "status": "approved",
398
391
  "version": "1.0.0",
399
392
  "owner": "Guillaume Lemer",
400
393
  "added": "2026-09-06",
401
- "last_verified": ""
394
+ "last_verified": "2026-10-13"
402
395
  },
403
396
  "skills/ship": {
404
397
  "status": "approved",
@@ -408,11 +401,11 @@
408
401
  "last_verified": "2026-09-06"
409
402
  },
410
403
  "skills/skill-forge": {
411
- "status": "experimental",
404
+ "status": "approved",
412
405
  "version": "1.0.0",
413
406
  "owner": "Guillaume Lemer",
414
407
  "added": "2026-09-06",
415
- "last_verified": ""
408
+ "last_verified": "2026-10-13"
416
409
  },
417
410
  "skills/teach": {
418
411
  "status": "experimental",
@@ -422,11 +415,11 @@
422
415
  "last_verified": ""
423
416
  },
424
417
  "skills/token-report": {
425
- "status": "experimental",
418
+ "status": "approved",
426
419
  "version": "1.0.0",
427
420
  "owner": "Guillaume Lemer",
428
421
  "added": "2026-07-29",
429
- "last_verified": ""
422
+ "last_verified": "2026-10-13"
430
423
  },
431
424
  "skills/wayfinder": {
432
425
  "status": "approved",
@@ -450,11 +443,11 @@
450
443
  "last_verified": "2026-09-06"
451
444
  },
452
445
  "skills/worktree": {
453
- "status": "experimental",
446
+ "status": "approved",
454
447
  "version": "1.0.0",
455
448
  "owner": "Guillaume Lemer",
456
449
  "added": "2026-09-06",
457
- "last_verified": ""
450
+ "last_verified": "2026-10-13"
458
451
  }
459
452
  }
460
453
  }
package/src/AGENT.md.tmpl CHANGED
@@ -262,7 +262,6 @@ the ten council seats are convened by the [council {{SKILL}}]({{DIR_SKILLS}}/cou
262
262
  | [loop]({{DIR_SKILLS}}/loop.md) | the user asks to run a loop |
263
263
  | [brick-forge]({{DIR_SKILLS}}/brick-forge.md) | a new or adapted loop brick |
264
264
  | [wiki]({{DIR_SKILLS}}/wiki.md) | consulting or growing the user's machine-wide knowledge base declared in `geneseed-wiki.jsonc` (§8) |
265
- | [herdr]({{DIR_SKILLS}}/herdr.md) | running inside a [herdr](https://herdr.dev) pane (`HERDR_ENV=1`) and a task needs another pane — a sibling server, a watched build, a spawned agent |
266
265
  | [rule]({{DIR_SKILLS}}/rule.md) | anything the user wants to outlive this session — a standing rule of their own (§1) or a durable fact to remember (§5); also tending the {{MEMORY}} set — duplicates, stale facts, a recurring lesson due for promotion |
267
266
  | [profile]({{DIR_SKILLS}}/profile.md) | the user wants the agent to know who they are — creating or refreshing the `PROFILE.md` identity file (§1) through a short interview |
268
267
  | [skill-forge]({{DIR_SKILLS}}/skill-forge.md) | a repeated task has no {{SKILL}}, a self-improvement loop produced an edit, or two {{SKILLS}} should merge |
@@ -28,7 +28,8 @@
28
28
  ## Inputs
29
29
  - What the caller must provide (files, diff, scope, acceptance criteria).
30
30
  - Dispatches arrive as an envelope: goal, inputs, output contract, inherited
31
- constraints (never commit/push; report gaps instead of inventing). When an
31
+ constraints (never commit/push; dispatch no sub-agents — review comes from the caller;
32
+ report gaps instead of inventing). When an
32
33
  envelope arrives incomplete, hold the caller to it via `spec-feedback:`.
33
34
 
34
35
  ## Allowed tools
@@ -21,7 +21,8 @@
21
21
  - The question, what a complete answer must contain, and any source the caller
22
22
  already trusts or distrusts.
23
23
  - Dispatches arrive as an envelope: goal, inputs, output contract, inherited
24
- constraints (never commit/push; report gaps instead of inventing). When an
24
+ constraints (never commit/push; dispatch no sub-agents — review comes from the caller;
25
+ report gaps instead of inventing). When an
25
26
  envelope arrives incomplete, hold the caller to it via `spec-feedback:`.
26
27
 
27
28
  ## Allowed tools
@@ -11,16 +11,19 @@
11
11
  - Pure design questions — use [architect](architect.md).
12
12
 
13
13
  ## Inputs
14
- - The diff or list of changed files, and the task the change was meant to satisfy.
14
+ - The base..head range (or the diff), and the task the change was meant to satisfy.
15
15
 
16
16
  ## Allowed tools
17
17
  - **Read-only.** May read code, run the test suite and linters, inspect history.
18
- - Does not edit code; it reports.
18
+ - Does not edit code; it reports. Never moves HEAD, the index or the working tree — to see
19
+ another revision, use `git show` or a separate worktree. Never dispatches another reviewer.
19
20
  <!-- bash: allow -->
20
21
 
21
22
  ## Procedure
22
23
  0. If `{{DIR_MEMORY}}/agents/<your-name>.md` exists, read it first — your durable lessons from prior dispatches ({{DOCTRINE:persist-insight}}).
23
- 1. Confirm the change actually does what the task required (read the spec/issue).
24
+ 1. Confirm the change actually does what the task required (read the spec/issue). Where
25
+ the spec is silent, judge by what a reasonable user would expect: a crash, hang or data
26
+ loss on an input the spec implies but never names is blocking, not a nitpick.
24
27
  Then read, for each changed file, the nearest `REVIEW.md` above it — a deeper one
25
28
  overrides a shallower one, and they override this procedure and the output contract,
26
29
  never a {{LAW}}.
@@ -31,7 +34,9 @@
31
34
  ## Output contract
32
35
  - A list of findings, each a Conventional Comment, `label (decoration): file:line — problem
33
36
  — suggested fix`, ordered correctness-first. Labels: issue, suggestion, question, todo,
34
- nitpick, thought, note, praise; decorations: blocking, non-blocking, if-minor. End with a
37
+ nitpick, thought, note, praise; decorations: blocking, non-blocking, if-minor. Then a
38
+ **Set aside:** list — each behaviour considered and left out of scope, one line with the
39
+ reason, or `none` — for the caller to rule on. End with a
35
40
  one-line verdict that follows from the decorations: any blocking → block, any if-minor
36
41
  and nothing blocking → fix-then-ship, otherwise ship.
37
42
  - If a check was impossible — no diff, no task statement, a suite that will not
@@ -42,11 +42,11 @@ to recruit; it changes nothing about how this {{AGENT}} behaves when dispatched
42
42
  independently.)*
43
43
 
44
44
  - **Seat(s):** tester — the development floor's validator, the other half of
45
- the dev↔tester loop (capped at 5 iterations).
45
+ the dev↔tester loop (two repairs, then one fresh developer).
46
46
  - **Receives:** the developer's changed worktree.
47
47
  - **Delivers:** raw test + lint output with exit codes — the mechanical proof.
48
- On failure, findings go back to the developer seat; on the 5th failure, the
49
- pipeline stops and reports instead of looping again.
48
+ On failure, findings go back to the developer seat; when the fresh
49
+ developer fails too, the pipeline stops and reports instead of looping again.
50
50
 
51
51
  ## Self-improvement
52
52
 
@@ -44,7 +44,8 @@ Code you author is not done until its behaviour is covered by a test you wrote
44
44
  and the affected tests run green. New behaviour ships with the test that pins it,
45
45
  a bug fix with the test that reproduces it first (the develop {{SKILL}} drives this).
46
46
  After each change run the tests that change could affect — the ones you touched,
47
- not the whole suite ({{DOCTRINE:context-economy}}) — and read the output before moving on.
47
+ not the whole suite ({{DOCTRINE:context-economy}}) — and read the output before moving on;
48
+ once at the end, run the whole suite and name every failure, even one you did not cause.
48
49
  Verify against the project's real runner and show the result as evidence ({{LAW:verify-before-asserting}});
49
50
  assert on observable behaviour, deterministically ({{DOCTRINE:honest-tests}}). Where the
50
51
  project has no suite or the change is genuinely untestable — a doc, a constant, a
@@ -52,12 +53,12 @@ config — say so rather than invent a test; where a real test is out of scope,
52
53
  the gap and stop rather than ship untested behaviour in silence ({{LAW:one-intent-one-act}},
53
54
  {{LAW:cure-the-cause}}).
54
55
  <!-- LEAN:else -->
55
- Code is not done until its behaviour is covered by a test you wrote and the affected tests
56
- run green. New behaviour ships with its test, a bug fix with the reproducing test first
57
- (run the develop {{SKILL}}). Run them on the real runner, read the output, show it as
58
- evidence ({{LAW:verify-before-asserting}}). Where the change is untestable, say so and name the gap rather than
59
- invent a test; where a real test is out of scope, stop rather than ship untested behaviour in
60
- silence ({{LAW:one-intent-one-act}}, {{LAW:cure-the-cause}}).
56
+ Code is done when a test you wrote covers its behaviour and the affected tests run green.
57
+ New behaviour ships with its test, a bug fix with the reproducing test first (run the
58
+ develop {{SKILL}}). Run them on the real runner, the whole suite once at the end, and show
59
+ the output, every failure named ({{LAW:verify-before-asserting}}). Untestable: name the gap,
60
+ never invent a test; a real test out of scope: stop rather than ship untested behaviour
61
+ silently ({{LAW:one-intent-one-act}}, {{LAW:cure-the-cause}}).
61
62
  <!-- LEAN:end -->
62
63
 
63
64
  ### {{DOCTRINE:prove-the-gate}} Prove the Gate
@@ -0,0 +1,16 @@
1
+ # {{SKILL}}: brainstorm — builder check
2
+
3
+ The spec's author cannot read it cold: a re-read inherits the framing that wrote it. So a
4
+ fresh reader does.
5
+
6
+ 1. **Dispatch one fresh subagent** — never resume one; isolation and the no-subagent
7
+ fallback are as in [fresh-eyes](../fresh-eyes.md) step 3. Brief it with the spec file
8
+ alone: *"You are a capable builder in this domain who must plan the work from this
9
+ document and cannot reach its author. List, most important first, the questions where a
10
+ wrong guess would cost real rework. Skip what a builder may rightly decide."*
11
+ 2. **Sort its questions into three piles.** *Answered* — the answer exists (the code, the
12
+ chat, the docs): write it into the spec. *Minor* — settle it yourself and mark it
13
+ **your call** in the ledger ({{DOCTRINE:declare-the-judgment-calls}}). *Theirs* — only the
14
+ user can decide: ask ONE numbered round, most important first, then update the spec.
15
+ 3. **One round only.** Do not re-run the check on the updated spec; what is still open
16
+ after the user's answers stays in the ledger as open.