@peterxiaoyang/superspec 0.1.7 → 0.1.9

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
@@ -82,6 +82,8 @@ superspec init --scope project
82
82
 
83
83
  这条命令的意思是:把 SuperSpec 当前可用的工作流入口安装到项目里。
84
84
 
85
+ 初始化还会安装托管的 `.codex/hooks.json`。它会让 Codex 在写文件前、工具执行后、子智能体启动和停止时调用 SuperSpec,做检查和记录。
86
+
85
87
  Windows PowerShell 如果拦截 npm 的 `.ps1` 脚本,请改用:
86
88
 
87
89
  ```powershell
@@ -157,6 +159,18 @@ openspec/changes/<变更ID>/.superspec/
157
159
  `.superspec/` 要不要提交到 git,由你的团队决定。
158
160
  如果不提交,删掉后就没有 git 历史可以恢复。
159
161
 
162
+ ## Hook 会做什么
163
+
164
+ SuperSpec 安装的 hook 会在几个关键时机运行:
165
+
166
+ - 写文件前:检查是否会改到 SuperSpec 的过程记录、提前归档、绕过任务检查,或写到当前任务不该写的地方
167
+ - 工具执行后:如果刚跑的是测试或验证命令,就记录这次结果
168
+ - 子智能体启动和停止时:记录这次子智能体运行的基本信息
169
+
170
+ 这些 hook 的默认超时时间是 `120` 秒。这个时间限制的是 hook 自己的检查过程,不限制 `npm test`、构建命令或子智能体本身能运行多久。
171
+
172
+ hook 不是安全沙箱。它能减少误操作、拦住一部分明显会破坏流程记录的写入,并留下审计线索;但不能保证阻止所有绕过,也不能把记录变成不可伪造的安全证明。
173
+
160
174
  ## 重要边界
161
175
 
162
176
  SuperSpec 能让流程更规范,但它不是安全锁。
@@ -175,7 +189,7 @@ SuperSpec 能让流程更规范,但它不是安全锁。
175
189
  - 阻止恶意伪造记录
176
190
  - 替代正式的安全审计、合规审计或法律证明
177
191
 
178
- 也就是说,SuperSpec v1 是“流程纪律工具”,不是“强制安全系统”。
192
+ 也就是说,SuperSpec 目前是“流程纪律 + 审计辅助工具”,不是“强制安全系统”。hook 会增强可见性和一部分写入检查,但它仍然不能替代正式的安全控制。
179
193
 
180
194
  ## 常用命令
181
195
 
@@ -223,6 +237,7 @@ superspec doctor
223
237
 
224
238
  ```text
225
239
  .codex/
240
+ hooks.json
226
241
  skills/superspec-explore/
227
242
  skills/superspec-propose/
228
243
  skills/superspec-apply/
@@ -27,6 +27,11 @@
27
27
  "source": "templates/workflow/skills/superspec-archive/SKILL.md",
28
28
  "target": ".codex/skills/superspec-archive/SKILL.md"
29
29
  },
