@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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. 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
+ }