@treeseed/dsh-chapters 0.1.24 → 0.2.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 (103) hide show
  1. package/README.md +2 -1
  2. package/cordis.patch.yml +49 -1
  3. package/docs/architecture.md +1 -496
  4. package/docs/backlog.md +234 -4
  5. package/docs/contract.md +127 -1
  6. package/docs/development.md +248 -12
  7. package/docs/host-compaction-seam.md +108 -0
  8. package/docs/knowledge-repo.md +532 -16
  9. package/docs/project-state.md +323 -0
  10. package/docs/verify.md +266 -0
  11. package/lib/archive.d.ts +49 -1
  12. package/lib/archive.js +56 -2
  13. package/lib/archive.js.map +1 -1
  14. package/lib/arrival.js +24 -10
  15. package/lib/arrival.js.map +1 -1
  16. package/lib/attach.d.ts +46 -0
  17. package/lib/attach.js +120 -0
  18. package/lib/attach.js.map +1 -0
  19. package/lib/auto-save.d.ts +74 -0
  20. package/lib/auto-save.js +175 -26
  21. package/lib/auto-save.js.map +1 -1
  22. package/lib/client.js +413 -11
  23. package/lib/client.js.map +1 -1
  24. package/lib/commands.d.ts +8 -1
  25. package/lib/commands.js +135 -7
  26. package/lib/commands.js.map +1 -1
  27. package/lib/compose.js +25 -6
  28. package/lib/compose.js.map +1 -1
  29. package/lib/continue-core.d.ts +24 -1
  30. package/lib/continue-core.js +41 -9
  31. package/lib/continue-core.js.map +1 -1
  32. package/lib/diag.d.ts +55 -0
  33. package/lib/diag.js +186 -0
  34. package/lib/diag.js.map +1 -0
  35. package/lib/engine-core.d.ts +53 -1
  36. package/lib/engine-core.js +64 -7
  37. package/lib/engine-core.js.map +1 -1
  38. package/lib/engine.d.ts +99 -0
  39. package/lib/engine.js +488 -36
  40. package/lib/engine.js.map +1 -1
  41. package/lib/enrich-queue.js +28 -2
  42. package/lib/enrich-queue.js.map +1 -1
  43. package/lib/enrich-wire.d.ts +92 -2
  44. package/lib/enrich-wire.js +135 -17
  45. package/lib/enrich-wire.js.map +1 -1
  46. package/lib/enrich.d.ts +27 -2
  47. package/lib/enrich.js +114 -18
  48. package/lib/enrich.js.map +1 -1
  49. package/lib/index.d.ts +52 -0
  50. package/lib/index.js +182 -2
  51. package/lib/index.js.map +1 -1
  52. package/lib/indexing.d.ts +7 -0
  53. package/lib/indexing.js.map +1 -1
  54. package/lib/injections.d.ts +1 -0
  55. package/lib/injections.js +38 -0
  56. package/lib/injections.js.map +1 -1
  57. package/lib/last-resort.d.ts +24 -0
  58. package/lib/last-resort.js +27 -0
  59. package/lib/last-resort.js.map +1 -0
  60. package/lib/prompt.d.ts +130 -0
  61. package/lib/prompt.js +255 -0
  62. package/lib/prompt.js.map +1 -0
  63. package/lib/registry.d.ts +39 -1
  64. package/lib/registry.js +76 -9
  65. package/lib/registry.js.map +1 -1
  66. package/lib/render.d.ts +31 -3
  67. package/lib/render.js +75 -3
  68. package/lib/render.js.map +1 -1
  69. package/lib/repair-run.d.ts +49 -0
  70. package/lib/repair-run.js +423 -0
  71. package/lib/repair-run.js.map +1 -0
  72. package/lib/repair.d.ts +200 -0
  73. package/lib/repair.js +354 -0
  74. package/lib/repair.js.map +1 -0
  75. package/lib/rules-commands.d.ts +22 -0
  76. package/lib/rules-commands.js +177 -20
  77. package/lib/rules-commands.js.map +1 -1
  78. package/lib/rules.d.ts +184 -7
  79. package/lib/rules.js +312 -27
  80. package/lib/rules.js.map +1 -1
  81. package/lib/save-core.d.ts +31 -1
  82. package/lib/save-core.js +153 -17
  83. package/lib/save-core.js.map +1 -1
  84. package/lib/session-rules.d.ts +222 -0
  85. package/lib/session-rules.js +454 -0
  86. package/lib/session-rules.js.map +1 -0
  87. package/lib/store.d.ts +22 -1
  88. package/lib/store.js +94 -17
  89. package/lib/store.js.map +1 -1
  90. package/lib/sync.js +13 -1
  91. package/lib/sync.js.map +1 -1
  92. package/lib/tools.d.ts +14 -0
  93. package/lib/tools.js +321 -32
  94. package/lib/tools.js.map +1 -1
  95. package/lib/types.d.ts +12 -1
  96. package/lib/types.js.map +1 -1
  97. package/lib/version.d.ts +9 -0
  98. package/lib/version.js +10 -0
  99. package/lib/version.js.map +1 -0
  100. package/lib/warnings.d.ts +10 -0
  101. package/lib/warnings.js +18 -0
  102. package/lib/warnings.js.map +1 -0
  103. package/package.json +9 -3
