@iowarp/clio-coder 0.3.6 → 0.3.7
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/CHANGELOG.md +28 -0
- package/README.md +16 -5
- package/dist/{acp-2BEHC4DL.js → acp-SK4MD6MM.js} +10 -10
- package/dist/{agents-LNNFTM53.js → agents-2FN2K6ME.js} +30 -25
- package/dist/assets/codewiki.json +1 -1
- package/dist/{auth-KXXFI2VS.js → auth-QIYZWM5I.js} +13 -13
- package/dist/{chunk-KHSFENX2.js → chunk-3HAPLH5M.js} +10 -10
- package/dist/{chunk-24I7BN55.js → chunk-465YSENW.js} +2 -2
- package/dist/{chunk-4OC57DA6.js → chunk-4DGYLA73.js} +53 -2
- package/dist/{chunk-CYQKWTG3.js → chunk-4DWFMQDR.js} +4 -4
- package/dist/{chunk-E2ER4LJF.js → chunk-5C3AQNDW.js} +25 -1
- package/dist/{chunk-22NAGB7X.js → chunk-5C77SEEY.js} +5 -94
- package/dist/{chunk-43AOLP7E.js → chunk-5FR74PWO.js} +2 -1
- package/dist/{chunk-K7T3E2SR.js → chunk-5UJ6ECTS.js} +10 -9
- package/dist/{chunk-6US73PDB.js → chunk-6M7VS3J3.js} +5 -5
- package/dist/{chunk-CJUB2JJ2.js → chunk-6TUKSZVF.js} +5 -5
- package/dist/{chunk-5JGRAMKL.js → chunk-AB4XIIVB.js} +8 -6
- package/dist/{chunk-R46L2BIR.js → chunk-BMWK7ZIZ.js} +14 -20
- package/dist/{chunk-4BPJXDWC.js → chunk-C4JBQ5SR.js} +30 -14
- package/dist/{chunk-XXQNGV4M.js → chunk-CEYBNUGC.js} +243 -63
- package/dist/{chunk-VEZEGCGW.js → chunk-D4MDIG46.js} +20 -18
- package/dist/chunk-DJNLUABN.js +843 -0
- package/dist/{chunk-RY3LY4J5.js → chunk-DMD2AGVS.js} +5 -4
- package/dist/{chunk-KOHPCX4K.js → chunk-DOOEX22V.js} +2 -2
- package/dist/chunk-DQA7QLMD.js +123 -0
- package/dist/chunk-DR52UMZW.js +21 -0
- package/dist/{chunk-XF5N4U5A.js → chunk-EBEFWSGL.js} +6 -5
- package/dist/{chunk-EYPA3EGJ.js → chunk-EELBMBT6.js} +120 -13
- package/dist/{chunk-CKXWIANG.js → chunk-EOOQZZDE.js} +16 -14
- package/dist/{chunk-WR67VIZY.js → chunk-FOT2FX5J.js} +63 -5
- package/dist/{chunk-FYYLNIL5.js → chunk-GH5622CP.js} +2 -2
- package/dist/chunk-GWS3VEIW.js +195 -0
- package/dist/{chunk-LYF7OHWH.js → chunk-J7PIKKWC.js} +8 -463
- package/dist/{chunk-NILBFAPG.js → chunk-JNXPYBB4.js} +2 -2
- package/dist/{chunk-4VP4KH3K.js → chunk-JRIO5UD2.js} +4 -4
- package/dist/{chunk-6XXKFVSN.js → chunk-JTSEDYVQ.js} +7 -7
- package/dist/{chunk-QKMUKYO7.js → chunk-KCMKRQX4.js} +236 -84
- package/dist/chunk-KZ2H5X4G.js +1026 -0
- package/dist/{chunk-QNQHSOLF.js → chunk-LADCF22A.js} +12 -12
- package/dist/chunk-M4AKACEO.js +382 -0
- package/dist/{chunk-XYDYPRZI.js → chunk-MXI6J5JF.js} +7 -7
- package/dist/{chunk-G7MUEIGA.js → chunk-OB5HIGJY.js} +1 -1
- package/dist/{chunk-EKY57CSP.js → chunk-OBMAI2DP.js} +61 -767
- package/dist/chunk-PD3MESLB.js +242 -0
- package/dist/{chunk-ZRGEBJ4T.js → chunk-QCTRSGHQ.js} +21 -21
- package/dist/chunk-RVG5JXAL.js +41 -0
- package/dist/{chunk-RD5U66HV.js → chunk-SROCI7ZU.js} +7 -7
- package/dist/{chunk-MFFY33HR.js → chunk-THKY7CD7.js} +466 -205
- package/dist/{chunk-PCZJO5TI.js → chunk-UFQ3F4FW.js} +13 -178
- package/dist/{chunk-AD2SYQYC.js → chunk-UHXRNZ2J.js} +121 -3
- package/dist/chunk-UND3GU2L.js +103 -0
- package/dist/{chunk-QM3F2GKX.js → chunk-UUANF5CR.js} +2247 -2096
- package/dist/chunk-UVDSQ6LW.js +472 -0
- package/dist/{chunk-DJVECN66.js → chunk-VQNODYQ4.js} +14 -14
- package/dist/{chunk-3BPUFZDL.js → chunk-VREKEFLL.js} +3 -3
- package/dist/{chunk-PBTHKCPN.js → chunk-WJHBC77E.js} +6 -6
- package/dist/{chunk-XE2VEJHX.js → chunk-X2KV5FXT.js} +2 -2
- package/dist/{chunk-ZXF4XRKW.js → chunk-XEGB6BCN.js} +157 -7
- package/dist/{chunk-E25LMLRW.js → chunk-YD734TPH.js} +2 -2
- package/dist/{verifiers-NCBTHHN2.js → chunk-YTYFXUI3.js} +65 -322
- package/dist/{chunk-OH3TOQTB.js → chunk-ZGH7FGS5.js} +13 -7
- package/dist/cli/index.js +32 -30
- package/dist/{clio-M2KGYUFZ.js → clio-WBVQEBKO.js} +7 -7
- package/dist/{code-nav-GQNL7XA6.js → code-nav-FGGFIE7L.js} +3 -3
- package/dist/{components-5TTYYX6G.js → components-F7OEATSO.js} +4 -4
- package/dist/{config-XUUYQIWO.js → config-TRBL3RCF.js} +34 -29
- package/dist/{configure-IHJ7YOMV.js → configure-OLCVPHNM.js} +15 -15
- package/dist/{context-74JLXAWD.js → context-MJIJ6GOX.js} +11 -11
- package/dist/{context-ZQ7SIFJV.js → context-WFPKQSM6.js} +19 -3
- package/dist/{context-75MIWW3U.js → context-XEWE3MOJ.js} +31 -26
- package/dist/{context-clear-GYKWNUML.js → context-clear-KNOS2JPB.js} +31 -26
- package/dist/{context-working-set-UX5KEP4J.js → context-working-set-EUXAZI6N.js} +8 -8
- package/dist/{dispatch-runner-GIJBHNFL.js → dispatch-runner-B7MTOVKL.js} +313 -53
- package/dist/{docs-6FZSCG5B.js → docs-FLJTIDSE.js} +4 -4
- package/dist/{doctor-SVJ5BZCW.js → doctor-RN4YKO2X.js} +14 -14
- package/dist/{eval-CG6LLBLD.js → eval-RUBJVSNQ.js} +8 -7
- package/dist/{evidence-ZYFIEN42.js → evidence-JZNBUOQZ.js} +30 -25
- package/dist/{evolve-QGEXEMDW.js → evolve-FJVC4KKI.js} +30 -25
- package/dist/{extensions-ADGNCJJD.js → extensions-IQL36S7K.js} +4 -4
- package/dist/{fleet-S5R4ZOQY.js → fleet-BDKYJFCP.js} +214 -360
- package/dist/fleet-commands-ZFIWZSB3.js +70 -0
- package/dist/fleet-graph-Y6HPXIVF.js +125 -0
- package/dist/fleet-new-RDVJLHHH.js +48 -0
- package/dist/fleet-validate-BIYREGIK.js +79 -0
- package/dist/{init-5DRU55YR.js → init-LQUB5COQ.js} +44 -37
- package/dist/library-NJAHIGG4.js +217 -0
- package/dist/{memory-7YKKR6UC.js → memory-OG6HOYKM.js} +31 -26
- package/dist/{models-ZPOLRU2C.js → models-5ZG5XY7J.js} +21 -20
- package/dist/{monitor-US5F5YGZ.js → monitor-TJ7AMTGB.js} +49 -30
- package/dist/{orchestrator-E2AL4T5N.js → orchestrator-WZYB54DM.js} +3827 -581
- package/dist/{paths-E7KYAQWE.js → paths-XUC7GS6E.js} +4 -4
- package/dist/{reset-KZ652EK6.js → reset-PXQT45IY.js} +7 -7
- package/dist/{run-SRNBKDWD.js → run-FQ74YF62.js} +53 -45
- package/dist/{share-CGZE33UP.js → share-FW7SVCL3.js} +33 -9
- package/dist/{skills-S2X4DLY5.js → skills-7E7IRB3R.js} +24 -8
- package/dist/{skills-eval-W2GGIC4R.js → skills-eval-LI75W6OK.js} +34 -27
- package/dist/{targets-54SWINWB.js → targets-4CIFKCTW.js} +23 -22
- package/dist/{terminal-lease-SAIF2OGY.js → terminal-lease-WUZY7ZV5.js} +4 -4
- package/dist/{uninstall-BVLWXKBT.js → uninstall-7FV7IP4E.js} +4 -4
- package/dist/{upgrade-JKAR27XC.js → upgrade-K2HVIVMQ.js} +20 -19
- package/dist/{usage-MSAWCLX4.js → usage-GTZELZQX.js} +116 -49
- package/dist/verifiers-RLAHT27O.js +336 -0
- package/dist/{verify-X5HDROLA.js → verify-BX3BRKH5.js} +7 -6
- package/dist/{wiki-generate-GUSOQ6ZP.js → wiki-generate-ASIFASCN.js} +45 -37
- package/dist/worker/entry.js +38 -35
- package/docs/README.md +3 -2
- package/docs/acp.md +1 -1
- package/docs/alcf-provider.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/artifact-versions.md +10 -6
- package/docs/built-in-agents.md +26 -2
- package/docs/capacity-and-scheduling.md +1 -1
- package/docs/commands-and-modes.md +82 -2
- package/docs/configuration-and-targets.md +79 -2
- package/docs/context-engine.md +1 -1
- package/docs/development-pipeline.md +1 -1
- package/docs/dispatch-architecture-rationale.md +1 -1
- package/docs/documentation-coverage.md +3 -3
- package/docs/documentation-guide.md +3 -3
- package/docs/eval-runner.md +1 -1
- package/docs/evals-internal.md +1 -1
- package/docs/evidence-and-memory.md +5 -5
- package/docs/evolution.md +1 -1
- package/docs/exit-codes-and-output.md +4 -1
- package/docs/extensions-and-sharing.md +6 -2
- package/docs/fleet-demo-runbook.md +2 -2
- package/docs/fleet-dispatch.md +197 -10
- package/docs/git-commit-provenance.md +2 -2
- package/docs/glossary.md +1 -1
- package/docs/installation-and-lifecycle.md +2 -2
- package/docs/middleware-and-components.md +2 -1
- package/docs/model-catalog.md +1 -1
- package/docs/observability.md +55 -8
- package/docs/proactive-memory.md +1 -1
- package/docs/prompt-envelope-and-tools.md +1 -1
- package/docs/provider-adapter-cookbook.md +1 -1
- package/docs/release-cut-checklist.md +79 -64
- package/docs/resource-library.md +59 -0
- package/docs/safety-model.md +2 -2
- package/docs/scientific-validation.md +3 -3
- package/docs/session-lifecycle.md +37 -1
- package/docs/skills-marketplace.md +16 -3
- package/docs/tool-usage.md +14 -7
- package/docs/trace-store.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/tui-design.md +1 -1
- package/docs/worker-dispatch-mechanics.md +3 -3
- package/package.json +1 -1
- package/src/cli/fleet-commands.ts +37 -0
- package/src/cli/fleet-graph.ts +102 -0
- package/src/cli/fleet-new.ts +36 -0
- package/src/cli/fleet-preflight.ts +121 -0
- package/src/cli/fleet-validate.ts +30 -0
- package/src/cli/fleet.ts +173 -335
- package/src/cli/index.ts +3 -1
- package/src/cli/library.ts +190 -0
- package/src/cli/share.ts +13 -1
- package/src/cli/usage.ts +111 -19
- package/src/core/bus-events.ts +4 -0
- package/src/core/commit-attribution.ts +4 -4
- package/src/core/config.ts +130 -0
- package/src/core/defaults.ts +81 -0
- package/src/domains/agents/builtins/architect.md +1 -0
- package/src/domains/agents/builtins/oracle.md +33 -0
- package/src/domains/agents/catalog.ts +13 -1
- package/src/domains/agents/fleet-contract.ts +278 -16
- package/src/domains/agents/index.ts +14 -0
- package/src/domains/agents/result-contract.ts +235 -1
- package/src/domains/config/classify.ts +4 -0
- package/src/domains/dispatch/active-route-planner.ts +14 -0
- package/src/domains/dispatch/backoff.ts +2 -1
- package/src/domains/dispatch/capability-match.ts +1 -0
- package/src/domains/dispatch/checkout-writer-lease.ts +175 -0
- package/src/domains/dispatch/contract.ts +34 -0
- package/src/domains/dispatch/delegation-plan.ts +167 -0
- package/src/domains/dispatch/execution-plan.ts +76 -5
- package/src/domains/dispatch/execution-role.ts +3 -1
- package/src/domains/dispatch/execution-scheduler.ts +183 -67
- package/src/domains/dispatch/extension.ts +258 -9
- package/src/domains/dispatch/fleet-gate.ts +14 -0
- package/src/domains/dispatch/fleet-plan.ts +63 -3
- package/src/domains/dispatch/fleet-run.ts +737 -0
- package/src/domains/dispatch/gate-role-prompts.ts +9 -0
- package/src/domains/dispatch/host-verification.ts +178 -0
- package/src/domains/dispatch/index.ts +38 -0
- package/src/domains/dispatch/intent.ts +159 -0
- package/src/domains/dispatch/receipt-integrity.ts +8 -4
- package/src/domains/dispatch/state.ts +36 -3
- package/src/domains/dispatch/types.ts +51 -6
- package/src/domains/dispatch/validation.ts +66 -6
- package/src/domains/evidence/trust-status.ts +10 -1
- package/src/domains/middleware/index.ts +15 -0
- package/src/domains/middleware/watchdog.ts +281 -0
- package/src/domains/observability/contract.ts +3 -1
- package/src/domains/observability/cost.ts +12 -1
- package/src/domains/observability/extension.ts +2 -2
- package/src/domains/observability/index.ts +10 -0
- package/src/domains/observability/out-of-turn-usage.ts +223 -0
- package/src/domains/resources/index.ts +20 -0
- package/src/domains/resources/library.ts +326 -0
- package/src/domains/resources/skills/marketplace.ts +37 -12
- package/src/domains/session/handoff.ts +629 -0
- package/src/domains/share/archive.ts +67 -2
- package/src/entry/orchestrator.ts +37 -0
- package/src/interactive/bus-notices.ts +26 -0
- package/src/interactive/chat-loop.ts +235 -1
- package/src/interactive/chat-renderer.ts +22 -0
- package/src/interactive/cost-overlay.ts +31 -3
- package/src/interactive/council-dispatch.ts +30 -0
- package/src/interactive/council-grid.ts +213 -0
- package/src/interactive/council.ts +99 -0
- package/src/interactive/dispatch-board.ts +260 -16
- package/src/interactive/fleet-run-preview.ts +307 -0
- package/src/interactive/footer/notifications.ts +219 -0
- package/src/interactive/handoff-round.ts +56 -0
- package/src/interactive/interactive-application.ts +43 -1
- package/src/interactive/interactive-event-projection.ts +9 -1
- package/src/interactive/interactive-slash-runtime.ts +52 -2
- package/src/interactive/interactive-subscriptions.ts +14 -2
- package/src/interactive/oracle.ts +179 -0
- package/src/interactive/overlay-ask-user-lifecycle.ts +6 -0
- package/src/interactive/overlay-general-openers.ts +190 -1
- package/src/interactive/overlay-key-routing.ts +17 -1
- package/src/interactive/overlay-lifecycle.ts +41 -1
- package/src/interactive/overlay-permission-lifecycle.ts +10 -0
- package/src/interactive/overlay-resource-openers.ts +11 -3
- package/src/interactive/overlay-session-lifecycle.ts +234 -2
- package/src/interactive/overlays/fleet-run-approval.ts +208 -0
- package/src/interactive/overlays/handoff-review.ts +185 -0
- package/src/interactive/overlays/library-install-confirm.ts +151 -0
- package/src/interactive/overlays/list-overlay.ts +168 -2
- package/src/interactive/overlays/settings.ts +101 -4
- package/src/interactive/overlays/side-question.ts +139 -0
- package/src/interactive/overlays/skills-hub.ts +401 -15
- package/src/interactive/side-question.ts +171 -0
- package/src/interactive/slash-commands.ts +432 -5
- package/src/interactive/slash-spec.ts +19 -6
- package/src/interactive/theme/tokens.ts +30 -0
- package/src/interactive/turn-middleware.ts +15 -1
- package/src/interactive/watchdog-run.ts +75 -0
- package/src/interactive/worker-share.ts +56 -1
- package/src/interactive/worker-stream.ts +7 -0
- package/src/tools/bootstrap.ts +3 -0
- package/src/tools/compete-worktrees.ts +13 -79
- package/src/tools/dispatch-admission.ts +242 -8
- package/src/tools/dispatch-arguments.ts +57 -1
- package/src/tools/dispatch-plan.ts +136 -6
- package/src/tools/dispatch-runner.ts +319 -13
- package/src/tools/dispatch-types.ts +20 -1
- package/src/tools/dispatch.ts +72 -2
- package/src/tools/monitor.ts +16 -0
- package/src/tools/profiles.ts +18 -4
- package/src/tools/task-worktree.ts +238 -0
- package/src/tools/verify/authoring.ts +61 -1
- package/src/tools/verify/scripts.ts +62 -0
- package/src/tools/worker-evidence.ts +2 -1
- package/src/worker/spec-contract.ts +1 -0
- package/dist/chunk-HC4CLZ2Y.js +0 -68
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/handoff <goal>`: carry one session's working state into a fresh session.
|
|
3
|
+
*
|
|
4
|
+
* A handoff is a session operation and nothing else. It never writes a memory
|
|
5
|
+
* promotion candidate, never touches the memory domain, and never calls the
|
|
6
|
+
* task-memory bank. What it produces is one reviewed Markdown document, seeded
|
|
7
|
+
* into a newly minted session as bounded data, plus a terminal note in the old
|
|
8
|
+
* session naming where the work continued.
|
|
9
|
+
*
|
|
10
|
+
* Everything in this module is pure. The model round, the operator review, and
|
|
11
|
+
* the session writes live in the interactive layer; the rules that decide what
|
|
12
|
+
* a handoff may contain live here so they can be asserted without a provider,
|
|
13
|
+
* a terminal, or a session directory.
|
|
14
|
+
*
|
|
15
|
+
* The one rule worth stating twice is path validation. Every file the model
|
|
16
|
+
* names is checked against this session's read ledger, meaning the set of
|
|
17
|
+
* workspace-relative paths its own persisted tool calls read, edited, wrote,
|
|
18
|
+
* listed, or grepped. It is never checked against the filesystem: a path that
|
|
19
|
+
* exists on disk but that this session never touched is still an invention, and
|
|
20
|
+
* the point of the check is to show the operator what the model invented rather
|
|
21
|
+
* than to confirm that the repository has files in it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { isAbsolute, normalize, relative, resolve } from "node:path";
|
|
25
|
+
import type { DecisionLedgerEntry, SessionEntry } from "./entries.js";
|
|
26
|
+
import { filterEntriesToActivePath } from "./tree/active-path.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Goal gate
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Shortest goal a handoff accepts. Anything shorter cannot name what to continue. */
|
|
33
|
+
export const HANDOFF_MIN_GOAL_LENGTH = 12;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Goals that name no goal. Each of these says "carry on" without saying what
|
|
37
|
+
* to carry on with, which is exactly the state a handoff exists to end.
|
|
38
|
+
*/
|
|
39
|
+
export const HANDOFF_GOAL_STOPLIST: ReadonlyArray<string> = [
|
|
40
|
+
"continue",
|
|
41
|
+
"keep going",
|
|
42
|
+
"same",
|
|
43
|
+
"next",
|
|
44
|
+
"go on",
|
|
45
|
+
"proceed",
|
|
46
|
+
"resume",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export type HandoffGoalVerdict = { ok: true; goal: string } | { ok: false; reason: string };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The gate, stated as the rule it enforces. A refusal names the rule so the
|
|
53
|
+
* operator can see which of the two conditions rejected the line rather than
|
|
54
|
+
* guessing at a usage string.
|
|
55
|
+
*
|
|
56
|
+
* The stoplist is checked before the length, because every stoplist entry is
|
|
57
|
+
* also shorter than the minimum and the length message would be the less useful
|
|
58
|
+
* of the two. An operator who typed "continue" wants to hear that it names no
|
|
59
|
+
* goal, not that it is four characters short.
|
|
60
|
+
*/
|
|
61
|
+
export function validateHandoffGoal(raw: string): HandoffGoalVerdict {
|
|
62
|
+
const goal = raw.trim();
|
|
63
|
+
if (HANDOFF_GOAL_STOPLIST.includes(goal.toLowerCase())) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
reason: `/handoff refuses "${goal}" because it names no goal; say what the next session should accomplish`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (goal.length < HANDOFF_MIN_GOAL_LENGTH) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: `/handoff needs a goal of at least ${HANDOFF_MIN_GOAL_LENGTH} characters; "${goal}" is ${goal.length}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, goal };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Extraction shape and bounds
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export interface HandoffDecision {
|
|
83
|
+
summary: string;
|
|
84
|
+
rationale?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface HandoffFile {
|
|
88
|
+
path: string;
|
|
89
|
+
why: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface HandoffCommand {
|
|
93
|
+
argv: string;
|
|
94
|
+
why: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface HandoffExtraction {
|
|
98
|
+
decisions: HandoffDecision[];
|
|
99
|
+
facts: string[];
|
|
100
|
+
files: HandoffFile[];
|
|
101
|
+
commands: HandoffCommand[];
|
|
102
|
+
openQuestions: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Per-list item ceilings. Over-bound output is truncated, never refused. */
|
|
106
|
+
export const HANDOFF_LIST_BOUNDS = {
|
|
107
|
+
decisions: 24,
|
|
108
|
+
facts: 32,
|
|
109
|
+
files: 48,
|
|
110
|
+
commands: 16,
|
|
111
|
+
openQuestions: 16,
|
|
112
|
+
} as const;
|
|
113
|
+
|
|
114
|
+
/** Ceiling for every string in the extraction, marker included. */
|
|
115
|
+
export const HANDOFF_MAX_STRING_BYTES = 512;
|
|
116
|
+
|
|
117
|
+
/** Visible flag appended to a string the byte bound cut. */
|
|
118
|
+
export const HANDOFF_TRUNCATION_MARKER = "…[truncated]";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The output contract for the extraction round, in the JSON-schema subset
|
|
122
|
+
* `src/core/response-schema.ts` validates. A runtime that speaks the response
|
|
123
|
+
* schema dialect can enforce it on the wire; every other runtime receives it as
|
|
124
|
+
* the round's stated contract and has its answer validated here on the way
|
|
125
|
+
* back. Either way the parse below is the authority.
|
|
126
|
+
*/
|
|
127
|
+
export const HANDOFF_RESPONSE_SCHEMA: Record<string, unknown> = {
|
|
128
|
+
type: "object",
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
required: ["decisions", "facts", "files", "commands", "openQuestions"],
|
|
131
|
+
properties: {
|
|
132
|
+
decisions: {
|
|
133
|
+
type: "array",
|
|
134
|
+
items: {
|
|
135
|
+
type: "object",
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
required: ["summary"],
|
|
138
|
+
properties: { summary: { type: "string" }, rationale: { type: "string" } },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
facts: { type: "array", items: { type: "string" } },
|
|
142
|
+
files: {
|
|
143
|
+
type: "array",
|
|
144
|
+
items: {
|
|
145
|
+
type: "object",
|
|
146
|
+
additionalProperties: false,
|
|
147
|
+
required: ["path", "why"],
|
|
148
|
+
properties: { path: { type: "string" }, why: { type: "string" } },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
commands: {
|
|
152
|
+
type: "array",
|
|
153
|
+
items: {
|
|
154
|
+
type: "object",
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
required: ["argv", "why"],
|
|
157
|
+
properties: { argv: { type: "string" }, why: { type: "string" } },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
openQuestions: { type: "array", items: { type: "string" } },
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export interface HandoffParseResult {
|
|
165
|
+
extraction: HandoffExtraction;
|
|
166
|
+
/** One line per bound that fired, rendered into the document so nothing is cut silently. */
|
|
167
|
+
truncations: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
171
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Cut a string to the byte ceiling and flag it. The marker counts against the
|
|
176
|
+
* ceiling, so the returned string is never longer than the bound, and the cut
|
|
177
|
+
* lands on a whole code point rather than mid-sequence.
|
|
178
|
+
*/
|
|
179
|
+
export function boundHandoffString(value: string): { text: string; truncated: boolean } {
|
|
180
|
+
const text = value.trim();
|
|
181
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
182
|
+
if (bytes <= HANDOFF_MAX_STRING_BYTES) return { text, truncated: false };
|
|
183
|
+
const markerBytes = Buffer.byteLength(HANDOFF_TRUNCATION_MARKER, "utf8");
|
|
184
|
+
const room = Math.max(0, HANDOFF_MAX_STRING_BYTES - markerBytes);
|
|
185
|
+
// `Buffer.toString` on a cut that lands mid-sequence yields a replacement
|
|
186
|
+
// character; dropping trailing replacement characters keeps the result a
|
|
187
|
+
// prefix of the original rather than a prefix plus a glyph the model never
|
|
188
|
+
// produced.
|
|
189
|
+
const head = Buffer.from(text, "utf8").subarray(0, room).toString("utf8").replace(/�+$/u, "");
|
|
190
|
+
return { text: `${head}${HANDOFF_TRUNCATION_MARKER}`, truncated: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function boundList<T>(items: ReadonlyArray<T>, limit: number, label: string, truncations: string[]): ReadonlyArray<T> {
|
|
194
|
+
if (items.length <= limit) return items;
|
|
195
|
+
truncations.push(`${label}: kept ${limit} of ${items.length}; the rest were dropped by the ${label} bound`);
|
|
196
|
+
return items.slice(0, limit);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stringsFrom(value: unknown): string[] {
|
|
200
|
+
if (!Array.isArray(value)) return [];
|
|
201
|
+
return value.flatMap((item) => (typeof item === "string" && item.trim().length > 0 ? [item.trim()] : []));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function recordsFrom(value: unknown): Record<string, unknown>[] {
|
|
205
|
+
if (!Array.isArray(value)) return [];
|
|
206
|
+
return value.filter(isRecord);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requiredField(record: Record<string, unknown>, key: string): string | null {
|
|
210
|
+
const value = record[key];
|
|
211
|
+
if (typeof value !== "string") return null;
|
|
212
|
+
const trimmed = value.trim();
|
|
213
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The first JSON object in the round's answer. Providers that honor the schema
|
|
218
|
+
* return the object alone; providers that do not often wrap it in a fenced
|
|
219
|
+
* block or a sentence, and refusing those would throw away a usable answer.
|
|
220
|
+
*/
|
|
221
|
+
function extractJsonObject(text: string): string | null {
|
|
222
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
223
|
+
const candidate = (fenced?.[1] ?? text).trim();
|
|
224
|
+
const start = candidate.indexOf("{");
|
|
225
|
+
if (start < 0) return null;
|
|
226
|
+
let depth = 0;
|
|
227
|
+
let inString = false;
|
|
228
|
+
let escaped = false;
|
|
229
|
+
for (let index = start; index < candidate.length; index += 1) {
|
|
230
|
+
const char = candidate[index];
|
|
231
|
+
if (inString) {
|
|
232
|
+
if (escaped) escaped = false;
|
|
233
|
+
else if (char === "\\") escaped = true;
|
|
234
|
+
else if (char === '"') inString = false;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (char === '"') inString = true;
|
|
238
|
+
else if (char === "{") depth += 1;
|
|
239
|
+
else if (char === "}") {
|
|
240
|
+
depth -= 1;
|
|
241
|
+
if (depth === 0) return candidate.slice(start, index + 1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type HandoffParseOutcome = { ok: true; result: HandoffParseResult } | { ok: false; reason: string };
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Validate the round's answer against the response schema and bound it.
|
|
251
|
+
*
|
|
252
|
+
* Bounds never refuse. A model that answers with two hundred facts gets the
|
|
253
|
+
* first thirty-two and a line in the document saying so, because an operator
|
|
254
|
+
* reviewing a truncated handoff is strictly better off than one holding a
|
|
255
|
+
* refusal.
|
|
256
|
+
*/
|
|
257
|
+
export function parseHandoffExtraction(text: string): HandoffParseOutcome {
|
|
258
|
+
const json = extractJsonObject(text);
|
|
259
|
+
if (json === null) return { ok: false, reason: "the extraction round returned no JSON object" };
|
|
260
|
+
let parsed: unknown;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(json);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return { ok: false, reason: `the extraction round returned invalid JSON: ${(error as Error).message}` };
|
|
265
|
+
}
|
|
266
|
+
if (!isRecord(parsed)) return { ok: false, reason: "the extraction round returned a non-object" };
|
|
267
|
+
|
|
268
|
+
const truncations: string[] = [];
|
|
269
|
+
let stringsCut = 0;
|
|
270
|
+
const bound = (value: string): string => {
|
|
271
|
+
const result = boundHandoffString(value);
|
|
272
|
+
if (result.truncated) stringsCut += 1;
|
|
273
|
+
return result.text;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const decisions = boundList(
|
|
277
|
+
recordsFrom(parsed.decisions),
|
|
278
|
+
HANDOFF_LIST_BOUNDS.decisions,
|
|
279
|
+
"decisions",
|
|
280
|
+
truncations,
|
|
281
|
+
).flatMap((record) => {
|
|
282
|
+
const summary = requiredField(record, "summary");
|
|
283
|
+
if (summary === null) return [];
|
|
284
|
+
const rationale = requiredField(record, "rationale");
|
|
285
|
+
return [{ summary: bound(summary), ...(rationale === null ? {} : { rationale: bound(rationale) }) }];
|
|
286
|
+
});
|
|
287
|
+
const facts = boundList(stringsFrom(parsed.facts), HANDOFF_LIST_BOUNDS.facts, "facts", truncations).map(bound);
|
|
288
|
+
const files = boundList(recordsFrom(parsed.files), HANDOFF_LIST_BOUNDS.files, "files", truncations).flatMap(
|
|
289
|
+
(record) => {
|
|
290
|
+
const path = requiredField(record, "path");
|
|
291
|
+
const why = requiredField(record, "why");
|
|
292
|
+
return path === null || why === null ? [] : [{ path: bound(path), why: bound(why) }];
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
const commands = boundList(
|
|
296
|
+
recordsFrom(parsed.commands),
|
|
297
|
+
HANDOFF_LIST_BOUNDS.commands,
|
|
298
|
+
"commands",
|
|
299
|
+
truncations,
|
|
300
|
+
).flatMap((record) => {
|
|
301
|
+
const argv = requiredField(record, "argv");
|
|
302
|
+
const why = requiredField(record, "why");
|
|
303
|
+
return argv === null || why === null ? [] : [{ argv: bound(argv), why: bound(why) }];
|
|
304
|
+
});
|
|
305
|
+
const openQuestions = boundList(
|
|
306
|
+
stringsFrom(parsed.openQuestions),
|
|
307
|
+
HANDOFF_LIST_BOUNDS.openQuestions,
|
|
308
|
+
"openQuestions",
|
|
309
|
+
truncations,
|
|
310
|
+
).map(bound);
|
|
311
|
+
|
|
312
|
+
if (stringsCut > 0) {
|
|
313
|
+
truncations.push(
|
|
314
|
+
`${stringsCut} ${stringsCut === 1 ? "entry was" : "entries were"} cut to ${HANDOFF_MAX_STRING_BYTES} bytes and marked with ${HANDOFF_TRUNCATION_MARKER}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { ok: true, result: { extraction: { decisions, facts, files, commands, openQuestions }, truncations } };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Read ledger
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Tool names whose call arguments name a path this session actually touched.
|
|
327
|
+
* `artifact` writes a file, so it counts as a write; `git` and `verify` are
|
|
328
|
+
* command runners whose path argument is a working directory rather than a
|
|
329
|
+
* file, so they do not.
|
|
330
|
+
*/
|
|
331
|
+
const READ_LEDGER_TOOLS: ReadonlySet<string> = new Set(["read", "edit", "write", "ls", "find", "grep", "artifact"]);
|
|
332
|
+
|
|
333
|
+
export interface HandoffReadLedgerOptions {
|
|
334
|
+
/** Session working directory. Paths resolve against it and relativize back to it. */
|
|
335
|
+
cwd?: string | null;
|
|
336
|
+
/** Active branch leaf, so an abandoned `/tree` branch contributes nothing. */
|
|
337
|
+
leafTurnId?: string | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Canonical workspace-relative form. Lexical only: no realpath, no filesystem
|
|
342
|
+
* probe, no `process.cwd()` fallback. A path outside the workspace keeps its
|
|
343
|
+
* absolute form, which is what makes it fail the ledger check against paths
|
|
344
|
+
* recorded relative to the workspace root.
|
|
345
|
+
*/
|
|
346
|
+
export function normalizeHandoffPath(value: string, cwd: string | null): string {
|
|
347
|
+
const trimmed = value.trim().replace(/[\\/]+$/u, "");
|
|
348
|
+
if (trimmed.length === 0) return "";
|
|
349
|
+
const absolute = isAbsolute(trimmed) ? normalize(trimmed) : cwd === null ? normalize(trimmed) : resolve(cwd, trimmed);
|
|
350
|
+
if (cwd === null) return absolute;
|
|
351
|
+
const rel = relative(cwd, absolute);
|
|
352
|
+
if (rel.length === 0) return ".";
|
|
353
|
+
return rel.startsWith("..") || isAbsolute(rel) ? absolute : rel;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function usableCwd(cwd: string | null | undefined): string | null {
|
|
357
|
+
if (typeof cwd !== "string") return null;
|
|
358
|
+
const trimmed = cwd.trim();
|
|
359
|
+
return trimmed.length > 0 && isAbsolute(trimmed) ? normalize(trimmed) : null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function stringField(record: Record<string, unknown> | null, ...keys: string[]): string | null {
|
|
363
|
+
if (record === null) return null;
|
|
364
|
+
for (const key of keys) {
|
|
365
|
+
const value = record[key];
|
|
366
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function pathFromCall(toolName: string, args: unknown, cwd: string | null, into: Set<string>): void {
|
|
372
|
+
if (!READ_LEDGER_TOOLS.has(toolName)) return;
|
|
373
|
+
const record = isRecord(args) ? args : null;
|
|
374
|
+
const named = stringField(record, "path", "file_path", "filePath");
|
|
375
|
+
// grep, find, and ls all default to the working directory, which is what
|
|
376
|
+
// they actually looked at.
|
|
377
|
+
const raw = named ?? (toolName === "grep" || toolName === "find" || toolName === "ls" ? "." : null);
|
|
378
|
+
if (raw === null) return;
|
|
379
|
+
const normalized = normalizeHandoffPath(raw, cwd);
|
|
380
|
+
if (normalized.length > 0) into.add(normalized);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Fold this session's persisted tool calls into the set of paths it touched.
|
|
385
|
+
*
|
|
386
|
+
* Entries pass through `filterEntriesToActivePath` first, the same way the task
|
|
387
|
+
* board folds its inputs, so a file read on a `/tree` branch the operator
|
|
388
|
+
* abandoned is not evidence that this session read it.
|
|
389
|
+
*/
|
|
390
|
+
export function buildHandoffReadLedger(
|
|
391
|
+
entries: ReadonlyArray<SessionEntry>,
|
|
392
|
+
options: HandoffReadLedgerOptions = {},
|
|
393
|
+
): ReadonlySet<string> {
|
|
394
|
+
const cwd = usableCwd(options.cwd);
|
|
395
|
+
const active = filterEntriesToActivePath(entries, options.leafTurnId ?? undefined);
|
|
396
|
+
const paths = new Set<string>();
|
|
397
|
+
for (const entry of active) {
|
|
398
|
+
if (entry.kind === "fileEntry") {
|
|
399
|
+
const normalized = normalizeHandoffPath(entry.path, cwd);
|
|
400
|
+
if (normalized.length > 0) paths.add(normalized);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (entry.kind !== "message") continue;
|
|
404
|
+
const payload = isRecord(entry.payload) ? entry.payload : null;
|
|
405
|
+
if (payload === null) continue;
|
|
406
|
+
if (entry.role === "tool_call") {
|
|
407
|
+
const name = stringField(payload, "name", "toolName", "tool");
|
|
408
|
+
if (name !== null) pathFromCall(name, payload.args ?? payload.arguments ?? payload.input, cwd, paths);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (entry.role !== "assistant" || !Array.isArray(payload.content)) continue;
|
|
412
|
+
for (const block of payload.content) {
|
|
413
|
+
if (!isRecord(block) || block.type !== "toolCall") continue;
|
|
414
|
+
const name = stringField(block, "name", "toolName");
|
|
415
|
+
if (name !== null) pathFromCall(name, block.arguments ?? block.args ?? block.input, cwd, paths);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return paths;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export interface HandoffPathVerdict {
|
|
422
|
+
kept: HandoffFile[];
|
|
423
|
+
dropped: HandoffFile[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Split the model's file list on the read ledger. A path the session never
|
|
428
|
+
* touched is dropped rather than corrected, and the document lists it so the
|
|
429
|
+
* operator can see what the model invented.
|
|
430
|
+
*/
|
|
431
|
+
export function validateHandoffFiles(
|
|
432
|
+
files: ReadonlyArray<HandoffFile>,
|
|
433
|
+
ledger: ReadonlySet<string>,
|
|
434
|
+
cwd: string | null = null,
|
|
435
|
+
): HandoffPathVerdict {
|
|
436
|
+
const kept: HandoffFile[] = [];
|
|
437
|
+
const dropped: HandoffFile[] = [];
|
|
438
|
+
for (const file of files) {
|
|
439
|
+
const normalized = normalizeHandoffPath(file.path, usableCwd(cwd));
|
|
440
|
+
if (normalized.length > 0 && ledger.has(normalized)) kept.push({ ...file, path: normalized });
|
|
441
|
+
else dropped.push(file);
|
|
442
|
+
}
|
|
443
|
+
return { kept, dropped };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Decisions
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
export interface MergedHandoffDecision extends HandoffDecision {
|
|
451
|
+
/** True for a decision the session's decision board already settled. */
|
|
452
|
+
settled: boolean;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function decisionMatchKey(text: string): string {
|
|
456
|
+
return text
|
|
457
|
+
.toLowerCase()
|
|
458
|
+
.replace(/[^a-z0-9]+/gu, " ")
|
|
459
|
+
.trim();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Merge the extracted decisions with the session's settled decision board.
|
|
464
|
+
*
|
|
465
|
+
* The board is the record of what an operator actually answered, so it wins on
|
|
466
|
+
* conflict and its entries carry a settled marker. An extracted decision
|
|
467
|
+
* conflicts when it restates a board decision's key or label, which is how a
|
|
468
|
+
* model paraphrasing an answer the operator already gave gets folded into the
|
|
469
|
+
* answer rather than listed beside it. Superseded board decisions are history
|
|
470
|
+
* and do not travel.
|
|
471
|
+
*/
|
|
472
|
+
export function mergeHandoffDecisions(
|
|
473
|
+
extracted: ReadonlyArray<HandoffDecision>,
|
|
474
|
+
board: ReadonlyArray<DecisionLedgerEntry>,
|
|
475
|
+
): MergedHandoffDecision[] {
|
|
476
|
+
const settled: MergedHandoffDecision[] = [];
|
|
477
|
+
const claimed: string[] = [];
|
|
478
|
+
for (const interview of board) {
|
|
479
|
+
for (const decision of interview.decisions) {
|
|
480
|
+
if (decision.status !== "active") continue;
|
|
481
|
+
const name = decision.label ?? decision.key;
|
|
482
|
+
const summary = boundHandoffString(`${name}: ${decision.value}`).text;
|
|
483
|
+
const rationale = decision.source_question ?? decision.correction;
|
|
484
|
+
settled.push({
|
|
485
|
+
summary,
|
|
486
|
+
...(rationale ? { rationale: boundHandoffString(rationale).text } : {}),
|
|
487
|
+
settled: true,
|
|
488
|
+
});
|
|
489
|
+
claimed.push(decisionMatchKey(name), decisionMatchKey(decision.key));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const keys = claimed.filter((key) => key.length > 0);
|
|
493
|
+
const seen = new Set(settled.map((decision) => decisionMatchKey(decision.summary)));
|
|
494
|
+
const merged = [...settled];
|
|
495
|
+
for (const decision of extracted) {
|
|
496
|
+
const key = decisionMatchKey(decision.summary);
|
|
497
|
+
if (seen.has(key)) continue;
|
|
498
|
+
if (keys.some((claim) => key.includes(claim))) continue;
|
|
499
|
+
seen.add(key);
|
|
500
|
+
merged.push({ ...decision, settled: false });
|
|
501
|
+
}
|
|
502
|
+
return merged;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Document
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/** Exact heading the dropped paths appear under. Asserted by contract. */
|
|
510
|
+
export const HANDOFF_DROPPED_HEADING = "dropped (not in this session's read ledger)";
|
|
511
|
+
|
|
512
|
+
export interface HandoffDocumentInput {
|
|
513
|
+
goal: string;
|
|
514
|
+
fromSessionId: string;
|
|
515
|
+
decisions: ReadonlyArray<MergedHandoffDecision>;
|
|
516
|
+
facts: ReadonlyArray<string>;
|
|
517
|
+
files: ReadonlyArray<HandoffFile>;
|
|
518
|
+
droppedFiles: ReadonlyArray<HandoffFile>;
|
|
519
|
+
commands: ReadonlyArray<HandoffCommand>;
|
|
520
|
+
openQuestions: ReadonlyArray<string>;
|
|
521
|
+
truncations: ReadonlyArray<string>;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function section(lines: string[], title: string, body: ReadonlyArray<string>): void {
|
|
525
|
+
if (body.length === 0) return;
|
|
526
|
+
lines.push(`## ${title}`, "");
|
|
527
|
+
for (const line of body) lines.push(line);
|
|
528
|
+
lines.push("");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Render the reviewable Markdown document. This is what the operator edits. */
|
|
532
|
+
export function renderHandoffDocument(input: HandoffDocumentInput): string {
|
|
533
|
+
const lines: string[] = [];
|
|
534
|
+
lines.push("# Handoff", "");
|
|
535
|
+
lines.push(`**Goal:** ${input.goal}`, "");
|
|
536
|
+
lines.push(`Carried from session \`${input.fromSessionId}\`.`, "");
|
|
537
|
+
|
|
538
|
+
section(
|
|
539
|
+
lines,
|
|
540
|
+
"Decisions",
|
|
541
|
+
input.decisions.map((decision) => {
|
|
542
|
+
const marker = decision.settled ? " _(settled)_" : "";
|
|
543
|
+
const rationale = decision.rationale ? `\n ${decision.rationale}` : "";
|
|
544
|
+
return `- ${decision.summary}${marker}${rationale}`;
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
section(
|
|
548
|
+
lines,
|
|
549
|
+
"Facts",
|
|
550
|
+
input.facts.map((fact) => `- ${fact}`),
|
|
551
|
+
);
|
|
552
|
+
section(
|
|
553
|
+
lines,
|
|
554
|
+
"Files",
|
|
555
|
+
input.files.map((file) => `- \`${file.path}\`: ${file.why}`),
|
|
556
|
+
);
|
|
557
|
+
if (input.droppedFiles.length > 0) {
|
|
558
|
+
lines.push(`### ${HANDOFF_DROPPED_HEADING}`, "");
|
|
559
|
+
for (const file of input.droppedFiles) lines.push(`- \`${file.path}\`: ${file.why}`);
|
|
560
|
+
lines.push("");
|
|
561
|
+
}
|
|
562
|
+
section(
|
|
563
|
+
lines,
|
|
564
|
+
"Commands",
|
|
565
|
+
input.commands.map((command) => `- \`${command.argv}\`: ${command.why}`),
|
|
566
|
+
);
|
|
567
|
+
section(
|
|
568
|
+
lines,
|
|
569
|
+
"Open questions",
|
|
570
|
+
input.openQuestions.map((question) => `- ${question}`),
|
|
571
|
+
);
|
|
572
|
+
section(
|
|
573
|
+
lines,
|
|
574
|
+
"Bounds applied",
|
|
575
|
+
input.truncations.map((note) => `- ${note}`),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
// Ledger entries
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
/** `custom.customType` of the seed entry the new session opens with. */
|
|
586
|
+
export const HANDOFF_SEED_CUSTOM_TYPE = "handoffSeed";
|
|
587
|
+
/** `custom.customType` of the terminal note the old session closes with. */
|
|
588
|
+
export const HANDOFF_NOTE_CUSTOM_TYPE = "handoffNote";
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* How the seed entry introduces itself to the model. It is labelled as carried
|
|
592
|
+
* data from a named session, so the model reads a handoff document rather than
|
|
593
|
+
* an instruction the operator never typed.
|
|
594
|
+
*/
|
|
595
|
+
export const HANDOFF_SEED_PREFIX = "Handoff document carried from session";
|
|
596
|
+
|
|
597
|
+
export interface HandoffSeedData {
|
|
598
|
+
fromSessionId: string;
|
|
599
|
+
goal: string;
|
|
600
|
+
document: string;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export interface HandoffNoteData {
|
|
604
|
+
toSessionId: string;
|
|
605
|
+
goal: string;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The model-facing text of a seed entry. Data, labelled by origin, never a user turn. */
|
|
609
|
+
export function handoffSeedContextText(data: HandoffSeedData): string {
|
|
610
|
+
return [
|
|
611
|
+
`${HANDOFF_SEED_PREFIX} ${data.fromSessionId}.`,
|
|
612
|
+
"It is reference material the operator reviewed, not a message they wrote.",
|
|
613
|
+
"",
|
|
614
|
+
data.document.trim(),
|
|
615
|
+
].join("\n");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function isHandoffSeedData(value: unknown): value is HandoffSeedData {
|
|
619
|
+
return (
|
|
620
|
+
isRecord(value) &&
|
|
621
|
+
typeof value.fromSessionId === "string" &&
|
|
622
|
+
typeof value.goal === "string" &&
|
|
623
|
+
typeof value.document === "string"
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function isHandoffNoteData(value: unknown): value is HandoffNoteData {
|
|
628
|
+
return isRecord(value) && typeof value.toSessionId === "string" && typeof value.goal === "string";
|
|
629
|
+
}
|