@abelxiaoxing/cadence 1.2.4 → 1.3.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 +40 -0
- package/package.json +2 -2
- package/src/design-control.ts +14 -4
- package/src/index.ts +23 -102
- package/src/openspec-cli.ts +494 -0
package/README.md
CHANGED
|
@@ -71,6 +71,46 @@ npm install -g @abelxiaoxing/cadence
|
|
|
71
71
|
|
|
72
72
|
本包要求 Node.js `>=22.13.0`,以使用稳定可用的内置 `node:sqlite`;不从参考仓库源安装,也不维护 Pi 主机版本兼容矩阵。
|
|
73
73
|
|
|
74
|
+
## OpenSpec 启动与平台范围
|
|
75
|
+
|
|
76
|
+
Design finalization 和 Implement delivery 加载共用无 shell 的 OpenSpec 适配层:读取已安装 `@fission-ai/openspec` 的 `package.json` 中 `bin` 声明,再以 Node 执行 JS 入口;不直接执行 Windows npm 的 `.cmd`,也不调用 `npx`、自动下载或切换 CLI 版本。
|
|
77
|
+
|
|
78
|
+
默认按宿主 `PATH` 定位 OpenSpec,支持 npm 全局默认/自定义 prefix,以及 Unix npm/Bun 的入口符号链接。
|
|
79
|
+
不搜索当前目录,不自动信任 consumer repository 内的 CLI;第一个已发现安装损坏时明确失败,不偷偷选用后续版本。
|
|
80
|
+
版本管理器的任意包装脚本、原生 exe 和 PowerShell 脚本不作为可执行回退。
|
|
81
|
+
|
|
82
|
+
特殊安装可在**启动宿主之前**配置以下环境变量(不属于 Worker 请求参数):
|
|
83
|
+
|
|
84
|
+
| 变量 | 含义 |
|
|
85
|
+
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `ABEL_OPENSPEC_PACKAGE_ROOT` | 已安装 `@fission-ai/openspec` 的绝对包目录,包含 `package.json`;不是 `.cmd` 或 JS 文件路径。显式配置代表操作者信任该安装。 |
|
|
87
|
+
| `ABEL_OPENSPEC_NODE` | Node 原生可执行文件的绝对路径,例如 `C:\Program Files\nodejs\node.exe`;默认复用 Node 宿主。非 Node 宿主必须显式配置。 |
|
|
88
|
+
|
|
89
|
+
配置无效不会退回 PATH。
|
|
90
|
+
Windows 环境变量名按不区分大小写读取。
|
|
91
|
+
更换全局安装或 PATH 后应重启宿主,确保它继承新环境。
|
|
92
|
+
|
|
93
|
+
两个 CLI 子进程各有 30 秒超时、每个输出流 4 MiB 上限,关闭 stdin、禁用 OpenSpec telemetry,并在返回检查结果前等待两者结束。
|
|
94
|
+
校验报告的 exit 1 与 `valid: false` 被识别为 strict-invalid;无法启动、协议异常、超时、取消、输出超限分别给出安全诊断。
|
|
95
|
+
诊断只暴露 command、phase、reason、允许列表中的 systemCode 和 exitCode,不返回原始 stderr、绝对路径或环境变量。
|
|
96
|
+
inspection 不可用时不再级联误报 traceability 输入缺失,且绝不生成成功的 ready 收据。
|
|
97
|
+
|
|
98
|
+
平台契约 CI 使用 Linux、Windows、macOS × Node 22.13.0/24.13.0,显式安装 OpenSpec 1.5.0,验证真实 npm 全局入口、严格校验、Design finalization、失败重试和 proof-bound delivery 加载。
|
|
99
|
+
状态协议要求布尔字段 `isComplete`,用于判断 1.5.0 的规划产物是否齐全;若另有 `isPlanningComplete`,该字段也必须为布尔值,且两者均为 `true` 才视为规划完成。
|
|
100
|
+
CLI 的其他版本必须满足相同 JSON 协议;新增支持版本应加入契约测试。
|
|
101
|
+
|
|
102
|
+
**此矩阵不代表原生 Implement 隔离已全平台可用。**
|
|
103
|
+
当前隔离后端仍为 Linux Bubblewrap;Windows/macOS 的原生隔离、ACL 与完整文件应用语义需要单独实现和验收,缺少隔离能力时保持暂停,不降级为主工作区直接执行。
|
|
104
|
+
|
|
105
|
+
本地可用 `CADENCE_REAL_OPENSPEC=1` 启用真实 CLI 测试(Windows 可用 PowerShell 设置 `$env:CADENCE_REAL_OPENSPEC = "1"`),然后运行:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
bun run test:target test/openspec-cli.test.ts test/design-delivery.integration.test.ts
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
未启用时,真实 CLI 用例明确跳过,其他适配层回归测试照常执行。
|
|
112
|
+
`bun run check` 的 JS 语法扫描使用 Node 遍历,不依赖 Unix `find`/`xargs`。
|
|
113
|
+
|
|
74
114
|
## Worker 路由(Worker routes)
|
|
75
115
|
|
|
76
116
|
路由配置是可选的:没有项目级或用户级文件时,三个包内 Agent 默认继承当前父模型,因此首次 Design、Implement 或 Diagnose 不需要预先配置 endpoint。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abelxiaoxing/cadence",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"pack:check": "node scripts/pack-check.mjs",
|
|
41
41
|
"traceability:check": "node scripts/traceability-check.mjs",
|
|
42
42
|
"verify": "bun run check && bun run lint && bun run test && bun run pack:check",
|
|
43
|
-
"check": "
|
|
43
|
+
"check": "node scripts/check-syntax.mjs && tsc --noEmit",
|
|
44
44
|
"lint": "biome check . && rumdl check .",
|
|
45
45
|
"test": "vitest run",
|
|
46
46
|
"test:target": "vitest run",
|
package/src/design-control.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
DesignJournal,
|
|
28
28
|
type DesignStatusProjection,
|
|
29
29
|
} from "./design-journal.ts";
|
|
30
|
+
import { OpenSpecCliError, type OpenSpecDiagnostic } from "./openspec-cli.ts";
|
|
30
31
|
import { RunStore } from "./run-store.ts";
|
|
31
32
|
import { observeSafePath } from "./safe-path.ts";
|
|
32
33
|
import type { ResolvedStateRoot } from "./state-root.ts";
|
|
@@ -131,11 +132,17 @@ export interface DesignControllerOptions {
|
|
|
131
132
|
export class DesignFinalizationError extends Error {
|
|
132
133
|
readonly diagnostics: readonly string[];
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
readonly openSpecDiagnostic?: Readonly<OpenSpecDiagnostic>;
|
|
136
|
+
|
|
137
|
+
constructor(
|
|
138
|
+
diagnostics: readonly string[],
|
|
139
|
+
openSpecDiagnostic?: Readonly<OpenSpecDiagnostic>,
|
|
140
|
+
) {
|
|
135
141
|
const normalized = [...new Set(diagnostics)].sort();
|
|
136
142
|
super("design-finalization-invalid");
|
|
137
143
|
this.name = "DesignFinalizationError";
|
|
138
144
|
this.diagnostics = Object.freeze(normalized);
|
|
145
|
+
this.openSpecDiagnostic = openSpecDiagnostic;
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
|
|
@@ -934,9 +941,12 @@ export class DesignController {
|
|
|
934
941
|
removeSafeFile(this.consumerRoot, readyPath);
|
|
935
942
|
const diagnostics = new Set<string>();
|
|
936
943
|
let inspection: DesignOpenSpecInspection | undefined;
|
|
944
|
+
let openSpecDiagnostic: Readonly<OpenSpecDiagnostic> | undefined;
|
|
937
945
|
try {
|
|
938
946
|
inspection = await this.#inspectOpenSpec(this.consumerRoot, change);
|
|
939
|
-
} catch {
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (error instanceof OpenSpecCliError)
|
|
949
|
+
openSpecDiagnostic = error.diagnostic;
|
|
940
950
|
diagnostics.add("design-openspec-unavailable");
|
|
941
951
|
}
|
|
942
952
|
if (inspection) {
|
|
@@ -1004,7 +1014,7 @@ export class DesignController {
|
|
|
1004
1014
|
let traceability:
|
|
1005
1015
|
| ReturnType<typeof assessDeliveryTraceability>
|
|
1006
1016
|
| undefined;
|
|
1007
|
-
if (compiled) {
|
|
1017
|
+
if (compiled && inspection) {
|
|
1008
1018
|
const tasks = artifactBytes.get("tasks.md");
|
|
1009
1019
|
const specs = [...artifactBytes]
|
|
1010
1020
|
.filter(
|
|
@@ -1035,7 +1045,7 @@ export class DesignController {
|
|
|
1035
1045
|
!traceability?.ok ||
|
|
1036
1046
|
diagnostics.size > 0
|
|
1037
1047
|
) {
|
|
1038
|
-
throw new DesignFinalizationError([...diagnostics]);
|
|
1048
|
+
throw new DesignFinalizationError([...diagnostics], openSpecDiagnostic);
|
|
1039
1049
|
}
|
|
1040
1050
|
const gateAReceipt = compileGateAReceipt({
|
|
1041
1051
|
change,
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// workflow routing (abel-design/implement/diagnose provenance) in the prompts
|
|
5
5
|
// integration; abel-init and ordinary prompts never activate dispatch.
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
8
7
|
import { createHash } from "node:crypto";
|
|
9
8
|
import {
|
|
10
9
|
lstatSync,
|
|
@@ -17,7 +16,6 @@ import {
|
|
|
17
16
|
import { homedir } from "node:os";
|
|
18
17
|
import path, { dirname, join } from "node:path";
|
|
19
18
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { promisify } from "node:util";
|
|
21
19
|
import type {
|
|
22
20
|
AgentToolResult,
|
|
23
21
|
AgentToolUpdateCallback,
|
|
@@ -59,6 +57,10 @@ import {
|
|
|
59
57
|
} from "./design-control.ts";
|
|
60
58
|
import { canonicalJson, hashCanonicalValue } from "./implement-graph.ts";
|
|
61
59
|
import { BubblewrapIsolationBackend } from "./isolation-backend.ts";
|
|
60
|
+
import {
|
|
61
|
+
inspectOpenSpecDelivery,
|
|
62
|
+
type OpenSpecDeliveryInspection,
|
|
63
|
+
} from "./openspec-cli.ts";
|
|
62
64
|
import { PACKET_ACTIONS, PacketRuntime } from "./packet-runtime.ts";
|
|
63
65
|
import { ParentPayloadBridge } from "./parent-payload-bridge.ts";
|
|
64
66
|
import { runtimeForWorkerRoute } from "./parent-provider.ts";
|
|
@@ -91,6 +93,11 @@ import {
|
|
|
91
93
|
type WorkflowDeliverySource,
|
|
92
94
|
} from "./workflow-engine.ts";
|
|
93
95
|
|
|
96
|
+
export {
|
|
97
|
+
inspectOpenSpecDelivery,
|
|
98
|
+
type OpenSpecDeliveryInspection,
|
|
99
|
+
} from "./openspec-cli.ts";
|
|
100
|
+
|
|
94
101
|
export const DISPATCH_TOOL = "abel_dispatch";
|
|
95
102
|
const DESIGN_PARENT_READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
96
103
|
|
|
@@ -589,7 +596,16 @@ function safeDesignFailure(
|
|
|
589
596
|
error instanceof DesignFinalizationError ||
|
|
590
597
|
error instanceof DesignPlanValidationError ||
|
|
591
598
|
error instanceof DeliveryValidationError
|
|
592
|
-
? error
|
|
599
|
+
? error instanceof DesignFinalizationError
|
|
600
|
+
? [
|
|
601
|
+
...error.diagnostics.filter(
|
|
602
|
+
(code) =>
|
|
603
|
+
!error.openSpecDiagnostic ||
|
|
604
|
+
code !== "design-openspec-unavailable",
|
|
605
|
+
),
|
|
606
|
+
...(error.openSpecDiagnostic ? [error.openSpecDiagnostic] : []),
|
|
607
|
+
]
|
|
608
|
+
: error.diagnostics
|
|
593
609
|
: [];
|
|
594
610
|
const diagnostics = rawDiagnostics
|
|
595
611
|
.flatMap((candidate) => {
|
|
@@ -625,6 +641,10 @@ function safeDesignFailure(
|
|
|
625
641
|
"dependencyTaskId",
|
|
626
642
|
"producerTaskId",
|
|
627
643
|
"producerPhase",
|
|
644
|
+
"command",
|
|
645
|
+
"reason",
|
|
646
|
+
"systemCode",
|
|
647
|
+
"exitCode",
|
|
628
648
|
].includes(entry[0]) &&
|
|
629
649
|
typeof entry[1] === "string" &&
|
|
630
650
|
SAFE_DESIGN_ERROR_CODE.test(entry[1])
|
|
@@ -681,15 +701,6 @@ export type WorkflowControlEngineFactory = (
|
|
|
681
701
|
) => WorkflowControlEngine | Promise<WorkflowControlEngine>;
|
|
682
702
|
|
|
683
703
|
const PACKAGE_DELIVERY_MAX_BYTES = 16 * 1024 * 1024;
|
|
684
|
-
const execFileAsync = promisify(execFile);
|
|
685
|
-
|
|
686
|
-
export interface OpenSpecDeliveryInspection {
|
|
687
|
-
change: string;
|
|
688
|
-
schema: string;
|
|
689
|
-
planningComplete: boolean;
|
|
690
|
-
strictValid: boolean;
|
|
691
|
-
artifactPaths: string[];
|
|
692
|
-
}
|
|
693
704
|
|
|
694
705
|
export interface PackageDeliverySourceOptions {
|
|
695
706
|
inspectOpenSpec?: (
|
|
@@ -783,96 +794,6 @@ function readPackageDeliveryFile(
|
|
|
783
794
|
return readFileSync(target);
|
|
784
795
|
}
|
|
785
796
|
|
|
786
|
-
function deliveryRecord(value: unknown): Record<string, unknown> | undefined {
|
|
787
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
788
|
-
? (value as Record<string, unknown>)
|
|
789
|
-
: undefined;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function parseCommandJson(
|
|
793
|
-
value: string,
|
|
794
|
-
code: string,
|
|
795
|
-
): Record<string, unknown> {
|
|
796
|
-
let parsed: unknown;
|
|
797
|
-
try {
|
|
798
|
-
parsed = JSON.parse(value);
|
|
799
|
-
} catch {
|
|
800
|
-
throw new Error(code);
|
|
801
|
-
}
|
|
802
|
-
const record = deliveryRecord(parsed);
|
|
803
|
-
if (!record) throw new Error(code);
|
|
804
|
-
return record;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
export async function inspectOpenSpecDelivery(
|
|
808
|
-
consumerRoot: string,
|
|
809
|
-
change: string,
|
|
810
|
-
): Promise<OpenSpecDeliveryInspection> {
|
|
811
|
-
const [statusExecution, validationExecution] = await Promise.all([
|
|
812
|
-
execFileAsync("openspec", ["status", "--change", change, "--json"], {
|
|
813
|
-
cwd: consumerRoot,
|
|
814
|
-
encoding: "utf8",
|
|
815
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
816
|
-
}),
|
|
817
|
-
execFileAsync(
|
|
818
|
-
"openspec",
|
|
819
|
-
["validate", change, "--strict", "--json", "--no-interactive"],
|
|
820
|
-
{
|
|
821
|
-
cwd: consumerRoot,
|
|
822
|
-
encoding: "utf8",
|
|
823
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
824
|
-
},
|
|
825
|
-
),
|
|
826
|
-
]);
|
|
827
|
-
const status = parseCommandJson(
|
|
828
|
-
statusExecution.stdout,
|
|
829
|
-
"delivery-openspec-status-invalid",
|
|
830
|
-
);
|
|
831
|
-
const validation = parseCommandJson(
|
|
832
|
-
validationExecution.stdout,
|
|
833
|
-
"delivery-openspec-validation-invalid",
|
|
834
|
-
);
|
|
835
|
-
const changeRoot = path.resolve(consumerRoot, "openspec", "changes", change);
|
|
836
|
-
const artifactPathsRecord = deliveryRecord(status.artifactPaths);
|
|
837
|
-
const artifactPaths = artifactPathsRecord
|
|
838
|
-
? Object.values(artifactPathsRecord).flatMap((entry) => {
|
|
839
|
-
const record = deliveryRecord(entry);
|
|
840
|
-
if (!record || !Array.isArray(record.existingOutputPaths)) return [];
|
|
841
|
-
return record.existingOutputPaths.flatMap((candidate) => {
|
|
842
|
-
if (typeof candidate !== "string") return [];
|
|
843
|
-
const resolved = path.resolve(candidate);
|
|
844
|
-
const relative = path.relative(changeRoot, resolved);
|
|
845
|
-
return relative &&
|
|
846
|
-
!relative.startsWith(`..${path.sep}`) &&
|
|
847
|
-
relative !== ".." &&
|
|
848
|
-
!path.isAbsolute(relative)
|
|
849
|
-
? [relative.split(path.sep).join("/")]
|
|
850
|
-
: [];
|
|
851
|
-
});
|
|
852
|
-
})
|
|
853
|
-
: [];
|
|
854
|
-
const items = Array.isArray(validation.items) ? validation.items : [];
|
|
855
|
-
const strictValid =
|
|
856
|
-
items.length === 1 &&
|
|
857
|
-
deliveryRecord(items[0])?.id === change &&
|
|
858
|
-
deliveryRecord(items[0])?.valid === true;
|
|
859
|
-
if (
|
|
860
|
-
status.changeName !== change ||
|
|
861
|
-
typeof status.schemaName !== "string" ||
|
|
862
|
-
artifactPaths.length === 0
|
|
863
|
-
) {
|
|
864
|
-
throw new Error("delivery-openspec-status-invalid");
|
|
865
|
-
}
|
|
866
|
-
return {
|
|
867
|
-
change,
|
|
868
|
-
schema: status.schemaName,
|
|
869
|
-
planningComplete:
|
|
870
|
-
status.isPlanningComplete === true && status.isComplete === true,
|
|
871
|
-
strictValid,
|
|
872
|
-
artifactPaths: [...new Set(artifactPaths)].sort(),
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
|
|
876
797
|
export function packageDeliverySource(
|
|
877
798
|
consumerRoot: string,
|
|
878
799
|
options: PackageDeliverySourceOptions = {},
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const PACKAGE_NAME = "@fission-ai/openspec";
|
|
6
|
+
export const OPENSPEC_LIMITS = Object.freeze({
|
|
7
|
+
timeoutMs: 30_000,
|
|
8
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
type Command = "resolve" | "status" | "validate";
|
|
12
|
+
type Phase = "resolution" | "spawn" | "execution" | "protocol";
|
|
13
|
+
type Reason =
|
|
14
|
+
| "installation-not-found"
|
|
15
|
+
| "installation-invalid"
|
|
16
|
+
| "runtime-unavailable"
|
|
17
|
+
| "cwd-unavailable"
|
|
18
|
+
| "terminated"
|
|
19
|
+
| "unsafe-installation"
|
|
20
|
+
| "launch-failed"
|
|
21
|
+
| "timeout"
|
|
22
|
+
| "cancelled"
|
|
23
|
+
| "output-limit"
|
|
24
|
+
| "exit-nonzero"
|
|
25
|
+
| "json-invalid"
|
|
26
|
+
| "schema-invalid"
|
|
27
|
+
| "change-invalid";
|
|
28
|
+
|
|
29
|
+
/** Only code-owned, non-secret fields may cross the Design failure boundary. */
|
|
30
|
+
export interface OpenSpecDiagnostic {
|
|
31
|
+
code: "design-openspec-unavailable";
|
|
32
|
+
command: Command;
|
|
33
|
+
phase: Phase;
|
|
34
|
+
reason: Reason;
|
|
35
|
+
systemCode?: string;
|
|
36
|
+
exitCode?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class OpenSpecCliError extends Error {
|
|
40
|
+
readonly diagnostic: Readonly<OpenSpecDiagnostic>;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
command: Command,
|
|
44
|
+
phase: Phase,
|
|
45
|
+
reason: Reason,
|
|
46
|
+
detail: { systemCode?: string; exitCode?: number } = {},
|
|
47
|
+
) {
|
|
48
|
+
super("design-openspec-unavailable");
|
|
49
|
+
this.name = "OpenSpecCliError";
|
|
50
|
+
this.diagnostic = Object.freeze({
|
|
51
|
+
code: "design-openspec-unavailable",
|
|
52
|
+
command,
|
|
53
|
+
phase,
|
|
54
|
+
reason,
|
|
55
|
+
...(["ENOENT", "EINVAL", "EACCES", "EPERM", "ENOEXEC", "E2BIG"].includes(
|
|
56
|
+
detail.systemCode ?? "",
|
|
57
|
+
)
|
|
58
|
+
? { systemCode: detail.systemCode }
|
|
59
|
+
: {}),
|
|
60
|
+
...(Number.isSafeInteger(detail.exitCode) && (detail.exitCode ?? -1) >= 0
|
|
61
|
+
? { exitCode: String(detail.exitCode) }
|
|
62
|
+
: {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OpenSpecCliOptions {
|
|
68
|
+
/** Trusted operator configuration, never a Worker-supplied command string. */
|
|
69
|
+
environment?: NodeJS.ProcessEnv;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
maxBuffer?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OpenSpecInvocation {
|
|
76
|
+
executable: string;
|
|
77
|
+
entry: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
81
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
82
|
+
? (value as Record<string, unknown>)
|
|
83
|
+
: undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function inside(root: string, target: string): boolean {
|
|
87
|
+
const relative = path.relative(root, target);
|
|
88
|
+
return (
|
|
89
|
+
relative === "" ||
|
|
90
|
+
(relative !== ".." &&
|
|
91
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
92
|
+
!path.isAbsolute(relative))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function envValue(
|
|
97
|
+
environment: NodeJS.ProcessEnv,
|
|
98
|
+
name: string,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
// Match Node's lexicographic, case-insensitive environment selection on Windows.
|
|
101
|
+
const key = Object.keys(environment)
|
|
102
|
+
.sort()
|
|
103
|
+
.find((candidate) =>
|
|
104
|
+
process.platform === "win32"
|
|
105
|
+
? candidate.toUpperCase() === name.toUpperCase()
|
|
106
|
+
: candidate === name,
|
|
107
|
+
);
|
|
108
|
+
return key === undefined ? undefined : environment[key];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function packageEntry(root: string): string {
|
|
112
|
+
const canonicalRoot = realpathSync(root);
|
|
113
|
+
const manifestPath = realpathSync(path.join(canonicalRoot, "package.json"));
|
|
114
|
+
const manifestStat = lstatSync(manifestPath);
|
|
115
|
+
if (
|
|
116
|
+
!inside(canonicalRoot, manifestPath) ||
|
|
117
|
+
!manifestStat.isFile() ||
|
|
118
|
+
manifestStat.size > 1024 * 1024
|
|
119
|
+
)
|
|
120
|
+
throw new Error("manifest-limit");
|
|
121
|
+
const manifest = record(JSON.parse(readFileSync(manifestPath, "utf8")));
|
|
122
|
+
const bin =
|
|
123
|
+
typeof manifest?.bin === "string"
|
|
124
|
+
? manifest.bin
|
|
125
|
+
: record(manifest?.bin)?.openspec;
|
|
126
|
+
if (
|
|
127
|
+
manifest?.name !== PACKAGE_NAME ||
|
|
128
|
+
typeof bin !== "string" ||
|
|
129
|
+
path.isAbsolute(bin)
|
|
130
|
+
)
|
|
131
|
+
throw new Error("manifest-invalid");
|
|
132
|
+
const entry = realpathSync(path.resolve(canonicalRoot, bin));
|
|
133
|
+
if (
|
|
134
|
+
!inside(canonicalRoot, entry) ||
|
|
135
|
+
!/\.(?:cjs|mjs|js)$/iu.test(entry) ||
|
|
136
|
+
!lstatSync(entry).isFile()
|
|
137
|
+
)
|
|
138
|
+
throw new Error("entry-invalid");
|
|
139
|
+
return entry;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function canonicalConsumerRoot(consumerRoot: string): string {
|
|
143
|
+
try {
|
|
144
|
+
const root = realpathSync(consumerRoot);
|
|
145
|
+
if (!lstatSync(root).isDirectory()) throw new Error("cwd-invalid");
|
|
146
|
+
return root;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new OpenSpecCliError("resolve", "resolution", "cwd-unavailable", {
|
|
149
|
+
systemCode: (error as NodeJS.ErrnoException).code,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function commandIn(directory: string): string | undefined {
|
|
155
|
+
const names =
|
|
156
|
+
process.platform === "win32"
|
|
157
|
+
? ["openspec.exe", "openspec.cmd", "openspec"]
|
|
158
|
+
: ["openspec"];
|
|
159
|
+
// Windows filesystems are usually case-insensitive; reject ambiguous names.
|
|
160
|
+
const entries = process.platform === "win32" ? readdirSync(directory) : names;
|
|
161
|
+
for (const name of names) {
|
|
162
|
+
const matches = entries.filter((entry) => entry.toLowerCase() === name);
|
|
163
|
+
if (matches.length > 1) throw new Error("ambiguous-command");
|
|
164
|
+
if (matches.length === 1) {
|
|
165
|
+
const candidate = path.join(directory, matches[0]);
|
|
166
|
+
if (lstatSync(candidate, { throwIfNoEntry: false })) return candidate;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolve once per inspection. Never execute or parse a shell shim. */
|
|
173
|
+
export function resolveOpenSpecInvocation(
|
|
174
|
+
consumerRoot: string,
|
|
175
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
176
|
+
): OpenSpecInvocation {
|
|
177
|
+
const configuredRoot = envValue(environment, "ABEL_OPENSPEC_PACKAGE_ROOT");
|
|
178
|
+
const configuredNode = envValue(environment, "ABEL_OPENSPEC_NODE");
|
|
179
|
+
const node =
|
|
180
|
+
configuredNode ??
|
|
181
|
+
(process.release.name === "node" && !process.versions.bun
|
|
182
|
+
? process.execPath
|
|
183
|
+
: undefined);
|
|
184
|
+
let executable: string;
|
|
185
|
+
try {
|
|
186
|
+
if (!node || !path.isAbsolute(node) || /\.(?:cmd|bat|ps1)$/iu.test(node)) {
|
|
187
|
+
throw new Error("runtime-invalid");
|
|
188
|
+
}
|
|
189
|
+
executable = realpathSync(node);
|
|
190
|
+
if (!lstatSync(executable).isFile()) throw new Error("runtime-invalid");
|
|
191
|
+
} catch {
|
|
192
|
+
throw new OpenSpecCliError("resolve", "resolution", "runtime-unavailable");
|
|
193
|
+
}
|
|
194
|
+
if (configuredRoot !== undefined) {
|
|
195
|
+
try {
|
|
196
|
+
if (!path.isAbsolute(configuredRoot)) throw new Error("root-invalid");
|
|
197
|
+
return { executable, entry: packageEntry(configuredRoot) };
|
|
198
|
+
} catch {
|
|
199
|
+
throw new OpenSpecCliError(
|
|
200
|
+
"resolve",
|
|
201
|
+
"resolution",
|
|
202
|
+
"installation-invalid",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const consumer = canonicalConsumerRoot(consumerRoot);
|
|
208
|
+
const directories = (envValue(environment, "PATH") ?? "").split(
|
|
209
|
+
path.delimiter,
|
|
210
|
+
);
|
|
211
|
+
for (const directory of directories) {
|
|
212
|
+
// Do not introduce implicit cwd search or automatically trust repository CLIs.
|
|
213
|
+
if (!path.isAbsolute(directory)) continue;
|
|
214
|
+
let command: string | undefined;
|
|
215
|
+
let canonicalDirectory: string;
|
|
216
|
+
try {
|
|
217
|
+
canonicalDirectory = realpathSync(directory);
|
|
218
|
+
if (inside(consumer, canonicalDirectory)) continue;
|
|
219
|
+
command = commandIn(canonicalDirectory);
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!command) continue;
|
|
224
|
+
try {
|
|
225
|
+
const target = realpathSync(command);
|
|
226
|
+
if (inside(consumer, target)) {
|
|
227
|
+
throw new OpenSpecCliError(
|
|
228
|
+
"resolve",
|
|
229
|
+
"resolution",
|
|
230
|
+
"unsafe-installation",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
// npm Windows/custom prefix and Unix global layouts. Symlinked npm/Bun
|
|
234
|
+
// entries are also discovered by walking their real target's ancestors.
|
|
235
|
+
const layoutRoots = [
|
|
236
|
+
path.join(canonicalDirectory, "node_modules", PACKAGE_NAME),
|
|
237
|
+
path.resolve(canonicalDirectory, "../lib/node_modules", PACKAGE_NAME),
|
|
238
|
+
];
|
|
239
|
+
const roots: string[] = [];
|
|
240
|
+
let ancestor = path.dirname(target);
|
|
241
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
242
|
+
roots.push(ancestor);
|
|
243
|
+
const parent = path.dirname(ancestor);
|
|
244
|
+
if (parent === ancestor) break;
|
|
245
|
+
ancestor = parent;
|
|
246
|
+
}
|
|
247
|
+
// A real symlink target identifies the selected version more precisely
|
|
248
|
+
// than an adjacent global installation.
|
|
249
|
+
const linked = lstatSync(command).isSymbolicLink();
|
|
250
|
+
const candidates = linked ? roots : layoutRoots;
|
|
251
|
+
for (const root of candidates) {
|
|
252
|
+
let entry: string;
|
|
253
|
+
try {
|
|
254
|
+
entry = packageEntry(root);
|
|
255
|
+
if (linked && entry !== target) continue;
|
|
256
|
+
} catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (inside(consumer, entry)) {
|
|
260
|
+
throw new OpenSpecCliError(
|
|
261
|
+
"resolve",
|
|
262
|
+
"resolution",
|
|
263
|
+
"unsafe-installation",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return { executable, entry };
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error instanceof OpenSpecCliError) throw error;
|
|
270
|
+
}
|
|
271
|
+
// A broken/shadowing installation must not silently select another version.
|
|
272
|
+
throw new OpenSpecCliError("resolve", "resolution", "installation-invalid");
|
|
273
|
+
}
|
|
274
|
+
throw new OpenSpecCliError("resolve", "resolution", "installation-not-found");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface CommandResult {
|
|
278
|
+
stdout: string;
|
|
279
|
+
exitCode: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function execute(
|
|
283
|
+
invocation: OpenSpecInvocation,
|
|
284
|
+
command: "status" | "validate",
|
|
285
|
+
args: string[],
|
|
286
|
+
consumerRoot: string,
|
|
287
|
+
options: OpenSpecCliOptions,
|
|
288
|
+
): Promise<CommandResult> {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
let child: ReturnType<typeof execFile> | undefined;
|
|
291
|
+
const abort = () => child?.kill("SIGKILL");
|
|
292
|
+
const failLaunch = (error: unknown) => {
|
|
293
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
294
|
+
reject(
|
|
295
|
+
new OpenSpecCliError(command, "spawn", "launch-failed", {
|
|
296
|
+
systemCode: typeof code === "string" ? code : undefined,
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
child = execFile(
|
|
302
|
+
invocation.executable,
|
|
303
|
+
[invocation.entry, ...args],
|
|
304
|
+
{
|
|
305
|
+
cwd: consumerRoot,
|
|
306
|
+
env: {
|
|
307
|
+
...(options.environment ?? process.env),
|
|
308
|
+
OPENSPEC_TELEMETRY: "0",
|
|
309
|
+
},
|
|
310
|
+
encoding: "utf8",
|
|
311
|
+
shell: false,
|
|
312
|
+
windowsHide: true,
|
|
313
|
+
timeout: options.timeoutMs ?? OPENSPEC_LIMITS.timeoutMs,
|
|
314
|
+
maxBuffer: options.maxBuffer ?? OPENSPEC_LIMITS.maxBuffer,
|
|
315
|
+
// This adapter launches a single Node CLI, not a shell/process tree.
|
|
316
|
+
killSignal: "SIGKILL",
|
|
317
|
+
},
|
|
318
|
+
(error, stdout) => {
|
|
319
|
+
options.signal?.removeEventListener("abort", abort);
|
|
320
|
+
if (options.signal?.aborted) {
|
|
321
|
+
return reject(
|
|
322
|
+
new OpenSpecCliError(command, "execution", "cancelled"),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (!error) return resolve({ stdout, exitCode: 0 });
|
|
326
|
+
const code = error.code;
|
|
327
|
+
if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
328
|
+
return reject(
|
|
329
|
+
new OpenSpecCliError(command, "execution", "output-limit"),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (error.killed) {
|
|
333
|
+
return reject(
|
|
334
|
+
new OpenSpecCliError(command, "execution", "timeout"),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (error.signal) {
|
|
338
|
+
return reject(
|
|
339
|
+
new OpenSpecCliError(command, "execution", "terminated"),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (typeof code === "number")
|
|
343
|
+
return resolve({ stdout, exitCode: code });
|
|
344
|
+
reject(
|
|
345
|
+
new OpenSpecCliError(command, "spawn", "launch-failed", {
|
|
346
|
+
systemCode: typeof code === "string" ? code : undefined,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
child.stdin?.end();
|
|
352
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
353
|
+
if (options.signal?.aborted) abort();
|
|
354
|
+
} catch (error) {
|
|
355
|
+
options.signal?.removeEventListener("abort", abort);
|
|
356
|
+
failLaunch(error);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseJson(result: CommandResult, command: "status" | "validate") {
|
|
362
|
+
if (
|
|
363
|
+
result.exitCode !== 0 &&
|
|
364
|
+
!(command === "validate" && result.exitCode === 1)
|
|
365
|
+
) {
|
|
366
|
+
throw new OpenSpecCliError(command, "execution", "exit-nonzero", result);
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const parsed = record(JSON.parse(result.stdout));
|
|
370
|
+
if (!parsed) throw new Error("not-object");
|
|
371
|
+
return parsed;
|
|
372
|
+
} catch {
|
|
373
|
+
throw new OpenSpecCliError(command, "protocol", "json-invalid", result);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface OpenSpecDeliveryInspection {
|
|
378
|
+
change: string;
|
|
379
|
+
schema: string;
|
|
380
|
+
planningComplete: boolean;
|
|
381
|
+
strictValid: boolean;
|
|
382
|
+
artifactPaths: string[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function inspectOpenSpecDelivery(
|
|
386
|
+
consumerRoot: string,
|
|
387
|
+
change: string,
|
|
388
|
+
options: OpenSpecCliOptions = {},
|
|
389
|
+
): Promise<OpenSpecDeliveryInspection> {
|
|
390
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u.test(change)) {
|
|
391
|
+
throw new OpenSpecCliError("resolve", "resolution", "change-invalid");
|
|
392
|
+
}
|
|
393
|
+
for (const limit of [options.timeoutMs, options.maxBuffer]) {
|
|
394
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
|
|
395
|
+
throw new Error("openspec-limit-invalid");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (options.signal?.aborted) {
|
|
399
|
+
throw new OpenSpecCliError("resolve", "execution", "cancelled");
|
|
400
|
+
}
|
|
401
|
+
consumerRoot = canonicalConsumerRoot(consumerRoot);
|
|
402
|
+
options = {
|
|
403
|
+
...options,
|
|
404
|
+
environment: { ...(options.environment ?? process.env) },
|
|
405
|
+
};
|
|
406
|
+
const invocation = resolveOpenSpecInvocation(
|
|
407
|
+
consumerRoot,
|
|
408
|
+
options.environment,
|
|
409
|
+
);
|
|
410
|
+
// allSettled ensures neither child outlives a failed inspection. Both have
|
|
411
|
+
// bounded time/output; deterministic status-first diagnostics avoid races.
|
|
412
|
+
const results = await Promise.allSettled([
|
|
413
|
+
execute(
|
|
414
|
+
invocation,
|
|
415
|
+
"status",
|
|
416
|
+
["status", "--change", change, "--json"],
|
|
417
|
+
consumerRoot,
|
|
418
|
+
options,
|
|
419
|
+
),
|
|
420
|
+
execute(
|
|
421
|
+
invocation,
|
|
422
|
+
"validate",
|
|
423
|
+
["validate", change, "--strict", "--json", "--no-interactive"],
|
|
424
|
+
consumerRoot,
|
|
425
|
+
options,
|
|
426
|
+
),
|
|
427
|
+
]);
|
|
428
|
+
const executions = results.map((result) => {
|
|
429
|
+
if (result.status === "rejected") throw result.reason;
|
|
430
|
+
return result.value;
|
|
431
|
+
});
|
|
432
|
+
const status = parseJson(executions[0], "status");
|
|
433
|
+
const validation = parseJson(executions[1], "validate");
|
|
434
|
+
const changeRoot = path.resolve(consumerRoot, "openspec", "changes", change);
|
|
435
|
+
const paths = record(status.artifactPaths);
|
|
436
|
+
const artifactPaths: string[] = [];
|
|
437
|
+
let invalidPaths = !paths;
|
|
438
|
+
for (const entry of Object.values(paths ?? {})) {
|
|
439
|
+
const outputs = record(entry)?.existingOutputPaths;
|
|
440
|
+
if (!Array.isArray(outputs)) {
|
|
441
|
+
invalidPaths = true;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
for (const candidate of outputs) {
|
|
445
|
+
if (typeof candidate !== "string" || candidate.includes("\0")) {
|
|
446
|
+
invalidPaths = true;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
// Relative CLI output is relative to its cwd, not the extension process.
|
|
450
|
+
const resolved = path.resolve(consumerRoot, candidate);
|
|
451
|
+
const relative = path.relative(changeRoot, resolved);
|
|
452
|
+
if (!relative || !inside(changeRoot, resolved)) {
|
|
453
|
+
invalidPaths = true;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
artifactPaths.push(relative.split(path.sep).join("/"));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (
|
|
460
|
+
status.changeName !== change ||
|
|
461
|
+
typeof status.schemaName !== "string" ||
|
|
462
|
+
!status.schemaName ||
|
|
463
|
+
("isPlanningComplete" in status &&
|
|
464
|
+
typeof status.isPlanningComplete !== "boolean") ||
|
|
465
|
+
typeof status.isComplete !== "boolean" ||
|
|
466
|
+
invalidPaths ||
|
|
467
|
+
artifactPaths.length === 0
|
|
468
|
+
)
|
|
469
|
+
throw new OpenSpecCliError("status", "protocol", "schema-invalid");
|
|
470
|
+
const items = Array.isArray(validation.items) ? validation.items : [];
|
|
471
|
+
const item = record(items[0]);
|
|
472
|
+
if (
|
|
473
|
+
items.length !== 1 ||
|
|
474
|
+
item?.id !== change ||
|
|
475
|
+
typeof item.valid !== "boolean" ||
|
|
476
|
+
(executions[1].exitCode !== 0 && item.valid)
|
|
477
|
+
) {
|
|
478
|
+
throw new OpenSpecCliError(
|
|
479
|
+
"validate",
|
|
480
|
+
"protocol",
|
|
481
|
+
"schema-invalid",
|
|
482
|
+
executions[1],
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
change,
|
|
487
|
+
schema: status.schemaName,
|
|
488
|
+
// OpenSpec 1.5.0 reports artifact completion only as isComplete.
|
|
489
|
+
// When a separate planning flag is present, both flags must be true.
|
|
490
|
+
planningComplete: status.isComplete && status.isPlanningComplete !== false,
|
|
491
|
+
strictValid: item.valid,
|
|
492
|
+
artifactPaths: [...new Set(artifactPaths)].sort(),
|
|
493
|
+
};
|
|
494
|
+
}
|