package/lib/tools.js CHANGED
@@ -7,11 +7,18 @@
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import * as fs from 'node:fs';
10
+ import { runRepair, restoreRepair } from "./repair-run.js";
11
+ import { formatWarnings } from "./warnings.js";
12
+ import { ensureDiagnosticsDefault } from "./diag.js";
13
+ import { REPAIR_CLASS_ALIASES, resolveRepairClasses } from "./repair.js";
14
+ import { readDurableSession } from "./auto-save.js";
10
15
  import * as path from 'node:path';
11
- import { estimateTokens, toolResultCandidates } from "./render.js";
16
+ import { candidateTotals, estimateTokens, toolResultCandidates } from "./render.js";
12
17
  import { detectPlans } from "./plans.js";
13
- import { buildRulesSection } from "./rules.js";
18
+ import { makeHeaderParentReader, queryTopics, renderRulesBlock } from "./session-rules.js";
14
19
  import { rulesCommand } from "./rules-commands.js";
20
+ import { diagnosticWarn } from "./diag.js";
21
+ import { attachCreatedChild } from "./attach.js";
15
22
  import { DEFAULT_CLONE_DIR } from "./sync.js";
16
23
  import { lineageOf } from "./types.js";
17
24
  import { composeChapters } from "./compose.js";
@@ -31,8 +38,21 @@ function callerOf(exec) {
31
38
  return agent ?? CALLER_MISSING;
32
39
  }
33
40
  export function buildChaptersTools(ctx, store, config) {
41
+ // #52: the delegation edge for rule ancestry, read from whatever this plane can
42
+ // reach (live handles, else the durable session query). undefined ⇒ the walk
43
+ // follows registry edges only, and a block that truncated says so.
44
+ const ruleParentOf = makeHeaderParentReader((name) => { try {
45
+ return ctx.get?.(name);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ } });
34
50
  const portsFor = async (caller) => {
35
51
  const cwd = caller.session.header.cwd ?? '';
52
+ // #61: a door is touching this project's store, so THIS is where failures get written unless the
53
+ // operator said otherwise. First door wins; `off` in the env still means no file at all.
54
+ if (cwd !== '')
55
+ ensureDiagnosticsDefault(path.join(cwd, config.artifactStoreRoot, 'diagnostics.log'));
36
56
  // every real tool use carries the caller's routed model — record it for
37
57
  // the enrichment resolver (the only route source the host plane can have)
38
58
  if (caller.options.provider !== undefined && caller.options.model !== undefined) {
@@ -75,6 +95,7 @@ export function buildChaptersTools(ctx, store, config) {
75
95
  return { windowTokens, headerBoundTokens };
76
96
  },
77
97
  newId: () => randomUUID(),
98
+ diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
78
99
  createChild: async ({ sessionId, noticeEvent, modeEvents, presetId: mountPreset, title }) => {
79
100
  const callerOptions = caller.options;
80
101
  // The seed is contiguous from seq 0: the notice, then any STATE events this
@@ -95,17 +116,20 @@ export function buildChaptersTools(ctx, store, config) {
95
116
  await presets.mount(agentCtx, mountPreset);
96
117
  },
97
118
  });
98
- try {
99
- const workspaceRegistry = ctx.get?.('workspaceRegistry');
100
- if (cwd !== '' && workspaceRegistry?.createCanonical !== undefined) {
101
- const workspace = await workspaceRegistry.createCanonical(cwd);
102
- await workspace?.attachSession?.(sessionId);
103
- }
104
- }
105
- catch (error) {
106
- // An unattached child is durable but may not list — say so, keep going.
107
- ctx.logger?.warn?.(`dsh-chapters: child workspace attach failed (${String(error)})`);
108
- }
119
+ // #54: was an optional chain through a REQUIRED step, inside a catch that only
120
+ // warned the profile logger. On host 0.2.x that turned a reshaped workspace API
121
+ // into a silent no-op — durable child, missing from the sidebar, door reporting
122
+ // success. attachCreatedChild verifies the user-visible fact (is it LISTED) and
123
+ // returns words the door passes through to the user.
124
+ const attach = await attachCreatedChild({
125
+ workspaceRegistry: ctx.get?.('workspaceRegistry'),
126
+ sessionQuery: ctx.get?.('sessionQuery'),
127
+ // the surface the browser reads — a child missing here is invisible and unopenable
128
+ sessionController: ctx.get?.('sessionController'),
129
+ diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
130
+ childId: sessionId,
131
+ cwd,
132
+ });
109
133
  // Title while the child is still live: sessionController.rename resolves
110
134
  // the agent, routes through the official session-title normalizer, and
111
135
  // appends the durable `session/title` event (r6: commands-execute was the
@@ -127,6 +151,7 @@ export function buildChaptersTools(ctx, store, config) {
127
151
  // handle", measured r3). Dispose after the live-work, keep the session.
128
152
  await handle.dispose?.();
129
153
  }
154
+ return { warnings: attach.warnings };
130
155
  },
131
156
  now: () => Date.now(),
132
157
  };
