@llm4ts/flow 2.40.1 → 2.42.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 (65) hide show
  1. package/dist/Activity.d.ts +6 -1
  2. package/dist/Activity.d.ts.map +1 -1
  3. package/dist/Activity.js +53 -5
  4. package/dist/Activity.js.map +1 -1
  5. package/dist/BoardSync.d.ts +3 -0
  6. package/dist/BoardSync.d.ts.map +1 -1
  7. package/dist/BoardSync.js +14 -0
  8. package/dist/BoardSync.js.map +1 -1
  9. package/dist/CostReport.d.ts +14 -1
  10. package/dist/CostReport.d.ts.map +1 -1
  11. package/dist/CostReport.js +43 -6
  12. package/dist/CostReport.js.map +1 -1
  13. package/dist/Flow.d.ts +26 -1
  14. package/dist/Flow.d.ts.map +1 -1
  15. package/dist/Flow.js +155 -133
  16. package/dist/Flow.js.map +1 -1
  17. package/dist/FlowContext.d.ts +5 -0
  18. package/dist/FlowContext.d.ts.map +1 -1
  19. package/dist/FlowContext.js.map +1 -1
  20. package/dist/FlowEvents.d.ts +69 -5
  21. package/dist/FlowEvents.d.ts.map +1 -1
  22. package/dist/FlowEvents.js +92 -6
  23. package/dist/FlowEvents.js.map +1 -1
  24. package/dist/Perimeter.d.ts +7 -0
  25. package/dist/Perimeter.d.ts.map +1 -1
  26. package/dist/Perimeter.js +31 -0
  27. package/dist/Perimeter.js.map +1 -1
  28. package/dist/Plan.d.ts +24 -0
  29. package/dist/Plan.d.ts.map +1 -1
  30. package/dist/Plan.js +52 -0
  31. package/dist/Plan.js.map +1 -1
  32. package/dist/PlanExecution.d.ts +39 -2
  33. package/dist/PlanExecution.d.ts.map +1 -1
  34. package/dist/PlanExecution.js +137 -3
  35. package/dist/PlanExecution.js.map +1 -1
  36. package/dist/Roster.d.ts +4 -2
  37. package/dist/Roster.d.ts.map +1 -1
  38. package/dist/Roster.js +50 -28
  39. package/dist/Roster.js.map +1 -1
  40. package/dist/RosterSeats.d.ts +11 -2
  41. package/dist/RosterSeats.d.ts.map +1 -1
  42. package/dist/RosterSeats.js +12 -5
  43. package/dist/RosterSeats.js.map +1 -1
  44. package/dist/Stories.d.ts +7 -0
  45. package/dist/Stories.d.ts.map +1 -1
  46. package/dist/Stories.js +157 -13
  47. package/dist/Stories.js.map +1 -1
  48. package/dist/Transcript.d.ts +4 -0
  49. package/dist/Transcript.d.ts.map +1 -1
  50. package/dist/Transcript.js +4 -0
  51. package/dist/Transcript.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/Activity.ts +73 -7
  54. package/src/BoardSync.ts +20 -0
  55. package/src/CostReport.ts +51 -6
  56. package/src/Flow.ts +108 -34
  57. package/src/FlowContext.ts +5 -0
  58. package/src/FlowEvents.ts +103 -6
  59. package/src/Perimeter.ts +36 -0
  60. package/src/Plan.ts +71 -0
  61. package/src/PlanExecution.ts +233 -4
  62. package/src/Roster.ts +60 -34
  63. package/src/RosterSeats.ts +31 -7
  64. package/src/Stories.ts +230 -17
  65. package/src/Transcript.ts +6 -0
package/src/CostReport.ts CHANGED
@@ -3,7 +3,7 @@ import * as Option from "effect/Option"
3
3
  import * as Schema from "effect/Schema"
4
4
  import type { TokenUsage } from "@llm4ts/core/Models"
5
5
  import { isEstimatedModel } from "./EstimatedUsage.ts"
6
- import { TokensUsed } from "./FlowEvents.ts"
6
+ import { TokensUsed, cloneName } from "./FlowEvents.ts"
7
7
  import type { TraceLine } from "./FlowRecorder.ts"
8
8
  import { estimateCostUsd, PricesAsOf } from "./PriceList.ts"
9
9
 
@@ -31,7 +31,10 @@ export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
31
31
  total: Schema.Int,
32
32
  cached: Schema.optionalKey(Schema.Int),
