@abelxiaoxing/cadence 1.4.0 → 1.4.1

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 (49) hide show
  1. package/README.md +58 -4
  2. package/agents/design-explorer.md +17 -11
  3. package/agents/diagnosis-worker.md +9 -1
  4. package/agents/implementation-worker.md +3 -1
  5. package/config/plan-draft.example.json +122 -0
  6. package/config/plan-draft.multiple-tasks.example.json +345 -0
  7. package/package.json +5 -3
  8. package/prompts/abel-design.md +93 -44
  9. package/src/activity-contracts.ts +82 -0
  10. package/src/candidate-artifact.ts +143 -0
  11. package/src/candidate-patch.ts +31 -11
  12. package/src/candidate-workspace.ts +74 -0
  13. package/src/change-contract-schema.ts +191 -0
  14. package/src/change-contract.ts +5 -0
  15. package/src/change-verification.ts +896 -0
  16. package/src/child-model.ts +129 -0
  17. package/src/child-session.ts +293 -326
  18. package/src/contracts.ts +184 -76
  19. package/src/delivery-compiler.ts +103 -120
  20. package/src/delivery-revision.ts +42 -0
  21. package/src/design-control.ts +113 -4
  22. package/src/design-diagnostics.ts +150 -0
  23. package/src/durable-contracts.ts +210 -0
  24. package/src/durable-workflow.ts +148 -3570
  25. package/src/evidence-draft.ts +122 -0
  26. package/src/implement-graph.ts +3 -0
  27. package/src/implement-plan.ts +70 -0
  28. package/src/index.ts +59 -522
  29. package/src/model-source.ts +22 -0
  30. package/src/package-candidate.ts +224 -0
  31. package/src/package-workflow.ts +253 -0
  32. package/src/packet-runtime.ts +27 -48
  33. package/src/parent-provider.ts +86 -198
  34. package/src/phase-execution.ts +2248 -0
  35. package/src/pi-adapter.ts +19 -0
  36. package/src/plan-draft-summary.ts +146 -0
  37. package/src/plan-draft.ts +493 -0
  38. package/src/subagent-activity.ts +32 -66
  39. package/src/submit-tool.ts +20 -59
  40. package/src/transport-budget.ts +32 -0
  41. package/src/worker-broker.ts +37 -57
  42. package/src/workflow-policy.ts +14 -53
  43. package/src/workflow-recovery-policy.ts +95 -0
  44. package/src/workflow-scheduling.ts +105 -0
  45. package/src/workflow-state-machine.ts +105 -497
  46. package/src/workflow-status.ts +384 -0
  47. package/src/workspace-io-worker.mjs +7 -0
  48. package/src/empty-resource-loader.ts +0 -38
  49. package/src/parent-payload-bridge.ts +0 -523
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Four explicit Abel workflow prompts with stage-isolated design and diagnosis, durable resumable implementation, and three package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,8 @@
51
51
  "peerDependencies": {
52
52
  "@earendil-works/pi-coding-agent": "*",
53
53
  "@earendil-works/pi-tui": "*",
54
- "typebox": "*"
54
+ "typebox": "*",
55
+ "@earendil-works/pi-ai": "*"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@biomejs/biome": "^2.4.16",
@@ -60,7 +61,8 @@
60
61
  "@types/node": "^22.15.3",
61
62
  "rumdl": "^0.2.10",
62
63
  "typescript": "^6.0.3",
63
- "vitest": "^4.1.8"
64
+ "vitest": "^4.1.8",
65
+ "@earendil-works/pi-ai": "0.84.3"
64
66
  },
