@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.
- package/LICENSE +14 -0
- package/README.md +76 -2
- package/cordis.patch.yml +363 -0
- package/docs/architecture.md +692 -0
- package/docs/autorun-guide.md +248 -0
- package/docs/backlog.md +2022 -0
- package/docs/contract.md +938 -0
- package/docs/development.md +990 -0
- package/docs/host-compaction-seam.md +225 -0
- package/docs/host-protected-head-ask.md +80 -0
- package/docs/knowledge-repo.md +1332 -0
- package/docs/plans-carriage.md +571 -0
- package/docs/project-state.md +642 -0
- package/docs/provider.md +98 -0
- package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
- package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
- package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
- package/docs/verify.md +561 -0
- package/lib/archive.d.ts +161 -0
- package/lib/archive.js +229 -0
- package/lib/archive.js.map +1 -0
- package/lib/arrival.d.ts +23 -0
- package/lib/arrival.js +125 -0
- package/lib/arrival.js.map +1 -0
- package/lib/artifact-query.d.ts +62 -0
- package/lib/artifact-query.js +187 -0
- package/lib/artifact-query.js.map +1 -0
- package/lib/attach.d.ts +46 -0
- package/lib/attach.js +120 -0
- package/lib/attach.js.map +1 -0
- package/lib/auto-save.d.ts +145 -0
- package/lib/auto-save.js +334 -0
- package/lib/auto-save.js.map +1 -0
- package/lib/aux-message.d.ts +17 -0
- package/lib/aux-message.js +20 -0
- package/lib/aux-message.js.map +1 -0
- package/lib/client.js +1419 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph.d.ts +76 -0
- package/lib/codegraph.js +190 -0
- package/lib/codegraph.js.map +1 -0
- package/lib/commands.d.ts +52 -0
- package/lib/commands.js +946 -0
- package/lib/commands.js.map +1 -0
- package/lib/compose.d.ts +30 -0
- package/lib/compose.js +185 -0
- package/lib/compose.js.map +1 -0
- package/lib/continue-core.d.ts +245 -0
- package/lib/continue-core.js +405 -0
- package/lib/continue-core.js.map +1 -0
- package/lib/diag.d.ts +55 -0
- package/lib/diag.js +186 -0
- package/lib/diag.js.map +1 -0
- package/lib/engine-core.d.ts +301 -0
- package/lib/engine-core.js +531 -0
- package/lib/engine-core.js.map +1 -0
- package/lib/engine-note.d.ts +35 -0
- package/lib/engine-note.js +32 -0
- package/lib/engine-note.js.map +1 -0
- package/lib/engine.d.ts +329 -0
- package/lib/engine.js +1179 -0
- package/lib/engine.js.map +1 -0
- package/lib/enrich-queue.d.ts +66 -0
- package/lib/enrich-queue.js +126 -0
- package/lib/enrich-queue.js.map +1 -0
- package/lib/enrich-store.d.ts +31 -0
- package/lib/enrich-store.js +103 -0
- package/lib/enrich-store.js.map +1 -0
- package/lib/enrich-wire.d.ts +163 -0
- package/lib/enrich-wire.js +353 -0
- package/lib/enrich-wire.js.map +1 -0
- package/lib/enrich.d.ts +102 -0
- package/lib/enrich.js +267 -0
- package/lib/enrich.js.map +1 -0
- package/lib/gitops.d.ts +107 -0
- package/lib/gitops.js +539 -0
- package/lib/gitops.js.map +1 -0
- package/lib/index.d.ts +241 -0
- package/lib/index.js +1162 -0
- package/lib/index.js.map +1 -0
- package/lib/indexing.d.ts +98 -0
- package/lib/indexing.js +223 -0
- package/lib/indexing.js.map +1 -0
- package/lib/injections.d.ts +90 -0
- package/lib/injections.js +165 -0
- package/lib/injections.js.map +1 -0
- package/lib/last-resort.d.ts +24 -0
- package/lib/last-resort.js +27 -0
- package/lib/last-resort.js.map +1 -0
- package/lib/log-shape.d.ts +38 -0
- package/lib/log-shape.js +93 -0
- package/lib/log-shape.js.map +1 -0
- package/lib/models-command.d.ts +74 -0
- package/lib/models-command.js +100 -0
- package/lib/models-command.js.map +1 -0
- package/lib/notice.d.ts +74 -0
- package/lib/notice.js +92 -0
- package/lib/notice.js.map +1 -0
- package/lib/plans.d.ts +154 -0
- package/lib/plans.js +315 -0
- package/lib/plans.js.map +1 -0
- package/lib/preset.d.ts +186 -0
- package/lib/preset.js +212 -0
- package/lib/preset.js.map +1 -0
- package/lib/prompt-keys.d.ts +7 -0
- package/lib/prompt-keys.js +8 -0
- package/lib/prompt-keys.js.map +1 -0
- package/lib/prompt-loop.d.ts +73 -0
- package/lib/prompt-loop.js +147 -0
- package/lib/prompt-loop.js.map +1 -0
- package/lib/prompt-push.d.ts +47 -0
- package/lib/prompt-push.js +115 -0
- package/lib/prompt-push.js.map +1 -0
- package/lib/prompt-runner.d.ts +48 -0
- package/lib/prompt-runner.js +90 -0
- package/lib/prompt-runner.js.map +1 -0
- package/lib/prompt-state.d.ts +142 -0
- package/lib/prompt-state.js +101 -0
- package/lib/prompt-state.js.map +1 -0
- package/lib/prompt.d.ts +270 -0
- package/lib/prompt.js +408 -0
- package/lib/prompt.js.map +1 -0
- package/lib/provider.d.ts +44 -0
- package/lib/provider.js +66 -0
- package/lib/provider.js.map +1 -0
- package/lib/redact.d.ts +24 -0
- package/lib/redact.js +85 -0
- package/lib/redact.js.map +1 -0
- package/lib/registry.d.ts +187 -0
- package/lib/registry.js +235 -0
- package/lib/registry.js.map +1 -0
- package/lib/removal.d.ts +159 -0
- package/lib/removal.js +556 -0
- package/lib/removal.js.map +1 -0
- package/lib/render.d.ts +97 -0
- package/lib/render.js +530 -0
- package/lib/render.js.map +1 -0
- package/lib/repair-run.d.ts +76 -0
- package/lib/repair-run.js +506 -0
- package/lib/repair-run.js.map +1 -0
- package/lib/repair.d.ts +208 -0
- package/lib/repair.js +357 -0
- package/lib/repair.js.map +1 -0
- package/lib/repo.d.ts +78 -0
- package/lib/repo.js +184 -0
- package/lib/repo.js.map +1 -0
- package/lib/rules-commands.d.ts +46 -0
- package/lib/rules-commands.js +379 -0
- package/lib/rules-commands.js.map +1 -0
- package/lib/rules.d.ts +267 -0
- package/lib/rules.js +478 -0
- package/lib/rules.js.map +1 -0
- package/lib/save-core.d.ts +72 -0
- package/lib/save-core.js +220 -0
- package/lib/save-core.js.map +1 -0
- package/lib/search.d.ts +112 -0
- package/lib/search.js +282 -0
- package/lib/search.js.map +1 -0
- package/lib/session-rules.d.ts +222 -0
- package/lib/session-rules.js +454 -0
- package/lib/session-rules.js.map +1 -0
- package/lib/signature.d.ts +45 -0
- package/lib/signature.js +199 -0
- package/lib/signature.js.map +1 -0
- package/lib/store.d.ts +156 -0
- package/lib/store.js +368 -0
- package/lib/store.js.map +1 -0
- package/lib/sync.d.ts +220 -0
- package/lib/sync.js +810 -0
- package/lib/sync.js.map +1 -0
- package/lib/tools.d.ts +113 -0
- package/lib/tools.js +1090 -0
- package/lib/tools.js.map +1 -0
- package/lib/treedx/client.d.ts +57 -0
- package/lib/treedx/client.js +82 -0
- package/lib/treedx/client.js.map +1 -0
- package/lib/treedx/provider.d.ts +39 -0
- package/lib/treedx/provider.js +479 -0
- package/lib/treedx/provider.js.map +1 -0
- package/lib/treedx/state.d.ts +27 -0
- package/lib/treedx/state.js +65 -0
- package/lib/treedx/state.js.map +1 -0
- package/lib/types.d.ts +210 -0
- package/lib/types.js +31 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +9 -0
- package/lib/version.js +10 -0
- package/lib/version.js.map +1 -0
- package/lib/vocabulary.d.ts +41 -0
- package/lib/vocabulary.js +118 -0
- package/lib/vocabulary.js.map +1 -0
- package/lib/warnings.d.ts +10 -0
- package/lib/warnings.js +18 -0
- package/lib/warnings.js.map +1 -0
- package/package.json +151 -4
package/lib/tools.js
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chapters_segment / chapters_continue — tool adapters. Thin by policy: caller
|
|
3
|
+
* resolution, port wiring, refusal mapping. The logic is continue-core.ts and
|
|
4
|
+
* the pure modules; the shapes are the dsh-session-fork idiom (defineTool,
|
|
5
|
+
* jsonOutput, exec.agent, ctx.tools.register), each cited in docs/contract.md.
|
|
6
|
+
*/
|
|
7
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import { capReportLines, renderRefusalGroups, 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";
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { candidateTotals, estimateTokens, toolResultCandidates } from "./render.js";
|
|
17
|
+
import { detectPlans } from "./plans.js";
|
|
18
|
+
import { makeHeaderParentReader, queryTopics, renderRulesBlock } from "./session-rules.js";
|
|
19
|
+
import { rulesCommand } from "./rules-commands.js";
|
|
20
|
+
import { diagnosticWarn } from "./diag.js";
|
|
21
|
+
import { attachCreatedChild } from "./attach.js";
|
|
22
|
+
import { DEFAULT_CLONE_DIR } from "./sync.js";
|
|
23
|
+
import { lineageOf } from "./types.js";
|
|
24
|
+
import { composeChapters } from "./compose.js";
|
|
25
|
+
import { projectForCwd } from "./sync.js";
|
|
26
|
+
import { deriveRanges, refusalResult, runContinue, runFork } from "./continue-core.js";
|
|
27
|
+
import { saveSession } from "./save-core.js";
|
|
28
|
+
import { searchKnowledge, searchLocalStore } from "./search.js";
|
|
29
|
+
import { resolveArtifactWithMirror, buildToc, searchArtifact, readLines } from "./artifact-query.js";
|
|
30
|
+
import { makeArchiveFs, resolveStoreRoot } from "./store.js";
|
|
31
|
+
import { appendMissingAccess } from "./removal.js";
|
|
32
|
+
const jsonOutput = (render) => ({
|
|
33
|
+
schema: { type: 'json' },
|
|
34
|
+
render: (_args, value) => [{ type: 'text', text: render(value) }],
|
|
35
|
+
});
|
|
36
|
+
const CALLER_MISSING = { ok: false, reason: 'no calling agent: this tool runs only inside an agent session' };
|
|
37
|
+
function callerOf(exec) {
|
|
38
|
+
const agent = exec.agent;
|
|
39
|
+
return agent ?? CALLER_MISSING;
|
|
40
|
+
}
|
|
41
|
+
export function buildChaptersTools(ctx, store, config) {
|
|
42
|
+
// #52: the delegation edge for rule ancestry, read from whatever this plane can
|
|
43
|
+
// reach (live handles, else the durable session query). undefined ⇒ the walk
|
|
44
|
+
// follows registry edges only, and a block that truncated says so.
|
|
45
|
+
const ruleParentOf = makeHeaderParentReader((name) => { try {
|
|
46
|
+
return ctx.get?.(name);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
} });
|
|
51
|
+
const portsFor = async (caller) => {
|
|
52
|
+
const cwd = caller.session.header.cwd ?? '';
|
|
53
|
+
// #61: a door is touching this project's store, so THIS is where failures get written unless the
|
|
54
|
+
// operator said otherwise. First door wins; `off` in the env still means no file at all.
|
|
55
|
+
if (cwd !== '')
|
|
56
|
+
ensureDiagnosticsDefault(path.join(cwd, config.artifactStoreRoot, 'diagnostics.log'));
|
|
57
|
+
// every real tool use carries the caller's routed model — record it for
|
|
58
|
+
// the enrichment resolver (the only route source the host plane can have)
|
|
59
|
+
if (caller.options.provider !== undefined && caller.options.model !== undefined) {
|
|
60
|
+
config.noteRoute?.({ provider: caller.options.provider, model: caller.options.model });
|
|
61
|
+
}
|
|
62
|
+
const presetId = ctx.sessionProjections?.stateOf(caller.session, 'agentPreset') ?? null;
|
|
63
|
+
return {
|
|
64
|
+
readCallerEvents: async () => [...(caller.session.snapshotEvents?.() ?? [])],
|
|
65
|
+
getState: (sessionId) => store.get(sessionId),
|
|
66
|
+
putState: (sessionId, state) => store.put(sessionId, state),
|
|
67
|
+
// `store.mutate` resolves with the written row; the port contract is fire-and-forget void, so the
|
|
68
|
+
// value is deliberately dropped here rather than leaking a row into a tool result.
|
|
69
|
+
mutateState: async (sessionId, fn) => { await store.mutate(sessionId, fn); },
|
|
70
|
+
// #255: a TOC cite that failed integrity lands in the missing-access log with its referrer —
|
|
71
|
+
// the operator's "should the referrer stay?" ledger. The verdict is minted once by
|
|
72
|
+
// verifyChapter; this is delivery, not a second decision (#61).
|
|
73
|
+
onIntegrity: (referrerSessionId, citedPath, verdict) => appendMissingAccess(resolveStoreRoot(cwd, config.artifactStoreRoot), [`${new Date().toISOString()} | dead-citation | referrer-session=${referrerSessionId} | cited=${citedPath} | verdict=${verdict}`]),
|
|
74
|
+
fs: () => {
|
|
75
|
+
if (cwd === '')
|
|
76
|
+
throw new Error('caller session has no workspace cwd — nowhere reachable for the read tool');
|
|
77
|
+
return makeArchiveFs(cwd);
|
|
78
|
+
},
|
|
79
|
+
budgetProbe: async () => {
|
|
80
|
+
const { provider, model } = caller.options;
|
|
81
|
+
let windowTokens = null;
|
|
82
|
+
if (provider !== undefined && model !== undefined && ctx.llm?.resolveModelInfo !== undefined) {
|
|
83
|
+
try {
|
|
84
|
+
const info = await ctx.llm.resolveModelInfo(provider, model, new AbortController().signal);
|
|
85
|
+
windowTokens = info?.context?.contextWindow ?? null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
windowTokens = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Header bound: the last real request's full prompt (usage) — an
|
|
92
|
+
// UPPER bound on header+surface-at-that-time, so it over-counts the
|
|
93
|
+
// header conservatively; refusals err toward the loud side.
|
|
94
|
+
let headerBoundTokens = null;
|
|
95
|
+
const events = caller.session.snapshotEvents?.() ?? [];
|
|
96
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
97
|
+
const usage = events[i].data?.usage;
|
|
98
|
+
if (usage !== undefined) {
|
|
99
|
+
headerBoundTokens = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { windowTokens, headerBoundTokens };
|
|
104
|
+
},
|
|
105
|
+
newId: () => randomUUID(),
|
|
106
|
+
diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
|
|
107
|
+
createChild: async ({ sessionId, noticeEvent, modeEvents, presetId: mountPreset, title }) => {
|
|
108
|
+
const callerOptions = caller.options;
|
|
109
|
+
// The seed is contiguous from seq 0: the notice, then any STATE events this
|
|
110
|
+
// plugin authors for the child (plan mode). Invariant 2 holds — nothing from
|
|
111
|
+
// the parent's log is copied; a seeded `plan/mode` is a synthetic fact about
|
|
112
|
+
// the boundary, and the kernel's own setup events still append after it
|
|
113
|
+
// (measured: notice@0 → plan/mode@1 → end-seed@2 → permission/sandbox/…).
|
|
114
|
+
const handle = await ctx.agents.create({
|
|
115
|
+
sessionId,
|
|
116
|
+
seed: [noticeEvent, ...modeEvents],
|
|
117
|
+
inheritedEventCount: 0,
|
|
118
|
+
meta: { cwd, isSeeded: false, ...(mountPreset !== undefined && mountPreset !== null ? { agentPreset: mountPreset } : {}) },
|
|
119
|
+
...(callerOptions.provider !== undefined ? { agentOptions: callerOptions } : {}),
|
|
120
|
+
setup: async (agentCtx) => {
|
|
121
|
+
const presets = ctx.get?.('agentPresets');
|
|
122
|
+
if (presets?.mount === undefined)
|
|
123
|
+
throw new Error('agentPresets service absent — the child would be tool-less');
|
|
124
|
+
await presets.mount(agentCtx, mountPreset);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
// #54: was an optional chain through a REQUIRED step, inside a catch that only
|
|
128
|
+
// warned the profile logger. On host 0.2.x that turned a reshaped workspace API
|
|
129
|
+
// into a silent no-op — durable child, missing from the sidebar, door reporting
|
|
130
|
+
// success. attachCreatedChild verifies the user-visible fact (is it LISTED) and
|
|
131
|
+
// returns words the door passes through to the user.
|
|
132
|
+
const attach = await attachCreatedChild({
|
|
133
|
+
workspaceRegistry: ctx.get?.('workspaceRegistry'),
|
|
134
|
+
sessionQuery: ctx.get?.('sessionQuery'),
|
|
135
|
+
// the surface the browser reads — a child missing here is invisible and unopenable
|
|
136
|
+
sessionController: ctx.get?.('sessionController'),
|
|
137
|
+
diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
|
|
138
|
+
childId: sessionId,
|
|
139
|
+
cwd,
|
|
140
|
+
});
|
|
141
|
+
// Title while the child is still live: sessionController.rename resolves
|
|
142
|
+
// the agent, routes through the official session-title normalizer, and
|
|
143
|
+
// appends the durable `session/title` event (r6: commands-execute was the
|
|
144
|
+
// wrong registry; the service method is the one that sticks). A refusal
|
|
145
|
+
// here is cosmetic — warn, never roll back a durable continuation.
|
|
146
|
+
try {
|
|
147
|
+
const controller = ctx.get?.('sessionController');
|
|
148
|
+
if (controller?.rename !== undefined)
|
|
149
|
+
await controller.rename({ sessionId, title });
|
|
150
|
+
else
|
|
151
|
+
ctx.logger?.warn?.('dsh-chapters: sessionController absent — child created without a title');
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
ctx.logger?.warn?.(`dsh-chapters: child title refused (${String(error)}); child remains untitled`);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
// The live handle is ours only until the child is handed to the user:
|
|
158
|
+
// holding it blocks their resume ("already owned by an active write
|
|
159
|
+
// handle", measured r3). Dispose after the live-work, keep the session.
|
|
160
|
+
await handle.dispose?.();
|
|
161
|
+
}
|
|
162
|
+
return { warnings: attach.warnings };
|
|
163
|
+
},
|
|
164
|
+
now: () => Date.now(),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
// ---------------------------------------------------------------- segment
|
|
168
|
+
const chaptersSegment = defineTool({
|
|
169
|
+
name: 'chapters_segment',
|
|
170
|
+
description: 'Facts for planning a chapter archive of THIS session: the completed-turn archive ceiling, '
|
|
171
|
+
+ 'every tool result with its real size, and the chapters already in the registry. '
|
|
172
|
+
+ 'Returns ranges for you to propose in chapters_continue — you never write chapter text; the plugin renders it from the log.',
|
|
173
|
+
parameters: {},
|
|
174
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
175
|
+
async execute(_args, exec) {
|
|
176
|
+
const caller = callerOf(exec);
|
|
177
|
+
if ('reason' in caller)
|
|
178
|
+
return { ...CALLER_MISSING };
|
|
179
|
+
const events = caller.session.snapshotEvents?.() ?? [];
|
|
180
|
+
const lastTurnEnd = [...events].reverse().find((e) => e.type === 'turn/end');
|
|
181
|
+
if (lastTurnEnd === undefined)
|
|
182
|
+
return { ok: false, reason: 'no completed turn to archive yet' };
|
|
183
|
+
const ceiling = lastTurnEnd.seq;
|
|
184
|
+
const below = events.filter((e) => e.seq <= ceiling);
|
|
185
|
+
const callerState = await store.get(caller.session.id);
|
|
186
|
+
// Plans over the WHOLE log, with the ceiling as the archived/live split: a
|
|
187
|
+
// submission below the ceiling can already hold its verdict above it (the
|
|
188
|
+
// review is answered in the turn still open), and an approved plan must not
|
|
189
|
+
// be re-reported as pending because the range filter hid the result
|
|
190
|
+
// (docs/plans-carriage.md § 1).
|
|
191
|
+
const planScan = detectPlans(events, { ceiling });
|
|
192
|
+
const planRow = (p) => ({
|
|
193
|
+
seq: p.seq,
|
|
194
|
+
title: p.title,
|
|
195
|
+
status: p.status,
|
|
196
|
+
resultSeq: p.resultSeq,
|
|
197
|
+
bytes: p.bytes,
|
|
198
|
+
estimatedTokens: estimateTokens(p.text),
|
|
199
|
+
outline: p.outline,
|
|
200
|
+
verdict: p.verdict === null ? null : p.verdict.slice(0, 200),
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
archiveCeiling: ceiling,
|
|
205
|
+
eventCount: below.length,
|
|
206
|
+
// Plans this session submitted and their verdicts as the log states them.
|
|
207
|
+
// Facts only — the model still chooses ranges, and the plugin renders the
|
|
208
|
+
// text (invariant 4). A plan's artifact path appears in the chapter after
|
|
209
|
+
// chapters_continue writes it, never promised here.
|
|
210
|
+
plans: planScan.plans.filter((p) => p.archived).map(planRow),
|
|
211
|
+
// Above the ceiling: an unfinished turn, so NOTHING can archive it yet and
|
|
212
|
+
// no artifact exists. Say so plainly rather than let the child guess.
|
|
213
|
+
livePlans: planScan.plans.filter((p) => !p.archived).map(planRow),
|
|
214
|
+
modeActive: planScan.modeActive,
|
|
215
|
+
...(planScan.plans.length === 0
|
|
216
|
+
? {}
|
|
217
|
+
: { planHint: 'a submitted plan is a topic boundary: the chapter that carries it ENDS there, and its title becomes the chapter title' }),
|
|
218
|
+
// fresh literals, not the ToolResultCandidate interface: anonymous
|
|
219
|
+
// object types carry the implicit index signature JsonValue needs.
|
|
220
|
+
// #61: the candidate list is CAPPED and says so. The field version returned 49,932 characters
|
|
221
|
+
// while also reporting `Omitted 275266 bytes` — ~81K est tokens into a 32K-class window, asking
|
|
222
|
+
// the model to keep/drop-judge a list it had not received. Ranking is by size descending, so the
|
|
223
|
+
// cap removes the SMALL entries and the big ones a real decision turns on stay visible.
|
|
224
|
+
...(() => {
|
|
225
|
+
const range = { title: '', summary: '', startSeq: 0, endSeq: ceiling };
|
|
226
|
+
const cap = config.segmentCandidateCapTokens ?? 4000;
|
|
227
|
+
const kept = cap > 0
|
|
228
|
+
? toolResultCandidates(below, range, cap)
|
|
229
|
+
: toolResultCandidates(below, range);
|
|
230
|
+
const totals = candidateTotals(below, range);
|
|
231
|
+
const withheld = Math.max(0, totals.count - kept.length);
|
|
232
|
+
const withheldBytes = Math.max(0, totals.bytes - kept.reduce((m, c) => m + c.bytes, 0));
|
|
233
|
+
return {
|
|
234
|
+
toolResults: kept.map((c) => ({ seq: c.seq, toolName: c.toolName, bytes: c.bytes, estimatedTokens: c.estimatedTokens, excerpt: c.excerpt })),
|
|
235
|
+
toolResultsReport: `showing ${kept.length} of ${totals.count} candidates by size, ${(withheldBytes / 1024).toFixed(0)} KB withheld`
|
|
236
|
+
+ (cap > 0 ? ` (segmentCandidateCapTokens=${cap})` : ' (uncapped)'),
|
|
237
|
+
};
|
|
238
|
+
})(),
|
|
239
|
+
existingChapters: callerState.chapters.map((c) => ({
|
|
240
|
+
number: c.number, path: c.path, title: c.title, startSeq: c.startSeq, endSeq: c.endSeq,
|
|
241
|
+
})),
|
|
242
|
+
budgetHint: { continuationBudgetRatio: config.continuationBudgetRatio, chapterTokenTarget: config.chapterTokenTarget },
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
// ---------------------------------------------------------------- continue
|
|
247
|
+
const chaptersContinue = defineTool({
|
|
248
|
+
name: 'chapters_continue',
|
|
249
|
+
description: 'Archive this conversation as verbatim Markdown chapters and open a NEW session that starts from their '
|
|
250
|
+
+ 'table of contents plus your handoff note. Supply event ranges (startSeq/endSeq) — never chapter text; '
|
|
251
|
+
+ 'the plugin renders bodies from the session log. This session is left untouched; the child gets its own '
|
|
252
|
+
+ 'cache and can reload any chapter with read. Oversized tool results defer to reference files by default; '
|
|
253
|
+
+ 'toolResultOverrides marks the exceptions you want kept inline (it still writes the artifact).',
|
|
254
|
+
parameters: {
|
|
255
|
+
title: { type: 'string', required: true, description: 'Name for the continued session.' },
|
|
256
|
+
handoffNote: { type: 'string', required: true, description: 'State of play for the child: what was in progress, decisions held, the next step. Budget-checked as part of the notice, never silently truncated.' },
|
|
257
|
+
chapters: {
|
|
258
|
+
type: 'array',
|
|
259
|
+
required: true,
|
|
260
|
+
description: 'Ascending, non-overlapping ranges covering every event to archive (<= the archiveCeiling from chapters_segment).',
|
|
261
|
+
items: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
properties: {
|
|
265
|
+
title: { type: 'string', required: true, description: 'Chapter title (topic-named; it becomes the file slug).' },
|
|
266
|
+
summary: { type: 'string', required: true, description: 'One-line TOC summary of what the chapter holds.' },
|
|
267
|
+
startSeq: { type: 'integer', required: true, description: 'First durable event seq, inclusive.' },
|
|
268
|
+
endSeq: { type: 'integer', required: true, description: 'Last durable event seq, inclusive.' },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
toolResultOverrides: {
|
|
273
|
+
type: 'array',
|
|
274
|
+
description: 'Sparse exceptions to the defer-by-default rule: {seq, inline:true} keeps a large result inline in its chapter (the artifact is still written); {seq, inline:false} trims a small one.',
|
|
275
|
+
items: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
additionalProperties: false,
|
|
278
|
+
properties: {
|
|
279
|
+
seq: { type: 'integer', required: true, description: 'The tool result event seq (from chapters_segment toolResults).' },
|
|
280
|
+
inline: { type: 'boolean', required: true, description: 'Keep inline (true) or defer (false), overriding the size default for this one result.' },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
286
|
+
async execute(args, exec) {
|
|
287
|
+
const caller = callerOf(exec);
|
|
288
|
+
if ('reason' in caller)
|
|
289
|
+
return { ...CALLER_MISSING };
|
|
290
|
+
try {
|
|
291
|
+
const ports = await portsFor(caller);
|
|
292
|
+
const cwd = caller.session.header?.cwd ?? '';
|
|
293
|
+
if (cwd !== '' && config.scheduler !== undefined) {
|
|
294
|
+
await config.scheduler.pullFor(cwd).catch(() => undefined); // §5.1: pull → archive → push
|
|
295
|
+
}
|
|
296
|
+
const line = projectLineFor(caller);
|
|
297
|
+
const rs = await rulesSectionFor(caller, 'continue');
|
|
298
|
+
if (rs.refusal !== undefined)
|
|
299
|
+
return { ok: false, reason: rs.refusal };
|
|
300
|
+
const result = await runContinue(ports, {
|
|
301
|
+
callerSessionId: caller.session.id,
|
|
302
|
+
callerPreset: ctx.sessionProjections?.stateOf(caller.session, 'agentPreset') ?? null,
|
|
303
|
+
title: args.title,
|
|
304
|
+
handoffNote: args.handoffNote,
|
|
305
|
+
chapters: args.chapters,
|
|
306
|
+
toolResultOverrides: args.toolResultOverrides ?? [],
|
|
307
|
+
...(line !== undefined ? { projectLine: line } : {}),
|
|
308
|
+
...(rs.section !== undefined ? { rulesSection: rs.section } : {}),
|
|
309
|
+
}, { ...config, workspaceCwd: cwd });
|
|
310
|
+
if (result.ok && cwd !== '')
|
|
311
|
+
config.scheduler?.schedule(cwd, 'archive:continue');
|
|
312
|
+
// conditional spreads: absent optionals must not surface as `undefined`
|
|
313
|
+
// (not a JsonValue); the output schema validates the successful body.
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
...(result.childSessionId !== undefined ? { childSessionId: result.childSessionId } : {}),
|
|
317
|
+
...(result.presetUsed !== undefined ? { presetUsed: result.presetUsed } : {}),
|
|
318
|
+
...(result.chapters !== undefined ? { chapters: result.chapters } : {}),
|
|
319
|
+
...(result.warnings !== undefined && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
320
|
+
...(result.budget !== undefined ? { budget: result.budget } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
const refusal = refusalResult(error);
|
|
325
|
+
if (refusal !== null) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
reason: refusal.reason ?? 'refused',
|
|
329
|
+
...(refusal.budget !== undefined ? { budget: refusal.budget } : {}),
|
|
330
|
+
...(refusal.warnings !== undefined && refusal.warnings.length > 0 ? { warnings: refusal.warnings } : {}),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return { ok: false, reason: `chapters_continue failed: ${error?.stack ?? String(error)}`.slice(0, 800) };
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
// ---------------------------------------------------------------- fork
|
|
338
|
+
const chaptersFork = defineTool({
|
|
339
|
+
name: 'chapters_fork',
|
|
340
|
+
description: 'Open a SIBLING session citing the same chapter archive this one already has — nothing new is '
|
|
341
|
+
+ 'archived, no files written, no numbers spent. Use for trying a genuinely different approach '
|
|
342
|
+
+ 'while the current session keeps working. Your handoff note says what the sibling should do.',
|
|
343
|
+
parameters: {
|
|
344
|
+
title: { type: 'string', required: true, description: 'Name for the forked session.' },
|
|
345
|
+
handoffNote: { type: 'string', required: true, description: 'What the sibling is for — its whole context is the archive TOC plus this note.' },
|
|
346
|
+
},
|
|
347
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
348
|
+
async execute(args, exec) {
|
|
349
|
+
const caller = callerOf(exec);
|
|
350
|
+
if ('reason' in caller)
|
|
351
|
+
return { ...CALLER_MISSING };
|
|
352
|
+
try {
|
|
353
|
+
const ports = await portsFor(caller);
|
|
354
|
+
const cwd = caller.session.header?.cwd ?? '';
|
|
355
|
+
if (cwd !== '' && config.scheduler !== undefined) {
|
|
356
|
+
await config.scheduler.pullFor(cwd).catch(() => undefined);
|
|
357
|
+
}
|
|
358
|
+
const line = projectLineFor(caller);
|
|
359
|
+
const rs = await rulesSectionFor(caller, 'branch');
|
|
360
|
+
if (rs.refusal !== undefined)
|
|
361
|
+
return { ok: false, reason: rs.refusal };
|
|
362
|
+
const result = await runFork(ports, {
|
|
363
|
+
callerSessionId: caller.session.id,
|
|
364
|
+
callerPreset: ctx.sessionProjections?.stateOf(caller.session, 'agentPreset') ?? null,
|
|
365
|
+
title: args.title,
|
|
366
|
+
handoffNote: args.handoffNote,
|
|
367
|
+
// A branch splits a NEW direction off the history: plans are cited, not
|
|
368
|
+
// re-asserted, and plan mode is not inherited (decision 2026-10-10).
|
|
369
|
+
intent: 'branch',
|
|
370
|
+
...(line !== undefined ? { projectLine: line } : {}),
|
|
371
|
+
...(rs.section !== undefined ? { rulesSection: rs.section } : {}),
|
|
372
|
+
}, config);
|
|
373
|
+
if (result.ok && cwd !== '')
|
|
374
|
+
config.scheduler?.schedule(cwd, 'archive:fork');
|
|
375
|
+
return {
|
|
376
|
+
ok: true,
|
|
377
|
+
...(result.childSessionId !== undefined ? { childSessionId: result.childSessionId } : {}),
|
|
378
|
+
...(result.presetUsed !== undefined ? { presetUsed: result.presetUsed } : {}),
|
|
379
|
+
...(result.budget !== undefined ? { budget: result.budget } : {}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const refusal = refusalResult(error);
|
|
384
|
+
if (refusal !== null) {
|
|
385
|
+
return { ok: false, reason: refusal.reason ?? 'refused', ...(refusal.budget !== undefined ? { budget: refusal.budget } : {}) };
|
|
386
|
+
}
|
|
387
|
+
return { ok: false, reason: `chapters_fork failed: ${error?.stack ?? String(error)}`.slice(0, 800) };
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
const deterministicCommand = (door) => ({
|
|
392
|
+
name: door.name,
|
|
393
|
+
description: door.intent === 'branch'
|
|
394
|
+
? 'Fork this conversation into a new session: archive everything so far into verbatim chapters and open a child whose history is the table of contents. Optional argument: a title for the branch.'
|
|
395
|
+
: 'Continue this conversation in a new session: archive everything so far into verbatim chapters and open a child that carries the same stream of work — its notice names the operative plan, if one is still standing. Optional argument: a title for the continuation.',
|
|
396
|
+
input: { hint: '[messageSeq] [title]' },
|
|
397
|
+
async handler(invocation) {
|
|
398
|
+
const agent = invocation.agent;
|
|
399
|
+
if (agent?.session === undefined)
|
|
400
|
+
return { kind: 'error', text: `${door.name}: no session context for this invocation` };
|
|
401
|
+
try {
|
|
402
|
+
const ports = await portsFor(agent);
|
|
403
|
+
const cwd0 = agent.session.header?.cwd ?? '';
|
|
404
|
+
if (cwd0 !== '' && config.scheduler !== undefined) {
|
|
405
|
+
await config.scheduler.pullFor(cwd0).catch(() => undefined);
|
|
406
|
+
}
|
|
407
|
+
const events = await ports.readCallerEvents();
|
|
408
|
+
// #221 ANCHORED DOORS: an optional numeric first argument selects the message to continue
|
|
409
|
+
// FROM — the last completed turn at or before it. The maintainer clicked Continue on an
|
|
410
|
+
// earlier assistant message and got end-of-session anyway; the affordance promised
|
|
411
|
+
// "continue from here" and delivered "continue from the end". No argument keeps today's
|
|
412
|
+
// behavior exactly (end of session). Parsing is strict: a non-numeric first token stays the
|
|
413
|
+
// title, so `/chapters-continue my title` is untouched, and only a bare integer anchors.
|
|
414
|
+
// Out-of-range anchors REFUSE with the numbers rather than silently archiving more or less.
|
|
415
|
+
const trimmedRaw = (invocation.rawInput ?? '').trim();
|
|
416
|
+
const anchorMatch = /^(\d+)\s*([\s\S]*)$/.exec(trimmedRaw);
|
|
417
|
+
let requestedAnchor = null;
|
|
418
|
+
let titleRest = trimmedRaw;
|
|
419
|
+
if (anchorMatch !== null && anchorMatch[1] !== undefined) {
|
|
420
|
+
requestedAnchor = Number(anchorMatch[1]);
|
|
421
|
+
titleRest = (anchorMatch[2] ?? '').trim();
|
|
422
|
+
}
|
|
423
|
+
const turnEnds = [...events].filter((e) => e.type === 'turn/end');
|
|
424
|
+
let anchor;
|
|
425
|
+
if (requestedAnchor !== null) {
|
|
426
|
+
const chosen = turnEnds.filter((e) => (e.seq ?? 0) <= requestedAnchor).at(-1);
|
|
427
|
+
if (chosen === undefined) {
|
|
428
|
+
const first = turnEnds[0]?.seq;
|
|
429
|
+
return { kind: 'error', text: `${door.name}: seq ${String(requestedAnchor)} has no completed turn beneath it — the earliest turn boundary is ${String(first ?? '-')}; try again without the number to archive everything` };
|
|
430
|
+
}
|
|
431
|
+
anchor = chosen.seq;
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
anchor = turnEnds.at(-1)?.seq;
|
|
435
|
+
}
|
|
436
|
+
if (anchor === undefined)
|
|
437
|
+
return { kind: 'error', text: `${door.name}: nothing to archive yet — the conversation has no completed turn` };
|
|
438
|
+
const parentTitle = [...events].reverse()
|
|
439
|
+
.find((e) => e.type === 'session/title')?.data?.title;
|
|
440
|
+
const title = titleRest !== '' ? titleRest : `${parentTitle ?? `Chapters ${door.noun}`} \u2014 ${door.noun}`;
|
|
441
|
+
const rsCmd = await rulesSectionFor(agent, door.intent);
|
|
442
|
+
if (rsCmd.refusal !== undefined)
|
|
443
|
+
return { kind: 'error', text: `${door.name} refused: ${rsCmd.refusal}` };
|
|
444
|
+
const callerArgs = {
|
|
445
|
+
callerSessionId: agent.session.id,
|
|
446
|
+
callerPreset: ctx.sessionProjections?.stateOf(agent.session, 'agentPreset') ?? null,
|
|
447
|
+
title,
|
|
448
|
+
// THE intent, and the only place it is chosen for a human-pulled door.
|
|
449
|
+
intent: door.intent,
|
|
450
|
+
toolResultOverrides: [],
|
|
451
|
+
...(projectLineFor(agent) !== undefined ? { projectLine: projectLineFor(agent) } : {}),
|
|
452
|
+
...(rsCmd.section !== undefined ? { rulesSection: rsCmd.section } : {}),
|
|
453
|
+
};
|
|
454
|
+
// Archive watermark: compaction chapters (and prior forks) already carry
|
|
455
|
+
// [.. lastArchived]. This segments ONLY what is newer — the store stays
|
|
456
|
+
// append-only AND duplicate-free, and the child's TOC inherits every earlier
|
|
457
|
+
// chapter through the ancestry walk. When nothing is newer, this degrades to a
|
|
458
|
+
// pure citation fork: child created, archive cited, nothing written.
|
|
459
|
+
const parentState = await ports.getState(agent.session.id);
|
|
460
|
+
const lastArchived = parentState.chapters.reduce((m, c) => Math.max(m, c.endSeq), 0);
|
|
461
|
+
const fromSeq = lastArchived > 0 ? lastArchived + 1 : 0;
|
|
462
|
+
if (fromSeq > 0 && anchor <= lastArchived) {
|
|
463
|
+
const forked = await runFork(ports, { ...callerArgs, handoffNote: `Archived through the Chapters ${door.noun}. Nothing had been said since the last archive, so this ${door.noun} cites the existing chapters unchanged. ${door.noDirective}` }, config);
|
|
464
|
+
if (cwd0 !== '')
|
|
465
|
+
config.scheduler?.schedule(cwd0, 'archive:fork-cite');
|
|
466
|
+
return { kind: 'success', text: `${door.verb} (nothing new to archive): ${door.noun} \u201C${title}\u201D is session ${forked.childSessionId ?? '(created)'} citing ${parentState.chapters.length} existing chapter(s). Switch from the sidebar.` };
|
|
467
|
+
}
|
|
468
|
+
// Topic-sequential composition (record §4.2): merge adjacent same-task
|
|
469
|
+
// collections from the stored per-turn signatures. No signatures yet (a session
|
|
470
|
+
// that predates the feature) → the legacy size-based segmentation, which is
|
|
471
|
+
// exactly what deriveRanges is.
|
|
472
|
+
const composed = composeChapters(events, fromSeq, anchor, parentState.collections, {
|
|
473
|
+
mergeThreshold: config.mergeThreshold,
|
|
474
|
+
chapterLimit: config.chapterLimit,
|
|
475
|
+
});
|
|
476
|
+
const chapters = composed.chapters.length > 0
|
|
477
|
+
? composed.chapters
|
|
478
|
+
: deriveRanges(events, anchor, config.chapterTokenTarget, fromSeq).chapters;
|
|
479
|
+
const notes = composed.chapters.length > 0
|
|
480
|
+
? composed.notes
|
|
481
|
+
: ['no stored turn signatures — legacy size-based segmentation'];
|
|
482
|
+
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}`
|
|
483
|
+
+ (notes.length > 0 ? `\n(Segmentation notes: ${notes.join('; ')})` : '');
|
|
484
|
+
const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, { ...config, workspaceCwd: cwd0 });
|
|
485
|
+
if (result.ok && cwd0 !== '')
|
|
486
|
+
config.scheduler?.schedule(cwd0, 'archive:fork');
|
|
487
|
+
const budgetText = result.budget !== undefined ? `; TOC ~${result.budget.usedTokens} tokens, allowance ${result.budget.allowanceTokens}` : '';
|
|
488
|
+
// #61: the archive happened; the caveats were in `result.warnings` and were NEVER printed here.
|
|
489
|
+
const dWarn = formatWarnings(result.warnings ?? [], config.warningsDisplayCap ?? 6);
|
|
490
|
+
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.`
|
|
491
|
+
+ (dWarn === '' ? '' : `\n warnings: ${dWarn}`) };
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
const refusal = refusalResult(error);
|
|
495
|
+
if (refusal !== null) {
|
|
496
|
+
const budget = refusal.budget !== undefined ? ` (notice ~${refusal.budget.usedTokens} > allowance ${refusal.budget.allowanceTokens})` : '';
|
|
497
|
+
const rWarn = formatWarnings(refusal.warnings ?? [], config.warningsDisplayCap ?? 6);
|
|
498
|
+
return { kind: 'error', text: `${door.name} refused: ${refusal.reason ?? 'refused'}${budget}${rWarn === '' ? '' : `\n warnings: ${rWarn}`}` };
|
|
499
|
+
}
|
|
500
|
+
return { kind: 'error', text: `${door.name} failed: ${String(error?.message ?? error)}`.slice(0, 500) };
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
const forkCommand = deterministicCommand({
|
|
505
|
+
name: 'chapters-fork',
|
|
506
|
+
intent: 'branch',
|
|
507
|
+
noun: 'branch',
|
|
508
|
+
verb: 'Forked',
|
|
509
|
+
// A branch SPLITS the work (decision 3): it inherits history, not an order.
|
|
510
|
+
noDirective: 'No task was handed over with this fork: ask the user what this branch should work on.',
|
|
511
|
+
});
|
|
512
|
+
const continueCommand = deterministicCommand({
|
|
513
|
+
name: 'chapters-continue',
|
|
514
|
+
intent: 'continue',
|
|
515
|
+
noun: 'continuation',
|
|
516
|
+
verb: 'Continued',
|
|
517
|
+
// A continuation EXTENDS the same stream. When a plan is operative the notice already
|
|
518
|
+
// carries it as a directive, so this line only speaks for the plan-less case.
|
|
519
|
+
noDirective: 'If the notice above names an active plan, that is your directive — read its full text before implementing and do NOT re-plan from recall. Otherwise ask the user what to carry on with.',
|
|
520
|
+
});
|
|
521
|
+
// ------------------------------------------------- /chapters-save (archive without child)
|
|
522
|
+
/**
|
|
523
|
+
* Archive-only door: the SHARED save core (src/save-core.ts) plus this door's
|
|
524
|
+
* policies — human-readable refusal/success text, enrichment run-now (a human
|
|
525
|
+
* asked, so a human waits for nothing else), sync scheduled. The same core is
|
|
526
|
+
* what door 5 (subagent settlement) runs; a row pins that both doors archive
|
|
527
|
+
* byte-identically (tests/integration/subagent-auto-save.test.ts).
|
|
528
|
+
*/
|
|
529
|
+
const saveCommand = {
|
|
530
|
+
name: 'chapters-save',
|
|
531
|
+
description: 'Save completed turns as searchable chapters without creating a new session. Archives verbatim, updates the search index, and triggers enrichment. Idempotent — re-running saves only what has not yet been archived.',
|
|
532
|
+
input: { hint: '[messageSeq] [title]' },
|
|
533
|
+
async handler(invocation) {
|
|
534
|
+
const agent = invocation.agent;
|
|
535
|
+
if (agent?.session === undefined)
|
|
536
|
+
return { kind: 'error', text: 'chapters-save: no session context for this invocation' };
|
|
537
|
+
try {
|
|
538
|
+
const ports = await portsFor(agent);
|
|
539
|
+
const cwd0 = agent.session.header?.cwd ?? '';
|
|
540
|
+
const events = await ports.readCallerEvents();
|
|
541
|
+
// Lineage from the durable header (issue #47): a child running under the
|
|
542
|
+
// subagent seam saves through this same door and gets stamped identically.
|
|
543
|
+
const out = await saveSession({
|
|
544
|
+
events,
|
|
545
|
+
sessionId: agent.session.id,
|
|
546
|
+
cwd: cwd0,
|
|
547
|
+
store,
|
|
548
|
+
config: {
|
|
549
|
+
artifactStoreRoot: config.artifactStoreRoot,
|
|
550
|
+
chapterTokenTarget: config.chapterTokenTarget,
|
|
551
|
+
toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
|
|
552
|
+
mergeThreshold: config.mergeThreshold,
|
|
553
|
+
chapterLimit: config.chapterLimit,
|
|
554
|
+
chapterOversizeFactor: config.chapterOversizeFactor,
|
|
555
|
+
...(config.planInlineChars !== undefined ? { planInlineChars: config.planInlineChars } : {}),
|
|
556
|
+
...(config.callArgArtifactTokens !== undefined ? { callArgArtifactTokens: config.callArgArtifactTokens } : {}),
|
|
557
|
+
},
|
|
558
|
+
subagentOf: lineageOf(agent.session.header),
|
|
559
|
+
});
|
|
560
|
+
if (out.skipped === 'no-completed-turn') {
|
|
561
|
+
return { kind: 'error', text: 'chapters-save: nothing to save yet — the conversation has no completed turn' };
|
|
562
|
+
}
|
|
563
|
+
if (out.skipped === 'nothing-new') {
|
|
564
|
+
return { kind: 'success', text: `Nothing to save: all ${out.priorChapters} chapter(s) already archived through seq ${out.lastArchived}. Content is searchable via chapters_search.` };
|
|
565
|
+
}
|
|
566
|
+
// Schedule enrichment asynchronously — do NOT await. Enrichment calls
|
|
567
|
+
// the LLM per chapter and can exceed the command timeout. The idle and
|
|
568
|
+
// afterPush triggers will pick up any remaining work.
|
|
569
|
+
if (config.enrich !== undefined) {
|
|
570
|
+
void config.enrich.runNow(cwd0 || undefined).catch(() => undefined);
|
|
571
|
+
}
|
|
572
|
+
// Schedule sync so saved chapters travel to the pool.
|
|
573
|
+
if (cwd0 !== '')
|
|
574
|
+
config.scheduler?.schedule(cwd0, 'archive:save');
|
|
575
|
+
// #61: this used to be ` (86 warning(s))` — a COUNT that carried none of the 86 messages, six of
|
|
576
|
+
// which named the hollow chapter and an unarchived 0-4082 hole. A count is not a message.
|
|
577
|
+
const warnText = formatWarnings(out.warnings, config.warningsDisplayCap ?? 6);
|
|
578
|
+
return { kind: 'success', text: `Saved: ${out.records.length} chapter(s) archived (seqs ${out.fromSeq}..${out.anchor}).`
|
|
579
|
+
+ (warnText === '' ? '' : `\n warnings: ${warnText}`)
|
|
580
|
+
+ ` Enrichment queued. Content is searchable via chapters_search from any session in this workspace.` };
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
return { kind: 'error', text: `chapters-save failed: ${String(error?.message ?? error)}`.slice(0, 500) };
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
// ------------------------------------------------- knowledge project line
|
|
588
|
+
// The notice carries the knowledge-project line (record §8) when a project
|
|
589
|
+
// is linked for this workspace: the deepest project record whose recorded
|
|
590
|
+
// cwd is a prefix of the caller's cwd.
|
|
591
|
+
const projectLineFor = (agent) => {
|
|
592
|
+
try {
|
|
593
|
+
const cwd = agent.session.header?.cwd ?? '';
|
|
594
|
+
const best = projectForCwd(store.projects(), cwd);
|
|
595
|
+
if (best === undefined)
|
|
596
|
+
return undefined;
|
|
597
|
+
return `Project: ${best.slug} \u00B7 ${best.projectKey}\nSearch past work with chapters_search (topic or keyword query; paths open with the read tool).`;
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
/**
|
|
604
|
+
* §7.3/§7.6: the rules block for a head this door is about to build, through
|
|
605
|
+
* the ONE renderer the compaction checkpoint also uses
|
|
606
|
+
* (`session-rules.renderRulesBlock`) — a session must never see two rule sets
|
|
607
|
+
* depending on which path rebuilt its head.
|
|
608
|
+
*
|
|
609
|
+
* `intent` is the whole semantic difference between the two doors, and it was
|
|
610
|
+
* already the axis that decides plan bytes (docs/plans-carriage.md §§ 9–13): a
|
|
611
|
+
* CONTINUATION extends the same stream of work, so it inherits the session
|
|
612
|
+
* tier; a BRANCH splits a new direction, so it gets the project tier and ZERO
|
|
613
|
+
* session bytes — a branch is not an order.
|
|
614
|
+
*/
|
|
615
|
+
const rulesSectionFor = async (agent, intent) => {
|
|
616
|
+
try {
|
|
617
|
+
if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined)
|
|
618
|
+
return {};
|
|
619
|
+
const cwd = agent.session.header?.cwd ?? '';
|
|
620
|
+
if (cwd === '')
|
|
621
|
+
return {};
|
|
622
|
+
const best = projectForCwd(store.projects(), cwd);
|
|
623
|
+
// §7.8: a session with NO project resolves normally renders zero rule bytes —
|
|
624
|
+
// and silence is exactly what this round exists to remove. But adding a
|
|
625
|
+
// "nothing is linked" line to EVERY unlinked workspace's head would spend real
|
|
626
|
+
// bytes on a warning most sessions do not need, so the line appears only when
|
|
627
|
+
// the session tier IS governing the head: then "0 project rules" is a fact the
|
|
628
|
+
// child must not misread as "the team approved nothing".
|
|
629
|
+
if (best === undefined) {
|
|
630
|
+
const orphan = await renderRulesBlock({
|
|
631
|
+
mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
|
|
632
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
|
|
633
|
+
projectKey: '',
|
|
634
|
+
harnessId: config.harnessId,
|
|
635
|
+
budgetTokens: config.coreRulesBudgetTokens,
|
|
636
|
+
relevance: config.rulesRelevance ?? true,
|
|
637
|
+
includeSession: intent === 'continue',
|
|
638
|
+
store,
|
|
639
|
+
sessionId: agent.session.id,
|
|
640
|
+
...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
|
|
641
|
+
});
|
|
642
|
+
if (orphan.kind !== 'ok' || orphan.shown.session === 0)
|
|
643
|
+
return {};
|
|
644
|
+
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 : ''}` };
|
|
645
|
+
}
|
|
646
|
+
const built = await renderRulesBlock({
|
|
647
|
+
mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
|
|
648
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
|
|
649
|
+
projectKey: best.projectKey,
|
|
650
|
+
harnessId: config.harnessId,
|
|
651
|
+
budgetTokens: config.coreRulesBudgetTokens,
|
|
652
|
+
...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
|
|
653
|
+
...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
|
|
654
|
+
relevance: config.rulesRelevance ?? true,
|
|
655
|
+
includeSession: intent === 'continue',
|
|
656
|
+
store,
|
|
657
|
+
sessionId: agent.session.id,
|
|
658
|
+
...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
|
|
659
|
+
});
|
|
660
|
+
if (built.kind === 'none')
|
|
661
|
+
return {};
|
|
662
|
+
if (built.kind === 'refusal') {
|
|
663
|
+
const per = built.perRule.map((r) => `${r.id}=${r.tokens}`).join(', ');
|
|
664
|
+
return { refusal: `core rules overflow their budget (${built.total} tokens > cap ${built.cap}; per rule: ${per}). Revoke or shrink with /chapters-rule — the notice never clips.` };
|
|
665
|
+
}
|
|
666
|
+
return { section: built.text };
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return {}; // rules are additive; a broken mirror must not break continuation
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
// ------------------------------------------------- knowledge search
|
|
673
|
+
// The mirror (clone) is the corpus: it holds local + pulled files, so
|
|
674
|
+
// searching it is searching the whole knowledge pool for this project.
|
|
675
|
+
const chaptersSearch = defineTool({
|
|
676
|
+
name: 'chapters_search',
|
|
677
|
+
description: 'Search this project\u2019s chapter knowledge pool (past sessions, indexed by topic/title/summary). '
|
|
678
|
+
+ 'Pass a topic or keyword query; raise maxTokens to see more of the ranked list. Results are file paths readable with the read tool.',
|
|
679
|
+
parameters: {
|
|
680
|
+
query: { type: 'string', required: true, description: 'Topic or keyword query.' },
|
|
681
|
+
maxTokens: { type: 'number', description: 'Token budget for the result list (default 400; raise to page deeper).' },
|
|
682
|
+
},
|
|
683
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
684
|
+
async execute(args, exec) {
|
|
685
|
+
const caller = callerOf(exec);
|
|
686
|
+
if ('reason' in caller)
|
|
687
|
+
return { ...CALLER_MISSING };
|
|
688
|
+
const cwd = caller.session.header?.cwd ?? '';
|
|
689
|
+
// Typed once rather than spread from a conditional: the union of `{…} | {}` is
|
|
690
|
+
// not spreadable under this tsconfig, and the alternative was casting blind.
|
|
691
|
+
const localOpts = () => config.rulesCoreBonus !== undefined ? { rulesCoreBonus: config.rulesCoreBonus } : {};
|
|
692
|
+
const cloneDir = path.join(cwd, DEFAULT_CLONE_DIR);
|
|
693
|
+
const budget = args.maxTokens ?? config.searchMaxTokens ?? 400;
|
|
694
|
+
// Two doors, one scorer. The pool mirror when a project is linked; the workspace
|
|
695
|
+
// store when it is not — because the chapters, `kind: plan` documents and rules
|
|
696
|
+
// are ALREADY here, and "link a remote first" is not an answer to a question a
|
|
697
|
+
// local archive can reply to. Which door answered is stated in `note`, so a
|
|
698
|
+
// session never mistakes "this machine only" for "the whole project".
|
|
699
|
+
// "not linked" and "linked but not pulled yet" are different situations with
|
|
700
|
+
// different fixes; one note for both would send a session to link a project it
|
|
701
|
+
// already linked (measured: the pinned row for exactly that case). The door is
|
|
702
|
+
// named on EVERY branch, including the empty store — a session cannot tell
|
|
703
|
+
// "nothing here" from "I did not look here" without it.
|
|
704
|
+
const doorTail = () => {
|
|
705
|
+
let linked = false;
|
|
706
|
+
try {
|
|
707
|
+
linked = projectForCwd(store.projects(), cwd) !== undefined;
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
linked = false;
|
|
711
|
+
}
|
|
712
|
+
return linked
|
|
713
|
+
? 'linked project has no pool mirror on disk yet — the sync loop pulls it; /chapters-status shows the last sync'
|
|
714
|
+
: 'no pool mirror — link one with /chapters-link to reach other machines\u2019 work';
|
|
715
|
+
};
|
|
716
|
+
const localNote = (n) => `searched this machine's store (${String(n)} indexed document(s); ${doorTail()})`;
|
|
717
|
+
if (!fs.existsSync(cloneDir)) {
|
|
718
|
+
const local = searchLocalStore(resolveStoreRoot(cwd, config.artifactStoreRoot), args.query, budget, localOpts());
|
|
719
|
+
return {
|
|
720
|
+
ok: true,
|
|
721
|
+
results: local.results.map((r) => ({ score: r.score, date: r.date, kind: r.kind, title: r.title, path: r.path, topics: r.topics })),
|
|
722
|
+
total: local.total,
|
|
723
|
+
shown: local.shown,
|
|
724
|
+
line: local.line,
|
|
725
|
+
budget: local.budget,
|
|
726
|
+
...(local.results.length > 0 ? { note: localNote(local.documents) } : { note: `nothing archived in this workspace yet — nothing to search (${String(local.documents)} indexed documents; ${doorTail()})` }),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const outcome = searchKnowledge(cloneDir, args.query, budget, {
|
|
730
|
+
...(config.harnessId !== undefined ? { harnessId: config.harnessId } : {}),
|
|
731
|
+
...(config.rulesCoreBonus !== undefined ? { rulesCoreBonus: config.rulesCoreBonus } : {}),
|
|
732
|
+
});
|
|
733
|
+
if (outcome.results.length === 0) {
|
|
734
|
+
const local = searchLocalStore(resolveStoreRoot(cwd, config.artifactStoreRoot), args.query, budget, localOpts());
|
|
735
|
+
if (local.results.length > 0) {
|
|
736
|
+
return {
|
|
737
|
+
ok: true,
|
|
738
|
+
results: local.results.map((r) => ({ score: r.score, date: r.date, kind: r.kind, title: r.title, path: r.path, topics: r.topics })),
|
|
739
|
+
total: local.total,
|
|
740
|
+
shown: local.shown,
|
|
741
|
+
line: local.line,
|
|
742
|
+
budget: local.budget,
|
|
743
|
+
note: `no pool match; found ${String(local.results.length)} in this machine's store`,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
results: outcome.results.map((r) => ({ score: r.score, date: r.date, kind: r.kind, title: r.title, path: r.path, topics: r.topics })),
|
|
750
|
+
total: outcome.total,
|
|
751
|
+
shown: outcome.shown,
|
|
752
|
+
budget: { requested: outcome.budget.requested, used: outcome.budget.used, remaining: outcome.budget.remaining },
|
|
753
|
+
...(outcome.total > outcome.shown ? { note: `showing ${outcome.shown} of ${outcome.total} within ${outcome.budget.requested} tokens — raise maxTokens to see the rest` } : {}),
|
|
754
|
+
};
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
const chaptersArtifact = defineTool({
|
|
758
|
+
name: 'chapters_artifact',
|
|
759
|
+
description: 'Query a stored artifact (a tool result too large to sit in context — deferred blobs and arrival-stored documents alike) '
|
|
760
|
+
+ 'WITHOUT loading it whole: action "toc" = heading map with line numbers (structured/markdown files), '
|
|
761
|
+
+ '"search" = query (regex or phrase) → small matched blocks with line numbers, "read" = exact line range. '
|
|
762
|
+
+ 'path accepts the artifacts/… path or the bare sha256 from the stub line.',
|
|
763
|
+
parameters: {
|
|
764
|
+
path: { type: 'string', required: true, description: 'Artifact reference from the stub: artifacts/<xx>/<sha>.txt path or the 64-hex sha.' },
|
|
765
|
+
action: { type: 'string', required: true, description: 'One of: toc | search | read.' },
|
|
766
|
+
query: { type: 'string', description: 'Required for search: regex or literal phrase (case-insensitive).' },
|
|
767
|
+
offset: { type: 'number', description: 'read: first line (1-based). Default 1.' },
|
|
768
|
+
limit: { type: 'number', description: 'read: line count (hard cap 400).' },
|
|
769
|
+
maxTokens: { type: 'number', description: 'search: budget for returned blocks (default 400).' },
|
|
770
|
+
},
|
|
771
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
772
|
+
async execute(args, exec) {
|
|
773
|
+
const caller = callerOf(exec);
|
|
774
|
+
if ('reason' in caller)
|
|
775
|
+
return { ...CALLER_MISSING };
|
|
776
|
+
const cwd = caller.session.header?.cwd ?? '';
|
|
777
|
+
const storeDir = resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters');
|
|
778
|
+
let abs;
|
|
779
|
+
try {
|
|
780
|
+
abs = resolveArtifactWithMirror(storeDir, args.path, path.join(cwd, DEFAULT_CLONE_DIR));
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
return { ok: false, error: String(error?.message ?? error) };
|
|
784
|
+
}
|
|
785
|
+
if (abs === null)
|
|
786
|
+
return { ok: false, error: `no artifact at ${args.path} — searched the store root, every session tree under it (${storeDir}/*/artifacts/) and the knowledge mirror (${path.join(cwd, DEFAULT_CLONE_DIR)}/artifacts/<projectKey>/); the blob was never written here or its pool has not published/pulled it yet (/chapters-status shows the transport state)` };
|
|
787
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
788
|
+
const action = args.action.toLowerCase();
|
|
789
|
+
if (action === 'toc') {
|
|
790
|
+
const toc = buildToc(text);
|
|
791
|
+
return { ok: true, action, path: args.path, bytes: fs.statSync(abs).size, headings: toc.entries.length, ...(toc.truncated ? { truncated: true } : {}), toc: toc.entries.map((e) => `${' '.repeat(e.depth - 1)}L${e.line} ${'#'.repeat(e.depth)} ${e.title}`) };
|
|
792
|
+
}
|
|
793
|
+
if (action === 'search') {
|
|
794
|
+
if (args.query === undefined || args.query.trim() === '')
|
|
795
|
+
return { ok: false, error: 'search requires a non-empty query' };
|
|
796
|
+
const pack = searchArtifact(text, args.query, { maxTokens: args.maxTokens ?? config.searchMaxTokens ?? 400 });
|
|
797
|
+
return { ok: true, action, path: args.path, query: args.query, totalMatches: pack.total, returnedBlocks: pack.blocks.length, truncated: pack.truncated, ...(pack.remaining > 0 ? { remainingMatches: pack.remaining, note: 'narrow the query or raise maxTokens' } : {}), blocks: pack.blocks.map((b) => b.lines.join('\n')) };
|
|
798
|
+
}
|
|
799
|
+
if (action === 'read') {
|
|
800
|
+
const r = readLines(text, args.offset ?? 1, args.limit ?? 200);
|
|
801
|
+
return { ok: true, action, path: args.path, start: r.start, end: r.end, of: r.of, ...(r.end - r.start + 1 < (args.limit ?? 200) ? { note: 'file end reached' } : {}), lines: r.lines };
|
|
802
|
+
}
|
|
803
|
+
return { ok: false, error: `unknown action ${args.action} (use toc | search | read)` };
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
// ---------------------------------------------------------------- P3 proposal tool
|
|
807
|
+
const chaptersRulePropose = defineTool({
|
|
808
|
+
name: 'chapters_rule_propose',
|
|
809
|
+
description: 'Propose a PROJECT RULE for shared review (record \u00A77): a durable, generalizable instruction '
|
|
810
|
+
+ 'learned from this work (conventions, invariants, pitfalls \u2014 not one-off facts). It enters as '
|
|
811
|
+
+ 'proposed: it changes NO session anywhere until a human approves it on their own machine.',
|
|
812
|
+
parameters: {
|
|
813
|
+
category: { type: 'string', required: true, description: 'Short slug, e.g. security, testing, style, deploy.' },
|
|
814
|
+
text: { type: 'string', required: true, description: 'The rule, one imperative paragraph. A human reads exactly this.' },
|
|
815
|
+
},
|
|
816
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
817
|
+
async execute(args, exec) {
|
|
818
|
+
const caller = callerOf(exec);
|
|
819
|
+
if ('reason' in caller)
|
|
820
|
+
return { ...CALLER_MISSING };
|
|
821
|
+
const cwd = caller.session.header?.cwd ?? '';
|
|
822
|
+
if (config.harnessId === undefined)
|
|
823
|
+
return { ok: false, text: 'rules surface not configured (harnessId absent)' };
|
|
824
|
+
try {
|
|
825
|
+
const reply = await rulesCommand({
|
|
826
|
+
cwd: () => cwd,
|
|
827
|
+
storeRoot: config.artifactStoreRoot,
|
|
828
|
+
harnessId: config.harnessId,
|
|
829
|
+
store,
|
|
830
|
+
projectFor: (c) => projectForCwd(store.projects(), c),
|
|
831
|
+
mirrorDir: (c) => path.join(c, DEFAULT_CLONE_DIR),
|
|
832
|
+
syncNow: async (c) => { config.scheduler?.schedule(c, 'archive:rules'); },
|
|
833
|
+
now: () => new Date(),
|
|
834
|
+
sourceSession: caller.session.id,
|
|
835
|
+
}, `add ${args.category} ${args.text}`);
|
|
836
|
+
return { ok: reply.kind === 'success', text: reply.text };
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
return { ok: false, text: `chapters_rule_propose failed: ${String(error?.message ?? error).slice(0, 200)}` };
|
|
840
|
+
}
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
// ------------------------------------------------- #50 §7.9: the assembly door
|
|
844
|
+
//
|
|
845
|
+
// The ONE rule verb a session is told to reach for. Capture and carriage are
|
|
846
|
+
// invisible to the model (§7.9); this is the exception, because a tool an
|
|
847
|
+
// instruction never names does not exist to a small model — and because the
|
|
848
|
+
// head can only show a capped, relevance-selected subset, so "what actually
|
|
849
|
+
// applies to THIS task" needs a door that answers with the text.
|
|
850
|
+
const chaptersRules = defineTool({
|
|
851
|
+
name: 'chapters_rules',
|
|
852
|
+
description: 'Assemble the rules that govern a task: the approved project tier plus this session tree\u2019s '
|
|
853
|
+
+ 'captured session rules, verbatim, within budget. Run it BEFORE starting a task. The answer '
|
|
854
|
+
+ 'names what it withheld, so "you are seeing all of them" is a fact rather than a guess.',
|
|
855
|
+
parameters: {
|
|
856
|
+
query: { type: 'string', required: true, description: 'What the session is about to do, in a sentence or two \u2014 it selects relevance.' },
|
|
857
|
+
scope: { type: 'string', description: "'all' (default) or 'project' to see only the shared tier a branch would inherit." },
|
|
858
|
+
maxTokens: { type: 'number', description: 'Token ceiling for this answer (defaults to coreRulesBudgetTokens).' },
|
|
859
|
+
},
|
|
860
|
+
output: jsonOutput((value) => JSON.stringify(value, null, 1)),
|
|
861
|
+
async execute(args, exec) {
|
|
862
|
+
const caller = callerOf(exec);
|
|
863
|
+
if ('reason' in caller)
|
|
864
|
+
return { ...CALLER_MISSING };
|
|
865
|
+
if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined) {
|
|
866
|
+
return { ok: false, error: 'rules surface not configured (harnessId/budget absent)' };
|
|
867
|
+
}
|
|
868
|
+
const cwd = caller.session.header?.cwd ?? '';
|
|
869
|
+
const best = cwd === '' ? undefined : projectForCwd(store.projects(), cwd);
|
|
870
|
+
try {
|
|
871
|
+
const built = await renderRulesBlock({
|
|
872
|
+
mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
|
|
873
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
|
|
874
|
+
projectKey: best?.projectKey ?? '',
|
|
875
|
+
harnessId: config.harnessId,
|
|
876
|
+
budgetTokens: args.maxTokens ?? config.coreRulesBudgetTokens,
|
|
877
|
+
...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
|
|
878
|
+
...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
|
|
879
|
+
relevance: config.rulesRelevance ?? true,
|
|
880
|
+
includeSession: args.scope !== 'project',
|
|
881
|
+
// the QUERY selects here (that is the door's whole job), not the session's
|
|
882
|
+
// own recent signatures — which is what a rebuilt head selects on
|
|
883
|
+
topicsOverride: queryTopics(args.query),
|
|
884
|
+
...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
|
|
885
|
+
store,
|
|
886
|
+
sessionId: caller.session.id,
|
|
887
|
+
});
|
|
888
|
+
if (built.kind === 'refusal')
|
|
889
|
+
return { ok: false, error: built.reason };
|
|
890
|
+
if (built.kind === 'none') {
|
|
891
|
+
// §7.8: "nothing applies" and "nothing is reachable" are different answers
|
|
892
|
+
return {
|
|
893
|
+
ok: true, shown: { project: 0, session: 0 }, withheld: { project: 0, session: 0 }, text: '',
|
|
894
|
+
note: best === undefined
|
|
895
|
+
? `no knowledge project resolves for ${cwd} \u2014 nothing is approved here AND no pool is linked (/chapters-link); session rules still apply once captured`
|
|
896
|
+
: 'no active rules: approved project rules need a human approve ON THIS MACHINE (/chapters-rule list --all), and this session tree has captured none',
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
const note = [
|
|
900
|
+
built.withheld.project + built.withheld.session > 0
|
|
901
|
+
? `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`
|
|
902
|
+
: 'nothing withheld: this is the complete active set',
|
|
903
|
+
...(best === undefined ? ['no linked project for this cwd \u2014 the project tier is empty by construction (/chapters-link)'] : []),
|
|
904
|
+
];
|
|
905
|
+
return { ok: true, shown: built.shown, withheld: built.withheld, text: built.text, tokens: built.tokens, note: note.join('; ') };
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
return { ok: false, error: `chapters_rules failed: ${String(error?.message ?? error).slice(0, 200)}` };
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
/**
|
|
913
|
+
* `/chapters-repair` — the operator door of the repair engine (#60). Read-only unless `apply` is
|
|
914
|
+
* named, and `apply` still cannot delete or rewrite anything: damage is SUPERSEDED under a new
|
|
915
|
+
* number, bad files are COPIED to `.repair-quarantine/`, and every run is journalled so `restore`
|
|
916
|
+
* can walk it backwards (docs/architecture.md § Repair).
|
|
917
|
+
*
|
|
918
|
+
* It repairs the sessions of THIS session's project — found by reading each registry row's durable
|
|
919
|
+
* header through `readDurableSession`, the same payload-bearing seam door 5 uses (#58). Rows whose
|
|
920
|
+
* log cannot be read are reported as unrecoverable rather than quietly skipped, because "nothing to
|
|
921
|
+
* repair" and "cannot repair" are different sentences.
|
|
922
|
+
*/
|
|
923
|
+
const repairCommand = {
|
|
924
|
+
name: 'chapters-repair',
|
|
925
|
+
description: 'Audit the archive against the durable log; repair what the registry lost. Read-only by default; never deletes, never rewrites a chapter body.',
|
|
926
|
+
input: { hint: '[audit | dry-run | apply <all|hollow|gaps|citations|bookkeeping|files> | status | journal [id] | restore <journal-id>]' },
|
|
927
|
+
async handler(invocation) {
|
|
928
|
+
const agent = invocation.agent;
|
|
929
|
+
if (agent?.session === undefined)
|
|
930
|
+
return { kind: 'error', text: 'chapters-repair: no session context for this invocation' };
|
|
931
|
+
const cwd0 = agent.session.header?.cwd ?? '';
|
|
932
|
+
if (cwd0 === '')
|
|
933
|
+
return { kind: 'error', text: 'chapters-repair: this session has no workspace cwd — no store to inspect' };
|
|
934
|
+
const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((x) => x.length > 0);
|
|
935
|
+
const verb = parts[0] ?? 'audit';
|
|
936
|
+
const get = (name) => { try {
|
|
937
|
+
return ctx.get?.(name);
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
return undefined;
|
|
941
|
+
} };
|
|
942
|
+
// targets: rows of this project, proven by their OWN durable header (never a path guess)
|
|
943
|
+
const ids = [];
|
|
944
|
+
// #101: a session the kernel will not open is named and skipped, not fatal — and it is stated in
|
|
945
|
+
// the output, because a repair that quietly omits sessions would report a healthy store it never
|
|
946
|
+
// looked at.
|
|
947
|
+
const unreadable = [];
|
|
948
|
+
const noteUnreadable = (id) => (m) => { unreadable.push(`${id}: ${m}`); };
|
|
949
|
+
for (const [id] of store.sessions()) {
|
|
950
|
+
const view = await readDurableSession(get, id, noteUnreadable(id));
|
|
951
|
+
if (view?.header.cwd !== undefined && path.resolve(view.header.cwd) === path.resolve(cwd0))
|
|
952
|
+
ids.push(id);
|
|
953
|
+
}
|
|
954
|
+
if (ids.length === 0)
|
|
955
|
+
return { kind: 'error', text: `chapters-repair: no session row under ${cwd0} has a readable durable log — nothing to audit` };
|
|
956
|
+
// roots this registry claims, so sibling session trees are not reported as litter
|
|
957
|
+
const roots = (await Promise.all(ids.map(async (id) => (await store.get(id)).rootSession)));
|
|
958
|
+
if (verb === 'status' || verb === 'journal') {
|
|
959
|
+
const lines = [];
|
|
960
|
+
for (const id of ids) {
|
|
961
|
+
const st = await store.get(id);
|
|
962
|
+
const dir = path.join(cwd0, config.artifactStoreRoot, st.rootSession, 'repairs');
|
|
963
|
+
if (!fs.existsSync(dir))
|
|
964
|
+
continue;
|
|
965
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
|
|
966
|
+
const j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
967
|
+
lines.push(`${j.id} ${id.slice(0, 14)}… ${j.completedAt === null ? 'UNFINISHED' : 'done'} ${j.findingsBefore}→${j.findingsAfter} findings ${j.entries.length} move(s)`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (verb === 'journal' && parts[1] !== undefined) {
|
|
971
|
+
const hit = lines.find((l) => l.startsWith(parts[1]));
|
|
972
|
+
return hit !== undefined ? { kind: 'success', text: hit } : { kind: 'error', text: `no journal ${parts[1]} in this project` };
|
|
973
|
+
}
|
|
974
|
+
return { kind: 'success', text: lines.length > 0 ? lines.join('\n') : 'no repair journals yet in this project' };
|
|
975
|
+
}
|
|
976
|
+
if (verb === 'restore') {
|
|
977
|
+
const jid = parts[1];
|
|
978
|
+
if (jid === undefined)
|
|
979
|
+
return { kind: 'error', text: 'restore needs a journal id (see `/chapters-repair status`)' };
|
|
980
|
+
const results = [];
|
|
981
|
+
const outcomes2 = [];
|
|
982
|
+
for (const id of ids) {
|
|
983
|
+
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);
|
|
984
|
+
outcomes2.push(r);
|
|
985
|
+
if (r.undone > 0 || r.problems.length > 0)
|
|
986
|
+
results.push(`${id.slice(0, 14)}…: ${r.undone} undone${r.problems.length > 0 ? `; ${r.problems.join('; ')}` : ''}`);
|
|
987
|
+
}
|
|
988
|
+
const anyUndone = outcomes2.some((o) => o.undone > 0);
|
|
989
|
+
return {
|
|
990
|
+
kind: anyUndone ? 'success' : 'error',
|
|
991
|
+
text: results.length > 0 ? results.join('\n') : `journal ${jid} matched no session row here — files were NOT touched`,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const dryRun = verb !== 'apply';
|
|
995
|
+
// ONE resolver, shared with the offline script (src/repair.ts). An unknown name is a REFUSAL:
|
|
996
|
+
// the first version fell through to `[which]`, matched nothing, and reported success while doing
|
|
997
|
+
// nothing — the silent no-op is the defect class this issue exists to end (#60).
|
|
998
|
+
const resolved = resolveRepairClasses((parts[1] ?? 'all').split(','));
|
|
999
|
+
if (resolved.unknown.length > 0) {
|
|
1000
|
+
return { kind: 'error', text: `chapters-repair: unknown class(es) ${resolved.unknown.join(', ')}\n known: ${Object.keys(REPAIR_CLASS_ALIASES).join(', ')} — nothing was applied` };
|
|
1001
|
+
}
|
|
1002
|
+
const classes = resolved.classes;
|
|
1003
|
+
const outcomes = [];
|
|
1004
|
+
let actionable = 0;
|
|
1005
|
+
for (const id of ids) {
|
|
1006
|
+
const view = await readDurableSession(get, id, noteUnreadable(id));
|
|
1007
|
+
if (view === undefined)
|
|
1008
|
+
continue;
|
|
1009
|
+
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 });
|
|
1010
|
+
actionable += out.findingsBefore.filter((f) => f.remedy !== 'report-only').length;
|
|
1011
|
+
if (out.findingsBefore.length > 0 || out.applied.length > 0) {
|
|
1012
|
+
const by = out.findingsBefore.reduce((m, f) => ({ ...m, [f.class]: (m[f.class] ?? 0) + 1 }), {});
|
|
1013
|
+
outcomes.push(`${id.slice(0, 14)}…: ${out.findingsBefore.length} finding(s) ${JSON.stringify(by)}${out.blindSpots.length > 0 ? ` blind to ${out.blindSpots.join(',')}` : ''}`);
|
|
1014
|
+
// #101: the same predicate the offline report uses, in one line per class. Un-grouped, this command
|
|
1015
|
+
// printed 21 identical `test-litter` refusals for each of 23 sessions and the operator could not see
|
|
1016
|
+
// the 372 findings that were actually actionable.
|
|
1017
|
+
for (const line of renderRefusalGroups(out.refused))
|
|
1018
|
+
outcomes.push(` ${line}`);
|
|
1019
|
+
if (!dryRun)
|
|
1020
|
+
outcomes.push(` → ${out.applied.length} move(s) journalled ${out.journalId}; ${out.findingsAfter.length} finding(s) remain`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const head = dryRun
|
|
1024
|
+
? `chapters-repair ${verb === 'audit' ? 'audit' : 'dry-run'} — ${ids.length} session(s): ${actionable} actionable finding(s). NOTHING WRITTEN.`
|
|
1025
|
+
: `chapters-repair applied to ${ids.length} session(s) — see the per-session counts below.`;
|
|
1026
|
+
const tail = dryRun
|
|
1027
|
+
? '\napply: /chapters-repair apply all (supersede under a new number; copy bad files to .repair-quarantine/; journal everything)'
|
|
1028
|
+
: '';
|
|
1029
|
+
// The refusal lines lead the report: they are the reason the count below is not the whole story.
|
|
1030
|
+
const skipped = unreadable.length > 0
|
|
1031
|
+
? [`${String(new Set(unreadable.map((l) => l.split(':')[0])).size)} session(s) could not be read and were NOT repaired:`,
|
|
1032
|
+
...[...new Set(unreadable)].slice(0, 12).map((l) => ` ! ${l}`)]
|
|
1033
|
+
: [];
|
|
1034
|
+
// #117: a dry-run over 23 sessions printed **2,796 findings** as one command result — 311 KB, an event
|
|
1035
|
+
// larger than the archive's whole oversize ceiling, which is how this session became un-compactable.
|
|
1036
|
+
// The AGENTS rule already said a tool caps and NAMES its output instead of dumping it into a 32K
|
|
1037
|
+
// window; the command predates its own discipline. So: the count always leads (it is the headline, not
|
|
1038
|
+
// a withheld number), the lines are bounded by `repairReportMaxLines`, and the cap says what to do
|
|
1039
|
+
// about the rest — a remedy, never a silent cut.
|
|
1040
|
+
const { shown, capLine } = capReportLines([...skipped, ...outcomes], config.repairReportMaxLines ?? 60);
|
|
1041
|
+
return { kind: 'success', text: [head, ...shown, ...(capLine === '' ? [] : [capLine]), tail].join('\n') };
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
return { segment: chaptersSegment, chaptersContinue, chaptersFork, chaptersSearch, chaptersArtifact, chaptersRulePropose, chaptersRules, forkCommand, continueCommand, saveCommand, repairCommand };
|
|
1045
|
+
}
|
|
1046
|
+
export function registerChaptersTools(ctx, store, config) {
|
|
1047
|
+
const built = buildChaptersTools(ctx, store, config);
|
|
1048
|
+
const disposeSegment = ctx.tools.register(built.segment);
|
|
1049
|
+
const disposeContinue = ctx.tools.register(built.chaptersContinue);
|
|
1050
|
+
const disposeFork = ctx.tools.register(built.chaptersFork);
|
|
1051
|
+
const disposeSearch = ctx.tools.register(built.chaptersSearch);
|
|
1052
|
+
const disposeArtifact = ctx.tools.register(built.chaptersArtifact);
|
|
1053
|
+
const disposeRulePropose = ctx.tools.register(built.chaptersRulePropose);
|
|
1054
|
+
const disposeRules = ctx.tools.register(built.chaptersRules);
|
|
1055
|
+
let disposeCommand = null;
|
|
1056
|
+
const commands = ctx.get?.('commands');
|
|
1057
|
+
if (commands?.register !== undefined) {
|
|
1058
|
+
try {
|
|
1059
|
+
// Both deterministic doors register on the same seam. Keep EVERY disposer: a loop
|
|
1060
|
+
// that overwrote `disposeCommand` would leak the first registration and unregister
|
|
1061
|
+
// only whichever door was added last.
|
|
1062
|
+
const commandDisposers = [];
|
|
1063
|
+
for (const def of [built.forkCommand, built.continueCommand, built.saveCommand, built.repairCommand]) {
|
|
1064
|
+
const disposer = commands.register(def);
|
|
1065
|
+
if (typeof disposer === 'function')
|
|
1066
|
+
commandDisposers.push(disposer);
|
|
1067
|
+
}
|
|
1068
|
+
if (commandDisposers.length > 0)
|
|
1069
|
+
disposeCommand = () => { for (const d of commandDisposers)
|
|
1070
|
+
d(); };
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
ctx.logger?.warn?.(`dsh-chapters: host command registration failed (${String(error)}); tools unaffected`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// Pre-existing comma operator here (`disposeArtifact(), disposeRulePropose()`) ran both,
|
|
1077
|
+
// so behaviour was right — but it reads as a typo and would silently break if anyone
|
|
1078
|
+
// reordered the line. Semicolons, since this is the unload path a test cannot cover.
|
|
1079
|
+
return () => {
|
|
1080
|
+
disposeSegment();
|
|
1081
|
+
disposeContinue();
|
|
1082
|
+
disposeFork();
|
|
1083
|
+
disposeSearch();
|
|
1084
|
+
disposeArtifact();
|
|
1085
|
+
disposeRulePropose();
|
|
1086
|
+
disposeRules();
|
|
1087
|
+
disposeCommand?.();
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
//# sourceMappingURL=tools.js.map
|