@@ -184,8 +209,25 @@ export function buildChaptersTools(ctx, store, config) {
184
209
  : { planHint: 'a submitted plan is a topic boundary: the chapter that carries it ENDS there, and its title becomes the chapter title' }),
185
210
  // fresh literals, not the ToolResultCandidate interface: anonymous
186
211
  // object types carry the implicit index signature JsonValue needs.
187
- toolResults: toolResultCandidates(below, { title: '', summary: '', startSeq: 0, endSeq: ceiling })
188
- .map((c) => ({ seq: c.seq, toolName: c.toolName, bytes: c.bytes, estimatedTokens: c.estimatedTokens, excerpt: c.excerpt })),
212
+ // #61: the candidate list is CAPPED and says so. The field version returned 49,932 characters
213
+ // while also reporting `Omitted 275266 bytes` — ~81K est tokens into a 32K-class window, asking
214
+ // the model to keep/drop-judge a list it had not received. Ranking is by size descending, so the
215
+ // cap removes the SMALL entries and the big ones a real decision turns on stay visible.
216
+ ...(() => {
217
+ const range = { title: '', summary: '', startSeq: 0, endSeq: ceiling };
218
+ const cap = config.segmentCandidateCapTokens ?? 4000;
219
+ const kept = cap > 0
220
+ ? toolResultCandidates(below, range, cap)
221
+ : toolResultCandidates(below, range);
222
+ const totals = candidateTotals(below, range);
223
+ const withheld = Math.max(0, totals.count - kept.length);
224
+ const withheldBytes = Math.max(0, totals.bytes - kept.reduce((m, c) => m + c.bytes, 0));
225
+ return {
226
+ toolResults: kept.map((c) => ({ seq: c.seq, toolName: c.toolName, bytes: c.bytes, estimatedTokens: c.estimatedTokens, excerpt: c.excerpt })),
227
+ toolResultsReport: `showing ${kept.length} of ${totals.count} candidates by size, ${(withheldBytes / 1024).toFixed(0)} KB withheld`
228
+ + (cap > 0 ? ` (segmentCandidateCapTokens=${cap})` : ' (uncapped)'),
229
+ };
230
+ })(),
189
231
  existingChapters: callerState.chapters.map((c) => ({
190
232
  number: c.number, path: c.path, title: c.title, startSeq: c.startSeq, endSeq: c.endSeq,
191
233
  })),
@@ -244,7 +286,7 @@ export function buildChaptersTools(ctx, store, config) {
244
286
  await config.scheduler.pullFor(cwd).catch(() => undefined); // §5.1: pull → archive → push
245
287
  }
246
288
  const line = projectLineFor(caller);
247
- const rs = rulesSectionFor(caller);
289
+ const rs = await rulesSectionFor(caller, 'continue');
248
290
  if (rs.refusal !== undefined)
249
291
  return { ok: false, reason: rs.refusal };
250
292
  const result = await runContinue(ports, {
@@ -256,7 +298,7 @@ export function buildChaptersTools(ctx, store, config) {
256
298
  toolResultOverrides: args.toolResultOverrides ?? [],
257
299
  ...(line !== undefined ? { projectLine: line } : {}),
258
300
  ...(rs.section !== undefined ? { rulesSection: rs.section } : {}),
259
- }, config);
301
+ }, { ...config, workspaceCwd: cwd });
260
302
  if (result.ok && cwd !== '')
261
303
  config.scheduler?.schedule(cwd, 'archive:continue');
262
304
  // conditional spreads: absent optionals must not surface as `undefined`
@@ -306,7 +348,7 @@ export function buildChaptersTools(ctx, store, config) {
306
348
  await config.scheduler.pullFor(cwd).catch(() => undefined);
307
349
  }
308
350
  const line = projectLineFor(caller);
