@abelxiaoxing/cadence 1.5.1 → 1.5.3

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 CHANGED
@@ -31,6 +31,9 @@ node /absolute/path/to/cadence/src/operator-cli.mjs runs /path/to/consumer
31
31
  ```
32
32
 
33
33
  `doctor` 检查 Git、Node、OpenSpec 入口、实际 Bubblewrap 启动、请求预算和常用脚本静态能力;不调用模型、不执行产品测试、不保证所有集成服务均可用。
34
+ 包管理器优先采用 `package.json.packageManager`,其次推断唯一锁文件所属的管理器;没有声明和锁文件时明确标为 npm 默认值。
35
+ 输出包含 `selectedPackageManager`、`selectionSource`、`lockfiles` 和 `ambiguousLockfiles`;多个管理器锁文件且无明确声明时阻塞脚本探测,不静默选择 Bun。
36
+ Doctor 不读取私有计划,计划中的显式 runner 仍由正式预检检查;请让项目声明与计划保持一致。
34
37
  `runs` 只读列出该项目最多 1,000 个 run 和有界存储占用;不会创建、迁移或删除数据库,不自动清理暂停任务。
35
38
 
36
39
  ### 可信本地项目:显式复用宿主环境
@@ -87,6 +90,7 @@ Gate、交付校验和执行状态转换由控制面代码保证。
87
90
  当前任务的 Gate 回答与续轮保持阶段有效;用户结束工作流或转向无关任务时,父模型先调用 `{"action":"finish"}`,扩展等待活动操作停止并恢复原工具,保留可恢复进度。
88
91
  此退出同样适用于 Implement,不表示完成或丢弃。
89
92
  显式调用 Init 也会先退出已有阶段。
93
+ Design 同任务续轮重新收紧允许的工具集合,预检失败或暂停不自动退出;会话重载/恢复不继承阶段激活权限,需重新显式调用对应命令,已保存的 run 不会因此完成或丢弃。
90
94
 
91
95
  Implement 只暴露 `start`、`status`、`resume`、`rebind`、`cancel` 和 `discard` 控制命令。
92
96
  `status` 完全本地可用;相同 operation id 幂等重放,进程、会话或 Worker 更换后仍从 durable checkpoint 继续。
@@ -99,6 +103,12 @@ Design 从第一步起统一使用 `action: "design"`:新需求通过 `start(r
99
103
  内联原子验证也可省略 `id` / `classification`;任务公共 `read` 与阶段读取合并,固定 baseline/repair 保护字段由编译器补齐,恢复次数和 AGENTS 影响仍需作者明确选择。
100
104
  [多任务示例](config/plan-draft.multiple-tasks.example.json) 保留显式依赖和产物 producer;ordered steps 仍使用完整内联合同。
101
105
  `validate-plan-draft` 返回有界的最终权限、验证用途、恢复次数和实际派生来源摘要;摘要不充当批准或封存证明。
106
+ 编译结果的 `checks` 分开报告 `structure`、`verificationCapability`、`contractCoverage` 和 `sealing`;静态 `closure.executable: true` 不代表产品测试已运行或 `READY_TO_IMPLEMENT`。
107
+ 独立 `compileImplementPlan` 未传合同则 `contractCoverage: "not-checked"`;传入合同也不证明该合同已获批准。
108
+ 正式预检从私有 journal 注入当前 Gate A 合同并报告 `gateA: "current"`,但仍是 `sealing: "not-performed"`;只有正式控制面可以批准和封存交付。
109
+ 验收缺失诊断列出 acceptance ID;仅当验证 ID 唯一对应一个不同义务时返回字段差异,不猜测最接近的验证器,也不回显命令或参数。
110
+ `changedSurfaces` 仅接受 `none`、`route-authorization`、`page-state`、`api-response`、`public-html`;公共影响不得改标为 `none` 来绕过闭包。
111
+ `verification.change.affected` 是固定标记 `"task-affected-contracts"`,不能使用 `{ "use": "..." }`;`affectedSuite` 中公共影响测试必须在 `relatedTests` 声明证据和实际 owner。
102
112
  推导有歧义时拒绝编译,显式错误仍会报错;代码不会据此扩大路径、依赖或产物权限,封存计划保持完整、严格的结构。
103
113
  预检集中返回不同任务的独立结构错误,并提供字段位置、期望/实际路径和修正提示;工具反馈与 TUI 共用有界脱敏投影。
104
114
  Design status 将可调用的 `legalOperations` 与顶层 `packetActions` 分开;显式退出只能发送 `{"action":"finish"}`,不能伪装成 `operation: "finish"`。
@@ -140,6 +150,58 @@ Gate 等待和可恢复暂停保持激活以接收直接后续操作。
140
150
  暂停会保留最小结构化恢复事实;完成或显式 discard 后安全清理。
141
151
  不会持久化凭据、环境值、原始 prompt、隐藏推理、完整子会话或原始模型输出。
142
152
 
153
+ ### 公共 UI 的 impactClosure 作者格式
154
+
155
+ `page-state` 与 `public-html` 可以同时声明。
156
+ 以下是 **PlanDraft 任务片段**,合并到现有 `tasks[i]`,不是完整计划;其余 Gate A、阶段、产物和验证合同仍按单任务示例填写。
157
+ 路径和证据仅用于说明,必须替换为仓库真实调查结果。
158
+ 假设 Red 修改 `test/page-state.mjs`,已有 `test/public-html.mjs` 只读保留:
159
+
160
+ ```json
161
+ {
162
+ "read": ["test/page-state.mjs", "test/public-html.mjs"],
163
+ "impactClosure": {
164
+ "changedSurfaces": ["page-state", "public-html"],
165
+ "searchEvidence": [
166
+ "检索页面状态的调用点及 test/、tests/ 中的覆盖:test/page-state.mjs 需更新状态切换断言,test/public-html.mjs 覆盖现有公开 HTML。"
167
+ ],
168
+ "relatedTests": [
169
+ {
170
+ "path": "test/page-state.mjs",
171
+ "evidence": "本任务 Red 更新页面状态切换回归;由阶段 write 推导为 current-task。"
172
+ },
173
+ {
174
+ "path": "test/public-html.mjs",
175
+ "evidence": "保留已有 HTML 断言并执行受影响验证;无任务写入,推导为 unaffected。"
176
+ }
177
+ ],
178
+ "affectedSuite": ["test/page-state.mjs", "test/public-html.mjs"]
179
+ }
180
+ }
181
+ ```
182
+
183
+ 完整规则:
184
+
185
+ - `impactClosure` 恰好包含 `changedSurfaces`、`searchEvidence`、`relatedTests`、`affectedSuite` 四个字段,不接受任意扩展字段。
186
+ - `changedSurfaces` 是非空、无重复的合法枚举数组;`none` 不能与其他值混用。
187
+ 不得为了通过校验将公共影响改为 `none`。
188
+ - 存在公共影响时,后三个数组均不得为空;`searchEvidence` 每项为非空字符串,记录实际检索及结论,不是路径对象,也不是可省略的推导字段。
189
+ - `relatedTests` 每项包含 `path` 和非空字符串 `evidence`;路径无重复,必须是 `test/` 或 `tests/` 下的仓库相对文件路径,并位于本任务各阶段合并后的 read/write 范围。
190
+ `src/foo.test.ts` 不满足当前测试路径规则。
191
+ - PlanDraft 可省略 `disposition`:本任务写入则推导为 `current-task`,只有另一任务写入则为 `regression-task` 并推导 `regressionTaskId`,没有任务写入则为 `unaffected`;多个其他写入者时必须消除歧义。
192
+ `unaffected` 表示不修改该测试,不表示不运行该测试。
193
+ - 显式作者格式为 `{ "path": "test/page-state.mjs", "disposition": "current-task", "evidence": "实际证据" }`;只读保留用 `unaffected`。
194
+ 跨任务格式为 `{ "path": "test/page-state.mjs", "disposition": "regression-task", "evidence": "实际证据", "regressionTaskId": "ui-regression" }`。
195
+ 只有 `regression-task` 携带 `regressionTaskId`;依赖、产物和阶段权限仍需按实际计划声明,不会由闭包新增。
196
+ - `affectedSuite` 是无重复的测试路径数组,每项必须有对应的 `relatedTests` 条目;具备快照的运行时校验还要求其中至少一个是已有普通测试文件,不能全靠未来新增测试充当已有证据。
197
+ - `affectedSuite` 是影响清单,不是验证器。
198
+ 显式 `affectedVerification` 应实际运行这些测试,例如 Vitest 的 `testFiles` 列出二者,或使用仓库真实的测试脚本。
199
+ 仍需声明验证输入并遵守 Gate A,不能只补清单就宣称测试已运行。
200
+
201
+ `validate-plan-draft` 对三个空数组分别返回字段级 category:`search-evidence-required`、`related-tests-required`、`affected-suite-required`,并提供代码维护的 `hint`。
202
+ 测试漏列仍返回 `impactClosure.affectedSuite.<index>` / `related-test-missing` 和具体 `path`。
203
+ 空数组不足以确定应选哪些测试,因此不会虚构 `expectedPaths`;按实际证据补齐后,在同一个 Design run 重试预检。
204
+
143
205
  ## 内部边界与运行验证
144
206
 
145
207
  扩展入口负责宿主生命周期与服务装配,交付加载和验证适配器分别由独立模块负责。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
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",
@@ -6,7 +6,10 @@ import {
6
6
  validateVerificationContract,
7
7
  verificationInputPaths,
8
8
  } from "./contracts.ts";
9
- import { DesignPlanValidationError } from "./design-diagnostics.ts";
9
+ import {
10
+ type DesignPlanDiagnostic,
11
+ DesignPlanValidationError,
12
+ } from "./design-diagnostics.ts";
10
13
  import { canonicalJson } from "./run-state.ts";
11
14
 
12
15
  export interface ChangeContract {
@@ -151,25 +154,73 @@ export function assertPlanWithinChangeContract(
151
154
  })[],
152
155
  verifications: readonly StructuredVerificationContract[],
153
156
  ): void {
154
- const available = new Set(
155
- [
156
- ...verifications,
157
- ...tasks.flatMap((task) => [
158
- task.affectedVerification,
159
- ...Object.entries(task.phases)
160
- .filter(
161
- ([phase]) =>
162
- phase !== "red" ||
163
- !task.verificationMode ||
164
- task.verificationMode === "behavior",
165
- )
166
- .map(([, phase]) => phase.verification),
167
- ]),
168
- ].map(verificationObligation),
169
- );
170
- for (const accepted of contract.acceptance)
171
- if (!available.has(verificationObligation(accepted.verification)))
172
- throw new Error("change-contract-acceptance-missing");
157
+ const candidates = [
158
+ ...verifications,
159
+ ...tasks.flatMap((task) => [
160
+ task.affectedVerification,
161
+ ...Object.entries(task.phases)
162
+ .filter(
163
+ ([phase]) =>
164
+ phase !== "red" ||
165
+ !task.verificationMode ||
166
+ task.verificationMode === "behavior",
167
+ )
168
+ .map(([, phase]) => phase.verification),
169
+ ]),
170
+ ];
171
+ const available = new Set(candidates.map(verificationObligation));
172
+ const diagnostics: DesignPlanDiagnostic[] = [];
173
+ for (const [index, accepted] of contract.acceptance.entries()) {
174
+ if (available.has(verificationObligation(accepted.verification))) continue;
175
+ // Only attribute a mismatch when the display ID selects one distinct
176
+ // obligation. Never guess a "closest" verifier or echo command/argument bytes.
177
+ const matching = [
178
+ ...new Set(
179
+ candidates
180
+ .filter((candidate) => candidate.id === accepted.verification.id)
181
+ .map(verificationObligation),
182
+ ),
183
+ ];
184
+ const expected = JSON.parse(verificationObligation(accepted.verification));
185
+ const actual =
186
+ matching.length === 1 && matching[0]
187
+ ? JSON.parse(matching[0])
188
+ : undefined;
189
+ diagnostics.push({
190
+ code: "change-contract-acceptance-missing",
191
+ acceptanceId: accepted.id,
192
+ verificationId: accepted.verification.id,
193
+ field: `changeContract.acceptance.${index}.verification`,
194
+ ...(actual
195
+ ? {
196
+ mismatchedFields: [
197
+ "kind",
198
+ "runner",
199
+ "packageManager",
200
+ "script",
201
+ "command",
202
+ "args",
203
+ "testFiles",
204
+ "minTests",
205
+ "classification",
206
+ "expectedFailure",
207
+ "steps",
208
+ ]
209
+ .filter(
210
+ (key) =>
211
+ canonicalJson(expected[key] ?? null) !==
212
+ canonicalJson(actual[key] ?? null),
213
+ )
214
+ .sort(),
215
+ }
216
+ : {}),
217
+ });
218
+ }
219
+ if (diagnostics.length)
220
+ throw new DesignPlanValidationError(
221
+ "change-contract-acceptance-missing",
222
+ diagnostics,
223
+ );
173
224
  for (const task of tasks) {
174
225
  if (
175
226
  !contract.policy.verificationModes.includes(
package/src/contracts.ts CHANGED
@@ -1233,6 +1233,7 @@ export type ImplementGraphContractDiagnostic =
1233
1233
  path?: string;
1234
1234
  expectedPaths?: string[];
1235
1235
  actualPaths?: string[];
1236
+ allowedValues?: string[];
1236
1237
  }
1237
1238
  | {
1238
1239
  code: "invalid-output-path";
@@ -1518,6 +1519,99 @@ function taskBoundaryContractDiagnostic(
1518
1519
  : diagnostic("task", "contract");
1519
1520
  }
1520
1521
 
1522
+ /** Refine rejected author input; this never admits a partial or repaired task. */
1523
+ function taskFieldDiagnostics(
1524
+ task: Record<string, unknown>,
1525
+ ): ImplementGraphContractDiagnostic[] {
1526
+ const diagnostics: ImplementGraphContractDiagnostic[] = [];
1527
+ const add = (
1528
+ detail: Omit<
1529
+ Extract<
1530
+ ImplementGraphContractDiagnostic,
1531
+ { code: "invalid-implement-graph" }
1532
+ >,
1533
+ "code"
1534
+ >,
1535
+ ) => {
1536
+ if (diagnostics.length < 64)
1537
+ diagnostics.push({
1538
+ code: "invalid-implement-graph",
1539
+ ...(validIdentifier(task.taskId) ? { taskId: task.taskId } : {}),
1540
+ ...detail,
1541
+ });
1542
+ };
1543
+ if (task.phases && typeof task.phases === "object") {
1544
+ const phases = task.phases as Record<string, unknown>;
1545
+ for (const phase of IMPLEMENTATION_PHASES) {
1546
+ const boundary = phases[phase];
1547
+ if (!boundary || typeof boundary !== "object") continue;
1548
+ for (const field of ["read", "write", "delete"] as const) {
1549
+ const paths = (boundary as Record<string, unknown>)[field];
1550
+ if (!Array.isArray(paths)) continue;
1551
+ const seen = new Set<string>();
1552
+ for (const [index, file] of paths.entries()) {
1553
+ if (!isValidRelativePath(file)) continue;
1554
+ if (seen.has(file))
1555
+ add({
1556
+ phase,
1557
+ field: `phases.${phase}.${field}.${index}`,
1558
+ category: "duplicate-path",
1559
+ path: file,
1560
+ });
1561
+ seen.add(file);
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+ if (task.impactClosure && typeof task.impactClosure === "object") {
1567
+ const closure = task.impactClosure as Record<string, unknown>;
1568
+ if (Array.isArray(closure.changedSurfaces)) {
1569
+ for (const [index, surface] of closure.changedSurfaces.entries()) {
1570
+ if (!(IMPACT_SURFACES as readonly unknown[]).includes(surface))
1571
+ add({
1572
+ field: `impactClosure.changedSurfaces.${index}`,
1573
+ category: "enum",
1574
+ allowedValues: [...IMPACT_SURFACES],
1575
+ });
1576
+ }
1577
+ const publicImpact = closure.changedSurfaces.some(
1578
+ (surface) =>
1579
+ surface !== "none" &&
1580
+ (IMPACT_SURFACES as readonly unknown[]).includes(surface),
1581
+ );
1582
+ if (publicImpact) {
1583
+ for (const [field, category] of [
1584
+ ["searchEvidence", "search-evidence-required"],
1585
+ ["relatedTests", "related-tests-required"],
1586
+ ["affectedSuite", "affected-suite-required"],
1587
+ ] as const) {
1588
+ if (Array.isArray(closure[field]) && closure[field].length === 0)
1589
+ add({ field: `impactClosure.${field}`, category });
1590
+ }
1591
+ }
1592
+ if (
1593
+ publicImpact &&
1594
+ Array.isArray(closure.affectedSuite) &&
1595
+ Array.isArray(closure.relatedTests)
1596
+ ) {
1597
+ const related = new Set(
1598
+ closure.relatedTests.flatMap((item) =>
1599
+ item && typeof item === "object" ? [item.path] : [],
1600
+ ),
1601
+ );
1602
+ for (const [index, file] of closure.affectedSuite.entries())
1603
+ if (isValidRelativePath(file) && !related.has(file))
1604
+ add({
1605
+ field: `impactClosure.affectedSuite.${index}`,
1606
+ category: "related-test-missing",
1607
+ path: file,
1608
+ });
1609
+ }
1610
+ }
1611
+ }
1612
+ return diagnostics;
1613
+ }
1614
+
1521
1615
  export function validateImplementGraphBoundary(value: unknown):
1522
1616
  | { ok: true; value: ImplementGraphBoundary }
1523
1617
  | {
@@ -1573,11 +1667,24 @@ export function validateImplementGraphBoundary(value: unknown):
1573
1667
  { changeId: value.changeId, ...task },
1574
1668
  undefined,
1575
1669
  );
1576
- if (diagnostic)
1577
- failures.push({
1578
- reason: "invalid Implement task boundary",
1579
- diagnostic: { code: "invalid-implement-graph", ...diagnostic },
1580
- });
1670
+ if (diagnostic) {
1671
+ const fields = taskFieldDiagnostics(task);
1672
+ // Replace a broad diagnostic only when these details refine that field;
1673
+ // retain unrelated shape/authority failures in the same batch.
1674
+ if (
1675
+ !fields.some(
1676
+ (detail) =>
1677
+ diagnostic.field &&
1678
+ detail.field?.startsWith(`${diagnostic.field}.`),
1679
+ )
1680
+ )
1681
+ fields.push({ code: "invalid-implement-graph", ...diagnostic });
1682
+ for (const detail of fields)
1683
+ failures.push({
1684
+ reason: "invalid Implement task boundary",
1685
+ diagnostic: detail,
1686
+ });
1687
+ }
1581
1688
  }
1582
1689
  for (const candidate of value.outputs) {
1583
1690
  if (
@@ -264,6 +264,13 @@ export type {
264
264
  export type { PlanDraft, PlanPhaseDraft, PlanTaskInput } from "./plan-draft.ts";
265
265
 
266
266
  export interface CompiledDelivery {
267
+ /** Static checks only: neither compilation nor preflight seals a delivery. */
268
+ checks: {
269
+ structure: "passed";
270
+ verificationCapability: "passed";
271
+ contractCoverage: "passed" | "not-checked";
272
+ sealing: "not-performed";
273
+ };
267
274
  plan: ImplementPlan;
268
275
  bytes: Uint8Array;
269
276
  rawSha256: string;
@@ -945,6 +952,20 @@ function normalizeAgentsCheckpointOperations(
945
952
  }
946
953
 
947
954
  function normalizeVerificationPlan(value: unknown): PlanVerification {
955
+ if (
956
+ isRecord(value) &&
957
+ isRecord(value.change) &&
958
+ value.change.affected !== "task-affected-contracts"
959
+ ) {
960
+ throw new DesignPlanValidationError("delivery-verification-plan-invalid", [
961
+ {
962
+ code: "delivery-verification-plan-invalid",
963
+ field: "verification.change.affected",
964
+ category: "enum",
965
+ allowedValues: ["task-affected-contracts"],
966
+ },
967
+ ]);
968
+ }
948
969
  if (
949
970
  !isRecord(value) ||
950
971
  !exactKeys(value, [
@@ -1289,6 +1310,12 @@ export function compileImplementPlan(
1289
1310
  const planHash = hashCanonicalValue(plan);
1290
1311
  const closure = structuredClone(readiness.closure);
1291
1312
  return {
1313
+ checks: {
1314
+ structure: "passed",
1315
+ verificationCapability: "passed",
1316
+ contractCoverage: plan.changeContract ? "passed" : "not-checked",
1317
+ sealing: "not-performed",
1318
+ },
1292
1319
  plan,
1293
1320
  bytes,
1294
1321
  rawSha256,
@@ -978,6 +978,7 @@ export class DesignController {
978
978
  operation: request.operation,
979
979
  runId: request.runId,
980
980
  valid: true,
981
+ checks: { ...compiled.checks, gateA: "current" },
981
982
  summary: planAuthoringSummary(compiled.plan, authoredDraft),
982
983
  plan: {
983
984
  taskCount: compiled.plan.tasks.length,
@@ -11,6 +11,7 @@ const IDENTITIES = new Set([
11
11
  "category",
12
12
  "owner",
13
13
  "verificationId",
14
+ "acceptanceId",
14
15
  "outputId",
15
16
  "dependencyTaskId",
16
17
  "producerTaskId",
@@ -22,6 +23,19 @@ const IDENTITIES = new Set([
22
23
  ]);
23
24
  const HINTS = new Map<string, string>(
24
25
  Object.entries({
26
+ "duplicate-path":
27
+ "Remove the repeated path at the indicated index; keep the existing declaration and do not widen phase permissions.",
28
+ enum: "Use one of allowedValues at the indicated field. For public impact, choose the actual changed surface; do not substitute none to bypass closure checks.",
29
+ "public-impact-incomplete":
30
+ "Public impact requires non-empty searchEvidence, relatedTests and affectedSuite; every affectedSuite path must have a relatedTests entry. Keep the actual changedSurfaces; do not substitute none.",
31
+ "search-evidence-required":
32
+ "Add non-empty searchEvidence strings describing actual searches of changed public surfaces, callers and existing tests. Do not invent evidence or substitute none for changedSurfaces.",
33
+ "related-tests-required":
34
+ "Add relatedTests entries with path and non-empty evidence for actual tests under test/ or tests/, within the task's declared read/write scope. In PlanDraft omit disposition to derive ownership from task writes; do not widen writes or substitute none.",
35
+ "affected-suite-required":
36
+ "Add actual affected test paths under test/ or tests/ to affectedSuite and declare each in relatedTests. Bind their execution in affectedVerification; retained snapshot validation requires at least one existing test. Do not invent paths or substitute none.",
37
+ "related-test-missing":
38
+ "Add this affected-suite test to relatedTests with evidence and its actual owner. Do not remove public impact or widen writes to satisfy ownership.",
25
39
  "change-contract-acceptance-missing":
26
40
  "Read the current approved ChangeContract from Design status and include each accepted verification obligation in the plan's phase, affected, full-suite or post-apply verification. Command, arguments, inputs and classification must match; copying only its ID is insufficient. Keep accepted IDs, statements and scope unchanged.",
27
41
  "script-command-mismatch":
@@ -96,6 +110,18 @@ export function projectDesignDiagnostic(
96
110
  : entry;
97
111
  else if (key === "path" && safePath(entry)) result.path = entry;
98
112
  else if (
113
+ (key === "allowedValues" || key === "mismatchedFields") &&
114
+ Array.isArray(entry)
115
+ ) {
116
+ result[key] = [
117
+ ...new Set(
118
+ entry.filter(
119
+ (item): item is string =>
120
+ typeof item === "string" && CODE.test(item),
121
+ ),
122
+ ),
123
+ ].slice(0, 32);
124
+ } else if (
99
125
  (key === "expectedPaths" || key === "actualPaths") &&
100
126
  Array.isArray(entry)
101
127
  ) {
@@ -120,6 +146,9 @@ export interface DesignPlanDiagnostic {
120
146
  category?: string;
121
147
  owner?: string;
122
148
  verificationId?: string;
149
+ acceptanceId?: string;
150
+ allowedValues?: string[];
151
+ mismatchedFields?: string[];
123
152
  outputId?: string;
124
153
  dependencyTaskId?: string;
125
154
  producerTaskId?: string;
package/src/index.ts CHANGED
@@ -887,6 +887,8 @@ export function registerWorkflowControl(
887
887
  pi.registerTool({
888
888
  name: DISPATCH_TOOL,
889
889
  label: "Abel Control",
890
+ promptSnippet:
891
+ "Control the explicitly activated Abel stage; retain paused work across same-task follow-ups.",
890
892
  description:
891
893
  kind === "command"
892
894
  ? 'Private stage-bound Abel workflow control. Accepts durable Implement commands, batch-bound {"action":"amend"} artifact revisions, or {"action":"finish"} to leave and preserve resumable work.'
@@ -1394,7 +1396,8 @@ export function registerWorkflowControl(
1394
1396
  activePrompt = prompt;
1395
1397
  }
1396
1398
  if (prompt) activateDispatcher(pi, activation, prompt, event.prompt);
1397
- if (verified && prompt === "abel-design") enforceDesignTools();
1399
+ if (activation.isActive() && activePrompt === "abel-design")
1400
+ enforceDesignTools();
1398
1401
  const boundary = activePrompt
1399
1402
  ? `Abel stage ${activePrompt} is active only for the invoked task and its direct follow-ups. If the user ends it or requests an unrelated task, first call abel_dispatch with {"action":"finish"}, then handle that task normally with the restored tools. A successful finish ends stage authority immediately, including within this turn. Do not extend Gates or workflow rules to that task. A direct Gate answer or same-task continuation stays in this stage. Never invoke another Abel stage automatically.`
1400
1403
  : init
@@ -1239,6 +1239,26 @@ function validateAcceptedVerificationCapability(root, verification, dependencyOw
1239
1239
  }
1240
1240
 
1241
1241
  // src/operator-tools.ts
1242
+ var PACKAGE_LOCKS = {
1243
+ bun: ["bun.lock", "bun.lockb"],
1244
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
1245
+ pnpm: ["pnpm-lock.yaml"],
1246
+ yarn: ["yarn.lock"]
1247
+ };
1248
+ function packageManagerSelection(root, declaration) {
1249
+ const managers = Object.keys(PACKAGE_LOCKS);
1250
+ const lockfiles = managers.flatMap((manager) => PACKAGE_LOCKS[manager].filter((file) => lstatSync6(path5.join(root, file), { throwIfNoEntry: false })?.isFile()));
1251
+ const inferred = managers.filter((manager) => PACKAGE_LOCKS[manager].some((file) => lockfiles.includes(file)));
1252
+ const declared = typeof declaration === "string" ? managers.find((manager) => new RegExp(`^${manager}@[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][a-z0-9._+-]+)?$`, "iu").test(declaration)) : undefined;
1253
+ const selectedPackageManager = declaration !== undefined ? declared ?? null : inferred.length === 1 ? inferred[0] ?? null : inferred.length > 1 ? null : "npm";
1254
+ const selectionSource = declaration !== undefined ? declared ? "package.json" : "invalid-declaration" : inferred.length === 1 ? "lockfile" : inferred.length > 1 ? "ambiguous" : "default";
1255
+ return {
1256
+ selectedPackageManager,
1257
+ selectionSource,
1258
+ lockfiles,
1259
+ ambiguousLockfiles: inferred.length > 1
1260
+ };
1261
+ }
1242
1262
  function inspectConsumer(root) {
1243
1263
  root = realpathSync4(root);
1244
1264
  const checks = [];
@@ -1317,6 +1337,7 @@ function inspectConsumer(root) {
1317
1337
  return `${links} repository workspace link(s); their targets must be included in the candidate baseline`;
1318
1338
  }, "Repair dangling/external workspace links and ensure local package sources are tracked or admitted in the candidate.");
1319
1339
  const manifest = path5.join(root, "package.json");
1340
+ let selection = packageManagerSelection(root, undefined);
1320
1341
  if (lstatSync6(manifest, { throwIfNoEntry: false })?.isFile()) {
1321
1342
  check("manifest", () => {
1322
1343
  const parsed2 = JSON.parse(readFileSync4(manifest, "utf8"));
@@ -1326,19 +1347,23 @@ function inspectConsumer(root) {
1326
1347
  }, "Repair package.json.");
1327
1348
  const parsed = (() => {
1328
1349
  try {
1329
- return JSON.parse(readFileSync4(manifest, "utf8"));
1350
+ const value = JSON.parse(readFileSync4(manifest, "utf8"));
1351
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1330
1352
  } catch {
1331
1353
  return {};
1332
1354
  }
1333
1355
  })();
1356
+ selection = packageManagerSelection(root, parsed.packageManager);
1357
+ check("package-manager", () => {
1358
+ if (!selection.selectedPackageManager)
1359
+ throw new Error(`package-manager-${selection.selectionSource}`);
1360
+ return `${selection.selectedPackageManager} (${selection.selectionSource})`;
1361
+ }, "Declare the intended packageManager in package.json; doctor does not read private plans, install runners or remove lockfiles.");
1334
1362
  for (const script of ["test", "check", "build"].filter((name) => typeof parsed.scripts?.[name] === "string")) {
1335
1363
  check(`script:${script}`, () => {
1336
- const manager = ["bun", "pnpm", "yarn", "npm"].find((name) => lstatSync6(path5.join(root, {
1337
- bun: "bun.lock",
1338
- pnpm: "pnpm-lock.yaml",
1339
- yarn: "yarn.lock",
1340
- npm: "package-lock.json"
1341
- }[name]), { throwIfNoEntry: false })) ?? "npm";
1364
+ const manager = selection.selectedPackageManager;
1365
+ if (!manager)
1366
+ throw new Error(`package-manager-${selection.selectionSource}`);
1342
1367
  const result = validateVerificationAdapterCapability(root, {
1343
1368
  kind: "package-script",
1344
1369
  id: `doctor-${script}`,
@@ -1349,12 +1374,12 @@ function inspectConsumer(root) {
1349
1374
  classification: "expected-green"
1350
1375
  });
1351
1376
  if (!result.ok)
1352
- throw new Error(result.diagnostic.code);
1377
+ throw new Error(`${manager}:${result.diagnostic.code}`);
1353
1378
  return "statically admitted; tests not executed";
1354
1379
  }, "Check the runner, installed dependencies and execution configuration; this probe does not execute your tests.");
1355
1380
  }
1356
1381
  }
1357
- return { root, ok: checks.every((entry) => entry.ok), checks };
1382
+ return { root, ...selection, ok: checks.every((entry) => entry.ok), checks };
1358
1383
  }
1359
1384
  function storageUsage(root, maximum = 1e5) {
1360
1385
  let entries = 0;
@@ -14,6 +14,59 @@ export interface DoctorCheck {
14
14
  detail: string;
15
15
  nextStep?: string;
16
16
  }
17
+ const PACKAGE_LOCKS = {
18
+ bun: ["bun.lock", "bun.lockb"],
19
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
20
+ pnpm: ["pnpm-lock.yaml"],
21
+ yarn: ["yarn.lock"],
22
+ } as const;
23
+ type PackageManager = keyof typeof PACKAGE_LOCKS;
24
+
25
+ function packageManagerSelection(root: string, declaration: unknown) {
26
+ const managers = Object.keys(PACKAGE_LOCKS) as PackageManager[];
27
+ const lockfiles = managers.flatMap((manager) =>
28
+ PACKAGE_LOCKS[manager].filter((file) =>
29
+ lstatSync(path.join(root, file), { throwIfNoEntry: false })?.isFile(),
30
+ ),
31
+ );
32
+ const inferred = managers.filter((manager) =>
33
+ PACKAGE_LOCKS[manager].some((file) => lockfiles.includes(file)),
34
+ );
35
+ const declared =
36
+ typeof declaration === "string"
37
+ ? managers.find((manager) =>
38
+ new RegExp(
39
+ `^${manager}@[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][a-z0-9._+-]+)?$`,
40
+ "iu",
41
+ ).test(declaration),
42
+ )
43
+ : undefined;
44
+ const selectedPackageManager =
45
+ declaration !== undefined
46
+ ? (declared ?? null)
47
+ : inferred.length === 1
48
+ ? (inferred[0] ?? null)
49
+ : inferred.length > 1
50
+ ? null
51
+ : "npm";
52
+ const selectionSource =
53
+ declaration !== undefined
54
+ ? declared
55
+ ? "package.json"
56
+ : "invalid-declaration"
57
+ : inferred.length === 1
58
+ ? "lockfile"
59
+ : inferred.length > 1
60
+ ? "ambiguous"
61
+ : "default";
62
+ return {
63
+ selectedPackageManager,
64
+ selectionSource,
65
+ lockfiles,
66
+ ambiguousLockfiles: inferred.length > 1,
67
+ };
68
+ }
69
+
17
70
  export function inspectConsumer(root: string) {
18
71
  root = realpathSync(root);
19
72
  const checks: DoctorCheck[] = [];
@@ -136,6 +189,7 @@ export function inspectConsumer(root: string) {
136
189
  "Repair dangling/external workspace links and ensure local package sources are tracked or admitted in the candidate.",
137
190
  );
138
191
  const manifest = path.join(root, "package.json");
192
+ let selection = packageManagerSelection(root, undefined);
139
193
  if (lstatSync(manifest, { throwIfNoEntry: false })?.isFile()) {
140
194
  check(
141
195
  "manifest",
@@ -151,51 +205,51 @@ export function inspectConsumer(root: string) {
151
205
  );
152
206
  const parsed = (() => {
153
207
  try {
154
- return JSON.parse(readFileSync(manifest, "utf8"));
208
+ const value = JSON.parse(readFileSync(manifest, "utf8"));
209
+ return value && typeof value === "object" && !Array.isArray(value)
210
+ ? value
211
+ : {};
155
212
  } catch {
156
213
  return {};
157
214
  }
158
215
  })();
216
+ selection = packageManagerSelection(root, parsed.packageManager);
217
+ check(
218
+ "package-manager",
219
+ () => {
220
+ if (!selection.selectedPackageManager)
221
+ throw new Error(`package-manager-${selection.selectionSource}`);
222
+ return `${selection.selectedPackageManager} (${selection.selectionSource})`;
223
+ },
224
+ "Declare the intended packageManager in package.json; doctor does not read private plans, install runners or remove lockfiles.",
225
+ );
159
226
  for (const script of ["test", "check", "build"].filter(
160
227
  (name) => typeof parsed.scripts?.[name] === "string",
161
228
  )) {
162
229
  check(
163
230
  `script:${script}`,
164
231
  () => {
165
- const manager =
166
- ["bun", "pnpm", "yarn", "npm"].find((name) =>
167
- lstatSync(
168
- path.join(
169
- root,
170
- (
171
- {
172
- bun: "bun.lock",
173
- pnpm: "pnpm-lock.yaml",
174
- yarn: "yarn.lock",
175
- npm: "package-lock.json",
176
- } as Record<string, string>
177
- )[name],
178
- ),
179
- { throwIfNoEntry: false },
180
- ),
181
- ) ?? "npm";
232
+ const manager = selection.selectedPackageManager;
233
+ if (!manager)
234
+ throw new Error(`package-manager-${selection.selectionSource}`);
182
235
  const result = validateVerificationAdapterCapability(root, {
183
236
  kind: "package-script",
184
237
  id: `doctor-${script}`,
185
- packageManager: manager as "bun" | "pnpm" | "yarn" | "npm",
238
+ packageManager: manager,
186
239
  script,
187
240
  command: parsed.scripts[script],
188
241
  args: [],
189
242
  classification: "expected-green",
190
243
  });
191
- if (!result.ok) throw new Error(result.diagnostic.code);
244
+ if (!result.ok)
245
+ throw new Error(`${manager}:${result.diagnostic.code}`);
192
246
  return "statically admitted; tests not executed";
193
247
  },
194
248
  "Check the runner, installed dependencies and execution configuration; this probe does not execute your tests.",
195
249
  );
196
250
  }
197
251
  }
198
- return { root, ok: checks.every((entry) => entry.ok), checks };
252
+ return { root, ...selection, ok: checks.every((entry) => entry.ok), checks };
199
253
  }
200
254
 
201
255
  /** Bounded metadata traversal, no symlink following and no automatic deletion. */