@nifrajs/coding-agent 3.2.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 (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. package/src/workflows.ts +279 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Recovery state machine for at-least-once node dispatch.
3
+ *
4
+ * The machine treats every effect invocation as potentially side-effecting. A normal retry is
5
+ * allowed only for an explicit pre-effect rejection. Once an effect has been invoked, recovery
6
+ * requires a matching idempotency proof; without one it dead-letters the dispatch instead of
7
+ * guessing that a duplicate is safe. This is deliberately conservative and never claims exactly
8
+ * once delivery or hostile-code isolation.
9
+ */
10
+
11
+ import {
12
+ type DispatchResultCode,
13
+ type InjectedClock,
14
+ type RunDispatchStore,
15
+ type RunLease,
16
+ systemClock,
17
+ } from "./dispatch.ts"
18
+
19
+ export interface IdempotencyProofStore {
20
+ has(key: string): boolean | Promise<boolean>
21
+ record(key: string): void | Promise<void>
22
+ }
23
+ /** Disposable proof reference for tests and one-process local runs. */
24
+ export class MemoryIdempotencyProofStore implements IdempotencyProofStore {
25
+ private readonly keys = new Set<string>()
26
+
27
+ has(key: string): boolean {
28
+ return this.keys.has(key)
29
+ }
30
+
31
+ record(key: string): void {
32
+ this.keys.add(key)
33
+ }
34
+
35
+ keysSnapshot(): readonly string[] {
36
+ return Object.freeze([...this.keys].sort())
37
+ }
38
+ }
39
+
40
+ /** A side effect must explicitly confirm that its idempotency proof was durably recorded. */
41
+ export interface CommittedEffect {
42
+ readonly committed: true
43
+ }
44
+
45
+ export interface RunEffectContext {
46
+ readonly lease: RunLease
47
+ readonly signal: AbortSignal
48
+ /** Persist a content-free safe boundary; a failed write prevents the next unsafe step. */
49
+ readonly checkpoint: (boundary: "before-effect" | "after-effect") => Promise<boolean>
50
+ }
51
+
52
+ export type RunEffect = (context: RunEffectContext) => Promise<CommittedEffect> | CommittedEffect
53
+
54
+ /** Stable pre-effect failure; it may be retried with a new logical attempt. */
55
+ export class EffectRejectedError extends Error {
56
+ readonly code: string
57
+
58
+ constructor(code = "effect_rejected") {
59
+ super("run effect was rejected before the side effect boundary")
60
+ this.name = "EffectRejectedError"
61
+ this.code = code
62
+ }
63
+ }
64
+
65
+ /** Simulates worker loss at a named boundary; the lease is intentionally left unresolved. */
66
+ export class RecoveryCrashError extends Error {
67
+ readonly boundary: "before-effect" | "after-effect" | "after-checkpoint"
68
+
69
+ constructor(boundary: "before-effect" | "after-effect" | "after-checkpoint") {
70
+ super("worker stopped at a recovery boundary")
71
+ this.name = "RecoveryCrashError"
72
+ this.boundary = boundary
73
+ }
74
+ }
75
+
76
+ export interface RecoveryMachineOptions {
77
+ readonly store: RunDispatchStore
78
+ readonly clock?: InjectedClock
79
+ readonly proofStore?: IdempotencyProofStore
80
+ readonly leaseMs?: number
81
+ readonly retryBackoff?: (attempt: number) => number
82
+ readonly signal?: AbortSignal
83
+ }
84
+
85
+ export interface RecoveryProcessResult {
86
+ readonly leased: number
87
+ readonly completed: number
88
+ readonly retried: number
89
+ readonly deadLettered: number
90
+ readonly cancelled: number
91
+ }
92
+
93
+ const DEFAULT_LEASE_MS = 30_000
94
+ const DEFAULT_BACKOFF = (attempt: number): number => Math.min(1_000 * Math.max(1, attempt), 60_000)
95
+
96
+ function stableCode(error: unknown): string {
97
+ if (error instanceof EffectRejectedError) return error.code
98
+ if (error instanceof RecoveryCrashError) return `worker_${error.boundary.replaceAll("-", "_")}`
99
+ return "effect_failed"
100
+ }
101
+
102
+ function isAbort(signal: AbortSignal, error?: unknown): boolean {
103
+ return signal.aborted || (error instanceof DOMException && error.name === "AbortError")
104
+ }
105
+
106
+ /**
107
+ * Drives leases through safe checkpoints and terminal transitions. The caller owns the effect and
108
+ * any real idempotency ledger; this class stores only opaque proof keys in the reference adapter.
109
+ */
110
+ export class RunRecoveryMachine {
111
+ private readonly store: RunDispatchStore
112
+ private readonly clock: InjectedClock
113
+ private readonly proofs: IdempotencyProofStore
114
+ private readonly leaseMs: number
115
+ private readonly retryBackoff: (attempt: number) => number
116
+ private readonly signal: AbortSignal
117
+
118
+ constructor(options: RecoveryMachineOptions) {
119
+ this.store = options.store
120
+ this.clock = options.clock ?? systemClock
121
+ this.proofs = options.proofStore ?? new MemoryIdempotencyProofStore()
122
+ this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS
123
+ this.retryBackoff = options.retryBackoff ?? DEFAULT_BACKOFF
124
+ this.signal = options.signal ?? new AbortController().signal
125
+ if (!Number.isFinite(this.leaseMs) || this.leaseMs <= 0)
126
+ throw new RangeError("recovery leaseMs must be positive")
127
+ }
128
+
129
+ /** Cancel all matching run dispatches; every late lease write then fails closed. */
130
+ async cancel(runId: string): Promise<number> {
131
+ return await this.store.cancel(runId, "cancelled")
132
+ }
133
+
134
+ /** Process one bounded lease batch. No payload is read or emitted by this coordinator. */
135
+ async process(
136
+ effect: RunEffect,
137
+ options: { readonly limit?: number } = {},
138
+ ): Promise<RecoveryProcessResult> {
139
+ const limit = options.limit ?? 1
140
+ const leases = await this.store.lease(this.clock.now(), limit, this.leaseMs)
141
+ const summary = {
142
+ leased: leases.length,
143
+ completed: 0,
144
+ retried: 0,
145
+ deadLettered: 0,
146
+ cancelled: 0,
147
+ }
148
+ for (const lease of leases) {
149
+ const outcome = await this.processLease(lease, effect)
150
+ summary.completed += outcome === "completed" ? 1 : 0
151
+ summary.retried += outcome === "retried" ? 1 : 0
152
+ summary.deadLettered += outcome === "dead-lettered" ? 1 : 0
153
+ summary.cancelled += outcome === "cancelled" ? 1 : 0
154
+ }
155
+ return Object.freeze(summary)
156
+ }
157
+
158
+ private async processLease(
159
+ lease: RunLease,
160
+ effect: RunEffect,
161
+ ): Promise<"completed" | "retried" | "dead-lettered" | "cancelled" | "waiting"> {
162
+ const signal = this.signal
163
+ if (signal.aborted) {
164
+ await this.store.cancel(lease.dispatch.runId, "cancelled")
165
+ return "cancelled"
166
+ }
167
+
168
+ const inspection = await this.store.inspect(lease.dispatch.dispatchId)
169
+ const checkpoint = inspection?.checkpoint
170
+ const proof = await this.proofs.has(lease.idempotencyKey)
171
+ if (proof || checkpoint?.boundary === "after-effect") {
172
+ if (!proof) {
173
+ await this.store.deadLetter(lease, "idempotency_required")
174
+ return "dead-lettered"
175
+ }
176
+ const completed = await this.store.complete(lease)
177
+ return completed.ok ? "completed" : "waiting"
178
+ }
179
+
180
+ const before = await this.storeCheckpoint(lease, "before-effect")
181
+ if (!before) return "waiting"
182
+ if (signal.aborted) {
183
+ await this.store.cancel(lease.dispatch.runId, "cancelled")
184
+ return "cancelled"
185
+ }
186
+
187
+ let invoked = false
188
+ try {
189
+ invoked = true
190
+ const committed = await effect({
191
+ lease,
192
+ signal,
193
+ checkpoint: (boundary) => this.storeCheckpoint(lease, boundary),
194
+ })
195
+ if (committed.committed !== true) {
196
+ await this.store.deadLetter(lease, "idempotency_required")
197
+ return "dead-lettered"
198
+ }
199
+ await this.proofs.record(lease.idempotencyKey)
200
+ const after = await this.storeCheckpoint(lease, "after-effect")
201
+ if (!after) return "waiting"
202
+ const completed = await this.store.complete(lease)
203
+ return completed.ok ? "completed" : "waiting"
204
+ } catch (error) {
205
+ if (error instanceof RecoveryCrashError) return "waiting"
206
+ if (isAbort(signal, error)) {
207
+ await this.store.cancel(lease.dispatch.runId, "cancelled")
208
+ return "cancelled"
209
+ }
210
+ const failure = stableCode(error)
211
+ if (invoked && !(error instanceof EffectRejectedError)) {
212
+ // The effect boundary was crossed, so a retry would be an unproven duplicate.
213
+ await this.store.deadLetter(lease, "idempotency_required")
214
+ return "dead-lettered"
215
+ }
216
+ if (lease.attempt >= lease.dispatch.maxAttempts) {
217
+ await this.store.deadLetter(lease, failure)
218
+ return "dead-lettered"
219
+ }
220
+ const delay = this.retryBackoff(lease.attempt)
221
+ if (!Number.isFinite(delay) || delay < 0)
222
+ throw new RangeError("recovery retry backoff is invalid")
223
+ const retried = await this.store.retry(
224
+ lease,
225
+ this.clock.now() + Math.min(delay, 86_400_000),
226
+ failure,
227
+ )
228
+ return retried.ok ? "retried" : "waiting"
229
+ }
230
+ }
231
+
232
+ private async storeCheckpoint(
233
+ lease: RunLease,
234
+ boundary: "before-effect" | "after-effect",
235
+ ): Promise<boolean> {
236
+ const outcome = await this.store.checkpoint(lease, {
237
+ version: 1,
238
+ dispatchId: lease.dispatch.dispatchId,
239
+ runId: lease.dispatch.runId,
240
+ nodeId: lease.dispatch.nodeId,
241
+ attempt: lease.attempt,
242
+ generation: lease.generation,
243
+ boundary,
244
+ idempotencyKey: lease.idempotencyKey,
245
+ at: this.clock.now(),
246
+ scheduleToken: lease.scheduleToken,
247
+ })
248
+ return outcome.ok
249
+ }
250
+ }
251
+
252
+ /** A small deterministic clock useful for recovery schedules and tests. */
253
+ export class TestClock implements InjectedClock {
254
+ private current: number
255
+
256
+ constructor(start = 0) {
257
+ if (!Number.isSafeInteger(start) || start < 0)
258
+ throw new RangeError("test clock start is invalid")
259
+ this.current = start
260
+ }
261
+
262
+ now = (): number => this.current
263
+
264
+ advance(milliseconds: number): void {
265
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0)
266
+ throw new RangeError("test clock advance is invalid")
267
+ this.current += milliseconds
268
+ }
269
+ }
270
+
271
+ export type { DispatchResultCode }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The end-to-end tracer: compile a {@link RunPlan}, run it through the {@link WorkflowRunner}, and
3
+ * project each transition into content-free {@link RunEvidence}. Every emitted record is re-parsed
4
+ * through {@link parseRunEvidence}, so the tracer itself proves the stream carries no payload and
5
+ * stays under the size cap. The terminal digest is a deterministic hash of the evidence stream:
6
+ * identical plan + catalog + inputs yield an identical digest, which is the deterministic-eval anchor.
7
+ */
8
+
9
+ import type {
10
+ ArtifactPort,
11
+ ArtifactRef,
12
+ NodeEffectKey,
13
+ RunEvidence,
14
+ RunNode,
15
+ RunNodeKind,
16
+ RunPlan,
17
+ } from "@nifrajs/agent-protocol"
18
+ import { parseRunEvidence, parseRunPlan, RUN_PLAN_VERSION } from "@nifrajs/agent-protocol"
19
+ import { type WorkflowEvent, WorkflowRunner } from "../workflows.ts"
20
+ import { noopArtifactPort } from "./artifact-port.ts"
21
+ import type { StepCatalog } from "./catalog.ts"
22
+ import { compileRunPlan, digestRunPlan } from "./compile.ts"
23
+ import { canonicalJson, sha256HexOf } from "./hash.ts"
24
+
25
+ /** A verify/approve node whose output is not a pass. Other kinds never gate. */
26
+ function isGateRejection(kind: RunNodeKind | undefined, output: unknown): boolean {
27
+ if (kind === "verify") {
28
+ if (output === true) return false
29
+ return !(
30
+ typeof output === "object" &&
31
+ output !== null &&
32
+ (output as { ok?: unknown }).ok === true
33
+ )
34
+ }
35
+ if (kind === "approve") return output !== true
36
+ return false
37
+ }
38
+
39
+ export interface RunTraceOptions {
40
+ /** Caller-supplied, so a trace is fully reproducible. No clock or randomness is read here. */
41
+ readonly runId: string
42
+ readonly catalog: StepCatalog
43
+ /** Defaults to {@link noopArtifactPort} (hash-and-discard). */
44
+ readonly artifactPort?: ArtifactPort
45
+ readonly signal?: AbortSignal
46
+ readonly maxSteps?: number
47
+ readonly maxDepth?: number
48
+ }
49
+
50
+ export interface RunTraceResult {
51
+ readonly ok: boolean
52
+ readonly runId: string
53
+ readonly planDigest: string
54
+ readonly evidence: readonly RunEvidence[]
55
+ /** SHA-256 hex of the canonical evidence stream. Deterministic across identical runs. */
56
+ readonly terminalDigest: string
57
+ readonly error?: string
58
+ }
59
+
60
+ /** Run a plan and return its content-free evidence stream plus a deterministic terminal digest. */
61
+ export async function runTrace(
62
+ source: RunPlan | unknown,
63
+ options: RunTraceOptions,
64
+ ): Promise<RunTraceResult> {
65
+ const plan = parseRunPlan(source)
66
+ const planDigest = await digestRunPlan(plan)
67
+ const artifactPort = options.artifactPort ?? noopArtifactPort()
68
+
69
+ const effectKeys = new Map<string, NodeEffectKey>()
70
+ const artifacts = new Map<string, ArtifactRef[]>()
71
+ const idempotent = new Map<string, boolean>()
72
+ const kindById = new Map<string, RunNodeKind>()
73
+ const nodeIds = new Set<string>()
74
+ const walk = (node: RunNode): void => {
75
+ nodeIds.add(node.id)
76
+ kindById.set(node.id, node.kind)
77
+ const step = "step" in node ? options.catalog.get(node.step) : undefined
78
+ idempotent.set(node.id, step?.selectEffect !== undefined)
79
+ switch (node.kind) {
80
+ case "sequence":
81
+ case "parallel":
82
+ for (const child of node.children) walk(child)
83
+ break
84
+ case "retry":
85
+ walk(node.child)
86
+ break
87
+ case "branch":
88
+ walk(node.then)
89
+ if (node.otherwise !== undefined) walk(node.otherwise)
90
+ break
91
+ }
92
+ }
93
+ for (const node of plan.nodes) walk(node)
94
+
95
+ const step = compileRunPlan(plan, {
96
+ catalog: options.catalog,
97
+ planDigest,
98
+ artifactPort,
99
+ onNodeEffect: (nodeId, key) => effectKeys.set(nodeId, key),
100
+ collectArtifact: (nodeId, ref) => {
101
+ const list = artifacts.get(nodeId)
102
+ if (list === undefined) artifacts.set(nodeId, [ref])
103
+ else list.push(ref)
104
+ },
105
+ })
106
+
107
+ const evidence: RunEvidence[] = []
108
+ let seq = 0
109
+ const push = (record: RunEvidence): void => {
110
+ // Re-parse each record: proves it is content-free and within the size cap before it is kept.
111
+ evidence.push(parseRunEvidence(record))
112
+ }
113
+
114
+ const onEvent = (event: WorkflowEvent): void => {
115
+ if (!("id" in event) || !nodeIds.has(event.id)) return
116
+ const base = {
117
+ version: RUN_PLAN_VERSION,
118
+ runId: options.runId,
119
+ planDigest,
120
+ nodeId: event.id,
121
+ seq: seq++,
122
+ idempotent: idempotent.get(event.id) ?? false,
123
+ } as const
124
+ if (event.type === "step.started") {
125
+ push({ ...base, status: "started" })
126
+ return
127
+ }
128
+ if (event.type === "step.completed") {
129
+ const key = effectKeys.get(event.id)
130
+ const refs = artifacts.get(event.id)
131
+ // A verify/approve gate reports pass/fail through its output, then the kernel aborts the run on
132
+ // a non-pass. Project that as a failed record so the evidence reflects the gate's verdict.
133
+ if (isGateRejection(kindById.get(event.id), event.output)) {
134
+ push({
135
+ ...base,
136
+ status: "failed",
137
+ errorCode: "GATE_REJECTED",
138
+ ...(key !== undefined ? { effectKey: key.digest } : {}),
139
+ })
140
+ return
141
+ }
142
+ push({
143
+ ...base,
144
+ status: "completed",
145
+ ...(key !== undefined ? { effectKey: key.digest } : {}),
146
+ ...(refs !== undefined && refs.length > 0 ? { artifacts: refs } : {}),
147
+ })
148
+ return
149
+ }
150
+ if (event.type === "step.failed") {
151
+ const key = effectKeys.get(event.id)
152
+ push({
153
+ ...base,
154
+ status: "failed",
155
+ errorCode: "STEP_FAILED",
156
+ ...(key !== undefined ? { effectKey: key.digest } : {}),
157
+ })
158
+ }
159
+ }
160
+
161
+ const result = await new WorkflowRunner({
162
+ onEvent,
163
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
164
+ ...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}),
165
+ ...(options.maxDepth !== undefined ? { maxDepth: options.maxDepth } : {}),
166
+ }).run(step)
167
+
168
+ const terminalDigest = await sha256HexOf(canonicalJson(evidence))
169
+ return {
170
+ ok: result.ok,
171
+ runId: options.runId,
172
+ planDigest,
173
+ evidence,
174
+ terminalDigest,
175
+ ...(result.error !== undefined ? { error: result.error } : {}),
176
+ }
177
+ }
package/src/plans.ts ADDED
@@ -0,0 +1,105 @@
1
+ import {
2
+ type WorkflowEvent,
3
+ type WorkflowResult,
4
+ WorkflowRunner,
5
+ type WorkflowStep,
6
+ } from "./workflows.ts"
7
+
8
+ export interface PlanPhase {
9
+ readonly id: string
10
+ readonly title: string
11
+ readonly step: WorkflowStep
12
+ }
13
+
14
+ export interface AgentPlan {
15
+ readonly id: string
16
+ readonly goal: string
17
+ readonly phases: readonly PlanPhase[]
18
+ readonly maxSteps?: number
19
+ readonly maxDepth?: number
20
+ }
21
+
22
+ export interface PlanEvent {
23
+ readonly type:
24
+ | "plan.started"
25
+ | "plan.phase.started"
26
+ | "plan.phase.completed"
27
+ | "plan.completed"
28
+ | "plan.failed"
29
+ readonly planId: string
30
+ readonly phaseId?: string
31
+ readonly message?: string
32
+ }
33
+
34
+ export interface PlanRunnerOptions {
35
+ readonly signal?: AbortSignal
36
+ readonly onEvent?: (event: PlanEvent | WorkflowEvent) => void | PromiseLike<void>
37
+ }
38
+
39
+ export interface PlanResult extends WorkflowResult {
40
+ readonly planId: string
41
+ readonly goal: string
42
+ }
43
+
44
+ /** First-party plan mode built on the bounded workflow primitives; no model or UI dependency. */
45
+ export class PlanRunner {
46
+ private readonly options: PlanRunnerOptions
47
+
48
+ constructor(options: PlanRunnerOptions = {}) {
49
+ this.options = options
50
+ }
51
+
52
+ async run(plan: AgentPlan): Promise<PlanResult> {
53
+ validatePlan(plan)
54
+ await this.emit({ type: "plan.started", planId: plan.id, message: plan.goal })
55
+ const workflow = new WorkflowRunner({
56
+ ...(this.options.signal === undefined ? {} : { signal: this.options.signal }),
57
+ ...(plan.maxSteps === undefined ? {} : { maxSteps: plan.maxSteps }),
58
+ ...(plan.maxDepth === undefined ? {} : { maxDepth: plan.maxDepth }),
59
+ onEvent: async (event) => this.options.onEvent?.(event),
60
+ })
61
+ const step: WorkflowStep = {
62
+ type: "sequence",
63
+ steps: plan.phases.map((phase) => ({
64
+ type: "task" as const,
65
+ id: phase.id,
66
+ run: async (context) => {
67
+ await this.emit({
68
+ type: "plan.phase.started",
69
+ planId: plan.id,
70
+ phaseId: phase.id,
71
+ message: phase.title,
72
+ })
73
+ const output = await context.run(phase.step)
74
+ await this.emit({ type: "plan.phase.completed", planId: plan.id, phaseId: phase.id })
75
+ return output
76
+ },
77
+ })),
78
+ }
79
+ const result = await workflow.run(step)
80
+ await this.emit({
81
+ type: result.ok ? "plan.completed" : "plan.failed",
82
+ planId: plan.id,
83
+ ...(result.error === undefined ? {} : { message: result.error }),
84
+ })
85
+ return { ...result, planId: plan.id, goal: plan.goal }
86
+ }
87
+
88
+ private async emit(event: PlanEvent): Promise<void> {
89
+ await this.options.onEvent?.(event)
90
+ }
91
+ }
92
+
93
+ function validatePlan(plan: AgentPlan): void {
94
+ if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(plan.id)) throw new TypeError("plan: id is invalid")
95
+ if (!plan.goal || plan.goal.length > 4_096) throw new TypeError("plan: goal is empty or too long")
96
+ if (plan.phases.length === 0 || plan.phases.length > 128)
97
+ throw new RangeError("plan: phases must be between 1 and 128")
98
+ const ids = new Set<string>()
99
+ for (const phase of plan.phases) {
100
+ if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(phase.id) || !phase.title || phase.title.length > 256)
101
+ throw new TypeError(`plan: invalid phase ${phase.id}`)
102
+ if (ids.has(phase.id)) throw new TypeError(`plan: duplicate phase ${phase.id}`)
103
+ ids.add(phase.id)
104
+ }
105
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { SubagentSpec } from "./subagents.ts"
2
+
3
+ export type AgentPresetName =
4
+ | "planner"
5
+ | "implementer"
6
+ | "test-fixer"
7
+ | "reviewer"
8
+ | "security-reviewer"
9
+ | "final-verifier"
10
+
11
+ export interface AgentPreset extends Omit<SubagentSpec, "id" | "prompt"> {
12
+ readonly name: AgentPresetName
13
+ readonly summary: string
14
+ }
15
+
16
+ const PRESETS: Readonly<Record<AgentPresetName, AgentPreset>> = Object.freeze({
17
+ planner: {
18
+ name: "planner",
19
+ role: "planner",
20
+ summary: "Break a task into bounded, verifiable phases.",
21
+ capabilities: Object.freeze(["read"]),
22
+ maxDepth: 1,
23
+ timeoutMs: 120_000,
24
+ },
25
+ implementer: {
26
+ name: "implementer",
27
+ role: "implementer",
28
+ summary: "Apply a narrowly scoped implementation and report changed files.",
29
+ capabilities: Object.freeze(["read", "write", "process"]),
30
+ maxDepth: 1,
31
+ timeoutMs: 600_000,
32
+ },
33
+ "test-fixer": {
34
+ name: "test-fixer",
35
+ role: "test-fixer",
36
+ summary: "Reproduce and repair a failing test within the task boundary.",
37
+ capabilities: Object.freeze(["read", "write", "process"]),
38
+ maxDepth: 1,
39
+ timeoutMs: 600_000,
40
+ },
41
+ reviewer: {
42
+ name: "reviewer",
43
+ role: "reviewer",
44
+ summary: "Review a proposed change for correctness and regressions.",
45
+ capabilities: Object.freeze(["read", "process"]),
46
+ maxDepth: 1,
47
+ timeoutMs: 240_000,
48
+ },
49
+ "security-reviewer": {
50
+ name: "security-reviewer",
51
+ role: "security-reviewer",
52
+ summary: "Review capabilities, trust boundaries, secrets, and process execution.",
53
+ capabilities: Object.freeze(["read", "process"]),
54
+ maxDepth: 1,
55
+ timeoutMs: 240_000,
56
+ },
57
+ "final-verifier": {
58
+ name: "final-verifier",
59
+ role: "final-verifier",
60
+ summary: "Run the configured verification gates and return structured evidence.",
61
+ capabilities: Object.freeze(["read", "process"]),
62
+ maxDepth: 1,
63
+ timeoutMs: 300_000,
64
+ },
65
+ })
66
+
67
+ export const AGENT_PRESETS = PRESETS
68
+
69
+ export function getAgentPreset(name: AgentPresetName): AgentPreset {
70
+ return PRESETS[name]
71
+ }
72
+
73
+ export function createPresetSpec(
74
+ name: AgentPresetName,
75
+ prompt: string,
76
+ id = `${name}-${Date.now().toString(36)}`,
77
+ ): SubagentSpec {
78
+ const preset = getAgentPreset(name)
79
+ if (!prompt || prompt.length > 16_384)
80
+ throw new TypeError("agent preset: prompt is empty or too long")
81
+ if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(id)) throw new TypeError("agent preset: id is invalid")
82
+ return {
83
+ id,
84
+ role: preset.role,
85
+ prompt,
86
+ ...(preset.capabilities === undefined ? {} : { capabilities: preset.capabilities }),
87
+ ...(preset.maxDepth === undefined ? {} : { maxDepth: preset.maxDepth }),
88
+ ...(preset.timeoutMs === undefined ? {} : { timeoutMs: preset.timeoutMs }),
89
+ }
90
+ }
package/src/process.ts ADDED
@@ -0,0 +1,47 @@
1
+ export interface BoundedText {
2
+ readonly text: string
3
+ readonly truncated: boolean
4
+ }
5
+
6
+ export async function readBoundedText(
7
+ stream: ReadableStream<Uint8Array> | null | undefined | number,
8
+ maxBytes: number,
9
+ ): Promise<BoundedText> {
10
+ if (stream === null || stream === undefined || typeof stream === "number")
11
+ return { text: "", truncated: false }
12
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
13
+ throw new RangeError("bounded process output: maxBytes must be positive")
14
+ const reader = stream.getReader()
15
+ const decoder = new TextDecoder()
16
+ const chunks: string[] = []
17
+ let total = 0
18
+ let truncated = false
19
+ try {
20
+ for (;;) {
21
+ const { done, value } = await reader.read()
22
+ if (done) {
23
+ chunks.push(decoder.decode())
24
+ break
25
+ }
26
+ if (value === undefined) continue
27
+ const remaining = maxBytes - total
28
+ if (remaining <= 0) {
29
+ truncated = true
30
+ await reader.cancel()
31
+ break
32
+ }
33
+ if (value.byteLength > remaining) {
34
+ chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }))
35
+ chunks.push(decoder.decode())
36
+ truncated = true
37
+ await reader.cancel()
38
+ break
39
+ }
40
+ total += value.byteLength
41
+ chunks.push(decoder.decode(value, { stream: true }))
42
+ }
43
+ } finally {
44
+ reader.releaseLock()
45
+ }
46
+ return { text: chunks.join(""), truncated }
47
+ }