33
33
  costUsd: Schema.optionalKey(Schema.Number),
34
- estimated: Schema.Boolean
34
+ estimated: Schema.Boolean,
35
+ /** The lane's executor and clone, when the usage came from a roster lane (ADR 0033). */
36
+ executor: Schema.optionalKey(Schema.String),
37
+ clone: Schema.optionalKey(Schema.Int)
35
38
  }) {}
36
39
 
37
40
  export class UsageTotals extends Schema.Class<UsageTotals>("UsageTotals")({
@@ -87,6 +90,13 @@ export class UsageProjection extends Schema.Class<UsageProjection>("UsageProject
87
90
  costUsdPerDay: Schema.optionalKey(Schema.Number)
88
91
  }) {}
89
92
 
93
+ /** One executor clone's usage (`codex#2`): the coder's calls on a lane that holds that clone. */
94
+ export class ExecutorUsage extends Schema.Class<ExecutorUsage>("ExecutorUsage")({
95
+ executor: Schema.String,
96
+ totals: UsageTotals,
97
+ share: Schema.Number
98
+ }) {}
99
+
90
100
  export class CostReport extends Schema.Class<CostReport>("CostReport")({
91
101
  schemaVersion: Schema.Int,
92
102
  timeZone: Schema.String,
@@ -102,10 +112,12 @@ export class CostReport extends Schema.Class<CostReport>("CostReport")({
102
112
  byDay: Schema.Array(UsageBucket),
103
113
  byHour: Schema.Array(UsageBucket),
104
114
  byModel: Schema.Array(ModelUsage),
105
- byAgent: Schema.Array(AgentUsage)
115
+ byAgent: Schema.Array(AgentUsage),
116
+ /** Per executor clone, for lanes served by a roster (schema 3, ADR 0033). */
117
+ byExecutor: Schema.Array(ExecutorUsage)
106
118
  }) {}
107
119
 
108
- export const CurrentCostReportSchema = 2
120
+ export const CurrentCostReportSchema = 3
109
121
 
110
122
  const tokensUsedCodec = Schema.fromJsonString(TokensUsed)
111
123
  const decodeTokensUsed = Schema.decodeUnknownOption(tokensUsedCodec)
@@ -145,7 +157,9 @@ export const usageSamplesFromTrace = (
145
157
  total: event.usage.total,
146
158
  ...(event.usage.cached === undefined ? {} : { cached: event.usage.cached }),
147
159
  ...(costUsd === undefined ? {} : { costUsd }),
148
- estimated: isEstimatedModel(model)
160
+ estimated: isEstimatedModel(model),
161
+ ...(event.executor === undefined ? {} : { executor: event.executor }),
162
+ ...(event.clone === undefined ? {} : { clone: event.clone })
149
163
  })
150
164
  ]
151
165
  })
@@ -320,6 +334,26 @@ export const buildCostReport = (
320
334
  right.totals.total - left.totals.total || left.agent.localeCompare(right.agent)
321
335
  )
322
336
 
337
+ // Coder clones only: a sample that names the lane's executor without its
338
+ // clone is a reviewer's or judge's, which ran elsewhere (ADR 0033).
339
+ const byExecutorGroups = new Map<string, Array<UsageSample>>()
340
+ for (const sample of ordered) {
341
+ if (sample.executor !== undefined && sample.clone !== undefined) {
342
+ const key = cloneName(sample.executor, sample.clone)
343
+ byExecutorGroups.set(key, [...(byExecutorGroups.get(key) ?? []), sample])
344
+ }
345
+ }
346
+ const byExecutor = [...byExecutorGroups.entries()]
347
+ .map(([executor, members]) => {
348
+ const executorTotals = totalsOf(members)
349
+ return ExecutorUsage.make({
350
+ executor,
351
+ totals: executorTotals,
352
+ share: tokens === 0 ? 0 : executorTotals.total / tokens
353
+ })
354
+ })
355
+ .sort((left, right) => left.executor.localeCompare(right.executor))
356
+
323
357
  return CostReport.make({
324
358
  schemaVersion: CurrentCostReportSchema,
325
359
  timeZone: zoneLabel(timeZone),
@@ -349,7 +383,8 @@ export const buildCostReport = (
349
383
  byDay,
350
384
  byHour,
351
385
  byModel,
352
- byAgent
386
+ byAgent,
387
+ byExecutor
353
388
  })
354
389
  }
355
390
 
@@ -427,6 +462,16 @@ export const renderCostReport = (report: CostReport): string => {
427
462
  )
