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