@treeseed/dsh-chapters 0.1.24 → 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 +49 -1
- package/docs/architecture.md +1 -496
- 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 +74 -0
- package/lib/auto-save.js +162 -26
- package/lib/auto-save.js.map +1 -1
- package/lib/client.js +413 -11
- package/lib/client.js.map +1 -1
- package/lib/commands.d.ts +8 -1
- package/lib/commands.js +135 -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 +63 -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 +51 -0
- package/lib/index.js +180 -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 +75 -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 +31 -1
- package/lib/save-core.js +153 -17
- package/lib/save-core.js.map +1 -1
- 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 +311 -32
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +12 -1
- 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,11 +7,18 @@
|
|
|
7
7
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
|
+
import { runRepair, restoreRepair } from "./repair-run.js";
|
|
11
|
+
import { formatWarnings } from "./warnings.js";
|
|
12
|
+
import { ensureDiagnosticsDefault } from "./diag.js";
|
|
13
|
+
import { REPAIR_CLASS_ALIASES, resolveRepairClasses } from "./repair.js";
|
|
14
|
+
import { readDurableSession } from "./auto-save.js";
|
|
10
15
|
import * as path from 'node:path';
|
|
11
|
-
import { estimateTokens, toolResultCandidates } from "./render.js";
|
|
16
|
+
import { candidateTotals, estimateTokens, toolResultCandidates } from "./render.js";
|
|
12
17
|
import { detectPlans } from "./plans.js";
|
|
13
|
-
import {
|
|
18
|
+
import { makeHeaderParentReader, queryTopics, renderRulesBlock } from "./session-rules.js";
|
|
14
19
|
import { rulesCommand } from "./rules-commands.js";
|
|
20
|
+
import { diagnosticWarn } from "./diag.js";
|
|
21
|
+
import { attachCreatedChild } from "./attach.js";
|
|
15
22
|
import { DEFAULT_CLONE_DIR } from "./sync.js";
|
|
16
23
|
import { lineageOf } from "./types.js";
|
|
17
24
|
import { composeChapters } from "./compose.js";
|
|
@@ -31,8 +38,21 @@ function callerOf(exec) {
|
|
|
31
38
|
return agent ?? CALLER_MISSING;
|
|
32
39
|
}
|
|
33
40
|
export function buildChaptersTools(ctx, store, config) {
|
|
41
|
+
// #52: the delegation edge for rule ancestry, read from whatever this plane can
|
|
42
|
+
// reach (live handles, else the durable session query). undefined ⇒ the walk
|
|
43
|
+
// follows registry edges only, and a block that truncated says so.
|
|
44
|
+
const ruleParentOf = makeHeaderParentReader((name) => { try {
|
|
45
|
+
return ctx.get?.(name);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
} });
|
|
34
50
|
const portsFor = async (caller) => {
|
|
35
51
|
const cwd = caller.session.header.cwd ?? '';
|
|
52
|
+
// #61: a door is touching this project's store, so THIS is where failures get written unless the
|
|
53
|
+
// operator said otherwise. First door wins; `off` in the env still means no file at all.
|
|
54
|
+
if (cwd !== '')
|
|
55
|
+
ensureDiagnosticsDefault(path.join(cwd, config.artifactStoreRoot, 'diagnostics.log'));
|
|
36
56
|
// every real tool use carries the caller's routed model — record it for
|
|
37
57
|
// the enrichment resolver (the only route source the host plane can have)
|
|
38
58
|
if (caller.options.provider !== undefined && caller.options.model !== undefined) {
|
|
@@ -75,6 +95,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
75
95
|
return { windowTokens, headerBoundTokens };
|
|
76
96
|
},
|
|
77
97
|
newId: () => randomUUID(),
|
|
98
|
+
diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
|
|
78
99
|
createChild: async ({ sessionId, noticeEvent, modeEvents, presetId: mountPreset, title }) => {
|
|
79
100
|
const callerOptions = caller.options;
|
|
80
101
|
// The seed is contiguous from seq 0: the notice, then any STATE events this
|
|
@@ -95,17 +116,20 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
95
116
|
await presets.mount(agentCtx, mountPreset);
|
|
96
117
|
},
|
|
97
118
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
ctx.
|
|
108
|
-
|
|
119
|
+
// #54: was an optional chain through a REQUIRED step, inside a catch that only
|
|
120
|
+
// warned the profile logger. On host 0.2.x that turned a reshaped workspace API
|
|
121
|
+
// into a silent no-op — durable child, missing from the sidebar, door reporting
|
|
122
|
+
// success. attachCreatedChild verifies the user-visible fact (is it LISTED) and
|
|
123
|
+
// returns words the door passes through to the user.
|
|
124
|
+
const attach = await attachCreatedChild({
|
|
125
|
+
workspaceRegistry: ctx.get?.('workspaceRegistry'),
|
|
126
|
+
sessionQuery: ctx.get?.('sessionQuery'),
|
|
127
|
+
// the surface the browser reads — a child missing here is invisible and unopenable
|
|
128
|
+
sessionController: ctx.get?.('sessionController'),
|
|
129
|
+
diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
|
|
130
|
+
childId: sessionId,
|
|
131
|
+
cwd,
|
|
132
|
+
});
|
|
109
133
|
// Title while the child is still live: sessionController.rename resolves
|
|
110
134
|
// the agent, routes through the official session-title normalizer, and
|
|
111
135
|
// appends the durable `session/title` event (r6: commands-execute was the
|
|
@@ -127,6 +151,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
127
151
|
// handle", measured r3). Dispose after the live-work, keep the session.
|
|
128
152
|
await handle.dispose?.();
|
|
129
153
|
}
|
|
154
|
+
return { warnings: attach.warnings };
|
|
130
155
|
},
|
|
131
156
|
now: () => Date.now(),
|
|
132
157
|
};
|
|
@@ -184,8 +209,25 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
184
209
|
: { planHint: 'a submitted plan is a topic boundary: the chapter that carries it ENDS there, and its title becomes the chapter title' }),
|
|
185
210
|
// fresh literals, not the ToolResultCandidate interface: anonymous
|
|
186
211
|
// object types carry the implicit index signature JsonValue needs.
|
|
187
|
-
|
|
188
|
-
|
|
212
|
+
// #61: the candidate list is CAPPED and says so. The field version returned 49,932 characters
|
|
213
|
+
// while also reporting `Omitted 275266 bytes` — ~81K est tokens into a 32K-class window, asking
|
|
214
|
+
// the model to keep/drop-judge a list it had not received. Ranking is by size descending, so the
|
|
215
|
+
// cap removes the SMALL entries and the big ones a real decision turns on stay visible.
|
|
216
|
+
...(() => {
|
|
217
|
+
const range = { title: '', summary: '', startSeq: 0, endSeq: ceiling };
|
|
218
|
+
const cap = config.segmentCandidateCapTokens ?? 4000;
|
|
219
|
+
const kept = cap > 0
|
|
220
|
+
? toolResultCandidates(below, range, cap)
|
|
221
|
+
: toolResultCandidates(below, range);
|
|
222
|
+
const totals = candidateTotals(below, range);
|
|
223
|
+
const withheld = Math.max(0, totals.count - kept.length);
|
|
224
|
+
const withheldBytes = Math.max(0, totals.bytes - kept.reduce((m, c) => m + c.bytes, 0));
|
|
225
|
+
return {
|
|
226
|
+
toolResults: kept.map((c) => ({ seq: c.seq, toolName: c.toolName, bytes: c.bytes, estimatedTokens: c.estimatedTokens, excerpt: c.excerpt })),
|
|
227
|
+
toolResultsReport: `showing ${kept.length} of ${totals.count} candidates by size, ${(withheldBytes / 1024).toFixed(0)} KB withheld`
|
|
228
|
+
+ (cap > 0 ? ` (segmentCandidateCapTokens=${cap})` : ' (uncapped)'),
|
|
229
|
+
};
|
|
230
|
+
})(),
|
|
189
231
|
existingChapters: callerState.chapters.map((c) => ({
|
|
190
232
|
number: c.number, path: c.path, title: c.title, startSeq: c.startSeq, endSeq: c.endSeq,
|
|
191
233
|
})),
|
|
@@ -244,7 +286,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
244
286
|
await config.scheduler.pullFor(cwd).catch(() => undefined); // §5.1: pull → archive → push
|
|
245
287
|
}
|
|
246
288
|
const line = projectLineFor(caller);
|
|
247
|
-
const rs = rulesSectionFor(caller);
|
|
289
|
+
const rs = await rulesSectionFor(caller, 'continue');
|
|
248
290
|
if (rs.refusal !== undefined)
|
|
249
291
|
return { ok: false, reason: rs.refusal };
|
|
250
292
|
const result = await runContinue(ports, {
|
|
@@ -256,7 +298,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
256
298
|
toolResultOverrides: args.toolResultOverrides ?? [],
|
|
257
299
|
...(line !== undefined ? { projectLine: line } : {}),
|
|
258
300
|
...(rs.section !== undefined ? { rulesSection: rs.section } : {}),
|
|
259
|
-
}, config);
|
|
301
|
+
}, { ...config, workspaceCwd: cwd });
|
|
260
302
|
if (result.ok && cwd !== '')
|
|
261
303
|
config.scheduler?.schedule(cwd, 'archive:continue');
|
|
262
304
|
// conditional spreads: absent optionals must not surface as `undefined`
|
|
@@ -306,7 +348,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
306
348
|
await config.scheduler.pullFor(cwd).catch(() => undefined);
|
|
307
349
|
}
|
|
308
350
|
const line = projectLineFor(caller);
|
|
309
|
-
const rs = rulesSectionFor(caller);
|
|
351
|
+
const rs = await rulesSectionFor(caller, 'branch');
|
|
310
352
|
if (rs.refusal !== undefined)
|
|
311
353
|
return { ok: false, reason: rs.refusal };
|
|
312
354
|
const result = await runFork(ports, {
|
|
@@ -362,7 +404,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
362
404
|
const parentTitle = [...events].reverse()
|
|
363
405
|
.find((e) => e.type === 'session/title')?.data?.title;
|
|
364
406
|
const title = trimmed !== '' ? trimmed : `${parentTitle ?? `Chapters ${door.noun}`} \u2014 ${door.noun}`;
|
|
365
|
-
const rsCmd = rulesSectionFor(agent);
|
|
407
|
+
const rsCmd = await rulesSectionFor(agent, door.intent);
|
|
366
408
|
if (rsCmd.refusal !== undefined)
|
|
367
409
|
return { kind: 'error', text: `${door.name} refused: ${rsCmd.refusal}` };
|
|
368
410
|
const callerArgs = {
|
|
@@ -405,17 +447,21 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
405
447
|
: ['no stored turn signatures — legacy size-based segmentation'];
|
|
406
448
|
const handoffNote = `Archived through the Chapters ${door.noun}. The chapters listed above carry every prior word verbatim — reload any with the read tool on its path. ${door.noDirective}`
|
|
407
449
|
+ (notes.length > 0 ? `\n(Segmentation notes: ${notes.join('; ')})` : '');
|
|
408
|
-
const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, config);
|
|
450
|
+
const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, { ...config, workspaceCwd: cwd0 });
|
|
409
451
|
if (result.ok && cwd0 !== '')
|
|
410
452
|
config.scheduler?.schedule(cwd0, 'archive:fork');
|
|
411
453
|
const budgetText = result.budget !== undefined ? `; TOC ~${result.budget.usedTokens} tokens, allowance ${result.budget.allowanceTokens}` : '';
|
|
412
|
-
|
|
454
|
+
// #61: the archive happened; the caveats were in `result.warnings` and were NEVER printed here.
|
|
455
|
+
const dWarn = formatWarnings(result.warnings ?? [], config.warningsDisplayCap ?? 6);
|
|
456
|
+
return { kind: 'success', text: `${door.verb}: ${chapters.length} new chapter(s) archived (watermark respected: seqs ${fromSeq}..${anchor}), ${door.noun} \u201C${title}\u201D is session ${result.childSessionId ?? '(created)'}${budgetText}. Switch to it from the sidebar — its first message is the table of contents.`
|
|
457
|
+
+ (dWarn === '' ? '' : `\n warnings: ${dWarn}`) };
|
|
413
458
|
}
|
|
414
459
|
catch (error) {
|
|
415
460
|
const refusal = refusalResult(error);
|
|
416
461
|
if (refusal !== null) {
|
|
417
462
|
const budget = refusal.budget !== undefined ? ` (notice ~${refusal.budget.usedTokens} > allowance ${refusal.budget.allowanceTokens})` : '';
|
|
418
|
-
|
|
463
|
+
const rWarn = formatWarnings(refusal.warnings ?? [], config.warningsDisplayCap ?? 6);
|
|
464
|
+
return { kind: 'error', text: `${door.name} refused: ${refusal.reason ?? 'refused'}${budget}${rWarn === '' ? '' : `\n warnings: ${rWarn}`}` };
|
|
419
465
|
}
|
|
420
466
|
return { kind: 'error', text: `${door.name} failed: ${String(error?.message ?? error)}`.slice(0, 500) };
|
|
421
467
|
}
|
|
@@ -471,6 +517,7 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
471
517
|
toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
|
|
472
518
|
mergeThreshold: config.mergeThreshold,
|
|
473
519
|
chapterLimit: config.chapterLimit,
|
|
520
|
+
chapterOversizeFactor: config.chapterOversizeFactor,
|
|
474
521
|
...(config.planInlineChars !== undefined ? { planInlineChars: config.planInlineChars } : {}),
|
|
475
522
|
...(config.callArgArtifactTokens !== undefined ? { callArgArtifactTokens: config.callArgArtifactTokens } : {}),
|
|
476
523
|
},
|
|
@@ -491,8 +538,12 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
491
538
|
// Schedule sync so saved chapters travel to the pool.
|
|
492
539
|
if (cwd0 !== '')
|
|
493
540
|
config.scheduler?.schedule(cwd0, 'archive:save');
|
|
494
|
-
|
|
495
|
-
|
|
541
|
+
// #61: this used to be ` (86 warning(s))` — a COUNT that carried none of the 86 messages, six of
|
|
542
|
+
// which named the hollow chapter and an unarchived 0-4082 hole. A count is not a message.
|
|
543
|
+
const warnText = formatWarnings(out.warnings, config.warningsDisplayCap ?? 6);
|
|
544
|
+
return { kind: 'success', text: `Saved: ${out.records.length} chapter(s) archived (seqs ${out.fromSeq}..${out.anchor}).`
|
|
545
|
+
+ (warnText === '' ? '' : `\n warnings: ${warnText}`)
|
|
546
|
+
+ ` Enrichment queued. Content is searchable via chapters_search from any session in this workspace.` };
|
|
496
547
|
}
|
|
497
548
|
catch (error) {
|
|
498
549
|
return { kind: 'error', text: `chapters-save failed: ${String(error?.message ?? error)}`.slice(0, 500) };
|
|
@@ -515,17 +566,62 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
515
566
|
return undefined;
|
|
516
567
|
}
|
|
517
568
|
};
|
|
518
|
-
/**
|
|
519
|
-
|
|
569
|
+
/**
|
|
570
|
+
* §7.3/§7.6: the rules block for a head this door is about to build, through
|
|
571
|
+
* the ONE renderer the compaction checkpoint also uses
|
|
572
|
+
* (`session-rules.renderRulesBlock`) — a session must never see two rule sets
|
|
573
|
+
* depending on which path rebuilt its head.
|
|
574
|
+
*
|
|
575
|
+
* `intent` is the whole semantic difference between the two doors, and it was
|
|
576
|
+
* already the axis that decides plan bytes (docs/plans-carriage.md §§ 9–13): a
|
|
577
|
+
* CONTINUATION extends the same stream of work, so it inherits the session
|
|
578
|
+
* tier; a BRANCH splits a new direction, so it gets the project tier and ZERO
|
|
579
|
+
* session bytes — a branch is not an order.
|
|
580
|
+
*/
|
|
581
|
+
const rulesSectionFor = async (agent, intent) => {
|
|
520
582
|
try {
|
|
521
583
|
if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined)
|
|
522
584
|
return {};
|
|
523
585
|
const cwd = agent.session.header?.cwd ?? '';
|
|
524
|
-
|
|
525
|
-
if (best === undefined)
|
|
586
|
+
if (cwd === '')
|
|
526
587
|
return {};
|
|
527
|
-
const
|
|
528
|
-
|
|
588
|
+
const best = projectForCwd(store.projects(), cwd);
|
|
589
|
+
// §7.8: a session with NO project resolves normally renders zero rule bytes —
|
|
590
|
+
// and silence is exactly what this round exists to remove. But adding a
|
|
591
|
+
// "nothing is linked" line to EVERY unlinked workspace's head would spend real
|
|
592
|
+
// bytes on a warning most sessions do not need, so the line appears only when
|
|
593
|
+
// the session tier IS governing the head: then "0 project rules" is a fact the
|
|
594
|
+
// child must not misread as "the team approved nothing".
|
|
595
|
+
if (best === undefined) {
|
|
596
|
+
const orphan = await renderRulesBlock({
|
|
597
|
+
mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
|
|
598
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
|
|
599
|
+
projectKey: '',
|
|
600
|
+
harnessId: config.harnessId,
|
|
601
|
+
budgetTokens: config.coreRulesBudgetTokens,
|
|
602
|
+
relevance: config.rulesRelevance ?? true,
|
|
603
|
+
includeSession: intent === 'continue',
|
|
604
|
+
store,
|
|
605
|
+
sessionId: agent.session.id,
|
|
606
|
+
...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
|
|
607
|
+
});
|
|
608
|
+
if (orphan.kind !== 'ok' || orphan.shown.session === 0)
|
|
609
|
+
return {};
|
|
610
|
+
return { section: `0 project rules — this checkout resolves no knowledge project (${cwd}); your session rules below are local only. Link one with /chapters-link to inherit the team's.\n\n${orphan.kind === 'ok' ? orphan.text : ''}` };
|
|
611
|
+
}
|
|
612
|
+
const built = await renderRulesBlock({
|
|
613
|
+
mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
|
|
614
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
|
|
615
|
+
projectKey: best.projectKey,
|
|
616
|
+
harnessId: config.harnessId,
|
|
617
|
+
budgetTokens: config.coreRulesBudgetTokens,
|
|
618
|
+
...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
|
|
619
|
+
...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
|
|
620
|
+
relevance: config.rulesRelevance ?? true,
|
|
621
|
+
includeSession: intent === 'continue',
|
|
622
|
+
store,
|
|
623
|
+
sessionId: agent.session.id,
|
|
624
|
+
...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
|
|
529
625
|
});
|
|
530
626
|
if (built.kind === 'none')
|
|
531
627
|
return {};
|
|
@@ -710,7 +806,188 @@ export function buildChaptersTools(ctx, store, config) {
|
|
|
710
806
|
}
|
|
711
807
|
},
|
|
712
808
|
});
|
|
713
|
-
|
|
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 };
|
|
714
991
|
}
|
|
715
992
|
export function registerChaptersTools(ctx, store, config) {
|
|
716
993
|
const built = buildChaptersTools(ctx, store, config);
|
|
@@ -720,6 +997,7 @@ export function registerChaptersTools(ctx, store, config) {
|
|
|
720
997
|
const disposeSearch = ctx.tools.register(built.chaptersSearch);
|
|
721
998
|
const disposeArtifact = ctx.tools.register(built.chaptersArtifact);
|
|
722
999
|
const disposeRulePropose = ctx.tools.register(built.chaptersRulePropose);
|
|
1000
|
+
const disposeRules = ctx.tools.register(built.chaptersRules);
|
|
723
1001
|
let disposeCommand = null;
|
|
724
1002
|
const commands = ctx.get?.('commands');
|
|
725
1003
|
if (commands?.register !== undefined) {
|
|
@@ -728,7 +1006,7 @@ export function registerChaptersTools(ctx, store, config) {
|
|
|
728
1006
|
// that overwrote `disposeCommand` would leak the first registration and unregister
|
|
729
1007
|
// only whichever door was added last.
|
|
730
1008
|
const commandDisposers = [];
|
|
731
|
-
for (const def of [built.forkCommand, built.continueCommand, built.saveCommand]) {
|
|
1009
|
+
for (const def of [built.forkCommand, built.continueCommand, built.saveCommand, built.repairCommand]) {
|
|
732
1010
|
const disposer = commands.register(def);
|
|
733
1011
|
if (typeof disposer === 'function')
|
|
734
1012
|
commandDisposers.push(disposer);
|
|
@@ -751,6 +1029,7 @@ export function registerChaptersTools(ctx, store, config) {
|
|
|
751
1029
|
disposeSearch();
|
|
752
1030
|
disposeArtifact();
|
|
753
1031
|
disposeRulePropose();
|
|
1032
|
+
disposeRules();
|
|
754
1033
|
disposeCommand?.();
|
|
755
1034
|
};
|
|
756
1035
|
}
|