428
463
  )
429
464
  }
465
+ if (report.byExecutor.length > 0) {
466
+ sections.push(
467
+ "",
468
+ "by executor clone:",
469
+ ...report.byExecutor.map(
470
+ (entry) =>
471
+ ` ${entry.executor} ${thousands(entry.totals.total)} tokens (${Math.round(entry.share * 100)}%)${money(entry.totals.costUsd)}`
472
+ )
473
+ )
474
+ }
430
475
  const notes = [
431
476
  `costs are backend-reported where available, otherwise estimated from the pricing table (rates as of ${report.pricesAsOf} — may be stale)`,
432
477
  ...(report.estimated.requests === 0
package/src/Flow.ts CHANGED
@@ -2,6 +2,7 @@ import { join } from "node:path"
2
2
  import * as Clock from "effect/Clock"
3
3
  import * as Effect from "effect/Effect"
4
4
  import * as Ref from "effect/Ref"
5
+ import * as Semaphore from "effect/Semaphore"
5
6
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
6
7
  import { collect } from "@llm4ts/core/Streaming"
7
8
  import { withToolActivity } from "./Activity.ts"
@@ -25,10 +26,16 @@ import {
25
26
  TokensUsed,
26
27
  type FlowEventsShape
27
28
  } from "./FlowEvents.ts"
28
- import type { Plan, Task } from "./Plan.ts"
29
+ import { hasParallelTasks, type Plan, type Task } from "./Plan.ts"
29
30
  import type { PlainFileStoreShape, PlanStoreShape } from "./Persistence.ts"
30
31
  import { ensureBaseline } from "./GateTriage.ts"
31
- import { implementTaskLoop, stage } from "./PlanExecution.ts"
32
+ import {
33
+ implementTaskGraph,
34
+ implementTaskLoop,
35
+ stage,
36
+ type AsideOutcome,
37
+ type AsideRequest
38
+ } from "./PlanExecution.ts"
32
39
  import { truth } from "@llm4ts/core/judgment/Schemas"
33
40
  import { certaintyOf, decide, judgmentOf, type JudgmentMode } from "./Judgment.ts"
34
41
  import {
@@ -74,6 +81,30 @@ export const completeAndPublish = Effect.fn("@llm4ts/flow/Flow.completeAndPublis
74
81
  return response.content
75
82
  })
76
83
 
84
+ /** What a task run aside replaces in the plan's options (ADR 0034). */
85
+ export interface TaskOverrides {
86
+ /** The gates in the aside worktree; `undefined` runs none. */
87
+ readonly lint?: Effect.Effect<ReviewResult, FlowError> | undefined
88
+ readonly triage?: GateTriageOptions
89
+ /** Present to replace the evidence check: the aside coder's tool calls are elsewhere. */
90
+ readonly onTaskReply?: ImplementPlanOptions["onTaskReply"] | undefined
91
+ /** The coder's system prompt there: its own working directory, its own paths. */
92
+ readonly system?: string
93
+ }
94
+
95
+ /** Tasks beside the held coder (ADR 0034): how many at once, and who runs one. */
96
+ export interface ParallelPlanTasks {
97
+ readonly concurrency: number
98
+ /**
99
+ * Runs one ready task on another coder: `run` is the task's whole body
100
+ * (coder, review, gates, commit) in the context it is given.
101
+ */
102
+ readonly aside: (
103
+ request: AsideRequest,
104
+ run: (context: FlowContextShape, overrides: TaskOverrides) => Effect.Effect<void, FlowError>
105
+ ) => Effect.Effect<AsideOutcome, FlowError>
106
+ }
107
+
77
108
  export interface ImplementPlanOptions {
78
109
  readonly store: PlanStoreShape
79
110
  readonly planPath: string
@@ -86,6 +117,11 @@ export interface ImplementPlanOptions {
86
117
  * one Chat is shared across every task in the plan.
87
118
  */
88
119
  readonly chatPerTask?: boolean
120
+ /**
121
+ * Ready tasks run beside the held coder (ADR 0034), with `chatPerTask`
122
+ * only, and only in a plan whose tasks declare that they may.
123
+ */
124
+ readonly parallel?: ParallelPlanTasks
89
125
  readonly reviewers?: ReadonlyArray<Reviewer>
90
126
  readonly commitMessage?: (plan: Plan, task: Task) => string
91
127
  readonly checkoutBranch?: boolean
@@ -288,24 +324,26 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
288
324
  })
289
325
  }
290
326
 
291
- return yield* implementTaskLoop(
292
- options.store,
293
- context.events,
294
- options.planPath,
295
- plan,
296
- (task, planSoFar) =>
297
- Effect.gen(function* () {
327
+ const carryLock = yield* Semaphore.make(1)
328
+ /** The task's body in `ctx`: the plan's own context at home, another one aside (ADR 0034). */
329
+ const taskBody =
330
+ (ctx: FlowContextShape, over: TaskOverrides) =>
331
+ (task: Task, planSoFar: Plan): Effect.Effect<void, FlowError> => {
332
+ const lint = "lint" in over ? over.lint : options.lint
333
+ const onTaskReply = "onTaskReply" in over ? over.onTaskReply : options.onTaskReply
334
+ const system = over.system ?? options.system
335
+ return Effect.gen(function* () {
298
336
  // `sharedCoder`'s definedness mirrors `options.chatPerTask !== true`
299
337
  // above: when it's set, every task reuses it; when it's undefined,
300
338
  // chatPerTask is active and each task builds its own fresh Chat.
301
339
  let coder: Chat
302
- if (sharedCoder !== undefined) {
340
+ if (sharedCoder !== undefined && ctx === context) {
303
341
  coder = sharedCoder
304
342
  } else {
305
- coder = yield* makeChat(context.coder, {
306
- events: context.events,
343
+ coder = yield* makeChat(ctx.coder, {
344
+ events: ctx.events,
307
345
  agent: "coder",
308
- system: composeSystem(options.system, planSoFar.render),
346
+ system: composeSystem(system, planSoFar.render),
309
347
  ...(options.stall === undefined ? {} : { stall: options.stall })
310
348
  })
311
349
  }
@@ -319,11 +357,17 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
319
357
  const keepFindings = (reply: string): Effect.Effect<void, FlowError> =>
320
358
  Effect.gen(function* () {
321
359
  const found = findingsIn(reply)
322
- if (options.carry !== undefined && found !== undefined) {
323
- yield* options.carry.write(appendNote(notes, task.title, found))
360
+ const carry = options.carry
361
+ if (carry !== undefined && found !== undefined) {
362
+ // Tasks may run at once (ADR 0034): append to the notes as they are now.
363
+ yield* carryLock.withPermit(
364
+ Effect.flatMap(carry.read, (latest) =>
365
+ carry.write(appendNote(latest, task.title, found))
366
+ )
367
+ )
324
368
  }
325
- if (options.onTaskReply !== undefined) {
326
- yield* options.onTaskReply(task, reply, trailerIn(found), taskStartedAt)
369
+ if (onTaskReply !== undefined) {
370
+ yield* onTaskReply(task, reply, trailerIn(found), taskStartedAt)
327
371
  }
328
372
  })
329
373
  yield* keepFindings(
@@ -333,7 +377,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
333
377
  : `${withNotes(plan.taskPrompt(task), notes)}\n${findingsRequest}`
334
378
  )
335
379
  )
336
- const produced = yield* context.git.diffAll
380
+ const produced = yield* ctx.git.diffAll
337
381
  if (produced.trim().length === 0) {
338
382
  // An empty diff is ambiguous: the task may be genuinely satisfied
339
383
  // already, or the coder may simply have produced nothing. Ask
@@ -348,19 +392,19 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
348
392
  ].join("\n")
349
393
  )
350
394
  yield* keepFindings(confirmation)
351
- const afterConfirmation = yield* context.git.diffAll
395
+ const afterConfirmation = yield* ctx.git.diffAll
352
396
  if (afterConfirmation.trim().length === 0) {
353
397
  const judged =
354
398
  options.satisfiedProbe !== undefined && options.satisfiedProbe !== "literal"
355
399
  ? yield* satisfiedByJudgment(
356
- context,
400
+ ctx,
357
401
  task.title,
358
402
  confirmation,
359
403
  options.satisfiedProbe === "judgment" ? "act" : options.satisfiedProbe.mode
360
404
  )
361
405
  : undefined
362
406
  if (judged ?? confirmation.includes("TASK_ALREADY_SATISFIED")) {
363
- yield* context.events.publish(
407
+ yield* ctx.events.publish(
364
408
  Info.make({
365
409
  message: `task "${task.title}" confirmed already satisfied; skipping review and commit`
366
410
  })
@@ -368,7 +412,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
368
412
  return
369
413
  }
370
414
  if (options.noopTaskPolicy === "complete") {
371
- yield* context.events.publish(
415
+ yield* ctx.events.publish(
372
416
  Info.make({
373
417
  message: `task "${task.title}" produced no changes without confirming TASK_ALREADY_SATISFIED; marking complete per noopTaskPolicy`
374
418
  })
@@ -380,7 +424,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
380
424
  })
381
425
  }
382
426
  }
383
- const triage = yield* triageFor(context, options)
427
+ const triage = over.triage ?? (yield* triageFor(ctx, options))
384
428
  const repoRules = options.repoRules === undefined ? undefined : yield* options.repoRules
385
429
  const reviewers = [
386
430
  ...(options.reviewers ?? minimalReviewers),
@@ -390,8 +434,8 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
390
434
  options.fixer === "separate"
391
435
  ? (prompt: string): Effect.Effect<string, FlowError> =>
392
436
  Effect.flatMap(
393
- makeChat(context.coder, {
394
- events: context.events,
437
+ makeChat(ctx.coder, {
438
+ events: ctx.events,
395
439
  agent: "coder",
396
440
  system: withContract(fixerBrief),
397
441
  ...(options.stall === undefined ? {} : { stall: options.stall })
@@ -403,7 +447,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
403
447
  options.oracle === undefined
404
448
  ? undefined
405
449
  : {
406
- diff: context.git.diffAll,
450
+ diff: ctx.git.diffAll,
407
451
  ...(options.oracle.rules === undefined ? {} : { rules: options.oracle.rules }),
408
452
  ...(options.oracle.testsChange === undefined
409
453
  ? {}
@@ -414,14 +458,14 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
414
458
  }
415
459
  yield* reviewAndFixLoop({
416
460
  reviewers,
417
- reviewerService: flowReviewer(context),
461
+ reviewerService: flowReviewer(ctx),
418
462
  coder,
419
463
  taskTitle: task.title,
420
- currentDiff: context.git.diffAll,
421
- events: context.events,
464
+ currentDiff: ctx.git.diffAll,
465
+ events: ctx.events,
422
466
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
423
467
  ...(options.settle === undefined ? {} : { settle: options.settle }),
424
- ...(options.lint === undefined ? {} : { lint: options.lint }),
468
+ ...(lint === undefined ? {} : { lint }),
425
469
  ...(options.format === undefined ? {} : { format: options.format }),
426
470
  ...(triage === undefined ? {} : { triage }),
427
471
  ...(oracle === undefined ? {} : { oracle }),
@@ -439,12 +483,12 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
439
483
  : options.onReview(task, round, result, settled)
440
484
  })
441
485
  })
442
- if (options.lint !== undefined) {
486
+ if (lint !== undefined) {
443
487
  const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
444
488
  const gate = yield* withOracle(
445
- yield* applyTriage(yield* options.lint, triage, context.events, reported),
489
+ yield* applyTriage(yield* lint, triage, ctx.events, reported),
446
490
  oracle,
447
- context.events,
491
+ ctx.events,
448
492
  yield* Ref.make(true)
449
493
  )
450
494
  if (!gate.isClean) {
@@ -460,7 +504,37 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
460
504
  })
461
505
  }
462
506
  }
463
- yield* context.git.commitAll((options.commitMessage ?? defaultCommitMessage)(plan, task))
507
+ yield* ctx.git.commitAll((options.commitMessage ?? defaultCommitMessage)(plan, task))
464
508
  })
509
+ }
510
+
511
+ const parallel = options.parallel
512
+ if (
513
+ parallel !== undefined &&
514
+ parallel.concurrency > 1 &&
515
+ options.chatPerTask === true &&
516
+ hasParallelTasks(plan)
517
+ ) {
518
+ return yield* implementTaskGraph(
519
+ options.store,
520
+ context.events,
521
+ options.planPath,
522
+ plan,
523
+ taskBody(context, {}),
524
+ {
525
+ concurrency: parallel.concurrency,
526
+ aside: (request) =>
527
+ parallel.aside(request, (ctx, over) =>
528
+ taskBody(ctx, over)(request.node.task, request.planSoFar)
529
+ )
530
+ }
531
+ )
532
+ }
533
+ return yield* implementTaskLoop(
534
+ options.store,
535
+ context.events,
536
+ options.planPath,
537
+ plan,
538
+ taskBody(context, {})
465
539
  )
466
540
  })
@@ -16,6 +16,11 @@ export interface ContextOptions {
16
16
  readonly prefer?: string
17
17
  /** Who the context is for, in the roster's events ("story conto-overview"). */
18
18
  readonly label?: string
19
+ /**
20
+ * Take a coder only if one is free now, else fail `RosterExhausted` at
21
+ * once: a task offered aside never waits for a slot (ADR 0034).
22
+ */
23
+ readonly ifFree?: boolean
19
24
  }
20
25
 
21
26
  export interface FlowContextShape {
package/src/FlowEvents.ts CHANGED
@@ -88,6 +88,8 @@ export class Info extends Schema.TaggedClass<Info>()("Info", {
88
88
  export class ToolUse extends Schema.TaggedClass<ToolUse>()("ToolUse", {
89
89
  tool: Schema.String,
90
90
  args: Schema.String,
91
+ /** The tool call this one runs inside, when a harness delegated it to a sub-agent (ADR 0033). */
92
+ parent: Schema.optionalKey(Schema.String),
91
93
  /** The concurrent unit (a story) this event belongs to; absent for run-wide events. */
92
94
  lane: Schema.optionalKey(Schema.String),
93
95
  /** The roster executor working that lane, when there is one (ADR 0019). */
@@ -109,7 +111,9 @@ export class TokensUsed extends Schema.TaggedClass<TokensUsed>()("TokensUsed", {
109
111
  /** The concurrent unit (a story) this event belongs to; absent for run-wide events. */
110
112
  lane: Schema.optionalKey(Schema.String),
111
113
  /** The roster executor working that lane, when there is one (ADR 0019). */
112
- executor: Schema.optionalKey(Schema.String)
114
+ executor: Schema.optionalKey(Schema.String),
115
+ /** The executor's clone that made the call, when the lane's coder holds a slot (ADR 0033). */
116
+ clone: Schema.optionalKey(Schema.Int)
113
117
  }) {}
114
118
 
115
119
  /** One issue a review round found, as the operator sees it. */
@@ -270,9 +274,58 @@ export class StoryJudged extends Schema.TaggedClass<StoryJudged>()("StoryJudged"
270
274
  * The executor roster's events (ADR 0019, 0022). `label` names who holds the
271
275
  * lease — a story's id, or "the run" — so a view can put an executor on its lane.
272
276
  */
277
+ /** One task of a story's plan as the task loop announces it (ADR 0033). */
278
+ export const PlannedTask = Schema.Struct({
279
+ title: Schema.String,
280
+ completed: Schema.Boolean,
281
+ /** The acceptance criteria the task's description names (`Satisfies: 1, 3`). */
282
+ satisfies: Schema.optionalKey(Schema.Array(Schema.Int))
283
+ })
284
+
285
+ /** The task loop's plan, before its first task: every task in order (ADR 0033). */
286
+ export class TasksPlanned extends Schema.TaggedClass<TasksPlanned>()("TasksPlanned", {
287
+ tasks: Schema.Array(PlannedTask),
288
+ lane: Schema.optionalKey(Schema.String),
289
+ executor: Schema.optionalKey(Schema.String)
290
+ }) {}
291
+
292
+ /** A task begins: its position in the plan, from 1, and the plan's size (ADR 0033). */
293
+ export class TaskStarted extends Schema.TaggedClass<TaskStarted>()("TaskStarted", {
294
+ index: Schema.Int,
295
+ count: Schema.Int,
296
+ title: Schema.String,
297
+ satisfies: Schema.optionalKey(Schema.Array(Schema.Int)),
298
+ /** The task runs beside the story's held coder, in its own worktree (ADR 0034). */
299
+ parallel: Schema.optionalKey(Schema.Boolean),
300
+ /** The clone of the extra coder a parallel task holds (ADR 0034). */
301
+ clone: Schema.optionalKey(Schema.Int),
302
+ lane: Schema.optionalKey(Schema.String),
303
+ executor: Schema.optionalKey(Schema.String)
304
+ }) {}
305
+
306
+ /** A task is ticked in the plan (ADR 0033). */
307
+ export class TaskCompleted extends Schema.TaggedClass<TaskCompleted>()("TaskCompleted", {
308
+ index: Schema.Int,
309
+ count: Schema.Int,
310
+ title: Schema.String,
311
+ lane: Schema.optionalKey(Schema.String),
312
+ executor: Schema.optionalKey(Schema.String)
313
+ }) {}
314
+
315
+ /** A story moved on the epic's board (ADR 0033); the statuses are `BoardSync`'s. */
316
+ export class StoryStatusChanged extends Schema.TaggedClass<StoryStatusChanged>()(
317
+ "StoryStatusChanged",
318
+ {
319
+ id: Schema.String,
320
+ status: Schema.Literals(["planned", "active", "waiting", "done", "failed", "skipped"])
321
+ }
322
+ ) {}
323
+
273
324
  export class ExecutorLeased extends Schema.TaggedClass<ExecutorLeased>()("ExecutorLeased", {
274
325
  executor: Schema.String,
275
326
  role: Schema.String,
327
+ /** Which of the executor's clones took the slot, from 1 (ADR 0033); absent when borrowed or before 2.41. */
328
+ clone: Schema.optionalKey(Schema.Int),
276
329
  label: Schema.optionalKey(Schema.String),
277
330
  /** What the call is for beyond its role: a review lens, a vote. */
278
331
  purpose: Schema.optionalKey(Schema.String),
@@ -290,6 +343,8 @@ export class ExecutorLeased extends Schema.TaggedClass<ExecutorLeased>()("Execut
290
343
  export class ExecutorReleased extends Schema.TaggedClass<ExecutorReleased>()("ExecutorReleased", {
291
344
  executor: Schema.String,
292
345
  role: Schema.String,
346
+ /** Which of the executor's clones freed the slot (ADR 0033); absent when borrowed or before 2.41. */
347
+ clone: Schema.optionalKey(Schema.Int),
293
348
  label: Schema.optionalKey(Schema.String)
294
349
  }) {}
295
350
 
@@ -335,12 +390,16 @@ const resumedWords: Readonly<Record<ExecutorResumed["why"], string>> = {
335
390
  * the roster used to publish as `Info` — or `undefined` for one it does not
336
391
  * show (a release) and for any other event.
337
392
  */
393
+ /** `codex#2` for a numbered clone, the bare executor otherwise (ADR 0033). */
394
+ export const cloneName = (executor: string, clone: number | undefined): string =>
395
+ clone === undefined ? executor : `${executor}#${clone}`
396
+
338
397
  export const rosterEventMessage = (event: FlowEvent): string | undefined => {
339
398
  const forLabel = (label: string | undefined): string =>
340
399
  label === undefined ? "" : ` for ${label}`
341
400
  switch (event._tag) {
342
401
  case "ExecutorLeased": {
343
- const taken = `roster: ${event.executor} takes ${event.role}${forLabel(event.label)}${
402
+ const taken = `roster: ${cloneName(event.executor, event.clone)} takes ${event.role}${forLabel(event.label)}${
344
403
  event.purpose === undefined ? "" : ` · ${event.purpose}`
345
404
  }`
346
405
  if (event.because === undefined && event.borrowed !== true) {
@@ -395,7 +454,11 @@ export const FlowEvent = Schema.Union([
395
454
  ExecutorHandedOver,
396
455
  StoryJudged,
397
456
  Timed,
398
- Began
457
+ Began,
458
+ TasksPlanned,
459
+ TaskStarted,
460
+ TaskCompleted,
461
+ StoryStatusChanged
399
462
  ])
400
463
  export type FlowEvent = typeof FlowEvent.Type
401
464
 
@@ -419,6 +482,8 @@ export interface Lane {
419
482
  readonly lane: string
420
483
  /** The executor working it now; read at every publish, so a handover shows. */
421
484
  readonly executor?: Effect.Effect<string | undefined>
485
+ /** The coder's clone number, read at every publish as `executor` is (ADR 0033). */
486
+ readonly clone?: Effect.Effect<number | undefined>
422
487
  /** The lane's working directory, dropped from tool arguments (the lane already names it). */
423
488
  readonly workDir?: string
424
489
  }
@@ -436,10 +501,37 @@ const stamped = (
436
501
  event: FlowEvent,
437
502
  lane: string,
438
503
  executor: string | undefined,
439
- workDir: string | undefined
504
+ workDir: string | undefined,
505
+ clone: number | undefined
440
506
  ): FlowEvent => {
441
507
  const tags = { lane, ...(executor === undefined ? {} : { executor }) }
508
+ const usageTags = { ...tags, ...(clone === undefined ? {} : { clone }) }
442
509
  switch (event._tag) {
510
+ case "TasksPlanned":
511
+ return event.lane !== undefined ? event : TasksPlanned.make({ tasks: event.tasks, ...tags })
512
+ case "TaskStarted":
513
+ return event.lane !== undefined
514
+ ? event
515
+ : TaskStarted.make({
516
+ index: event.index,
517
+ count: event.count,
518
+ title: event.title,
519
+ ...(event.satisfies === undefined ? {} : { satisfies: event.satisfies }),
520
+ ...tags,
521
+ // A parallel task names its own coder, not the lane's (ADR 0034).
522
+ ...(event.parallel === undefined ? {} : { parallel: event.parallel }),
523
+ ...(event.executor === undefined ? {} : { executor: event.executor }),
524
+ ...(event.clone === undefined ? {} : { clone: event.clone })
525
+ })
526
+ case "TaskCompleted":
527
+ return event.lane !== undefined
528
+ ? event
529
+ : TaskCompleted.make({
530
+ index: event.index,
531
+ count: event.count,
532
+ title: event.title,
533
+ ...tags
534
+ })
443
535
  case "StageStarted":
444
536
  return event.lane !== undefined ? event : StageStarted.make({ stage: event.stage, ...tags })
445
537
  case "StageCompleted":
@@ -456,6 +548,7 @@ const stamped = (
456
548
  : ToolUse.make({
457
549
  tool: event.tool,
458
550
  args: shortenLaneArgs(event.args, workDir),
551
+ ...(event.parent === undefined ? {} : { parent: event.parent }),
459
552
  ...tags
460
553
  })
461
554
  case "AssistantMessage":
@@ -485,7 +578,9 @@ const stamped = (
485
578
  agent: event.agent,
486
579
  usage: event.usage,
487
580
  ...(event.model === undefined ? {} : { model: event.model }),
488
- ...tags
581
+ // The lane's clone is its coder's: a reviewer or judge on the
582
+ // lane's events ran on another executor (ADR 0033).
583
+ ...(event.agent === "coder" ? usageTags : tags)
489
584
  })
490
585
  // The lane's executor is its coder's: it names a coder call or tool, not
491
586
  // a reviewer's or judge's call made on another executor.
@@ -523,7 +618,9 @@ const stamped = (
523
618
  export const withLane = (events: FlowEventsShape, lane: Lane): FlowEventsShape => ({
524
619
  publish: (event) =>
525
620
  Effect.flatMap(lane.executor ?? Effect.succeed(undefined), (executor) =>
526
- events.publish(stamped(event, lane.lane, executor, lane.workDir))
621
+ Effect.flatMap(lane.clone ?? Effect.succeed(undefined), (clone) =>
622
+ events.publish(stamped(event, lane.lane, executor, lane.workDir, clone))
623
+ )
527
624
  )
528
625
  })
529
626
 
package/src/Perimeter.ts CHANGED
@@ -131,3 +131,39 @@ export const strayTasks = (
131
131
  })
132
132
  return [{ task, paths, foreign }]
133
133
  })
134
+
135
+ /** A task's `Owns:` entry as a path prefix: `src/session/*` and `src/routes/**` own their directory. */
136
+ const ownedPrefix = (entry: string): string => entry.replace(/\/?\*+$/u, "")
137
+
138
+ /**
139
+ * A task run aside (ADR 0034) as a gate over what it changed: every path
140
+ * outside its `Owns:` entries is one Critical issue handed back to its
141
+ * coder, so two tasks running at once keep to their own files. A task that
142
+ * names no paths is not checked; the story's perimeter still is.
143
+ */
144
+ export const taskOwnsGate = (
145
+ changedPaths: ReadonlyArray<string>,
146
+ owns: ReadonlyArray<string>
147
+ ): ReviewResult => {
148
+ if (owns.length === 0) {
149
+ return ReviewResult.make({ issues: [], summary: "" })
150
+ }
151
+ const prefixes = owns.map(ownedPrefix)
152
+ const stray = changedPaths.filter((path) => !prefixes.some((prefix) => pathWithin(path, prefix)))
153
+ return stray.length === 0
154
+ ? ReviewResult.make({ issues: [], summary: "task owns: clean" })
155
+ : ReviewResult.make({
156
+ issues: [
157
+ ReviewIssue.make({
158
+ severity: "Critical",
159
+ title: "task owns",
160
+ description: [
161
+ `This task owns ${owns.join(", ")} and changed paths outside them: ${stray.join(", ")}.`,
162
+ "Another task may be changing those right now. Revert them and keep this task's",
163
+ "work inside the paths it owns."
164
+ ].join("\n")
165
+ })
166
+ ],
167
+ summary: "task owns: violated"
168
+ })
169
+ }