@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.20

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 (195) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +76 -2
  3. package/cordis.patch.yml +363 -0
  4. package/docs/architecture.md +692 -0
  5. package/docs/autorun-guide.md +248 -0
  6. package/docs/backlog.md +2022 -0
  7. package/docs/contract.md +938 -0
  8. package/docs/development.md +990 -0
  9. package/docs/host-compaction-seam.md +225 -0
  10. package/docs/host-protected-head-ask.md +80 -0
  11. package/docs/knowledge-repo.md +1332 -0
  12. package/docs/plans-carriage.md +571 -0
  13. package/docs/project-state.md +642 -0
  14. package/docs/provider.md +98 -0
  15. package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
  16. package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
  17. package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
  18. package/docs/verify.md +561 -0
  19. package/lib/archive.d.ts +161 -0
  20. package/lib/archive.js +229 -0
  21. package/lib/archive.js.map +1 -0
  22. package/lib/arrival.d.ts +23 -0
  23. package/lib/arrival.js +125 -0
  24. package/lib/arrival.js.map +1 -0
  25. package/lib/artifact-query.d.ts +62 -0
  26. package/lib/artifact-query.js +187 -0
  27. package/lib/artifact-query.js.map +1 -0
  28. package/lib/attach.d.ts +46 -0
  29. package/lib/attach.js +120 -0
  30. package/lib/attach.js.map +1 -0
  31. package/lib/auto-save.d.ts +145 -0
  32. package/lib/auto-save.js +334 -0
  33. package/lib/auto-save.js.map +1 -0
  34. package/lib/aux-message.d.ts +17 -0
  35. package/lib/aux-message.js +20 -0
  36. package/lib/aux-message.js.map +1 -0
  37. package/lib/client.js +1419 -0
  38. package/lib/client.js.map +1 -0
  39. package/lib/codegraph.d.ts +76 -0
  40. package/lib/codegraph.js +190 -0
  41. package/lib/codegraph.js.map +1 -0
  42. package/lib/commands.d.ts +52 -0
  43. package/lib/commands.js +946 -0
  44. package/lib/commands.js.map +1 -0
  45. package/lib/compose.d.ts +30 -0
  46. package/lib/compose.js +185 -0
  47. package/lib/compose.js.map +1 -0
  48. package/lib/continue-core.d.ts +245 -0
  49. package/lib/continue-core.js +405 -0
  50. package/lib/continue-core.js.map +1 -0
  51. package/lib/diag.d.ts +55 -0
  52. package/lib/diag.js +186 -0
  53. package/lib/diag.js.map +1 -0
  54. package/lib/engine-core.d.ts +301 -0
  55. package/lib/engine-core.js +531 -0
  56. package/lib/engine-core.js.map +1 -0
  57. package/lib/engine-note.d.ts +35 -0
  58. package/lib/engine-note.js +32 -0
  59. package/lib/engine-note.js.map +1 -0
  60. package/lib/engine.d.ts +329 -0
  61. package/lib/engine.js +1179 -0
  62. package/lib/engine.js.map +1 -0
  63. package/lib/enrich-queue.d.ts +66 -0
  64. package/lib/enrich-queue.js +126 -0
  65. package/lib/enrich-queue.js.map +1 -0
  66. package/lib/enrich-store.d.ts +31 -0
  67. package/lib/enrich-store.js +103 -0
  68. package/lib/enrich-store.js.map +1 -0
  69. package/lib/enrich-wire.d.ts +163 -0
  70. package/lib/enrich-wire.js +353 -0
  71. package/lib/enrich-wire.js.map +1 -0
  72. package/lib/enrich.d.ts +102 -0
  73. package/lib/enrich.js +267 -0
  74. package/lib/enrich.js.map +1 -0
  75. package/lib/gitops.d.ts +107 -0
  76. package/lib/gitops.js +539 -0
  77. package/lib/gitops.js.map +1 -0
  78. package/lib/index.d.ts +241 -0
  79. package/lib/index.js +1162 -0
  80. package/lib/index.js.map +1 -0
  81. package/lib/indexing.d.ts +98 -0
  82. package/lib/indexing.js +223 -0
  83. package/lib/indexing.js.map +1 -0
  84. package/lib/injections.d.ts +90 -0
  85. package/lib/injections.js +165 -0
  86. package/lib/injections.js.map +1 -0
  87. package/lib/last-resort.d.ts +24 -0
  88. package/lib/last-resort.js +27 -0
  89. package/lib/last-resort.js.map +1 -0
  90. package/lib/log-shape.d.ts +38 -0
  91. package/lib/log-shape.js +93 -0
  92. package/lib/log-shape.js.map +1 -0
  93. package/lib/models-command.d.ts +74 -0
  94. package/lib/models-command.js +100 -0
  95. package/lib/models-command.js.map +1 -0
  96. package/lib/notice.d.ts +74 -0
  97. package/lib/notice.js +92 -0
  98. package/lib/notice.js.map +1 -0
  99. package/lib/plans.d.ts +154 -0
  100. package/lib/plans.js +315 -0
  101. package/lib/plans.js.map +1 -0
  102. package/lib/preset.d.ts +186 -0
  103. package/lib/preset.js +212 -0
  104. package/lib/preset.js.map +1 -0
  105. package/lib/prompt-keys.d.ts +7 -0
  106. package/lib/prompt-keys.js +8 -0
  107. package/lib/prompt-keys.js.map +1 -0
  108. package/lib/prompt-loop.d.ts +73 -0
  109. package/lib/prompt-loop.js +147 -0
  110. package/lib/prompt-loop.js.map +1 -0
  111. package/lib/prompt-push.d.ts +47 -0
  112. package/lib/prompt-push.js +115 -0
  113. package/lib/prompt-push.js.map +1 -0
  114. package/lib/prompt-runner.d.ts +48 -0
  115. package/lib/prompt-runner.js +90 -0
  116. package/lib/prompt-runner.js.map +1 -0
  117. package/lib/prompt-state.d.ts +142 -0
  118. package/lib/prompt-state.js +101 -0
  119. package/lib/prompt-state.js.map +1 -0
  120. package/lib/prompt.d.ts +270 -0
  121. package/lib/prompt.js +408 -0
  122. package/lib/prompt.js.map +1 -0
  123. package/lib/provider.d.ts +44 -0
  124. package/lib/provider.js +66 -0
  125. package/lib/provider.js.map +1 -0
  126. package/lib/redact.d.ts +24 -0
  127. package/lib/redact.js +85 -0
  128. package/lib/redact.js.map +1 -0
  129. package/lib/registry.d.ts +187 -0
  130. package/lib/registry.js +235 -0
  131. package/lib/registry.js.map +1 -0
  132. package/lib/removal.d.ts +159 -0
  133. package/lib/removal.js +556 -0
  134. package/lib/removal.js.map +1 -0
  135. package/lib/render.d.ts +97 -0
  136. package/lib/render.js +530 -0
  137. package/lib/render.js.map +1 -0
  138. package/lib/repair-run.d.ts +76 -0
  139. package/lib/repair-run.js +506 -0
  140. package/lib/repair-run.js.map +1 -0
  141. package/lib/repair.d.ts +208 -0
  142. package/lib/repair.js +357 -0
  143. package/lib/repair.js.map +1 -0
  144. package/lib/repo.d.ts +78 -0
  145. package/lib/repo.js +184 -0
  146. package/lib/repo.js.map +1 -0
  147. package/lib/rules-commands.d.ts +46 -0
  148. package/lib/rules-commands.js +379 -0
  149. package/lib/rules-commands.js.map +1 -0
  150. package/lib/rules.d.ts +267 -0
  151. package/lib/rules.js +478 -0
  152. package/lib/rules.js.map +1 -0
  153. package/lib/save-core.d.ts +72 -0
  154. package/lib/save-core.js +220 -0
  155. package/lib/save-core.js.map +1 -0
  156. package/lib/search.d.ts +112 -0
  157. package/lib/search.js +282 -0
  158. package/lib/search.js.map +1 -0
  159. package/lib/session-rules.d.ts +222 -0
  160. package/lib/session-rules.js +454 -0
  161. package/lib/session-rules.js.map +1 -0
  162. package/lib/signature.d.ts +45 -0
  163. package/lib/signature.js +199 -0
  164. package/lib/signature.js.map +1 -0
  165. package/lib/store.d.ts +156 -0
  166. package/lib/store.js +368 -0
  167. package/lib/store.js.map +1 -0
  168. package/lib/sync.d.ts +220 -0
  169. package/lib/sync.js +810 -0
  170. package/lib/sync.js.map +1 -0
  171. package/lib/tools.d.ts +113 -0
  172. package/lib/tools.js +1090 -0
  173. package/lib/tools.js.map +1 -0
  174. package/lib/treedx/client.d.ts +57 -0
  175. package/lib/treedx/client.js +82 -0
  176. package/lib/treedx/client.js.map +1 -0
  177. package/lib/treedx/provider.d.ts +39 -0
  178. package/lib/treedx/provider.js +479 -0
  179. package/lib/treedx/provider.js.map +1 -0
  180. package/lib/treedx/state.d.ts +27 -0
  181. package/lib/treedx/state.js +65 -0
  182. package/lib/treedx/state.js.map +1 -0
  183. package/lib/types.d.ts +210 -0
  184. package/lib/types.js +31 -0
  185. package/lib/types.js.map +1 -0
  186. package/lib/version.d.ts +9 -0
  187. package/lib/version.js +10 -0
  188. package/lib/version.js.map +1 -0
  189. package/lib/vocabulary.d.ts +41 -0
  190. package/lib/vocabulary.js +118 -0
  191. package/lib/vocabulary.js.map +1 -0
  192. package/lib/warnings.d.ts +10 -0
  193. package/lib/warnings.js +18 -0
  194. package/lib/warnings.js.map +1 -0
  195. package/package.json +151 -4