30
+ {
31
+ "kind": "hook",
32
+ "source": "templates/hooks/codex-hooks.json",
33
+ "target": ".codex/hooks.json"
34
+ },
30
35
  {
31
36
  "kind": "prompt",
32
37
  "source": "templates/workflow/prompts/architect.md",
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runEntry } from "./launch.js";
3
+
4
+ await runEntry("../dist/superspec_hook.js", "main_hook");
@@ -35,6 +35,8 @@ function write_text(path, text) {
35
35
  function excluded_manifest_entry(relPath) {
36
36
  if (relPath.endsWith(STATE_LOCK_FILENAME) || relPath.endsWith("superspec-state.tmp"))
37
37
  return true;
38
+ if (relPath === ".superspec/hook-runtime" || relPath.startsWith(".superspec/hook-runtime/"))
39
+ return true;
38
40
  if (relPath === ".superspec/artifacts/archive-preservation.json")
39
41
  return true;
40
42
  return false;
@@ -9,6 +9,11 @@ export type ParsedArgs = {
9
9
  task_id?: string;
10
10
  test_id?: string;
11
11
  phase?: "red" | "characterization" | "green";
12
+ event_ref?: string;
13
+ workflow?: string;
14
+ entrypoint_token?: string;
15
+ end_reason?: string;
16
+ lifecycle_token?: string;
12
17
  role?: string;
13
18
  evidence_kind?: string;
14
19
  round?: number;
@@ -11,6 +11,8 @@ const SIMPLE_COMMANDS = [
11
11
  "check-verify-ready",
12
12
  "check-archive-ready",
13
13
  "check-archived",
14
+ "hook-health",
15
+ "hook-session-status",
14
16
  ];
15
17
  const COMMANDS = [
16
18
  "init",
@@ -27,6 +29,13 @@ const COMMANDS = [
27
29
  "apply-code-review-packet",
28
30
  "apply-verify-packet",
29
31
  "ledger-render",
32
+ "hook-check-write",
33
+ "hook-check-command",
34
+ "hook-record-test",
35
+ "hook-record-subagent-start",
36
+ "hook-record-subagent-stop",
37
+ "hook-session-begin",
38
+ "hook-session-end",
30
39
  ];
31
40
  const COMMAND_LIST = COMMANDS.join(",");
32
41
  const COMMAND_CHOICES = COMMANDS.map((item) => `'${item}'`).join(", ");
@@ -61,6 +70,13 @@ function requiredValueFlags(command) {
61
70
  flags.push("--task-id", "--executor-report-ref", "--task-code-review-report-ref", "--green-test-run-evidence-ref");
62
71
  if (command === "ledger-render")
63
72
  flags.push("--gate");
73
+ if (command === "hook-check-write" || command === "hook-check-command" || command === "hook-record-test"
74
+ || command === "hook-record-subagent-start" || command === "hook-record-subagent-stop")
75
+ flags.push("--event-ref");
76
+ if (command === "hook-session-begin")
77
+ flags.push("--workflow", "--entrypoint-token");
78
+ if (command === "hook-session-end")
79
+ flags.push("--reason");
64
80
  return flags;
65
81
  }
66
82
  function requiredBooleanFlags(command) {
@@ -84,6 +100,8 @@ function optionalValueFlags(command) {
84
100
  return ["--red-test-run-evidence-ref", "--characterization-test-run-evidence-ref"];
85
101
  if (command === "ledger-render")
86
102
  return ["--round"];
103
+ if (command === "hook-session-end")
104
+ return ["--lifecycle-token"];
87
105
  return [];
88
106
  }
89
107
  function formatUsage(command) {
@@ -376,6 +394,29 @@ export function parse_argv(argv) {
376
394
  throw new GuardError("--round 必须是大于等于 1 的整数");
377
395
  return { command, change, gate, round };
378
396
  }
397
+ if (command === "hook-check-write" || command === "hook-check-command" || command === "hook-record-test"
398
+ || command === "hook-record-subagent-start" || command === "hook-record-subagent-stop") {
399
+ const eventRef = getValue("--event-ref");
400
+ if (!eventRef)
401
+ throw new Error("缺少必填参数 --event-ref");
402
+ return { command, change, format, event_ref: eventRef };
403
+ }
404
+ if (command === "hook-session-begin") {
405
+ const workflow = getValue("--workflow");
406
+ const entrypointToken = getValue("--entrypoint-token");
407
+ if (!workflow)
408
+ throw new Error("缺少必填参数 --workflow");
409
+ if (!entrypointToken)
410
+ throw new Error("缺少必填参数 --entrypoint-token");
411
+ return { command, change, format, workflow, entrypoint_token: entrypointToken };
412
+ }
413
+ if (command === "hook-session-end") {
414
+ const endReason = getValue("--reason");
415
+ const lifecycleToken = getValue("--lifecycle-token");
416
+ if (!endReason)
417
+ throw new Error("缺少必填参数 --reason");
418
+ return { command, change, format, end_reason: endReason, lifecycle_token: lifecycleToken ?? "" };
419
+ }
379
420
  if (command === "check-task-reopen" || command === "check-task-edit" || command === "check-task-complete") {
380
421
  const taskId = getValue("--task-id");
381
422
  if (!taskId)
package/dist/src/core.js CHANGED
@@ -23,6 +23,7 @@ import { dirty_worktree_paths, dirty_worktree_reasons, dirty_write_scope_red_rea
23
23
  import { append_ledger, load_state, compute_fingerprints, prepare_recomputed_state_write, read_ledger_text, record_supersede_ledger_events, restore_state_snapshot_locked, state_corrupt_reasons, state_file, state_file_corrupt, state_stale_reasons, force_unlock_state, with_state_lock, write_prepared_state_locked, } from "./state.js";
24
24
  import { archive_manifest_path, begin_archive_preservation_bundle, check_archived, preset_upgrade_reasons, preset_upgrade_required_from_context, write_archive_preservation_bundle, } from "./archive.js";
25
25
  import { check_archive_ready, check_apply_ready, check_artifact, check_init, check_superspec_gate, check_review_complete, check_review_ready, check_task_complete, check_task_edit, check_task_reopen, check_verify_complete, evidence_schema_guard, superspec_agent_reasons, superspec_workflow_skill_reasons, openspec_cli_capability_reasons, openspec_init_reasons, } from "./gates.js";
26
+ import { hookCheckCommand, hookCheckWrite, hookHealth, hookRecordSubagentStart, hookRecordSubagentStop, hookRecordTest, hookSessionBegin, hookSessionEnd, hookSessionStatus, } from "./hooks/guard_api.js";
26
27
  const RETRY_WAIT = new Int32Array(new SharedArrayBuffer(4));
27
28
  const DEFAULT_MAX_STATE_WRITE_RETRIES = 5;
28
29
  function sleep_ms(ms) {
@@ -42,6 +43,39 @@ export function load_context(change) {
42
43
  const evidences = index_evidence(changeRoot);
43
44
  return [status, repoRoot, changeRoot, evidences];
44
45
  }
46
+ function is_hook_command(command) {
47
+ return command === "hook-check-write"
48
+ || command === "hook-check-command"
49
+ || command === "hook-record-test"
50
+ || command === "hook-record-subagent-start"
51
+ || command === "hook-record-subagent-stop"
52
+ || command === "hook-health"
53
+ || command === "hook-session-begin"
54
+ || command === "hook-session-status"
55
+ || command === "hook-session-end";
56
+ }
57
+ function dispatch_hook_command(args) {
58
+ const change = args.change;
59
+ if (args.command === "hook-check-write")
60
+ return [hookCheckWrite(change, args.event_ref ?? ""), "hook"];
61
+ if (args.command === "hook-check-command")
62
+ return [hookCheckCommand(change, args.event_ref ?? ""), "hook"];
63
+ if (args.command === "hook-record-test")
64
+ return [hookRecordTest(change, args.event_ref ?? ""), "hook"];
65
+ if (args.command === "hook-record-subagent-start")
66
+ return [hookRecordSubagentStart(change, args.event_ref ?? ""), "hook"];
67
+ if (args.command === "hook-record-subagent-stop")
68
+ return [hookRecordSubagentStop(change, args.event_ref ?? ""), "hook"];
69
+ if (args.command === "hook-health")
70
+ return [hookHealth(change), "hook"];
71
+ if (args.command === "hook-session-begin")
72
+ return [hookSessionBegin(change, args.workflow ?? "", args.entrypoint_token ?? ""), "hook"];
73
+ if (args.command === "hook-session-status")
74
+ return [hookSessionStatus(change), "hook"];
75
+ if (args.command === "hook-session-end")
76
+ return [hookSessionEnd(change, args.end_reason ?? "", args.lifecycle_token ?? ""), "hook"];
77
+ throw new GuardError(`unknown hook command: ${args.command}`);
78
+ }
45
79
  export function cmd_status(change) {
46
80
  const [status, repoRoot, changeRoot] = runtime.load_context(change);
47
81
  const amap = artifact_status_map(status);
@@ -117,6 +151,8 @@ export function cmd_init_summary(change, changeRoot, decision) {
117
151
  function dispatch_once(args) {
118
152
  const change = args.change;
119
153
  const cmd = args.command;
154
+ if (is_hook_command(cmd))
155
+ return dispatch_hook_command(args);
120
156
  if (cmd === "check-archived")
121
157
  return [check_archived(change, repo_root_from_cwd()), "archive"];
122
158
  const [status, repoRoot, changeRoot, evidences] = runtime.load_context(change);
@@ -5,6 +5,7 @@ import { normalize_gate } from "./openspec.js";
5
5
  import { superspec_dir } from "./paths.js";
6
6
  import { findings_schema_reasons, review_digest_schema_reasons, standing_authorization_schema_reasons, user_decision_schema_reasons, } from "./disclosure.js";
7
7
  import { pinned_artifact_ref_reasons as shared_pinned_artifact_ref_reasons, worker_test_run_reasons, } from "./apply_worker_chain.js";
8
+ import { strictRoleRunlogReasons, strictRuntimeEvidenceReasons } from "./hooks/validation.js";
8
9
  function file_ref_reasons(baseRoot, ev, field, code) {
9
10
  const problems = [];
10
11
  const raw = ev[field];
@@ -509,6 +510,7 @@ export function validate_evidence_schema(ev, change, changeRoot, repoRoot) {
509
510
  problems.push(...human_confirmation_reasons(ev));
510
511
  if (ev.kind === "test_run")
511
512
  problems.push(...test_run_reasons(ev, changeRoot));
513
+ problems.push(...strictRuntimeEvidenceReasons(ev));
512
514
  if (ev.kind === "apply_worker_chain")
513
515
  problems.push(...apply_worker_chain_reasons(ev, changeRoot));
514
516
  // DISC Phase 1: disclosure evidence kinds and reviewer findings[] are schema-checked fail-closed.
@@ -556,6 +558,7 @@ export function validate_evidence_schema(ev, change, changeRoot, repoRoot) {
556
558
  const targetRoot = ev.kind === "source_guidance" ? repoRoot : changeRoot;
557
559
  problems.push(...role_target_ref_reasons(ev, targetRoot));
558
560
  problems.push(...output_ref_target_overlap_reasons(ev, changeRoot, targetRoot));
561
+ problems.push(...strictRoleRunlogReasons(changeRoot, ev));
559
562
  }
560
563
  if (ev.kind === "source_guidance") {
561
564
  if (!REVIEW_GUIDANCE_ROLES.includes(String(ev.agent_role))) {
package/dist/src/gates.js CHANGED
@@ -3,6 +3,7 @@ import { join, relative } from "node:path";
3
3
  import { ARTIFACT_ENTER_GATE, MAIN_ADJUDICATION_DECISIONS, REQUEST_CHANGES_ROUTES, NO_TDD_REASONS, OPENSPEC_ARTIFACTS, FINAL_VERIFICATION_ROLES, REQUIRED_SUPERSPEC_AGENT_ROLES, REQUIRED_SUPERSPEC_WORKFLOW_SKILLS, REVIEW_GUIDANCE_ROLES, REVIEW_EVIDENCE_REQUIRED_FIELDS, TDD_MODES, VERIFY_EVIDENCE_REQUIRED_FIELDS, allow, block, isObject, reason, renderList, repr, pinned_ref_key, safe_within, sha256_text, runtime, toPosix, } from "./util.js";
4
4
  import { all_done, artifact_status_map, get_repo_root, is_done, normalize_gate, openspec_cli_probe } from "./openspec.js";
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
+ import { hookInitReasons } from "./hooks/health.js";
6
7
  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
8
  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
9
  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";
@@ -427,8 +428,7 @@ export function check_init(change, status, repoRoot, changeRoot) {
427
428
  if (!Array.isArray(status.applyRequires) || !new Set(status.applyRequires).has("tasks")) {
428
429
  reasons.push(reason("unexpected_apply_requires", "OpenSpec applyRequires must include native tasks artifact"));
429
430
  }
430
- if (existsSync(join(repoRoot, ".codex", "hooks.json")))
431
- reasons.push(reason("v1_hook_artifact_present", ".codex/hooks.json belongs to superspec v2"));
431
+ reasons.push(...hookInitReasons(repoRoot));
432
432
  if (existsSync(join(repoRoot, "openspec", "schemas", "superspec")))
433
433
  reasons.push(reason("custom_superspec_schema_present", "openspec/schemas/superspec is not part of superspec v1 overlay"));
434
434
  if (reasons.length > 0)
@@ -0,0 +1,5 @@
1
+ export declare function runHookAdapter(argv?: string[], stdin?: string): {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ };
@@ -0,0 +1,311 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { isObject } from "../util.js";
6
+ import { hookCheckCommand, hookCheckWrite, hookRecordSubagentStart, hookRecordSubagentStop, hookRecordTest, } from "./guard_api.js";
7
+ import { commandLooksLikeTestValidation, extractWriteIntent, isSuperSpecTrustRootPath } from "./policy_event.js";
8
+ function parseArgs(argv) {
9
+ let change = process.env.SUPERSPEC_CHANGE || null;
10
+ for (let idx = 0; idx < argv.length; idx += 1) {
11
+ if (argv[idx] === "--change")
12
+ change = argv[idx + 1] || null;
13
+ }
14
+ return { change };
15
+ }
16
+ function isDir(path) {
17
+ try {
18
+ return statSync(path).isDirectory();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function readJson(path) {
25
+ try {
26
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
27
+ return isObject(parsed) ? parsed : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function validReasonArray(value) {
34
+ return Array.isArray(value) && value.every((item) => (isObject(item)
35
+ && typeof item.code === "string"
36
+ && item.code.length > 0
37
+ && typeof item.message === "string"
38
+ && item.message.length > 0
39
+ && Array.isArray(item.refs)
40
+ && item.refs.every((ref) => typeof ref === "string")));
41
+ }
42
+ function validActiveSessionRecord(path, change, repoRoot, event) {
43
+ const record = readJson(path);
44
+ if (!record)
45
+ return false;
46
+ if (record.schema_version !== 2 || record.kind !== "hook_active_session")
47
+ return false;
48
+ if (record.trust !== "audit-only" && record.trust !== "trusted")
49
+ return false;
50
+ if (record.strict_profile !== "available" && record.strict_profile !== "unavailable")
51
+ return false;
52
+ if (record.change_id !== change)
53
+ return false;
54
+ if (resolve(String(record.repo_root ?? "")) !== resolve(repoRoot))
55
+ return false;
56
+ if (typeof record.session_id !== "string" || !record.session_id)
57
+ return false;
58
+ if (!validReasonArray(record.audit_only_reasons))
59
+ return false;
60
+ const eventSessionId = typeof event.session_id === "string" && event.session_id ? event.session_id : "";
61
+ const auditRepoChangeLeaseFallback = record.trust === "audit-only"
62
+ && record.strict_profile === "unavailable"
63
+ && record.session_id === "manual-cli-session";
64
+ if (eventSessionId && record.session_id !== eventSessionId && !auditRepoChangeLeaseFallback)
65
+ return false;
66
+ const expiresAt = Date.parse(String(record.expires_at ?? ""));
67
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
68
+ return false;
69
+ return true;
70
+ }
71
+ function inferChangeFromActiveSessions(event) {
72
+ const repoRoot = findRepoRootFromCwd(event);
73
+ const changesDir = join(repoRoot, "openspec", "changes");
74
+ if (!isDir(changesDir))
75
+ return { state: "none" };
76
+ const candidates = readdirSync(changesDir).filter((change) => {
77
+ const runtimeDir = join(changesDir, change, ".superspec", "hook-runtime");
78
+ const activeDir = join(changesDir, change, ".superspec", "hook-runtime", "active-sessions");
79
+ if (isDir(activeDir) && readdirSync(activeDir).some((name) => (name.endsWith(".json") && validActiveSessionRecord(join(activeDir, name), change, repoRoot, event))))
80
+ return true;
81
+ return isDir(runtimeDir);
82
+ });
83
+ const unique = [...new Set(candidates)];
84
+ if (unique.length === 1)
85
+ return { state: "unique", change: unique[0] };
86
+ if (unique.length > 1)
87
+ return { state: "ambiguous", changes: unique.sort() };
88
+ return { state: "none" };
89
+ }
90
+ function cwdRoot(event) {
91
+ return typeof event.cwd === "string" && event.cwd ? resolve(event.cwd) : process.cwd();
92
+ }
93
+ function findRepoRootFromCwd(event) {
94
+ let dir = cwdRoot(event);
95
+ while (true) {
96
+ if ((isDir(join(dir, "openspec")) || isDir(join(dir, ".codex"))) && statMaybe(join(dir, "package.json")) === "file")
97
+ return dir;
98
+ if (isDir(join(dir, "openspec", "changes")) || isDir(join(dir, ".codex")))
99
+ return dir;
100
+ if (statMaybe(join(dir, ".git")))
101
+ return dir;
102
+ const parent = dirname(dir);
103
+ if (parent === dir)
104
+ return cwdRoot(event);
105
+ dir = parent;
106
+ }
107
+ }
108
+ function passThrough(message) {
109
+ return {
110
+ hookSpecificOutput: {
111
+ hookEventName: "PreToolUse",
112
+ additionalContext: message,
113
+ },
114
+ };
115
+ }
116
+ function statMaybe(path) {
117
+ try {
118
+ const st = statSync(path);
119
+ if (st.isFile())
120
+ return "file";
121
+ if (st.isDirectory())
122
+ return "dir";
123
+ return null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ function denyPreToolUse(reason) {
130
+ return {
131
+ hookSpecificOutput: {
132
+ hookEventName: "PreToolUse",
133
+ permissionDecision: "deny",
134
+ permissionDecisionReason: reason,
135
+ },
136
+ };
137
+ }
138
+ function isTestValidationCommand(event) {
139
+ if (String(event.tool_name ?? "") !== "Bash")
140
+ return false;
141
+ const toolInput = isObject(event.tool_input) ? event.tool_input : {};
142
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
143
+ return commandLooksLikeTestValidation(command);
144
+ }
145
+ function inertPostToolUseOutput() {
146
+ return {
147
+ systemMessage: "SuperSpec hook ignored non-test command; no test telemetry was recorded.",
148
+ hookSpecificOutput: {
149
+ hookEventName: "PostToolUse",
150
+ additionalContext: "SuperSpec runtime telemetry is inert for non-test commands.",
151
+ },
152
+ };
153
+ }
154
+ function noChangeFallback(event, inference) {
155
+ if (String(event.hook_event_name ?? "PreToolUse") !== "PreToolUse") {
156
+ return { systemMessage: "SuperSpec hook inert: SUPERSPEC_CHANGE/--change not set" };
157
+ }
158
+ const intent = extractWriteIntent(event, findRepoRootFromCwd(event));
159
+ const writeCapable = intent.target_paths.length > 0
160
+ || intent.archive_command
161
+ || intent.internal_hook_writer_command
162
+ || intent.unsupported_write_surface
163
+ || intent.shell_write_command
164
+ || intent.reasons.length > 0
165
+ || ["apply_patch", "Edit", "Write"].includes(String(event.tool_name ?? ""));
166
+ if (inference.state === "ambiguous" && writeCapable) {
167
+ return denyPreToolUse(`blocked by SuperSpec hook policy: multiple active SuperSpec sessions (${inference.changes.join(", ")}); set SUPERSPEC_CHANGE or close stale leases`);
168
+ }
169
+ if (intent.internal_hook_writer_command) {
170
+ return denyPreToolUse("blocked by SuperSpec hook policy: internal hook writer invocation requires Guard-owned hook authority");
171
+ }
172
+ if (intent.unsafe_lifecycle_termination_command) {
173
+ return denyPreToolUse("blocked by SuperSpec hook policy: hook session cancellation requires trusted terminal authority");
174
+ }
175
+ if (intent.reasons.some((item) => item.code === "target_path_outside_repo")) {
176
+ return denyPreToolUse("blocked by SuperSpec hook policy: write target escapes the current repository root");
177
+ }
178
+ if (intent.reasons.some((item) => item.code === "protected_trust_root_link_source")) {
179
+ return denyPreToolUse("blocked by SuperSpec hook policy: link source points at a SuperSpec trust root");
180
+ }
181
+ if (intent.reasons.some((item) => item.code === "shell_path_may_touch_trust_root")) {
182
+ return denyPreToolUse("blocked by SuperSpec hook policy: shell-expanded path may touch SuperSpec trust roots");
183
+ }
184
+ if (intent.reasons.some((item) => item.code === "curl_config_write_target_unknown")) {
185
+ return denyPreToolUse("blocked by SuperSpec hook policy: curl config-driven writes have unknown targets");
186
+ }
187
+ if (intent.reasons.some((item) => item.code === "curl_write_target_unknown")) {
188
+ return denyPreToolUse("blocked by SuperSpec hook policy: curl write options have unknown targets");
189
+ }
190
+ if (intent.target_paths.some((path) => isSuperSpecTrustRootPath(path))) {
191
+ return denyPreToolUse("blocked by SuperSpec hook policy: direct writes to SuperSpec trust roots require an active Guard context");
192
+ }
193
+ if (intent.shell_write_command && intent.trust_root_text_matches.length > 0) {
194
+ return denyPreToolUse("blocked by SuperSpec hook policy: pathless write-capable command appears to touch SuperSpec trust roots");
195
+ }
196
+ if (intent.archive_command) {
197
+ return denyPreToolUse("blocked by SuperSpec hook policy: openspec archive requires an active Guard context and archive_ready allow");
198
+ }
199
+ return passThrough("SuperSpec hook inert: SUPERSPEC_CHANGE/--change not set");
200
+ }
201
+ function preToolUseOutput(decision) {
202
+ if (!decision.allowed) {
203
+ return {
204
+ hookSpecificOutput: {
205
+ hookEventName: "PreToolUse",
206
+ permissionDecision: "deny",
207
+ permissionDecisionReason: decision.block_reasons.map((item) => item.message).join("; ") || "blocked by SuperSpec hook policy",
208
+ },
209
+ };
210
+ }
211
+ return {
212
+ hookSpecificOutput: {
213
+ hookEventName: "PreToolUse",
214
+ additionalContext: `SuperSpec hook ${decision.enforcement}; strict_profile=${decision.strict_profile}; trust=${decision.trust}`,
215
+ },
216
+ };
217
+ }
218
+ function postToolUseOutput(decision) {
219
+ return {
220
+ systemMessage: `SuperSpec hook telemetry recorded as ${decision.trust}; strict_profile=${decision.strict_profile}`,
221
+ hookSpecificOutput: {
222
+ hookEventName: "PostToolUse",
223
+ additionalContext: `SuperSpec runtime evidence is ${decision.strict_profile === "available" ? "runtime-verified" : "audit-only"}.`,
224
+ },
225
+ };
226
+ }
227
+ function subagentOutput(decision, eventName) {
228
+ return {
229
+ systemMessage: `SuperSpec ${eventName} runlog recorded as ${decision.trust}; strict_profile=${decision.strict_profile}`,
230
+ };
231
+ }
232
+ function writeTempEvent(event) {
233
+ const path = join(tmpdir(), `superspec-hook-event-${randomUUID()}.json`);
234
+ writeFileSync(path, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
235
+ return path;
236
+ }
237
+ function validateHookEvent(event) {
238
+ const eventName = typeof event.hook_event_name === "string" ? event.hook_event_name : "";
239
+ if (!eventName)
240
+ return "hook event missing required hook_event_name";
241
+ if (!["PreToolUse", "PostToolUse", "SubagentStart", "SubagentStop"].includes(eventName)) {
242
+ return `unsupported hook_event_name: ${eventName}`;
243
+ }
244
+ if (typeof event.cwd !== "string" || !event.cwd)
245
+ return "hook event missing required cwd";
246
+ if (eventName === "PreToolUse") {
247
+ if (typeof event.tool_name !== "string" || !event.tool_name)
248
+ return "PreToolUse hook event missing required tool_name";
249
+ if (!isObject(event.tool_input))
250
+ return "PreToolUse hook event missing required tool_input object";
251
+ }
252
+ return null;
253
+ }
254
+ export function runHookAdapter(argv = process.argv.slice(2), stdin = readFileSync(0, "utf8")) {
255
+ const parsedArgs = parseArgs(argv);
256
+ let event;
257
+ try {
258
+ if (!stdin.trim())
259
+ throw new Error("hook stdin is empty");
260
+ const parsed = JSON.parse(stdin);
261
+ if (!isObject(parsed))
262
+ throw new Error("hook stdin must be a JSON object");
263
+ event = parsed;
264
+ const validationError = validateHookEvent(event);
265
+ if (validationError)
266
+ throw new Error(validationError);
267
+ }
268
+ catch (err) {
269
+ return { code: 2, stdout: "", stderr: `SuperSpec hook event parse failed: ${err.message}\n` };
270
+ }
271
+ const inference = parsedArgs.change ? { state: "unique", change: parsedArgs.change } : inferChangeFromActiveSessions(event);
272
+ const change = inference.state === "unique" ? inference.change : null;
273
+ if (!change) {
274
+ const payload = noChangeFallback(event, inference);
275
+ return { code: 0, stdout: `${JSON.stringify(payload)}\n`, stderr: "" };
276
+ }
277
+ const eventRef = writeTempEvent(event);
278
+ try {
279
+ const eventName = String(event.hook_event_name ?? "");
280
+ let payload;
281
+ if (eventName === "PreToolUse") {
282
+ const toolName = String(event.tool_name ?? "");
283
+ const decision = toolName === "Bash" ? hookCheckCommand(change, eventRef) : hookCheckWrite(change, eventRef);
284
+ payload = preToolUseOutput(decision);
285
+ }
286
+ else if (eventName === "PostToolUse") {
287
+ payload = isTestValidationCommand(event) ? postToolUseOutput(hookRecordTest(change, eventRef)) : inertPostToolUseOutput();
288
+ }
289
+ else if (eventName === "SubagentStart") {
290
+ payload = subagentOutput(hookRecordSubagentStart(change, eventRef), "SubagentStart");
291
+ }
292
+ else if (eventName === "SubagentStop") {
293
+ payload = subagentOutput(hookRecordSubagentStop(change, eventRef), "SubagentStop");
294
+ }
295
+ else {
296
+ payload = { systemMessage: `SuperSpec hook ignored unsupported event ${eventName}` };
297
+ }
298
+ return { code: 0, stdout: `${JSON.stringify(payload)}\n`, stderr: "" };
299
+ }
300
+ catch (err) {
301
+ return { code: 2, stdout: "", stderr: `SuperSpec hook failed closed: ${err.message}\n` };
302
+ }
303
+ finally {
304
+ try {
305
+ unlinkSync(resolve(eventRef));
306
+ }
307
+ catch {
308
+ // best effort temp cleanup
309
+ }
310
+ }
311
+ }
@@ -0,0 +1,12 @@
1
+ import type { Reason } from "../util.ts";
2
+ import type { HookDecision } from "./types.ts";
3
+ export declare function hookCheckWrite(change: string, eventRef: string): HookDecision;
4
+ export declare function hookCheckCommand(change: string, eventRef: string): HookDecision;
5
+ export declare function hookHealth(change: string): HookDecision;
6
+ export declare function hookSessionBegin(change: string, workflow: string, _entrypointToken: string): HookDecision;
7
+ export declare function hookSessionStatus(change: string): HookDecision;
8
+ export declare function hookSessionEnd(change: string, endReason: string, _lifecycleToken: string): HookDecision;
9
+ export declare function hookRecordTest(change: string, eventRef: string): HookDecision;
10
+ export declare function hookRecordSubagentStart(change: string, eventRef: string): HookDecision;
11
+ export declare function hookRecordSubagentStop(change: string, eventRef: string): HookDecision;
12
+ export declare function hookInitReasons(repoRoot: string): Reason[];