@peterxiaoyang/superspec 0.1.2 → 0.1.4

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.
@@ -0,0 +1,230 @@
1
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { PACKAGE_ROOT } from "./install_engine.js";
4
+ import { openspec_cli_probe } from "./openspec.js";
5
+ import { commandExists, runCommand } from "./util.js";
6
+ function readPackageJson(packageRoot) {
7
+ try {
8
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
9
+ return parsed && typeof parsed === "object" ? parsed : {};
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ export function superspec_package_version(packageRoot = PACKAGE_ROOT) {
16
+ const version = readPackageJson(packageRoot).version;
17
+ return typeof version === "string" && version ? version : "0.0.0";
18
+ }
19
+ function realpathMaybe(filePath) {
20
+ try {
21
+ return realpathSync(filePath);
22
+ }
23
+ catch {
24
+ return resolve(filePath);
25
+ }
26
+ }
27
+ function pathInfo(filePath) {
28
+ if (!filePath)
29
+ return null;
30
+ const info = { path: filePath, exists: existsSync(filePath) };
31
+ if (!info.exists)
32
+ return info;
33
+ try {
34
+ const stat = lstatSync(filePath);
35
+ info.kind = stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other";
36
+ if (stat.isSymbolicLink())
37
+ info.link_target = readlinkSync(filePath);
38
+ info.realpath = realpathMaybe(filePath);
39
+ }
40
+ catch (err) {
41
+ info.error = err.message;
42
+ }
43
+ return info;
44
+ }
45
+ function firstNonEmptyLine(text) {
46
+ return text.split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0) ?? null;
47
+ }
48
+ function commandPath(cmd, opts) {
49
+ if (!opts.commandExistsFn(cmd, { cwd: opts.cwd }))
50
+ return null;
51
+ const proc = opts.platform === "win32"
52
+ ? opts.run("where.exe", [cmd], { cwd: opts.cwd, timeout: 15_000, platform: opts.platform })
53
+ : opts.run("sh", ["-c", `command -v ${cmd}`], { cwd: opts.cwd, timeout: 15_000, platform: opts.platform });
54
+ if (proc.error || proc.status !== 0)
55
+ return null;
56
+ return firstNonEmptyLine(proc.stdout);
57
+ }
58
+ function npmValue(args, opts) {
59
+ if (!opts.commandExistsFn("npm", { cwd: opts.cwd }))
60
+ return { available: false, value: null, error: "npm not found on PATH" };
61
+ const proc = opts.run("npm", args, { cwd: opts.cwd, timeout: 15_000, platform: opts.platform });
62
+ if (proc.error || proc.status !== 0) {
63
+ return {
64
+ available: true,
65
+ value: null,
66
+ error: (proc.error?.message ?? (proc.stderr || proc.stdout)).trim(),
67
+ };
68
+ }
69
+ return { available: true, value: firstNonEmptyLine(proc.stdout), error: null };
70
+ }
71
+ function containsUnscopedSuperspecNodeModule(value) {
72
+ if (!value)
73
+ return false;
74
+ return /[/\\]node_modules[/\\]superspec[/\\]/u.test(value);
75
+ }
76
+ function add(checks, status, name, detail, refs) {
77
+ checks.push({ status, name, detail, refs });
78
+ }
79
+ export function build_doctor_report(opts = {}) {
80
+ const cwd = resolve(opts.cwd ?? process.cwd());
81
+ const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
82
+ const platform = opts.platform ?? process.platform;
83
+ const run = opts.run ?? runCommand;
84
+ const commandExistsFn = opts.commandExistsFn ?? ((cmd, meta) => commandExists(cmd, { cwd: meta?.cwd, platform }));
85
+ const packageJson = readPackageJson(packageRoot);
86
+ const version = superspec_package_version(packageRoot);
87
+ const packageName = typeof packageJson.name === "string" ? packageJson.name : "unknown";
88
+ const expectedBin = join(packageRoot, "bin", "superspec.js");
89
+ const resolvedCommand = commandPath("superspec", { cwd, platform, commandExistsFn, run });
90
+ const command = pathInfo(resolvedCommand);
91
+ const prefix = npmValue(["prefix", "-g"], { cwd, platform, commandExistsFn, run });
92
+ const root = npmValue(["root", "-g"], { cwd, platform, commandExistsFn, run });
93
+ const npmRoot = typeof root.value === "string" ? root.value : null;
94
+ const scopedPackage = npmRoot ? pathInfo(join(npmRoot, "@peterxiaoyang", "superspec")) : null;
95
+ const unscopedPackage = npmRoot ? pathInfo(join(npmRoot, "superspec")) : null;
96
+ const openspecProbe = openspec_cli_probe({ cwd, commandExistsFn, run });
97
+ const checks = [];
98
+ const nextActions = [];
99
+ if (packageName === "@peterxiaoyang/superspec" && version !== "0.0.0") {
100
+ add(checks, "ok", "package metadata", `${packageName}@${version}`);
101
+ }
102
+ else {
103
+ add(checks, "fail", "package metadata", `could not identify @peterxiaoyang/superspec package metadata under ${packageRoot}`);
104
+ }
105
+ if (openspecProbe.ok) {
106
+ add(checks, "ok", "OpenSpec CLI", openspecProbe.message);
107
+ }
108
+ else {
109
+ add(checks, "fail", "OpenSpec CLI", openspecProbe.message);
110
+ nextActions.push("npm install -g @fission-ai/openspec@latest");
111
+ }
112
+ if (!command) {
113
+ add(checks, "warn", "superspec command", "`superspec` is not resolvable from PATH in this shell");
114
+ }
115
+ else {
116
+ const commandRealpath = typeof command.realpath === "string" ? command.realpath : undefined;
117
+ const commandTarget = typeof command.link_target === "string" ? command.link_target : undefined;
118
+ if (containsUnscopedSuperspecNodeModule(commandRealpath) || containsUnscopedSuperspecNodeModule(commandTarget)) {
119
+ add(checks, "fail", "superspec command owner", "`superspec` points at the unscoped `superspec` package, which conflicts with @peterxiaoyang/superspec", [String(command.path)]);
120
+ nextActions.push("npm uninstall -g superspec");
121
+ nextActions.push("npm install -g @peterxiaoyang/superspec@latest");
122
+ }
123
+ else if (existsSync(expectedBin) && commandRealpath && realpathMaybe(expectedBin) !== commandRealpath) {
124
+ add(checks, "warn", "superspec command owner", "`superspec` on PATH does not point at this package root", [String(command.path), expectedBin]);
125
+ }
126
+ else {
127
+ add(checks, "ok", "superspec command owner", "`superspec` resolves to this package");
128
+ }
129
+ }
130
+ if (root.error) {
131
+ add(checks, "warn", "npm global root", String(root.error));
132
+ }
133
+ else if (npmRoot) {
134
+ add(checks, "ok", "npm global root", npmRoot);
135
+ }
136
+ if (unscopedPackage?.exists) {
137
+ add(checks, "warn", "unscoped superspec package", "global package `superspec` is installed and can claim the same `superspec` binary", [String(unscopedPackage.path)]);
138
+ nextActions.push("npm uninstall -g superspec");
139
+ }
140
+ if (scopedPackage && !scopedPackage.exists) {
141
+ add(checks, "warn", "scoped superspec package", "@peterxiaoyang/superspec is not present under npm global root", [String(scopedPackage.path)]);
142
+ }
143
+ const dedupedNextActions = [...new Set(nextActions)];
144
+ return {
145
+ ok: !checks.some((check) => check.status === "fail"),
146
+ cwd,
147
+ superspec: {
148
+ name: packageName,
149
+ version,
150
+ package_root: packageRoot,
151
+ expected_bin: expectedBin,
152
+ entry: opts.argv0 ?? process.argv[1] ?? null,
153
+ command,
154
+ scoped_global_package: scopedPackage,
155
+ unscoped_global_package: unscopedPackage,
156
+ },
157
+ node: {
158
+ version: process.versions.node,
159
+ exec_path: process.execPath,
160
+ platform,
161
+ },
162
+ npm: {
163
+ prefix,
164
+ root,
165
+ },
166
+ openspec: {
167
+ ok: openspecProbe.ok,
168
+ state: openspecProbe.state,
169
+ version: openspecProbe.version,
170
+ message: openspecProbe.message,
171
+ },
172
+ checks,
173
+ next_actions: dedupedNextActions,
174
+ };
175
+ }
176
+ export function render_doctor_report(report) {
177
+ const lines = [
178
+ "SuperSpec doctor",
179
+ "",
180
+ `SuperSpec: ${report.superspec.name}@${report.superspec.version}`,
181
+ `Package root: ${report.superspec.package_root}`,
182
+ `Command path: ${report.superspec.command?.path ?? "not found"}`,
183
+ `OpenSpec: ${report.openspec.message}`,
184
+ `Node: ${report.node.version} (${report.node.platform})`,
185
+ `npm prefix: ${report.npm.prefix.value ?? "unknown"}`,
186
+ `npm root: ${report.npm.root.value ?? "unknown"}`,
187
+ "",
188
+ "Checks:",
189
+ ...report.checks.map((check) => {
190
+ const refs = check.refs && check.refs.length > 0 ? ` (${check.refs.join(", ")})` : "";
191
+ return ` [${check.status}] ${check.name}: ${check.detail}${refs}`;
192
+ }),
193
+ ];
194
+ if (report.next_actions.length > 0) {
195
+ lines.push("", "Next actions:", ...report.next_actions.map((action) => ` ${action}`));
196
+ }
197
+ lines.push("");
198
+ return lines.join("\n");
199
+ }
200
+ function doctorHelp() {
201
+ return [
202
+ "usage: superspec doctor [--json]",
203
+ "",
204
+ "diagnoses SuperSpec, OpenSpec, npm global package, and PATH wiring.",
205
+ "",
206
+ "options:",
207
+ " --json print machine-readable JSON",
208
+ " -h, --help show this help",
209
+ "",
210
+ ].join("\n");
211
+ }
212
+ export function main_doctor(argv = process.argv.slice(2)) {
213
+ if (argv.includes("-h") || argv.includes("--help")) {
214
+ process.stdout.write(doctorHelp());
215
+ return 0;
216
+ }
217
+ const unknown = argv.filter((arg) => arg !== "--json");
218
+ if (unknown.length > 0) {
219
+ process.stderr.write(`superspec doctor: unknown option ${JSON.stringify(unknown[0])}\n\n${doctorHelp()}`);
220
+ return 2;
221
+ }
222
+ const report = build_doctor_report();
223
+ if (argv.includes("--json")) {
224
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
225
+ }
226
+ else {
227
+ process.stdout.write(render_doctor_report(report));
228
+ }
229
+ return report.ok ? 0 : 1;
230
+ }
@@ -26,3 +26,4 @@ export declare function live_pass(evidences: JsonMap[], filters?: {
26
26
  kind?: string | null;
27
27
  task_id?: string | null;
28
28
  }): JsonMap[];
29
+ export declare function live_user_confirmations(evidences: JsonMap[], gate: string): JsonMap[];
@@ -272,6 +272,9 @@ function finding_string_list_reasons(ev, finding, field, opts = {}) {
272
272
  function human_confirmation_reasons(ev) {
273
273
  const problems = [];
274
274
  const gate = normalize_gate(String(ev.gate ?? ""));
275
+ if (String(ev.created_by ?? "") !== "user") {
276
+ problems.push(reason("human_confirmation_invalid", `${ev._path}: human_confirmation must be created_by user`));
277
+ }
275
278
  if (!HUMAN_CONFIRMATION_GATES.has(gate)) {
276
279
  problems.push(reason("human_confirmation_invalid", `${ev._path}: human_confirmation gate=${repr(ev.gate)} is not consumed by any guard gate; expected one of ${renderList([...HUMAN_CONFIRMATION_GATES].sort())}`));
277
280
  }
@@ -847,3 +850,7 @@ export function live_pass(evidences, filters = {}) {
847
850
  const dead = superseded_ids(evidences);
848
851
  return find_pass(evidences, filters).filter((ev) => !dead.has(ev.evidence_id));
849
852
  }
853
+ export function live_user_confirmations(evidences, gate) {
854
+ return live_pass(evidences, { gate, kind: "human_confirmation" })
855
+ .filter((ev) => String(ev.created_by ?? "") === "user");
856
+ }
@@ -8,6 +8,7 @@ export declare function check_init(change: string, status: JsonMap, repoRoot: st
8
8
  export declare function check_superspec_gate(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[], gateRaw: string): Decision;
9
9
  export declare function check_artifact(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[], artifact: string): Decision;
10
10
  export declare function check_task_reopen(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[], taskId: string): Decision;
11
+ export declare function check_apply_ready(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[]): Decision;
11
12
  export declare function check_task_edit(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[], taskId: string): Decision;
12
13
  export declare function check_task_complete(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[], taskId: string): Decision;
13
14
  export declare function check_review_ready(change: string, status: JsonMap, changeRoot: string, evidences: JsonMap[]): Decision;
package/dist/src/gates.js CHANGED
@@ -5,7 +5,7 @@ import { all_done, artifact_status_map, get_repo_root, is_done, normalize_gate,
5
5
  import { read_agent_toml_name, read_skill_frontmatter_name, sidecar_business_invariants_path, sidecar_discovery_path, sidecar_test_contract_path } from "./paths.js";
6
6
  import { business_invariant_ids, business_invariant_validation_reasons, automated_hard_business_invariant_ids, evidence_invariant_refs, evidence_invariant_ref_reasons, evidence_test_contract_invariant_reasons, human_confirmation_business_invariant_ids, invariant_matrix_coverage_reasons, post_implementation_business_invariant_ids, red_green_invariant_ids, test_contract_invariant_ids, } from "./invariants.js";
7
7
  import { evidence_test_id_reasons, declared_test_evidence_reasons, parse_spec_scenarios, parse_tasks, parse_test_contract_ids, parse_test_contract_records, red_green_test_ids, splitList, tasks_structure_hash, task_alternative_verification, task_test_evidence, task_test_refs, test_contract_covers_scenario, test_contract_invariant_refs_by_test, write_scope_conflict_reasons, } from "./tasks.js";
8
- import { duplicate_evidence_id_reasons, dangling_evidence_ref_reasons, final_verification_evidences, live_task_reopens, live_task_reopen_resolutions, live_pass, pass_task_reopens, supersede_reasons, unresolved_live_task_reopens, validate_evidence_schema, verify_reference_reasons, } from "./evidence.js";
8
+ import { duplicate_evidence_id_reasons, dangling_evidence_ref_reasons, final_verification_evidences, live_task_reopens, live_task_reopen_resolutions, live_pass, live_user_confirmations, pass_task_reopens, supersede_reasons, unresolved_live_task_reopens, validate_evidence_schema, verify_reference_reasons, } from "./evidence.js";
9
9
  import { review_disclosure_reasons } from "./disclosure.js";
10
10
  import { archive_manifest_path } from "./archive.js";
11
11
  function action_list(...items) {
@@ -311,7 +311,7 @@ function archive_ready_actions(change, reasons, review) {
311
311
  function default_gate_next_actions(gate) {
312
312
  switch (gate) {
313
313
  case "explore_complete":
314
- return ["write .superspec/artifacts/discovery.md and record native_subagent critic evidence"];
314
+ return ["write .superspec/artifacts/discovery.md, record native_subagent critic evidence, and record explore_complete human confirmation"];
315
315
  case "proposal_reviewed":
316
316
  return ["run the proposal critic review (round-tagged, findings[]) and record a main_review_digest disclosing every finding"];
317
317
  case "design_complete":
@@ -478,6 +478,9 @@ export function check_superspec_gate(change, status, changeRoot, evidences, gate
478
478
  reasons.push(reason("missing_native_subagent_evidence", `explore_complete requires native_subagent ${role} report`));
479
479
  }
480
480
  reasons.push(...stale_artifact_review_reasons(exploreReviews, changeRoot, ".superspec/artifacts/discovery.md", "stale_explore_review", "explore_complete"));
481
+ if (live_user_confirmations(evidences, "explore_complete").length === 0) {
482
+ reasons.push(reason("missing_human_confirmation", "explore_complete requires human confirmation before entering propose"));
483
+ }
481
484
  // DISC Phase 1: material findings raised by explore reviews must be disclosed to the user
482
485
  // (main_review_digest + user_review_decision) before the gate can pass.
483
486
  reasons.push(...review_disclosure_reasons("explore_complete", changeRoot, evidences));
@@ -510,7 +513,7 @@ export function check_superspec_gate(change, status, changeRoot, evidences, gate
510
513
  if (!designReviews.some((ev) => ev.agent_role === role))
511
514
  reasons.push(reason(`missing_${role}_review`, `design_complete requires native_subagent ${role} report`));
512
515
  }
513
- if (live_pass(evidences, { kind: "human_confirmation", gate: "design_complete" }).length === 0)
516
+ if (live_user_confirmations(evidences, "design_complete").length === 0)
514
517
  reasons.push(reason("missing_human_confirmation", "design_complete requires human confirmation"));
515
518
  reasons.push(...stale_artifact_review_reasons(designReviews, changeRoot, "design.md", "stale_design_review", "design_complete"));
516
519
  // DISC Phase 2: design reviews carrying round-tagged findings enter the disclosure loop
@@ -536,7 +539,7 @@ export function check_superspec_gate(change, status, changeRoot, evidences, gate
536
539
  const humanRequired = human_confirmation_business_invariant_ids(changeRoot);
537
540
  if (humanRequired.size > 0) {
538
541
  const confirmed = new Set();
539
- for (const ev of live_pass(evidences, { gate: "invariants_reviewed", kind: "human_confirmation" })) {
542
+ for (const ev of live_user_confirmations(evidences, "invariants_reviewed")) {
540
543
  for (const id of evidence_invariant_refs(ev))
541
544
  confirmed.add(id);
542
545
  }
@@ -654,6 +657,9 @@ export function check_superspec_gate(change, status, changeRoot, evidences, gate
654
657
  }
655
658
  }
656
659
  }
660
+ else if (gate === "apply_ready") {
661
+ return check_apply_ready(change, status, changeRoot, evidences);
662
+ }
657
663
  else {
658
664
  return block(change, gate, [reason("unknown_gate", `unknown superspec gate: ${gate}`)]);
659
665
  }
@@ -1029,6 +1035,7 @@ export function check_task_reopen(change, status, changeRoot, evidences, taskId)
1029
1035
  reasons.push(reason("propose_not_complete", "task_reopen requires propose_complete"));
1030
1036
  reasons.push(...propose.block_reasons);
1031
1037
  }
1038
+ reasons.push(...apply_scope_confirmation_reasons(changeRoot, evidences));
1032
1039
  const tasks = parse_tasks(changeRoot);
1033
1040
  const task = tasks[taskId];
1034
1041
  if (!task)
@@ -1038,8 +1045,13 @@ export function check_task_reopen(change, status, changeRoot, evidences, taskId)
1038
1045
  reasons.push(...request_changes_round_reasons(evidences));
1039
1046
  const reopenCheck = active_task_reopen_reasons(changeRoot, evidences, task, taskId, "pre_revert");
1040
1047
  reasons.push(...reopenCheck.reasons);
1041
- if (reasons.length > 0)
1042
- return block(change, gate, reasons, { task_id: taskId, next_actions: [`keep ${taskId} checked, fix task_reopen evidence / supersedes, then rerun check-task-reopen`] });
1048
+ if (reasons.length > 0) {
1049
+ const reasonSet = reason_codes(reasons);
1050
+ return block(change, gate, reasons, {
1051
+ task_id: taskId,
1052
+ next_actions: action_list(reasonSet.has("apply_isolation_unconfirmed") ? "AskUserQuestion for apply isolation/execution mode and record gate=\"apply_isolation\" human_confirmation" : null, reasonSet.has("scope_expansion_unconfirmed") ? "stop: redesign/split the change or record gate=\"scope_expansion\" human_confirmation re-approving tasks.md structure" : null, `keep ${taskId} checked, fix task_reopen evidence / supersedes, then rerun check-task-reopen`),
1053
+ });
1054
+ }
1043
1055
  return allow(change, gate, { task_id: taskId });
1044
1056
  }
1045
1057
  // FIX-8 (audit A-5): apply work requires the user's explicit isolation/execution-mode choice,
@@ -1048,7 +1060,7 @@ export function check_task_reopen(change, status, changeRoot, evidences, taskId)
1048
1060
  // apply-phase scope expansion (SPEC §14.7) and demands explicit user re-approval — redesign,
1049
1061
  // split into a new change, or record a scope_expansion confirmation re-pinning the structure.
1050
1062
  function apply_scope_confirmation_reasons(changeRoot, evidences) {
1051
- const isolation = live_pass(evidences, { gate: "apply_isolation", kind: "human_confirmation" });
1063
+ const isolation = live_user_confirmations(evidences, "apply_isolation");
1052
1064
  const currentHash = tasks_structure_hash(changeRoot);
1053
1065
  if (isolation.length === 0) {
1054
1066
  const hashHint = currentHash ? ` with tasks_structure_hash=${currentHash}` : "";
@@ -1056,11 +1068,28 @@ function apply_scope_confirmation_reasons(changeRoot, evidences) {
1056
1068
  }
1057
1069
  if (currentHash === null)
1058
1070
  return [];
1059
- const approvals = [...isolation, ...live_pass(evidences, { gate: "scope_expansion", kind: "human_confirmation" })];
1071
+ const approvals = [...isolation, ...live_user_confirmations(evidences, "scope_expansion")];
1060
1072
  if (approvals.some((ev) => String(ev.tasks_structure_hash ?? "") === currentHash))
1061
1073
  return [];
1062
1074
  return [reason("scope_expansion_unconfirmed", `tasks.md structure changed after the last user-approved apply scope; ask the user to redesign/split the change or re-approve by recording gate="scope_expansion" human_confirmation with tasks_structure_hash=${currentHash}`)];
1063
1075
  }
1076
+ export function check_apply_ready(change, status, changeRoot, evidences) {
1077
+ const gate = "apply_ready";
1078
+ const reasons = [];
1079
+ const propose = check_superspec_gate(change, status, changeRoot, evidences, "propose_complete");
1080
+ if (!propose.allowed) {
1081
+ reasons.push(reason("propose_not_complete", "apply_ready requires propose_complete"));
1082
+ reasons.push(...propose.block_reasons);
1083
+ }
1084
+ reasons.push(...apply_scope_confirmation_reasons(changeRoot, evidences));
1085
+ if (reasons.length > 0) {
1086
+ const reasonSet = reason_codes(reasons);
1087
+ return block(change, gate, reasons, {
1088
+ next_actions: action_list(!propose.allowed ? "pass propose_complete before apply" : null, reasonSet.has("apply_isolation_unconfirmed") ? "AskUserQuestion for apply isolation/execution mode and record gate=\"apply_isolation\" human_confirmation" : null, reasonSet.has("scope_expansion_unconfirmed") ? "stop: redesign/split the change or record gate=\"scope_expansion\" human_confirmation re-approving tasks.md structure" : null),
1089
+ });
1090
+ }
1091
+ return allow(change, gate, { openspec_summary: artifact_status_map(status) });
1092
+ }
1064
1093
  export function check_task_edit(change, status, changeRoot, evidences, taskId) {
1065
1094
  const gate = "task_edit";
1066
1095
  const reasons = [];
@@ -1378,7 +1407,7 @@ export function check_review_complete(change, status, changeRoot, evidences) {
1378
1407
  && (ev.kind === "verification_review" || ev.kind === "final_test")
1379
1408
  && ev.status === "fail");
1380
1409
  if (failedVerifications.length > 0) {
1381
- const dispositions = new Set(live_pass(evidences, { gate: "verify_failure_handling", kind: "human_confirmation" })
1410
+ const dispositions = new Set(live_user_confirmations(evidences, "verify_failure_handling")
1382
1411
  .flatMap((ev) => (Array.isArray(ev.confirmed_refs) ? ev.confirmed_refs.map((item) => String(item)) : [])));
1383
1412
  const unhandled = failedVerifications
1384
1413
  .map((ev) => String(ev.evidence_id ?? ev._path ?? "unknown"))
@@ -1456,7 +1485,7 @@ export function check_archive_ready(change, status, changeRoot, evidences) {
1456
1485
  if (!ok && !reviewCodes.has("validate_failed")) {
1457
1486
  reasons.push(reason("validate_failed", "openspec validate did not pass"));
1458
1487
  }
1459
- if (live_pass(evidences, { gate: "archive_ready", kind: "human_confirmation" }).length === 0) {
1488
+ if (live_user_confirmations(evidences, "archive_ready").length === 0) {
1460
1489
  reasons.push(reason("missing_final_confirmation", "archive_ready requires human confirmation evidence"));
1461
1490
  }
1462
1491
  if (reasons.length > 0)
package/dist/src/git.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative } from "node:path";
3
3
  import { GuardError, reason, renderList, runCommand, runtime } from "./util.js";
4
4
  import { splitList, task_test_evidence } from "./tasks.js";
5
- import { live_pass } from "./evidence.js";
5
+ import { live_user_confirmations } from "./evidence.js";
6
6
  export function file_blob_sha(filePath) {
7
7
  if (!existsSync(filePath) || !statSync(filePath).isFile())
8
8
  throw new GuardError(`git_blob_inspection_failure: reviewed target missing: ${filePath}`);
@@ -50,7 +50,7 @@ export function dirty_worktree_reasons(repoRoot, changeRoot, evidences) {
50
50
  const unknown = dirty.filter((item) => !(changeRel && item.startsWith(`${changeRel}/`)));
51
51
  if (unknown.length === 0)
52
52
  return [];
53
- const confirmations = live_pass(evidences, { gate: "branch_handling", kind: "human_confirmation" });
53
+ const confirmations = live_user_confirmations(evidences, "branch_handling");
54
54
  const confirmedScopes = confirmations.flatMap((ev) => (Array.isArray(ev.confirmed_paths) ? ev.confirmed_paths.map((item) => String(item)).filter(Boolean) : []));
55
55
  const unconfirmed = unknown.filter((item) => !confirmedScopes.some((scope) => pathInScope(item, scope)));
56
56
  if (unconfirmed.length === 0)
@@ -1,10 +1,11 @@
1
- import { runCommand, type OpenspecCliProbe } from "./core.ts";
1
+ import { runCommand, type DecisionOutputFormat, type OpenspecCliProbe } from "./core.ts";
2
2
  import { type InstallScope } from "./install_engine.ts";
3
3
  type InitArgs = {
4
4
  path: string;
5
5
  codexHome: string;
6
6
  mode: "install" | "update" | "uninstall";
7
7
  scope: InstallScope | null;
8
+ format: DecisionOutputFormat;
8
9
  dryRun: boolean;
9
10
  force: boolean;
10
11
  };
@@ -1,4 +1,4 @@
1
- import { block, commandExists, GuardError, printDecision, reason, runCommand, openspec_cli_probe, REQUIRED_OPENSPEC_MIN_VERSION, } from "./core.js";
1
+ import { block, commandExists, GuardError, printDecision, reason, runCommand, openspec_cli_probe, parseDecisionOutputFormat, REQUIRED_OPENSPEC_MIN_VERSION, } from "./core.js";
2
2
  import { forced_openspec_install_plan, project_init, recommended_openspec_install_plan } from "./project_init.js";
3
3
  import { install_workflow, uninstall_workflow, update_workflow } from "./install_engine.js";
4
4
  import { homedir } from "node:os";
@@ -6,21 +6,25 @@ import { resolve } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { system_failure_zh } from "./i18n.js";
8
8
  function usage() {
9
- return "usage: superspec init [-h] [--scope {project,user}] [--path PATH] [--codex-home PATH] [--create] [--update] [--uninstall] [--dry-run] [--force]\n";
9
+ return "usage: superspec init [-h] [--scope {project,user}] [--path PATH] [--codex-home PATH] [--format {json,agent,user}] [--create] [--update] [--uninstall] [--dry-run] [--force]\n";
10
10
  }
11
11
  function help() {
12
- return `${usage()}\n可选参数:\n -h, --help 显示帮助并退出\n --scope {project,user} 安装到当前项目的 .codex 目录,或安装到用户级 Codex 目录(默认:project)\n --project 等价于 --scope project\n --user 等价于 --scope user\n --global 兼容别名,等价于 --user\n --path PATH --scope project 时使用的项目根目录(默认:当前目录)\n --codex-home PATH --scope user 时使用的 Codex 用户目录(默认:$CODEX_HOME 或 ~/.codex)\n --create 兼容参数;init 默认就会创建缺失内容\n --update 按 manifest 更新 SuperSpec 管理的文件;用户改动文件保留,新的版本写入 *.new\n --uninstall 按 manifest 卸载 SuperSpec 管理的文件;.superspec 数据与既有/用户改动文件会保留\n --dry-run 配合 --uninstall 时只预览将删除的文件,不实际修改\n --force 安装时覆盖已有且内容不同的文件,并保留 *.bak 备份\n`;
12
+ return `${usage()}\n可选参数:\n -h, --help 显示帮助并退出\n --scope {project,user} 安装到当前项目的 .codex 目录,或安装到用户级 Codex 目录(默认:project)\n --project 等价于 --scope project\n --user 等价于 --scope user\n --global 兼容别名,等价于 --user\n --path PATH --scope project 时使用的项目根目录(默认:当前目录)\n --codex-home PATH --scope user 时使用的 Codex 用户目录(默认:$CODEX_HOME 或 ~/.codex)\n --format {json,agent,user} 输出格式;json 用于诊断,agent/user 用于安全展示\n --user-facing 等价于 --format user\n --create 兼容参数;init 默认就会创建缺失内容\n --update 按 manifest 更新 SuperSpec 管理的文件;用户改动文件保留,新的版本写入 *.new\n --uninstall 按 manifest 卸载 SuperSpec 管理的文件;.superspec 数据与既有/用户改动文件会保留\n --dry-run 配合 --uninstall 时只预览将删除的文件,不实际修改\n --force 安装时覆盖已有且内容不同的文件,并保留 *.bak 备份\n`;
13
13
  }
14
14
  function parse_init_argv(argv) {
15
- const getValue = (flag) => {
16
- const idx = argv.indexOf(flag);
17
- if (idx === -1)
18
- return undefined;
19
- const value = argv[idx + 1];
20
- if (value === undefined || value.startsWith("--"))
21
- throw new GuardError(`${flag} 缺少取值`);
22
- return value;
15
+ const getValues = (flag) => {
16
+ const values = [];
17
+ for (let idx = 0; idx < argv.length; idx += 1) {
18
+ if (argv[idx] !== flag)
19
+ continue;
20
+ const value = argv[idx + 1];
21
+ if (value === undefined || value.startsWith("--"))
22
+ throw new GuardError(`${flag} 缺少取值`);
23
+ values.push(value);
24
+ }
25
+ return values;
23
26
  };
27
+ const getValue = (flag) => getValues(flag)[0];
24
28
  const update = argv.includes("--update");
25
29
  const uninstall = argv.includes("--uninstall");
26
30
  if (update && uninstall)
@@ -36,11 +40,19 @@ function parse_init_argv(argv) {
36
40
  scope = "user";
37
41
  if (argv.includes("--project"))
38
42
  scope = "project";
43
+ const formatValues = getValues("--format");
44
+ for (const value of formatValues)
45
+ parseDecisionOutputFormat(value);
46
+ const selectedFormat = argv.includes("--user-facing")
47
+ ? "user"
48
+ : (formatValues.length > 0 ? formatValues[formatValues.length - 1] : "json");
49
+ const format = parseDecisionOutputFormat(selectedFormat);
39
50
  return {
40
51
  path: resolve(getValue("--path") ?? process.cwd()),
41
52
  codexHome: resolve(getValue("--codex-home") ?? process.env.CODEX_HOME ?? joinHomeCodex()),
42
53
  mode: uninstall ? "uninstall" : update ? "update" : "install",
43
54
  scope,
55
+ format,
44
56
  dryRun: argv.includes("--dry-run"),
45
57
  force: argv.includes("--force"),
46
58
  };
@@ -149,7 +161,7 @@ function openspecPreflightBlocked(args, scope, installResult) {
149
161
  project_root: args.path,
150
162
  install_scope: scope,
151
163
  install_root: targetRoot,
152
- }, { command: "init" });
164
+ }, { command: "init", format: args.format });
153
165
  return 1;
154
166
  }
155
167
  function run_init(args, scope) {
@@ -176,16 +188,17 @@ function run_init(args, scope) {
176
188
  }
177
189
  summary.install_scope = scope;
178
190
  summary.install_root = targetRoot;
179
- printDecision(summary, { command: "init" });
191
+ printDecision(summary, { command: "init", format: args.format });
180
192
  return summary.allowed ? 0 : 1;
181
193
  }
182
194
  export function main_init(argv = process.argv.slice(2)) {
195
+ let args = null;
183
196
  try {
184
197
  if (argv.includes("-h") || argv.includes("--help")) {
185
198
  process.stdout.write(help());
186
199
  return 0;
187
200
  }
188
- const args = parse_init_argv(argv);
201
+ args = parse_init_argv(argv);
189
202
  const scope = args.scope ?? "project";
190
203
  const openspecInstall = maybe_install_missing_openspec({ cwd: args.path, scope, mode: args.mode });
191
204
  if (openspecInstall === "failed" || openspecInstall === "skipped")
@@ -195,17 +208,18 @@ export function main_init(argv = process.argv.slice(2)) {
195
208
  catch (err) {
196
209
  const change = "project";
197
210
  const errReason = err instanceof GuardError ? reason("guard_error", err.message) : reason("guard_internal_error", `${err.name}: ${err.message}`);
198
- printDecision(block(change, "guard_error", [errReason]), { command: "init" });
211
+ printDecision(block(change, "guard_error", [errReason]), { command: "init", format: args?.format });
199
212
  return 2;
200
213
  }
201
214
  }
202
215
  export async function main_init_async(argv = process.argv.slice(2)) {
216
+ let args = null;
203
217
  try {
204
218
  if (argv.includes("-h") || argv.includes("--help")) {
205
219
  process.stdout.write(help());
206
220
  return 0;
207
221
  }
208
- const args = parse_init_argv(argv);
222
+ args = parse_init_argv(argv);
209
223
  const scope = args.scope ?? (canPrompt() ? await promptInstallScope() : "project");
210
224
  const openspecInstall = maybe_install_missing_openspec({ cwd: args.path, scope, mode: args.mode });
211
225
  if (openspecInstall === "failed" || openspecInstall === "skipped")
@@ -215,7 +229,7 @@ export async function main_init_async(argv = process.argv.slice(2)) {
215
229
  catch (err) {
216
230
  const change = "project";
217
231
  const errReason = err instanceof GuardError ? reason("guard_error", err.message) : reason("guard_internal_error", `${err.name}: ${err.message}`);
218
- printDecision(block(change, "guard_error", [errReason]), { command: "init" });
232
+ printDecision(block(change, "guard_error", [errReason]), { command: "init", format: args?.format });
219
233
  return 2;
220
234
  }
221
235
  }
@@ -0,0 +1,14 @@
1
+ import { runCommand } from "./util.ts";
2
+ type RunFn = typeof runCommand;
3
+ type CommandExistsFn = (cmd: string, meta?: {
4
+ cwd?: string;
5
+ }) => boolean;
6
+ export declare function update_self_then_rerun(opts: {
7
+ args: string[];
8
+ cwd?: string;
9
+ run?: RunFn;
10
+ commandExistsFn?: CommandExistsFn;
11
+ writeStdout?: (text: string) => void;
12
+ writeStderr?: (text: string) => void;
13
+ }): number;
14
+ export {};
@@ -0,0 +1,56 @@
1
+ import { commandExists, runCommand } from "./util.js";
2
+ const SUPERSPEC_NPM_PACKAGE = "@peterxiaoyang/superspec";
3
+ function renderCommand(cmd, args) {
4
+ return [cmd, ...args].join(" ");
5
+ }
6
+ function commandFailure(proc) {
7
+ const output = (proc.error?.message ?? (proc.stderr || proc.stdout)).trim();
8
+ if (output)
9
+ return output;
10
+ return proc.status === null ? "command failed" : `command exited with status ${proc.status}`;
11
+ }
12
+ function shouldRetrySuperSpecInstallWithForce(proc) {
13
+ const output = `${proc.error?.message ?? ""}\n${proc.stderr}\n${proc.stdout}`;
14
+ const binConflict = /\bEEXIST\b|already exists|file exists|Refusing to delete|will not overwrite|would overwrite/iu.test(output);
15
+ const superspecBin = /\bsuperspec(?:\.(?:cmd|ps1))?\b/iu.test(output);
16
+ return binConflict && superspecBin;
17
+ }
18
+ export function update_self_then_rerun(opts) {
19
+ const cwd = opts.cwd ?? process.cwd();
20
+ const run = opts.run ?? runCommand;
21
+ const commandExistsFn = opts.commandExistsFn ?? ((cmd, meta) => commandExists(cmd, { cwd: meta?.cwd }));
22
+ const writeStdout = opts.writeStdout ?? ((text) => process.stdout.write(text));
23
+ const writeStderr = opts.writeStderr ?? ((text) => process.stderr.write(text));
24
+ if (!commandExistsFn("npm", { cwd })) {
25
+ writeStderr("SuperSpec update requires npm on PATH to install @peterxiaoyang/superspec@latest. Use `superspec update --local-only` to update surfaces from the currently installed package.\n");
26
+ return 1;
27
+ }
28
+ const installArgs = ["install", "-g", `${SUPERSPEC_NPM_PACKAGE}@latest`];
29
+ writeStderr(`Updating SuperSpec CLI: ${renderCommand("npm", installArgs)}\n`);
30
+ let install = run("npm", installArgs, { cwd, timeout: 300_000 });
31
+ if ((install.error || install.status !== 0) && shouldRetrySuperSpecInstallWithForce(install)) {
32
+ const forcedArgs = ["install", "-g", "--force", `${SUPERSPEC_NPM_PACKAGE}@latest`];
33
+ writeStderr(`SuperSpec CLI install hit a global bin conflict; retrying with: ${renderCommand("npm", forcedArgs)}\n`);
34
+ install = run("npm", forcedArgs, { cwd, timeout: 300_000 });
35
+ }
36
+ if (install.error || install.status !== 0) {
37
+ writeStderr(`SuperSpec CLI update failed: ${commandFailure(install)}\n`);
38
+ return 1;
39
+ }
40
+ if (!commandExistsFn("superspec", { cwd })) {
41
+ writeStderr("SuperSpec CLI update completed, but `superspec` is not resolvable from PATH. Reopen the shell or check npm global bin configuration.\n");
42
+ return 1;
43
+ }
44
+ const rerunArgs = ["update", ...opts.args, "--skip-self-update"];
45
+ const rerun = run("superspec", rerunArgs, { cwd, timeout: 300_000 });
46
+ if (rerun.stdout)
47
+ writeStdout(rerun.stdout);
48
+ if (rerun.stderr)
49
+ writeStderr(rerun.stderr);
50
+ if (rerun.error || rerun.status !== 0) {
51
+ if (rerun.error && !rerun.stderr)
52
+ writeStderr(`SuperSpec update rerun failed: ${rerun.error.message}\n`);
53
+ return rerun.status ?? 1;
54
+ }
55
+ return 0;
56
+ }