geneseed 3.0.0 → 3.1.1

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 (50) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/DESIGN.md +16 -5
  3. package/README.md +1 -1
  4. package/SETUP.md +29 -2
  5. package/SHIPPED.md +1 -1
  6. package/bin/geneseed.mjs +51 -2
  7. package/docs/extending.md +2 -2
  8. package/docs/web/rules.md +2 -0
  9. package/docs/web/setup-choices.md +13 -1
  10. package/harness.config.json +1 -1
  11. package/js/checkout.mjs +43 -2
  12. package/js/doctor.mjs +39 -1
  13. package/js/emit.mjs +6 -5
  14. package/js/generate.mjs +18 -3
  15. package/js/installs.mjs +50 -0
  16. package/js/inventory.mjs +14 -3
  17. package/js/render.mjs +59 -2
  18. package/js/settings.mjs +31 -14
  19. package/js/setup.mjs +15 -5
  20. package/js/uninstall.mjs +7 -2
  21. package/js/web/actions.mjs +41 -5
  22. package/js/web/api.mjs +23 -8
  23. package/js/web/jobs.mjs +8 -2
  24. package/package.json +1 -1
  25. package/src/AGENT.md.tmpl +1 -1
  26. package/web/dist/assets/{About-C0Mk0igT.js → About-DyK7yRy7.js} +1 -1
  27. package/web/dist/assets/{Activity-C4aJJVXz.js → Activity-D_sYBlW-.js} +1 -1
  28. package/web/dist/assets/{ActivityDetail-DaQ85za0.js → ActivityDetail-BomKcMhy.js} +1 -1
  29. package/web/dist/assets/{Diff-B3tlO2Yj.js → Diff-BSvi1WUg.js} +1 -1
  30. package/web/dist/assets/{Doctor-BNC-5Dmo.js → Doctor-Cz1s-y1n.js} +1 -1
  31. package/web/dist/assets/{Graph-us0T6ZcV.js → Graph-CuntzFJ0.js} +1 -1
  32. package/web/dist/assets/{Harnesses-DAMgyfM_.js → Harnesses-DgRyOviS.js} +1 -1
  33. package/web/dist/assets/Laws-CWk3Mpri.js +3 -0
  34. package/web/dist/assets/{Library-KmwrHA1J.js → Library-DXhTN9ju.js} +1 -1
  35. package/web/dist/assets/{Markdown-0eQOVGSF.js → Markdown-BoiZcdUl.js} +1 -1
  36. package/web/dist/assets/{Profile-ZU7WV6Xl.js → Profile-DfCHlZ03.js} +1 -1
  37. package/web/dist/assets/{Rules-CtDoPhhf.js → Rules-eND7hJv9.js} +1 -1
  38. package/web/dist/assets/{Skills-BhoOxdkD.js → Skills-CDl9opZO.js} +1 -1
  39. package/web/dist/assets/{StatusBadge-CGvYVCwa.js → StatusBadge-DSDtcArX.js} +1 -1
  40. package/web/dist/assets/{Themes-Dr7OOkD7.js → Themes-CoAWuIeM.js} +1 -1
  41. package/web/dist/assets/{activity-B8aq3qtl.js → activity-Dmb0FTAx.js} +1 -1
  42. package/web/dist/assets/index-B-mXquBZ.css +1 -0
  43. package/web/dist/assets/index-CH_Z725C.js +12 -0
  44. package/web/dist/assets/{index-D0lPN_qW.js → index-Dg58ScBe.js} +1 -1
  45. package/web/dist/assets/{index-AhMzkdbI.js → index-jrY4m9jP.js} +1 -1
  46. package/web/dist/index.html +2 -2
  47. package/web/src/pages/Laws.jsx +214 -31
  48. package/web/dist/assets/Laws-rOxegJ75.js +0 -1
  49. package/web/dist/assets/index-2wqPDxuN.css +0 -1
  50. package/web/dist/assets/index-CRftEtBX.js +0 -12
package/js/installs.mjs CHANGED
@@ -290,6 +290,47 @@ export function doctrinesForBuild(d) {
290
290
  return doctrinesOfDir(d) ?? [...PACK_ORDER];
291
291
  }
292
292
 
293
+ /** `Excluded rules: process 7, craft 3` — the marker's optional second line. */
294
+ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
295
+
296
+ /**
297
+ * The individual doctrine rules a DEPLOYED install left out — the second axis, read back.
298
+ *
299
+ * ⚠ ITS NULL SEMANTICS ARE THE OPPOSITE OF `doctrinesOfDir`'S, AND THAT IS THE POINT. A
300
+ * missing `Active packs:` line means "this install does not say", which has to resolve to
301
+ * ALL packs or a pre-migration install loses its consent gate on the next upgrade. A missing
302
+ * `Excluded rules:` line means "nothing was excluded" — because the line is only ever written
303
+ * when something is, so its absence is a STATEMENT rather than a silence. Both answers fail
304
+ * closed: unknown packs bind the most, unknown exclusions take away the least. They only look
305
+ * inconsistent until you ask which direction is safe for each.
306
+ *
307
+ * That is also what makes this backward compatible by construction: every install that exists
308
+ * today has no such line, reads as `[]`, and behaves exactly as it does now.
309
+ *
310
+ * A malformed entry condemns the whole line to `[]` rather than to the part that parsed —
311
+ * same rule as the pack list, same reason, opposite default. Half an exclusion list would
312
+ * take away rules nobody named.
313
+ */
314
+ export function excludedRulesOfDir(d) {
315
+ for (const carrier of CARRIERS) {
316
+ const text = readMaybe(path.join(d, carrier));
317
+ if (text === null) continue;
318
+ const m = EXCLUDED_RULES_RE.exec(text);
319
+ if (!m) continue;
320
+ if (m[1] === 'none') return [];
321
+ const named = m[1].split(',').map((s) => s.trim());
322
+ // `<pack> <n>` on the page, `<pack>.<n>` in the code — the same asymmetry the rendered
323
+ // line documents. A name this checkout does not ship is a corrupt line, not a hint.
324
+ const ids = named.map((s) => {
325
+ const parts = s.split(/[ \t]+/);
326
+ return parts.length === 2 && PACK_ORDER.includes(parts[0]) && /^\d+$/.test(parts[1])
327
+ ? `${parts[0]}.${Number(parts[1])}` : null;
328
+ });
329
+ return ids.every(Boolean) ? [...new Set(ids)].sort() : [];
330
+ }
331
+ return [];
332
+ }
333
+
293
334
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
294
335
 