@@ -0,0 +1,946 @@
1
+ /**
2
+ * Host-side slash commands for the knowledge layer (knowledge-repo.md §5, §2).
3
+ * Both render as command flow nodes — verified to stay OUT of the model's
4
+ * context (record §7.2: in-session UX, zero context cost).
5
+ *
6
+ * /chapters-link show upstream + mirror status
7
+ * /chapters-link <local-path> bind a path upstream (no creds)
8
+ * /chapters-link <https-url> <token> bind a network git upstream
9
+ * /chapters-link treedx+<url>/<repo> <token> bind a TreeDX service upstream
10
+ * /chapters-status last sync result
11
+ * /chapters-models [<provider> [<model>]] discover the ids the three model setters accept (#173)
12
+ * /chapters-enrich run | model … | report P2 enrichment surface (record §6.2)
13
+ * /chapters-prompt model … | off | suggest prompt recommendations (#71, site §28)
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { DEFAULT_CLONE_DIR, planStoreToRepo, projectForCwd, readSyncStatus, readToken, runSync, writeToken, } from "./sync.js";
18
+ import { classifyOffer } from "./prompt-state.js";
19
+ import { isLocalUpstreamUrl, readOrigin, resolveLocalUpstreamPath } from "./gitops.js";
20
+ import { parseKnowledgeRemote, projectKeyForTarget, projectKeyFromRemote, resolveProject } from "./repo.js";
21
+ import { createTreeDxClient } from "./treedx/client.js";
22
+ import { resolveRepoId } from "./treedx/provider.js";
23
+ import { readState } from "./treedx/state.js";
24
+ import { selectProvider } from "./provider.js";
25
+ import { modelsCommand } from "./models-command.js";
26
+ import { resolveStoreRoot } from "./store.js";
27
+ import { diagnosticStatusLine } from "./diag.js";
28
+ import { registryHealth } from "./repair.js";
29
+ import { removedGuard } from "./removal.js";
30
+ import { applyRemoval, planRemoval, readMissingAccess, resolveRemovalId, restoreRemoval } from "./removal.js";
31
+ import { readDurableSession } from "./auto-save.js";
32
+ import { ancestorPath, buildIndex, isRemovedChapter } from "./registry.js";
33
+ import { verifyChapter } from "./archive.js";
34
+ import { searchKnowledge, searchLocalStore } from "./search.js";
35
+ import { makeArchiveFs } from "./store.js";
36
+ /** Door 5 state, stated with its config name so a user can flip it (issue #47). */
37
+ const AUTO_SAVE_LINE = (config) => `\nSubagent auto-save: ${config.subagentAutoSave === false ? 'OFF (subagentAutoSave: false)' : 'ON — chapters-preset children archive their completed turns at settlement'}`;
38
+ const cwdOf = (agent) => (agent?.session?.header?.cwd ?? process.cwd());
39
+ /** branch name (or short oid) the mirror's HEAD points at. */
40
+ function readMirrorHead(mirrorAbs) {
41
+ try {
42
+ const head = fs.readFileSync(path.join(mirrorAbs, '.git', 'HEAD'), 'utf8').trim();
43
+ const m = /^ref: refs\/heads\/(.+)$/.exec(head);
44
+ return m?.[1] ?? head.slice(0, 8);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** store files not yet present in the mirror (the honest pending-push view). */
51
+ function countPending(mirrorAbs, storeDir, projectKey) {
52
+ try {
53
+ return planStoreToRepo(storeDir, projectKey).filter((f) => !fs.existsSync(path.join(mirrorAbs, f.rel))).length;
54
+ }
55
+ catch {
56
+ return -1;
57
+ }
58
+ }
59
+ export function registerHostCommands(ctx, domain, config) {
60
+ const commands = ctx.get?.('commands');
61
+ const disposers = [];
62
+ if (commands?.register === undefined)
63
+ return () => { };
64
+ /**
65
+ * /chapters-link — the upstream configuration surface, three modes:
66
+ * (no args) show the current upstream + mirror state (git-status-like)
67
+ * <local-path> bind the pool to a directory upstream (bare repo created
68
+ * on demand; no credentials — §2.1's local upstreams)
69
+ * <url> <token> bind to an http(s) upstream; the token is REQUIRED unless
70
+ * the host is loopback, and is stored in the DSH home, 0600
71
+ */
72
+ const linkCommand = {
73
+ name: 'chapters-link',
74
+ description: 'Show / set this project\u2019s knowledge upstream: /chapters-link [path | <https-url> <token> | treedx+<url>/<repo> <token>]',
75
+ input: { hint: '[path | <https-remote-url> <token> | treedx+<url>/<repo> <token>]' },
76
+ async handler(invocation) {
77
+ const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
78
+ const cwd = cwdOf(invocation.agent);
79
+ const table = domain.table('projects');
80
+ try {
81
+ // ---- mode 1: no args — the git-status-like view
82
+ if (parts.length === 0) {
83
+ const rec = projectForCwd(table.entries(), cwd);
84
+ if (rec === undefined) {
85
+ return { kind: 'success', text: 'Upstream: none — this workspace is knowledge-local (chapters, forks, signatures all work; nothing syncs).\nSet one: /chapters-link <path> or /chapters-link <https-url> <token>' };
86
+ }
87
+ const mirrorAbs = path.join(cwd, DEFAULT_CLONE_DIR);
88
+ const treedx = (rec.kind ?? 'git') === 'treedx';
89
+ const tstate = treedx ? readState(mirrorAbs) : null;
90
+ const head = treedx ? (tstate?.head !== null && tstate?.head !== undefined ? tstate.head.slice(0, 10) : null) : readMirrorHead(mirrorAbs);
91
+ const origin = treedx ? tstate?.origin ?? null : readOrigin(mirrorAbs);
92
+ const status = readSyncStatus(cwd, config.artifactStoreRoot);
93
+ const pending = countPending(mirrorAbs, resolveStoreRoot(cwd, config.artifactStoreRoot), rec.projectKey);
94
+ const hasToken = readToken(cwd, config.artifactStoreRoot, rec.projectKey) !== undefined;
95
+ const local = isLocalUpstreamUrl(rec.remote);
96
+ const lines = [
97
+ `Provider: ${rec.kind ?? 'git'}`,
98
+ `Upstream: ${rec.remote}`,
99
+ `Bound: ${rec.slug} · key ${rec.projectKey} · since ${rec.linkedAt}`,
100
+ // §7.8 IDENTITY MUST FAIL LOUDLY. Project rules carry across checkouts
101
+ // because the key derives from the canonicalized REMOTE — so the four
102
+ // ways it can diverge (no remote ⇒ path-derived, a fork listed first,
103
+ // a TreeDX link keyed on the pool, an unlinked clone) are reported here
104
+ // rather than leaving a checkout silently owning its own namespace.
105
+ (() => {
106
+ const derived = resolveProject(cwd, config.projectKeyOverride !== undefined && config.projectKeyOverride !== '' ? config.projectKeyOverride : undefined);
107
+ const where = derived.projectKeySource === 'remote' ? `remote ${derived.remote ?? ''}`
108
+ : derived.projectKeySource === 'override' ? 'config override'
109
+ : `PATH ${cwd} (not the remote — unstable across machines)`;
110
+ return derived.projectKey === rec.projectKey
111
+ ? `Identity: ${derived.projectKeySource} — matches the bound key (${where})`
112
+ : `Identity: ${derived.projectKeySource} ⚠ THIS CHECKOUT RESOLVES A DIFFERENT PROJECT KEY (${derived.projectKey.slice(0, 8)}… vs bound ${rec.projectKey.slice(0, 8)}…) — rules and chapters approved elsewhere will NOT appear here. ${where}`;
113
+ })(),
114
+ diagnosticStatusLine(),
115
+ `Mirror: ${fs.existsSync(mirrorAbs) ? DEFAULT_CLONE_DIR : '(not created yet)'}` +
116
+ (treedx ? ` head ${head ?? (tstate === null ? 'not cloned' : 'empty repo')}${tstate?.offline === true ? ' · OFFLINE (push deferred)' : ''}` : ` branch ${head ?? 'main'}`) +
117
+ (origin !== null && origin !== (treedx ? rec.remote : local ? resolveLocalUpstreamPath(rec.remote, cwd) : rec.remote) ? ` [origin drift: ${origin} — will rebuild]` : ''),
118
+ `Sync: ${status === null ? 'never run' : `${status.mode} @ ${status.at}`}${status !== null && !status.lastOk ? ` — ${status.detail}` : ''}`,
119
+ `Pending: ${pending} file(s) not yet mirrored${hasToken || local ? '' : ' ⚠ no credentials — /chapters-link ' + (treedx ? 'treedx+<url>/<repo> <token>' : '<url> <token>')}`,
120
+ `Harness: ${rec.harnessId}`,
121
+ ];
122
+ return { kind: 'success', text: lines.join('\n') };
123
+ }
124
+ // ---- mode 2: local-path upstream
125
+ const target = parts[0];
126
+ if (!/^https?:\/\//.test(target) && (target.startsWith('/') || target === '~' || target.startsWith('~/') || target.startsWith('./') || target.startsWith('../') || /^file:\/\/\//.test(target))) {
127
+ const resolved = resolveLocalUpstreamPath(target, cwd);
128
+ const record = {
129
+ projectKey: projectKeyFromRemote(`file://${resolved}`),
130
+ slug: path.basename(resolved).replace(/\.git$/, ''),
131
+ remote: resolved,
132
+ harnessId: config.harnessId,
133
+ linkedAt: new Date().toISOString(),
134
+ cwd,
135
+ };
136
+ table.put(record.projectKey, record);
137
+ const sync = await syncNow(cwd, record, undefined);
138
+ return { kind: 'success', text: `Linked to local upstream ${resolved} (a bare pool repo is created there if absent; no credentials needed).\nKey: ${record.projectKey}\nSync: ${sync.mode} \u2014 ${sync.steps.join(' \u2192 ') || sync.detail}` };
139
+ }
140
+ // ---- mode 4: TreeDX service upstream (§15 amendment)
141
+ if (target.startsWith('treedx+')) {
142
+ const parsed = parseKnowledgeRemote(target); // throws on a malformed form — caught below
143
+ const token = parts[1];
144
+ if (token === undefined || token === '') {
145
+ return { kind: 'error', text: 'TreeDX always needs a bearer token: /chapters-link treedx+<url>/<repo> <token> (a local dev container: scripts/treedx-local.sh token mints one)' };
146
+ }
147
+ if (parsed.treedx === undefined)
148
+ return { kind: 'error', text: 'internal: treedx+ target did not parse to a service target' };
149
+ const client = createTreeDxClient({ baseUrl: parsed.treedx.baseUrl, token, fetchTimeoutMs: 15000 });
150
+ // Resolve-or-create the managed repository — linking is an explicit
151
+ // human act (§2.1), so creating the named pool is in scope here and
152
+ // ONLY here. 409 (exists) is a success path. One shared live-shaped
153
+ // catalog parser with the transport (repoCatalog/resolveRepoId) —
154
+ // the first live run broke precisely on parser drift.
155
+ let repoId = await resolveRepoId(client, parsed.treedx.repoName);
156
+ let resolveNote = '';
157
+ if (repoId === '') {
158
+ const created = await client.post('/api/v1/repos', {
159
+ repositoryName: parsed.treedx.repoName, source: { type: 'empty' }, placement: { mode: 'local' },
160
+ });
161
+ if (!created.ok && created.code !== 'conflict' && created.status !== 409) {
162
+ return { kind: 'error', text: `TreeDX repository '${parsed.treedx.repoName}' not found and create failed (${created.detail}) — check the URL/token or create it server-side.` };
163
+ }
164
+ resolveNote = '\nCreated the managed repository (born with refs/heads/main).';
165
+ repoId = await resolveRepoId(client, parsed.treedx.repoName);
166
+ }
167
+ const record = {
168
+ projectKey: projectKeyForTarget(parsed),
169
+ slug: parsed.treedx.repoName,
170
+ remote: target,
171
+ kind: 'treedx',
172
+ ...(repoId !== '' ? { repoId } : {}),
173
+ harnessId: config.harnessId,
174
+ linkedAt: new Date().toISOString(),
175
+ cwd,
176
+ };
177
+ writeToken(record.projectKey, token);
178
+ table.put(record.projectKey, record);
179
+ const sync = await syncNow(cwd, record, token);
180
+ return {
181
+ kind: 'success',
182
+ text: `Linked to TreeDX ${parsed.treedx.baseUrl} repo '${parsed.treedx.repoName}'${repoId !== '' ? ` (${repoId})` : ''}.\nKey: ${record.projectKey}\nCredentials stored (DSH home, 0600).${resolveNote}\nSync: ${sync.mode} — ${sync.steps.join(' → ') || sync.detail}` +
183
+ (sync.ok || sync.mode === 'local-only' ? '' : '\nThe service may be down or the token wrong — /chapters-status carries the detail; nothing else is affected.'),
184
+ };
185
+ }
186
+ // ---- mode 3: https upstream — token required unless loopback
187
+ if (!/^https?:\/\//.test(target)) {
188
+ return { kind: 'error', text: 'usage: /chapters-link [<local-path> | <https-url> <token>] \u2014 (no args shows the current upstream)' };
189
+ }
190
+ const token = parts[1];
191
+ const host = (() => { try {
192
+ return new URL(target).hostname;
193
+ }
194
+ catch {
195
+ return '';
196
+ } })();
197
+ const loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1';
198
+ if (token === undefined && !loopback) {
199
+ return { kind: 'error', text: 'A credential is required for a network upstream: /chapters-link <url> <token> (stored 0600 under the DSH home \u2014 never in the project). Local paths need none: /chapters-link ./vendor/kb.git' };
200
+ }
201
+ const resolved = resolveProject(cwd, undefined, target);
202
+ const record = {
203
+ projectKey: resolved.projectKey,
204
+ slug: (resolved.remote ?? target).split('/').pop().replace(/\.git$/, ''),
205
+ remote: target,
206
+ harnessId: config.harnessId,
207
+ linkedAt: new Date().toISOString(),
208
+ cwd,
209
+ };
210
+ if (token !== undefined)
211
+ writeToken(record.projectKey, token);
212
+ table.put(record.projectKey, record);
213
+ const sync = await syncNow(cwd, record, token ?? readToken(cwd, config.artifactStoreRoot, record.projectKey));
214
+ const hint = sync.ok || sync.mode === 'local-only'
215
+ ? ''
216
+ : '\nThe upstream may not exist yet \u2014 create the private repo (GitHub creates one on push for a suitably-scoped token), or point at a local path: /chapters-link ./vendor/kb.git';
217
+ return { kind: 'success', text: `Linked to ${target}.\nKey: ${record.projectKey}${token === undefined ? '\n(no credentials: loopback upstream)' : '\nCredentials stored (DSH home, 0600).'}\nSync: ${sync.mode} \u2014 ${sync.steps.join(' \u2192 ') || sync.detail}${hint}` };
218
+ }
219
+ catch (error) {
220
+ return { kind: 'error', text: `chapters-link failed: ${String(error?.message ?? error)}`.slice(0, 300) };
221
+ }
222
+ },
223
+ };
224
+ /** one immediate sync pass for a fresh record (explicit project wins). */
225
+ async function syncNow(cwd, record, token) {
226
+ if (config.scheduler !== undefined)
227
+ return config.scheduler.run(cwd, 'link', record);
228
+ return runSync({
229
+ cwd, storeRoot: config.artifactStoreRoot, cloneDir: DEFAULT_CLONE_DIR,
230
+ project: record, ...(token !== undefined && token !== null ? { token } : {}),
231
+ // #255: the guard reads removed stubs from the registry rows this command already holds —
232
+ // a deletion must never be rehydrated back into the store on the pass that publishes it.
233
+ removed: removedGuard(domain.table('sessions').entries()),
234
+ force: true,
235
+ });
236
+ }
237
+ const statusCommand = {
238
+ name: 'chapters-status',
239
+ description: 'Show the knowledge-repo mirror status for this project.',
240
+ input: { hint: '[prints project, remote, and last sync result]' },
241
+ async handler(invocation) {
242
+ const cwd = cwdOf(invocation.agent);
243
+ const project = projectForCwd(domain.table('projects').entries(), cwd);
244
+ const status = readSyncStatus(cwd, config.artifactStoreRoot);
245
+ // #61: this command used to RETURN here when nothing was linked — one sentence, and every health
246
+ // line below it (Enrichment, Diagnostics, Auto-save, Findings) was unreachable precisely for the
247
+ // users who most needed it. An unlinked project is a STATE, not an absence of health.
248
+ const lines = [];
249
+ if (project === undefined && status === null) {
250
+ lines.push('No knowledge repository is linked yet. Link one with /chapters-link <https-remote-url> [token] — '
251
+ + 'until then this project\u2019s chapters, rules and index stay on THIS machine only.');
252
+ }
253
+ if (project !== undefined) {
254
+ const pending = config.scheduler?.hasPending(cwd) === true;
255
+ lines.push(`Project: ${project.slug} \u00B7 key ${project.projectKey}`);
256
+ lines.push(`Remote: ${project.remote}`);
257
+ try {
258
+ lines.push(`Provider: ${selectProvider(project).describe(project)}`);
259
+ }
260
+ catch {
261
+ lines.push('Provider: (kind unrecognized — sync will refuse loudly, nothing silently reroutes)');
262
+ }
263
+ // #50 §7.8 IDENTITY MUST FAIL LOUDLY. Project rules carry across checkouts
264
+ // because the key is UUIDv5 over the canonicalized REMOTE — so the ways it
265
+ // can silently diverge (no readable remote ⇒ path-derived, a fork listed
266
+ // before origin, a TreeDX link keyed on the pool location) are reported
267
+ // here instead of leaving a checkout owning a private rules namespace while
268
+ // its developer believes the team's rules apply.
269
+ {
270
+ const derived = resolveProject(cwd, config.projectKeyOverride !== undefined && config.projectKeyOverride !== '' ? config.projectKeyOverride : undefined);
271
+ const where = derived.projectKeySource === 'remote' ? `remote ${derived.remote ?? ''}`
272
+ : derived.projectKeySource === 'override' ? 'config override'
273
+ : `PATH ${cwd} (not the remote — unstable across machines)`;
274
+ lines.push(derived.projectKey === project.projectKey
275
+ ? `Identity: ${derived.projectKeySource} — matches the bound key (${where})`
276
+ : `Identity: ${derived.projectKeySource} ⚠ THIS CHECKOUT RESOLVES A DIFFERENT PROJECT KEY (${derived.projectKey.slice(0, 8)}… vs bound ${project.projectKey.slice(0, 8)}…) — rules and chapters approved elsewhere will NOT appear here. ${where}`);
277
+ }
278
+ lines.push(pending ? 'Sync: a push is pending (debounced)' : `Sync: ${status?.mode ?? 'never run'}${pending ? '' : ''}`);
279
+ }
280
+ if (status !== null) {
281
+ lines.push(`Last sync: ${status.at} \u2014 ${status.lastOk ? 'synced' : `${status.mode ?? 'local-only'}: ${status.detail}`}`);
282
+ lines.push(` ${(status.steps ?? []).join(' \u2192 ')}`);
283
+ }
284
+ // #61: an unwired surface is STATED, not omitted. A reader who sees no enrichment line cannot tell
285
+ // whether enrichment is off, broken, or simply not printed here — which is how "the feature never
286
+ // ran" hides for three days behind a logger nobody reads.
287
+ lines.push(config.enrich !== undefined ? config.enrich.statusLine() : 'Enrichment: not wired on this plane (no model route registered)');
288
+ lines.push(config.prompt !== undefined ? config.prompt.statusLine() : 'Prompt suggestions: not wired on this plane');
289
+ // #225: the prompt PLANE block — model, transport, door sightings, and THIS session's last
290
+ // persisted verdict. The maintainer asked why hundreds of pulls never produced an offer;
291
+ // from here that question is one command wide, answered by facts instead of archaeology.
292
+ if (config.prompt !== undefined) {
293
+ const plane = config.prompt;
294
+ if (typeof plane.planeStatus === 'function')
295
+ for (const l of plane.planeStatus())
296
+ lines.push(l);
297
+ if (typeof plane.sessionRow === 'function') {
298
+ const sessId = invocation.agent?.session?.id
299
+ ?? invocation.agent?.agent?.session?.id;
300
+ if (sessId !== undefined) {
301
+ try {
302
+ lines.push(await plane.sessionRow(sessId));
303
+ }
304
+ catch { /* the block degrades to what it has */ }
305
+ }
306
+ }
307
+ }
308
+ lines.push(diagnosticStatusLine());
309
+ lines.push(AUTO_SAVE_LINE(config));
310
+ // Findings, read-only, from the #60 auditor's row-level view. What a status cannot check WITHOUT
311
+ // the durable log is named as unchecked rather than counted as clean: coverage holes, orphan
312
+ // files and dead citations need `/chapters-repair audit`.
313
+ {
314
+ const states = [...domain.table('sessions').entries()]
315
+ .map(([, st]) => st)
316
+ .filter((st) => st.rootSession !== undefined);
317
+ const health = registryHealth(states);
318
+ const dead = states.reduce((m, st) => m + (st.chapters?.length ?? 0), 0);
319
+ lines.push(`Findings (registry-level, this machine): ${health.text}`);
320
+ lines.push(` NOT checked here: coverage holes, orphan files, dead citations — run /chapters-repair audit${dead === 0 ? ' (no chapter records at all yet)' : ''}`);
321
+ }
322
+ // #255: removals are a STATE — printed in every condition, unlinked included (#61).
323
+ {
324
+ const storeDir0 = cwd === '' ? '' : resolveStoreRoot(cwd, config.artifactStoreRoot);
325
+ const stubs = storeDir0 === '' ? 0 : [...domain.table('sessions').entries()]
326
+ .filter(([, st]) => st.rootSession !== undefined && fs.existsSync(path.join(storeDir0, st.rootSession)))
327
+ .reduce((m, [, st]) => m + (st.chapters ?? []).filter(isRemovedChapter).length, 0);
328
+ const mal = storeDir0 === '' ? null : readMissingAccess(storeDir0, 0);
329
+ lines.push(`Removals (this project): ${stubs} removed stub(s); missing-access ledger: ${mal === null ? 'no workspace cwd on this session' : mal.total === 0 ? 'empty (it fills at the next TOC build that cites a vanished chapter — /chapters-remove report)' : `${mal.total} line(s) at ${mal.file}`}`);
330
+ }
331
+ // The retrievability line: a long session that has never tripped an archive moment is the field
332
+ // case #61 found (2,363 events, 414 tool calls, `chapters: []`) — knowledge that exists only in
333
+ // the log and is invisible to every search on this machine.
334
+ {
335
+ const session = invocation.agent;
336
+ const sess = session.session ?? session.agent?.session;
337
+ const events = sess?.snapshotEvents?.() ?? [];
338
+ const ends = events.filter((e) => e.type === 'turn/end').length;
339
+ const mine = sess?.id !== undefined
340
+ ? (domain.table('sessions').get(sess.id)?.chapters.length ?? 0)
341
+ : 0;
342
+ if (mine === 0 && ends > 0) {
343
+ lines.push(`This session: ${events.length} events, ${ends} completed turn(s), 0 chapters — NOTHING it does is retrievable. /chapters-save archives it, or /chapters-continue opens a child on the archive.`);
344
+ }
345
+ else if (mine > 0) {
346
+ lines.push(`This session: ${events.length} events, ${ends} completed turn(s), ${mine} chapter record(s) archived.`);
347
+ }
348
+ }
349
+ return { kind: 'success', text: lines.join('\n') };
350
+ },
351
+ };
352
+ const enrichCommand = {
353
+ name: 'chapters-enrich',
354
+ description: 'P2 chapter enrichment (record \u00A76): run a batch now, set the model, or report pending work.',
355
+ input: { hint: 'run | model <provider/model | clear> | report' },
356
+ async handler(invocation) {
357
+ if (config.enrich === undefined)
358
+ return { kind: 'error', text: 'enrichment is not wired on this plane' };
359
+ const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
360
+ const verb = parts[0] ?? 'help';
361
+ try {
362
+ if (verb === 'model') {
363
+ const arg = parts[1];
364
+ if (arg === undefined) {
365
+ const o = config.enrich.modelOverride();
366
+ return { kind: 'success', text: `enrichment model: ${o ?? 'conversation default'}${o === undefined || o === null ? '' : ' (override)'}` };
367
+ }
368
+ await config.enrich.setModelOverride(arg === 'clear' ? null : arg);
369
+ return { kind: 'success', text: arg === 'clear' ? 'enrichment model reset to the conversation default' : `enrichment model set to ${arg} (pending chapters re-qualify under the new key)` };
370
+ }
371
+ if (verb === 'run') {
372
+ const r = await config.enrich.runNow(cwdOf(invocation.agent));
373
+ return { kind: 'success', text: `enrichment: processed ${r.processed} chapter(s); ${r.remaining} pending` };
374
+ }
375
+ if (verb === 'report') {
376
+ return { kind: 'success', text: `${config.enrich.statusLine()}\npending: ${await config.enrich.pendingCount()} chapter(s)` };
377
+ }
378
+ return { kind: 'success', text: 'usage: /chapters-enrich run | model <provider/model | clear> | report' };
379
+ }
380
+ catch (error) {
381
+ return { kind: 'error', text: `chapters-enrich failed: ${String(error?.message ?? error).slice(0, 200)}` };
382
+ }
383
+ },
384
+ };
385
+ // #173: the discovery surface for the three model settings. Pure core in models-command.ts;
386
+ // this shell only refuses when the catalog is not wired and re-reads the io per invocation.
387
+ // #221 ANCHOR RPC: the door buttons need the durable seq of the assistant message they were
388
+ // clicked under. The client only ever sees `messageId` (a uuid — measured in the host's own
389
+ // TurnTailNodeView); the seq lives in the session log, which only the server reads. This command
390
+ // is that lookup: `chapters-anchor <messageId>` answers with the bare seq, nothing else. It is
391
+ // plugin-private by naming (no slash menu pretension beyond its purpose) and idempotent.
392
+ const anchorCommand = {
393
+ name: 'chapters-anchor',
394
+ description: 'Resolve an assistant messageId to its durable seq (the continuation doors use it to anchor at the clicked message).',
395
+ input: { hint: '<messageId>' },
396
+ async handler(invocation) {
397
+ const messageId = (invocation.rawInput ?? '').trim();
398
+ if (messageId === '')
399
+ return { kind: 'error', text: 'chapters-anchor: usage /chapters-anchor <messageId>' };
400
+ const session = invocation.agent?.session
401
+ ?? invocation.agent?.agent?.session;
402
+ if (session === undefined || typeof session.snapshotEvents !== 'function') {
403
+ return { kind: 'error', text: 'chapters-anchor: no session context — run it in a session' };
404
+ }
405
+ try {
406
+ for (const ev of session.snapshotEvents()) {
407
+ const e = ev;
408
+ if (e.type !== 'assistant/message')
409
+ continue;
410
+ if (e.data?.message?.id === messageId && typeof e.seq === 'number')
411
+ return { kind: 'success', text: String(e.seq) };
412
+ }
413
+ return { kind: 'error', text: `chapters-anchor: messageId ${messageId.slice(0, 12)}… not found in this session` };
414
+ }
415
+ catch (error) {
416
+ return { kind: 'error', text: `chapters-anchor failed: ${String(error?.message ?? error).slice(0, 120)}` };
417
+ }
418
+ },
419
+ };
420
+ const modelsCatalogCommand = {
421
+ name: 'chapters-models',
422
+ description: 'Discover the provider/model identifiers the enrichment, rules-mining, and prompt settings accept, with capabilities and current values: /chapters-models [<provider> [<model>]]',
423
+ input: { hint: '[<provider> [<model>]] — a paste-ready provider/model id works as one arg' },
424
+ async handler(invocation) {
425
+ if (config.models === undefined)
426
+ return { kind: 'error', text: 'the model catalog is not wired on this plane' };
427
+ return modelsCommand(config.models(), invocation.rawInput ?? '');
428
+ },
429
+ };
430
+ const promptCommand = {
431
+ name: 'chapters-prompt',
432
+ description: 'Prompt recommendations (#71, site \u00A728): set the defining model, turn the composer ghost off, or answer the client\u2019s pull.',
433
+ input: { hint: 'model [provider/model | clear] | off | suggest' },
434
+ async handler(invocation) {
435
+ if (config.prompt === undefined)
436
+ return { kind: 'error', text: 'prompt recommendations are not wired on this plane' };
437
+ const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
438
+ const verb = parts[0] ?? 'help';
439
+ try {
440
+ if (verb === 'model') {
441
+ const arg = parts[1];
442
+ if (arg === undefined) {
443
+ const { spec, source } = config.prompt.effective();
444
+ return { kind: 'success', text: spec === ''
445
+ ? `prompt model: OFF${source === 'override' ? ' (stored override — /chapters-prompt model <provider/model> to enable)' : ' (unset — set one with /chapters-prompt model <provider/model>)'}`
446
+ : `prompt model: ${spec} (${source}) — ON` };
447
+ }
448
+ if (arg === 'clear') {
449
+ await config.prompt.setModel(null);
450
+ const { spec } = config.prompt.effective();
451
+ return { kind: 'success', text: spec === ''
452
+ ? 'prompt model override cleared — the config default is unset, so suggestions are OFF'
453
+ : `prompt model override cleared — now following the config default (${spec}, ON)` };
454
+ }
455
+ // #188: the caps ride WITH the model — `/chapters-prompt model <id> [maxChars [contextChars]]`.
456
+ // Explicit numbers store beside prompt.model in the settings table; omitted numbers derive from the
457
+ // selected model's real context window (window × promptContextRatio) at suggestion time, and the
458
+ // config keys are the no-window fallback. Resolution and sources: `/chapters-prompt state`.
459
+ const maxArg = parts[2];
460
+ const ctxArg = parts[3];
461
+ if (parts.length > 4 || (maxArg !== undefined && !/^[1-9][0-9]*$/.test(maxArg)) || (ctxArg !== undefined && !/^[1-9][0-9]*$/.test(ctxArg))) {
462
+ return { kind: 'error', text: `model takes <provider/model> [maxChars [contextChars]] — positive integers (got: ${parts.slice(1).join(' ')})` };
463
+ }
464
+ await config.prompt.setModel(arg);
465
+ if (config.prompt.setCaps !== undefined && (maxArg !== undefined || ctxArg !== undefined)) {
466
+ await config.prompt.setCaps({
467
+ ...(maxArg !== undefined ? { maxChars: maxArg } : {}),
468
+ ...(ctxArg !== undefined ? { contextChars: ctxArg } : {}),
469
+ });
470
+ }
471
+ const capsNote = maxArg !== undefined || ctxArg !== undefined
472
+ ? ` Caps stored beside the model${maxArg !== undefined ? `: suggestion ${maxArg} chars` : ''}${ctxArg !== undefined ? `${maxArg !== undefined ? ',' : ':'} context budget ${ctxArg} chars` : ''}.`
473
+ : " Caps derive from this model's context window (window × promptContextRatio); pass numbers to pin them.";
474
+ // #225: enabling is a PLANE-WIDE write; the bar for THIS session only learns about it by
475
+ // pulling (and with the ambient clock gone by default, that means next page load). The
476
+ // maintainer enabled at 18:16 and saw nothing appear — correct per schedule, terrible per
477
+ // experience. So the enable path writes the row and mirrors the record right here, making
478
+ // the bar visible immediately where push exists; pull hosts get the same answer from the
479
+ // state verb on their next sanctioned moment.
480
+ const enHolder = invocation.agent;
481
+ const enSess = (enHolder.session ?? enHolder.agent?.session);
482
+ if (enSess?.id !== undefined && config.prompt.bar !== undefined) {
483
+ try {
484
+ await config.prompt.bar.setOn(enSess.id, enSess);
485
+ }
486
+ catch { /* the enable itself succeeded */ }
487
+ }
488
+ return { kind: 'success', text: `prompt model set to ${arg} — this ENABLES the composer ghost (one bounded call per turn boundary; /chapters-prompt off disables).${capsNote} If the bar is hidden, it reappears at the next page reload.` };
489
+ }
490
+ if (verb === 'off') {
491
+ await config.prompt.setModel('');
492
+ // #194: the global switch alone leaves a PUSHED bar showing its last offer forever — no row
493
+ // write means no record. The calling session's row goes off too (what `setOff` exists for),
494
+ // so the record carries the truth to this bar; other open sessions converge through the
495
+ // pull staleness guard on next activation (documented in the contract's push lock).
496
+ const offHolder = invocation.agent;
497
+ const offSessId = offHolder.session?.id ?? offHolder.agent?.session?.id;
498
+ if (offSessId !== undefined && config.prompt.bar !== undefined) {
499
+ await config.prompt.bar.setOff(offSessId, offHolder.session ?? offHolder.agent?.session);
500
+ }
501
+ return { kind: 'success', text: 'prompt suggestions OFF (stored override; /chapters-prompt model clear restores the config default)' };
502
+ }
503
+ // #113: the bar's three pulls. `state` answers in k: v lines so ONE output serves both the human
504
+ // typing it and the component rendering it — a separate JSON verb would be a second contract to keep
505
+ // in step, and the goal bar's own dock reads plainly-worded state for the same reason.
506
+ if (verb === 'state') {
507
+ const sessId = invocation.agent
508
+ .session?.id ?? invocation.agent.agent?.session?.id;
509
+ if (sessId === undefined || config.prompt.bar === undefined) {
510
+ return { kind: 'error', text: 'state needs a session scope and the chapters prompt store — run it inside a session' };
511
+ }
512
+ // #231: the state reply comes from the ROW — a durable read with no side effects. The full
513
+ // `bar.read` (row + live loop status) is consulted ONLY when the row is absent, which is also
514
+ // the one moment worth mirroring: an empty row means "this session has never been served",
515
+ // so the mirror writes the record that makes future mounts push-fed. A ready offer was NEVER
516
+ // re-read or re-mirrored here before — every pull cost a store read and (on push hosts) a
517
+ // duplicate record append. On the maintainer's host that path ran every minute for hours.
518
+ let st = await config.prompt.bar.peekRow(sessId);
519
+ if (st === null) {
520
+ st = await config.prompt.bar.read(sessId);
521
+ const sessObj = invocation.agent.session
522
+ ?? invocation.agent.agent?.session;
523
+ if (sessObj !== undefined && typeof config.prompt.bar.read.mirror === 'function') {
524
+ void config.prompt.bar.read.mirror(sessId, sessObj);
525
+ }
526
+ }
527
+ const { spec, source } = config.prompt.effective();
528
+ // #113: `off` must mean OFF. A session with a defining model and no stored offer is `idle` — enabled,
529
+ // awaiting its first turn — and collapsing that into `off` is what made the bar look switched-off
530
+ // while the config row was present. The distinction is the human's only signal that the feature is
531
+ // live but has nothing to say yet, so it is made here, at the one place the state is rendered.
532
+ const resting = st.status === 'off' && spec !== '' ? { ...st, status: 'idle' } : st;
533
+ return { kind: 'success', text: [
534
+ `suggested next step: ${resting.status}`,
535
+ ` model: ${spec !== '' ? spec : 'unset'} (${source}) — ${spec === '' ? 'the bar is absent' : 'one offer per completed turn'}`,
536
+ ` autorun: ${st.autorun}${st.autorun === 'armed' ? ` (${String(st.fired)} round(s) started this process)` : ''}`,
537
+ ` turn: ${st.turnSeq === null ? '-' : String(st.turnSeq)}`,
538
+ // #113: intent and the revision it was cut against, because they decide whether autorun may act.
539
+ // A `goal-edit` offer is held for a keystroke — the host reserves goal edits for a human turn —
540
+ // and the bar must be able to say that without re-parsing the prompt.
541
+ ` intent: ${st.intent}${st.intent === 'goal-edit' ? ' — re-cuts the goal, so Run it yourself; autorun will not' : ''}`,
542
+ ` goal: ${st.goalRevision === null ? 'none' : `revision ${String(st.goalRevision)}`}`,
543
+ // #229: the heartbeat the server has configured rides EVERY state reply — the client's
544
+ // schedule is the operator's setting, not a compile-time guess. Zero (the default) means
545
+ // no ambient clock exists at all; the sanctioned moments still do.
546
+ ` heartbeat: ${String(config.prompt.bar.quietHeartbeatSec ?? 0)}s`,
547
+ ...(st.error === '' ? [] : [` error: ${st.error}`]),
548
+ ...(st.text === '' ? [] : [' prompt:', ...st.text.split('\n').map((l) => ` ${l}`)]),
549
+ ].join('\n') };
550
+ }
551
+ if (verb === 'run') {
552
+ // The bar's Run button. Session-scoped like `state`, because "the offer on screen" is a per-session
553
+ // fact and a global command would run whatever the last session happened to be shown.
554
+ const runId = invocation.agent?.session?.id
555
+ ?? invocation.agent?.agent?.session?.id;
556
+ if (runId === undefined || config.prompt.bar === undefined) {
557
+ return { kind: 'error', text: 'run needs a session scope and the chapters prompt store' };
558
+ }
559
+ console.error(`[chapters-prompt] /run invoked for session=${String(runId)}`);
560
+ const res = await config.prompt.bar.runNow(runId);
561
+ console.error(`[chapters-prompt] /run result ok=${String(res.ok)} reason=${String(res.reason ?? '')}`);
562
+ return res.ok
563
+ ? { kind: 'success', text: `running the suggested next step as your own turn:\n${res.text}` }
564
+ : { kind: 'error', text: `cannot run: ${res.reason ?? 'the offer is no longer available'}` };
565
+ }
566
+ if (verb === 'autorun') {
567
+ const arg = parts[1];
568
+ const sessId = invocation.agent
569
+ .session?.id ?? invocation.agent.agent?.session?.id;
570
+ const on = arg === 'on' || arg === 'arm';
571
+ const off = arg === 'off' || arg === 'disarm';
572
+ if (sessId === undefined || config.prompt.bar === undefined) {
573
+ return { kind: 'error', text: 'autorun needs a session scope — run it inside the session you want to drive' };
574
+ }
575
+ if (!on && !off) {
576
+ return { kind: 'error', text: `autorun needs 'on' or 'off' (got ${arg ?? 'nothing'}) — /chapters-prompt autorun on starts the loop, off leaves the offers in place and stops the running` };
577
+ }
578
+ const { spec } = config.prompt.effective();
579
+ if (on && spec === '') {
580
+ return { kind: 'error', text: 'cannot arm autorun with no prompt model — the loop has nothing to generate offers. /chapters-prompt model <provider/model> first' };
581
+ }
582
+ // #194: the live session rides along so the autorun flip can mirror to the push transport.
583
+ const sessObj = invocation.agent.session
584
+ ?? invocation.agent.agent?.session;
585
+ const live = config.prompt.bar.setAutorun(sessId, on, sessObj);
586
+ return { kind: 'success', text: on
587
+ ? `autorun ARMED for ${sessId} — each completed turn's offer runs for you, until you stop it or send a prompt yourself.` + (live.displaced
588
+ ? ' The goal remains the objective; its own auto-continuation is disarmed, so the suggestion loop now drives the worker. Re-arm it with /goal resume.'
589
+ : ' The goal still owns the turn; this loop yields to it until the goal is paused, completed, or disarmed.') + ' State: /chapters-prompt state'
590
+ : `autorun disarmed for ${sessId} — offers still appear each turn; nothing runs itself (${String(live.fired)} round(s) were started this process)` };
591
+ }
592
+ if (verb === 'on') {
593
+ if (config.prompt.bar === undefined)
594
+ return { kind: 'error', text: 'the chapters prompt store is unavailable in this context' };
595
+ const { spec } = config.prompt.effective();
596
+ if (spec === '') {
597
+ return { kind: 'error', text: 'nothing to turn on: the prompt model is unset. /chapters-prompt model <provider/model> sets it, and setting it IS the enable' };
598
+ }
599
+ const holder = invocation.agent;
600
+ const sessId = holder.session?.id ?? holder.agent?.session?.id;
601
+ if (sessId !== undefined)
602
+ await config.prompt.bar.setOn(sessId, holder.session ?? holder.agent?.session);
603
+ return { kind: 'success', text: `suggestions ON (${spec}) — the next completed turn offers a next step; /chapters-prompt state shows it, /chapters-prompt autorun on runs them. If the bar is hidden, it reappears at the next page reload.` };
604
+ }
605
+ if (verb === 'suggest') {
606
+ const holder = invocation.agent;
607
+ const sess = holder.session ?? holder.agent?.session;
608
+ if (sess?.id === undefined)
609
+ return { kind: 'error', text: 'suggest needs a session scope — run it inside a session (the composer ghost calls this verb; humans rarely do)' };
610
+ const events = (sess.snapshotEvents?.() ?? []);
611
+ const titleEvent = [...events].reverse().find((e) => e.type === 'session/title');
612
+ const title = titleEvent !== undefined && typeof titleEvent.data.title === 'string' ? titleEvent.data.title : undefined;
613
+ const r = await config.prompt.suggest({ id: sess.id, title, cwd: cwdOf(invocation.agent), events: [...events] });
614
+ // #113: this returned `text: r.text` — ONE bare line, which the composer ellipsized with no way to
615
+ // recover it. /chapters-status gets an expander because it answers with a labelled multi-line block,
616
+ // so this does the same: the prompt verbatim inside a fence, then the facts that bound it (which
617
+ // model, from which source, against which cap, for which session). Stating the cap matters because an
618
+ // over-cap answer is refused and retried, never truncated — the number belongs in the output.
619
+ const F = String.fromCharCode(96).repeat(3);
620
+ const { spec, source } = config.prompt.effective();
621
+ const lim = config.prompt.limits !== undefined ? await config.prompt.limits(null) : undefined;
622
+ const who = `${title !== undefined && title !== '' ? `\u201c${title}\u201d ` : ''}(${sess.id})`;
623
+ const capLine = lim !== undefined
624
+ ? ` caps: suggestion ${String(lim.maxChars)} chars (${lim.sources.maxChars}) · context budget ${String(lim.contextChars)} chars (${lim.sources.contextChars}) — over-cap is refused and retried, never truncated`
625
+ : ' caps: (not reported by this surface)';
626
+ const meta = ` model: ${spec !== '' ? spec : 'unset'} (${source})\n${capLine}`;
627
+ if (r.ok) {
628
+ // #225: `suggest` used to print the offer inside a fence with instructions to COPY IT
629
+ // INTO THE COMPOSER by hand — an affordance dead since the ghost chip was removed. The
630
+ // human asked for a suggestion; the answer is the BAR SHOWING IT, so this writes the
631
+ // offer through the same row + mirror path the turn-end runner uses and reports the
632
+ // facts compactly. Run stays the one way an offer becomes a turn (host rule: no plugin
633
+ // submits without a keystroke).
634
+ const sugHolder = invocation.agent;
635
+ const sugSess = (sugHolder.session ?? sugHolder.agent?.session);
636
+ const offer = classifyOffer(r.text);
637
+ if (sugSess?.id !== undefined && config.prompt.bar !== undefined) {
638
+ try {
639
+ await config.prompt.bar.setSuggested?.(sugSess.id, r.text, sugSess);
640
+ }
641
+ catch { /* the answer still prints below */ }
642
+ }
643
+ return { kind: 'success', text: [
644
+ `/chapters-prompt suggest — the next prompt for ${who} is on the bar:`,
645
+ ` ${offer.text.split('\n')[0].slice(0, 140)}`,
646
+ meta,
647
+ ].join('\n') };
648
+ }
649
+ if (r.quiet === true) {
650
+ // Nothing to offer is an ANSWER to an explicit request, not a failure. With the ghost gone there is
651
+ // no automated caller that reads a success line as an offer — that collision is what made wiring
652
+ // `quiet` wrong last time, and removing the chip is what unblocked it.
653
+ return { kind: 'success', text: [`/chapters-prompt suggest — nothing to offer yet for ${who}`, '', ` ${r.text}`, '', meta].join('\n') };
654
+ }
655
+ return { kind: 'error', text: `chapters-prompt: ${r.text}\n model: ${spec !== '' ? spec : 'unset'} (${source})` };
656
+ }
657
+ return { kind: 'success', text: 'usage: /chapters-prompt model [provider/model | clear] | off | suggest' };
658
+ }
659
+ catch (error) {
660
+ return { kind: 'error', text: `chapters-prompt failed: ${String(error?.message ?? error).slice(0, 200)}` };
661
+ }
662
+ },
663
+ };
664
+ /**
665
+ * #255: `/chapters-remove` — the operator door that deletes BAD chapters from THIS project's
666
+ * memory. The field case is the wrong-project prompt; supersede (#60) keeps bytes, this removes
667
+ * them. Verbs: `list [sessionId]`, `find <query>`, `[dry-run] <id…>`, `apply <id…>`, `report`,
668
+ * `restore <journal-id>`. Dry-run is the default; `apply` is the only word that deletes. Scope
669
+ * is same-project by construction: only rows whose store tree exists under THIS workspace resolve.
670
+ */
671
+ const removeCommand = {
672
+ name: 'chapters-remove',
673
+ description: 'Delete bad chapters from this project\u2019s memory (#255): list | find | dry-run | apply | report | restore.',
674
+ input: { hint: '[list [sessionId] | find <query> | [dry-run] <id…> | apply <id…> | report | restore <journal-id>]' },
675
+ async handler(invocation) {
676
+ const cwd = cwdOf(invocation.agent);
677
+ const cap = config.reportMaxLines ?? 40;
678
+ const err = (t) => ({ kind: 'error', text: t });
679
+ if (cwd === '')
680
+ return err('chapters-remove: this session has no workspace cwd — nowhere to act');
681
+ // The store handle is required only by MUTATING arms (`apply`, `restore`). `list`, `find`,
682
+ // `report` and the dry-run resolve are reads of rows and files the plane already exposes —
683
+ // refusing them for a plumbing detail would hide the operator's own memory behind it.
684
+ // Each mutating arm guards `config.store === undefined` inline, which also narrows the type.
685
+ const noStore = () => ({ kind: 'error', text: 'chapters-remove: this plane carries no registry handle — nothing was written' });
686
+ const wrapped = invocation.agent;
687
+ const callerId = wrapped.session?.id ?? wrapped.agent?.session?.id;
688
+ const storeDir = resolveStoreRoot(cwd, config.artifactStoreRoot);
689
+ // Same-project scope (#255): a row resolves iff its store tree exists under this workspace.
690
+ // Session ids are globally unique, so the tree-exists test cannot cross projects.
691
+ const allRows = [...domain.table('sessions').entries()];
692
+ const rows = allRows.filter(([, st]) => st.rootSession !== undefined && st.rootSession !== '' && fs.existsSync(path.join(storeDir, st.rootSession)));
693
+ const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
694
+ const verb = parts[0] ?? '';
695
+ const argTail = parts.slice(1);
696
+ const fmtBytes = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
697
+ if (verb === 'list') {
698
+ const focus = argTail[0] ?? callerId;
699
+ if (focus === undefined)
700
+ return err('chapters-remove list: no session context — pass list <sessionId> whose ancestor path names the chapters to review');
701
+ const byId = new Map(rows);
702
+ if (byId.get(focus) === undefined)
703
+ return err(`chapters-remove list: session ${focus} has no row under this project's store (${storeDir}) — removal is same-project only; run it in the project that owns the session`);
704
+ let pathIds;
705
+ try {
706
+ pathIds = ancestorPath((id) => byId.get(id), focus);
707
+ }
708
+ catch (error) {
709
+ return err(`chapters-remove list: ${String(error?.message ?? error)}`);
710
+ }
711
+ const entries = buildIndex(pathIds, (id) => byId.get(id), () => 'ok');
712
+ const afs = makeArchiveFs(cwd);
713
+ const lines = [];
714
+ for (let i = 0; i < entries.length; i += 1) {
715
+ const e = entries[i];
716
+ const rec = byId.get(e.authorSession)?.chapters.find((c) => c.number === e.number);
717
+ const verdict = rec !== undefined ? await verifyChapter(afs, rec) : 'missing';
718
+ lines.push(` ${String(i + 1)}. ${e.authorSession}:${e.number} · ${verdict} · ${e.title} — ${e.summary}\n ${e.path}`);
719
+ }
720
+ const stubs = rows.reduce((m, [, st]) => m + st.chapters.filter(isRemovedChapter).length, 0);
721
+ const head = entries.length === 0
722
+ ? 'chapters-remove list: no ACTIVE chapters on this ancestor path — nothing removable here.'
723
+ : `chapters-remove list (${pathIds.join(' → ')}) — ${entries.length} active chapter(s), reading order = TOC order; the ids are what dry-run/apply accept:`;
724
+ return { kind: 'success', text: [head, ...lines, ` +${stubs} removed stub(s) on this project (uncited by design; ledger: /chapters-remove report)`].join('\n') };
725
+ }
726
+ if (verb === 'find') {
727
+ const query = argTail.join(' ');
728
+ if (query === '')
729
+ return err('chapters-remove find: needs a query — /chapters-remove find <words>');
730
+ const project = projectForCwd(domain.table('projects').entries(), cwd);
731
+ const lines = [];
732
+ const push = (rawPath, title, kindLabel) => {
733
+ if (kindLabel === 'rule') {
734
+ lines.push(` ${rawPath} · ${title} — rules are revoked via /chapters-rule, not removed here`);
735
+ return;
736
+ }
737
+ const res = resolveRemovalId(rawPath, rows, cwd, callerId);
738
+ lines.push('target' in res
739
+ ? ` ${rawPath} → removable id ${res.target.sessionId}:${res.target.record.number} · ${title}`
740
+ : ` ${rawPath} · ${title} — not removable from this tree (${res.refusal.split('\n')[0]})`);
741
+ };
742
+ if (project !== undefined) {
743
+ const out = searchKnowledge(path.join(cwd, DEFAULT_CLONE_DIR), query, 600, { projectKey: project.projectKey });
744
+ for (const r of out.results)
745
+ push(r.path, r.title, r.kind);
746
+ if (out.total === 0)
747
+ return { kind: 'success', text: `chapters-remove find "${query}" — no matches in the pool mirror. Nothing was written.` };
748
+ const withheldF = Math.max(0, out.results.length - cap);
749
+ return { kind: 'success', text: [
750
+ `chapters-remove find "${query}" — ${out.total} hit(s) in the pool mirror, ${lines.length} shown:`,
751
+ ...lines.slice(0, cap),
752
+ ...(withheldF > 0 ? [` … ${withheldF} more withheld by the report cap (${cap})`] : []),
753
+ ' next: /chapters-remove <removable id> (dry-run), then apply <id>',
754
+ ].join('\n') };
755
+ }
756
+ const out = searchLocalStore(storeDir, query, 600);
757
+ for (const r of out.results)
758
+ push(r.path, r.title, r.kind);
759
+ if (out.total === 0)
760
+ return { kind: 'success', text: `chapters-remove find "${query}" — no matches in this project's local store (${out.documents} document(s) scanned; no pool linked). Nothing was written.` };
761
+ const withheldL = Math.max(0, out.results.length - cap);
762
+ return { kind: 'success', text: [
763
+ `chapters-remove find "${query}" — ${out.total} hit(s) in the LOCAL store (knowledge-local: no pool linked), ${lines.length} shown:`,
764
+ ...lines.slice(0, cap),
765
+ ...(withheldL > 0 ? [` … ${withheldL} more withheld by the report cap (${cap})`] : []),
766
+ ' next: /chapters-remove <removable id> (dry-run), then apply <id>',
767
+ ].join('\n') };
768
+ }
769
+ // dry-run is the DEFAULT: a first token that is not a verb but looks like an id (path,
770
+ // `session:number`, pool tail, or a bare ordinal) resolves as `[dry-run] <id…>`. A token
771
+ // that is neither never guesses — it hits the unknown-verb refusal below.
772
+ const looksLikeId = !['list', 'find', 'report', 'restore', 'apply', 'dry-run'].includes(verb) && /[:/.0-9]/.test(verb);
773
+ if (verb === '' || verb === 'dry-run' || verb === 'apply' || looksLikeId) {
774
+ const ids = verb === 'apply' ? argTail : parts.filter((p) => p !== 'dry-run');
775
+ if (ids.length === 0)
776
+ return err('usage: /chapters-remove list [sessionId] | find <query> | [dry-run] <id…> | apply <id…> | report | restore <journal-id>');
777
+ const targets = [];
778
+ const refusals = [];
779
+ for (const raw of ids) {
780
+ const res = resolveRemovalId(raw, rows, cwd, callerId);
781
+ if ('target' in res)
782
+ targets.push(res.target);
783
+ else
784
+ refusals.push(res.refusal);
785
+ }
786
+ if (refusals.length > 0)
787
+ return err(`chapters-remove: NOTHING WRITTEN — ${refusals.length} unresolved id(s):\n ${refusals.join('\n ')}${targets.length > 0 ? `\n (${targets.length} did resolve; drop the bad ids and re-run)` : ''}`);
788
+ const plan = planRemoval({ targets, rows, cwd });
789
+ const bytes = plan.chapterFiles.reduce((s, f) => s + f.bytes, 0) + plan.blobFiles.reduce((s, f) => s + f.bytes, 0);
790
+ const header = [`chapters-remove ${verb === 'apply' ? 'APPLY' : 'dry-run'} — ${targets.length} chapter(s) from ${new Set(targets.map((t) => t.sessionId)).size} tree(s):`];
791
+ const set = [
792
+ ...plan.chapterFiles.map((f) => ` chapter ${f.stored} (${fmtBytes(f.bytes)})`),
793
+ ...plan.blobFiles.map((f) => ` blob ${f.stored} (${fmtBytes(f.bytes)}) — orphaned: no active chapter names it`),
794
+ ...plan.missing.map((m) => ` absent ${m} — the record cites it, the disk does not have it; the stub closes the claim anyway`),
795
+ ` total leaving the store: ${fmtBytes(bytes)} across ${plan.chapterFiles.length + plan.blobFiles.length} file(s); blobs shared with a surviving chapter are NEVER deleted (orphan math)`,
796
+ ];
797
+ const dang = plan.dangling.length > 0
798
+ ? [` dangling after removal (${plan.dangling.length}) — surviving chapters that still cite a removed file ("does the referrer stay?" is the operator's call):`,
799
+ ...plan.dangling.slice(0, cap).map((d) => ` ${d.referrer} cites ${d.needle}`),
800
+ ...(plan.dangling.length > cap ? [` … ${plan.dangling.length - cap} more (all of them land in /chapters-remove report)`] : [])]
801
+ : [' no surviving chapter cites these files'];
802
+ if (verb !== 'apply')
803
+ return { kind: 'success', text: [...header, ...set, ...dang, ' NOTHING WRITTEN. apply: /chapters-remove apply <the same ids>'].join('\n') };
804
+ // the mutating seam: narrowing this way (a lone guard at the use site) is what keeps
805
+ // `config.store` typed below — the compound `verb && !store` guard never narrowed.
806
+ if (config.store === undefined)
807
+ return noStore();
808
+ const project = projectForCwd(domain.table('projects').entries(), cwd);
809
+ const mirrorAbs = path.join(cwd, DEFAULT_CLONE_DIR);
810
+ const out = await applyRemoval({
811
+ store: config.store,
812
+ cwd, rows, storeDir,
813
+ ...(project !== undefined && fs.existsSync(mirrorAbs) ? { cloneDir: mirrorAbs, projectKey: project.projectKey } : {}),
814
+ now: () => new Date().toISOString(),
815
+ }, plan);
816
+ let syncLine = ' pool: unlinked project — store-only removal; this machine\u2019s memory is what got cleaned';
817
+ if (project !== undefined) {
818
+ try {
819
+ const res = config.scheduler !== undefined
820
+ ? await config.scheduler.run(cwd, 'removal', project)
821
+ : await syncNow(cwd, project, readToken(cwd, config.artifactStoreRoot, project.projectKey));
822
+ syncLine = ` sync: ${res.steps.join('') !== '' ? res.steps.join(' → ') : res.detail}${res.ok ? '' : ' ⚠ the deletion is applied locally; the pool push FAILED — /chapters-status carries the detail'}`;
823
+ }
824
+ catch (error) {
825
+ syncLine = ` sync: pass threw (${String(error?.message ?? error)}) — local removal is complete; run /chapters-link or let the scheduler retry`;
826
+ }
827
+ }
828
+ return { kind: 'success', text: [
829
+ ...header, ...set, ...dang,
830
+ ` removed ${out.removed.length} file(s), stubbed ${out.stubbed.length === 0 ? 'NONE (see problems)' : out.stubbed.join(', ')}`,
831
+ ` journal: ${out.journalId !== '' ? `${out.journalId} — reversal: /chapters-remove restore ${out.journalId}` : 'NOT WRITTEN — nothing was deleted'}`,
832
+ out.problems.length > 0 ? ` PROBLEMS (${out.problems.length}): ${out.problems.join(' | ')}` : ' no problems',
833
+ syncLine,
834
+ ' every TOC built from here on omits the removed chapter(s); already-seeded children keep their recorded text (invariant 1) and their misses land in /chapters-remove report',
835
+ ].join('\n') };
836
+ }
837
+ if (verb === 'report') {
838
+ const r = readMissingAccess(storeDir, cap);
839
+ if (r.total === 0)
840
+ return { kind: 'success', text: `chapters-remove report: the missing-access ledger is empty — no removal citations and no dead-citation misses recorded on this project yet. File when written: ${r.file}` };
841
+ return { kind: 'success', text: [
842
+ `chapters-remove report — ${r.total} line(s) at ${r.file}, last ${r.lines.length}:`,
843
+ ...r.lines,
844
+ ...(r.withheld > 0 ? [` … ${r.withheld} earlier line(s) withheld by the report cap (${cap}) — read the file for all`] : []),
845
+ ' verdicts: dead-citation = a TOC cite whose bytes vanished (decide: repair or remove the referrer); removed= = the removal ledger',
846
+ ].join('\n') };
847
+ }
848
+ if (verb === 'restore') {
849
+ if (argTail[0] === undefined)
850
+ return err('chapters-remove restore: needs a journal id (printed by apply; journals live under <store>/<tree>/removals/)');
851
+ if (config.store === undefined)
852
+ return noStore();
853
+ if (config.save === undefined)
854
+ return err('chapters-remove restore: this plane carries no render config — restore re-derives through the save core and cannot guess its thresholds');
855
+ const get = (name) => { try {
856
+ return ctx.get?.(name);
857
+ }
858
+ catch {
859
+ return undefined;
860
+ } };
861
+ const out = await restoreRemoval({
862
+ store: config.store, cwd, storeDir,
863
+ readLog: async (sid) => (await readDurableSession(get, sid))?.events ?? null,
864
+ config: config.save,
865
+ now: () => new Date().toISOString(),
866
+ }, argTail[0]);
867
+ const lines = [
868
+ `chapters-remove restore — journal ${out.journalId}:`,
869
+ ...(out.restored.length > 0 ? [` re-derived under new numbers: ${out.restored.join(' · ')}`] : [' nothing was re-derived']),
870
+ ...out.skipped.map((s) => ` skipped: ${s}`),
871
+ ...out.problems.map((p) => ` PROBLEM: ${p}`),
872
+ ' the log was read, never written; removed numbers stay spent; the re-rendered chapters are normal chapters from here on',
873
+ ];
874
+ let syncLine = ' pool: unlinked — the restored chapters live in the local store';
875
+ if (out.restored.length > 0) {
876
+ const project = projectForCwd(domain.table('projects').entries(), cwd);
877
+ if (project !== undefined) {
878
+ try {
879
+ const res = config.scheduler !== undefined
880
+ ? await config.scheduler.run(cwd, 'removal-restore', project)
881
+ : await syncNow(cwd, project, readToken(cwd, config.artifactStoreRoot, project.projectKey));
882
+ syncLine = ` sync: ${res.steps.join(' → ') !== '' ? res.steps.join(' → ') : res.detail}${res.ok ? '' : ' ⚠ restored locally; the pool push failed — /chapters-status carries the detail'}`;
883
+ }
884
+ catch (error) {
885
+ syncLine = ` sync: pass threw (${String(error?.message ?? error)}) — restored chapters are safe in the store`;
886
+ }
887
+ }
888
+ }
889
+ return { kind: out.problems.length > 0 && out.restored.length === 0 ? 'error' : 'success', text: [...lines, syncLine].join('\n') };
890
+ }
891
+ return err(`chapters-remove: unknown verb "${verb}" — usage: list [sessionId] | find <query> | [dry-run] <id…> | apply <id…> | report | restore <journal-id>`);
892
+ },
893
+ };
894
+ try {
895
+ const d1 = commands.register(linkCommand);
896
+ if (typeof d1 === 'function')
897
+ disposers.push(d1);
898
+ const d2 = commands.register(statusCommand);
899
+ if (typeof d2 === 'function')
900
+ disposers.push(d2);
901
+ const dRemove = commands.register(removeCommand);
902
+ if (typeof dRemove === 'function')
903
+ disposers.push(dRemove);
904
+ const d3 = commands.register(enrichCommand);
905
+ if (typeof d3 === 'function')
906
+ disposers.push(d3);
907
+ const d3b = commands.register(promptCommand);
908
+ if (typeof d3b === 'function')
909
+ disposers.push(d3b);
910
+ const d3d = commands.register(anchorCommand);
911
+ if (typeof d3d === 'function')
912
+ disposers.push(d3d);
913
+ const d3c = commands.register(modelsCatalogCommand);
914
+ if (typeof d3c === 'function')
915
+ disposers.push(d3c);
916
+ if (config.rules !== undefined) {
917
+ const rulesFn = config.rules;
918
+ const rulesCommandDef = {
919
+ name: 'chapters-rule',
920
+ description: 'Rules (record \u00A77): project tier propose/approve/revoke per machine; session tier review, pin, conflict report.',
921
+ input: { hint: 'add <category> <text> | list [--all|--proposed|--category <c>|--scope project|session] | view <id> | active | approve <id> | revoke <id> | pin <id> | unpin <id> | conflicts' },
922
+ async handler(invocation) {
923
+ // the session id reaches the resolver because the SESSION tier activates
924
+ // by ancestry (§7.5): `active` cannot answer without knowing which tree
925
+ // is asking. Command output never enters the model context (§7.2).
926
+ const sessionId = invocation.agent?.session?.id;
927
+ const r = await rulesFn(cwdOf(invocation.agent), invocation.rawInput ?? '', sessionId);
928
+ return { kind: r.kind, text: r.text };
929
+ },
930
+ };
931
+ const d4 = commands.register(rulesCommandDef);
932
+ if (typeof d4 === 'function')
933
+ disposers.push(d4);
934
+ }
935
+ }
936
+ catch (error) {
937
+ // LOUD on the operator's console too — a swallowed register error here is
938
+ // how r35g's silent command-absence cost an extra boot to diagnose.
939
+ const message = `dsh-chapters: host command registration failed (${String(error)})`;
940
+ ctx.logger?.warn?.(message);
941
+ console.error(message);
942
+ }
943
+ return () => { for (const d of disposers)
944
+ d(); };
945
+ }
946
+ //# sourceMappingURL=commands.js.map