@tea-agent/loop-agent 0.23.1 → 0.24.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 (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Contract-apply Human Gate receipts with atomic prepared → dispatching →
3
+ * dispatched consumption so concurrent confirm POSTs cannot create duplicate
4
+ * operations.
5
+ */
6
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ export class ContractApplyReceiptStore {
9
+ dir;
10
+ constructor(appData) {
11
+ this.dir = path.join(appData.root, "contract-apply-receipts");
12
+ }
13
+ file(id) {
14
+ return path.join(this.dir, `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
15
+ }
16
+ async prepare(input) {
17
+ const receipt = {
18
+ schemaVersion: 1,
19
+ state: "prepared",
20
+ ...input,
21
+ };
22
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
23
+ await writeFile(this.file(receipt.receiptId), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
24
+ return receipt;
25
+ }
26
+ async get(id) {
27
+ try {
28
+ return JSON.parse(await readFile(this.file(id), "utf8"));
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ENOENT")
32
+ return undefined;
33
+ throw error;
34
+ }
35
+ }
36
+ async tryReserve(id) {
37
+ const receipt = await this.get(id);
38
+ if (!receipt) {
39
+ return {
40
+ ok: false,
41
+ code: "NOT_FOUND",
42
+ message: `receipt not found: ${id}`,
43
+ };
44
+ }
45
+ if (Date.parse(receipt.expiresAt) <= Date.now()) {
46
+ return {
47
+ ok: false,
48
+ code: "EXPIRED",
49
+ message: "contract apply receipt expired",
50
+ receipt,
51
+ };
52
+ }
53
+ if (receipt.state === "dispatching") {
54
+ return {
55
+ ok: true,
56
+ receipt,
57
+ reserved: false,
58
+ reason: "already-dispatching",
59
+ };
60
+ }
61
+ if (receipt.state === "dispatched") {
62
+ return {
63
+ ok: true,
64
+ receipt,
65
+ reserved: false,
66
+ reason: "already-dispatched",
67
+ };
68
+ }
69
+ if (receipt.state !== "prepared") {
70
+ return {
71
+ ok: false,
72
+ code: "INVALID_STATE",
73
+ message: `receipt is ${receipt.state}`,
74
+ receipt,
75
+ };
76
+ }
77
+ const next = {
78
+ ...receipt,
79
+ state: "dispatching",
80
+ reservedAt: new Date().toISOString(),
81
+ };
82
+ const claimed = await this.compareAndSwap(id, "prepared", next);
83
+ if (!claimed.ok) {
84
+ const current = claimed.current;
85
+ if (current?.state === "dispatching") {
86
+ return {
87
+ ok: true,
88
+ receipt: current,
89
+ reserved: false,
90
+ reason: "already-dispatching",
91
+ };
92
+ }
93
+ if (current?.state === "dispatched") {
94
+ return {
95
+ ok: true,
96
+ receipt: current,
97
+ reserved: false,
98
+ reason: "already-dispatched",
99
+ };
100
+ }
101
+ return {
102
+ ok: false,
103
+ code: "INVALID_STATE",
104
+ message: `receipt raced into ${current?.state ?? "missing"}`,
105
+ receipt: current,
106
+ };
107
+ }
108
+ return { ok: true, receipt: next, reserved: true };
109
+ }
110
+ async markDispatched(id, operationId) {
111
+ const receipt = await this.get(id);
112
+ if (!receipt)
113
+ throw new Error(`receipt not found: ${id}`);
114
+ const next = {
115
+ ...receipt,
116
+ state: "dispatched",
117
+ ...(operationId ? { operationId } : {}),
118
+ dispatchedAt: new Date().toISOString(),
119
+ };
120
+ await writeFile(this.file(id), `${JSON.stringify(next, null, 2)}\n`, {
121
+ mode: 0o600,
122
+ });
123
+ return next;
124
+ }
125
+ async releaseToPrepared(id) {
126
+ const receipt = await this.get(id);
127
+ if (!receipt || receipt.state !== "dispatching")
128
+ return receipt;
129
+ const next = {
130
+ ...receipt,
131
+ state: "prepared",
132
+ reservedAt: undefined,
133
+ };
134
+ await writeFile(this.file(id), `${JSON.stringify(next, null, 2)}\n`, {
135
+ mode: 0o600,
136
+ });
137
+ return next;
138
+ }
139
+ async compareAndSwap(id, expectedState, next) {
140
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
141
+ const target = this.file(id);
142
+ const lockPath = `${target}.lock`;
143
+ try {
144
+ await writeFile(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
145
+ }
146
+ catch (error) {
147
+ if (error.code === "EEXIST") {
148
+ await new Promise((r) => setTimeout(r, 25));
149
+ return { ok: false, current: await this.get(id) };
150
+ }
151
+ throw error;
152
+ }
153
+ try {
154
+ const current = await this.get(id);
155
+ if (!current || current.state !== expectedState) {
156
+ return { ok: false, current };
157
+ }
158
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
159
+ await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, {
160
+ mode: 0o600,
161
+ });
162
+ await rename(tmp, target);
163
+ return { ok: true };
164
+ }
165
+ finally {
166
+ try {
167
+ await unlink(lockPath);
168
+ }
169
+ catch {
170
+ // lock cleanup is best-effort
171
+ }
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Operator Chat — safe read-only explore tools (roadmap M0-A / G16).
3
+ *
4
+ * Replaces the bare SDK built-in bash/read/grep with a typed, boundary-
5
+ * enforced read-only surface. The model can still probe the repo, but:
6
+ *
7
+ * - No shell escapes (no `echo >`, `tee`, `sed -i`, `node -e`, pipes to
8
+ * shell, redirects, subshells). The write-via-redirect residual is gone.
9
+ * - read/grep enforce a repo-relative path boundary (no `..`, no absolute
10
+ * paths outside repo, no symlink escape) and a sensitive-file denylist
11
+ * (env files, key/pem, .git internals, Pi auth.json, sessions dir,
12
+ * harness dag-run session facts).
13
+ * - All returned content is run through secret scrubbing so API keys, OAuth
14
+ * tokens and private keys never reach the model context (prompt injection
15
+ * / credential leak defense — roadmap §3.2 read-face gap, G16).
16
+ * - gitStatus / gitDiff are typed read-only git probes (no `git` shell).
17
+ *
18
+ * These are registered as Pi customTools alongside the operator-action tools.
19
+ * They are NOT operator actions (not in the capabilities registry) — they are
20
+ * the Chat's built-in explore surface, parallel to find/ls.
21
+ */
22
+ import path from "node:path";
23
+ import { open, readFile, realpath } from "node:fs/promises";
24
+ import { createHash } from "node:crypto";
25
+ /** Repo-relative path patterns that are ALWAYS denied to read/grep (G16). */
26
+ export const SENSITIVE_PATH_PATTERNS = [
27
+ // Environment / secrets
28
+ /(^|\/)\.env(\.|$)/i,
29
+ /(^|\/)\.env\.[a-z0-9_-]+$/i,
30
+ /\.key$/i,
31
+ /\.pem$/i,
32
+ /\.pfx$/i,
33
+ /\.keystore$/i,
34
+ // Git internals
35
+ /(^|\/)\.git\//i,
36
+ // Pi credential / session store (user-level + project-level + console app-data)
37
+ /(^|\/)auth\.json$/i,
38
+ /(^|\/)sessions\//i,
39
+ // DAG run-owned session facts (runtime internals, not for model eyes)
40
+ /(^|\/)\.harness\/dag-runs\/.*\/session\b/i,
41
+ // Generic credential filenames
42
+ /(^|\/)(credentials|secrets)\.json$/i,
43
+ /(^|\/)id_rsa/i,
44
+ /(^|\/)id_ecdsa/i,
45
+ /(^|\/)id_ed25519/i,
46
+ ];
47
+ /** Secret-like patterns scrubbed from any tool result content (G16). */
48
+ export const SECRET_SCRUB_PATTERNS = [
49
+ // AWS access key id
50
+ { name: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
51
+ // AWS secret access key (40 char base64-ish after the key id context)
52
+ {
53
+ name: "aws-secret",
54
+ re: /\b(?:aws_secret_access_key|secret_access_key)["'\s:=]+([A-Za-z0-9/+=]{40})\b/gi,
55
+ },
56
+ // Google API key / OAuth
57
+ { name: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
58
+ // Generic API key assignments (header style)
59
+ {
60
+ name: "api-key-assign",
61
+ re: /\b(?:api[_-]?key|apikey|x-api-key)["'\s:=]+([A-Za-z0-9_-]{20,})/gi,
62
+ },
63
+ // Bearer tokens
64
+ { name: "bearer-token", re: /\bBearer\s+[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
65
+ // GitHub tokens
66
+ { name: "github-token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22}_[A-Za-z0-9]{59})\b/g },
67
+ // Slack tokens
68
+ { name: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]+\b/g },
69
+ // Private key blocks (PEM)
70
+ {
71
+ name: "private-key-block",
72
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/g,
73
+ },
74
+ ];
75
+ /** Throw a typed explore error (kept as a value object so callers can read .code). */
76
+ export class ExploreDeniedError extends Error {
77
+ code;
78
+ ok = false;
79
+ constructor(code, message) {
80
+ super(message);
81
+ this.code = code;
82
+ this.name = "ExploreDeniedError";
83
+ }
84
+ }
85
+ /**
86
+ * Replace secret-like substrings with a redaction marker. Returns the scrubbed
87
+ * text plus a tally (no secret values). Used by safe-read / safe-grep before
88
+ * any content reaches the model context.
89
+ */
90
+ export function scrubSecrets(input) {
91
+ let out = input;
92
+ const redactions = [];
93
+ for (const { name, re } of SECRET_SCRUB_PATTERNS) {
94
+ re.lastIndex = 0;
95
+ const matches = out.match(re);
96
+ if (matches && matches.length > 0) {
97
+ out = out.replace(re, `[REDACTED:${name}]`);
98
+ redactions.push({ name, count: matches.length });
99
+ }
100
+ }
101
+ return { scrubbed: out, redactions };
102
+ }
103
+ /** True if a repo-relative path matches a sensitive-file denylist pattern. */
104
+ export function isSensitivePath(repoRelative) {
105
+ const normalized = repoRelative.replace(/\\/g, "/").replace(/^\.\/+/, "");
106
+ for (const re of SENSITIVE_PATH_PATTERNS) {
107
+ if (re.test(normalized))
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ /**
113
+ * Resolve a model-supplied path against the repo root and enforce:
114
+ * - repo-relative only (no absolute paths, no `..` escape, no symlink escape
115
+ * outside repo via realpath),
116
+ * - sensitive-file denylist.
117
+ */
118
+ export async function guardReadPath(repoRoot, rawPath) {
119
+ if (!rawPath || typeof rawPath !== "string") {
120
+ return {
121
+ ok: false,
122
+ code: "PATH_OUTSIDE_REPO",
123
+ message: "path is required",
124
+ };
125
+ }
126
+ // Reject absolute paths (Windows drive letters too) — model must use repo-relative.
127
+ if (path.isAbsolute(rawPath)) {
128
+ return {
129
+ ok: false,
130
+ code: "PATH_OUTSIDE_REPO",
131
+ message: `absolute paths are not allowed; use a repo-relative path (got ${rawPath})`,
132
+ };
133
+ }
134
+ // Reject `..` segments outright (defense before realpath).
135
+ if (rawPath.split(/[\\/]/).some((seg) => seg === "..")) {
136
+ return {
137
+ ok: false,
138
+ code: "PATH_OUTSIDE_REPO",
139
+ message: `path traversal (..) is not allowed: ${rawPath}`,
140
+ };
141
+ }
142
+ const normalizedRepoRelative = rawPath.replace(/\\/g, "/").replace(/^\.\/+/, "");
143
+ if (isSensitivePath(normalizedRepoRelative)) {
144
+ return {
145
+ ok: false,
146
+ code: "SENSITIVE_PATH_DENIED",
147
+ message: `path "${normalizedRepoRelative}" matches a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/** / dag-runs session); reading is denied to protect credentials`,
148
+ };
149
+ }
150
+ const abs = path.resolve(repoRoot, normalizedRepoRelative);
151
+ // Lexical containment rejects traversal before touching the filesystem.
152
+ const rel = path.relative(repoRoot, abs);
153
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
154
+ return {
155
+ ok: false,
156
+ code: "PATH_OUTSIDE_REPO",
157
+ message: `resolved path escapes repo root: ${rawPath}`,
158
+ };
159
+ }
160
+ // path.resolve() alone does not stop `repo/link -> /private/file`: readFile
161
+ // follows the link and would expose the target outside the repo. Resolve both
162
+ // ends when the candidate exists, then repeat the containment and denylist
163
+ // checks against its canonical location. A nonexistent candidate remains
164
+ // lexically valid so callers such as git-diff can still ask Git about it;
165
+ // safeReadFile will subsequently report its normal read failure.
166
+ try {
167
+ const [realRepoRoot, realCandidate] = await Promise.all([
168
+ realpath(repoRoot),
169
+ realpath(abs),
170
+ ]);
171
+ const realRelative = path.relative(realRepoRoot, realCandidate);
172
+ if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
173
+ return {
174
+ ok: false,
175
+ code: "PATH_OUTSIDE_REPO",
176
+ message: `symlink target escapes repo root: ${rawPath}`,
177
+ };
178
+ }
179
+ if (isSensitivePath(realRelative.replace(/\\/g, "/"))) {
180
+ return {
181
+ ok: false,
182
+ code: "SENSITIVE_PATH_DENIED",
183
+ message: `symlink target is a sensitive path: ${rawPath}`,
184
+ };
185
+ }
186
+ return { ok: true, abs, repoRelative: normalizedRepoRelative };
187
+ }
188
+ catch {
189
+ return { ok: true, abs, repoRelative: normalizedRepoRelative };
190
+ }
191
+ }
192
+ /** Cap read size so the model context cannot be flooded (G12). */
193
+ export const READ_BYTE_LIMIT = 256 * 1024; // 256 KiB
194
+ /**
195
+ * Read a repo-relative file with full M0-A guards. Throws a typed error
196
+ * object (code/message) on denial so the customTool wrapper can surface it.
197
+ */
198
+ export async function safeReadFile(repoRoot, rawPath) {
199
+ const guard = await guardReadPath(repoRoot, rawPath);
200
+ if (!guard.ok) {
201
+ throw new ExploreDeniedError(guard.code, guard.message);
202
+ }
203
+ // Do not call readFile() here: it allocates the entire file before the
204
+ // truncation check, so an oversized repo file could exhaust the Console
205
+ // process even though the response is bounded. Read at most the public cap.
206
+ const handle = await open(guard.abs, "r");
207
+ try {
208
+ const info = await handle.stat();
209
+ if (!info.isFile()) {
210
+ throw new ExploreDeniedError("READ_FAILED", `not a regular file: ${guard.repoRelative}`);
211
+ }
212
+ const bytes = info.size;
213
+ const bytesToRead = Math.min(bytes, READ_BYTE_LIMIT);
214
+ const buffer = Buffer.alloc(bytesToRead);
215
+ const bytesRead = bytesToRead > 0
216
+ ? (await handle.read(buffer, 0, bytesToRead, 0)).bytesRead
217
+ : 0;
218
+ const { scrubbed, redactions } = scrubSecrets(buffer.subarray(0, bytesRead).toString("utf8"));
219
+ return {
220
+ path: guard.repoRelative,
221
+ bytes,
222
+ truncated: bytes > READ_BYTE_LIMIT,
223
+ redactions,
224
+ content: scrubbed,
225
+ };
226
+ }
227
+ finally {
228
+ await handle.close();
229
+ }
230
+ }
231
+ export const GREP_MATCH_LIMIT = 100;
232
+ /**
233
+ * Repo-bounded, secret-scrubbed line search. The pattern is a plain string or
234
+ * regex source (no shell, no flags beyond `i`). Scans files under repoRoot,
235
+ * skipping the sensitive denylist and binary-looking files.
236
+ */
237
+ export async function safeGrep(repoRoot, pattern, options) {
238
+ if (!pattern) {
239
+ throw new ExploreDeniedError("INVALID_INPUT", "pattern is required");
240
+ }
241
+ let re;
242
+ try {
243
+ re = new RegExp(pattern, options?.caseInsensitive ? "i" : undefined);
244
+ }
245
+ catch (e) {
246
+ throw new ExploreDeniedError("INVALID_INPUT", `invalid regex pattern: ${e instanceof Error ? e.message : String(e)}`);
247
+ }
248
+ // Dynamic import to keep the explore module free of a hard chokidar dep at
249
+ // module load (rg is not guaranteed cross-platform; use node fs walk).
250
+ const { walkRepoFiles } = await import("./repo-walk.js");
251
+ const files = await walkRepoFiles(repoRoot, {
252
+ glob: options?.glob,
253
+ skipSensitive: true,
254
+ });
255
+ const matches = [];
256
+ let totalRedactions = [];
257
+ for (const file of files) {
258
+ if (matches.length >= GREP_MATCH_LIMIT)
259
+ break;
260
+ try {
261
+ const buf = await readFile(file.abs);
262
+ if (buf.byteLength > READ_BYTE_LIMIT)
263
+ continue;
264
+ const text = buf.toString("utf8");
265
+ const lines = text.split(/\r?\n/);
266
+ for (let i = 0; i < lines.length; i++) {
267
+ if (matches.length >= GREP_MATCH_LIMIT)
268
+ break;
269
+ if (re.test(lines[i])) {
270
+ const { scrubbed, redactions } = scrubSecrets(lines[i]);
271
+ if (redactions.length > 0) {
272
+ for (const r of redactions) {
273
+ const found = totalRedactions.find((t) => t.name === r.name);
274
+ if (found)
275
+ found.count += r.count;
276
+ else
277
+ totalRedactions.push({ ...r });
278
+ }
279
+ }
280
+ matches.push({ path: file.repoRelative, line: i + 1, text: scrubbed });
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ // skip unreadable / binary files
286
+ }
287
+ }
288
+ return {
289
+ pattern,
290
+ matchCount: matches.length,
291
+ truncated: matches.length >= GREP_MATCH_LIMIT,
292
+ redactions: totalRedactions,
293
+ matches,
294
+ };
295
+ }
296
+ /** A stable, denoised fingerprint of the repo root (for hashing). */
297
+ export function repoRootKey(repoRoot) {
298
+ return createHash("sha256").update(path.resolve(repoRoot)).digest("hex").slice(0, 16);
299
+ }
@@ -0,0 +1,37 @@
1
+ export function makeHumanGateCard(input) {
2
+ const canonical = JSON.stringify({
3
+ cardId: input.cardId,
4
+ gateType: input.gateType,
5
+ state: input.state,
6
+ taskId: input.taskId,
7
+ confirmationId: input.confirmationId,
8
+ receiptId: input.receiptId,
9
+ contractRevision: input.contractRevision,
10
+ dagSpine: input.dagSpine,
11
+ });
12
+ let hash = 2166136261;
13
+ for (let i = 0; i < canonical.length; i += 1)
14
+ hash = Math.imul(hash ^ canonical.charCodeAt(i), 16777619);
15
+ const previewHash = `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}`;
16
+ return { schemaVersion: 1, ...input, previewHash };
17
+ }
18
+ export function humanGateCardFromEventPayload(value) {
19
+ if (!value || typeof value !== "object")
20
+ return undefined;
21
+ const card = value;
22
+ if (card.schemaVersion !== 1 || typeof card.cardId !== "string" ||
23
+ (card.gateType !== "contract-apply" && card.gateType !== "dag-run") ||
24
+ typeof card.taskId !== "string" || typeof card.state !== "string" ||
25
+ typeof card.expiresAt !== "string" || typeof card.inspectHref !== "string" ||
26
+ typeof card.previewHash !== "string")
27
+ return undefined;
28
+ return card;
29
+ }
30
+ export function mergeHumanGateCardState(cards, next) {
31
+ const index = cards.findIndex((card) => card.cardId === next.cardId);
32
+ if (index < 0)
33
+ return [...cards, next];
34
+ const copy = [...cards];
35
+ copy[index] = next;
36
+ return copy;
37
+ }
@@ -0,0 +1,136 @@
1
+ import { AssessmentStore, isAssessmentFreshForDraft } from "../interview/assessment.js";
2
+ import { applyGrillMeAnswer, emptyDraft, isDraftStructurallyComplete, listDraftGaps, nextGrillMeQuestion } from "../interview/grill-me.js";
3
+ import { InterviewSessionStore } from "../interview/session.js";
4
+ import { DraftStore } from "../draft-store.js";
5
+ import { normalizeConsoleWorkflowKind } from "../workflow-kinds.js";
6
+ import { scrubSecrets } from "./explore-tools.js";
7
+ function redact(value) {
8
+ const raw = JSON.stringify(value);
9
+ return JSON.parse(scrubSecrets(raw).scrubbed);
10
+ }
11
+ /** Browser/model-safe projection: strips app-data paths and scrubs secret-shaped content. */
12
+ export function projectChatInterviewState(state) {
13
+ return redact({
14
+ interview: state.interview,
15
+ draft: { draft: state.draft.draft, draftSha256: state.draft.draftSha256 },
16
+ ...(state.question ? { question: state.question } : {}),
17
+ ...(state.taskKindRecommendation ? { taskKindRecommendation: state.taskKindRecommendation } : {}),
18
+ assessment: state.assessment,
19
+ });
20
+ }
21
+ export class ChatInterviewAdapter {
22
+ appData;
23
+ events;
24
+ interviews;
25
+ drafts;
26
+ assessments;
27
+ constructor(appData, events) {
28
+ this.appData = appData;
29
+ this.events = events;
30
+ this.interviews = new InterviewSessionStore(appData);
31
+ this.drafts = new DraftStore(appData);
32
+ this.assessments = new AssessmentStore(appData);
33
+ }
34
+ append(operatorSessionId, turnId, kind, data) {
35
+ this.events.append(operatorSessionId, turnId, { kind, data: redact(data) });
36
+ }
37
+ assessment(draft, assessmentId, fresh = false, reason) {
38
+ const missingFields = listDraftGaps(draft.draft);
39
+ return { outcome: missingFields.length === 0 ? "complete" : "questions-required", fresh, missingFields, ...(reason ? { reason } : {}), ...(assessmentId ? { assessmentId } : {}), draftSha256: draft.draftSha256 };
40
+ }
41
+ async start(input) {
42
+ const interview = await this.interviews.create({ taskId: input.taskId, operatorSessionId: input.operatorSessionId });
43
+ const base = emptyDraft(input.taskId, input.title);
44
+ const draft = await this.drafts.save({ ...base, ...input.initialDraft, requirement: { ...base.requirement, ...input.initialDraft?.requirement }, constraints: { ...base.constraints, ...input.initialDraft?.constraints }, verification: { ...base.verification, ...input.initialDraft?.verification }, schemaVersion: 1, taskId: input.taskId });
45
+ const question = nextGrillMeQuestion(draft.draft);
46
+ const savedInterview = await this.interviews.setCurrentQuestion(interview.sessionId, question ? { id: question.id, text: question.text, affectsFields: question.affectsFields, risk: question.risk } : undefined, question ? "awaiting-user-answer" : "draft-ready");
47
+ this.append(input.operatorSessionId, interview.sessionId, "interview-turn", { interviewSessionId: interview.sessionId, taskId: input.taskId, state: savedInterview.state, ...(question ? { question } : {}) });
48
+ this.append(input.operatorSessionId, interview.sessionId, "draft", { taskId: input.taskId, draft: draft.draft, draftSha256: draft.draftSha256 });
49
+ const assessment = this.assessment(draft);
50
+ this.append(input.operatorSessionId, interview.sessionId, "assessment", { taskId: input.taskId, ...assessment });
51
+ return { interview: savedInterview, draft, question, assessment };
52
+ }
53
+ async answer(input) {
54
+ const interview = await this.interviews.get(input.interviewSessionId);
55
+ if (!interview || interview.operatorSessionId !== input.operatorSessionId)
56
+ throw new Error("interview session not found for Chat session");
57
+ if (!interview.currentQuestion || interview.currentQuestion.id !== input.questionId)
58
+ throw new Error("answer does not match current open question");
59
+ const current = await this.drafts.get(interview.taskId);
60
+ if (!current)
61
+ throw new Error(`draft not found: ${interview.taskId}`);
62
+ const question = nextGrillMeQuestion(current.draft);
63
+ if (!question || question.id !== input.questionId)
64
+ throw new Error("draft gap does not match current question");
65
+ const applied = applyGrillMeAnswer(current.draft, question, { response: input.response, text: input.text });
66
+ const isTaskKind = question.gapId === "taskKind";
67
+ const recommendation = isTaskKind ? applied.taskKind : undefined;
68
+ if (isTaskKind)
69
+ delete applied.taskKind;
70
+ const draft = await this.drafts.save(applied);
71
+ await this.interviews.recordAnswer(interview.sessionId, { questionId: input.questionId, response: input.response, ...(input.text ? { text: input.text } : {}), at: new Date().toISOString() });
72
+ const projected = recommendation ? { ...draft.draft, taskKind: recommendation } : draft.draft;
73
+ const nextQuestion = nextGrillMeQuestion(projected);
74
+ const savedInterview = await this.interviews.setCurrentQuestion(interview.sessionId, nextQuestion ? { id: nextQuestion.id, text: nextQuestion.text, affectsFields: nextQuestion.affectsFields, risk: nextQuestion.risk } : undefined, nextQuestion ? "awaiting-user-answer" : "awaiting-confirmation");
75
+ this.append(input.operatorSessionId, interview.sessionId, "interview-turn", { interviewSessionId: interview.sessionId, taskId: interview.taskId, state: savedInterview.state, answer: { questionId: input.questionId, response: input.response, ...(input.text ? { text: input.text } : {}) }, ...(nextQuestion ? { question: nextQuestion } : {}) });
76
+ this.append(input.operatorSessionId, interview.sessionId, "draft", { taskId: interview.taskId, draft: draft.draft, draftSha256: draft.draftSha256, ...(recommendation ? { taskKindRecommendation: recommendation } : {}) });
77
+ const assessment = this.assessment({ ...draft, draft: projected });
78
+ this.append(input.operatorSessionId, interview.sessionId, "assessment", { taskId: interview.taskId, ...assessment });
79
+ return { interview: savedInterview, draft, question: nextQuestion, taskKindRecommendation: recommendation, assessment };
80
+ }
81
+ async confirmTaskKind(input) {
82
+ const interview = await this.interviews.get(input.interviewSessionId);
83
+ if (!interview || interview.operatorSessionId !== input.operatorSessionId)
84
+ throw new Error("interview session not found for Chat session");
85
+ const current = await this.drafts.get(interview.taskId);
86
+ if (!current)
87
+ throw new Error(`draft not found: ${interview.taskId}`);
88
+ const taskKind = normalizeConsoleWorkflowKind(input.taskKind, "standard");
89
+ // Save the taskKind change first so we have a stable draftSha256 to bind the
90
+ // assessment against (isAssessmentFreshForDraft compares draftSha256).
91
+ const draft = await this.drafts.save({ ...current.draft, taskKind });
92
+ const outcome = isDraftStructurallyComplete(draft.draft) ? "complete" : "questions-required";
93
+ const assessmentRef = await this.assessments.create({ draft: draft.draft, instructionVersion: interview.instructionVersion, provider: "deterministic", model: "grill-me", sessionId: interview.sessionId, turnId: interview.sessionId, outcome });
94
+ // Write the assessmentRef back into the draft so contractApply can resolve a
95
+ // fresh assessment from the DraftStore (operator-actions contractApply reads
96
+ // draft.interviewAssessmentRef.assessmentId). computeDraftSha256 excludes
97
+ // interviewAssessmentRef, so this does not churn the hash that certified it.
98
+ const draftWithRef = await this.drafts.save({ ...draft.draft, interviewAssessmentRef: { assessmentId: assessmentRef.assessmentId, draftSha256: draft.draftSha256 } });
99
+ const freshness = isAssessmentFreshForDraft(assessmentRef, draftWithRef.draft);
100
+ let savedInterview = await this.interviews.markDraftReady(interview.sessionId, draftWithRef.draft, draftWithRef.draftSha256);
101
+ savedInterview = await this.interviews.markAwaitingConfirmation(interview.sessionId, assessmentRef.assessmentId);
102
+ const assessment = this.assessment(draftWithRef, assessmentRef.assessmentId, freshness.fresh, freshness.fresh ? undefined : freshness.reason);
103
+ this.append(input.operatorSessionId, interview.sessionId, "taskKind-confirmed", { taskId: interview.taskId, taskKind, confirmedAt: new Date().toISOString(), confirmedBy: "human" });
104
+ this.append(input.operatorSessionId, interview.sessionId, "draft", { taskId: interview.taskId, draft: draftWithRef.draft, draftSha256: draftWithRef.draftSha256 });
105
+ this.append(input.operatorSessionId, interview.sessionId, "assessment", { taskId: interview.taskId, ...assessment });
106
+ return { interview: savedInterview, draft: draftWithRef, assessment };
107
+ }
108
+ async get(interviewSessionId) {
109
+ const interview = await this.interviews.get(interviewSessionId);
110
+ if (!interview)
111
+ return undefined;
112
+ const draft = await this.drafts.get(interview.taskId);
113
+ if (!draft)
114
+ return undefined;
115
+ const question = nextGrillMeQuestion(draft.draft);
116
+ let fresh = false;
117
+ let reason;
118
+ if (interview.assessmentId) {
119
+ const ref = await this.assessments.get(interview.assessmentId);
120
+ if (ref) {
121
+ const result = isAssessmentFreshForDraft(ref, draft.draft);
122
+ fresh = result.fresh;
123
+ if (!result.fresh)
124
+ reason = result.reason;
125
+ }
126
+ }
127
+ return { interview, draft, question, assessment: this.assessment(draft, interview.assessmentId, fresh, reason) };
128
+ }
129
+ restore(operatorSessionId) {
130
+ const snapshot = this.events.snapshot(operatorSessionId);
131
+ if (!snapshot.length)
132
+ return undefined;
133
+ const latest = (kind) => [...snapshot].reverse().find((event) => event.kind === kind)?.data;
134
+ return { interview: latest("interview-turn"), draft: latest("draft")?.draft, assessment: latest("assessment"), taskKind: latest("taskKind-confirmed") };
135
+ }
136
+ }
@@ -0,0 +1,23 @@
1
+ export function mergeOperationCardState(current, incoming) {
2
+ const index = current.findIndex((operation) => operation.operationId === incoming.operationId);
3
+ if (index < 0)
4
+ return [...current, incoming];
5
+ return current.map((operation, candidateIndex) => candidateIndex === index ? { ...operation, ...incoming } : operation);
6
+ }
7
+ export function operationFromEventPayload(payload) {
8
+ const operation = payload.operation;
9
+ if (!operation || typeof operation !== "object")
10
+ return undefined;
11
+ const candidate = operation;
12
+ if (candidate.schemaVersion !== 1 ||
13
+ typeof candidate.operationId !== "string" ||
14
+ typeof candidate.action !== "string" ||
15
+ typeof candidate.state !== "string" ||
16
+ typeof candidate.inspectHref !== "string" ||
17
+ typeof candidate.createdAt !== "string" ||
18
+ typeof candidate.updatedAt !== "string" ||
19
+ typeof candidate.previewHash !== "string") {
20
+ return undefined;
21
+ }
22
+ return candidate;
23
+ }