295
336
  /** `_harness_mcp._claude_read_manifest`. */
@@ -437,6 +478,7 @@ export function installTargets() {
437
478
  export function installedDefaults() {
438
479
  const found = {
439
480
  theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
481
+ excludeRules: null,
440
482
  };
441
483
  const candidates = [];
442
484
  for (const { host, configDir } of HOSTS) {
@@ -470,6 +512,14 @@ export function installedDefaults() {
470
512
  // for a carrier with no marker, which would end this walk at the FIRST candidate and hide
471
513
  // a later carrier that does say. The null has to survive to the caller here.
472
514
  if (found.doctrines === null) found.doctrines = doctrinesOfDir(base);
515
+ // ⚠ THE PER-RULE AXIS HAS ONLY TWO STATES, so the walk cannot use `=== null` to mean
516
+ // "keep looking" the way the line above does — `excludedRulesOfDir` answers `[]` for a
517
+ // carrier that excludes nothing AND for one with no marker at all, and the empty list is a
518
+ // real answer. An EMPTY answer therefore keeps the walk going and a non-empty one stops it:
519
+ // the first carrier on this machine that actually names an exclusion is the one that
520
+ // pre-selects. The cost of that choice is that "excludes nothing" and "no carrier" are
521
+ // indistinguishable here, which is exactly what the marker itself already says.
522
+ if (!found.excludeRules?.length) found.excludeRules = excludedRulesOfDir(base);
473
523
  }
474
524
  return found;
475
525
  }
package/js/inventory.mjs CHANGED
@@ -250,7 +250,7 @@ export function entityStatus(registry, key) {
250
250
  * `status`, `catalog`, `tui`, the web API, its `SECTIONS` id, three test fixtures and two
251
251
  * recorded corpora to say the same thing in a different word.
252
252
  */
253
- export function tuiInventory(themeName, doctrines = null) {
253
+ export function tuiInventory(themeName, doctrines = null, excluded = []) {
254
254
  const { items } = renderAll(makeCfg(), themeName);
255
255
  const registry = loadRegistry();
256
256
  const agents = [];
@@ -299,7 +299,18 @@ export function tuiInventory(themeName, doctrines = null) {
299
299
  // second: the narrative order is the reading order, and it is what the marker line, the
300
300
  // wizard and the rendered section all use.
301
301
  const on = Array.isArray(doctrines) ? doctrines : PACK_ORDER;
302
- const built = PACK_ORDER.filter((p) => packs.has(p))
303
- .map((p) => ({ ...packs.get(p), active: on.includes(p) }));
302
+ // ⚠ `active` NOW LIVES ON THE RULE, and the pack's is derived from it rather than the other
303
+ // way round. The unit a user toggles is the rule: a pack is "active" exactly when at least
304
+ // one of its rules is, which is also the condition under which it renders at all. Keeping a
305
+ // pack-level flag as the source would let the two disagree — a pack marked active whose
306
+ // every rule is excluded is a header the render refuses to emit.
307
+ const off = new Set(Array.isArray(excluded) ? excluded : []);
308
+ const built = PACK_ORDER.filter((p) => packs.has(p)).map((p) => {
309
+ const pack = packs.get(p);
310
+ const rules = pack.rules.map((r) => ({
311
+ ...r, active: on.includes(p) && !off.has(`${r.pack}.${r.n}`),
312
+ }));
313
+ return { ...pack, rules, active: rules.some((r) => r.active) };
314
+ });
304
315
  return { agents, skills, laws, ontology, doctrines: built, theme: themeName };
305
316
  }
package/js/render.mjs CHANGED
@@ -279,7 +279,20 @@ function activeDoctrines(cfg) {
279
279
  throw e;
280
280
  }
281
281
  }
282
- return PACK_ORDER.filter((p) => wanted.includes(p));
282
+ // ⚠ A PACK WHOSE EVERY RULE IS EXCLUDED IS NOT A PACK, it is a heading over nothing. The
283
+ // console only ever offers per-RULE switches, so "turn this whole pack off" arrives here as
284
+ // seven exclusions rather than as a missing pack name — and rendering `**Observance** — how
285
+ // a task is run.` with no rules under it would be the one shape the reader cannot
286
+ // interpret. Dropped here rather than in `doctrinesBody` so `DOCTRINES_LIST` — the
287
+ // `Active packs:` marker a later reader parses back — agrees with what was rendered.
288
+ const excluded = new Set(cfg.excludeRules ?? []);
289
+ const survives = (pack) => {
290
+ const ids = [...readText(path.join(dir, `${pack}.md`))
291
+ .matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)]
292
+ .map((m) => `${m[1]}.${Number(m[2])}`);
293
+ return ids.some((id) => !excluded.has(id));
294
+ };
295
+ return PACK_ORDER.filter((p) => wanted.includes(p)).filter(survives);
283
296
  }
284
297
 
285
298
  /**
@@ -292,10 +305,40 @@ function activeDoctrines(cfg) {
292
305
  * pack — closes with one pointer at the full catalogue, which ships in every bundle whether
293
306
  * or not a pack is active.
294
307
  */
308
+ /** `### {{DOCTRINE}} <pack> <n> —` in the UNRENDERED source, which is where the address is. */
309
+ const SRC_RULE_HEADING_RE = /^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/;
310
+
311
+ /**
312
+ * Drop the individual rules an install excluded, leaving the pack's lead line and the rest.
313
+ *
314
+ * ⚠ FILTERED BEFORE SUBSTITUTION, ON THE SOURCE. The rendered heading spells the tier noun in
315
+ * the theme's own voice — `Doctrina process 7` on imperial, `Custom process 7` on pirate — so
316
+ * matching after substitution means matching fourteen spellings, and a theme whose noun
317
+ * happened not to match would silently exclude nothing at all. The source spelling is one
318
+ * literal and is the same in every voice.
319
+ *
320
+ * The pack's lead line (`**{{PACK_CRAFT}}** — how code is written.`) sits before the first
321
+ * heading and always survives: `splitAtLawHeadings` returns it as the leading chunk, and a
322
+ * pack whose every rule is excluded is not rendered at all — `activeDoctrines` drops it from
323
+ * the selection, so a header with nothing under it cannot occur.
324
+ */
325
+ function dropExcludedRules(text, pack, excluded) {
326
+ if (!excluded.size) return text;
327
+ return splitAtLawHeadings(text)
328
+ .filter((block) => {
329
+ const m = SRC_RULE_HEADING_RE.exec(block);
330
+ return !m || !excluded.has(`${m[1]}.${Number(m[2])}`);
331
+ })
332
+ .join('');
333
+ }
334
+
295
335
  function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
296
336
  if (!active.length) return '';
337
+ const excluded = new Set(cfg.excludeRules ?? []);
297
338
  const parts = active.map((name) => {
298
- const body = substitute(readText(path.join(cfg.src, 'doctrines', `${name}.md`)), theme).trim();
339
+ const raw = dropExcludedRules(readText(path.join(cfg.src, 'doctrines', `${name}.md`)),
340
+ name, excluded);
341
+ const body = substitute(raw, theme).trim();
299
342
  return footprint === 'lean' ? terseBlocks(body) : body;
300
343
  });
301
344
  if (footprint === 'lean') {
@@ -330,6 +373,20 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
330
373
  const active = activeDoctrines(cfg);
331
374
  theme.DOCTRINES_BODY = doctrinesBody(cfg, theme, active, footprint, lawsPrefix);
332
375
  theme.DOCTRINES_LIST = active.length ? active.join(', ') : 'none';
376
+ // ⚠ EMITTED ONLY WHEN SOMETHING IS EXCLUDED, and that is not cosmetic. An unconditional
377
+ // second marker line would put a new line into EVERY carrier — 261 golden cells, both
378
+ // footprints, nine emit modes — to say "nothing was excluded", which is what the line's
379
+ // absence already says. Keeping the default build byte-identical is what lets this ship
380
+ // without re-blessing anything, and it is why `excludedRulesOfDir` reads a MISSING line as
381
+ // `[]` rather than as unknown: absence is a real answer here, unlike the pack list.
382
+ //
383
+ // Rendered `process 7`, not `process.7`: the dotted form is the console's address, the
384
+ // spaced form is how every citation in the corpus already spells a rule, and this line is
385
+ // read by people first and by `excludedRulesOfDir` second.
386
+ const dropped = [...(cfg.excludeRules ?? [])].sort();
387
+ theme.EXCLUDED_RULES_LINE = dropped.length
388
+ ? `\nExcluded rules: ${dropped.map((id) => id.replace('.', ' ')).join(', ')}`
389
+ : '';
333
390
  return theme;
334
391
  }
335
392
 
package/js/settings.mjs CHANGED
@@ -79,6 +79,23 @@ function isDict(v) {
79
79
  */
80
80
  const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
81
81
 
82
+ /**
83
+ * The address of the rule the git gate IS. Not a pack any more — a rule.
84
+ *
85
+ * ⚠ THE GATE MOVED FROM THE PACK TO THE RULE, and the reason is that the pack stopped being
86
+ * the unit a user toggles. With per-rule exclusion an install can keep every other `process`
87
+ * rule and drop this one; keying the boundary on `d.includes('process')` would then leave the
88
+ * hook wired for a rule the carrier no longer states — prompt and boundary disagreeing, which
89
+ * is the single thing this design forbids in either direction.
90
+ *
91
+ * `excluded` defaults to `[]` for the same fail-closed reason `excludedRulesOfDir` does: a
92
+ * caller that has not learned about the second axis excludes nothing, so the gate stays. Every
93
+ * pre-existing call site keeps its exact behaviour without being touched.
94
+ */
95
+ const CONSENT_RULE = 'process.5';
96
+ const consentRuleOn = (d, excluded = []) =>
97
+ processPackOn(d) && !(Array.isArray(excluded) && excluded.includes(CONSENT_RULE));
98
+
82
99
  /**
83
100
  * `_build_settings._default_permission`.
84
101
  *
@@ -92,9 +109,9 @@ const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
92
109
  * Law IV's territory — an always-on invariant — not the process pack's, and the guard
93
110
  * plugin's own force-push arm is unconditional for the same reason.
94
111
  */
95
- export function defaultPermission(doctrines = null) {
112
+ export function defaultPermission(doctrines = null, excluded = []) {
96
113
  const bash = { 'rm -rf *': 'ask' };
97
- if (processPackOn(doctrines)) {
114
+ if (consentRuleOn(doctrines, excluded)) {
98
115
  bash['git commit*'] = 'ask';
99
116
  bash['git push*'] = 'ask';
100
117
  }
@@ -146,7 +163,7 @@ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', 'git push --force*',
146
163
  * FAIL-CLOSED direction — the boundary asks for a confirmation the constitution no longer
147
164
  * compels, exactly as the commit skill's own consent step does with the pack off. The unsafe
148
165
  * direction, a rule with no gate behind it, cannot occur here. A FRESH file is unaffected:
149
- * `mergeOpencodeJson` writes `defaultPermission(doctrines)` whole, so a new pack-off install
166
+ * `mergeOpencodeJson` writes `defaultPermission(doctrines, excluded)` whole, so a new pack-off install
150
167
  * has no git gate at all.
151
168
  *
152
169
  * ⚠ KEY ORDER IS NOT RECONCILED, DELIBERATELY. `defaultPermission` emits its keys in a fixed
@@ -284,7 +301,7 @@ export function readJsonc(text) {
284
301
  * `'add'`, which is what the boolean callers this replaced meant.
285
302
  */
286
303
  export function warnCommentedJsonc(target, agentPath, permission,
287
- includeLsp = false, prefix = 'geneseed', doctrines = null) {
304
+ includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = []) {
288
305
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
289
306
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
290
307
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
@@ -293,7 +310,7 @@ export function warnCommentedJsonc(target, agentPath, permission,
293
310
  ? `[${prefix}] and its "permission".bash is missing gates this build wires — `
294
311
  + 'reconcile it by hand against:\n'
295
312
  : `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
296
- for (const line of jsonDumpsIndent(defaultPermission(doctrines)).split('\n')) {
313
+ for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded)).split('\n')) {
297
314
  process.stdout.write(`[${prefix}] ${line}\n`);
298
315
  }
299
316
  }
@@ -322,7 +339,7 @@ export function atomicWriteJson(p, config) {
322
339
  * re-implements the inverse itself rather than inherit this one's permission/lsp side
323
340
  * effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
324
341
  * `_build_global.emit_opencode_global`). */
325
- export function mergeOpencodeJson(p, agentPath, doctrines = null) {
342
+ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = []) {
326
343
  const target = opencodeTarget(p);
327
344
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
328
345
  let hadComments = false;
@@ -361,10 +378,10 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
361
378
  let permChanged = addPerm;
362
379
  let stranded = [];
363
380
  let opaque = null;
364
- if (addPerm) config.permission = defaultPermission(doctrines);
381
+ if (addPerm) config.permission = defaultPermission(doctrines, excluded);
365
382
  else {
366
383
  [permChanged, stranded, opaque] = reconcileOpencodePermission(get(config, 'permission'),
367
- defaultPermission(doctrines));
384
+ defaultPermission(doctrines, excluded));
368
385
  }
369
386
  // The shape said no. This is the direction the residue warning above is NOT — a rule stated
370
387
  // in AGENT.md with nothing behind it — and it used to pass in silence, because a blanket
@@ -374,7 +391,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
374
391
  if (opaque) {
375
392
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
376
393
  + `${opaque}, so Geneseed cannot wire its own gates into it — including `
377
- + `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines).bash, k))
394
+ + `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines, excluded).bash, k))
378
395
  .map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
379
396
  + 'harness states those rules but nothing enforces them at this boundary.\n');
380
397
  }
@@ -404,7 +421,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
404
421
  // block to paste, split by which of the two situations the reader is actually in. It is
405
422
  // stdout, not stderr, because on this path the paste-in IS the output.
406
423
  warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
407
- addLsp, 'geneseed', doctrines);
424
+ addLsp, 'geneseed', doctrines, excluded);
408
425
  return target;
409
426
  }
410
427
  try {
@@ -698,7 +715,7 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
698
715
  * belongs in — the user's call — not how work is run, so it is not the process pack's to
699
716
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
700
717
  */
701
- export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
718
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = []) {
702
719
  const run = hookPrefix(hookOpts);
703
720
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
704
721
  // --root carries the install's own dir so a GLOBAL hook can stand down when a project
@@ -709,7 +726,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
709
726
  const learn = `${run} learn ${mem} || exit 0`;
710
727
  return {
711
728
  PreToolUse: [
712
- ...(processPackOn(doctrines)
729
+ ...(consentRuleOn(doctrines, excluded)
713
730
  ? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
714
731
  {
715
732
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
@@ -741,7 +758,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
741
758
  * `scope` is accepted and unused, exactly as in the Python.
742
759
  */
743
760
  export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
744
- doctrines = null) {
761
+ doctrines = null, excluded = []) {
745
762
  const prior = (priorHooks || []).filter(isDict);
746
763
  let config = {};
747
764
  let hadComments = false;
@@ -761,7 +778,7 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
761
778
  }
762
779
  let hooks = get(config, 'hooks');
763
780
  if (!isDict(hooks)) hooks = {};
764
- const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
781
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded);
765
782
  const canonFlat = [];
766
783
  for (const [event, groups] of Object.entries(canonical)) {
767
784
  for (const g of groups) canonFlat.push({ event, group: g });
package/js/setup.mjs CHANGED
@@ -383,11 +383,21 @@ export function collectSetupLines() {
383
383
  // own: the menu the user just answered was built from `doctrineOptions()`, so "they picked
384
384
  // everything" must be judged against that same list, not against a second one.
385
385
  const allPacks = doctrineOptions().map(([n]) => n);
386
+ // ⚠ PRESERVED, NOT ASKED. The wizard has no per-rule question — five registers is already
387
+ // the ceiling for a prompt sequence somebody answers by holding Enter, and a rule list is a
388
+ // console control, not a terminal one. But it must not be DROPPED either: re-running the
389
+ // wizard on an install that excluded `process 5` would otherwise hand the consent gate back
390
+ // without a word, which is the same silent widening the pack question was fixed for.
391
+ //
392
+ // `null` when there is nothing to preserve rather than `[]`: an empty list makes
393
+ // `setupBuildArgs` spell `--exclude-rules none`, and that string would land in the
394
+ // "About to run:" line every recorded wizard probe compares byte for byte.
395
+ const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
386
396
  pyPrint(`\nAbout to run: geneseed build ${
387
- setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks)
388
- .join(' ')}\n`);
397
+ setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
398
+ excludeRules).join(' ')}\n`);
389
399
  if (!confirm('Proceed?', true)) return null;
390
- return { theme, posture, mode, doctrines, emit, out, root, footprint };
400
+ return { theme, posture, mode, doctrines, excludeRules, emit, out, root, footprint };
391
401
  }
392
402
 
393
403
  // --------------------------------------------------------------------------------------
@@ -526,7 +536,7 @@ export function setupLines() {
526
536
  }
527
537
  const {
528
538
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
529
- doctrines = null,
539
+ doctrines = null, excludeRules = null,
530
540
  } = sel;
531
541
  if (emit === 'opencode-global') {
532
542
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -545,7 +555,7 @@ export function setupLines() {
545
555
  // runs must carry the pack selection the plan line just previewed, or the wizard would print
546
556
  // one command and run another.
547
557
  const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
548
- doctrineOptions().map(([n]) => n));
558
+ doctrineOptions().map(([n]) => n), excludeRules);
549
559
  pyPrint(`Running: geneseed build ${argv.join(' ')}\n`);
550
560
  const rc = driverMain(argv);
551
561
  if (rc !== 0) {
package/js/uninstall.mjs CHANGED
@@ -50,7 +50,8 @@ import path from 'node:path';
50
50
  import { hookRunnerEntry } from '../bin/geneseed.mjs';
51
51
  import { confirm } from './setup.mjs';
52
52
  import {
53
- claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, installKind, installState,
53
+ claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
+ installState,
54
55
  registeredTargets, readMaybe, DISABLED_STASH,
55
56
  } from './installs.mjs';
56
57
  import {
@@ -1032,8 +1033,12 @@ function remergeClaudeHooks(cfg, root = cfg) {
1032
1033
  // config dir (`.github/copilot-instructions.md`), and on a global install the two are the
1033
1034
  // same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
1034
1035
  const doctrines = doctrinesOfDir(root) ?? doctrinesOfDir(cfg);
1036
+ // Same two-carrier read for the second axis, and its own default: an absent line means
1037
+ // NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
1038
+ const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1039
+ : excludedRulesOfDir(cfg);
1035
1040
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1036
- managed.settings_hooks ?? null, hookRunnerEntry(), doctrines);
1041
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded);
1037
1042
  // `and data` — a manifest that did not parse is not one to write back.
1038
1043
  if (!pyEq(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1039
1044
  managed.settings_hooks = claims;
@@ -104,7 +104,9 @@ import os from 'node:os';
104
104
  import path from 'node:path';
105
105
 
106
106
  import { emitGlobalInto } from '../../bin/geneseed.mjs';
107
- import { ROOT, discoverNames, PACK_ORDER } from '../checkout.mjs';
107
+ import {
108
+ ROOT, discoverNames, knownRuleIds, PACK_ORDER,
109
+ } from '../checkout.mjs';
108
110
  import { withStdoutSwallowed } from '../diff.mjs';
109
111
  import { excludeAdd, excludeRemove } from '../excludes.mjs';
110
112
  import { setupBuildArgs } from '../generate.mjs';
@@ -114,8 +116,8 @@ import {
114
116
  pyResolve,
115
117
  } from '../hosts.mjs';
116
118
  import {
117
- EMIT_HOST_SCOPE, doctrinesForBuild, footprintOfDir, installState, installTargets, modeOfDir,
118
- postureOfDir, readMaybe,
119
+ EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState,
120
+ installTargets, modeOfDir, postureOfDir, readMaybe,
119
121
  } from '../installs.mjs';
120
122
  import {
121
123
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
@@ -170,6 +172,32 @@ function bodyDoctrines(body) {
170
172
  return PACK_ORDER.filter((p) => names.includes(p));
171
173
  }
172
174
 
175
+ /**
176
+ * The SECOND doctrine axis across the same trust boundary — which individual rules to drop.
177
+ *
178
+ * Same shape and same reasons as `bodyDoctrines` above: a list closed member by member
179
+ * against what this checkout actually ships (`knownRuleIds`, the one enumerator the CLI flag
180
+ * uses too), re-ordered canonically because the `Excluded rules:` line it produces is a
181
+ * marker a later reader parses back out, and `null` on any rejection so the caller decides
182
+ * what "unspecified" means.
183
+ *
184
+ * Accepts `process 7` or `process.7` — the console sends the dotted address, and a curl by
185
+ * hand naturally writes the spelling the marker shows. `none`/`[]` is the deliberate empty
186
+ * selection: "exclude nothing", which is a real answer and NOT the same as not saying.
187
+ */
188
+ function bodyExcludeRules(body) {
189
+ const raw = bget(body, 'excludeRules');
190
+ if (Array.isArray(raw) && !raw.length) return [];
191
+ const names = Array.isArray(raw) ? raw
192
+ : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
193
+ if (names === null || !names.length) return null;
194
+ if (names.length === 1 && names[0] === 'none') return [];
195
+ const known = knownRuleIds();
196
+ const ids = names.map((n) => (typeof n === 'string' ? n.trim().replace(/[ \t]+/, '.') : n));
197
+ if (!ids.every((n) => typeof n === 'string' && known.includes(n))) return null;
198
+ return known.filter((id) => ids.includes(id));
199
+ }
200
+
173
201
  /** `str.split()` with no argument — runs of PYTHON whitespace, no empties. */
174
202
  const pyWords = (s) => s.split(new RegExp(`[${PY_SPACE}]+`)).filter(Boolean);
175
203
 
@@ -825,8 +853,14 @@ export function apiInstallCmd(state, body) {
825
853
  // `null` elided the flag, and the generator's config fallback then narrowed the install and
826
854
  // took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
827
855
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
856
+ // The per-rule axis needs NO `doctrinesForBuild`-style rescue: the marker it reads is only
857
+ // written when something is excluded, so its absence is a statement ("nothing") rather than
858
+ // the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
859
+ // own answer, and a pre-marker install answers the empty list — which is what it has.
860
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
828
861
  const out = scope === 'global' ? null : String(root);
829
- const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines);
862
+ const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
863
+ PACK_ORDER, excludeRules);
830
864
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
831
865
  }
832
866
 
@@ -929,7 +963,9 @@ export function apiDeployCmd(state, body) {
929
963
  // existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
930
964
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
931
965
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
966
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
932
967
  // project-scope emit name == host name (opencode / claude / bob / copilot)
933
- const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines);
968
+ const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
969
+ PACK_ORDER, excludeRules);
934
970
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
935
971
  }
package/js/web/api.mjs CHANGED
@@ -61,7 +61,8 @@ import { doctorCollect } from '../doctor.mjs';
61
61
  import { excludesSnapshot } from '../excludes.mjs';
62
62
  import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, pyResolve } from '../hosts.mjs';
63
63
  import {
64
- doctrinesForBuild, footprintOfDir, installState, installTargets, installedDefaults, modeOfDir,
64
+ doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
65
+ installedDefaults, modeOfDir,
65
66
  postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
66
67
  } from '../installs.mjs';
67
68
  import { frontmatter } from '../hooks.mjs';
@@ -288,7 +289,8 @@ export function specEntries(root, nested) {
288
289
  * its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
289
290
  */
290
291
  export function deployedInventory(state) {
291
- const render = tuiInventory(state.theme, doctrinesForBuild(state.target));
292
+ const render = tuiInventory(state.theme, doctrinesForBuild(state.target),
293
+ excludedRulesOfDir(state.target));
292
294
  const registry = loadRegistry();
293
295
  const skills = specEntries(path.join(state.target, 'skills'), true);
294
296
  for (const e of skills) {
@@ -642,10 +644,12 @@ function constitutionItems(inv) {
642
644
  pack: p.pack,
643
645
  packTitle: p.title,
644
646
  packDesc: p.desc,
645
- // ⚠ EVERY RULE IS LISTED WHETHER OR NOT ITS PACK IS BUILT IN. An inactive pack that
646
- // vanished from the payload would be indistinguishable from one that never shipped, and
647
- // the console could not then offer the command that turns it back on.
648
- active: p.active,
647
+ // ⚠ EVERY RULE IS LISTED WHETHER OR NOT IT IS BUILT IN, and `active` is the RULE's own
648
+ // — a pack can be on with one rule excluded. A row that vanished from the payload would
649
+ // be indistinguishable from one that never shipped, and the console could not then
650
+ // offer the switch that turns it back on.
651
+ active: r.active !== false,
652
+ packActive: p.active,
649
653
  }))),
650
654
  ];
651
655
  }