65
67
  "repository": {
66
68
  "type": "git",
@@ -29,9 +29,36 @@ Design resolves product and technical decisions, collects cited evidence, and co
29
29
  It never launches an implementation Worker, creates a product-code candidate, applies a product diff, runs Red/Green/Refactor, or mutates the main workspace.
30
30
 
31
31
  If no requirement can be identified or several changes cannot be distinguished, ask one focused question about the target.
32
- Otherwise investigate first: resolve discoverable unknowns from repository evidence and choose reversible defaults within existing authority; only unresolved substantive choices require a user decision.
32
+ Otherwise make Design `start` the first control call, before repository discovery.
33
+ It creates only private resumable identity and does not approve scope.
34
+ Then resolve discoverable unknowns from repository evidence and choose reversible defaults within existing authority; only unresolved substantive choices require a user decision.
33
35
  An explicit `--change <name>` resumes only that existing change; never reinterpret a misspelling as a new requirement.
34
36
 
37
+ ## Durable Design identity
38
+
39
+ Use the same `action: "design"` envelope from the first call through finalization.
40
+ Call `start` once with either the complete new requirement or an explicit existing change name, then use the returned `runId` for every later Design operation.
41
+ For a new requirement, the control plane normalizes and hashes the transient text itself; the raw requirement is not written to the durable journal.
42
+ After Gate A fixes its name, bind that same provisional run with `bind-change` rather than starting a second run.
43
+ Use `status` by `runId` to recover local lifecycle and Design facts without a model or endpoint request.
44
+ A completed Design run is immutable; an explicit later `--change <change>` starts the next durable Design revision for that change while preserving prior approval facts for receipt verification.
45
+
46
+ ```json
47
+ {"action":"design","request":{"operation":"start","requirement":"<complete-requirement>","operationId":"<unique-operation>"}}
48
+ {"action":"design","request":{"operation":"start","change":"<existing-change>","operationId":"<unique-operation>"}}
49
+ {"action":"design","request":{"operation":"status","runId":"<run-id>"}}
50
+ {"action":"design","request":{"operation":"bind-change","runId":"<run-id>","operationId":"<unique-operation>","change":"<gate-a-approved-change>"}}
51
+ ```
52
+
53
+ ## Focused evidence collection
54
+
55
+ For a local change with known paths, read applicable repository instructions, the package manifest, the affected implementation and existing tests in the same assistant turn when independent.
56
+ Expand to callers, fixtures or architecture only for a concrete unresolved impact or verification question.
57
+ Once observable behavior, authorized scope, existing-test impact and an executable verification command are established, proceed to the accepted contract and draft.
58
+ Do not continue broad directory traversal or read Cadence implementation sources to reconstruct its protocol; use the current tool schema, its installed example paths and returned field diagnostics.
59
+ The `start` / `status` response includes a current-step hint; it is guidance, not new approval authority.
60
+ Evidence packets are optional: use them only for independent evidence gaps that justify another model call.
61
+
35
62
  ## Write boundary
36
63
 
37
64
  - During verified Design, the parent tool set is mechanically limited to the previously active `read`, `grep`, `find`, and `ls` tools plus `abel_dispatch`.
@@ -56,22 +83,6 @@ Identical replay is idempotent and conflicting reuse of an operation id fails cl
56
83
  {"action":"design","request":{"operation":"delete-artifact","runId":"<run-id>","operationId":"<unique-operation>","path":"specs/<capability>/spec.md"}}
57
84
  ```
58
85
 
59
- ## Durable Design identity
60
-
61
- Use the same `action: "design"` envelope from the first call through finalization.
62
- Call `start` once with either the complete new requirement or an explicit existing change name, then use the returned `runId` for every later Design operation.
63
- For a new requirement, the control plane normalizes and hashes the transient text itself; the raw requirement is not written to the durable journal.
64
- After Gate A fixes its name, bind that same provisional run with `bind-change` rather than starting a second run.
65
- Use `status` by `runId` to recover local lifecycle and Design facts without a model or endpoint request.
66
- A completed Design run is immutable; an explicit later `--change <change>` starts the next durable Design revision for that change while preserving prior approval facts for receipt verification.
67
-
68
- ```json
69
- {"action":"design","request":{"operation":"start","requirement":"<complete-requirement>","operationId":"<unique-operation>"}}
70
- {"action":"design","request":{"operation":"start","change":"<existing-change>","operationId":"<unique-operation>"}}
71
- {"action":"design","request":{"operation":"status","runId":"<run-id>"}}
72
- {"action":"design","request":{"operation":"bind-change","runId":"<run-id>","operationId":"<unique-operation>","change":"<gate-a-approved-change>"}}
73
- ```
74
-
75
86
  ## Read-only evidence packets
76
87
 
77
88
  Use `abel_dispatch` with `action: "run"` only for bounded Design evidence.
@@ -133,6 +144,19 @@ Design settles the goal, main decisions, explicit constraints, and non-goals.
133
144
  Make the implementation delegation clear in the accepted proposal: the parent will choose its recommended solution for later implementation choices within those constraints, record material decisions, and continue to verified results without another selection or confirmation round.
134
145
  Do not ask the user to approve this delegation separately or attempt to enumerate every future implementation detail.
135
146
 
147
+ ## Structured authority for new work
148
+
149
+ Use a structured `ChangeContract` as the Gate A `contract` for new designs: `goal`, `acceptance` (stable `id`, accepted `statement`, complete structured `verification`), `constraints` (stable `id` and `statement`), and `policy` (`writeRoots`, allowed dependency names in `dependencies`, and `verificationModes`).
150
+ Use `behavior` by default; explicitly permit `mechanical` or `refactor` only for suitable work.
151
+ Present the accepted goal, constraints and scope together with the recommended choices.
152
+ Do not include secrets or raw conversation in the contract.
153
+
154
+ Code normalizes and hashes the object.
155
+ The journal retains the normalized authority and injects it into compiled plans; callers do not compute hashes.
156
+ Keep acceptance IDs and statements stable when reshaping a plan.
157
+ Compilation rejects missing acceptance verification, outside-policy writes/dependencies, or substituted authority.
158
+ Historical prose approvals remain readable; their existence does not authorize new scope.
159
+
136
160
  Gate A approves the complete WHAT: goal, observable scenarios, failures, scope/non-goals, compatibility/migration/security policy, and success criteria.
137
161
  Before approval, present the unresolved behavior decisions together.
138
162
  After explicit approval:
@@ -143,7 +167,7 @@ After explicit approval:
143
167
  4. materialize schema-ready behavior artifacts one at a time.
144
168
 
145
169
  ```json
146
- {"action":"design","request":{"operation":"approve-gate","runId":"<run-id>","operationId":"<unique-operation>","gate":"gate-a","contract":"<complete-approved-what-contract>"}}
170
+ {"action":"design","request":{"operation":"approve-gate","runId":"<run-id>","operationId":"<unique-operation>","gate":"gate-a","contract":{"goal":"<accepted goal>","acceptance":[{"id":"<acceptance-id>","statement":"<accepted behavior>","verification":{"kind":"static-check","id":"<acceptance-verifier-id>","runner":{"kind":"node","script":"<observed-test-path>"},"args":[],"classification":"expected-green"}}],"constraints":[],"policy":{"writeRoots":["<approved-source-path>","<approved-test-path>"],"dependencies":[],"verificationModes":["behavior"]}}}}
147
171
  ```
148
172
 
149
173
  The control plane generates the canonical behavior-contract hash; the returned proof contains its approval revision, contract hash, and owner-private record hash.
@@ -158,6 +182,46 @@ Compiling identical canonical bytes after unchanged decisions preserves the exis
158
182
  Derive HOW from Gate A plus repository evidence.
159
183
  Ask only unresolved substantive technical decisions, then build a complete task DAG with stable Requirement → Scenario → Verification → Task references.
160
184
 
185
+ Start from the package's [single-task PlanDraft example](../config/plan-draft.example.json), resolved relative to this prompt's installed path.
186
+ It describes a small Node consumer with `package.json`, `src/add.mjs` exporting `add`, and one existing `test/add.test.mjs` that uses Node assertions and currently covers `add(0, 0) === 0`.
187
+ The Red phase preserves that assertion and adds `add(2, 3) === 5` with the failure marker `[ADD:positive-integers]`; Green repairs the function.
188
+ The example's nested `changeContract` also shows the structured Gate A shape.
189
+ Replace its goal, acceptance, paths, context, impact evidence, and verification commands with observed repository facts before approval; its sample evidence is not evidence about the current project.
190
+ Use the project's actual full suite when adapting this single-test example.
191
+ The control plane inherits the approved `changeContract`, so the draft may omit that duplicate object.
192
+ Keep author input separate from the complete sealed execution plan; execution never reads author shorthand.
193
+ Reuse an atomic command through `verificationDefinitions` and `{ "use": "name" }`; place `expectedFailure` on its Red use.
194
+ Definitions contain an existing atomic verifier's command fields, without identity, classification, bindings, nested references, or arbitrary use-site overrides.
195
+ Inline atomic contracts may also omit `id` and `classification`; the compiler derives stable purpose identities and phase classifications.
196
+ In PlanDraft, omit a package-script `command` (including in a named definition or package-script runner) to bind the literal package.json script value before identity generation.
197
+ An explicit `command` must be that exact manifest value, never a launcher such as `npm run test`.
198
+ Gate A acceptance verifiers remain complete explicit contracts; use the observed literal script value there.
199
+ Explicit task `read` supplies common reads to its phases, unioned with any phase-local `read`; writes, deletes, roots, dependencies, and output producers remain explicit.
200
+ The compiler also supplies fixed baseline/repair boundary fields and `agents.managedOnly`; retain recovery attempt limits and observed AGENTS impact.
201
+ It supplies an empty AGENTS checkpoint only when every explicitly declared impact is `none`.
202
+ The [dependent-task example](../config/plan-draft.multiple-tasks.example.json) shows a Red-produced test consumed by another task's ordered verification, with an explicit dependency and producer.
203
+ Ordered `steps` remain complete inline contracts; reuse means separate verification executions, never reuse of a prior successful report.
204
+ Omit `tracking` to generate parent-owned `tasks.md` metadata from the task ids; an explicitly supplied block must match those ids and the fixed tracking policy.
205
+ In the author portion of `tasks.md`, write one checkbox with each backticked task ID, its objective and its owned backticked `specs/<capability>/spec.md#Requirement title/Scenario title` references.
206
+ Every Scenario must have exactly one reference.
207
+ Do not copy generated phase verifier IDs into this text.
208
+ Compilation installs their code-owned `ABEL:VERIFICATION-BINDINGS` region while preserving the author text.
209
+ Editing tasks afterward requires recompilation before finalization.
210
+ Omit phase `verificationInputs`: the compiler derives the exact paths from each verification contract and binds them to a unique declared output or an existing workspace file.
211
+ Keep supporting sources and fixtures in `read`; input derivation never adds read/write paths, outputs, or dependencies.
212
+ Omit each `relatedTests` entry's `disposition` and `regressionTaskId` to derive ownership from task writes; retain its observed `path` and `evidence`.
213
+ An ambiguous producer or test owner requires an explicit correction; explicitly supplied fields are checked, never silently replaced.
214
+ Scope, verification choices, recovery limits, and necessary AGENTS operations remain explicit.
215
+
216
+ Mechanical tasks may write document/data/config files (`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.toml`, `.lock`).
217
+ Refactor tasks preserve accepted verifier inputs.
218
+ Both must declare no public behavior change and require an explicitly allowed mode in Gate A. Specify task `verificationMode` and Green (plus optional Refactor); omit Red in the draft.
219
+ The compiler handles its internal compatibility representation.
220
+ Runtime captures baseline and executes Green with affected, cumulative and post-apply verification, without generating a Red candidate.
221
+ Behavior tasks continue to require Red/Green.
222
+
223
+ An AGENTS checkpoint additionally requires its exact target path in `policy.writeRoots`; a broad source root does not grant AGENTS authority.
224
+
161
225
  Each task must seal:
162
226
 
163
227
  - stable id, objective, exact dependencies and edge reasons;
@@ -174,21 +238,25 @@ This task-wide read authority is part of the proposal, not an ad hoc escalation
174
238
  Keep tasks small enough for one complete patch using compact exact replacements, and separate independent outputs during planning.
175
239
 
176
240
  Impact closure must name affected existing tests and fixtures; a suite made only from newly added tests is insufficient.
241
+ In `impactClosure.relatedTests`, `current-task` means this task edits that test: its path must appear in a phase's `write` set.
242
+ Use `regression-task` with `regressionTaskId` when another planned task owns the edit; use `unaffected` with evidence when an existing test is preserved without edits, even if it remains in `affectedSuite`.
243
+ Do not expand write sets merely to satisfy a disposition label.
177
244
  For public UI or API work, seal the relevant route authorization, page state, API response and contract, public HTML/template/theme behavior, and an approved browser E2E check when those surfaces are affected.
178
245
 
179
246
  The plan admits only shell-free `vitest`, `package-script`, `static-check`, or ordered `steps` contracts.
180
247
  `dev-browser` is required only by an approved browser E2E contract; its absence does not block unrelated tasks or stages.
181
- Pin local runners, package scripts, arguments, `minTests`, classifications, and `noInstall` behavior.
248
+ Pin local runners, package scripts, arguments, `minTests`, and `noInstall` behavior; sealed classifications are derived from phase/purpose when omitted.
182
249
  Reject shell operators, implicit downloads, absolute/escaping paths, missing local capability, and unsupported verification shapes during Design readiness.
183
250
 
184
- After current Gate A approval, send the complete draft through `write-artifact` only to the relative path `plan-draft.json`.
251
+ After current Gate A approval, send the author draft through `write-artifact` only to the relative path `plan-draft.json`.
185
252
  Before mutation-owning compilation, use the read-only typed preflight; it validates the same fixed draft and returns task/output counts plus hashes without installing canonical files or journaling a plan revision:
186
253
 
187
254
  ```json
188
255
  {"action":"design","request":{"operation":"validate-plan-draft","runId":"<run-id>"}}
189
256
  ```
190
257
 
191
- If preflight fails, use its safe structured diagnostics (`code`, and when applicable `taskId`, `phase`, `field`, `category`, `owner`, `verificationId`, or `outputId`) to repair the exact boundary.
258
+ If preflight fails, correct the returned diagnostic batch using `field`, `path`, `expectedPaths`, `actualPaths`, and code-owned `hint` where available.
259
+ Path lists marked `pathsTruncated` are partial; inspect only the identified boundary when more detail is needed.
192
260
  Do not bisect the draft blindly or repeat an unchanged validation/finalization request.
193
261
  After preflight succeeds, invoke code-owned compilation:
194
262
 
@@ -196,7 +264,8 @@ After preflight succeeds, invoke code-owned compilation:
196
264
  {"action":"design","request":{"operation":"compile-plan","runId":"<run-id>","operationId":"<unique-operation>"}}
197
265
  ```
198
266
 
199
- The compiler reads only that fixed safe path, validates capability and graph closure again, atomically installs canonical `implement-plan.json`, and returns its raw and canonical hashes.
267
+ The compiler reads only that fixed safe path, validates capability and graph closure again, installs the generated verification bindings into the existing `tasks.md` and canonical `implement-plan.json` under the same compilation lease, and records the plan only after both writes succeed.
268
+ It returns the plan’s raw and canonical hashes.
200
269
  Do not hand-assemble canonical plan bytes, hashes, generated task Markdown, or receipts.
201
270
  The canonical `ImplementPlan` contains:
202
271
 
@@ -257,6 +326,8 @@ Endpoint/transport failure in an evidence packet pauses that packet and retains
257
326
  Missing package scripts/runners or a non-executable verification contract must be corrected before Gate B; they must not be deferred as an Implement surprise.
258
327
 
259
328
  Report `READY_TO_IMPLEMENT` only when strict validation passes, user decisions are resolved and both private proofs are current, all artifacts/hashes/traceability resolve, and the canonical plan is executable.
329
+ After successful code-owned finalization, return one brief readiness response with the change and delivery revision.
330
+ Do not reread receipts, survey directories or restate the full decision history after that success; finalization has already reread and validated the delivery.
260
331
  Successful finalization deactivates private dispatch before an unrelated later request.
261
332
  Gate waits remain active for direct follow-up; if the user explicitly ends an unfinished Design interaction, send `{"action":"finish"}`.
262
333
  Otherwise report the current Design state, retained evidence, and the one unresolved decision or artifact that prevents readiness.
@@ -266,25 +337,3 @@ Never send `operation: "finish"`; exit only with `{"action":"finish"}`.
266
337
  Do not implement product code and do not archive, publish, release, stage, or commit implicitly.
267
338
 
268
339
  <!-- ABEL:END -->
269
-
270
- ## Structured authority for new work
271
-
272
- Use a structured `ChangeContract` as the Gate A `contract` for new designs: `goal`, `acceptance` (stable `id`, accepted `statement`, complete structured `verification`), `constraints` (stable `id` and `statement`), and `policy` (`writeRoots`, allowed dependency names in `dependencies`, and `verificationModes`).
273
- Use `behavior` by default; explicitly permit `mechanical` or `refactor` only for suitable work.
274
- Present the accepted goal, constraints and scope together with the recommended choices.
275
- Do not include secrets or raw conversation in the contract.
276
-
277
- Code normalizes and hashes the object.
278
- The journal retains the normalized authority and injects it into compiled plans; callers do not compute hashes.
279
- Keep acceptance IDs and statements stable when reshaping a plan.
280
- Compilation rejects missing acceptance verification, outside-policy writes/dependencies, or substituted authority.
281
- Historical prose approvals remain readable; their existence does not authorize new scope.
282
-
283
- Mechanical tasks may write document/data/config files (`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.toml`, `.lock`).
284
- Refactor tasks preserve accepted verifier inputs.
285
- Both must declare no public behavior change and require an explicitly allowed mode in Gate A. Specify task `verificationMode` and Green (plus optional Refactor); omit Red in the draft.
286
- The compiler handles its internal compatibility representation.
287
- Runtime captures baseline and executes Green with affected, cumulative and post-apply verification, without generating a Red candidate.
288
- Behavior tasks continue to require Red/Green.
289
-
290
- An AGENTS checkpoint additionally requires its exact target path in `policy.writeRoots`; a broad source root does not grant AGENTS authority.
@@ -0,0 +1,82 @@
1
+ export type PacketActivityState =
2
+ | "queued"
3
+ | "preparing"
4
+ | "connecting"
5
+ | "waiting-first-response"
6
+ | "running"
7
+ | "retrying"
8
+ | "completed"
9
+ | "failed"
10
+ | "cancelled"
11
+ | "timed-out";
12
+
13
+ export type PacketFailureReason =
14
+ | "subagent failed"
15
+ | "subagent cancelled"
16
+ | "phase timed out";
17
+
18
+ export interface PacketActivityEvent {
19
+ state: PacketActivityState;
20
+ requestId: string;
21
+ role: string;
22
+ phase: string;
23
+ objective: string;
24
+ sequence: number;
25
+ attempt?: number;
26
+ maxAttempts?: number;
27
+ code?: string;
28
+ wait?: string;
29
+ failureReason?: PacketFailureReason;
30
+ }
31
+
32
+ export type PacketActivityObserver = (
33
+ event: PacketActivityEvent,
34
+ ) => void | Promise<void>;
35
+
36
+ export const WORKFLOW_ACTIVITY_STATES = [
37
+ "queued",
38
+ "preparing",
39
+ "connecting",
40
+ "waiting-first-response",
41
+ "running",
42
+ "validating",
43
+ "retrying",
44
+ "verifying",
45
+ "paused",
46
+ "approval-needed",
47
+ "applying",
48
+ "recovering",
49
+ "operation-cancelled",
50
+ "discarded",
51
+ "rejected",
52
+ "completed",
53
+ ] as const;
54
+
55
+ export type WorkflowActivityState = (typeof WORKFLOW_ACTIVITY_STATES)[number];
56
+ export type ActivityState = PacketActivityState | WorkflowActivityState;
57
+ export type TerminalActivityState =
58
+ | Exclude<
59
+ PacketActivityState,
60
+ | "queued"
61
+ | "preparing"
62
+ | "connecting"
63
+ | "waiting-first-response"
64
+ | "running"
65
+ | "retrying"
66
+ >
67
+ | WorkflowActivityState;
68
+
69
+ export interface WorkflowActivityUpdate {
70
+ state: WorkflowActivityState;
71
+ stage?: "abel-design" | "abel-implement";
72
+ runId?: string;
73
+ change?: string;
74
+ taskId?: string;
75
+ phase?: string;
76
+ objective?: string;
77
+ code?: string;
78
+ attempt?: number;
79
+ maxAttempts?: number;
80
+ wait?: string;
81
+ legalCommands?: string[];
82
+ }
@@ -0,0 +1,143 @@
1
+ import { createHash } from "node:crypto";
2
+ import { diffWritePaths } from "./contracts.ts";
3
+ import {
4
+ type BeginCandidateInput,
5
+ TASK_LEDGER_LIMITS,
6
+ type TaskLedger,
7
+ } from "./task-ledger.ts";
8
+ import type { DurableCandidateProposal } from "./workflow-policy.ts";
9
+
10
+ type ArtifactProposal = Extract<
11
+ DurableCandidateProposal,
12
+ { kind: "candidate" | "sealed-candidate" }
13
+ >;
14
+ interface CandidateArtifactInput {
15
+ ledger: TaskLedger;
16
+ identity: BeginCandidateInput;
17
+ proposal: ArtifactProposal;
18
+ }
19
+
20
+ const sha256 = (bytes: Uint8Array) =>
21
+ createHash("sha256").update(bytes).digest("hex");
22
+
23
+ /** Inspect the same sealed bytes for normal phases and repairs. Callers own recovery policy. */
24
+ export function inspectCandidateArtifact({
25
+ ledger,
26
+ identity,
27
+ proposal,
28
+ approvedPaths,
29
+ }: CandidateArtifactInput & { approvedPaths: readonly string[] }):
30
+ | { ok: true; bytes: Buffer; paths: string[] }
31
+ | {
32
+ ok: false;
33
+ code: "candidate-diff-invalid" | "write-set-mismatch";
34
+ stage: string;
35
+ } {
36
+ const invalid = (stage: string) => ({
37
+ ok: false as const,
38
+ code: "candidate-diff-invalid" as const,
39
+ stage,
40
+ });
41
+ ledger.beginCandidate(identity);
42
+ let bytes: Buffer;
43
+ if (proposal.kind === "sealed-candidate") {
44
+ if (
45
+ proposal.candidateId !== identity.candidateId ||
46
+ !/^[a-f0-9]{64}$/u.test(proposal.artifactHash) ||
47
+ !Number.isSafeInteger(proposal.bytes) ||
48
+ proposal.bytes < 1 ||
49
+ !Array.isArray(proposal.paths)
50
+ )
51
+ return invalid("candidate-seal");
52
+ bytes = Buffer.from(ledger.readSealedCandidate(identity.candidateId));
53
+ if (
54
+ bytes.length !== proposal.bytes ||
55
+ sha256(bytes) !== proposal.artifactHash
56
+ ) {
57
+ throw new Error("candidate-seal-integrity-invalid");
58
+ }
59
+ } else {
60
+ if (
61
+ !(proposal.bytes instanceof Uint8Array) ||
62
+ proposal.bytes.length === 0
63
+ ) {
64
+ return invalid("candidate-submit");
65
+ }
66
+ bytes = Buffer.from(proposal.bytes);
67
+ }
68
+ let paths: string[];
69
+ try {
70
+ paths = diffWritePaths(
71
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
72
+ ).paths;
73
+ } catch {
74
+ return invalid("candidate-diff");
75
+ }
76
+ if (paths.some((relative) => !approvedPaths.includes(relative))) {
77
+ return {
78
+ ok: false,
79
+ code: "write-set-mismatch",
80
+ stage: "candidate-boundary",
81
+ };
82
+ }
83
+ if (
84
+ proposal.kind === "sealed-candidate" &&
85
+ JSON.stringify([...proposal.paths].sort()) !==
86
+ JSON.stringify([...paths].sort())
87
+ ) {
88
+ return invalid("candidate-seal");
89
+ }
90
+ return { ok: true, bytes, paths };
91
+ }
92
+
93
+ /** Seal after isolated patch/dependency checks, before any phase verification. */
94
+ export function sealCandidateArtifact({
95
+ ledger,
96
+ identity,
97
+ proposal,
98
+ bytes,
99
+ }: CandidateArtifactInput & { bytes: Buffer }):
100
+ | { ok: true; artifactHash: string }
101
+ | { ok: false; kind: "paused" | "retryable"; code: string; stage: string } {
102
+ if (proposal.kind === "sealed-candidate")
103
+ return { ok: true, artifactHash: proposal.artifactHash };
104
+ let sequence = 0;
105
+ for (
106
+ let offset = 0;
107
+ offset < bytes.length;
108
+ offset += TASK_LEDGER_LIMITS.maxSegmentBytes
109
+ ) {
110
+ const segment = bytes.subarray(
111
+ offset,
112
+ Math.min(offset + TASK_LEDGER_LIMITS.maxSegmentBytes, bytes.length),
113
+ );
114
+ const accepted = ledger.appendCandidateSegment({
115
+ ...identity,
116
+ sequence,
117
+ bytes: segment,
118
+ segmentHash: sha256(segment),
119
+ });
120
+ if (!accepted.ok)
121
+ return {
122
+ ok: false,
123
+ kind: "paused",
124
+ code: accepted.code,
125
+ stage: "candidate-segment",
126
+ };
127
+ sequence++;
128
+ }
129
+ const sealed = ledger.sealCandidate({
130
+ ...identity,
131
+ segmentCount: sequence,
132
+ totalBytes: bytes.length,
133
+ candidateHash: sha256(bytes),
134
+ });
135
+ return sealed.ok
136
+ ? { ok: true, artifactHash: sealed.artifactHash }
137
+ : {
138
+ ok: false,
139
+ kind: "retryable",
140
+ code: sealed.code,
141
+ stage: "candidate-seal",
142
+ };
143
+ }
@@ -147,7 +147,7 @@ export function compileCandidatePatch(input: {
147
147
  return state;
148
148
  };
149
149
 
150
- for (const operation of input.operations) {
150
+ for (const [operationIndex, operation] of input.operations.entries()) {
151
151
  if (
152
152
  !operation ||
153
153
  typeof operation !== "object" ||
@@ -166,20 +166,40 @@ export function compileCandidatePatch(input: {
166
166
  throw new Error("candidate-patch-replace-invalid");
167
167
  }
168
168
  const relative = requirePath(operation.path);
169
+ const reject = (reason: string, hint: string): never => {
170
+ throw new Error(
171
+ JSON.stringify({
172
+ code: "candidate-patch-replace-invalid",
173
+ operationIndex,
174
+ ...(relative.length <= 512 && !/[\p{Cc}\p{Cf}]/u.test(relative)
175
+ ? { path: relative }
176
+ : {}),
177
+ reason,
178
+ hint,
179
+ }),
180
+ );
181
+ };
169
182
  if (!writePaths.has(relative)) {
170
- throw new Error("candidate-patch-replace-invalid");
183
+ reject(
184
+ "path-not-authorized",
185
+ "Use only approved write paths; request context instead of widening authority.",
186
+ );
171
187
  }
172
188
  const state = load(relative);
173
- if (
174
- state.current === null ||
175
- occurrenceCount(state.current, operation.oldText) !== 1
176
- ) {
177
- throw new Error("candidate-patch-replace-invalid");
189
+ const current =
190
+ state.current ??
191
+ reject(
192
+ "file-not-found",
193
+ "Use create for a new approved file, or correct the path.",
194
+ );
195
+ const occurrences = occurrenceCount(current, operation.oldText);
196
+ if (occurrences !== 1) {
197
+ reject(
198
+ occurrences === 0 ? "old-text-not-found" : "old-text-ambiguous",
199
+ "Read the approved file and supply exact unique oldText, including enough surrounding context. Operations are applied in order.",
200
+ );
178
201
  }
179
- state.current = state.current.replace(
180
- operation.oldText,
181
- operation.newText,
182
- );
202
+ state.current = current.replace(operation.oldText, operation.newText);
183
203
  break;
184
204
  }
185
205
  case "rewrite": {
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import { lstatSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { observeSafePath } from "./safe-path.ts";
5
+
6
+ export function runGitApply(
7
+ root: string,
8
+ diff: Uint8Array,
9
+ checkOnly: boolean,
10
+ signal: AbortSignal,
11
+ ): Promise<"ok" | "failed" | "cancelled"> {
12
+ if (signal.aborted) return Promise.resolve("cancelled");
13
+ return new Promise((resolve) => {
14
+ let settled = false;
15
+ const settle = (value: "ok" | "failed" | "cancelled") => {
16
+ if (settled) return;
17
+ settled = true;
18
+ signal.removeEventListener("abort", abort);
19
+ resolve(value);
20
+ };
21
+ const child = spawn(
22
+ "git",
23
+ [
24
+ "apply",
25
+ ...(checkOnly ? ["--check"] : []),
26
+ "--recount",
27
+ "--whitespace=nowarn",
28
+ "-",
29
+ ],
30
+ { cwd: root, shell: false, stdio: ["pipe", "ignore", "ignore"] },
31
+ );
32
+ const abort = () => {
33
+ child.kill("SIGTERM");
34
+ settle("cancelled");
35
+ };
36
+ signal.addEventListener("abort", abort, { once: true });
37
+ child.once("error", () => settle(signal.aborted ? "cancelled" : "failed"));
38
+ child.once("close", (code) =>
39
+ settle(signal.aborted ? "cancelled" : code === 0 ? "ok" : "failed"),
40
+ );
41
+ child.stdin.on("error", () => undefined);
42
+ child.stdin.end(Buffer.from(diff));
43
+ });
44
+ }
45
+ export function revisionChanges(
46
+ root: string,
47
+ paths: readonly string[],
48
+ ): Record<
49
+ string,
50
+ { kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
51
+ > {
52
+ const changes: Record<
53
+ string,
54
+ { kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
55
+ > = {};
56
+ for (const relative of [...new Set(paths)].sort()) {
57
+ const observation = observeSafePath(root, relative);
58
+ if (observation.kind === "absent") {
59
+ changes[relative] = { kind: "absent" };
60
+ continue;
61
+ }
62
+ if (observation.kind !== "file") {
63
+ throw new Error("workflow-candidate-path-unsafe");
64
+ }
65
+ const target = path.join(root, ...relative.split("/"));
66
+ const stat = lstatSync(target);
67
+ changes[relative] = {
68
+ kind: "file",
69
+ bytes: readFileSync(target),
70
+ mode: stat.mode & 0o777,
71
+ };
72
+ }
73
+ return changes;
74
+ }