@peterxiaoyang/superspec 0.1.7 → 0.1.8

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.
@@ -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[];