@fusionkit/handoff 0.1.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.
@@ -0,0 +1,593 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { canonicalize, defaultExecutionSpec, hashCanonical, isTerminalStatus, PolicyDeniedError, PROTOCOL_VERSIONS, sha256Hex } from "@fusionkit/protocol";
5
+ import { PlaneClient } from "@fusionkit/sdk";
6
+ import { captureWorkspace } from "@fusionkit/workspace";
7
+ import { agents } from "./agents.js";
8
+ import { HandoffCheckpointManager } from "./checkpoint-manager.js";
9
+ import { BLOB_UPLOAD_CONCURRENCY, DEFAULT_ACTOR_ID, DEFAULT_POLL_INTERVAL_MS, DEFAULT_STREAM_TIMEOUT_MS } from "./defaults.js";
10
+ import { localFirst, planContinuation } from "./policy.js";
11
+ import { evaluateTriggers } from "./triggers.js";
12
+ import { reviewRuns, reviewStrategies } from "./review.js";
13
+ import { HandoffRun } from "./run.js";
14
+ import { HandoffTraceLog } from "./trace-log.js";
15
+ import { HandoffToolJournal } from "./tool-journal.js";
16
+ import { wrapTools } from "./tools.js";
17
+ /**
18
+ * Module-level defaults set by defineHandoffConfig. This is deliberately
19
+ * process-global, provider-style configuration: call it once at startup
20
+ * (like configuring a logging or tracing SDK). Code that needs isolated
21
+ * configuration — parallel tests, multi-tenant hosts — passes everything
22
+ * explicitly to handoff(), which always wins over these defaults and never
23
+ * reads them after construction.
24
+ */
25
+ let handoffDefaults = {};
26
+ /**
27
+ * Register provider-style defaults once; subsequent `handoff({...})` calls
28
+ * merge them under their explicit config (explicit values win):
29
+ *
30
+ * defineHandoffConfig({ plane, agent: agents.claudeCode(), policy: localFirst() });
31
+ * const h = handoff({ workspace: "." });
32
+ */
33
+ export function defineHandoffConfig(defaults) {
34
+ handoffDefaults = defaults;
35
+ return defaults;
36
+ }
37
+ /**
38
+ * Actor used when none is configured: the OS user, falling back to a fixed
39
+ * id. Callers who need a real identity pass `actor` in HandoffConfig (or
40
+ * via defineHandoffConfig); this fallback exists so local experimentation
41
+ * works without ceremony while still attributing runs to *someone*.
42
+ */
43
+ function defaultActor() {
44
+ return { kind: "human", id: process.env.USER ?? DEFAULT_ACTOR_ID };
45
+ }
46
+ /**
47
+ * The continuation context. One object that can checkpoint local work,
48
+ * plan a continuation under policy, hand the work to a governed runner
49
+ * pool, and pull the results (and the receipts) back.
50
+ *
51
+ * Built entirely on Warrant primitives: every continuation is a signed
52
+ * run contract, every result is an offline-verifiable receipt, and the
53
+ * envelope hash is pinned inside the contract.
54
+ */
55
+ export class Handoff {
56
+ client;
57
+ workspaceDir;
58
+ actor;
59
+ agent;
60
+ policy;
61
+ secrets;
62
+ allowHosts;
63
+ allowUntracked;
64
+ budget;
65
+ traceLog = new HandoffTraceLog();
66
+ toolJournal = new HandoffToolJournal();
67
+ requestedRuns = [];
68
+ checkpointsState = new HandoffCheckpointManager();
69
+ lastEnvelopeValue;
70
+ userRequestedContinuation = false;
71
+ modelEscalationCount = 0;
72
+ constructor(init) {
73
+ const config = { ...handoffDefaults, ...init };
74
+ if (!config.plane) {
75
+ throw new Error("handoff requires a plane (pass it in the config or via defineHandoffConfig)");
76
+ }
77
+ this.client =
78
+ config.plane instanceof PlaneClient
79
+ ? config.plane
80
+ : new PlaneClient(config.plane.url, config.plane.adminToken);
81
+ this.workspaceDir = resolve(config.workspace);
82
+ this.actor = config.actor ?? defaultActor();
83
+ // mock() is the safe default agent: it invokes no vendor CLI, spends
84
+ // nothing, and visibly writes MOCK_AGENT.md into the workspace, so an
85
+ // unconfigured context cannot silently run a real agent. Real agents
86
+ // are always an explicit choice (agents.claudeCode(), agents.codex(), ...).
87
+ this.agent = config.agent ?? agents.mock();
88
+ this.policy = config.policy ?? localFirst();
89
+ this.secrets = config.secrets ?? [];
90
+ this.allowHosts = config.allowHosts ?? [];
91
+ this.allowUntracked = config.allowUntracked ?? [];
92
+ this.budget = config.budget ?? {};
93
+ }
94
+ /** The local workspace this context is bound to. */
95
+ get workspacePath() {
96
+ return this.workspaceDir;
97
+ }
98
+ /** Local trace: every planning, envelope, run, pull, and tool decision. */
99
+ trace() {
100
+ return this.traceLog.snapshot();
101
+ }
102
+ /**
103
+ * Wrap an AI SDK-shaped toolset (any tools with `execute`) so every call
104
+ * is journaled. The journal travels as content-addressed semantic state
105
+ * in the next checkpoint, so a continuation carries what the loop's tools
106
+ * saw and did. Tools still execute locally, in the caller's process —
107
+ * this is capture, not orchestration. For governed *remote* execution,
108
+ * use @fusionkit/adapter-ai-sdk's remoteTools instead.
109
+ */
110
+ tools(toolset) {
111
+ return wrapTools(toolset, () => this.toolJournal.length, ({ record, inputHash, outputHash, ok }) => {
112
+ this.toolJournal.append(record);
113
+ this.record({
114
+ type: "tool.called",
115
+ ts: record.ts,
116
+ toolName: record.toolName,
117
+ inputHash,
118
+ ...(outputHash !== undefined ? { outputHash } : {}),
119
+ ok,
120
+ durationMs: record.durationMs
121
+ });
122
+ });
123
+ }
124
+ /** Snapshot of the observable state that continuation triggers evaluate. */
125
+ triggerState() {
126
+ return {
127
+ userRequested: this.userRequestedContinuation,
128
+ toolFailures: this.toolJournal.failureCount(),
129
+ totalToolDurationMs: this.toolJournal.totalDurationMs(),
130
+ modelEscalations: this.modelEscalationCount
131
+ };
132
+ }
133
+ /** Which of the policy's continueWhen triggers currently fire, and why. */
134
+ firedTriggers() {
135
+ return evaluateTriggers(this.policy.continueWhen ?? [], this.triggerState());
136
+ }
137
+ /**
138
+ * Explicitly request continuation (the user gesture). Makes
139
+ * triggers.userRequested() fire on the next needs() check.
140
+ */
141
+ requestContinuation(reason) {
142
+ this.userRequestedContinuation = true;
143
+ this.record({
144
+ type: "continuation.requested",
145
+ ts: new Date().toISOString(),
146
+ ...(reason ? { reason } : {})
147
+ });
148
+ }
149
+ /**
150
+ * Report a model routing decision (wired up by withModel from
151
+ * @fusionkit/adapter-ai-sdk). Escalations feed triggers.modelEscalated().
152
+ */
153
+ noteModelDecision(decision) {
154
+ if (decision.escalated)
155
+ this.modelEscalationCount++;
156
+ this.record({
157
+ type: "model.routed",
158
+ ts: new Date().toISOString(),
159
+ model: decision.model,
160
+ route: decision.route,
161
+ escalated: decision.escalated,
162
+ reason: decision.reason
163
+ });
164
+ }
165
+ /**
166
+ * Deterministic check: should this work continue on the target?
167
+ * True when the continuation policy permits the target AND — if the
168
+ * policy declares continueWhen triggers — at least one trigger fires
169
+ * against observable context state (tool failures, slow tools, explicit
170
+ * requests, model escalations). Pure: records nothing, moves nothing.
171
+ */
172
+ needs(target, options = {}) {
173
+ const allowed = planContinuation(this.policy, {
174
+ target,
175
+ secrets: options.secrets ?? this.secrets,
176
+ budget: options.budget ?? this.budget,
177
+ // Defaults to a single run; pass `parallelism` when probing whether
178
+ // a parallel() fan-out of that width would be permitted.
179
+ parallelism: options.parallelism ?? 1
180
+ }).decision === "continue";
181
+ if (!allowed)
182
+ return false;
183
+ const triggersConfigured = this.policy.continueWhen ?? [];
184
+ if (triggersConfigured.length === 0)
185
+ return true;
186
+ return this.firedTriggers().length > 0;
187
+ }
188
+ /** Recomputed view over the trace plus live run statuses from the plane. */
189
+ async summary() {
190
+ // Statuses are fetched concurrently with per-run error isolation: a run
191
+ // whose status fetch fails is reported with its last locally known
192
+ // status ("created" at minimum) instead of rejecting the whole summary.
193
+ const runs = await Promise.all(this.requestedRuns.map(async (requested) => {
194
+ try {
195
+ const view = await this.client.getRun(requested.runId);
196
+ return { ...requested, status: view.status };
197
+ }
198
+ catch {
199
+ return { ...requested, status: "created" };
200
+ }
201
+ }));
202
+ let checkpoints = 0;
203
+ let toolCalls = 0;
204
+ let planned = 0;
205
+ let denied = 0;
206
+ let requested = 0;
207
+ let pulls = 0;
208
+ let localRoutes = 0;
209
+ let cloudRoutes = 0;
210
+ let escalations = 0;
211
+ for (const event of this.traceLog.snapshot()) {
212
+ switch (event.type) {
213
+ case "checkpoint.created":
214
+ checkpoints++;
215
+ break;
216
+ case "tool.called":
217
+ toolCalls++;
218
+ break;
219
+ case "continuation.planned":
220
+ planned++;
221
+ if (event.decision === "deny")
222
+ denied++;
223
+ break;
224
+ case "continuation.requested":
225
+ requested++;
226
+ break;
227
+ case "model.routed":
228
+ if (event.route === "local")
229
+ localRoutes++;
230
+ else
231
+ cloudRoutes++;
232
+ if (event.escalated)
233
+ escalations++;
234
+ break;
235
+ case "results.pulled":
236
+ pulls++;
237
+ break;
238
+ case "envelope.created":
239
+ case "run.requested":
240
+ case "run.terminal":
241
+ break;
242
+ default: {
243
+ const exhausted = event;
244
+ throw new Error(`unreachable trace event: ${String(exhausted)}`);
245
+ }
246
+ }
247
+ }
248
+ return {
249
+ workspace: this.workspaceDir,
250
+ checkpoints,
251
+ toolCalls,
252
+ continuations: { planned, denied, requested },
253
+ modelRoutes: { local: localRoutes, cloud: cloudRoutes, escalations },
254
+ runs,
255
+ pulls
256
+ };
257
+ }
258
+ /** Every checkpoint this context created, oldest first (lineage intact). */
259
+ checkpoints() {
260
+ return this.checkpointsState.snapshot();
261
+ }
262
+ /**
263
+ * Live, typed event stream over a set of runs: status transitions, every
264
+ * appended chained event, artifact availability, and terminal states.
265
+ * Completes when all runs are terminal.
266
+ */
267
+ async *stream(runs, options = {}) {
268
+ const pollMs = options.pollMs ?? DEFAULT_POLL_INTERVAL_MS;
269
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS);
270
+ const lastStatus = new Map();
271
+ const lastSeq = new Map();
272
+ const terminal = new Set();
273
+ while (terminal.size < runs.length) {
274
+ if (Date.now() > deadline) {
275
+ throw new Error("stream timed out before all runs reached a terminal state");
276
+ }
277
+ for (const run of runs) {
278
+ if (terminal.has(run.runId))
279
+ continue;
280
+ const view = await this.client.getRun(run.runId);
281
+ for (const entry of view.events) {
282
+ if (entry.seq <= (lastSeq.get(run.runId) ?? -1))
283
+ continue;
284
+ lastSeq.set(run.runId, entry.seq);
285
+ yield { type: "run.event", runId: run.runId, event: entry };
286
+ if (entry.event.type === "artifact.created") {
287
+ yield {
288
+ type: "artifact.ready",
289
+ runId: run.runId,
290
+ kind: entry.event.kind,
291
+ hash: entry.event.hash
292
+ };
293
+ }
294
+ }
295
+ if (view.status !== lastStatus.get(run.runId)) {
296
+ lastStatus.set(run.runId, view.status);
297
+ yield { type: "run.status", runId: run.runId, status: view.status };
298
+ }
299
+ if (isTerminalStatus(view.status)) {
300
+ terminal.add(run.runId);
301
+ yield { type: "run.terminal", runId: run.runId, status: view.status };
302
+ }
303
+ }
304
+ if (terminal.size < runs.length) {
305
+ // Polling is the plane's supported transport (plain stateless HTTP);
306
+ // the interval is shared with HandoffRun.wait and caller-tunable.
307
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
308
+ }
309
+ }
310
+ }
311
+ /** The most recent envelope this context produced. */
312
+ lastEnvelope() {
313
+ return this.lastEnvelopeValue;
314
+ }
315
+ record(event) {
316
+ this.traceLog.append(event);
317
+ }
318
+ capture() {
319
+ return captureWorkspace(this.workspaceDir, {
320
+ allowUntracked: this.allowUntracked
321
+ });
322
+ }
323
+ /**
324
+ * Upload the captured workspace with bounded concurrency. Blobs are
325
+ * content-addressed and idempotent, so a partial failure simply leaves
326
+ * already-uploaded blobs in place — retried checkpoints reuse them and
327
+ * the plane's reference-counting GC reclaims any that end up orphaned.
328
+ */
329
+ async uploadCapture(captured) {
330
+ const payloads = [
331
+ captured.bundle,
332
+ ...(captured.dirtyDiff ? [captured.dirtyDiff] : []),
333
+ ...captured.untracked.map((file) => file.content)
334
+ ];
335
+ let next = 0;
336
+ const workers = Array.from({ length: Math.min(BLOB_UPLOAD_CONCURRENCY, payloads.length) }, async () => {
337
+ while (next < payloads.length) {
338
+ const index = next++;
339
+ const payload = payloads[index];
340
+ if (payload)
341
+ await this.client.putBlob(payload);
342
+ }
343
+ });
344
+ await Promise.all(workers);
345
+ }
346
+ /** Snapshot the tool journal as a content-addressed blob payload. */
347
+ journalSnapshot() {
348
+ return this.toolJournal.snapshot();
349
+ }
350
+ /**
351
+ * Capture resumable state: the workspace manifest (git base ref, dirty
352
+ * diff, allowlisted untracked files) plus an optional transcript as
353
+ * semantic state. Content moves to the plane blob store; secret-pattern
354
+ * files are denied capture and the denial is recorded in the manifest.
355
+ */
356
+ async checkpoint(message, options = {}) {
357
+ const captured = this.capture();
358
+ await this.uploadCapture(captured);
359
+ let transcriptHash;
360
+ if (options.transcript !== undefined) {
361
+ const blob = Buffer.from(options.transcript, "utf8");
362
+ transcriptHash = sha256Hex(blob);
363
+ await this.client.putBlob(blob);
364
+ }
365
+ const journal = this.journalSnapshot();
366
+ if (journal)
367
+ await this.client.putBlob(journal.blob);
368
+ const checkpoint = this.checkpointsState.create({
369
+ captured,
370
+ ...(message ? { message } : {}),
371
+ ...(transcriptHash ? { transcriptHash } : {}),
372
+ ...(journal?.hash ? { toolJournalHash: journal.hash } : {})
373
+ });
374
+ this.record({
375
+ type: "checkpoint.created",
376
+ ts: checkpoint.createdAt,
377
+ checkpointId: checkpoint.checkpointId,
378
+ tier: checkpoint.tier,
379
+ ...(message ? { message } : {})
380
+ });
381
+ return checkpoint;
382
+ }
383
+ /** Deterministic continuation planning; never moves anything. */
384
+ plan(target, options = {}, parallelism = 1) {
385
+ const decision = planContinuation(this.policy, {
386
+ target,
387
+ secrets: options.secrets ?? this.secrets,
388
+ budget: options.budget ?? this.budget,
389
+ parallelism
390
+ });
391
+ this.record({
392
+ type: "continuation.planned",
393
+ ts: new Date().toISOString(),
394
+ decision: decision.decision,
395
+ target: target.id,
396
+ reasons: decision.reasons
397
+ });
398
+ return decision;
399
+ }
400
+ buildEnvelope(target, checkpoint, options) {
401
+ const agent = options.agent ?? this.agent;
402
+ return {
403
+ version: PROTOCOL_VERSIONS.envelope,
404
+ envelopeId: `env_${randomUUID()}`,
405
+ createdAt: new Date().toISOString(),
406
+ source: { kind: "local", actor: this.actor, host: hostname() },
407
+ target: { kind: "runner-pool", pool: target.pool },
408
+ // Security posture, not a tunable: egress is deny-by-default with an
409
+ // explicit allowHosts list, and secret claims are always "requested"
410
+ // (release is the plane's policy decision, never the SDK's). Orgs
411
+ // that pre-approve hosts express that in plane policy, not here.
412
+ checkpoint,
413
+ agent,
414
+ task: { prompt: options.task },
415
+ execution: options.execution ?? defaultExecutionSpec(agent, options.task),
416
+ ...(options.reason ? { reason: options.reason } : {}),
417
+ secrets: (options.secrets ?? this.secrets).map((name) => ({
418
+ name,
419
+ scope: "requested"
420
+ })),
421
+ network: {
422
+ defaultDeny: true,
423
+ allowHosts: options.allowHosts ?? this.allowHosts
424
+ },
425
+ budget: options.budget ?? this.budget,
426
+ disclosure: this.policy.disclosure,
427
+ ...(options.session ? { isolation: options.session } : {})
428
+ };
429
+ }
430
+ buildRunRequest(envelope, envelopeHash) {
431
+ if (!envelope.checkpoint.workspace) {
432
+ throw new Error("envelope checkpoint is missing a workspace manifest");
433
+ }
434
+ return {
435
+ requestedBy: this.actor,
436
+ agentKind: envelope.agent.kind,
437
+ ...(envelope.agent.version ? { agentVersion: envelope.agent.version } : {}),
438
+ prompt: envelope.task.prompt,
439
+ pool: envelope.target.pool,
440
+ secretNames: envelope.secrets.map((claim) => claim.name),
441
+ workspace: envelope.checkpoint.workspace,
442
+ network: envelope.network,
443
+ budget: envelope.budget,
444
+ disclosure: envelope.disclosure,
445
+ ...(envelope.execution ? { execution: envelope.execution } : {}),
446
+ ...(envelope.isolation ? { isolation: envelope.isolation } : {}),
447
+ continuation: {
448
+ envelopeHash,
449
+ checkpointId: envelope.checkpoint.checkpointId,
450
+ tier: envelope.checkpoint.tier
451
+ }
452
+ };
453
+ }
454
+ /**
455
+ * "What would move?" — the disclosure report for a continuation,
456
+ * computed without uploading, issuing, or executing anything.
457
+ */
458
+ async dryRun(target, options) {
459
+ const decision = this.plan(target, options);
460
+ if (decision.decision === "deny") {
461
+ throw new PolicyDeniedError(decision.reasons);
462
+ }
463
+ const captured = this.capture();
464
+ let transcriptHash;
465
+ if (options.transcript !== undefined) {
466
+ transcriptHash = sha256Hex(Buffer.from(options.transcript, "utf8"));
467
+ }
468
+ const journal = this.journalSnapshot();
469
+ const checkpoint = options.checkpoint ??
470
+ this.checkpointsState.create({
471
+ captured,
472
+ ...(options.reason ? { message: options.reason } : {}),
473
+ ...(transcriptHash ? { transcriptHash } : {}),
474
+ ...(journal?.hash ? { toolJournalHash: journal.hash } : {}),
475
+ remember: false
476
+ });
477
+ const envelope = this.buildEnvelope(target, checkpoint, options);
478
+ const envelopeHash = hashCanonical(envelope);
479
+ const report = await this.client.dryRun(this.buildRunRequest(envelope, envelopeHash));
480
+ return { report, envelope, decision };
481
+ }
482
+ /**
483
+ * Continue this work in the target pool: plan under policy (fail
484
+ * closed), checkpoint, wrap the continuation in a content-addressed
485
+ * envelope, and submit it as a governed run whose signed contract pins
486
+ * the envelope hash.
487
+ */
488
+ async continueIn(target, options) {
489
+ const decision = this.plan(target, options);
490
+ if (decision.decision === "deny") {
491
+ throw new PolicyDeniedError(decision.reasons);
492
+ }
493
+ const checkpoint = options.checkpoint ??
494
+ (await this.checkpoint(options.reason, {
495
+ ...(options.transcript !== undefined
496
+ ? { transcript: options.transcript }
497
+ : {})
498
+ }));
499
+ return this.submit(target, checkpoint, options, decision);
500
+ }
501
+ async submit(target, checkpoint, options, decision) {
502
+ const envelope = this.buildEnvelope(target, checkpoint, options);
503
+ // Store the canonical JSON bytes so the blob address equals the
504
+ // envelope's content hash, which the signed contract pins.
505
+ const envelopeHash = hashCanonical(envelope);
506
+ await this.client.putBlob(Buffer.from(canonicalize(envelope), "utf8"));
507
+ this.lastEnvelopeValue = envelope;
508
+ this.record({
509
+ type: "envelope.created",
510
+ ts: envelope.createdAt,
511
+ envelopeId: envelope.envelopeId,
512
+ envelopeHash,
513
+ target: target.id
514
+ });
515
+ const created = await this.client.requestRun(this.buildRunRequest(envelope, envelopeHash));
516
+ this.requestedRuns.push({
517
+ runId: created.runId,
518
+ task: options.task,
519
+ target: target.id
520
+ });
521
+ this.record({
522
+ type: "run.requested",
523
+ ts: new Date().toISOString(),
524
+ runId: created.runId,
525
+ status: created.status,
526
+ task: options.task
527
+ });
528
+ return new HandoffRun({
529
+ runId: created.runId,
530
+ target,
531
+ envelope,
532
+ envelopeHash,
533
+ explanation: decision.reasons.join("; "),
534
+ ...(options.isolate ? { isolate: options.isolate } : {}),
535
+ client: this.client,
536
+ actor: this.actor,
537
+ workspaceDir: this.workspaceDir,
538
+ onTerminal: (runId, status) => this.record({
539
+ type: "run.terminal",
540
+ ts: new Date().toISOString(),
541
+ runId,
542
+ status
543
+ }),
544
+ onPulled: (runId, mode) => this.record({
545
+ type: "results.pulled",
546
+ ts: new Date().toISOString(),
547
+ runId,
548
+ mode
549
+ })
550
+ });
551
+ }
552
+ /**
553
+ * Fan the same checkpoint out across several isolated attempts. Each
554
+ * attempt is its own governed run with its own contract, envelope, and
555
+ * receipt; outputs land on separate branches at pull time.
556
+ */
557
+ async parallel(tasks, target, options = {}) {
558
+ if (tasks.length === 0)
559
+ throw new Error("parallel requires at least one task");
560
+ // One plan covers the whole fan-out by construction: parallel() takes a
561
+ // single options object, so secrets, budget, and target — everything
562
+ // policy evaluates — are identical for every attempt. Only the task
563
+ // prompt differs, and prompts are not a policy input. Attempts needing
564
+ // different secrets or budgets are separate continueIn() calls.
565
+ const decision = this.plan(target, { ...options, task: tasks[0] ?? "" }, tasks.length);
566
+ if (decision.decision === "deny") {
567
+ throw new PolicyDeniedError(decision.reasons);
568
+ }
569
+ const checkpoint = options.checkpoint ??
570
+ (await this.checkpoint(options.reason ?? `fan-out of ${tasks.length} attempts`, {
571
+ ...(options.transcript !== undefined
572
+ ? { transcript: options.transcript }
573
+ : {})
574
+ }));
575
+ const runs = [];
576
+ for (const task of tasks) {
577
+ runs.push(await this.submit(target, checkpoint, { ...options, task, checkpoint }, decision));
578
+ }
579
+ return runs;
580
+ }
581
+ /** Compare fan-out attempts with a typed, deterministic strategy. */
582
+ review(runs, options = {}) {
583
+ return reviewRuns(this.client, runs, options.choose ?? reviewStrategies.smallestDiff());
584
+ }
585
+ }
586
+ /**
587
+ * Create a continuation context bound to a workspace and a plane. Anything
588
+ * not supplied here falls back to defaults registered via
589
+ * defineHandoffConfig; the workspace is always explicit.
590
+ */
591
+ export function handoff(init) {
592
+ return new Handoff(init);
593
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @fusionkit/handoff — the continuation-first SDK. Start work wherever it
3
+ * naturally begins, continue it on a governed runner when conditions
4
+ * change, preserve state across the boundary, and prove what moved, why
5
+ * it moved, who approved it, and how to resume.
6
+ *
7
+ * Everything here composes Warrant primitives and nothing else: a
8
+ * continuation is a signed run contract, the moved state is a
9
+ * content-addressed envelope pinned by that contract, and the result is
10
+ * an offline-verifiable receipt.
11
+ */
12
+ export { defineHandoffConfig, Handoff, handoff } from "./handoff.js";
13
+ export type { ContinueOptions, HandoffConfig, HandoffInit, HandoffStreamEvent, HandoffSummary, HandoffTraceEvent, ModelDecision, ParallelOptions } from "./handoff.js";
14
+ export { HandoffRun } from "./run.js";
15
+ export type { WaitOptions, WaitOutcome } from "./run.js";
16
+ export { createCommandContext, executeGovernedCommand, toGovernedRunRecord } from "./run-executor.js";
17
+ export type { CommandHarnessConfig, GovernedCommandOptions, GovernedCommandResult, GovernedRunRecord } from "./run-executor.js";
18
+ export { targets } from "./targets.js";
19
+ export type { RuntimeTarget } from "./targets.js";
20
+ export { agents } from "./agents.js";
21
+ export { localFirst } from "./policy.js";
22
+ export type { ContinuationPolicy, LocalFirstOptions } from "./policy.js";
23
+ export { triggers } from "./triggers.js";
24
+ export type { FiredTrigger, Trigger } from "./triggers.js";
25
+ export { branch } from "./isolation.js";
26
+ export type { IsolationStrategy } from "./isolation.js";
27
+ export { reviewStrategies, scorecardFor } from "./review.js";
28
+ export type { ReviewedRun, ReviewResult, ReviewStrategy, Scorecard } from "./review.js";
29
+ export type { ToolCallObservation, ToolLike } from "./tools.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @fusionkit/handoff — the continuation-first SDK. Start work wherever it
3
+ * naturally begins, continue it on a governed runner when conditions
4
+ * change, preserve state across the boundary, and prove what moved, why
5
+ * it moved, who approved it, and how to resume.
6
+ *
7
+ * Everything here composes Warrant primitives and nothing else: a
8
+ * continuation is a signed run contract, the moved state is a
9
+ * content-addressed envelope pinned by that contract, and the result is
10
+ * an offline-verifiable receipt.
11
+ */
12
+ export { defineHandoffConfig, Handoff, handoff } from "./handoff.js";
13
+ export { HandoffRun } from "./run.js";
14
+ export { createCommandContext, executeGovernedCommand, toGovernedRunRecord } from "./run-executor.js";
15
+ export { targets } from "./targets.js";
16
+ export { agents } from "./agents.js";
17
+ export { localFirst } from "./policy.js";
18
+ export { triggers } from "./triggers.js";
19
+ export { branch } from "./isolation.js";
20
+ export { reviewStrategies, scorecardFor } from "./review.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Typed isolation strategies for pulling run output back into the local
3
+ * workspace. `auto` applies in place when the workspace is clean at the
4
+ * contract base ref and branches otherwise; `branch` always lands on a
5
+ * dedicated branch and never touches the working tree.
6
+ */
7
+ export type IsolationStrategy = {
8
+ kind: "isolation-strategy";
9
+ id: "auto" | "branch";
10
+ };
11
+ /** Always materialize results on a dedicated branch. */
12
+ export declare function branch(): IsolationStrategy;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Typed isolation strategies for pulling run output back into the local
3
+ * workspace. `auto` applies in place when the workspace is clean at the
4
+ * contract base ref and branches otherwise; `branch` always lands on a
5
+ * dedicated branch and never touches the working tree.
6
+ */
7
+ /** Always materialize results on a dedicated branch. */
8
+ export function branch() {
9
+ return { kind: "isolation-strategy", id: "branch" };
10
+ }