309
- const rs = rulesSectionFor(caller);
351
+ const rs = await rulesSectionFor(caller, 'branch');
310
352
  if (rs.refusal !== undefined)
311
353
  return { ok: false, reason: rs.refusal };
312
354
  const result = await runFork(ports, {
@@ -362,7 +404,7 @@ export function buildChaptersTools(ctx, store, config) {
362
404
  const parentTitle = [...events].reverse()
363
405
  .find((e) => e.type === 'session/title')?.data?.title;
364
406
  const title = trimmed !== '' ? trimmed : `${parentTitle ?? `Chapters ${door.noun}`} \u2014 ${door.noun}`;
365
- const rsCmd = rulesSectionFor(agent);
407
+ const rsCmd = await rulesSectionFor(agent, door.intent);
366
408
  if (rsCmd.refusal !== undefined)
367
409
  return { kind: 'error', text: `${door.name} refused: ${rsCmd.refusal}` };
368
410
  const callerArgs = {
@@ -405,17 +447,21 @@ export function buildChaptersTools(ctx, store, config) {
405
447
  : ['no stored turn signatures — legacy size-based segmentation'];
406
448
  const handoffNote = `Archived through the Chapters ${door.noun}. The chapters listed above carry every prior word verbatim — reload any with the read tool on its path. ${door.noDirective}`
407
449
  + (notes.length > 0 ? `\n(Segmentation notes: ${notes.join('; ')})` : '');
408
- const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, config);
450
+ const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, { ...config, workspaceCwd: cwd0 });
409
451
  if (result.ok && cwd0 !== '')
410
452
  config.scheduler?.schedule(cwd0, 'archive:fork');
411
453
  const budgetText = result.budget !== undefined ? `; TOC ~${result.budget.usedTokens} tokens, allowance ${result.budget.allowanceTokens}` : '';
412
- return { kind: 'success', text: `${door.verb}: ${chapters.length} new chapter(s) archived (watermark respected: seqs ${fromSeq}..${anchor}), ${door.noun} \u201C${title}\u201D is session ${result.childSessionId ?? '(created)'}${budgetText}. Switch to it from the sidebar — its first message is the table of contents.` };
454
+ // #61: the archive happened; the caveats were in `result.warnings` and were NEVER printed here.
455
+ const dWarn = formatWarnings(result.warnings ?? [], config.warningsDisplayCap ?? 6);
456
+ return { kind: 'success', text: `${door.verb}: ${chapters.length} new chapter(s) archived (watermark respected: seqs ${fromSeq}..${anchor}), ${door.noun} \u201C${title}\u201D is session ${result.childSessionId ?? '(created)'}${budgetText}. Switch to it from the sidebar — its first message is the table of contents.`
457
+ + (dWarn === '' ? '' : `\n warnings: ${dWarn}`) };
413
458
  }
414
459
  catch (error) {
415
460
  const refusal = refusalResult(error);
416
461
  if (refusal !== null) {
417
462
  const budget = refusal.budget !== undefined ? ` (notice ~${refusal.budget.usedTokens} > allowance ${refusal.budget.allowanceTokens})` : '';
418
- return { kind: 'error', text: `${door.name} refused: ${refusal.reason ?? 'refused'}${budget}` };
463
+ const rWarn = formatWarnings(refusal.warnings ?? [], config.warningsDisplayCap ?? 6);
464
+ return { kind: 'error', text: `${door.name} refused: ${refusal.reason ?? 'refused'}${budget}${rWarn === '' ? '' : `\n warnings: ${rWarn}`}` };
419
465
  }
420
466
  return { kind: 'error', text: `${door.name} failed: ${String(error?.message ?? error)}`.slice(0, 500) };
421
467
  }
@@ -471,6 +517,7 @@ export function buildChaptersTools(ctx, store, config) {
471
517
  toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
472
518
  mergeThreshold: config.mergeThreshold,
473
519
  chapterLimit: config.chapterLimit,
520
+ chapterOversizeFactor: config.chapterOversizeFactor,
474
521
  ...(config.planInlineChars !== undefined ? { planInlineChars: config.planInlineChars } : {}),
475
522
  ...(config.callArgArtifactTokens !== undefined ? { callArgArtifactTokens: config.callArgArtifactTokens } : {}),
476
523
  },
@@ -491,8 +538,12 @@ export function buildChaptersTools(ctx, store, config) {
491
538
  // Schedule sync so saved chapters travel to the pool.
492
539
  if (cwd0 !== '')
493
540
  config.scheduler?.schedule(cwd0, 'archive:save');
494
- const warnings = out.warnings.length > 0 ? ` (${out.warnings.length} warning(s))` : '';
495
- return { kind: 'success', text: `Saved: ${out.records.length} chapter(s) archived (seqs ${out.fromSeq}..${out.anchor})${warnings}. Enrichment queued. Content is searchable via chapters_search from any session in this workspace.` };
541
+ // #61: this used to be ` (86 warning(s))` — a COUNT that carried none of the 86 messages, six of
542
+ // which named the hollow chapter and an unarchived 0-4082 hole. A count is not a message.
543
+ const warnText = formatWarnings(out.warnings, config.warningsDisplayCap ?? 6);
544
+ return { kind: 'success', text: `Saved: ${out.records.length} chapter(s) archived (seqs ${out.fromSeq}..${out.anchor}).`
545
+ + (warnText === '' ? '' : `\n warnings: ${warnText}`)
546
+ + ` Enrichment queued. Content is searchable via chapters_search from any session in this workspace.` };
496
547
  }
497
548
  catch (error) {
498
549
  return { kind: 'error', text: `chapters-save failed: ${String(error?.message ?? error)}`.slice(0, 500) };
@@ -515,17 +566,62 @@ export function buildChaptersTools(ctx, store, config) {
515
566
  return undefined;
516
567
  }
517
568
  };
518
- /** P3 §7.3: this machine's rules block for a continuation notice. */
519
- const rulesSectionFor = (agent) => {
569
+ /**
570
+ * §7.3/§7.6: the rules block for a head this door is about to build, through
571
+ * the ONE renderer the compaction checkpoint also uses
572
+ * (`session-rules.renderRulesBlock`) — a session must never see two rule sets
573
+ * depending on which path rebuilt its head.
574
+ *
575
+ * `intent` is the whole semantic difference between the two doors, and it was
576
+ * already the axis that decides plan bytes (docs/plans-carriage.md §§ 9–13): a
577
+ * CONTINUATION extends the same stream of work, so it inherits the session
578
+ * tier; a BRANCH splits a new direction, so it gets the project tier and ZERO
579
+ * session bytes — a branch is not an order.
580
+ */
581
+ const rulesSectionFor = async (agent, intent) => {
520
582
  try {
521
583
  if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined)
522
584
  return {};
523
585
  const cwd = agent.session.header?.cwd ?? '';
524
- const best = projectForCwd(store.projects(), cwd);
525
- if (best === undefined)
586
+ if (cwd === '')
526
587
  return {};
527
- const built = buildRulesSection(path.join(cwd, DEFAULT_CLONE_DIR), {
528
- projectKey: best.projectKey, harnessId: config.harnessId, budgetTokens: config.coreRulesBudgetTokens,
588
+ const best = projectForCwd(store.projects(), cwd);
589
+ // §7.8: a session with NO project resolves normally renders zero rule bytes —
590
+ // and silence is exactly what this round exists to remove. But adding a
591
+ // "nothing is linked" line to EVERY unlinked workspace's head would spend real
592
+ // bytes on a warning most sessions do not need, so the line appears only when
593
+ // the session tier IS governing the head: then "0 project rules" is a fact the
594
+ // child must not misread as "the team approved nothing".
595
+ if (best === undefined) {
596
+ const orphan = await renderRulesBlock({
597
+ mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
598
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
599
+ projectKey: '',
600
+ harnessId: config.harnessId,
601
+ budgetTokens: config.coreRulesBudgetTokens,
602
+ relevance: config.rulesRelevance ?? true,
603
+ includeSession: intent === 'continue',
604
+ store,
605
+ sessionId: agent.session.id,
606
+ ...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
607
+ });
608
+ if (orphan.kind !== 'ok' || orphan.shown.session === 0)
609
+ return {};
610
+ return { section: `0 project rules — this checkout resolves no knowledge project (${cwd}); your session rules below are local only. Link one with /chapters-link to inherit the team's.\n\n${orphan.kind === 'ok' ? orphan.text : ''}` };
611
+ }
612
+ const built = await renderRulesBlock({
613
+ mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
614
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
615
+ projectKey: best.projectKey,
616
+ harnessId: config.harnessId,
617
+ budgetTokens: config.coreRulesBudgetTokens,
618
+ ...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
619
+ ...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
620
+ relevance: config.rulesRelevance ?? true,
621
+ includeSession: intent === 'continue',
622
+ store,
623
+ sessionId: agent.session.id,
624
+ ...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
529
625
  });
530
626
  if (built.kind === 'none')
531
627
  return {};
@@ -710,7 +806,198 @@ export function buildChaptersTools(ctx, store, config) {
710
806
  }
711
807
  },
712
808
  });
713
- return { segment: chaptersSegment, chaptersContinue, chaptersFork, chaptersSearch, chaptersArtifact, chaptersRulePropose, forkCommand, continueCommand, saveCommand };
809
+ // ------------------------------------------------- #50 §7.9: the assembly door
810
+ //
811
+ // The ONE rule verb a session is told to reach for. Capture and carriage are
812
+ // invisible to the model (§7.9); this is the exception, because a tool an
813
+ // instruction never names does not exist to a small model — and because the
814
+ // head can only show a capped, relevance-selected subset, so "what actually
815
+ // applies to THIS task" needs a door that answers with the text.
816
+ const chaptersRules = defineTool({
817
+ name: 'chapters_rules',
818
+ description: 'Assemble the rules that govern a task: the approved project tier plus this session tree\u2019s '
819
+ + 'captured session rules, verbatim, within budget. Run it BEFORE starting a task. The answer '
820
+ + 'names what it withheld, so "you are seeing all of them" is a fact rather than a guess.',
821
+ parameters: {
822
+ query: { type: 'string', required: true, description: 'What the session is about to do, in a sentence or two \u2014 it selects relevance.' },
823
+ scope: { type: 'string', description: "'all' (default) or 'project' to see only the shared tier a branch would inherit." },
824
+ maxTokens: { type: 'number', description: 'Token ceiling for this answer (defaults to coreRulesBudgetTokens).' },
825
+ },
826
+ output: jsonOutput((value) => JSON.stringify(value, null, 1)),
827
+ async execute(args, exec) {
828
+ const caller = callerOf(exec);
829
+ if ('reason' in caller)
830
+ return { ...CALLER_MISSING };
831
+ if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined) {
832
+ return { ok: false, error: 'rules surface not configured (harnessId/budget absent)' };
833
+ }
834
+ const cwd = caller.session.header?.cwd ?? '';
835
+ const best = cwd === '' ? undefined : projectForCwd(store.projects(), cwd);
836
+ try {
837
+ const built = await renderRulesBlock({
838
+ mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
839
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
840
+ projectKey: best?.projectKey ?? '',
841
+ harnessId: config.harnessId,
842
+ budgetTokens: args.maxTokens ?? config.coreRulesBudgetTokens,
843
+ ...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
844
+ ...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
845
+ relevance: config.rulesRelevance ?? true,
846
+ includeSession: args.scope !== 'project',
847
+ // the QUERY selects here (that is the door's whole job), not the session's
848
+ // own recent signatures — which is what a rebuilt head selects on
849
+ topicsOverride: queryTopics(args.query),
850
+ ...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
851
+ store,
852
+ sessionId: caller.session.id,
853
+ });
854
+ if (built.kind === 'refusal')
855
+ return { ok: false, error: built.reason };
856
+ if (built.kind === 'none') {
857
+ // §7.8: "nothing applies" and "nothing is reachable" are different answers
858
+ return {
859
+ ok: true, shown: { project: 0, session: 0 }, withheld: { project: 0, session: 0 }, text: '',
860
+ note: best === undefined
861
+ ? `no knowledge project resolves for ${cwd} \u2014 nothing is approved here AND no pool is linked (/chapters-link); session rules still apply once captured`
862
+ : 'no active rules: approved project rules need a human approve ON THIS MACHINE (/chapters-rule list --all), and this session tree has captured none',
863
+ };
864
+ }
865
+ const note = [
866
+ built.withheld.project + built.withheld.session > 0
867
+ ? `withheld ${built.withheld.project} project / ${built.withheld.session} session rule(s) over the display caps \u2014 /chapters-rule active lists all, pin one to keep it in every head`
868
+ : 'nothing withheld: this is the complete active set',
869
+ ...(best === undefined ? ['no linked project for this cwd \u2014 the project tier is empty by construction (/chapters-link)'] : []),
870
+ ];
871
+ return { ok: true, shown: built.shown, withheld: built.withheld, text: built.text, tokens: built.tokens, note: note.join('; ') };
872
+ }
873
+ catch (error) {
874
+ return { ok: false, error: `chapters_rules failed: ${String(error?.message ?? error).slice(0, 200)}` };
875
+ }
876
+ },
877
+ });
878
+ /**
879
+ * `/chapters-repair` — the operator door of the repair engine (#60). Read-only unless `apply` is
880
+ * named, and `apply` still cannot delete or rewrite anything: damage is SUPERSEDED under a new
881
+ * number, bad files are COPIED to `.repair-quarantine/`, and every run is journalled so `restore`
882
+ * can walk it backwards (docs/architecture.md § Repair).
883
+ *
884
+ * It repairs the sessions of THIS session's project — found by reading each registry row's durable
885
+ * header through `readDurableSession`, the same payload-bearing seam door 5 uses (#58). Rows whose
886
+ * log cannot be read are reported as unrecoverable rather than quietly skipped, because "nothing to
887
+ * repair" and "cannot repair" are different sentences.
888
+ */
889
+ const repairCommand = {
890
+ name: 'chapters-repair',
891
+ description: 'Audit the archive against the durable log; repair what the registry lost. Read-only by default; never deletes, never rewrites a chapter body.',
892
+ input: { hint: '[audit | dry-run | apply <all|hollow|gaps|citations|bookkeeping|files> | status | journal [id] | restore <journal-id>]' },
893
+ async handler(invocation) {
894
+ const agent = invocation.agent;
895
+ if (agent?.session === undefined)
896
+ return { kind: 'error', text: 'chapters-repair: no session context for this invocation' };
897
+ const cwd0 = agent.session.header?.cwd ?? '';
898
+ if (cwd0 === '')
899
+ return { kind: 'error', text: 'chapters-repair: this session has no workspace cwd — no store to inspect' };
900
+ const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((x) => x.length > 0);
901
+ const verb = parts[0] ?? 'audit';
902
+ const get = (name) => { try {
903
+ return ctx.get?.(name);
904
+ }
905
+ catch {
906
+ return undefined;
907
+ } };
908
+ // targets: rows of this project, proven by their OWN durable header (never a path guess)
909
+ const ids = [];
910
+ // #101: a session the kernel will not open is named and skipped, not fatal — and it is stated in
911
+ // the output, because a repair that quietly omits sessions would report a healthy store it never
912
+ // looked at.
913
+ const unreadable = [];
914
+ const noteUnreadable = (id) => (m) => { unreadable.push(`${id}: ${m}`); };
915
+ for (const [id] of store.sessions()) {
916
+ const view = await readDurableSession(get, id, noteUnreadable(id));
917
+ if (view?.header.cwd !== undefined && path.resolve(view.header.cwd) === path.resolve(cwd0))
918
+ ids.push(id);
919
+ }
920
+ if (ids.length === 0)
921
+ return { kind: 'error', text: `chapters-repair: no session row under ${cwd0} has a readable durable log — nothing to audit` };
922
+ // roots this registry claims, so sibling session trees are not reported as litter
923
+ const roots = (await Promise.all(ids.map(async (id) => (await store.get(id)).rootSession)));
924
+ if (verb === 'status' || verb === 'journal') {
925
+ const lines = [];
926
+ for (const id of ids) {
927
+ const st = await store.get(id);
928
+ const dir = path.join(cwd0, config.artifactStoreRoot, st.rootSession, 'repairs');
929
+ if (!fs.existsSync(dir))
930
+ continue;
931
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
932
+ const j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
933
+ lines.push(`${j.id} ${id.slice(0, 14)}… ${j.completedAt === null ? 'UNFINISHED' : 'done'} ${j.findingsBefore}→${j.findingsAfter} findings ${j.entries.length} move(s)`);
934
+ }
935
+ }
936
+ if (verb === 'journal' && parts[1] !== undefined) {
937
+ const hit = lines.find((l) => l.startsWith(parts[1]));
938
+ return hit !== undefined ? { kind: 'success', text: hit } : { kind: 'error', text: `no journal ${parts[1]} in this project` };
939
+ }
940
+ return { kind: 'success', text: lines.length > 0 ? lines.join('\n') : 'no repair journals yet in this project' };
941
+ }
942
+ if (verb === 'restore') {
943
+ const jid = parts[1];
944
+ if (jid === undefined)
945
+ return { kind: 'error', text: 'restore needs a journal id (see `/chapters-repair status`)' };
946
+ const results = [];
947
+ const outcomes2 = [];
948
+ for (const id of ids) {
949
+ const r = await restoreRepair({ store, readLog: async (sid) => (await readDurableSession(get, sid))?.events ?? null, cwd: cwd0, config, log: () => { }, warn: (m) => results.push(`! ${m}`) }, jid, id);
950
+ outcomes2.push(r);
951
+ if (r.undone > 0 || r.problems.length > 0)
952
+ results.push(`${id.slice(0, 14)}…: ${r.undone} undone${r.problems.length > 0 ? `; ${r.problems.join('; ')}` : ''}`);
953
+ }
954
+ const anyUndone = outcomes2.some((o) => o.undone > 0);
955
+ return {
956
+ kind: anyUndone ? 'success' : 'error',
957
+ text: results.length > 0 ? results.join('\n') : `journal ${jid} matched no session row here — files were NOT touched`,
958
+ };
959
+ }
960
+ const dryRun = verb !== 'apply';
961
+ // ONE resolver, shared with the offline script (src/repair.ts). An unknown name is a REFUSAL:
962
+ // the first version fell through to `[which]`, matched nothing, and reported success while doing
963
+ // nothing — the silent no-op is the defect class this issue exists to end (#60).
964
+ const resolved = resolveRepairClasses((parts[1] ?? 'all').split(','));
965
+ if (resolved.unknown.length > 0) {
966
+ return { kind: 'error', text: `chapters-repair: unknown class(es) ${resolved.unknown.join(', ')}\n known: ${Object.keys(REPAIR_CLASS_ALIASES).join(', ')} — nothing was applied` };
967
+ }
968
+ const classes = resolved.classes;
969
+ const outcomes = [];
970
+ let actionable = 0;
971
+ for (const id of ids) {
972
+ const view = await readDurableSession(get, id, noteUnreadable(id));
973
+ if (view === undefined)
974
+ continue;
975
+ const out = await runRepair({ store, readLog: async (sid) => (await readDurableSession(get, sid))?.events ?? null, cwd: cwd0, config, knownRoots: roots, log: (m) => outcomes.push(`+ ${m}`), warn: (m) => outcomes.push(`! ${m}`) }, { sessionId: id, classes: dryRun ? 'all' : classes, dryRun });
976
+ actionable += out.findingsBefore.filter((f) => f.remedy !== 'report-only').length;
977
+ if (out.findingsBefore.length > 0 || out.applied.length > 0) {
978
+ const by = out.findingsBefore.reduce((m, f) => ({ ...m, [f.class]: (m[f.class] ?? 0) + 1 }), {});
979
+ outcomes.push(`${id.slice(0, 14)}…: ${out.findingsBefore.length} finding(s) ${JSON.stringify(by)}${out.blindSpots.length > 0 ? ` blind to ${out.blindSpots.join(',')}` : ''}`);
980
+ for (const r of out.refused)
981
+ outcomes.push(` refused: ${r}`);
982
+ if (!dryRun)
983
+ outcomes.push(` → ${out.applied.length} move(s) journalled ${out.journalId}; ${out.findingsAfter.length} finding(s) remain`);
984
+ }
985
+ }
986
+ const head = dryRun
987
+ ? `chapters-repair ${verb === 'audit' ? 'audit' : 'dry-run'} — ${ids.length} session(s): ${actionable} actionable finding(s). NOTHING WRITTEN.`
988
+ : `chapters-repair applied to ${ids.length} session(s) — see the per-session counts below.`;
989
+ const tail = dryRun
990
+ ? '\napply: /chapters-repair apply all (supersede under a new number; copy bad files to .repair-quarantine/; journal everything)'
991
+ : '';
992
+ // The refusal lines lead the report: they are the reason the count below is not the whole story.
993
+ const skipped = unreadable.length > 0
994
+ ? [`${String(new Set(unreadable.map((l) => l.split(':')[0])).size)} session(s) could not be read and were NOT repaired:`,
995
+ ...[...new Set(unreadable)].slice(0, 12).map((l) => ` ! ${l}`)]
996
+ : [];
997
+ return { kind: 'success', text: [head, ...skipped, ...outcomes, tail].join('\n') };
998
+ },
999
+ };
1000
+ return { segment: chaptersSegment, chaptersContinue, chaptersFork, chaptersSearch, chaptersArtifact, chaptersRulePropose, chaptersRules, forkCommand, continueCommand, saveCommand, repairCommand };
714
1001
  }
