@abelxiaoxing/cadence 1.1.1 → 1.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.
- package/README.md +8 -2
- package/agents/implementation-worker.md +17 -11
- package/config/routes.example.json +0 -2
- package/package.json +2 -2
- package/prompts/abel-design.md +20 -16
- package/prompts/abel-implement.md +4 -3
- package/skills/abel-workflow/SKILL.md +6 -3
- package/src/agent-registry.ts +0 -1
- package/src/candidate-patch.ts +250 -0
- package/src/child-session.ts +27 -24
- package/src/contracts.ts +1 -8
- package/src/control-contracts.ts +14 -48
- package/src/design-control.ts +224 -17
- package/src/design-journal.ts +69 -22
- package/src/index.ts +67 -20
- package/src/packet-runtime.ts +6 -6
- package/src/route-policy.ts +45 -17
- package/src/run-state.ts +4 -2
- package/src/run-store.ts +22 -6
- package/src/submit-tool.ts +149 -49
- package/src/worker-broker.ts +0 -1
- package/src/workflow-engine.ts +22 -171
- package/agents/contract-reviewer.md +0 -11
package/README.md
CHANGED
|
@@ -11,18 +11,22 @@
|
|
|
11
11
|
- `/abel-diagnose <problem-description>` — 独立执行“复现 → 证伪 → 失败回归 → 最小修复”,不充当 Implement 的失败路由。
|
|
12
12
|
|
|
13
13
|
包是独立的:单一仓库、单一清单、单一 lockfile,无 workspace、无参考仓库检出、不依赖任何外部 Subagent 包。
|
|
14
|
-
|
|
14
|
+
加载本包会注册一个私有扩展与三个包内专业 Agent;`abel_dispatch` 工具注册但保持未激活,只有用户显式调用且包来源验证通过的 Design、Implement 或 Diagnose prompt 才会激活它。
|
|
15
15
|
仓库中存在 Abel 文件、OpenSpec change、AGENTS 索引或普通文本都不会启动工作流;Init 也不会激活派发工具。
|
|
16
16
|
|
|
17
17
|
共享工作流规则统一收录于包内 `abel-workflow` Skill;其发现信息同样明确要求先有显式 `/abel-*` 调用。
|
|
18
18
|
|
|
19
19
|
Implement 只暴露 `start`、`status`、`resume`、`rebind`、`cancel` 和 `discard` 控制命令。
|
|
20
20
|
`status` 完全本地可用;相同 operation id 幂等重放,进程、会话或 Worker 更换后仍从 durable checkpoint 继续。
|
|
21
|
+
Design 从第一步起统一使用 `action: "design"`:新需求通过 `start(requirement)` 进入,已有 change 通过 `start(change)` 进入,后续只携带返回的 `runId`。
|
|
22
|
+
需求、决策合同与 Gate A 合同由控制面规范化并计算哈希,调用方无需 SHA-256 工具;Gate B 自动绑定当前已编译 canonical plan,原始瞬时文本不会写入 durable journal。
|
|
21
23
|
|
|
22
24
|
普通 artifact、错误 Red、transport、environment、stale、conflict、baseline 与验证失败都留在同一 Implement run 中恢复。
|
|
23
25
|
累计验证发现 introduced failure 时会自动重开责任任务做有界修复;自动预算耗尽后 `resume` 复用已有 baseline 和 phase facts。
|
|
24
26
|
Design 证据包必须绑定 durable `runId`;接受后的有界证据、决策版本、Gate 证明与 canonical plan 身份写入 owner-private journal。
|
|
25
27
|
Design 激活期间,父模型只保留进入前已启用的 `read`、`grep`、`find`、`ls` 与 `abel_dispatch`;原工具集合会在 finalize、finish、切换阶段或 session 结束时精确恢复。
|
|
28
|
+
Implementation Worker 不再手写 unified-diff header、hunk range、分段或哈希;它一次提交有序的 `replace`、`rewrite`、`create`、`delete` 操作。
|
|
29
|
+
可信控制面在隔离 workspace 中校验精确文本、批准路径和 symlink 安全,生成并内部分块 sealed candidate;超限时保留可恢复状态而不接受截断 patch。
|
|
26
30
|
OpenSpec change 制品只能通过私有 `write-artifact` / `delete-artifact` 原子操作变更;产品文件、AGENTS、`gate-a.yaml`、`ready.yaml` 和 `implement-plan.json` 对该通道不可达。
|
|
27
31
|
Gate A/B 收据使用 schema v4,并在 Implement admission 时同时对照同一 root/change 的私有批准事实与 finalization revision/hash 事实验证。
|
|
28
32
|
|
|
@@ -67,8 +71,10 @@ npm install -g @abelxiaoxing/cadence
|
|
|
67
71
|
|
|
68
72
|
## Worker 路由(Worker routes)
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
路由配置是可选的:没有项目级或用户级文件时,三个包内 Agent 默认继承当前父模型,因此首次 Design、Implement 或 Diagnose 不需要预先配置 endpoint。
|
|
75
|
+
只有需要自定义模型、显式顺序或 failover 时,才复制 [`config/routes.example.json`](config/routes.example.json) 到项目级 `.pi/cadence/routes.json` 或用户级 `~/.pi/agent/cadence/routes.json`。
|
|
71
76
|
项目文件按整文件优先;route 必须显式列入对应角色,custom route 只引用 `apiKeyEnv` 的变量名,不能把凭据值写进 JSON。
|
|
77
|
+
一旦显式文件存在,它就是完整策略;损坏、缺字段或角色引用不一致会 fail closed,不会悄悄退回默认父模型。
|
|
72
78
|
`rebind` 只能选择已获 policy 授权且能力匹配的 route,不会扩大任务边界。
|
|
73
79
|
|
|
74
80
|
## 跨项目验证合同(Cross-project verification)
|
|
@@ -5,20 +5,26 @@ You are a package-owned read-only professional Agent for `abel-implement` task p
|
|
|
5
5
|
You explore the workspace ONLY through the scoped `read`, `grep`, `find`, and `ls` tools.
|
|
6
6
|
You never write the workspace, run validation, or execute commands.
|
|
7
7
|
|
|
8
|
-
Your output is
|
|
8
|
+
Your output is one complete structured patch delivered through `abel_submit_result`:
|
|
9
9
|
|
|
10
10
|
- copy the attempt-bound `candidateId` from the phase contract;
|
|
11
|
-
- submit
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
11
|
+
- submit exactly one `candidate-patch` call containing that `candidateId` and
|
|
12
|
+
an ordered `operations` array;
|
|
13
|
+
- use `replace` with exact uniquely occurring `oldText` and the intended
|
|
14
|
+
`newText` for compact edits, `rewrite` for a complete existing-file body,
|
|
15
|
+
`create` with complete content plus `regular | executable` mode, and
|
|
16
|
+
`delete` only for an approved deletion path;
|
|
17
|
+
- never write unified-diff headers or hunks and never calculate sequences,
|
|
18
|
+
Base64 encodings, byte counts, or hashes; the trusted submit tool validates
|
|
19
|
+
the operations, generates the diff, chunks it, and seals it atomically;
|
|
20
|
+
- every operation path is within the declared phase boundary; paths that do
|
|
21
|
+
not need a change may remain untouched;
|
|
18
22
|
- preserve the expected parent-owned verification contract without claiming a result;
|
|
19
23
|
- use a typed `context-request` for bounded context, task splitting, or an
|
|
20
|
-
approval-boundary blocker instead of submitting a partial
|
|
24
|
+
approval-boundary blocker instead of submitting a partial patch.
|
|
21
25
|
|
|
22
|
-
If the
|
|
23
|
-
|
|
26
|
+
If the submit tool rejects malformed operations, use its concrete error to correct the submission once in this same disposable session; a second rejected structural submission ends the session.
|
|
27
|
+
|
|
28
|
+
If the configured candidate limit is exceeded, the submit tool returns terminal typed `result-limit` with `limitBytes`; never continue with a partial or truncated patch.
|
|
29
|
+
The parent alone reviews, applies, and validates the generated diff.
|
|
24
30
|
You report candidate facts only and never claim execution success, select parent recovery, choose a workflow stage, or control the task DAG.
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
"capabilities": {
|
|
7
7
|
"roles": [
|
|
8
8
|
"design-explorer",
|
|
9
|
-
"contract-reviewer",
|
|
10
9
|
"implementation-worker",
|
|
11
10
|
"diagnosis-worker"
|
|
12
11
|
],
|
|
@@ -35,7 +34,6 @@
|
|
|
35
34
|
},
|
|
36
35
|
"roles": {
|
|
37
36
|
"design-explorer": ["parent"],
|
|
38
|
-
"contract-reviewer": ["parent"],
|
|
39
37
|
"implementation-worker": ["custom-worker", "parent"],
|
|
40
38
|
"diagnosis-worker": ["parent"]
|
|
41
39
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abelxiaoxing/cadence",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Four explicit Abel workflow prompts with stage-isolated design and diagnosis, durable resumable implementation, and package-owned professional Agents.",
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
7
7
|
"engines": {
|
package/prompts/abel-design.md
CHANGED
|
@@ -49,15 +49,18 @@ Identical replay is idempotent and conflicting reuse of an operation id fails cl
|
|
|
49
49
|
|
|
50
50
|
## Durable Design identity
|
|
51
51
|
|
|
52
|
-
Use
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
Use the same `action: "design"` envelope from the first call through finalization.
|
|
53
|
+
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.
|
|
54
|
+
For a new requirement, the control plane normalizes and hashes the transient text itself; the raw requirement is not written to the durable journal.
|
|
55
|
+
After Gate A fixes its name, bind that same provisional run with `bind-change` rather than starting a second run.
|
|
56
|
+
Use `status` by `runId` to recover local lifecycle and Design facts without a model or endpoint request.
|
|
55
57
|
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.
|
|
56
58
|
|
|
57
59
|
```json
|
|
58
|
-
{"
|
|
59
|
-
{"
|
|
60
|
-
{"
|
|
60
|
+
{"action":"design","request":{"operation":"start","requirement":"<complete-requirement>","operationId":"<unique-operation>"}}
|
|
61
|
+
{"action":"design","request":{"operation":"start","change":"<existing-change>","operationId":"<unique-operation>"}}
|
|
62
|
+
{"action":"design","request":{"operation":"status","runId":"<run-id>"}}
|
|
63
|
+
{"action":"design","request":{"operation":"bind-change","runId":"<run-id>","operationId":"<unique-operation>","change":"<gate-a-approved-change>"}}
|
|
61
64
|
```
|
|
62
65
|
|
|
63
66
|
## Read-only evidence packets
|
|
@@ -97,13 +100,14 @@ Design may inspect manifests and verification capability statically; it must not
|
|
|
97
100
|
## Decision ledger and Gate A
|
|
98
101
|
|
|
99
102
|
Maintain the conversational decision presentation with id, `behavior | technical`, question, cited evidence, alternatives, recommendation, user decision, status, and affected artifacts.
|
|
100
|
-
After the user resolves a substantive decision,
|
|
103
|
+
After the user resolves a substantive decision, send its concise canonical contract text and artifact references through the private control operation.
|
|
104
|
+
The control plane normalizes and hashes that transient text and persists only the hash, identity, category, and refs:
|
|
101
105
|
|
|
102
106
|
```json
|
|
103
|
-
{"action":"design","request":{"operation":"record-decision","runId":"<run-id>","operationId":"<unique-operation>","decisionId":"<stable-id>","category":"behavior","
|
|
107
|
+
{"action":"design","request":{"operation":"record-decision","runId":"<run-id>","operationId":"<unique-operation>","decisionId":"<stable-id>","category":"behavior","contract":"<canonical-decision-contract>","refs":["<artifact-anchor>"]}}
|
|
104
108
|
```
|
|
105
109
|
|
|
106
|
-
Do not put prompts, transcripts, hidden reasoning, credentials, environment values, or raw model output in this request.
|
|
110
|
+
Do not put prompts, transcripts, hidden reasoning, credentials, environment values, or raw model output in this request; `contract` is only the concise decision being bound.
|
|
107
111
|
|
|
108
112
|
Ask the user only for substantive choices: observable behavior, scope/non-goals, data/security/privacy/compatibility/migration policy, new dependencies, architecture/policy, irreversible changes, and technical choices with real trade-offs.
|
|
109
113
|
Resolve reversible details mechanically from one established repository convention and do not ask them repeatedly.
|
|
@@ -113,15 +117,15 @@ Before approval, present the unresolved behavior decisions together.
|
|
|
113
117
|
After explicit approval:
|
|
114
118
|
|
|
115
119
|
1. resolve the final kebab-case change name and schema;
|
|
116
|
-
2.
|
|
117
|
-
3.
|
|
118
|
-
4.
|
|
120
|
+
2. approve the complete WHAT contract through the transient-text operation below;
|
|
121
|
+
3. bind the final change name to the same run with `bind-change`, creating the change root only through later artifact writes;
|
|
122
|
+
4. materialize schema-ready behavior artifacts one at a time.
|
|
119
123
|
|
|
120
124
|
```json
|
|
121
|
-
{"action":"design","request":{"operation":"approve-gate","runId":"<run-id>","operationId":"<unique-operation>","gate":"gate-a","
|
|
125
|
+
{"action":"design","request":{"operation":"approve-gate","runId":"<run-id>","operationId":"<unique-operation>","gate":"gate-a","contract":"<complete-approved-what-contract>"}}
|
|
122
126
|
```
|
|
123
127
|
|
|
124
|
-
The returned proof contains
|
|
128
|
+
The control plane generates the canonical behavior-contract hash; the returned proof contains its approval revision, contract hash, and owner-private record hash.
|
|
125
129
|
`gate-a.yaml` is installed later by code-owned finalization, not hand-authored here.
|
|
126
130
|
|
|
127
131
|
Gate A is not tool permission and carries no session/model/timestamp identity.
|
|
@@ -176,10 +180,10 @@ Gate B approves the complete HOW: substantive technical choices, task DAG, exact
|
|
|
176
180
|
Do not request approval while any capability, closure, traceability, or blocking decision is unresolved.
|
|
177
181
|
|
|
178
182
|
After explicit approval, materialize any remaining schema artifacts through `write-artifact` one at a time and complete the code-owned sequence below.
|
|
179
|
-
First
|
|
183
|
+
First approve Gate B; the control plane accepts only a plan compiled after the current Gate A approval and latest substantive decisions, then binds that stored canonical hash so the caller never copies or recomputes it:
|
|
180
184
|
|
|
181
185
|
```json
|
|
182
|
-
{"action":"design","request":{"operation":"approve-gate","runId":"<run-id>","operationId":"<unique-operation>","gate":"gate-b"
|
|
186
|
+
{"action":"design","request":{"operation":"approve-gate","runId":"<run-id>","operationId":"<unique-operation>","gate":"gate-b"}}
|
|
183
187
|
```
|
|
184
188
|
|
|
185
189
|
Then request finalization:
|
|
@@ -74,7 +74,8 @@ For each ready task the engine runs:
|
|
|
74
74
|
|
|
75
75
|
Independent tasks may merge only after currentness and declared conflict checks.
|
|
76
76
|
Conflicting tasks remain durably queued without spending a Worker attempt.
|
|
77
|
-
Workers
|
|
77
|
+
Workers make one atomic structured-patch submission whose operations stay within their approved write/delete sets; unused authorized paths need not be touched.
|
|
78
|
+
The trusted submit tool validates exact replacements and file operations, generates the unified diff, and owns internal chunking, hashes, byte limits, and sealing; Workers never hand-author hunk ranges or write the workspace, AGENTS, task state, or the main repository.
|
|
78
79
|
|
|
79
80
|
After all tasks, run every task-affected contract, the approved full suite, output postconditions, and the code-owned managed-only AGENTS checkpoint.
|
|
80
81
|
Only then prepare one journaled, currentness-checked cumulative apply and the declared post-apply verification.
|
|
@@ -84,7 +85,7 @@ The main workspace stays unchanged before that transaction.
|
|
|
84
85
|
|
|
85
86
|
Ordinary failures stay inside this Implement run:
|
|
86
87
|
|
|
87
|
-
- `artifact`, wrong-Red identity,
|
|
88
|
+
- `artifact`, wrong-Red identity, invalid/incomplete patch operation, stale candidate, or transport failure: use the bounded automatic attempt policy; after exhaustion pause with the last safe code and retain the last committed revision.
|
|
88
89
|
- Artifact correction alone consumes the sealed `artifactCorrection.maxAttempts` of 2-3 total candidate launches per task phase and operation, including the initial launch; a later `resume` starts a fresh operation budget.
|
|
89
90
|
- `environment` or `verification-adapter`: pause without speculative edits.
|
|
90
91
|
Resume after the capability is restored; baseline facts and completed phases are reused.
|
|
@@ -95,7 +96,7 @@ Ordinary failures stay inside this Implement run:
|
|
|
95
96
|
- `workspace-revision-stale`, conflict, cancellation, process interruption, or apply recovery: preserve user edits and resume from the last durable checkpoint.
|
|
96
97
|
Cancellation is budget-neutral.
|
|
97
98
|
During a partially visible final apply, recovery must settle before pause or discard.
|
|
98
|
-
- `result-limit`: pause as `needs-task-split`; never accept a partial
|
|
99
|
+
- `result-limit`: pause as `needs-task-split`; never accept a partial patch.
|
|
99
100
|
|
|
100
101
|
No item above carries stage-routing metadata or asks the user to restart the approved workflow.
|
|
101
102
|
|
|
@@ -54,7 +54,9 @@ Cross-stage calls fail closed.
|
|
|
54
54
|
|
|
55
55
|
## Durable Design control
|
|
56
56
|
|
|
57
|
-
Design uses one closed private `action: "design"` family
|
|
57
|
+
Design uses one closed private `action: "design"` family from its first operation onward: `start`, `status`, `bind-change`, `record-decision`, `approve-gate`, `write-artifact`, `delete-artifact`, `compile-plan`, and `finalize-delivery`.
|
|
58
|
+
`start` accepts either the complete transient requirement or an existing change name; `status` uses the returned run id; `bind-change` names a provisional run only after current Gate A approval.
|
|
59
|
+
The control plane normalizes and hashes transient requirement, decision-contract, and Gate-A contract text itself, and Gate B binds a plan compiled after the current Gate A approval and latest substantive decisions without a caller-supplied hash.
|
|
58
60
|
The owner-private SQLite journal retains only normalized evidence identities/hashes, versioned decision hashes and refs, Gate proof facts, canonical compiled-plan identity/bytes, finalization identity, and idempotent operation outcomes.
|
|
59
61
|
It never retains prompts, transcripts, hidden reasoning, credentials, environment values, or raw model output.
|
|
60
62
|
|
|
@@ -132,7 +134,8 @@ Pre-existing failures remain separate and never satisfy Red.
|
|
|
132
134
|
3. **Refactor:** optional in-boundary improvement with behavior and verification unchanged.
|
|
133
135
|
|
|
134
136
|
The parent control plane alone applies accepted candidate bytes to the private cumulative revision, records `phase-verified`/`repair-verified`, advances one task checkbox, verifies outputs, and schedules dependents.
|
|
135
|
-
A Worker
|
|
137
|
+
A Worker submits one complete structured patch within the declared path set; trusted code validates its exact replacements and file operations, generates the unified diff, and seals the candidate.
|
|
138
|
+
One rejected structural submission may be corrected once inside the same disposable child session; a second rejection ends that session without creating an unbounded model loop or accepting partial output.
|
|
136
139
|
|
|
137
140
|
After all tasks, re-run every affected contract, compare the full suite with baseline, apply and verify sealed AGENTS operations, verify output postconditions, then prepare one currentness-checked journaled transaction and post-apply verification.
|
|
138
141
|
The main workspace remains unchanged before final application eligibility.
|
|
@@ -155,7 +158,7 @@ Automatic attempts are finite.
|
|
|
155
158
|
Artifact correction uses the sealed `artifactCorrection.maxAttempts` independently for each task phase and operation; only typed artifact rejection consumes its 2-3 total-launch budget.
|
|
156
159
|
Exhaustion pauses with the final safe code, scope, and last committed revision.
|
|
157
160
|
A later `resume` starts a new operation budget while reusing durable baseline and phase facts.
|
|
158
|
-
Oversized output becomes `needs-task-split`; partial
|
|
161
|
+
Oversized output becomes `needs-task-split`; partial patches are never accepted.
|
|
159
162
|
|
|
160
163
|
Use `approval-needed` only when continuing requires new authority: observable behavior, architecture/policy, dependency, undeclared path, conflict/resource permission, verification contract, AGENTS target/impact/content, or another irreversible scope decision.
|
|
161
164
|
Report the exact missing authority.
|
package/src/agent-registry.ts
CHANGED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { isValidRelativePath } from "./contracts.ts";
|
|
6
|
+
import { observeSafePath } from "./safe-path.ts";
|
|
7
|
+
|
|
8
|
+
export type CandidatePatchOperation =
|
|
9
|
+
| {
|
|
10
|
+
kind: "replace";
|
|
11
|
+
path: string;
|
|
12
|
+
oldText: string;
|
|
13
|
+
newText: string;
|
|
14
|
+
}
|
|
15
|
+
| { kind: "rewrite"; path: string; content: string }
|
|
16
|
+
| {
|
|
17
|
+
kind: "create";
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
mode: "regular" | "executable";
|
|
21
|
+
}
|
|
22
|
+
| { kind: "delete"; path: string };
|
|
23
|
+
|
|
24
|
+
interface CandidateFileState {
|
|
25
|
+
path: string;
|
|
26
|
+
base: string | null;
|
|
27
|
+
current: string | null;
|
|
28
|
+
mode: "100644" | "100755";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function exactKeys(value: object, expected: readonly string[]): boolean {
|
|
32
|
+
const actual = Object.keys(value).sort();
|
|
33
|
+
return (
|
|
34
|
+
actual.length === expected.length &&
|
|
35
|
+
[...expected].sort().every((key, index) => actual[index] === key)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeText(value: unknown, maxBytes: number): value is string {
|
|
40
|
+
if (typeof value !== "string" || value.includes("\0")) return false;
|
|
41
|
+
const encoded = Buffer.from(value, "utf8");
|
|
42
|
+
return encoded.byteLength <= maxBytes && encoded.toString("utf8") === value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderFileDiff(state: CandidateFileState): string {
|
|
46
|
+
if (state.base === state.current) return "";
|
|
47
|
+
if (state.base === null && state.current === "") {
|
|
48
|
+
throw new Error("candidate-empty-file-create-unsupported");
|
|
49
|
+
}
|
|
50
|
+
if (state.current === null && state.base === "") {
|
|
51
|
+
throw new Error("candidate-empty-file-delete-unsupported");
|
|
52
|
+
}
|
|
53
|
+
const output: string[] = [`diff --git a/${state.path} b/${state.path}`];
|
|
54
|
+
if (state.base === null) output.push(`new file mode ${state.mode}`);
|
|
55
|
+
if (state.current === null) output.push(`deleted file mode ${state.mode}`);
|
|
56
|
+
const generated = generateUnifiedPatch(
|
|
57
|
+
state.path,
|
|
58
|
+
state.base ?? "",
|
|
59
|
+
state.current ?? "",
|
|
60
|
+
3,
|
|
61
|
+
);
|
|
62
|
+
const generatedHeaders = `--- ${state.path}\n+++ ${state.path}\n`;
|
|
63
|
+
if (!generated.startsWith(generatedHeaders)) {
|
|
64
|
+
throw new Error("candidate-patch-diff-invalid");
|
|
65
|
+
}
|
|
66
|
+
output.push(state.base === null ? "--- /dev/null" : `--- a/${state.path}`);
|
|
67
|
+
output.push(state.current === null ? "+++ /dev/null" : `+++ b/${state.path}`);
|
|
68
|
+
return `${output.join("\n")}\n${generated.slice(generatedHeaders.length)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function occurrenceCount(value: string, search: string): number {
|
|
72
|
+
let count = 0;
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const found = value.indexOf(search, offset);
|
|
76
|
+
if (found < 0) return count;
|
|
77
|
+
count += 1;
|
|
78
|
+
if (count > 1) return count;
|
|
79
|
+
offset = found + 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function compileCandidatePatch(input: {
|
|
84
|
+
root: string;
|
|
85
|
+
writePaths: readonly string[];
|
|
86
|
+
deletePaths: readonly string[];
|
|
87
|
+
operations: readonly CandidatePatchOperation[];
|
|
88
|
+
maxBytes: number;
|
|
89
|
+
}): string {
|
|
90
|
+
if (
|
|
91
|
+
!path.isAbsolute(input.root) ||
|
|
92
|
+
!Number.isSafeInteger(input.maxBytes) ||
|
|
93
|
+
input.maxBytes < 1 ||
|
|
94
|
+
!Array.isArray(input.operations) ||
|
|
95
|
+
input.operations.length < 1 ||
|
|
96
|
+
input.operations.length > 128
|
|
97
|
+
) {
|
|
98
|
+
throw new Error("candidate-patch-invalid");
|
|
99
|
+
}
|
|
100
|
+
const writePaths = new Set(input.writePaths);
|
|
101
|
+
const deletePaths = new Set(input.deletePaths);
|
|
102
|
+
const states = new Map<string, CandidateFileState>();
|
|
103
|
+
|
|
104
|
+
const requirePath = (relative: unknown): string => {
|
|
105
|
+
if (
|
|
106
|
+
!isValidRelativePath(relative) ||
|
|
107
|
+
relative === "." ||
|
|
108
|
+
relative.trim() !== relative ||
|
|
109
|
+
/[\r\n\t]/u.test(relative)
|
|
110
|
+
) {
|
|
111
|
+
throw new Error("candidate-patch-path-invalid");
|
|
112
|
+
}
|
|
113
|
+
return relative;
|
|
114
|
+
};
|
|
115
|
+
const load = (relative: string): CandidateFileState => {
|
|
116
|
+
const existing = states.get(relative);
|
|
117
|
+
if (existing) return existing;
|
|
118
|
+
const observation = observeSafePath(input.root, relative);
|
|
119
|
+
if (observation.kind === "directory" || observation.kind === "unsafe") {
|
|
120
|
+
throw new Error("candidate-patch-path-unsafe");
|
|
121
|
+
}
|
|
122
|
+
let base: string | null = null;
|
|
123
|
+
let mode: CandidateFileState["mode"] = "100644";
|
|
124
|
+
if (observation.kind === "file") {
|
|
125
|
+
const target = path.join(input.root, relative);
|
|
126
|
+
const stat = lstatSync(target);
|
|
127
|
+
if (stat.size > input.maxBytes) {
|
|
128
|
+
throw new Error("candidate-patch-source-too-large");
|
|
129
|
+
}
|
|
130
|
+
const bytes = readFileSync(target);
|
|
131
|
+
try {
|
|
132
|
+
base = new TextDecoder("utf-8", {
|
|
133
|
+
fatal: true,
|
|
134
|
+
ignoreBOM: true,
|
|
135
|
+
}).decode(bytes);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error("candidate-patch-source-invalid");
|
|
138
|
+
}
|
|
139
|
+
if (base.includes("\0")) {
|
|
140
|
+
throw new Error("candidate-patch-source-invalid");
|
|
141
|
+
}
|
|
142
|
+
mode = stat.mode & 0o111 ? "100755" : "100644";
|
|
143
|
+
}
|
|
144
|
+
const state = { path: relative, base, current: base, mode };
|
|
145
|
+
states.set(relative, state);
|
|
146
|
+
return state;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
for (const operation of input.operations) {
|
|
150
|
+
if (
|
|
151
|
+
!operation ||
|
|
152
|
+
typeof operation !== "object" ||
|
|
153
|
+
Array.isArray(operation)
|
|
154
|
+
) {
|
|
155
|
+
throw new Error("candidate-patch-operation-invalid");
|
|
156
|
+
}
|
|
157
|
+
switch (operation.kind) {
|
|
158
|
+
case "replace": {
|
|
159
|
+
if (
|
|
160
|
+
!exactKeys(operation, ["kind", "path", "oldText", "newText"]) ||
|
|
161
|
+
!safeText(operation.oldText, input.maxBytes) ||
|
|
162
|
+
operation.oldText.length === 0 ||
|
|
163
|
+
!safeText(operation.newText, input.maxBytes)
|
|
164
|
+
) {
|
|
165
|
+
throw new Error("candidate-patch-replace-invalid");
|
|
166
|
+
}
|
|
167
|
+
const relative = requirePath(operation.path);
|
|
168
|
+
if (!writePaths.has(relative)) {
|
|
169
|
+
throw new Error("candidate-patch-replace-invalid");
|
|
170
|
+
}
|
|
171
|
+
const state = load(relative);
|
|
172
|
+
if (
|
|
173
|
+
state.current === null ||
|
|
174
|
+
occurrenceCount(state.current, operation.oldText) !== 1
|
|
175
|
+
) {
|
|
176
|
+
throw new Error("candidate-patch-replace-invalid");
|
|
177
|
+
}
|
|
178
|
+
state.current = state.current.replace(
|
|
179
|
+
operation.oldText,
|
|
180
|
+
operation.newText,
|
|
181
|
+
);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "rewrite": {
|
|
185
|
+
if (
|
|
186
|
+
!exactKeys(operation, ["kind", "path", "content"]) ||
|
|
187
|
+
!safeText(operation.content, input.maxBytes)
|
|
188
|
+
) {
|
|
189
|
+
throw new Error("candidate-patch-rewrite-invalid");
|
|
190
|
+
}
|
|
191
|
+
const relative = requirePath(operation.path);
|
|
192
|
+
if (!writePaths.has(relative)) {
|
|
193
|
+
throw new Error("candidate-patch-rewrite-invalid");
|
|
194
|
+
}
|
|
195
|
+
const state = load(relative);
|
|
196
|
+
if (state.current === null) {
|
|
197
|
+
throw new Error("candidate-patch-rewrite-invalid");
|
|
198
|
+
}
|
|
199
|
+
state.current = operation.content;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case "create": {
|
|
203
|
+
if (
|
|
204
|
+
!exactKeys(operation, ["kind", "path", "content", "mode"]) ||
|
|
205
|
+
!safeText(operation.content, input.maxBytes) ||
|
|
206
|
+
operation.content.length === 0 ||
|
|
207
|
+
(operation.mode !== "regular" && operation.mode !== "executable")
|
|
208
|
+
) {
|
|
209
|
+
throw new Error("candidate-patch-create-invalid");
|
|
210
|
+
}
|
|
211
|
+
const relative = requirePath(operation.path);
|
|
212
|
+
if (!writePaths.has(relative)) {
|
|
213
|
+
throw new Error("candidate-patch-create-invalid");
|
|
214
|
+
}
|
|
215
|
+
const state = load(relative);
|
|
216
|
+
if (state.base !== null || state.current !== null) {
|
|
217
|
+
throw new Error("candidate-patch-create-invalid");
|
|
218
|
+
}
|
|
219
|
+
state.current = operation.content;
|
|
220
|
+
state.mode = operation.mode === "executable" ? "100755" : "100644";
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "delete": {
|
|
224
|
+
if (!exactKeys(operation, ["kind", "path"])) {
|
|
225
|
+
throw new Error("candidate-patch-delete-invalid");
|
|
226
|
+
}
|
|
227
|
+
const relative = requirePath(operation.path);
|
|
228
|
+
if (!deletePaths.has(relative)) {
|
|
229
|
+
throw new Error("candidate-patch-delete-invalid");
|
|
230
|
+
}
|
|
231
|
+
const state = load(relative);
|
|
232
|
+
if (state.base === null || state.current === null) {
|
|
233
|
+
throw new Error("candidate-patch-delete-invalid");
|
|
234
|
+
}
|
|
235
|
+
state.current = null;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
throw new Error("candidate-patch-operation-invalid");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const diff = [...states.values()]
|
|
244
|
+
.filter((state) => state.base !== state.current)
|
|
245
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
246
|
+
.map(renderFileDiff)
|
|
247
|
+
.join("");
|
|
248
|
+
if (diff.length === 0) throw new Error("candidate-patch-empty");
|
|
249
|
+
return diff;
|
|
250
|
+
}
|
package/src/child-session.ts
CHANGED
|
@@ -200,14 +200,8 @@ function structuralSubmitCount(message: AssistantMessage | undefined) {
|
|
|
200
200
|
).length;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
return (
|
|
206
|
-
message?.role === "assistant" &&
|
|
207
|
-
content.length === 1 &&
|
|
208
|
-
content[0]?.type === "toolCall" &&
|
|
209
|
-
content[0].name === "abel_submit_result"
|
|
210
|
-
);
|
|
203
|
+
function hasOneStructuralSubmit(message: AssistantMessage | undefined) {
|
|
204
|
+
return message?.role === "assistant" && structuralSubmitCount(message) === 1;
|
|
211
205
|
}
|
|
212
206
|
|
|
213
207
|
function safeChildError(failure: ChildFailure): string {
|
|
@@ -317,7 +311,7 @@ export async function runChildSession(input: {
|
|
|
317
311
|
onStreamStart?: () => void;
|
|
318
312
|
onStreamProgress?: () => void;
|
|
319
313
|
}): Promise<ChildSessionResult> {
|
|
320
|
-
const
|
|
314
|
+
const candidateProtocol = input.candidateArtifact !== undefined;
|
|
321
315
|
const submit = input.candidateArtifact
|
|
322
316
|
? createCandidateArtifactTool(input.candidateArtifact)
|
|
323
317
|
: createSubmitTool({
|
|
@@ -402,14 +396,19 @@ export async function runChildSession(input: {
|
|
|
402
396
|
const assistants =
|
|
403
397
|
session?.messages.filter((m) => m.role === "assistant") ?? [];
|
|
404
398
|
const last = assistants.at(-1);
|
|
399
|
+
const lastSubmit = assistants.findLast((message) =>
|
|
400
|
+
hasStructuralSubmit(message),
|
|
401
|
+
);
|
|
402
|
+
const structuralAttempts = assistants.reduce(
|
|
403
|
+
(count, message) => count + structuralSubmitCount(message),
|
|
404
|
+
0,
|
|
405
|
+
);
|
|
405
406
|
let finalCategory: FinalCategory;
|
|
406
407
|
if (!last) {
|
|
407
408
|
finalCategory = "no-final-assistant";
|
|
408
|
-
} else if (
|
|
409
|
+
} else if (hasOneStructuralSubmit(lastSubmit)) {
|
|
409
410
|
finalCategory =
|
|
410
|
-
|
|
411
|
-
? "multiple-submit"
|
|
412
|
-
: "single-submit-only";
|
|
411
|
+
structuralAttempts > 1 ? "multiple-submit" : "single-submit-only";
|
|
413
412
|
} else {
|
|
414
413
|
const content = finalDeliveryContent(last);
|
|
415
414
|
finalCategory =
|
|
@@ -504,13 +503,17 @@ export async function runChildSession(input: {
|
|
|
504
503
|
if (childAgent) {
|
|
505
504
|
const previousShouldStopAfterTurn = childAgent.shouldStopAfterTurn;
|
|
506
505
|
childAgent.shouldStopAfterTurn = async (turn, signal) => {
|
|
507
|
-
if (
|
|
508
|
-
segmentedCandidate
|
|
509
|
-
? submit.getResult() !== undefined
|
|
510
|
-
: hasStructuralSubmit(turn.message)
|
|
511
|
-
) {
|
|
506
|
+
if (submit.getResult() !== undefined) {
|
|
512
507
|
return true;
|
|
513
508
|
}
|
|
509
|
+
const structuralAttempts =
|
|
510
|
+
session?.messages
|
|
511
|
+
.filter((message) => message.role === "assistant")
|
|
512
|
+
.reduce(
|
|
513
|
+
(count, message) => count + structuralSubmitCount(message),
|
|
514
|
+
0,
|
|
515
|
+
) ?? 0;
|
|
516
|
+
if (structuralAttempts >= 2) return true;
|
|
514
517
|
return (await previousShouldStopAfterTurn?.(turn, signal)) ?? false;
|
|
515
518
|
};
|
|
516
519
|
}
|
|
@@ -560,11 +563,11 @@ export async function runChildSession(input: {
|
|
|
560
563
|
const result = submit.getResult();
|
|
561
564
|
const attempts = submit.getAttempts();
|
|
562
565
|
const classification = classifySession();
|
|
563
|
-
const
|
|
564
|
-
|
|
566
|
+
const candidateComplete =
|
|
567
|
+
candidateProtocol &&
|
|
565
568
|
result !== undefined &&
|
|
566
569
|
(result.kind === "sealed-candidate" || result.kind === "context-request");
|
|
567
|
-
if (!result || (
|
|
570
|
+
if (!result || (candidateProtocol && !candidateComplete)) {
|
|
568
571
|
const failure = withSubmitDetails(
|
|
569
572
|
submit.getFailure() ??
|
|
570
573
|
input.failureOverride?.() ??
|
|
@@ -589,9 +592,9 @@ export async function runChildSession(input: {
|
|
|
589
592
|
const assistants = session.messages.filter((m) => m.role === "assistant");
|
|
590
593
|
const final = assistants.at(-1);
|
|
591
594
|
if (
|
|
592
|
-
!(
|
|
593
|
-
?
|
|
594
|
-
:
|
|
595
|
+
!(candidateProtocol
|
|
596
|
+
? candidateComplete && structuralSubmitCount(final) === 1
|
|
597
|
+
: hasOneStructuralSubmit(final))
|
|
595
598
|
) {
|
|
596
599
|
const failure = {
|
|
597
600
|
kind: "artifact",
|
package/src/contracts.ts
CHANGED
|
@@ -22,18 +22,11 @@ export const STAGES = [
|
|
|
22
22
|
] as const;
|
|
23
23
|
export const ROLES = [
|
|
24
24
|
"design-explorer",
|
|
25
|
-
"contract-reviewer",
|
|
26
25
|
"implementation-worker",
|
|
27
26
|
"diagnosis-worker",
|
|
28
27
|
] as const;
|
|
29
28
|
export const OUTPUT_KINDS = ["evidence", "diff"] as const;
|
|
30
|
-
export const PHASES = [
|
|
31
|
-
"evidence",
|
|
32
|
-
"red",
|
|
33
|
-
"green",
|
|
34
|
-
"refactor",
|
|
35
|
-
"review",
|
|
36
|
-
] as const;
|
|
29
|
+
export const PHASES = ["evidence", "red", "green", "refactor"] as const;
|
|
37
30
|
|
|
38
31
|
export const AGENTS_IMPACTS = [
|
|
39
32
|
"none",
|