@@ -854,6 +858,13 @@ export function apiOverview(state) {
854
858
  accent: accentFor(state.theme),
855
859
  emit: state.emit,
856
860
  footprint: install ? install.footprint : state.footprint,
861
+ // ⚠ NO PACK ROSTER HERE, DELIBERATELY. One was added when the toggle lived in Settings,
862
+ // which has no other source for the pack names. The control moved to the Constitution
863
+ // page, which already holds the whole catalogue — `/api/catalog/laws` carries `pack`,
864
+ // `packTitle`, `packDesc` and `active` on every doctrine row — so a second copy of the
865
+ // roster on this endpoint would be API surface with no reader, and two places for the
866
+ // same fact to disagree. The SUMMARY stays, under `counts.doctrines`: the dashboard
867
+ // tiles need the fraction and must not fetch a catalogue to draw it.
857
868
  install,
858
869
  target: state.target,
859
870
  deployed: deployed(state),
@@ -872,8 +883,12 @@ export function apiOverview(state) {
872
883
  doctrines: {
873
884
  active: (inv.doctrines ?? []).filter((p) => p.active).length,
874
885
  total: (inv.doctrines ?? []).length,
875
- rules: (inv.doctrines ?? []).filter((p) => p.active)
876
- .reduce((n, p) => n + p.rules.length, 0),
886
+ // Counted RULE BY RULE, not pack by pack: with the per-rule axis an active pack can
887
+ // carry an inactive rule, so `p.rules.length` over active packs over-counts by
888
+ // exactly what the user switched off — the one number on the dashboard that claims
889
+ // to say how many rules bind this install.
890
+ rules: (inv.doctrines ?? [])
891
+ .reduce((n, p) => n + p.rules.filter((r) => r.active !== false).length, 0),
877
892
  },
878
893
  memory: memoryItems(state).length,
879
894
  notebook: notebookItems(state).length,
package/js/web/jobs.mjs CHANGED
@@ -451,13 +451,19 @@ export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
451
451
  * `harness.config.json`, and a `{"doctrines":["craft"]}` there re-emits the deployed install
452
452
  * without its commit/push consent gate. So a caller that forgets the axis gets ALL packs —
453
453
  * unknown resolves to the full set, never to a config value (`doctrinesForBuild`).
454
+ *
455
+ * `excludeRules` is the same argument one tier down and it defaults the same way — to the
456
+ * SAFE end, which here is the empty list. An omitted flag would fall through to
457
+ * `harness.config.json` exactly as the pack list does, so `{"excludeRules":["process.5"]}`
458
+ * there would take the consent gate out of a console Build that never asked for it. `[]` is
459
+ * spelled `--exclude-rules none` and says "exclude nothing" out loud.
454
460
  */
455
461
  function actionTable({
456
462
  theme = 'neutral', emit = 'opencode-global', footprint = 'full',
457
- posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER],
463
+ posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER], excludeRules = [],
458
464
  } = {}) {
459
465
  const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode,
460
- doctrines);
466
+ doctrines, PACK_ORDER, excludeRules);
461
467
  return {
462
468
  doctor: [[NODE(), CLI(), 'doctor']],
463
469
  build: [[NODE(), GEN(), ...buildArgv]],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.0.0",
4
+ "version": "3.1.1",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/src/AGENT.md.tmpl CHANGED
@@ -109,7 +109,7 @@ rebuilding with `--doctrines <list>` (or through the setup wizard).
109
109
 
110
110
  {{DOCTRINES_BODY}}
111
111
 
112
- Active packs: {{DOCTRINES_LIST}}
112
+ Active packs: {{DOCTRINES_LIST}}{{EXCLUDED_RULES_LINE}}
113
113
 
114
114
  ---
115
115
 
@@ -1 +1 @@
1
- import{a as l,j as e,I as a}from"./index-CRftEtBX.js";import{r as n}from"./react-DOpiTn5v.js";const c="https://github.com/Arylmera/Geneseed",h="https://github.com/Arylmera";function u(){const[r,i]=n.useState(c),[t,o]=n.useState(!0);return n.useEffect(()=>{l.docsPage("about").then(s=>{s!=null&&s.repo&&(i(s.repo),o(!!s.repo_is_github))}).catch(()=>{})},[]),e.jsxs("div",{className:"narrow",children:[e.jsx("div",{className:"head-row mb-18",children:e.jsxs("div",{children:[e.jsx("h1",{className:"h",children:"About"}),e.jsx("p",{className:"sub",children:"The project, where the source lives, and a word from the person who grew it."})]})}),e.jsxs("div",{className:"card pad-lg mb-16",children:[e.jsx("div",{className:"card-head",children:e.jsx("h3",{children:"Project"})}),e.jsx("p",{className:"sub mb-16",children:"Geneseed is a portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent. One canonical source, many voices, MIT-licensed."}),e.jsxs("div",{className:"row wrap gap-10",children:[e.jsxs("a",{className:"btn ghost",href:r,target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"github"}),t?"Source on GitHub":"Source repo"]}),t&&e.jsxs(e.Fragment,{children:[e.jsxs("a",{className:"btn ghost",href:r+"/issues",target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"external"}),"Issues"]}),e.jsxs("a",{className:"btn ghost",href:r+"/blob/main/LICENSE",target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"external"}),"MIT License"]})]})]})]}),e.jsxs("div",{className:"card pad-lg",children:[e.jsx("div",{className:"card-head",children:e.jsx("h3",{children:"Creator"})}),e.jsxs("p",{className:"sub mb-16",children:["Geneseed is built by ",e.jsx("strong",{children:"Guillaume Lemer"}),". It started as a personal, Obsidian-vault-grown agent operating system and was distilled into the repo you are running. Implant it once, and a disciplined agent grows around the same inherited rules, skills, and memory in every repo it follows you into."]}),e.jsx("p",{className:"sub mb-16",children:"If you find this useful, a star on the repo or an issue with feedback is the best way to help shape what comes next."}),e.jsx("div",{className:"row wrap gap-10",children:e.jsxs("a",{className:"btn ghost",href:h,target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"github"}),"@Arylmera on GitHub"]})})]})]})}export{u as default};
1
+ import{a as l,j as e,I as a}from"./index-CH_Z725C.js";import{r as n}from"./react-DOpiTn5v.js";const c="https://github.com/Arylmera/Geneseed",h="https://github.com/Arylmera";function u(){const[r,i]=n.useState(c),[t,o]=n.useState(!0);return n.useEffect(()=>{l.docsPage("about").then(s=>{s!=null&&s.repo&&(i(s.repo),o(!!s.repo_is_github))}).catch(()=>{})},[]),e.jsxs("div",{className:"narrow",children:[e.jsx("div",{className:"head-row mb-18",children:e.jsxs("div",{children:[e.jsx("h1",{className:"h",children:"About"}),e.jsx("p",{className:"sub",children:"The project, where the source lives, and a word from the person who grew it."})]})}),e.jsxs("div",{className:"card pad-lg mb-16",children:[e.jsx("div",{className:"card-head",children:e.jsx("h3",{children:"Project"})}),e.jsx("p",{className:"sub mb-16",children:"Geneseed is a portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent. One canonical source, many voices, MIT-licensed."}),e.jsxs("div",{className:"row wrap gap-10",children:[e.jsxs("a",{className:"btn ghost",href:r,target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"github"}),t?"Source on GitHub":"Source repo"]}),t&&e.jsxs(e.Fragment,{children:[e.jsxs("a",{className:"btn ghost",href:r+"/issues",target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"external"}),"Issues"]}),e.jsxs("a",{className:"btn ghost",href:r+"/blob/main/LICENSE",target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"external"}),"MIT License"]})]})]})]}),e.jsxs("div",{className:"card pad-lg",children:[e.jsx("div",{className:"card-head",children:e.jsx("h3",{children:"Creator"})}),e.jsxs("p",{className:"sub mb-16",children:["Geneseed is built by ",e.jsx("strong",{children:"Guillaume Lemer"}),". It started as a personal, Obsidian-vault-grown agent operating system and was distilled into the repo you are running. Implant it once, and a disciplined agent grows around the same inherited rules, skills, and memory in every repo it follows you into."]}),e.jsx("p",{className:"sub mb-16",children:"If you find this useful, a star on the repo or an issue with feedback is the best way to help shape what comes next."}),e.jsx("div",{className:"row wrap gap-10",children:e.jsxs("a",{className:"btn ghost",href:h,target:"_blank",rel:"noreferrer",children:[e.jsx(a,{name:"github"}),"@Arylmera on GitHub"]})})]})]})}export{u as default};
@@ -1 +1 @@
1
- import{j as e,E as v,L as y,a as x,I as g,r as f,g as u}from"./index-CRftEtBX.js";import{r as p}from"./react-DOpiTn5v.js";import{S as j,b,E as h,c as N,a as w,T as k}from"./activity-B8aq3qtl.js";function S({s}){const l=j[s.status]||j.idle,n=s.files,a=s.todos,i=a&&a.total>0||s.error||s.blocked_on,r=()=>u(`#/activity/${encodeURIComponent(s.session_id)}`);return e.jsxs("div",{className:"card pad-md act-card",style:{cursor:"pointer"},onClick:r,role:"link",tabIndex:0,onKeyDown:d=>{d.key==="Enter"&&r()},children:[e.jsxs("div",{className:"row between wrap gap-12",children:[e.jsxs("div",{className:"row gap-10",style:{minWidth:0},children:[e.jsx("span",{className:`feed-dot ${l.cls||"acc"}`,style:{width:9,height:9}}),e.jsxs("div",{style:{minWidth:0},children:[e.jsx("h3",{style:{margin:0},children:s.title||b(s.cwd)||s.session_id}),s.cwd&&e.jsx("div",{className:"dim mono",style:{fontSize:12,...h},title:s.cwd,children:s.cwd})]})]}),e.jsxs("div",{className:"row gap-8",style:{flexShrink:0},children:[s.model&&e.jsx("span",{className:"badge mono",title:"model",children:s.model}),s.agent&&e.jsx("span",{className:"badge",children:s.agent}),e.jsxs("span",{className:`badge ${l.cls}`,children:[e.jsx("span",{className:"dot"}),l.label]}),e.jsx(g,{name:"chevron",className:"glyph act-chev"})]})]}),s.phase&&e.jsxs("div",{className:"row gap-8",style:{marginTop:10,fontSize:14},children:[e.jsx(g,{name:"activity"}),e.jsx("span",{style:h,children:s.phase})]}),(s.tokens>0||s.cost>0||s.turn_started_at||n&&n.count>0)&&e.jsxs("div",{className:"row wrap",style:{gap:16,marginTop:12,fontSize:13,color:"var(--text-2)"},children:[s.tokens>0&&e.jsxs("span",{children:[N(s.tokens)," tok"]}),s.cost>0&&e.jsxs("span",{children:["$",s.cost.toFixed(2)]}),s.turn_started_at?e.jsx("span",{children:e.jsx(w,{startedAt:s.turn_started_at})}):null,n&&n.count>0&&e.jsxs("span",{children:[n.count," file",n.count===1?"":"s"," ",e.jsxs("span",{style:{color:"var(--good)"},children:["+",n.additions]})," ",e.jsxs("span",{style:{color:"var(--bad)"},children:["−",n.deletions]})]})]}),i&&e.jsxs("div",{className:"row between wrap gap-12",style:{marginTop:12,paddingTop:10,borderTop:"1px solid var(--line)"},children:[a&&a.total>0?e.jsxs("span",{className:"row gap-8 dim",style:{fontSize:13},children:[e.jsx(k,{done:a.done,total:a.total}),"plan ",a.done,"/",a.total]}):e.jsx("span",{}),s.error?e.jsx("span",{className:"badge bad",title:s.error,style:{maxWidth:300,...h},children:s.error}):s.blocked_on?e.jsx("span",{className:"badge bad",title:s.blocked_on,style:{maxWidth:300,...h},children:s.blocked_on}):null]}),e.jsxs("div",{className:"dim mono",style:{fontSize:11,marginTop:8},children:["updated ",f(s.updated_at)," ago"]})]})}function I(){const[s,l]=p.useState(null),[n,a]=p.useState("");p.useEffect(()=>{let t=!0;const o=()=>x.activity().then(m=>{t&&(l(m),a(""))},m=>{t&&a(m.message)});o();const c=setInterval(o,2e3);return()=>{t=!1,clearInterval(c)}},[]);const i=s?s.enabled!==!1:!0,r=(s==null?void 0:s.activity)||[],d=async()=>{const t=!i;try{const o=await x.activityToggle(t);l(c=>({...c||{},enabled:o.enabled,activity:o.enabled?(c==null?void 0:c.activity)||[]:[]})),a("")}catch(o){a(o.message)}};return e.jsxs("div",{className:"narrow",children:[e.jsxs("div",{className:"head-row mb-16",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"h",children:"Activity"}),e.jsx("p",{className:"sub",children:"Live OpenCode sessions this harness is running: what each one is doing right now. Refreshes every couple of seconds; sessions drop off when their process exits."})]}),e.jsxs("div",{className:"row gap-10",style:{alignItems:"center",flexShrink:0},children:[e.jsx("span",{className:"dim mono",style:{fontSize:12},children:i?"tracking on":"tracking off"}),e.jsx("div",{className:`sw-toggle${i?" on":""}`,role:"switch","aria-checked":i,"aria-label":"Toggle activity tracking",tabIndex:0,onClick:d,onKeyDown:t=>{(t.key==="Enter"||t.key===" ")&&(t.key===" "&&t.preventDefault(),d())}})]})]}),e.jsx(v,{error:n,style:{marginBottom:12}}),s?i?r.length===0?e.jsxs("div",{className:"empty",children:[e.jsx("div",{className:"big",children:"No active sessions"}),"Run ",e.jsx("code",{children:"opencode"})," and the sessions it spins up appear here."]}):e.jsx("div",{className:"stack gap-12",children:r.map(t=>e.jsx(S,{s:t},t.session_id))}):e.jsxs("div",{className:"empty",children:[e.jsx("div",{className:"big",children:"Activity tracking is off"}),"The plugin isn't recording sessions. Flip the switch to turn it back on."]}):e.jsx(y,{label:"Reading live sessions…"})]})}export{I as default};
1
+ import{j as e,E as v,L as y,a as x,I as g,r as f,g as u}from"./index-CH_Z725C.js";import{r as p}from"./react-DOpiTn5v.js";import{S as j,b,E as h,c as N,a as w,T as k}from"./activity-Dmb0FTAx.js";function S({s}){const l=j[s.status]||j.idle,n=s.files,a=s.todos,i=a&&a.total>0||s.error||s.blocked_on,r=()=>u(`#/activity/${encodeURIComponent(s.session_id)}`);return e.jsxs("div",{className:"card pad-md act-card",style:{cursor:"pointer"},onClick:r,role:"link",tabIndex:0,onKeyDown:d=>{d.key==="Enter"&&r()},children:[e.jsxs("div",{className:"row between wrap gap-12",children:[e.jsxs("div",{className:"row gap-10",style:{minWidth:0},children:[e.jsx("span",{className:`feed-dot ${l.cls||"acc"}`,style:{width:9,height:9}}),e.jsxs("div",{style:{minWidth:0},children:[e.jsx("h3",{style:{margin:0},children:s.title||b(s.cwd)||s.session_id}),s.cwd&&e.jsx("div",{className:"dim mono",style:{fontSize:12,...h},title:s.cwd,children:s.cwd})]})]}),e.jsxs("div",{className:"row gap-8",style:{flexShrink:0},children:[s.model&&e.jsx("span",{className:"badge mono",title:"model",children:s.model}),s.agent&&e.jsx("span",{className:"badge",children:s.agent}),e.jsxs("span",{className:`badge ${l.cls}`,children:[e.jsx("span",{className:"dot"}),l.label]}),e.jsx(g,{name:"chevron",className:"glyph act-chev"})]})]}),s.phase&&e.jsxs("div",{className:"row gap-8",style:{marginTop:10,fontSize:14},children:[e.jsx(g,{name:"activity"}),e.jsx("span",{style:h,children:s.phase})]}),(s.tokens>0||s.cost>0||s.turn_started_at||n&&n.count>0)&&e.jsxs("div",{className:"row wrap",style:{gap:16,marginTop:12,fontSize:13,color:"var(--text-2)"},children:[s.tokens>0&&e.jsxs("span",{children:[N(s.tokens)," tok"]}),s.cost>0&&e.jsxs("span",{children:["$",s.cost.toFixed(2)]}),s.turn_started_at?e.jsx("span",{children:e.jsx(w,{startedAt:s.turn_started_at})}):null,n&&n.count>0&&e.jsxs("span",{children:[n.count," file",n.count===1?"":"s"," ",e.jsxs("span",{style:{color:"var(--good)"},children:["+",n.additions]})," ",e.jsxs("span",{style:{color:"var(--bad)"},children:["−",n.deletions]})]})]}),i&&e.jsxs("div",{className:"row between wrap gap-12",style:{marginTop:12,paddingTop:10,borderTop:"1px solid var(--line)"},children:[a&&a.total>0?e.jsxs("span",{className:"row gap-8 dim",style:{fontSize:13},children:[e.jsx(k,{done:a.done,total:a.total}),"plan ",a.done,"/",a.total]}):e.jsx("span",{}),s.error?e.jsx("span",{className:"badge bad",title:s.error,style:{maxWidth:300,...h},children:s.error}):s.blocked_on?e.jsx("span",{className:"badge bad",title:s.blocked_on,style:{maxWidth:300,...h},children:s.blocked_on}):null]}),e.jsxs("div",{className:"dim mono",style:{fontSize:11,marginTop:8},children:["updated ",f(s.updated_at)," ago"]})]})}function I(){const[s,l]=p.useState(null),[n,a]=p.useState("");p.useEffect(()=>{let t=!0;const o=()=>x.activity().then(m=>{t&&(l(m),a(""))},m=>{t&&a(m.message)});o();const c=setInterval(o,2e3);return()=>{t=!1,clearInterval(c)}},[]);const i=s?s.enabled!==!1:!0,r=(s==null?void 0:s.activity)||[],d=async()=>{const t=!i;try{const o=await x.activityToggle(t);l(c=>({...c||{},enabled:o.enabled,activity:o.enabled?(c==null?void 0:c.activity)||[]:[]})),a("")}catch(o){a(o.message)}};return e.jsxs("div",{className:"narrow",children:[e.jsxs("div",{className:"head-row mb-16",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"h",children:"Activity"}),e.jsx("p",{className:"sub",children:"Live OpenCode sessions this harness is running: what each one is doing right now. Refreshes every couple of seconds; sessions drop off when their process exits."})]}),e.jsxs("div",{className:"row gap-10",style:{alignItems:"center",flexShrink:0},children:[e.jsx("span",{className:"dim mono",style:{fontSize:12},children:i?"tracking on":"tracking off"}),e.jsx("div",{className:`sw-toggle${i?" on":""}`,role:"switch","aria-checked":i,"aria-label":"Toggle activity tracking",tabIndex:0,onClick:d,onKeyDown:t=>{(t.key==="Enter"||t.key===" ")&&(t.key===" "&&t.preventDefault(),d())}})]})]}),e.jsx(v,{error:n,style:{marginBottom:12}}),s?i?r.length===0?e.jsxs("div",{className:"empty",children:[e.jsx("div",{className:"big",children:"No active sessions"}),"Run ",e.jsx("code",{children:"opencode"})," and the sessions it spins up appear here."]}):e.jsx("div",{className:"stack gap-12",children:r.map(t=>e.jsx(S,{s:t},t.session_id))}):e.jsxs("div",{className:"empty",children:[e.jsx("div",{className:"big",children:"Activity tracking is off"}),"The plugin isn't recording sessions. Flip the switch to turn it back on."]}):e.jsx(y,{label:"Reading live sessions…"})]})}export{I as default};