@playola-radio/slipstream 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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- package/schemas/slipstream.task.started.v1.json +72 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner + corpus helpers for the Swift-parse feasibility harness. Everything
|
|
3
|
+
* that must load the grammar goes through `runSwiftParseChild`, which spawns the
|
|
4
|
+
* isolated `--liftoff-only` host (src/swift-parse-host.ts), so the calling
|
|
5
|
+
* process (checker, test runner, acceptance) never loads Swift itself and can
|
|
6
|
+
* never be aborted by the OOM. The runner owns the child's deadline, abort
|
|
7
|
+
* wiring, and abnormal-exit reporting.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { siblingModuleUrl } from "./sibling-module.js";
|
|
11
|
+
import { readdir } from 'node:fs/promises';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { SWIFT_UNISOLATED_ENV } from "./swift-grammar.js";
|
|
15
|
+
import { emitProjectionPhase, emitProjectionTrace, traceProcessId } from "./projection-trace.js";
|
|
16
|
+
export const SWIFT_CORPUS_DIR = fileURLToPath(new URL('../contracts/swift-syntax/v1/', import.meta.url));
|
|
17
|
+
const HOST_PATH = fileURLToPath(siblingModuleUrl('swift-parse-host', import.meta.url));
|
|
18
|
+
const CASE_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
+
export class SwiftFixtureError extends Error {
|
|
20
|
+
}
|
|
21
|
+
export function swiftFixturePath(name, file = 'input.swift') {
|
|
22
|
+
if (!CASE_NAME.test(name))
|
|
23
|
+
throw new SwiftFixtureError(`invalid fixture name '${name}'`);
|
|
24
|
+
return join(SWIFT_CORPUS_DIR, name, file);
|
|
25
|
+
}
|
|
26
|
+
export async function listSwiftFixtures() {
|
|
27
|
+
const entries = await readdir(SWIFT_CORPUS_DIR, { withFileTypes: true });
|
|
28
|
+
return entries.filter((e) => e.isDirectory() && CASE_NAME.test(e.name)).map((e) => e.name).sort();
|
|
29
|
+
}
|
|
30
|
+
/** A child that exited abnormally (non-zero, killed by the deadline, or aborted).
|
|
31
|
+
* Carries the exit code/signal and captured stderr for an honest report. */
|
|
32
|
+
export class SwiftChildError extends Error {
|
|
33
|
+
detail;
|
|
34
|
+
constructor(message, detail) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'SwiftChildError';
|
|
37
|
+
this.detail = detail;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const DEFAULT_DEADLINE_MS = 30_000;
|
|
41
|
+
/** Spawn the isolated host, feed it `request` on stdin, and resolve with its one
|
|
42
|
+
* JSON result. Rejects with SwiftChildError on any abnormal exit. */
|
|
43
|
+
export async function runSwiftParseChild(request, opts = {}) {
|
|
44
|
+
return runSwiftHost([], JSON.stringify(request), opts);
|
|
45
|
+
}
|
|
46
|
+
/** Parse a named file in the isolated host. The checker passes only the path,
|
|
47
|
+
* so it never builds a second full source string or JSON copy in its process. */
|
|
48
|
+
export async function runSwiftParseFile(path, opts = {}) {
|
|
49
|
+
return runSwiftHost(['--parse-file', path], '', opts);
|
|
50
|
+
}
|
|
51
|
+
/** Forward stdin bytes directly to the isolated host. This keeps the checker's
|
|
52
|
+
* one input buffer out of JSON serialization and decoding. */
|
|
53
|
+
export async function runSwiftParseStdin(source, opts = {}) {
|
|
54
|
+
return runSwiftHost(['--parse-stdin'], source, opts);
|
|
55
|
+
}
|
|
56
|
+
async function runSwiftHost(hostArgs, input, opts) {
|
|
57
|
+
// An already-aborted signal must never launch work (the abort listener below
|
|
58
|
+
// only fires on FUTURE aborts).
|
|
59
|
+
if (opts.signal?.aborted) {
|
|
60
|
+
throw new SwiftChildError('swift-parse child aborted before start', { code: null, signal: null, stderr: '' });
|
|
61
|
+
}
|
|
62
|
+
const deadlineMs = opts.deadlineMs ?? DEFAULT_DEADLINE_MS;
|
|
63
|
+
const unisolated = opts.liftoffOnly === false;
|
|
64
|
+
const execArgv = unisolated ? [] : ['--liftoff-only'];
|
|
65
|
+
const env = unisolated ? { ...process.env, [SWIFT_UNISOLATED_ENV]: '1' } : process.env;
|
|
66
|
+
const startedAtNs = opts.trace ? process.hrtime.bigint() : undefined;
|
|
67
|
+
const child = spawn(process.execPath, [...execArgv, HOST_PATH, ...(opts.trace ? ['--fd5-trace-startup'] : []), ...hostArgs], { stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
68
|
+
const processId = opts.trace ? traceProcessId() : undefined;
|
|
69
|
+
if (processId !== undefined) {
|
|
70
|
+
let spawned = false;
|
|
71
|
+
child.once('spawn', () => {
|
|
72
|
+
spawned = true;
|
|
73
|
+
emitProjectionTrace(opts.trace, { kind: 'process-start', processId, process: 'swift-child',
|
|
74
|
+
...(opts.traceUnitId === undefined ? {} : { unitId: opts.traceUnitId }), atNs: process.hrtime.bigint() });
|
|
75
|
+
emitProjectionPhase(opts.trace, 'child-spawn', startedAtNs, { scope: 'swift', processId, unitId: opts.traceUnitId });
|
|
76
|
+
});
|
|
77
|
+
child.once('error', () => {
|
|
78
|
+
if (!spawned)
|
|
79
|
+
emitProjectionTrace(opts.trace, { kind: 'process-spawn-failed', processId,
|
|
80
|
+
...(opts.traceUnitId === undefined ? {} : { unitId: opts.traceUnitId }), atNs: process.hrtime.bigint() });
|
|
81
|
+
});
|
|
82
|
+
child.once('close', (code, signal) => {
|
|
83
|
+
if (spawned) {
|
|
84
|
+
emitProjectionPhase(opts.trace, 'child-lifecycle', startedAtNs, { scope: 'swift', processId, unitId: opts.traceUnitId });
|
|
85
|
+
emitProjectionTrace(opts.trace, { kind: 'process-exit', processId,
|
|
86
|
+
code, signal, atNs: process.hrtime.bigint() });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
let stdout = '';
|
|
91
|
+
let stderr = '';
|
|
92
|
+
let startupObserved = false;
|
|
93
|
+
child.stdout.setEncoding('utf8');
|
|
94
|
+
child.stderr.setEncoding('utf8');
|
|
95
|
+
child.stdout.on('data', (d) => {
|
|
96
|
+
stdout += d;
|
|
97
|
+
if (!startupObserved && processId !== undefined && stdout.includes('FD5_CHILD_READY\n')) {
|
|
98
|
+
startupObserved = true;
|
|
99
|
+
emitProjectionPhase(opts.trace, 'child-startup', startedAtNs, { scope: 'swift', processId, unitId: opts.traceUnitId });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
103
|
+
// Terminating the child while its stdin still holds buffered input emits EPIPE
|
|
104
|
+
// on the write side; swallow it so a cancellation never escapes the
|
|
105
|
+
// SwiftChildError contract as an uncaught error. The real cause is reported
|
|
106
|
+
// from the close/deadline path below.
|
|
107
|
+
child.stdin.on('error', () => { });
|
|
108
|
+
const timer = setTimeout(() => { killed = 'deadline'; child.kill('SIGKILL'); }, deadlineMs);
|
|
109
|
+
let killed = null;
|
|
110
|
+
const onAbort = () => { killed = 'abort'; child.kill('SIGKILL'); };
|
|
111
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
112
|
+
try {
|
|
113
|
+
child.stdin.end(input);
|
|
114
|
+
const [code, signal] = await new Promise((resolve, reject) => {
|
|
115
|
+
child.on('error', reject);
|
|
116
|
+
child.on('close', (c, s) => resolve([c, s]));
|
|
117
|
+
});
|
|
118
|
+
if (killed === 'deadline')
|
|
119
|
+
throw new SwiftChildError(`swift-parse child exceeded ${deadlineMs}ms deadline`, { code, signal, stderr });
|
|
120
|
+
if (killed === 'abort')
|
|
121
|
+
throw new SwiftChildError('swift-parse child aborted by signal', { code, signal, stderr });
|
|
122
|
+
if (code !== 0 || signal !== null) {
|
|
123
|
+
throw new SwiftChildError(`swift-parse child exited abnormally (code=${code}, signal=${signal})`, { code, signal, stderr });
|
|
124
|
+
}
|
|
125
|
+
const line = stdout.trimEnd().split('\n').at(-1) ?? '';
|
|
126
|
+
if (!line)
|
|
127
|
+
throw new SwiftChildError('swift-parse child produced no result line', { code, signal, stderr });
|
|
128
|
+
return JSON.parse(line);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-tree-sitter, given a JS string, reports node `startIndex`/`endIndex` in
|
|
3
|
+
* UTF-16 code units — not UTF-8 bytes. The interface contract (STAGE-T-PREREQS
|
|
4
|
+
* D10) requires whole-declaration spans as UTF-8-byte, half-open ranges, so
|
|
5
|
+
* every parser index must be converted. This module owns that conversion and
|
|
6
|
+
* nothing else: it never touches the WASM runtime, so it is verified by fast
|
|
7
|
+
* pure unit tests whose expected byte offsets are hand-derived from the UTF-8
|
|
8
|
+
* encoding rules, independent of the code here.
|
|
9
|
+
*/
|
|
10
|
+
/** Thrown when a UTF-16 index falls between the two units of a surrogate pair —
|
|
11
|
+
* i.e. inside a single astral code point, where there is no UTF-8 byte boundary.
|
|
12
|
+
* Parser node boundaries never land there; if one does, that is a defect we
|
|
13
|
+
* surface loudly rather than silently rounding to a neighbouring byte. */
|
|
14
|
+
export class SurrogateBoundaryError extends Error {
|
|
15
|
+
}
|
|
16
|
+
/** Build the conversion table in a single code-point pass. */
|
|
17
|
+
export function buildUtf16ToByteTable(source) {
|
|
18
|
+
const length = source.length; // UTF-16 code units
|
|
19
|
+
const bytes = new Uint32Array(length + 1);
|
|
20
|
+
const valid = new Uint8Array(length + 1).fill(1);
|
|
21
|
+
let byte = 0;
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < length) {
|
|
24
|
+
bytes[i] = byte;
|
|
25
|
+
const code = source.charCodeAt(i);
|
|
26
|
+
const low = i + 1 < length ? source.charCodeAt(i + 1) : 0;
|
|
27
|
+
if (code >= 0xd800 && code <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) {
|
|
28
|
+
// Astral code point: two UTF-16 units, four UTF-8 bytes. The boundary
|
|
29
|
+
// between the two units (index i+1) is inside the code point — no byte.
|
|
30
|
+
valid[i + 1] = 0;
|
|
31
|
+
bytes[i + 1] = byte;
|
|
32
|
+
byte += 4;
|
|
33
|
+
i += 2;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Single UTF-16 unit (BMP scalar, or a lone surrogate). A lone surrogate
|
|
37
|
+
// cannot be UTF-8-encoded; fatal UTF-8 decoding upstream rejects such
|
|
38
|
+
// input before it reaches here, so we size it as a 3-byte BMP scalar.
|
|
39
|
+
byte += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
bytes[length] = byte;
|
|
44
|
+
return { bytes, valid, length };
|
|
45
|
+
}
|
|
46
|
+
/** Convert one UTF-16 code-unit index to its UTF-8 byte offset. */
|
|
47
|
+
export function utf16IndexToByte(table, index) {
|
|
48
|
+
if (index < 0 || index > table.length) {
|
|
49
|
+
throw new RangeError(`UTF-16 index ${index} out of range [0, ${table.length}]`);
|
|
50
|
+
}
|
|
51
|
+
if (table.valid[index] === 0) {
|
|
52
|
+
throw new SurrogateBoundaryError(`UTF-16 index ${index} falls inside a surrogate pair`);
|
|
53
|
+
}
|
|
54
|
+
return table.bytes[index];
|
|
55
|
+
}
|
|
56
|
+
/** Convert a half-open UTF-16 range to a half-open UTF-8 byte range. */
|
|
57
|
+
export function utf16RangeToByteRange(table, startIndex, endIndex) {
|
|
58
|
+
return {
|
|
59
|
+
byteStart: utf16IndexToByte(table, startIndex),
|
|
60
|
+
byteEnd: utf16IndexToByte(table, endIndex),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* begin_task orchestration for the MCP forwarder.
|
|
3
|
+
*
|
|
4
|
+
* The forwarder is a control client that emits exactly one mutation — begin_task —
|
|
5
|
+
* and must never lie to the agent about whether it committed. Two rules from the
|
|
6
|
+
* locked design (Codex consult) drive this file:
|
|
7
|
+
*
|
|
8
|
+
* 1. Post-send ambiguity is real. The control client throws {@link
|
|
9
|
+
* OutcomeUnknownError} when a request reached the wire but its outcome is
|
|
10
|
+
* unknowable. Because `session.beginTask` idempotency is durable and anchored
|
|
11
|
+
* on `request_id` (the daemon rebuilds `committedTasks` from the log), we can
|
|
12
|
+
* safely auto-resend the BYTE-IDENTICAL payload EXACTLY once — within one
|
|
13
|
+
* capture session a same-UUID resend resolves idempotently, never double-
|
|
14
|
+
* committing. Only a begin_task with a nonempty request_id qualifies (see
|
|
15
|
+
* {@link shouldAutoResend}); a generic ambiguous request is never blindly
|
|
16
|
+
* retried.
|
|
17
|
+
*
|
|
18
|
+
* KNOWN LIMIT (at-least-once): the forwarder omits session_id (it never
|
|
19
|
+
* attaches, so it cannot know the capture UUID), so idempotency is scoped to
|
|
20
|
+
* the selected capture session. If the SAME identity triple is detached and
|
|
21
|
+
* reattached between the first send and the resend, the resend lands in a NEW
|
|
22
|
+
* capture session whose `committedTasks` does not yet hold this request_id,
|
|
23
|
+
* and the task is recorded a second time (in the new session). The success we
|
|
24
|
+
* then report is truthful — a task WAS declared — but the intent is recorded
|
|
25
|
+
* twice across the two sessions. This is the accepted cost of the locked
|
|
26
|
+
* at-least-once auto-resend; closing it needs store-level request_id dedup
|
|
27
|
+
* (P3 idempotency scope), which is a design decision, not a forwarder fix.
|
|
28
|
+
*
|
|
29
|
+
* 2. When the resend cannot definitively resolve the first send, we must not
|
|
30
|
+
* imply "nothing committed." A structurally-rejected resend (INVALID_TITLE /
|
|
31
|
+
* IDENTITY_UNRESOLVED) proves nothing durable happened, so it replaces the
|
|
32
|
+
* unknown honestly. A SESSION_NOT_SELECTED resend is reported as
|
|
33
|
+
* selection-unavailable with the prior commit status explicitly UNKNOWN
|
|
34
|
+
* (the daemon may have committed before it lost the session). Anything else
|
|
35
|
+
* still ambiguous — a second OutcomeUnknown, or a transient daemon/storage
|
|
36
|
+
* error — collapses to OUTCOME_UNKNOWN, which tells the agent NOT to
|
|
37
|
+
* re-declare (a fresh declaration would be a new task, never a retry).
|
|
38
|
+
*/
|
|
39
|
+
import { randomUUID } from 'node:crypto';
|
|
40
|
+
import { OutcomeUnknownError } from "./control-client.js";
|
|
41
|
+
import { isResolved } from "./harness-context.js";
|
|
42
|
+
/** Only a begin_task carrying a nonempty request_id is safe to auto-resend: the
|
|
43
|
+
* request_id is the durable idempotency key that makes an identical resend commit
|
|
44
|
+
* at most once. Any other verb, or a missing key, must never be blindly retried. */
|
|
45
|
+
export function shouldAutoResend(envelope) {
|
|
46
|
+
return envelope.verb === 'begin_task' && typeof envelope.request_id === 'string' && envelope.request_id.length > 0;
|
|
47
|
+
}
|
|
48
|
+
function nonEmptyString(v) {
|
|
49
|
+
return typeof v === 'string' && v.length > 0;
|
|
50
|
+
}
|
|
51
|
+
/** A control ack proves a durable commit only if it carries all four task-commit
|
|
52
|
+
* identifiers. The control-client envelope validator accepts any `ok:true` shape,
|
|
53
|
+
* so an ack missing (or mistyping) these fields is NOT a confirmed commit — it is
|
|
54
|
+
* post-send ambiguity, handled exactly like an {@link OutcomeUnknownError}. */
|
|
55
|
+
function isCommittedAck(res) {
|
|
56
|
+
return (res.ok === true &&
|
|
57
|
+
nonEmptyString(res.session_id) &&
|
|
58
|
+
nonEmptyString(res.task_id) &&
|
|
59
|
+
nonEmptyString(res.event_id) &&
|
|
60
|
+
nonEmptyString(res.seq));
|
|
61
|
+
}
|
|
62
|
+
function successResult(res) {
|
|
63
|
+
const structured = {
|
|
64
|
+
session_id: res.session_id,
|
|
65
|
+
task_id: res.task_id,
|
|
66
|
+
event_id: res.event_id,
|
|
67
|
+
seq: res.seq,
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
text: `Task declared. session=${String(res.session_id)} task=${String(res.task_id)} seq=${String(res.seq)}`,
|
|
71
|
+
isError: false,
|
|
72
|
+
structured,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function errorResult(code, text, requestId) {
|
|
76
|
+
const structured = { code };
|
|
77
|
+
if (requestId !== undefined)
|
|
78
|
+
structured.request_id = requestId;
|
|
79
|
+
return { text, isError: true, structured };
|
|
80
|
+
}
|
|
81
|
+
function outcomeUnknown(requestId) {
|
|
82
|
+
return errorResult('OUTCOME_UNKNOWN', `OUTCOME_UNKNOWN: the declaration reached the daemon but its outcome could not be confirmed for request_id ${requestId}; do not re-declare — a fresh declaration would create a second task.`, requestId);
|
|
83
|
+
}
|
|
84
|
+
/** Map the RESEND response while resolving an earlier OutcomeUnknown. */
|
|
85
|
+
function mapResendResponse(res, requestId) {
|
|
86
|
+
if (res.ok)
|
|
87
|
+
return isCommittedAck(res) ? successResult(res) : outcomeUnknown(requestId);
|
|
88
|
+
switch (res.code) {
|
|
89
|
+
case 'INVALID_TITLE':
|
|
90
|
+
case 'IDENTITY_UNRESOLVED':
|
|
91
|
+
// Structurally rejected — a durable commit was impossible either time, so
|
|
92
|
+
// this cleanly replaces the unknown.
|
|
93
|
+
return errorResult(res.code, `${res.code}: ${res.message}`, requestId);
|
|
94
|
+
case 'SESSION_NOT_SELECTED':
|
|
95
|
+
// The daemon may have committed on the first send before it lost selection;
|
|
96
|
+
// report selection-unavailable WITHOUT claiming "not committed".
|
|
97
|
+
return errorResult('SESSION_NOT_SELECTED', `SESSION_NOT_SELECTED: selection unavailable; prior commit status unknown for request_id ${requestId}`, requestId);
|
|
98
|
+
default:
|
|
99
|
+
// DAEMON_UNAVAILABLE / CAPTURE_NOT_READY / STORAGE_UNAVAILABLE / PROTOCOL:
|
|
100
|
+
// transient or ambiguous, and does not prove the first send failed to
|
|
101
|
+
// commit. Reporting the transient code would imply "safe to retry" — a lie.
|
|
102
|
+
return outcomeUnknown(requestId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function forwardBeginTask(deps) {
|
|
106
|
+
if (!isResolved(deps.identity)) {
|
|
107
|
+
// Fail closed before any round trip: we cannot ship a verified triple.
|
|
108
|
+
return errorResult('IDENTITY_UNRESOLVED', `IDENTITY_UNRESOLVED: ${deps.identity.unresolved}`);
|
|
109
|
+
}
|
|
110
|
+
const requestId = deps.requestId ?? randomUUID();
|
|
111
|
+
// The optional session_id guard is intentionally omitted: the forwarder never
|
|
112
|
+
// attaches and so does not know the capture UUID, and carrying one forward from
|
|
113
|
+
// a prior task would wrongly reject a legitimate later task. Selection rests on
|
|
114
|
+
// the identity triple, which the daemon compares atomically.
|
|
115
|
+
const envelope = {
|
|
116
|
+
v: 1,
|
|
117
|
+
verb: 'begin_task',
|
|
118
|
+
title: deps.title,
|
|
119
|
+
request_id: requestId,
|
|
120
|
+
harness: deps.identity.harness,
|
|
121
|
+
harness_session_id: deps.identity.harness_session_id,
|
|
122
|
+
worktree: deps.identity.worktree,
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
const res = await deps.send(envelope);
|
|
126
|
+
if (res.ok) {
|
|
127
|
+
// A well-formed commit ack is the only definitive success. A malformed
|
|
128
|
+
// `ok:true` proves nothing durable, so it falls through to the one resend
|
|
129
|
+
// exactly like a post-send OutcomeUnknown.
|
|
130
|
+
if (isCommittedAck(res))
|
|
131
|
+
return successResult(res);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
return errorResult(res.code, `${res.code}: ${res.message}`, requestId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (!(err instanceof OutcomeUnknownError))
|
|
139
|
+
throw err;
|
|
140
|
+
// fall through to the single byte-identical resend
|
|
141
|
+
}
|
|
142
|
+
if (!shouldAutoResend(envelope))
|
|
143
|
+
return outcomeUnknown(requestId);
|
|
144
|
+
try {
|
|
145
|
+
return mapResendResponse(await deps.send(envelope), requestId);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err instanceof OutcomeUnknownError)
|
|
149
|
+
return outcomeUnknown(requestId);
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { scopeFromPaths } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pure Claude Code transcript adapter.
|
|
5
|
+
*
|
|
6
|
+
* A Claude transcript is one JSON object per line. An `assistant` record carries
|
|
7
|
+
* `message.content[]` blocks; each `tool_use` block is one logical invocation
|
|
8
|
+
* whose native id is `tool_use.id` (`toolu_…`) — that id, namespaced by the
|
|
9
|
+
* harness session, is the evidence `record_id`. The tool RESULT arrives later as
|
|
10
|
+
* a `user` record with a `tool_result` block referencing `tool_use_id`; it
|
|
11
|
+
* carries no tool name or scope, so we remember the start's tool/scope and emit
|
|
12
|
+
* the end record under the SAME key/tool/scope (a time span for the invocation),
|
|
13
|
+
* never a provisional record we would later have to "correct".
|
|
14
|
+
*
|
|
15
|
+
* Tool taxonomy (A2 adapter knowledge): known write tools expose paths; known
|
|
16
|
+
* read-only tools emit nothing; everything else — Bash, unrecognized names —
|
|
17
|
+
* emits an explicit unknown scope (a possible writer we cannot map), because
|
|
18
|
+
* silently dropping an unknown effect would be dishonest.
|
|
19
|
+
*/
|
|
20
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
21
|
+
// Deliberately excludes `Task`: a subagent it launches can edit files, so a Task
|
|
22
|
+
// is a possible writer whose paths we cannot see — it must fall through to
|
|
23
|
+
// unknown scope, not be silenced as read-only.
|
|
24
|
+
const READONLY_TOOLS = new Set([
|
|
25
|
+
'Read',
|
|
26
|
+
'NotebookRead',
|
|
27
|
+
'Grep',
|
|
28
|
+
'Glob',
|
|
29
|
+
'LS',
|
|
30
|
+
'TodoWrite',
|
|
31
|
+
'WebFetch',
|
|
32
|
+
'WebSearch',
|
|
33
|
+
'BashOutput',
|
|
34
|
+
'KillShell',
|
|
35
|
+
]);
|
|
36
|
+
export function initialClaudeState() {
|
|
37
|
+
return { pending: new Map() };
|
|
38
|
+
}
|
|
39
|
+
function isObject(v) {
|
|
40
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
41
|
+
}
|
|
42
|
+
function parseMs(record) {
|
|
43
|
+
const ts = record.timestamp;
|
|
44
|
+
if (typeof ts !== 'string')
|
|
45
|
+
return undefined;
|
|
46
|
+
const ms = Date.parse(ts);
|
|
47
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
48
|
+
}
|
|
49
|
+
/** The write path a known write tool declared, or undefined if it is missing. */
|
|
50
|
+
function writePath(toolName, input) {
|
|
51
|
+
const field = toolName === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
52
|
+
const value = input[field];
|
|
53
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
function scopeForTool(toolName, input, ctx) {
|
|
56
|
+
if (READONLY_TOOLS.has(toolName))
|
|
57
|
+
return 'skip';
|
|
58
|
+
if (WRITE_TOOLS.has(toolName)) {
|
|
59
|
+
const path = isObject(input) ? writePath(toolName, input) : undefined;
|
|
60
|
+
if (path === undefined) {
|
|
61
|
+
return {
|
|
62
|
+
scope: { kind: 'unknown', reason: `${toolName} record declared no file path` },
|
|
63
|
+
diagnostics: [{ kind: 'malformed', detail: `${toolName} without a file path` }],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Claude's write tools declare absolute paths by design. A relative path can
|
|
67
|
+
// only be resolved against the recorded cwd, which for a session's early
|
|
68
|
+
// records may still be provisional (bound to the root while the transcript was
|
|
69
|
+
// empty) or an alias of the root — resolving against it risks crediting the
|
|
70
|
+
// wrong file. Rather than guess, disclose it as an unmapped possible writer.
|
|
71
|
+
if (!isAbsolute(path)) {
|
|
72
|
+
return {
|
|
73
|
+
scope: { kind: 'unknown', reason: `${toolName} declared a non-absolute path` },
|
|
74
|
+
diagnostics: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const scope = scopeFromPaths([path], ctx);
|
|
78
|
+
// A write outside the capture root is out of scope for this session.
|
|
79
|
+
if (!scope)
|
|
80
|
+
return 'skip';
|
|
81
|
+
return { scope, diagnostics: [] };
|
|
82
|
+
}
|
|
83
|
+
// Bash or an unrecognized tool: a possible writer we cannot scope.
|
|
84
|
+
return {
|
|
85
|
+
scope: { kind: 'unknown', reason: `unmapped tool ${toolName}` },
|
|
86
|
+
diagnostics: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function makeEvidence(ctx, harnessSessionId, recordId, toolName, scope, atMs, basis) {
|
|
90
|
+
return {
|
|
91
|
+
evidence_key: {
|
|
92
|
+
harness: ctx.harness,
|
|
93
|
+
harness_session_id: harnessSessionId,
|
|
94
|
+
record_id: recordId,
|
|
95
|
+
},
|
|
96
|
+
adapter_version: ctx.adapterVersion,
|
|
97
|
+
tool_name: toolName,
|
|
98
|
+
timestamp: { at_ms: atMs, basis },
|
|
99
|
+
file_scope: scope,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function stepAssistant(state, record, ctx) {
|
|
103
|
+
const evidence = [];
|
|
104
|
+
const diagnostics = [];
|
|
105
|
+
const message = record.message;
|
|
106
|
+
const content = isObject(message) ? message.content : undefined;
|
|
107
|
+
if (!Array.isArray(content)) {
|
|
108
|
+
// Every Claude assistant record carries a `message.content` array of blocks.
|
|
109
|
+
// Anything else — a string, a null/absent message, a missing content — is a
|
|
110
|
+
// malformed envelope, not a clean read: disclose it so coverage degrades and
|
|
111
|
+
// stays distinct from a successfully-read record with no tool evidence. (User
|
|
112
|
+
// records, whose content is legitimately a string, are handled separately.)
|
|
113
|
+
diagnostics.push({ kind: 'malformed', detail: 'assistant record has no content block array' });
|
|
114
|
+
return { state, evidence, diagnostics };
|
|
115
|
+
}
|
|
116
|
+
const atMs = parseMs(record);
|
|
117
|
+
// The harness session is the record's own `sessionId` (the native session), so
|
|
118
|
+
// a copied/renamed transcript keeps one identity instead of splitting into
|
|
119
|
+
// filename-derived candidates; the bound file name is only a fallback.
|
|
120
|
+
const rawSession = record.sessionId;
|
|
121
|
+
const sessionId = typeof rawSession === 'string' && rawSession.length > 0 ? rawSession : ctx.harnessSessionId;
|
|
122
|
+
for (const block of content) {
|
|
123
|
+
if (!isObject(block) || block.type !== 'tool_use')
|
|
124
|
+
continue;
|
|
125
|
+
const id = block.id;
|
|
126
|
+
const toolName = block.name;
|
|
127
|
+
// A missing/empty invocation id is not a stable identity we can key: report it
|
|
128
|
+
// unsupported (never a fabricated or empty key that would collide).
|
|
129
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
130
|
+
diagnostics.push({ kind: 'unsupported', detail: 'tool_use without a stable id' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (typeof toolName !== 'string' || toolName.length === 0) {
|
|
134
|
+
diagnostics.push({ kind: 'malformed', detail: `tool_use ${id} without a tool name` });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const resolved = scopeForTool(toolName, block.input, ctx);
|
|
138
|
+
if (resolved === 'skip')
|
|
139
|
+
continue;
|
|
140
|
+
diagnostics.push(...resolved.diagnostics);
|
|
141
|
+
if (atMs === undefined) {
|
|
142
|
+
diagnostics.push({ kind: 'malformed', detail: `tool_use ${id} has no usable timestamp` });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
state.pending.set(id, { sessionId, toolName, scope: resolved.scope });
|
|
146
|
+
evidence.push(makeEvidence(ctx, sessionId, id, toolName, resolved.scope, atMs, 'tool-start'));
|
|
147
|
+
}
|
|
148
|
+
return { state, evidence, diagnostics };
|
|
149
|
+
}
|
|
150
|
+
function stepUser(state, record, ctx) {
|
|
151
|
+
const evidence = [];
|
|
152
|
+
const diagnostics = [];
|
|
153
|
+
const message = record.message;
|
|
154
|
+
if (!isObject(message)) {
|
|
155
|
+
diagnostics.push({ kind: 'malformed', detail: 'user record has no message object' });
|
|
156
|
+
return { state, evidence, diagnostics };
|
|
157
|
+
}
|
|
158
|
+
const content = message.content;
|
|
159
|
+
// A user record legitimately carries plain string content (typed text) with no
|
|
160
|
+
// tool results to fold; that is a clean read, not a malformed one.
|
|
161
|
+
if (typeof content === 'string')
|
|
162
|
+
return { state, evidence, diagnostics };
|
|
163
|
+
if (!Array.isArray(content)) {
|
|
164
|
+
diagnostics.push({ kind: 'malformed', detail: 'user record content is neither text nor a block array' });
|
|
165
|
+
return { state, evidence, diagnostics };
|
|
166
|
+
}
|
|
167
|
+
const atMs = parseMs(record);
|
|
168
|
+
for (const block of content) {
|
|
169
|
+
if (!isObject(block) || block.type !== 'tool_result')
|
|
170
|
+
continue;
|
|
171
|
+
const id = block.tool_use_id;
|
|
172
|
+
if (typeof id !== 'string')
|
|
173
|
+
continue;
|
|
174
|
+
const memo = state.pending.get(id);
|
|
175
|
+
// An orphan result (start never seen) carries no tool/scope to emit honestly;
|
|
176
|
+
// the start, if it is ever read, records the invocation on its own.
|
|
177
|
+
if (!memo)
|
|
178
|
+
continue;
|
|
179
|
+
// A matched result with an unusable timestamp is a malformed record, not a clean
|
|
180
|
+
// read: disclose it (as the start side does) rather than silently drop the endpoint.
|
|
181
|
+
if (atMs === undefined) {
|
|
182
|
+
diagnostics.push({ kind: 'malformed', detail: `tool_result ${id} has no usable timestamp` });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
evidence.push(makeEvidence(ctx, memo.sessionId, id, memo.toolName, memo.scope, atMs, 'tool-end'));
|
|
186
|
+
}
|
|
187
|
+
return { state, evidence, diagnostics };
|
|
188
|
+
}
|
|
189
|
+
/** Fold one parsed Claude transcript record into the running adapter state. */
|
|
190
|
+
export function claudeStep(state, record, ctx) {
|
|
191
|
+
if (!isObject(record)) {
|
|
192
|
+
return { state, evidence: [], diagnostics: [{ kind: 'malformed', detail: 'record is not an object' }] };
|
|
193
|
+
}
|
|
194
|
+
if (record.type === 'assistant')
|
|
195
|
+
return stepAssistant(state, record, ctx);
|
|
196
|
+
if (record.type === 'user')
|
|
197
|
+
return stepUser(state, record, ctx);
|
|
198
|
+
return { state, evidence: [], diagnostics: [] };
|
|
199
|
+
}
|