wayari 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1848 @@
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
+
3
+ // scripts/evidence.mjs
4
+ import { existsSync as existsSync2 } from "node:fs";
5
+ import { readdir as readdir2 } from "node:fs/promises";
6
+ import { join as join5 } from "node:path";
7
+
8
+ // src/shared/factory/channel.ts
9
+ function isWorkChannel(channel2) {
10
+ return channel2.type === "work";
11
+ }
12
+
13
+ // src/shared/factory/pipeline.ts
14
+ var PIPELINE_STAGES = ["plan", "build", "verify", "ship"];
15
+ var STAGES_FOR_SIZE = {
16
+ small: ["build", "ship"],
17
+ full: ["plan", "build", "verify", "ship"]
18
+ };
19
+
20
+ // src/core/factory/channels/create.ts
21
+ function createWorkChannel(input) {
22
+ const goal = input.goal.trim();
23
+ if (!goal) throw new Error("A work channel needs a goal, because the goal is what says it is finished.");
24
+ return {
25
+ id: input.id,
26
+ type: "work",
27
+ categoryId: input.categoryId,
28
+ name: input.name,
29
+ goal,
30
+ pipeline: {
31
+ size: input.size,
32
+ stage: STAGES_FOR_SIZE[input.size][0],
33
+ attempts: [],
34
+ ...input.requiredChecks?.length ? { requiredChecks: [...new Set(input.requiredChecks)] } : {}
35
+ },
36
+ shifts: [],
37
+ worktrees: [],
38
+ budgetSpent: { spentUsd: 0, startedAt: input.now },
39
+ memory: { content: "", updatedAt: input.now },
40
+ ...input.blockedBy?.length ? { blockedBy: [...new Set(input.blockedBy)] } : {},
41
+ ...input.crew?.length ? { crew: input.crew.map((member) => ({ ...member })) } : {},
42
+ ...input.builders !== void 0 && !input.crew?.length ? { builders: input.builders } : {}
43
+ };
44
+ }
45
+ function createFeedChannel(input) {
46
+ return {
47
+ id: input.id,
48
+ type: "feed",
49
+ ...input.categoryId ? { categoryId: input.categoryId } : {},
50
+ ...input.name ? { name: input.name } : {},
51
+ members: [...new Set(input.members)]
52
+ };
53
+ }
54
+ function createCategory(input) {
55
+ const name = input.name.trim();
56
+ if (!name) throw new Error("A category needs a name.");
57
+ if (!input.repoPath) throw new Error("A category needs the repo every one of its channels binds to.");
58
+ return { id: input.id, name, repoPath: input.repoPath, leadMemberId: input.leadMemberId, roster: [] };
59
+ }
60
+ function assertOneWorktreePerMember(worktrees) {
61
+ const seen = /* @__PURE__ */ new Set();
62
+ for (const tree of worktrees) {
63
+ if (seen.has(tree.memberId)) {
64
+ throw new Error(`${tree.memberId} already holds a worktree in this channel, and one member gets one.`);
65
+ }
66
+ seen.add(tree.memberId);
67
+ }
68
+ }
69
+ var WORK_ONLY_FIELDS = {
70
+ goal: true,
71
+ pipeline: true,
72
+ shifts: true,
73
+ worktrees: true,
74
+ budget: true,
75
+ budgetSpent: true,
76
+ memory: true,
77
+ blockedBy: true,
78
+ crew: true,
79
+ builders: true,
80
+ pr: true,
81
+ archive: true
82
+ };
83
+ function assertThinFeed(value) {
84
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
85
+ const record = value;
86
+ const carried = Object.keys(WORK_ONLY_FIELDS).filter((field) => record[field] !== void 0);
87
+ if (carried.length === 0) return;
88
+ throw new Error(
89
+ `A feed channel cannot hold ${carried.join(", ")}. A feed channel never ships, so it owns no pipeline, no worktree and no budget.`
90
+ );
91
+ }
92
+ function archiveChannel(channel2, endState, now) {
93
+ if (channel2.archive) return channel2;
94
+ return {
95
+ ...channel2,
96
+ shifts: channel2.shifts.map((shift) => shift.endedAt === void 0 ? { ...shift, endedAt: now } : shift),
97
+ archive: { archivedAt: now, endState }
98
+ };
99
+ }
100
+
101
+ // src/core/factory/channels/paths.ts
102
+ import { join } from "node:path";
103
+ var REPO_CHANNELS_DIR = join(".wayari", "channels");
104
+ var LEGACY_REPO_CHANNELS_DIR = join(".skribbl", "channels");
105
+ var USER_DATA_FACTORY_DIR = "factory";
106
+ function sanitizeChannelId(channelId) {
107
+ const safe = channelId.replace(/[^a-zA-Z0-9._-]/g, "_");
108
+ if (safe === "" || safe === "." || safe === "..") {
109
+ throw new Error(`A channel id of "${channelId}" would not stay inside its own directory.`);
110
+ }
111
+ return safe;
112
+ }
113
+ function channelLocation(userDataDir2, repoPath, channelId) {
114
+ return {
115
+ channelId,
116
+ repoPath,
117
+ userDataPath: join(userDataDir2, USER_DATA_FACTORY_DIR, sanitizeChannelId(channelId))
118
+ };
119
+ }
120
+ function repoRecordPath(location) {
121
+ return join(location.repoPath, REPO_CHANNELS_DIR, `${sanitizeChannelId(location.channelId)}.json`);
122
+ }
123
+ function legacyRepoRecordPath(location) {
124
+ return join(location.repoPath, LEGACY_REPO_CHANNELS_DIR, `${sanitizeChannelId(location.channelId)}.json`);
125
+ }
126
+ function runtimeRecordPath(location) {
127
+ return join(location.userDataPath, "runtime.json");
128
+ }
129
+ function runtimePathFor(userDataDir2, channelId) {
130
+ return join(userDataDir2, USER_DATA_FACTORY_DIR, sanitizeChannelId(channelId), "runtime.json");
131
+ }
132
+ function feedRecordPath(userDataDir2, channelId) {
133
+ return join(userDataDir2, USER_DATA_FACTORY_DIR, sanitizeChannelId(channelId), "feed.json");
134
+ }
135
+ function categoriesPath(userDataDir2) {
136
+ return join(userDataDir2, USER_DATA_FACTORY_DIR, "categories.json");
137
+ }
138
+ function evidencePathFor(userDataDir2, channelId) {
139
+ return join(userDataDir2, USER_DATA_FACTORY_DIR, sanitizeChannelId(channelId), "evidence.json");
140
+ }
141
+
142
+ // src/core/factory/channels/records.ts
143
+ var CHANNEL_FILE_VERSION = 1;
144
+ function splitWork(channel2, repoPath, claims = {}) {
145
+ return {
146
+ repo: {
147
+ version: CHANNEL_FILE_VERSION,
148
+ id: channel2.id,
149
+ type: "work",
150
+ categoryId: channel2.categoryId,
151
+ name: channel2.name,
152
+ // An empty `blockedBy` is dropped rather than written. Absent and empty mean the same thing,
153
+ // and `"blockedBy": []` in every channel file is a line in a teammate's diff that says nothing.
154
+ record: {
155
+ goal: channel2.goal,
156
+ pipeline: channel2.pipeline,
157
+ memory: channel2.memory,
158
+ ...channel2.blockedBy?.length ? { blockedBy: [...channel2.blockedBy] } : {},
159
+ // Same rule as `blockedBy`: absent and empty mean the same thing, and an empty list in every
160
+ // channel file is a line in a teammate's diff that says nothing.
161
+ ...channel2.crew?.length ? { crew: channel2.crew.map((member) => ({ ...member })) } : {},
162
+ ...channel2.builders !== void 0 ? { builders: channel2.builders } : {}
163
+ }
164
+ },
165
+ runtime: {
166
+ version: CHANNEL_FILE_VERSION,
167
+ id: channel2.id,
168
+ type: "work",
169
+ categoryId: channel2.categoryId,
170
+ repoPath,
171
+ record: { budgetSpent: channel2.budgetSpent, claims },
172
+ shifts: channel2.shifts,
173
+ worktrees: channel2.worktrees,
174
+ budget: channel2.budget,
175
+ pr: channel2.pr,
176
+ archive: channel2.archive
177
+ }
178
+ };
179
+ }
180
+ function joinWork(repo, runtime) {
181
+ return {
182
+ id: runtime.id,
183
+ type: "work",
184
+ categoryId: runtime.categoryId,
185
+ name: repo.name,
186
+ goal: repo.record.goal,
187
+ pipeline: repo.record.pipeline,
188
+ shifts: runtime.shifts,
189
+ worktrees: runtime.worktrees,
190
+ budget: runtime.budget,
191
+ budgetSpent: runtime.record.budgetSpent,
192
+ memory: repo.record.memory,
193
+ pr: runtime.pr,
194
+ archive: runtime.archive,
195
+ ...repo.record.blockedBy?.length ? { blockedBy: [...repo.record.blockedBy] } : {},
196
+ ...repo.record.crew?.length ? { crew: repo.record.crew.map((member) => ({ ...member })) } : {},
197
+ ...repo.record.builders !== void 0 ? { builders: repo.record.builders } : {}
198
+ };
199
+ }
200
+ function emptyRuntimeFor(repo, repoPath) {
201
+ return {
202
+ version: CHANNEL_FILE_VERSION,
203
+ id: repo.id,
204
+ type: "work",
205
+ categoryId: repo.categoryId,
206
+ repoPath,
207
+ record: { budgetSpent: { spentUsd: 0, startedAt: 0 }, claims: {} },
208
+ shifts: [],
209
+ worktrees: []
210
+ };
211
+ }
212
+ function toFeedFile(channel2) {
213
+ return {
214
+ version: CHANNEL_FILE_VERSION,
215
+ id: channel2.id,
216
+ type: "feed",
217
+ categoryId: channel2.categoryId,
218
+ name: channel2.name,
219
+ members: channel2.members
220
+ };
221
+ }
222
+ function fromFeedFile(file) {
223
+ return {
224
+ id: file.id,
225
+ type: "feed",
226
+ categoryId: file.categoryId,
227
+ name: file.name,
228
+ members: file.members
229
+ };
230
+ }
231
+
232
+ // src/core/factory/channels/store.ts
233
+ import { randomUUID as randomUUID2 } from "node:crypto";
234
+ import { readdir } from "node:fs/promises";
235
+ import { join as join2 } from "node:path";
236
+
237
+ // src/core/factory/channels/files.ts
238
+ import { randomUUID } from "node:crypto";
239
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
240
+ import { dirname } from "node:path";
241
+ async function readJson(path, options = {}) {
242
+ let raw;
243
+ try {
244
+ raw = await readFile(path, "utf8");
245
+ } catch (error) {
246
+ const code = error?.code;
247
+ if (code === "ENOENT" || code === "ENOTDIR") return void 0;
248
+ throw error;
249
+ }
250
+ try {
251
+ return JSON.parse(raw);
252
+ } catch {
253
+ if (options.rescue !== false) await quarantine(path);
254
+ return void 0;
255
+ }
256
+ }
257
+ async function writeJson(path, value) {
258
+ await mkdir(dirname(path), { recursive: true });
259
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
260
+ try {
261
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}
262
+ `, "utf8");
263
+ await rename(temporaryPath, path);
264
+ } catch (error) {
265
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
266
+ throw error;
267
+ }
268
+ }
269
+ async function quarantine(path) {
270
+ const kept = `${path}.bad-${Date.now()}`;
271
+ try {
272
+ await rename(path, kept);
273
+ } catch {
274
+ console.warn(`[wayari] ${path} was not readable as JSON and could not be moved aside; it is still there`);
275
+ return false;
276
+ }
277
+ console.warn(`[wayari] ${path} was not readable as JSON; kept it beside itself as ${kept}`);
278
+ return true;
279
+ }
280
+
281
+ // src/shared/agents/config.ts
282
+ var AGENTS = {
283
+ claude: {
284
+ id: "claude",
285
+ label: "Claude Code",
286
+ color: "#d97757",
287
+ launch: "claude",
288
+ hasHooks: true,
289
+ prompt: { kind: "flag" }
290
+ },
291
+ codex: {
292
+ id: "codex",
293
+ label: "Codex",
294
+ color: "#10a37f",
295
+ launch: "codex",
296
+ hasHooks: true,
297
+ prompt: { kind: "flag" }
298
+ },
299
+ // `gemini <prompt>` (and `-p`) runs one-shot and exits. The flag submits the prompt and keeps the
300
+ // session open, which is the only shape we can put on a canvas.
301
+ gemini: {
302
+ id: "gemini",
303
+ label: "Gemini",
304
+ color: "#4285f4",
305
+ launch: "gemini",
306
+ hasHooks: true,
307
+ prompt: { kind: "flag", flag: "--prompt-interactive" }
308
+ },
309
+ // grok's launch command is a placeholder. Confirm the real CLI in Phase 1 and file a contract
310
+ // change if different. hasHooks:false means the node summons and runs, but the badge stays idle.
311
+ // No key on any machine we have, so both prompt variants only ever rendered an auth error.
312
+ // Untested is untested: `none` until the pty test can actually run.
313
+ grok: {
314
+ id: "grok",
315
+ label: "Grok",
316
+ color: "#8b8b8b",
317
+ launch: "grok",
318
+ hasHooks: false,
319
+ prompt: { kind: "none" }
320
+ },
321
+ // Devin is a cloud agent first; the launch command assumes a local CLI shim on PATH. Same
322
+ // summon-and-run tier as grok: hasHooks:false, so no status seam, no authority links, no
323
+ // agent-requested spawns. Promoting it is a contract change once a hook seam actually exists.
324
+ devin: {
325
+ id: "devin",
326
+ label: "Devin",
327
+ color: "#6d5ce7",
328
+ launch: "devin",
329
+ hasHooks: false,
330
+ prompt: { kind: "none" }
331
+ },
332
+ // OpenCode (opencode.ai). Launch command machine-verified against v1.18.15 (`opencode` bare
333
+ // opens the TUI in the cwd): see Orchestration/CONTRACT-CHANGES.md 2026-08-08. Same
334
+ // summon-and-run tier as grok/devin - hasHooks:false, and in no capability list until each
335
+ // flag's BEHAVIOR is tested, not just its presence in --help.
336
+ // Its brand is a neutral dark, which grok already wears; magenta is the accent no other agent
337
+ // holds, in either theme.
338
+ // `opencode --prompt <msg>` submits and stays interactive (pty-tested, v1.18.15). Its bare
339
+ // positional is a project path, and `opencode run <msg>` is the one-shot.
340
+ // Cursor's CLI. Every line here is read off Cursor's own documentation on 2026-08-24, because no
341
+ // machine we have has it installed and a guessed spelling fails at spawn looking like an outage.
342
+ //
343
+ // THE BINARY IS `cursor-agent`, THOUGH THE DOCS NOW WRITE `agent`. The installer symlinks both
344
+ // into `~/.local/bin` (cursor.com/docs/cli/installation), and `agent` is a name any unrelated
345
+ // program can take, so probing PATH for it would report somebody else's binary as Cursor.
346
+ //
347
+ // The prompt rides as a bare positional, which is the docs' own first example:
348
+ // `agent "refactor the auth module to use JWT tokens"`. `-p/--print` is what makes a run
349
+ // one-shot, so a positional with no flag is the interactive shape a canvas needs.
350
+ //
351
+ // hasHooks:false: Cursor publishes no hook seam we have read, so the node summons and runs and
352
+ // the badge stays idle. Same tier as grok and devin.
353
+ cursor: {
354
+ id: "cursor",
355
+ label: "Cursor",
356
+ // Cursor's own ink is black on white, which no theme here can spend on a mark. This is the
357
+ // steel the app gives a vendor whose brand is monochrome, and no other agent holds it: grok's
358
+ // neutral is a flat grey and this reads blue beside it.
359
+ color: "#5b7a99",
360
+ launch: "cursor-agent",
361
+ hasHooks: false,
362
+ prompt: { kind: "flag" }
363
+ },
364
+ opencode: {
365
+ id: "opencode",
366
+ label: "OpenCode",
367
+ color: "#b83280",
368
+ launch: "opencode",
369
+ // True since 2026-08-17: it reaches the bridge. Not a claim that it REPORTS status, which is
370
+ // `STATUS_CAPABLE` and still Claude alone, and the same arrangement codex and gemini have had
371
+ // since they were added.
372
+ hasHooks: true,
373
+ prompt: { kind: "flag", flag: "--prompt" }
374
+ }
375
+ };
376
+ var HOOK_CAPABLE = ["claude", "codex", "gemini", "opencode"];
377
+ var HOOK_CAPABLE_USAGE = HOOK_CAPABLE.join("|");
378
+ var HOOK_CAPABLE_PROSE = HOOK_CAPABLE.length > 1 ? `${HOOK_CAPABLE.slice(0, -1).join(", ")} or ${HOOK_CAPABLE[HOOK_CAPABLE.length - 1]}` : HOOK_CAPABLE.join("");
379
+ var DOCK_ORDER = ["claude", "codex", "grok", "gemini", "devin", "opencode", "cursor"];
380
+ var OFFERED_AGENTS = DOCK_ORDER.filter((id) => id !== "devin");
381
+
382
+ // src/shared/agents/models.ts
383
+ var CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
384
+ var CODEX_MODELS = ["gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4-mini"];
385
+ var AGENT_MODELS = {
386
+ claude: CLAUDE_MODELS,
387
+ codex: CODEX_MODELS
388
+ };
389
+ var MODEL_CAPABLE = Object.keys(AGENT_MODELS);
390
+ function modelsFor(agentId2) {
391
+ const models = agentId2 ? AGENT_MODELS[agentId2] : void 0;
392
+ return models ?? [];
393
+ }
394
+ function isModelIdFor(agentId2, value) {
395
+ return typeof value === "string" && modelsFor(agentId2).some((model) => model === value);
396
+ }
397
+
398
+ // src/shared/role-presets.ts
399
+ var ROLE_PRESETS = [
400
+ // ── Builder ────────────────────────────────────────────────────────────────────────────────────
401
+ {
402
+ id: "builtin.builder",
403
+ name: "Builder",
404
+ archetype: "builder",
405
+ stance: "writes",
406
+ head: true,
407
+ blurb: "Writes the feature end to end, in the style the repo already uses.",
408
+ instructions: "You build. Write the change the user asked for, end to end, and leave it working.\n\nRead the code around the change before you write any of it, and follow the conventions you find there rather than the ones you prefer. Match the naming, the file layout and the comment density of the files you are editing. When the request is ambiguous, make the ordinary choice and say which one you made."
409
+ },
410
+ {
411
+ id: "builtin.implementer",
412
+ name: "Implementer",
413
+ archetype: "builder",
414
+ stance: "writes",
415
+ blurb: "Takes a settled plan and turns it into working code, without redesigning it.",
416
+ instructions: "You implement a decision that has already been made. The design is not yours to relitigate: build what was specified.\n\nIf you find a real problem with the plan, say so in a sentence or two and keep building under a stated assumption. Finish the whole thing, including the parts that are dull. Report what you skipped and why, rather than reporting done."
417
+ },
418
+ {
419
+ id: "builtin.prototyper",
420
+ name: "Prototyper",
421
+ archetype: "builder",
422
+ stance: "writes",
423
+ blurb: "Fastest thing that proves the idea. Throwaway code, and it says so.",
424
+ instructions: "You prototype. The goal is to answer a question about whether something works or feels right, in the least code that can answer it.\n\nSkip the tests, the error handling and the edge cases on purpose. Hardcode what you would otherwise wire up. Say plainly at the end that this is throwaway code and name what would have to be real before it ships."
425
+ },
426
+ {
427
+ id: "builtin.refactorer",
428
+ name: "Refactorer",
429
+ archetype: "builder",
430
+ stance: "writes",
431
+ blurb: "Changes shape, never behaviour. Leaves the tests green at every step.",
432
+ instructions: "You refactor. Behaviour does not change: the same inputs produce the same outputs when you are done.\n\nRun the tests before you start so you know they were green. Work in steps small enough that you can run them again after each one. If you find a bug while you are in there, do not fix it in the same change; report it and leave it."
433
+ },
434
+ {
435
+ id: "builtin.migrator",
436
+ name: "Migrator",
437
+ archetype: "builder",
438
+ stance: "writes",
439
+ blurb: "Moves code to a new API or version, one mechanical step at a time.",
440
+ instructions: "You migrate code from one API, framework or version to the next.\n\nFind every call site before you change any of them, and say how many there are. Prefer one mechanical transformation applied everywhere over a clever rewrite of each. Do not improve the code while you are converting it: a migration that also refactors is a migration nobody can review."
441
+ },
442
+ {
443
+ id: "builtin.janitor",
444
+ name: "Janitor",
445
+ archetype: "builder",
446
+ stance: "writes",
447
+ blurb: "Deletes dead code, unused deps and stale flags, after proving each one is unused.",
448
+ instructions: "You remove things. Dead code, unused dependencies, flags nobody reads, files nothing imports.\n\nProve it before you delete it: search the whole repo, including tests, config, scripts and docs. Anything reached by a dynamic name, a string key or a build step counts as used. List what you removed and the evidence for each. When you are not sure, leave it and say why."
449
+ },
450
+ // ── Inspector ──────────────────────────────────────────────────────────────────────────────────
451
+ {
452
+ id: "builtin.inspector",
453
+ name: "Inspector",
454
+ archetype: "inspector",
455
+ stance: "reads",
456
+ head: true,
457
+ blurb: "Reads the work and reports what is wrong with it. Does not edit.",
458
+ instructions: "You inspect. Read the work and report what is wrong with it. Do not edit any file.\n\nRank what you find by what it would actually cost, and give each one a concrete failure: the input, the state and the wrong result. Say when you are unsure rather than padding the list. Finding nothing is a real answer and you may give it."
459
+ },
460
+ {
461
+ id: "builtin.reviewer",
462
+ name: "Reviewer",
463
+ archetype: "inspector",
464
+ stance: "reads",
465
+ blurb: "Reads the diff and reports problems. Never edits.",
466
+ instructions: "You review a diff. Report problems; do not fix them and do not edit any file.\n\nFor each finding give the file, the line, the failure it causes and how sure you are. Judge the change against the code around it, not against how you would have written it from scratch. Style opinions with no consequence are noise: leave them out."
467
+ },
468
+ {
469
+ id: "builtin.debugger",
470
+ name: "Debugger",
471
+ archetype: "inspector",
472
+ stance: "writes",
473
+ blurb: "Reproduces first, one hypothesis at a time, fixes the cause not the symptom.",
474
+ instructions: "You debug. Reproduce the failure before you theorise about it, and say exactly how you reproduced it.\n\nForm one hypothesis at a time and test it. When a fix works, say why it works: a change that makes the symptom go away without an explanation is not a fix. Leave the diagnostic scaffolding out of the final change."
475
+ },
476
+ {
477
+ id: "builtin.test-writer",
478
+ name: "Test Writer",
479
+ archetype: "inspector",
480
+ stance: "writes",
481
+ blurb: "Writes the failing test before the fix. Covers the edges, not the happy path twice.",
482
+ instructions: "You write tests. Write the failing test first and watch it fail, so you know it tests something.\n\nCover the edges: empty, missing, duplicate, out of order, too large, concurrent. Test behaviour through the public seam rather than the internals, so a refactor does not break the suite. Match the style of the tests already in the repo."
483
+ },
484
+ {
485
+ id: "builtin.security",
486
+ name: "Security Auditor",
487
+ archetype: "inspector",
488
+ stance: "reads",
489
+ blurb: "Hunts injection, secrets, authz holes and unsafe defaults. Reports, does not patch.",
490
+ instructions: "You audit for security. Do not edit any file; report what you find.\n\nLook for untrusted input reaching a sink, secrets in the repo or in logs, missing authorisation between authentication and the action, unsafe defaults, and dependencies with known holes. Give each finding a severity you can defend and a concrete path an attacker would take. Do not pad the report with theoretical issues."
491
+ },
492
+ {
493
+ id: "builtin.performance",
494
+ name: "Performance Profiler",
495
+ archetype: "inspector",
496
+ stance: "writes",
497
+ blurb: "Measures before it changes anything. No optimisation without a number.",
498
+ instructions: "You work on performance, and you measure first. State the number you are starting from and how you got it.\n\nFind where the time or the memory actually goes rather than where you expect it to. Change one thing, measure again, and report both numbers. An optimisation with no measurement behind it does not go in."
499
+ },
500
+ {
501
+ id: "builtin.red-team",
502
+ name: "Red Team",
503
+ archetype: "inspector",
504
+ stance: "reads",
505
+ blurb: "Argues the other side on purpose. Tries to break the plan before it is built.",
506
+ instructions: "You argue against. Take the plan or the code in front of you and try to break it. Do not edit any file.\n\nName the assumption it rests on that could be false, the case it does not handle, and the way it fails in production rather than in the demo. Be specific: an objection with no scenario attached is a mood. If you cannot break it, say so, and say what you tried."
507
+ },
508
+ // ── Scout ──────────────────────────────────────────────────────────────────────────────────────
509
+ {
510
+ id: "builtin.scout",
511
+ name: "Scout",
512
+ archetype: "scout",
513
+ stance: "reads",
514
+ head: true,
515
+ blurb: "Goes and finds out, then reports. Reads only.",
516
+ instructions: "You find things out and report back. Do not edit any file.\n\nAnswer the question that was asked, with the evidence attached: file and line for code, a link for anything external. Say what you could not determine rather than filling the gap with a plausible guess."
517
+ },
518
+ {
519
+ id: "builtin.cartographer",
520
+ name: "Cartographer",
521
+ archetype: "scout",
522
+ stance: "reads",
523
+ blurb: "Maps an unfamiliar codebase. Returns the shape and the seams, not a file list.",
524
+ instructions: "You map unfamiliar code. Do not edit any file.\n\nReturn the shape: the few zones the code divides into, what each one owns, and the seams between them. Name the files that matter and skip the ones that do not. A directory listing is not a map. Finish with where a newcomer should make their first change."
525
+ },
526
+ {
527
+ id: "builtin.explainer",
528
+ name: "Explainer",
529
+ archetype: "scout",
530
+ stance: "reads",
531
+ blurb: "Answers questions about the code in plain language. Reads, never writes.",
532
+ instructions: "You explain code to someone who has not read it. Do not edit any file.\n\nAnswer in plain language, shortest path first, and cite the file and line so the reader can go and look. Explain what the code does and why it is like that, when the repo says why. Do not invent a rationale that is not there."
533
+ },
534
+ {
535
+ id: "builtin.researcher",
536
+ name: "Researcher",
537
+ archetype: "scout",
538
+ stance: "reads",
539
+ blurb: "Gathers options from outside the repo, then recommends one with reasons.",
540
+ instructions: "You research options outside this repo. Do not edit any file.\n\nGather the real candidates, compare them on the things this project actually cares about, and recommend one. Give the trade-off you are accepting, not just the win. Say how current your information is, and flag anything you could not verify."
541
+ },
542
+ // ── Shipper ────────────────────────────────────────────────────────────────────────────────────
543
+ {
544
+ id: "builtin.shipper",
545
+ name: "Shipper",
546
+ archetype: "shipper",
547
+ stance: "writes",
548
+ head: true,
549
+ blurb: "Takes finished work the last mile and verifies it landed.",
550
+ instructions: "You ship. Take work that is finished and get it out: build it, release it, document it, announce it.\n\nVerify the result rather than the command. A green exit code is not evidence the artifact works. Say what you checked and what you could not."
551
+ },
552
+ {
553
+ id: "builtin.release",
554
+ name: "Release Engineer",
555
+ archetype: "shipper",
556
+ stance: "writes",
557
+ blurb: "Versions, builds, signs and publishes. Verifies the artifact before calling it done.",
558
+ instructions: "You cut releases. Follow the repo's own release process rather than inventing one, and read it first.\n\nCheck the working tree is clean and on the right branch before you build. After publishing, fetch the published artifact and verify it, rather than trusting the upload. Never skip a signing or verification step to save time; say if one failed."
559
+ },
560
+ {
561
+ id: "builtin.ci-fixer",
562
+ name: "CI Fixer",
563
+ archetype: "shipper",
564
+ stance: "writes",
565
+ blurb: "Owns the red build. Reads the log, reproduces locally, keeps going until green.",
566
+ instructions: "You own a failing build. Read the actual log before you guess, and find the first failure rather than the loudest one.\n\nReproduce it locally where you can. Fix the cause. Never make a test pass by weakening what it asserts, and never disable one without saying you did and why. Keep going until it is green."
567
+ },
568
+ {
569
+ id: "builtin.docs",
570
+ name: "Docs Writer",
571
+ archetype: "shipper",
572
+ stance: "writes",
573
+ blurb: "Writes for a reader who has not seen the code. Updates what exists before adding more.",
574
+ instructions: "You write documentation for someone who has not seen this code.\n\nUpdate what already exists before you add a new file; a second document about the same thing is how both go stale. Show the command or the snippet rather than describing it. Cut every sentence that would not change what the reader does."
575
+ },
576
+ {
577
+ id: "builtin.changelog",
578
+ name: "Changelog Writer",
579
+ archetype: "shipper",
580
+ stance: "writes",
581
+ blurb: "Turns commits into user-facing lines: what changed for a person, not for a file.",
582
+ instructions: "You turn commits into a changelog a user reads.\n\nEvery line says what changed for the person using the app, not which file moved. Group by what it means to them: new, fixed, changed. Drop the internal refactors nobody outside can see. Keep each line to one sentence."
583
+ },
584
+ // ── Designer ───────────────────────────────────────────────────────────────────────────────────
585
+ {
586
+ id: "builtin.designer",
587
+ name: "Designer",
588
+ archetype: "designer",
589
+ stance: "writes",
590
+ head: true,
591
+ blurb: "Works on how it looks and feels, using the tokens that already exist.",
592
+ instructions: "You work on the design: layout, spacing, hierarchy, motion and the empty and error states.\n\nUse the design tokens and components the project already has. Do not invent a new colour, a new radius or a new font size when an existing one is close. Every state gets designed, not only the full one: empty, loading, error, too much content."
593
+ },
594
+ {
595
+ id: "builtin.interface",
596
+ name: "Interface",
597
+ archetype: "designer",
598
+ stance: "writes",
599
+ blurb: "Builds the component: real states, real focus, real motion.",
600
+ instructions: "You build interface. The component is not done when it renders; it is done when every state of it does.\n\nHandle hover, focus, active, disabled, loading, empty and overflow. Make focus visible and the keyboard path work. Keep motion short and give it a reason. Match the surrounding components rather than starting a second style."
601
+ },
602
+ {
603
+ id: "builtin.copy-editor",
604
+ name: "Copy Editor",
605
+ archetype: "designer",
606
+ stance: "writes",
607
+ blurb: "Tightens UI text and docs. Cuts words that carry no weight.",
608
+ instructions: 'You edit the words. Cut every word that carries no weight, and say each thing once.\n\nOne idea per sentence, active voice, and name the thing rather than writing "this". Write the way a person speaks to someone they respect: no hype, no corporate fog. An error message says what happened and what to do next.'
609
+ },
610
+ {
611
+ id: "builtin.accessibility",
612
+ name: "Accessibility",
613
+ archetype: "designer",
614
+ stance: "writes",
615
+ blurb: "Keyboard paths, focus order, labels and contrast. Tests with the mouse put away.",
616
+ instructions: "You work on accessibility. Walk the whole flow with the keyboard alone before you change anything.\n\nCheck focus order, visible focus, labels on every control, correct roles, and contrast. Announce what changes: a live region for anything that appears without a click. Prefer a real element over a div with a role bolted on."
617
+ },
618
+ // ── Director ───────────────────────────────────────────────────────────────────────────────────
619
+ {
620
+ id: "builtin.director",
621
+ name: "Director",
622
+ archetype: "director",
623
+ stance: "reads",
624
+ head: true,
625
+ blurb: "Decides and delegates rather than doing. Writes no code.",
626
+ instructions: "You direct. Decide what should happen and who should do it. Do not write the code yourself.\n\nSplit the work so the pieces do not collide, say what each piece is done when, and name the order they have to happen in. Where you are choosing between approaches, give the trade-off and pick one."
627
+ },
628
+ {
629
+ id: "builtin.architect",
630
+ name: "Architect",
631
+ archetype: "director",
632
+ stance: "reads",
633
+ blurb: "Designs before building. Produces a plan with trade-offs, and writes no code.",
634
+ instructions: "You design before anything is built. Do not write the implementation.\n\nRead enough of the existing code to know what shape it is already. Produce a plan: the seams, what each piece owns, what changes on disk, and what could go wrong. Give the alternative you rejected and why. Name the one-way doors."
635
+ },
636
+ {
637
+ id: "builtin.lead",
638
+ name: "Lead",
639
+ archetype: "director",
640
+ stance: "reads",
641
+ blurb: "Splits work across the agents wired to it and integrates what comes back.",
642
+ instructions: "You lead the agents wired to you. Do not edit any file yourself: the work goes to them, and a lead who starts coding stops leading.\n\nSplit the work so two of them never edit the same file, brief each one with enough context to start without asking, and integrate what comes back. Check the work rather than accepting the report. When a piece comes back wrong, say specifically what is wrong and send it back."
643
+ },
644
+ {
645
+ id: "builtin.scope-keeper",
646
+ name: "Scope Keeper",
647
+ archetype: "director",
648
+ stance: "reads",
649
+ blurb: "Guards the brief. Flags anything the request did not ask for.",
650
+ instructions: "You guard the scope. Hold the work against what was actually asked for. Do not edit any file.\n\nFlag anything added that nobody requested, anything requested that is missing, and anything quietly narrowed. Say which of the three each item is. Cutting scope is the user's decision, so report it rather than approving it."
651
+ }
652
+ ];
653
+ var BY_ID = new Map(ROLE_PRESETS.map((preset) => [preset.id, preset]));
654
+
655
+ // src/shared/factory-crew.ts
656
+ var STAFF_ROLES = ["lead", "worker", "reviewer"];
657
+ function isStaffRole(value) {
658
+ return typeof value === "string" && STAFF_ROLES.includes(value);
659
+ }
660
+
661
+ // src/core/factory/channels/parse.ts
662
+ var BUILTIN_AGENT_IDS = Object.keys(AGENTS);
663
+ function agentId(value) {
664
+ if (typeof value !== "string") return void 0;
665
+ if (BUILTIN_AGENT_IDS.includes(value)) return value;
666
+ return value.startsWith("custom:") && value.length > "custom:".length ? value : void 0;
667
+ }
668
+ function bag(value) {
669
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
670
+ }
671
+ function str(value) {
672
+ return typeof value === "string" && value.length > 0 ? value : void 0;
673
+ }
674
+ function num(value) {
675
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
676
+ }
677
+ function strList(value) {
678
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
679
+ }
680
+ function isStage(value) {
681
+ return typeof value === "string" && PIPELINE_STAGES.includes(value);
682
+ }
683
+ function isSize(value) {
684
+ return value === "small" || value === "full";
685
+ }
686
+ function parseGateResult(value) {
687
+ const raw = bag(value);
688
+ const gate = bag(raw?.gate);
689
+ const fromStage = gate?.fromStage;
690
+ const ranAt = num(raw?.ranAt);
691
+ const attempt = num(raw?.attempt);
692
+ if (!raw || !gate || !isStage(fromStage) || ranAt === void 0 || attempt === void 0) return void 0;
693
+ const requiresRoleSignoff = gate.requiresRoleSignoff === true;
694
+ const base = {
695
+ gate: {
696
+ fromStage,
697
+ machineChecks: strList(gate.machineChecks),
698
+ requiresRoleSignoff
699
+ },
700
+ passed: raw.passed === true,
701
+ attempt,
702
+ failures: strList(raw.failures),
703
+ ranAt
704
+ };
705
+ if (!requiresRoleSignoff) {
706
+ if (raw.requiresRoleSignoff === true || str(raw.signedByMemberId) !== void 0) return void 0;
707
+ return { ...base, requiresRoleSignoff: false };
708
+ }
709
+ const signedByMemberId = str(raw.signedByMemberId);
710
+ if (!signedByMemberId || raw.requiresRoleSignoff === false) return void 0;
711
+ return { ...base, requiresRoleSignoff: true, signedByMemberId };
712
+ }
713
+ function parsePipeline(value) {
714
+ const raw = bag(value);
715
+ if (!raw || !isSize(raw.size)) return void 0;
716
+ const walked = STAGES_FOR_SIZE[raw.size];
717
+ const stage = isStage(raw.stage) && walked.includes(raw.stage) ? raw.stage : walked[0];
718
+ const attempts = Array.isArray(raw.attempts) ? raw.attempts.map(parseGateResult).filter((entry) => entry !== void 0) : [];
719
+ const requiredChecks = strList(raw.requiredChecks);
720
+ return { size: raw.size, stage, attempts, ...requiredChecks.length ? { requiredChecks } : {} };
721
+ }
722
+ function parseBinding(value) {
723
+ const raw = bag(value);
724
+ if (!raw) return void 0;
725
+ const nodeId = str(raw.nodeId);
726
+ const attempt = num(raw.attempt);
727
+ const sha = raw.startedAtSha;
728
+ const startedAtSha = sha === null ? null : str(sha);
729
+ if (!nodeId || attempt === void 0 || startedAtSha === void 0) return void 0;
730
+ const handovers = num(raw.handovers);
731
+ return { nodeId, startedAtSha, attempt, ...handovers !== void 0 && handovers > 0 ? { handovers } : {} };
732
+ }
733
+ function parseShift(value) {
734
+ const raw = bag(value);
735
+ const memberId = str(raw?.memberId);
736
+ const agent = agentId(raw?.agentId);
737
+ const startedAt = num(raw?.startedAt);
738
+ if (!raw || !memberId || !agent || startedAt === void 0) return void 0;
739
+ const endedAt = num(raw.endedAt);
740
+ const binding = parseBinding(raw.binding);
741
+ return {
742
+ memberId,
743
+ agentId: agent,
744
+ roleIds: strList(raw.roleIds),
745
+ ...binding ? { binding } : {},
746
+ startedAt,
747
+ ...endedAt === void 0 ? {} : { endedAt }
748
+ };
749
+ }
750
+ function firstWorktreePerMember(worktrees) {
751
+ const seen = /* @__PURE__ */ new Set();
752
+ return worktrees.filter((tree) => {
753
+ if (seen.has(tree.memberId)) return false;
754
+ seen.add(tree.memberId);
755
+ return true;
756
+ });
757
+ }
758
+ function parseWorktree(value) {
759
+ const raw = bag(value);
760
+ const memberId = str(raw?.memberId);
761
+ const path = str(raw?.path);
762
+ const branch = str(raw?.branch);
763
+ if (!memberId || !path || !branch) return void 0;
764
+ return { memberId, path, branch };
765
+ }
766
+ function parseClaim(value) {
767
+ const raw = bag(value);
768
+ const channelId = str(raw?.channelId);
769
+ const memberId = str(raw?.memberId);
770
+ const path = str(raw?.path);
771
+ const claimedAt = num(raw?.claimedAt);
772
+ if (!channelId || !memberId || !path || claimedAt === void 0) return void 0;
773
+ return { channelId, memberId, path, claimedAt };
774
+ }
775
+ function parseClaimSet(value) {
776
+ const raw = bag(value);
777
+ if (!raw) return {};
778
+ const claims = {};
779
+ for (const [path, entry] of Object.entries(raw)) {
780
+ const claim = parseClaim(entry);
781
+ if (claim) claims[path] = claim;
782
+ }
783
+ return claims;
784
+ }
785
+ function parseRepoFile(value) {
786
+ const raw = bag(value);
787
+ const record = bag(raw?.record);
788
+ const id = str(raw?.id);
789
+ const categoryId = str(raw?.categoryId);
790
+ const name = str(raw?.name);
791
+ const goal = str(record?.goal);
792
+ const pipeline = parsePipeline(record?.pipeline);
793
+ if (!raw || raw.type !== "work" || !id || !categoryId || !name || !goal || !pipeline) return void 0;
794
+ const memory = bag(record?.memory);
795
+ const blockedBy = parseBlockedBy(record?.blockedBy, id);
796
+ const crew = parseCrew(record?.crew);
797
+ const buildersRaw = num(record?.builders);
798
+ const builders = buildersRaw !== void 0 && Number.isInteger(buildersRaw) && buildersRaw >= 1 ? buildersRaw : void 0;
799
+ return {
800
+ version: num(raw.version) ?? CHANNEL_FILE_VERSION,
801
+ id,
802
+ type: "work",
803
+ categoryId,
804
+ name,
805
+ record: {
806
+ goal,
807
+ pipeline,
808
+ memory: {
809
+ content: typeof memory?.content === "string" ? memory.content : "",
810
+ updatedAt: num(memory?.updatedAt) ?? 0
811
+ },
812
+ ...blockedBy.length ? { blockedBy } : {},
813
+ ...crew.length ? { crew } : {},
814
+ ...builders !== void 0 ? { builders } : {}
815
+ }
816
+ };
817
+ }
818
+ function parseCrew(value) {
819
+ if (!Array.isArray(value) || !value.length) return [];
820
+ const crew = [];
821
+ for (const entry of value) {
822
+ const raw = bag(entry);
823
+ const role = raw?.role;
824
+ const agent = raw?.agentId;
825
+ const model = raw?.model;
826
+ if (!isStaffRole(role)) return [];
827
+ if (typeof agent !== "string" || !Object.hasOwn(AGENTS, agent)) return [];
828
+ if (typeof model !== "string" || !isModelIdFor(agent, model)) return [];
829
+ crew.push({ role, agentId: agent, model });
830
+ }
831
+ return crew;
832
+ }
833
+ function parseBlockedBy(value, self) {
834
+ if (!Array.isArray(value)) return [];
835
+ const seen = /* @__PURE__ */ new Set();
836
+ for (const entry of value) {
837
+ const id = str(entry);
838
+ if (!id || id === self) continue;
839
+ seen.add(id);
840
+ }
841
+ return [...seen];
842
+ }
843
+ function parseRuntimeFile(value) {
844
+ const raw = bag(value);
845
+ const id = str(raw?.id);
846
+ const categoryId = str(raw?.categoryId);
847
+ const repoPath = str(raw?.repoPath);
848
+ if (!raw || raw.type !== "work" || !id || !categoryId || !repoPath) return void 0;
849
+ const record = bag(raw.record);
850
+ const spent = bag(record?.budgetSpent);
851
+ const budget = parseBudget(bag(raw.budget));
852
+ const pr = parsePullRequest(bag(raw.pr));
853
+ const archive = parseArchive(bag(raw.archive));
854
+ return {
855
+ version: num(raw.version) ?? CHANNEL_FILE_VERSION,
856
+ id,
857
+ type: "work",
858
+ categoryId,
859
+ repoPath,
860
+ record: {
861
+ budgetSpent: { spentUsd: num(spent?.spentUsd) ?? 0, startedAt: num(spent?.startedAt) ?? 0 },
862
+ claims: parseClaimSet(record?.claims)
863
+ },
864
+ shifts: Array.isArray(raw.shifts) ? raw.shifts.map(parseShift).filter((entry) => entry !== void 0) : [],
865
+ worktrees: firstWorktreePerMember(
866
+ Array.isArray(raw.worktrees) ? raw.worktrees.map(parseWorktree).filter((entry) => entry !== void 0) : []
867
+ ),
868
+ ...budget ? { budget } : {},
869
+ ...pr ? { pr } : {},
870
+ ...archive ? { archive } : {}
871
+ };
872
+ }
873
+ function parseBudget(raw) {
874
+ const limitUsd = num(raw?.limitUsd);
875
+ const endsAt = num(raw?.endsAt);
876
+ if (limitUsd === void 0 || endsAt === void 0) return void 0;
877
+ return {
878
+ limitUsd,
879
+ endsAt,
880
+ armedByMemberId: str(raw?.armedByMemberId) ?? "",
881
+ armedAt: num(raw?.armedAt) ?? 0
882
+ };
883
+ }
884
+ function parsePullRequest(raw) {
885
+ const ref = str(raw?.ref);
886
+ const status = raw?.status;
887
+ if (!ref || !isPrStatus(status)) return void 0;
888
+ return { ref, status, openedAt: num(raw?.openedAt) ?? 0 };
889
+ }
890
+ function parseArchive(raw) {
891
+ const endState = raw?.endState;
892
+ if (!isEndState(endState)) return void 0;
893
+ return { archivedAt: num(raw?.archivedAt) ?? 0, endState };
894
+ }
895
+ var PR_STATUSES = {
896
+ draft: true,
897
+ ready: true,
898
+ merged: true,
899
+ closed: true
900
+ };
901
+ var END_STATES = {
902
+ shipped: true,
903
+ held: true,
904
+ "out-of-money": true,
905
+ "out-of-clock": true,
906
+ died: true
907
+ };
908
+ function isPrStatus(value) {
909
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(PR_STATUSES, value);
910
+ }
911
+ function isEndState(value) {
912
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(END_STATES, value);
913
+ }
914
+ function parseFeedFile(value) {
915
+ assertThinFeed(value);
916
+ const raw = bag(value);
917
+ const id = str(raw?.id);
918
+ if (!raw || raw.type !== "feed" || !id) return void 0;
919
+ const categoryId = str(raw.categoryId);
920
+ const name = str(raw.name);
921
+ return {
922
+ version: num(raw.version) ?? CHANNEL_FILE_VERSION,
923
+ id,
924
+ type: "feed",
925
+ ...categoryId ? { categoryId } : {},
926
+ ...name ? { name } : {},
927
+ members: strList(raw.members)
928
+ };
929
+ }
930
+ function parseEmployedAgent(value, taken) {
931
+ const raw = bag(value);
932
+ const memberId = str(raw?.memberId);
933
+ const agent = agentId(raw?.agentId);
934
+ if (!memberId || !agent) return void 0;
935
+ const defaultModel = str(raw?.defaultModel);
936
+ const name = str(raw?.name);
937
+ return {
938
+ memberId,
939
+ agentId: agent,
940
+ ...name && !taken.has(name) ? { name } : {},
941
+ defaultRoleIds: strList(raw?.defaultRoleIds),
942
+ ...defaultModel ? { defaultModel } : {}
943
+ };
944
+ }
945
+ function parseCategory(value) {
946
+ const raw = bag(value);
947
+ const id = str(raw?.id);
948
+ const name = str(raw?.name);
949
+ const repoPath = str(raw?.repoPath);
950
+ if (!id || !name || !repoPath) return void 0;
951
+ const roster = [];
952
+ const taken = /* @__PURE__ */ new Set();
953
+ for (const seat of Array.isArray(raw?.roster) ? raw.roster : []) {
954
+ const employed = parseEmployedAgent(seat, taken);
955
+ if (!employed) continue;
956
+ if (employed.name) taken.add(employed.name);
957
+ roster.push(employed);
958
+ }
959
+ return { id, name, repoPath, leadMemberId: str(raw?.leadMemberId) ?? "", roster };
960
+ }
961
+ function parseCategoriesFile(value) {
962
+ const raw = bag(value);
963
+ const list = Array.isArray(raw?.categories) ? raw.categories : Array.isArray(value) ? value : [];
964
+ return {
965
+ version: num(raw?.version) ?? CHANNEL_FILE_VERSION,
966
+ categories: list.map(parseCategory).filter((entry) => entry !== void 0)
967
+ };
968
+ }
969
+
970
+ // src/core/factory/channels/store.ts
971
+ var ChannelStore = class {
972
+ constructor(userDataDir2, options = {}) {
973
+ this.userDataDir = userDataDir2;
974
+ this.now = options.now ?? (() => Date.now());
975
+ this.newId = options.newId ?? (() => randomUUID2());
976
+ this.rescue = options.rescue ?? true;
977
+ }
978
+ userDataDir;
979
+ now;
980
+ newId;
981
+ rescue;
982
+ /** The tail of the read-modify-write queue. Never rejects: each turn swallows before the next. */
983
+ pending = Promise.resolve();
984
+ /** Settle whatever turn is in flight. Tests await this. */
985
+ async flush() {
986
+ await this.pending.catch(() => void 0);
987
+ }
988
+ // -- categories -----------------------------------------------------------------------------
989
+ categories() {
990
+ return this.enqueue(() => this.readCategories());
991
+ }
992
+ category(id) {
993
+ return this.enqueue(async () => (await this.readCategories()).find((entry) => entry.id === id));
994
+ }
995
+ /** Create a category and hand back the category itself rather than the list: the caller has just
996
+ * named a thing and needs its id to make a channel under it. */
997
+ createCategory(input) {
998
+ return this.enqueue(async () => {
999
+ const category = createCategory({ ...input, id: input.id ?? this.newId() });
1000
+ const current = await this.readCategories();
1001
+ await this.writeCategories([...current.filter((entry) => entry.id !== category.id), category]);
1002
+ return category;
1003
+ });
1004
+ }
1005
+ /** Upsert by id. Order is the roster's only ordering and an existing id keeps its seat. */
1006
+ saveCategory(category) {
1007
+ return this.enqueue(async () => {
1008
+ const current = await this.readCategories();
1009
+ const at = current.findIndex((entry) => entry.id === category.id);
1010
+ const next = at === -1 ? [...current, category] : current.map((e, i) => i === at ? category : e);
1011
+ await this.writeCategories(next);
1012
+ return next;
1013
+ });
1014
+ }
1015
+ // -- channels -------------------------------------------------------------------------------
1016
+ /**
1017
+ * Create a work channel under a category and persist both halves at once.
1018
+ *
1019
+ * The repo comes from the category and is never passed in: one channel binds to exactly one repo,
1020
+ * inherited from its category (ticket 02). A channel whose category is gone cannot be created,
1021
+ * because there would be nowhere to commit its goal.
1022
+ */
1023
+ createWork(input) {
1024
+ return this.enqueue(async () => {
1025
+ const category = (await this.readCategories()).find((entry) => entry.id === input.categoryId);
1026
+ if (!category) throw new Error(`No category ${input.categoryId}, so there is no repo to commit its goal to.`);
1027
+ const channel2 = createWorkChannel({ ...input, id: input.id ?? this.newId(), now: this.now() });
1028
+ await this.writeWork(channel2, category.repoPath, {});
1029
+ return channel2;
1030
+ });
1031
+ }
1032
+ createFeed(input) {
1033
+ return this.enqueue(async () => {
1034
+ const channel2 = createFeedChannel({ ...input, id: input.id ?? this.newId() });
1035
+ await writeJson(feedRecordPath(this.userDataDir, channel2.id), toFeedFile(channel2));
1036
+ return channel2;
1037
+ });
1038
+ }
1039
+ /** Persist a channel that already exists. Claims are passed separately, and omitting them keeps
1040
+ * whatever is already on disk rather than clearing it: a caller saving a stage transition has no
1041
+ * business dropping another agent's claims. */
1042
+ save(channel2, claims) {
1043
+ return this.enqueue(async () => {
1044
+ if (!isWorkChannel(channel2)) {
1045
+ await writeJson(feedRecordPath(this.userDataDir, channel2.id), toFeedFile(channel2));
1046
+ return;
1047
+ }
1048
+ const repoPath = await this.repoPathFor(channel2);
1049
+ const held = claims ?? (await this.readRuntime(channel2.id))?.record.claims ?? {};
1050
+ await this.writeWork(channel2, repoPath, held);
1051
+ });
1052
+ }
1053
+ /** Replace the claims held against a channel, leaving both other halves alone. */
1054
+ saveClaims(channelId, claims) {
1055
+ return this.enqueue(async () => {
1056
+ const runtime = await this.readRuntime(channelId);
1057
+ if (!runtime) throw new Error(`No channel ${channelId} in userData, so its claims have nowhere to go.`);
1058
+ await writeJson(runtimeRecordPath(this.locationOf(channelId, runtime.repoPath)), {
1059
+ ...runtime,
1060
+ record: { ...runtime.record, claims }
1061
+ });
1062
+ });
1063
+ }
1064
+ /** One channel, both halves, or undefined when there is no such channel. A work channel whose
1065
+ * committed half is missing is NOT half-loaded: the goal and the pipeline are the channel, and a
1066
+ * channel with neither cannot say what it is doing or where it is. */
1067
+ load(channelId) {
1068
+ return this.enqueue(() => this.readChannel(channelId));
1069
+ }
1070
+ /** A work channel and the claims held against it. Undefined for a feed channel, which holds none. */
1071
+ loadWork(channelId) {
1072
+ return this.enqueue(async () => {
1073
+ const runtime = await this.readRuntime(channelId);
1074
+ if (!runtime) return void 0;
1075
+ const channel2 = await this.readChannel(channelId);
1076
+ if (!channel2 || !isWorkChannel(channel2)) return void 0;
1077
+ return { channel: channel2, claims: runtime.record.claims };
1078
+ });
1079
+ }
1080
+ /**
1081
+ * Every channel this machine knows about, work and feed, and every one it could not read.
1082
+ *
1083
+ * The failures come back rather than being swallowed. A `catch (() => undefined)` here made a
1084
+ * channel with a bad record vanish from the listing with no error and no log line, which is a
1085
+ * worse version of the mistake `assertThinFeed` throws to avoid: the surface renders a room short
1086
+ * and nobody can say which room or why. A caller that only wants the good ones reads `channels`.
1087
+ *
1088
+ * No order is promised. Neither store has one, so the caller sorts by whatever it is showing.
1089
+ */
1090
+ list(categoryId) {
1091
+ return this.enqueue(async () => {
1092
+ const ids = await this.channelIds();
1093
+ const read = await Promise.all(
1094
+ ids.map(async (channelId) => {
1095
+ try {
1096
+ return { channelId, channel: await this.readChannel(channelId) };
1097
+ } catch (error) {
1098
+ return { channelId, reason: error instanceof Error ? error.message : String(error) };
1099
+ }
1100
+ })
1101
+ );
1102
+ const channels2 = [];
1103
+ const failed = [];
1104
+ for (const entry of read) {
1105
+ if ("reason" in entry && entry.reason !== void 0) {
1106
+ failed.push({ channelId: entry.channelId, reason: entry.reason });
1107
+ continue;
1108
+ }
1109
+ const channel2 = "channel" in entry ? entry.channel : void 0;
1110
+ if (!channel2) continue;
1111
+ if (categoryId === void 0 || channel2.categoryId === categoryId) channels2.push(channel2);
1112
+ }
1113
+ return { channels: channels2, failed };
1114
+ });
1115
+ }
1116
+ /**
1117
+ * Archive a work channel: end every open shift, record the end state, delete nothing.
1118
+ *
1119
+ * Worktrees stay on disk. Ticket 02 removes a worktree only after the channel's PR merges, never
1120
+ * on archive alone, because a worktree holding unmerged commits is exactly the irreversible act
1121
+ * the map bans. Feed channels have no archive lifecycle and this refuses one.
1122
+ */
1123
+ archive(channelId, endState) {
1124
+ return this.enqueue(async () => {
1125
+ const channel2 = await this.readChannel(channelId);
1126
+ if (!channel2) throw new Error(`No channel ${channelId} to archive.`);
1127
+ if (!isWorkChannel(channel2)) {
1128
+ throw new Error("A feed channel never ships, so it has no shift to end and cannot be archived.");
1129
+ }
1130
+ const archived = archiveChannel(channel2, endState, this.now());
1131
+ const runtime = await this.readRuntime(channelId);
1132
+ await this.writeWork(archived, await this.repoPathFor(channel2), runtime?.record.claims ?? {});
1133
+ return archived;
1134
+ });
1135
+ }
1136
+ // -- the disk -------------------------------------------------------------------------------
1137
+ locationOf(channelId, repoPath) {
1138
+ return channelLocation(this.userDataDir, repoPath, channelId);
1139
+ }
1140
+ async repoPathFor(channel2) {
1141
+ const runtime = await this.readRuntime(channel2.id);
1142
+ if (runtime) return runtime.repoPath;
1143
+ const category = (await this.readCategories()).find((entry) => entry.id === channel2.categoryId);
1144
+ if (!category) {
1145
+ throw new Error(`No category ${channel2.categoryId}, so there is no repo to commit ${channel2.id} to.`);
1146
+ }
1147
+ return category.repoPath;
1148
+ }
1149
+ async writeWork(channel2, repoPath, claims) {
1150
+ assertOneWorktreePerMember(channel2.worktrees);
1151
+ const location = this.locationOf(channel2.id, repoPath);
1152
+ const { repo, runtime } = splitWork(channel2, repoPath, claims);
1153
+ await writeJson(repoRecordPath(location), repo);
1154
+ await writeJson(runtimeRecordPath(location), runtime);
1155
+ }
1156
+ async readRuntime(channelId) {
1157
+ const raw = await readJson(runtimePathFor(this.userDataDir, channelId), { rescue: this.rescue });
1158
+ return raw === void 0 ? void 0 : parseRuntimeFile(raw);
1159
+ }
1160
+ async readChannel(channelId) {
1161
+ const feedRaw = await readJson(feedRecordPath(this.userDataDir, channelId), { rescue: this.rescue });
1162
+ if (feedRaw !== void 0) {
1163
+ const feed = parseFeedFile(feedRaw);
1164
+ return feed ? fromFeedFile(feed) : void 0;
1165
+ }
1166
+ const runtime = await this.readRuntime(channelId);
1167
+ if (runtime) {
1168
+ const repoRaw = await this.readRepoRecord(this.locationOf(channelId, runtime.repoPath));
1169
+ if (repoRaw === void 0) return void 0;
1170
+ const repo = parseRepoFile(repoRaw);
1171
+ return repo ? joinWork(repo, runtime) : void 0;
1172
+ }
1173
+ return this.adopt(channelId);
1174
+ }
1175
+ /**
1176
+ * A committed half with no runtime half beside it, read back from the repo.
1177
+ *
1178
+ * This is what makes the split worth having on a second machine. A teammate clones the repo, or
1179
+ * the user clears userData, and `<repoPath>/.skribbl/channels/<id>.json` still holds the goal, the
1180
+ * stage and the memory. Without this the bytes survive and nothing can read them, which is half
1181
+ * the reason ticket 02 committed them.
1182
+ *
1183
+ * Every field of the runtime half is regenerable or a zeroed number, which is the same argument
1184
+ * `parseRuntimeFile` already makes when a field is missing. A half that is missing entirely is the
1185
+ * limit of that argument, not an exception to it. Nothing is written here: adoption is a read, and
1186
+ * the next `save` puts the runtime half back on disk.
1187
+ *
1188
+ * It also makes a failed create harmless. `writeWork` writes the committed half first, so a
1189
+ * userData write that throws used to leave an unreadable orphan in the user's repo forever.
1190
+ */
1191
+ async adopt(channelId) {
1192
+ for (const category of await this.readCategories()) {
1193
+ const location = this.locationOf(channelId, category.repoPath);
1194
+ const raw = await this.readRepoRecord(location).catch(() => void 0);
1195
+ if (raw === void 0) continue;
1196
+ const repo = parseRepoFile(raw);
1197
+ if (!repo) continue;
1198
+ return joinWork(repo, emptyRuntimeFor(repo, category.repoPath));
1199
+ }
1200
+ return void 0;
1201
+ }
1202
+ /**
1203
+ * Every channel id this machine can reach, from BOTH stores.
1204
+ *
1205
+ * One directory per channel under `<userData>/factory/`, so those ids are the directory names,
1206
+ * and `categories.json` is a file and falls out of the listing on its own. The repo side is
1207
+ * walked as well, once per category, so a channel whose runtime half is gone is still listed and
1208
+ * still adoptable rather than being invisible until somebody happens to know its id.
1209
+ */
1210
+ async channelIds() {
1211
+ const ids = /* @__PURE__ */ new Set();
1212
+ const entries = await readdir(join2(this.userDataDir, USER_DATA_FACTORY_DIR), {
1213
+ withFileTypes: true
1214
+ }).catch(() => []);
1215
+ for (const entry of entries) {
1216
+ if (entry.isDirectory()) ids.add(entry.name);
1217
+ }
1218
+ const repos = new Set((await this.readCategories()).map((category) => category.repoPath));
1219
+ for (const repoPath of repos) {
1220
+ for (const dir of [REPO_CHANNELS_DIR, LEGACY_REPO_CHANNELS_DIR]) {
1221
+ const files = await readdir(join2(repoPath, dir)).catch(() => []);
1222
+ for (const name of files) {
1223
+ if (name.endsWith(".json")) ids.add(name.slice(0, -".json".length));
1224
+ }
1225
+ }
1226
+ }
1227
+ return [...ids];
1228
+ }
1229
+ /**
1230
+ * The committed half, from `.wayari/channels` first and `.skribbl/channels` second.
1231
+ *
1232
+ * The new path wins when both exist, because it is the one every write since the rename made and
1233
+ * so the newer of the two. The old one is read, never rewritten and never removed: see
1234
+ * `LEGACY_REPO_CHANNELS_DIR`. `writeWork` writes the new path only, so a channel touched after the
1235
+ * rename has its current record there and a stale copy under the old name that this order ignores.
1236
+ */
1237
+ async readRepoRecord(location) {
1238
+ const current = await readJson(repoRecordPath(location), { rescue: this.rescue });
1239
+ if (current !== void 0) return current;
1240
+ return readJson(legacyRepoRecordPath(location), { rescue: this.rescue });
1241
+ }
1242
+ async readCategories() {
1243
+ const raw = await readJson(categoriesPath(this.userDataDir), { rescue: this.rescue });
1244
+ return raw === void 0 ? [] : parseCategoriesFile(raw).categories;
1245
+ }
1246
+ async writeCategories(categories) {
1247
+ await writeJson(categoriesPath(this.userDataDir), { version: CHANNEL_FILE_VERSION, categories });
1248
+ }
1249
+ enqueue(work) {
1250
+ const turn = this.pending.catch(() => void 0).then(work);
1251
+ this.pending = turn.catch(() => void 0);
1252
+ return turn;
1253
+ }
1254
+ };
1255
+
1256
+ // src/core/factory/host/userdata.ts
1257
+ import { existsSync } from "node:fs";
1258
+ import { homedir } from "node:os";
1259
+ import { join as join3 } from "node:path";
1260
+ function appUserDataCandidates(probe) {
1261
+ if (probe.platform === "darwin") {
1262
+ const base2 = join3(probe.homeDir, "Library", "Application Support");
1263
+ return [join3(base2, "wayari"), join3(base2, "skribbl"), join3(base2, "Skribbl")];
1264
+ }
1265
+ if (probe.platform === "win32") {
1266
+ const base2 = probe.env.APPDATA ?? join3(probe.homeDir, "AppData", "Roaming");
1267
+ return [join3(base2, "wayari"), join3(base2, "skribbl"), join3(base2, "Skribbl")];
1268
+ }
1269
+ const base = probe.env.XDG_CONFIG_HOME ?? join3(probe.homeDir, ".config");
1270
+ return [join3(base, "wayari"), join3(base, "skribbl"), join3(base, "Skribbl")];
1271
+ }
1272
+ function cliUserData(homeDir) {
1273
+ return join3(homeDir, ".wayari");
1274
+ }
1275
+ function defaultUserData(probe = liveProbe()) {
1276
+ const named = probe.env.WAYARI_HOME;
1277
+ if (named) return named;
1278
+ for (const dir of appUserDataCandidates(probe)) if (probe.exists(dir)) return dir;
1279
+ return cliUserData(probe.homeDir);
1280
+ }
1281
+ function liveProbe() {
1282
+ return { homeDir: homedir(), platform: process.platform, env: process.env, exists: existsSync };
1283
+ }
1284
+
1285
+ // src/shared/agents/usage.ts
1286
+ function emptyUsage() {
1287
+ return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, cacheCreate1h: 0 };
1288
+ }
1289
+ function addUsage(a, b) {
1290
+ return {
1291
+ input: a.input + b.input,
1292
+ output: a.output + b.output,
1293
+ cacheRead: a.cacheRead + b.cacheRead,
1294
+ cacheCreate: a.cacheCreate + b.cacheCreate,
1295
+ cacheCreate1h: a.cacheCreate1h + b.cacheCreate1h
1296
+ };
1297
+ }
1298
+ var CACHE_READ = 0.1;
1299
+ var CACHE_WRITE_5M = 1.25;
1300
+ var CACHE_WRITE_1H = 2;
1301
+ function card(input, output) {
1302
+ return {
1303
+ input,
1304
+ output,
1305
+ cacheRead: input * CACHE_READ,
1306
+ cacheCreate: input * CACHE_WRITE_5M,
1307
+ cacheCreate1h: input * CACHE_WRITE_1H
1308
+ };
1309
+ }
1310
+ var MODEL_RATES = {
1311
+ opus: card(5, 25),
1312
+ fable: card(10, 50),
1313
+ haiku: card(1, 5),
1314
+ sonnet: card(3, 15)
1315
+ };
1316
+ var FALLBACK_RATES = MODEL_RATES.sonnet;
1317
+ function cardFor(model) {
1318
+ if (!model) return void 0;
1319
+ const id = model.toLowerCase();
1320
+ for (const [tier2, rates] of Object.entries(MODEL_RATES)) {
1321
+ if (id.includes(tier2)) return rates;
1322
+ }
1323
+ return void 0;
1324
+ }
1325
+ function ratesFor(model) {
1326
+ return cardFor(model) ?? FALLBACK_RATES;
1327
+ }
1328
+ function ratesKnown(model) {
1329
+ return cardFor(model) !== void 0;
1330
+ }
1331
+ function costOf(usage2, model) {
1332
+ const rates = ratesFor(model);
1333
+ return (usage2.input * rates.input + usage2.output * rates.output + usage2.cacheRead * rates.cacheRead + usage2.cacheCreate * rates.cacheCreate + usage2.cacheCreate1h * rates.cacheCreate1h) / 1e6;
1334
+ }
1335
+ var WEEK_WINDOWS = 12;
1336
+ var WEEK_MS = 7 * 24 * 60 * 6e4;
1337
+ function tier(id, label, windowTokens) {
1338
+ return { id, label, windowTokens, weekTokens: windowTokens * WEEK_WINDOWS };
1339
+ }
1340
+ var PLAN_TIERS = [
1341
+ tier("pro", "Claude Pro", 8e6),
1342
+ tier("max5", "Claude Max 5x", 4e7),
1343
+ tier("max20", "Claude Max 20x", 16e7),
1344
+ tier("api", "API key or credits", 0)
1345
+ ];
1346
+
1347
+ // src/core/factory/evidence/parse.ts
1348
+ var EVIDENCE_FILE_VERSION = 1;
1349
+ var KINDS = [
1350
+ "answered-gate",
1351
+ "forced-gate",
1352
+ "answered-approval",
1353
+ "killed",
1354
+ "corrected-agent"
1355
+ ];
1356
+ function bag2(value) {
1357
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
1358
+ }
1359
+ function str2(value) {
1360
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1361
+ }
1362
+ function count(value) {
1363
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
1364
+ }
1365
+ function usage(value) {
1366
+ const raw = bag2(value);
1367
+ if (!raw) return emptyUsage();
1368
+ const out = emptyUsage();
1369
+ for (const field of Object.keys(out)) {
1370
+ out[field] = count(raw[field]) ?? 0;
1371
+ }
1372
+ return out;
1373
+ }
1374
+ function intervention(value) {
1375
+ const raw = bag2(value);
1376
+ if (!raw) return void 0;
1377
+ const kind = KINDS.find((candidate) => candidate === raw.kind);
1378
+ const at = count(raw.at);
1379
+ if (!kind || at === void 0) return void 0;
1380
+ const memberId = str2(raw.memberId);
1381
+ return {
1382
+ kind,
1383
+ at,
1384
+ ...memberId ? { memberId } : {},
1385
+ note: str2(raw.note) ?? kind
1386
+ };
1387
+ }
1388
+ function baseline(value) {
1389
+ const raw = bag2(value);
1390
+ if (!raw) return void 0;
1391
+ const costUsd = count(raw.costUsd);
1392
+ const minutes2 = count(raw.minutes);
1393
+ const recordedAt = count(raw.recordedAt);
1394
+ if (costUsd === void 0 || minutes2 === void 0 || recordedAt === void 0) return void 0;
1395
+ return { costUsd, minutes: minutes2, note: str2(raw.note) ?? "", recordedAt };
1396
+ }
1397
+ function parseEvidenceFile(value) {
1398
+ const raw = bag2(value);
1399
+ if (!raw) return void 0;
1400
+ const body = bag2(raw.evidence);
1401
+ if (!body) return void 0;
1402
+ const channelId = str2(body.channelId);
1403
+ const goalStatedAt = count(body.goalStatedAt);
1404
+ if (!channelId || goalStatedAt === void 0) return void 0;
1405
+ const prOpenedAt = count(body.prOpenedAt);
1406
+ const prMergedAt = count(body.prMergedAt);
1407
+ const held = baseline(body.baseline);
1408
+ const rows2 = Array.isArray(body.interventions) ? body.interventions : [];
1409
+ return {
1410
+ version: count(raw.version) ?? EVIDENCE_FILE_VERSION,
1411
+ evidence: {
1412
+ channelId,
1413
+ goalStatedAt,
1414
+ ...prOpenedAt !== void 0 ? { prOpenedAt } : {},
1415
+ ...prMergedAt !== void 0 ? { prMergedAt } : {},
1416
+ costUsd: count(body.costUsd) ?? 0,
1417
+ tokens: usage(body.tokens),
1418
+ interventions: rows2.map(intervention).filter((row) => row !== void 0),
1419
+ gateFailures: count(body.gateFailures) ?? 0,
1420
+ ...held ? { baseline: held } : {}
1421
+ }
1422
+ };
1423
+ }
1424
+
1425
+ // src/core/factory/evidence/record.ts
1426
+ function newEvidence(channelId, goalStatedAt) {
1427
+ return {
1428
+ channelId,
1429
+ goalStatedAt,
1430
+ costUsd: 0,
1431
+ tokens: emptyUsage(),
1432
+ interventions: [],
1433
+ gateFailures: 0
1434
+ };
1435
+ }
1436
+ function withSpend(evidence, delta) {
1437
+ return {
1438
+ ...evidence,
1439
+ costUsd: evidence.costUsd + Math.max(0, delta.costUsd),
1440
+ tokens: addUsage(evidence.tokens, delta.usage)
1441
+ };
1442
+ }
1443
+ function withIntervention(evidence, record) {
1444
+ return { ...evidence, interventions: [...evidence.interventions, record] };
1445
+ }
1446
+ function withGateResult(evidence, result) {
1447
+ if (result.passed) return evidence;
1448
+ return { ...evidence, gateFailures: evidence.gateFailures + 1 };
1449
+ }
1450
+ function withPrOpened(evidence, at) {
1451
+ if (evidence.prOpenedAt !== void 0) return evidence;
1452
+ return { ...evidence, prOpenedAt: at };
1453
+ }
1454
+ function withPrMerged(evidence, at) {
1455
+ return { ...evidence, prMergedAt: at };
1456
+ }
1457
+ function withBaseline(evidence, baseline2) {
1458
+ return { ...evidence, baseline: baseline2 };
1459
+ }
1460
+ function summarize(evidence) {
1461
+ return {
1462
+ channelId: evidence.channelId,
1463
+ costUsd: evidence.costUsd,
1464
+ ...evidence.prMergedAt !== void 0 ? { costPerMergedPrUsd: evidence.costUsd } : {},
1465
+ ...evidence.prOpenedAt !== void 0 ? { wallClockMs: Math.max(0, evidence.prOpenedAt - evidence.goalStatedAt) } : {},
1466
+ interventions: evidence.interventions.length,
1467
+ gateFailures: evidence.gateFailures,
1468
+ ...evidence.baseline ? { baseline: evidence.baseline } : {}
1469
+ };
1470
+ }
1471
+
1472
+ // src/core/factory/evidence/store.ts
1473
+ var SPEND_WRITE_DELAY_MS = 5e3;
1474
+ var EvidenceStore = class _EvidenceStore {
1475
+ constructor(path, evidence, options) {
1476
+ this.path = path;
1477
+ this.current = evidence;
1478
+ this.now = options.now ?? (() => Date.now());
1479
+ this.spendWriteDelayMs = options.spendWriteDelayMs ?? SPEND_WRITE_DELAY_MS;
1480
+ }
1481
+ path;
1482
+ current;
1483
+ pending = Promise.resolve();
1484
+ writeTimer;
1485
+ now;
1486
+ spendWriteDelayMs;
1487
+ /**
1488
+ * Open a channel's evidence, starting a fresh account when there is none.
1489
+ *
1490
+ * `goalStatedAt` is only used for a channel with no file yet, because it is the start of clock 2
1491
+ * and a reopened channel already has one. Passing the current time on every open would restart the
1492
+ * clock at every app launch and make every wall-clock reading a lie.
1493
+ */
1494
+ static async open(input) {
1495
+ const path = evidencePathFor(input.userDataDir, input.channelId);
1496
+ const raw = await readJson(path);
1497
+ const file = raw === void 0 ? void 0 : parseEvidenceFile(raw);
1498
+ const evidence = file?.evidence ?? newEvidence(input.channelId, input.goalStatedAt);
1499
+ return new _EvidenceStore(path, { ...evidence, channelId: input.channelId }, input.options ?? {});
1500
+ }
1501
+ /** What has been observed so far, as a copy. Handing back the live object would let a caller edit
1502
+ * a channel's account through a getter. */
1503
+ read() {
1504
+ return { ...this.current, tokens: { ...this.current.tokens }, interventions: [...this.current.interventions] };
1505
+ }
1506
+ /** The four numbers. */
1507
+ summary() {
1508
+ return summarize(this.current);
1509
+ }
1510
+ /** One spend delta. Debounced: see the header. */
1511
+ spend(delta) {
1512
+ this.current = withSpend(this.current, delta);
1513
+ this.scheduleWrite();
1514
+ }
1515
+ /** One human act. Lands on disk before this resolves. */
1516
+ intervention(record) {
1517
+ this.current = withIntervention(this.current, record);
1518
+ return this.persist();
1519
+ }
1520
+ /** One gate run. A pass is recorded as nothing, which is correct: number 4 counts failures. */
1521
+ gateResult(result) {
1522
+ const next = withGateResult(this.current, result);
1523
+ if (next === this.current) return Promise.resolve();
1524
+ this.current = next;
1525
+ return this.persist();
1526
+ }
1527
+ prOpened(at = this.now()) {
1528
+ const next = withPrOpened(this.current, at);
1529
+ if (next === this.current) return Promise.resolve();
1530
+ this.current = next;
1531
+ return this.persist();
1532
+ }
1533
+ prMerged(at = this.now()) {
1534
+ this.current = withPrMerged(this.current, at);
1535
+ return this.persist();
1536
+ }
1537
+ /** The hand-built figure the human supplies. This is the only field nothing observes. */
1538
+ baseline(baseline2) {
1539
+ this.current = withBaseline(this.current, baseline2);
1540
+ return this.persist();
1541
+ }
1542
+ /** Write now, so a debounced spend cannot be lost on quit. */
1543
+ async flush() {
1544
+ if (this.writeTimer !== void 0) {
1545
+ clearTimeout(this.writeTimer);
1546
+ this.writeTimer = void 0;
1547
+ }
1548
+ await this.persist();
1549
+ }
1550
+ scheduleWrite() {
1551
+ if (this.writeTimer !== void 0) return;
1552
+ this.writeTimer = setTimeout(() => {
1553
+ this.writeTimer = void 0;
1554
+ void this.persist().catch(() => void 0);
1555
+ }, this.spendWriteDelayMs);
1556
+ this.writeTimer.unref?.();
1557
+ }
1558
+ persist() {
1559
+ return this.enqueue(async () => {
1560
+ await writeJson(this.path, { version: EVIDENCE_FILE_VERSION, evidence: this.current });
1561
+ });
1562
+ }
1563
+ enqueue(work) {
1564
+ const turn = this.pending.catch(() => void 0).then(work);
1565
+ this.pending = turn.catch(() => void 0);
1566
+ return turn;
1567
+ }
1568
+ };
1569
+
1570
+ // src/core/factory/routing/store.ts
1571
+ import { join as join4 } from "node:path";
1572
+
1573
+ // src/core/factory/routing/record.ts
1574
+ function withRouteOutcome(record, input) {
1575
+ const byModel = input.usageByModel;
1576
+ const models = byModel ? Object.keys(byModel) : [];
1577
+ const usage2 = byModel ? models.reduce((sum, model) => addUsage(sum, byModel[model]), emptyUsage()) : void 0;
1578
+ const priceable = byModel !== void 0 && models.length > 0 && models.every((model) => ratesKnown(model));
1579
+ const usd = priceable ? models.reduce((sum, model) => sum + costOf(byModel[model], model), 0) : void 0;
1580
+ const outcome = {
1581
+ settledAt: input.settledAt,
1582
+ gatePassed: input.gatePassed,
1583
+ failures: input.failures ?? [],
1584
+ ...usage2 ? { usage: usage2 } : {},
1585
+ ...byModel ? { usageByModel: { ...byModel } } : {},
1586
+ ...usd !== void 0 ? { usd } : {},
1587
+ wallMs: Math.max(0, (input.endedAt ?? input.settledAt) - record.decidedAt),
1588
+ held: input.held ?? false,
1589
+ ...input.subagents !== void 0 ? { subagents: input.subagents } : {},
1590
+ ...input.firstClaimAt !== void 0 ? { firstClaimAt: input.firstClaimAt } : {}
1591
+ };
1592
+ return { ...record, outcome };
1593
+ }
1594
+
1595
+ // src/core/factory/routing/store.ts
1596
+ var ROUTES_FILE_VERSION = 1;
1597
+ function routesPathFor(userDataDir2, channelId) {
1598
+ return join4(userDataDir2, USER_DATA_FACTORY_DIR, sanitizeChannelId(channelId), "routes.json");
1599
+ }
1600
+ function parseRecord(value) {
1601
+ if (!value || typeof value !== "object") return void 0;
1602
+ const row = value;
1603
+ if (typeof row.decidedAt !== "number" || !Number.isFinite(row.decidedAt)) return void 0;
1604
+ if (typeof row.channelId !== "string" || !row.channelId) return void 0;
1605
+ if (typeof row.member !== "string" || !row.member) return void 0;
1606
+ if (!row.chosen || typeof row.chosen !== "object") return void 0;
1607
+ if (!Array.isArray(row.considered)) return void 0;
1608
+ return row;
1609
+ }
1610
+ function parseFile(value) {
1611
+ if (!value || typeof value !== "object") return [];
1612
+ const file = value;
1613
+ if (!Array.isArray(file.records)) return [];
1614
+ return file.records.map(parseRecord).filter((record) => record !== void 0);
1615
+ }
1616
+ var RouteStore = class _RouteStore {
1617
+ constructor(path, records) {
1618
+ this.path = path;
1619
+ this.records = records;
1620
+ }
1621
+ path;
1622
+ records;
1623
+ pending = Promise.resolve();
1624
+ /** Open a channel's records, starting an empty list when there are none. */
1625
+ static async open(input) {
1626
+ const path = routesPathFor(input.userDataDir, input.channelId);
1627
+ const raw = await readJson(path);
1628
+ return new _RouteStore(path, parseFile(raw));
1629
+ }
1630
+ /** What has been decided so far, oldest first, as copies. Handing back the live rows would let a
1631
+ * caller edit the training set through a getter. */
1632
+ all() {
1633
+ return this.records.map((record) => ({ ...record }));
1634
+ }
1635
+ /** One decision, written the moment the shift is briefed. */
1636
+ add(record) {
1637
+ this.records = [...this.records, record];
1638
+ return this.persist();
1639
+ }
1640
+ /**
1641
+ * The outcome half, matched to its decision by member and `decidedAt`.
1642
+ *
1643
+ * Both halves of the key, because a member runs several shifts in a channel and the second one
1644
+ * must not overwrite the first one's label. Answers null when no decision matches, which is a
1645
+ * caller settling a shift this store never saw rather than an error worth throwing at a quit path.
1646
+ */
1647
+ settle(member, decidedAt, outcome) {
1648
+ const index = this.records.findIndex(
1649
+ (record) => record.member === member && record.decidedAt === decidedAt
1650
+ );
1651
+ if (index === -1) return Promise.resolve(null);
1652
+ const settled = withRouteOutcome(this.records[index], outcome);
1653
+ this.records = this.records.map((record, at) => at === index ? settled : record);
1654
+ return this.persist().then(() => settled);
1655
+ }
1656
+ /** Write now. Nothing here is debounced, so this only ever waits for a write already in flight. */
1657
+ async flush() {
1658
+ await this.persist();
1659
+ }
1660
+ persist() {
1661
+ return this.enqueue(async () => {
1662
+ const file = { version: ROUTES_FILE_VERSION, records: this.records };
1663
+ await writeJson(this.path, file);
1664
+ });
1665
+ }
1666
+ enqueue(work) {
1667
+ const turn = this.pending.catch(() => void 0).then(work);
1668
+ this.pending = turn.catch(() => void 0);
1669
+ return turn;
1670
+ }
1671
+ };
1672
+
1673
+ // scripts/evidence.mjs
1674
+ var argv = process.argv.slice(2);
1675
+ var positional = argv.filter((arg, index) => !arg.startsWith("--") && !argv[index - 1]?.startsWith("--"));
1676
+ function flagValue(name) {
1677
+ const index = argv.indexOf(name);
1678
+ return index >= 0 ? argv[index + 1] : void 0;
1679
+ }
1680
+ var userDataDir = flagValue("--userdata") ?? defaultUserData();
1681
+ var target = positional[0] ?? null;
1682
+ async function channelsOnDisk() {
1683
+ const factoryDir = join5(userDataDir, USER_DATA_FACTORY_DIR);
1684
+ const entries = await readdir2(factoryDir, { withFileTypes: true }).catch(() => []);
1685
+ const listing = await new ChannelStore(userDataDir, { rescue: false }).list().catch(() => ({ channels: [], failed: [] }));
1686
+ const known = new Map(listing.channels.filter(isWorkChannel).map((channel2) => [channel2.id, channel2]));
1687
+ const found = [];
1688
+ for (const entry of entries) {
1689
+ if (!entry.isDirectory()) continue;
1690
+ const runtime = parseRuntimeFile(await readJson(join5(factoryDir, entry.name, "runtime.json"), { rescue: false }));
1691
+ if (!runtime) continue;
1692
+ const record = known.get(runtime.id);
1693
+ found.push({
1694
+ id: runtime.id,
1695
+ name: record?.name ?? "(committed record not found)",
1696
+ stage: record?.pipeline.stage ?? "unknown",
1697
+ shifts: runtime.shifts,
1698
+ goalStatedAt: runtime.record.budgetSpent.startedAt
1699
+ });
1700
+ }
1701
+ return found.sort((a, b) => a.goalStatedAt - b.goalStatedAt);
1702
+ }
1703
+ var channels = await channelsOnDisk();
1704
+ if (!channels.length) {
1705
+ console.log(`No work channels under ${userDataDir}.`);
1706
+ process.exit(0);
1707
+ }
1708
+ function median(values) {
1709
+ const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
1710
+ if (!sorted.length) return void 0;
1711
+ const mid = Math.floor(sorted.length / 2);
1712
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
1713
+ }
1714
+ function minutes(ms) {
1715
+ return ms === void 0 ? "no reading" : `${(ms / 6e4).toFixed(1)} min`;
1716
+ }
1717
+ function dollars(usd) {
1718
+ return usd === void 0 ? "unpriced" : `$${usd.toFixed(2)}`;
1719
+ }
1720
+ function count2(n, singular, plural = `${singular}s`) {
1721
+ return `${n} ${n === 1 ? singular : plural}`;
1722
+ }
1723
+ async function fourNumbers(channel2) {
1724
+ if (!existsSync2(join5(userDataDir, USER_DATA_FACTORY_DIR, channel2.id, "evidence.json"))) return [" no evidence account"];
1725
+ const store = await EvidenceStore.open({ userDataDir, channelId: channel2.id, goalStatedAt: channel2.goalStatedAt });
1726
+ const summary = store.summary();
1727
+ const lines = [
1728
+ ` 1. cost ${dollars(summary.costUsd)}${summary.costPerMergedPrUsd !== void 0 ? ", and the PR merged, so that is the cost per merged PR" : ", PR not merged, so no cost per merged PR yet"}`,
1729
+ ` 2. wall clock ${summary.wallClockMs === void 0 ? "still running: the PR has not opened" : `${minutes(summary.wallClockMs)} from goal to PR open`}`,
1730
+ ` 3. ${count2(summary.interventions, "intervention")}`,
1731
+ ` 4. ${count2(summary.gateFailures, "gate failure")}`
1732
+ ];
1733
+ if (summary.baseline) {
1734
+ lines.push(
1735
+ ` by hand: ${dollars(summary.baseline.costUsd)} and ${summary.baseline.minutes} min, recorded ${new Date(summary.baseline.recordedAt).toISOString()}: ${summary.baseline.note}`
1736
+ );
1737
+ } else {
1738
+ lines.push(" by hand: not recorded. Add it with --baseline-usd, --baseline-minutes and --note.");
1739
+ }
1740
+ return lines;
1741
+ }
1742
+ function describeArm(name, rows2) {
1743
+ const labelled = rows2.filter((row) => row.outcome);
1744
+ const lines = [` ${name}: ${count2(rows2.length, "shift")}, ${count2(labelled.length, "labelled")}`];
1745
+ if (!labelled.length) return lines;
1746
+ const passed = labelled.filter((row) => row.outcome.gatePassed).length;
1747
+ lines.push(` gate passed for ${passed} of ${labelled.length}`);
1748
+ const orientation = labelled.filter((row) => row.outcome.firstClaimAt !== void 0).map((row) => row.outcome.firstClaimAt - row.decidedAt);
1749
+ lines.push(` decision to first claim, median ${minutes(median(orientation))} over ${count2(orientation.length, "shift")} with a claim`);
1750
+ const priced = labelled.filter((row) => row.outcome.usd !== void 0).map((row) => row.outcome.usd);
1751
+ lines.push(` cost, median ${dollars(median(priced))} over ${count2(priced.length, "priced shift")}`);
1752
+ const wall = labelled.map((row) => row.outcome.wallMs);
1753
+ lines.push(` wall clock, median ${minutes(median(wall))} over ${count2(wall.length, "shift")}`);
1754
+ return lines;
1755
+ }
1756
+ if (!target) {
1757
+ const rows2 = [];
1758
+ for (const channel2 of channels) {
1759
+ const routes2 = await RouteStore.open({ userDataDir, channelId: channel2.id });
1760
+ rows2.push(...routes2.all());
1761
+ }
1762
+ console.log(`${count2(channels.length, "work channel")} under ${userDataDir}, ${count2(rows2.length, "shift row")}.`);
1763
+ console.log("");
1764
+ for (const channel2 of channels) {
1765
+ const mine = rows2.filter((row) => row.channelId === channel2.id);
1766
+ const labelled = mine.filter((row) => row.outcome).length;
1767
+ console.log(`${channel2.id} ${channel2.name}, stage ${channel2.stage}, ${count2(mine.length, "shift")}, ${labelled} labelled`);
1768
+ for (const line of await fourNumbers(channel2)) console.log(line);
1769
+ }
1770
+ console.log("");
1771
+ const measured = rows2.filter((row) => row.atlas);
1772
+ console.log(`Atlas arms, over ${count2(measured.length, "shift")} whose arm was recorded (${rows2.length - measured.length} not measured):`);
1773
+ for (const line of describeArm("excerpted", measured.filter((row) => !row.atlas.withheld))) console.log(line);
1774
+ for (const line of describeArm("withheld", measured.filter((row) => row.atlas.withheld))) console.log(line);
1775
+ console.log("");
1776
+ const armedRows = rows2.filter((row) => row.handover);
1777
+ console.log(`Handover arms, over ${count2(armedRows.length, "shift")} run while the floor was set (${rows2.length - armedRows.length} before it):`);
1778
+ if (!armedRows.length) console.log(" none yet: HANDOVER_FLOOR_TOKENS is undefined until the orientation cost above is measured");
1779
+ for (const line of describeArm("armed", armedRows.filter((row) => row.handover.armed))) console.log(line);
1780
+ for (const line of describeArm("not armed", armedRows.filter((row) => !row.handover.armed))) console.log(line);
1781
+ console.log("");
1782
+ const counted = rows2.filter((row) => row.outcome?.subagents !== void 0);
1783
+ console.log(`Delegation, over ${count2(counted.length, "shift")} whose transcript was read (${rows2.length - counted.length} unread):`);
1784
+ for (const line of describeArm("sent subagents", counted.filter((row) => row.outcome.subagents > 0))) console.log(line);
1785
+ for (const line of describeArm("sent none", counted.filter((row) => row.outcome.subagents === 0))) console.log(line);
1786
+ console.log("");
1787
+ const ordinaryRaw = rows2.filter((row) => row.attempt === 1 && (row.reason === "default-workhorse" || row.reason === "floored-workhorse"));
1788
+ const firstBy = /* @__PURE__ */ new Map();
1789
+ for (const row of ordinaryRaw) {
1790
+ const key = `${row.channelId}:${row.member}:${row.attempt}`;
1791
+ const seen = firstBy.get(key);
1792
+ if (!seen || row.decidedAt < seen.decidedAt) firstBy.set(key, row);
1793
+ }
1794
+ const ordinary = [...firstBy.values()];
1795
+ const byModel = /* @__PURE__ */ new Map();
1796
+ for (const row of ordinary) byModel.set(row.chosen.id, [...byModel.get(row.chosen.id) ?? [], row]);
1797
+ const collapsed = ordinaryRaw.length - ordinary.length;
1798
+ console.log(`First attempts at ordinary work, by model, over ${count2(ordinary.length, "shift")}${collapsed ? ` (${count2(collapsed, "handover row")} folded into the shift that started them)` : ""}:`);
1799
+ if (!ordinary.length) console.log(" none yet");
1800
+ for (const [model, group] of [...byModel.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1801
+ for (const line of describeArm(model, group)) console.log(line);
1802
+ }
1803
+ console.log(" WORKHORSE_FLOOR stays empty until two of these models each have enough labelled rows to put their pass rates");
1804
+ console.log(" into the break-even beside the constant; a dozen each is the least that reads.");
1805
+ console.log("");
1806
+ console.log("Pass a channel id to print its shifts: npm run evidence <channelId>");
1807
+ process.exit(0);
1808
+ }
1809
+ var channel = channels.find((candidate) => candidate.id === target || candidate.name === target);
1810
+ if (!channel) {
1811
+ console.error(`No work channel ${target} under ${userDataDir}.`);
1812
+ process.exit(1);
1813
+ }
1814
+ var baselineUsd = flagValue("--baseline-usd");
1815
+ var baselineMinutes = flagValue("--baseline-minutes");
1816
+ var note = flagValue("--note");
1817
+ if (baselineUsd !== void 0 || baselineMinutes !== void 0 || note !== void 0) {
1818
+ const usd = Number(baselineUsd);
1819
+ const mins = Number(baselineMinutes);
1820
+ if (!Number.isFinite(usd) || !Number.isFinite(mins) || !note) {
1821
+ console.error('A baseline needs all three: --baseline-usd <dollars> --baseline-minutes <minutes> --note "<what was built and how it was measured>".');
1822
+ process.exit(1);
1823
+ }
1824
+ const store = await EvidenceStore.open({ userDataDir, channelId: channel.id, goalStatedAt: channel.goalStatedAt });
1825
+ await store.baseline({ costUsd: usd, minutes: mins, note, recordedAt: Date.now() });
1826
+ console.log(`Recorded the hand-built baseline for ${channel.name}.`);
1827
+ console.log("");
1828
+ }
1829
+ console.log(`${channel.name} (${channel.id}), stage ${channel.stage}.`);
1830
+ for (const line of await fourNumbers(channel)) console.log(line);
1831
+ console.log("");
1832
+ var routes = await RouteStore.open({ userDataDir, channelId: channel.id });
1833
+ var rows = routes.all();
1834
+ console.log(`${count2(rows.length, "shift row")}:`);
1835
+ for (const row of rows) {
1836
+ const arm = row.atlas ? row.atlas.withheld ? "withheld" : `excerpted, ${row.atlas.pages} pages, ${row.atlas.chars} chars` : "arm not recorded";
1837
+ console.log(` ${row.member} attempt ${row.attempt} ${row.chosen.id} (${row.reason}) ${arm}`);
1838
+ const outcome = row.outcome;
1839
+ if (!outcome) {
1840
+ console.log(" no label: the gate has not answered for this shift, or the row was written before 2026-09-02");
1841
+ continue;
1842
+ }
1843
+ const gate = outcome.held ? "held" : outcome.gatePassed ? "gate passed" : `gate failed: ${outcome.failures.join(", ") || "no check named"}`;
1844
+ console.log(` ${gate}, ${minutes(outcome.wallMs)} wall, ${dollars(outcome.usd)}`);
1845
+ const claim = outcome.firstClaimAt === void 0 ? "no claim recorded" : `first claim after ${minutes(outcome.firstClaimAt - row.decidedAt)}`;
1846
+ const hired = outcome.subagents === void 0 ? "transcript unread" : count2(outcome.subagents, "subagent");
1847
+ console.log(` ${claim}, ${hired}`);
1848
+ }