715
1002
  export function registerChaptersTools(ctx, store, config) {
716
1003
  const built = buildChaptersTools(ctx, store, config);
@@ -720,6 +1007,7 @@ export function registerChaptersTools(ctx, store, config) {
720
1007
  const disposeSearch = ctx.tools.register(built.chaptersSearch);
721
1008
  const disposeArtifact = ctx.tools.register(built.chaptersArtifact);
722
1009
  const disposeRulePropose = ctx.tools.register(built.chaptersRulePropose);
1010
+ const disposeRules = ctx.tools.register(built.chaptersRules);
723
1011
  let disposeCommand = null;
724
1012
  const commands = ctx.get?.('commands');
725
1013
  if (commands?.register !== undefined) {
@@ -728,7 +1016,7 @@ export function registerChaptersTools(ctx, store, config) {
728
1016
  // that overwrote `disposeCommand` would leak the first registration and unregister
729
1017
  // only whichever door was added last.
730
1018
  const commandDisposers = [];
731
- for (const def of [built.forkCommand, built.continueCommand, built.saveCommand]) {
1019
+ for (const def of [built.forkCommand, built.continueCommand, built.saveCommand, built.repairCommand]) {
732
1020
  const disposer = commands.register(def);
733
1021
  if (typeof disposer === 'function')
734
1022
  commandDisposers.push(disposer);
@@ -751,6 +1039,7 @@ export function registerChaptersTools(ctx, store, config) {
751
1039
  disposeSearch();
752
1040
  disposeArtifact();
753
1041
  disposeRulePropose();
1042
+ disposeRules();
754
1043
  disposeCommand?.();
755
1044
  };
756
1045
  }