@peterxiaoyang/superspec 0.1.6 → 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.
Files changed (34) hide show
  1. package/adapters/codex/install-map.json +5 -0
  2. package/bin/superspec-hook.js +4 -0
  3. package/dist/src/apply_worker_chain_lifecycle.d.ts +19 -0
  4. package/dist/src/apply_worker_chain_lifecycle.js +278 -0
  5. package/dist/src/archive.js +2 -0
  6. package/dist/src/cli_args.d.ts +5 -0
  7. package/dist/src/cli_args.js +41 -0
  8. package/dist/src/core.js +36 -0
  9. package/dist/src/evidence.js +3 -0
  10. package/dist/src/gates.js +8 -258
  11. package/dist/src/hooks/adapter.d.ts +5 -0
  12. package/dist/src/hooks/adapter.js +311 -0
  13. package/dist/src/hooks/guard_api.d.ts +12 -0
  14. package/dist/src/hooks/guard_api.js +797 -0
  15. package/dist/src/hooks/health.d.ts +4 -0
  16. package/dist/src/hooks/health.js +98 -0
  17. package/dist/src/hooks/policy_event.d.ts +41 -0
  18. package/dist/src/hooks/policy_event.js +2624 -0
  19. package/dist/src/hooks/types.d.ts +72 -0
  20. package/dist/src/hooks/types.js +1 -0
  21. package/dist/src/hooks/validation.d.ts +3 -0
  22. package/dist/src/hooks/validation.js +70 -0
  23. package/dist/src/packet_measure.js +67 -102
  24. package/dist/src/packet_render.js +4 -85
  25. package/dist/superspec_hook.d.ts +4 -0
  26. package/dist/superspec_hook.js +35 -0
  27. package/package.json +7 -2
  28. package/schemas/hook-event.schema.json +27 -0
  29. package/templates/hooks/codex-hooks.json +61 -0
  30. package/templates/workflow/skills/superspec-apply/SKILL.md +7 -0
  31. package/templates/workflow/skills/superspec-archive/SKILL.md +8 -0
  32. package/templates/workflow/skills/superspec-explore/SKILL.md +7 -0
  33. package/templates/workflow/skills/superspec-propose/SKILL.md +7 -0
  34. package/templates/workflow/skills/superspec-review/SKILL.md +7 -0
package/dist/src/gates.js CHANGED
@@ -3,12 +3,14 @@ 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
- import { duplicate_evidence_id_reasons, dangling_evidence_ref_reasons, find_pass, 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
+ 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
10
  import { review_disclosure_reasons } from "./disclosure.js";
10
11
  import { archive_manifest_path } from "./archive.js";
11
- import { apply_worker_implementation_fingerprint, apply_worker_executor_input_ref_digest, compute_apply_worker_freshness, fingerprint_matches, pinned_artifact_ref_reasons as shared_pinned_artifact_ref_reasons, pre_edit_evidence_ref_reasons, read_pinned_artifact_json, worker_input_ref_digest, worker_test_run_reasons, } from "./apply_worker_chain.js";
12
+ import { fingerprint_matches, } from "./apply_worker_chain.js";
13
+ import { apply_worker_chain_lifecycle_reasons as shared_apply_worker_chain_lifecycle_reasons, apply_worker_chain_lifecycle_state, serial_completion_green, } from "./apply_worker_chain_lifecycle.js";
12
14
  function action_list(...items) {
13
15
  return [...new Set(items.filter((item) => typeof item === "string" && item.length > 0))];
14
16
  }
@@ -426,8 +428,7 @@ export function check_init(change, status, repoRoot, changeRoot) {
426
428
  if (!Array.isArray(status.applyRequires) || !new Set(status.applyRequires).has("tasks")) {
427
429
  reasons.push(reason("unexpected_apply_requires", "OpenSpec applyRequires must include native tasks artifact"));
428
430
  }
429
- if (existsSync(join(repoRoot, ".codex", "hooks.json")))
430
- reasons.push(reason("v1_hook_artifact_present", ".codex/hooks.json belongs to superspec v2"));
431
+ reasons.push(...hookInitReasons(repoRoot));
431
432
  if (existsSync(join(repoRoot, "openspec", "schemas", "superspec")))
432
433
  reasons.push(reason("custom_superspec_schema_present", "openspec/schemas/superspec is not part of superspec v1 overlay"));
433
434
  if (reasons.length > 0)
@@ -1145,259 +1146,8 @@ export function check_task_edit(change, status, changeRoot, evidences, taskId) {
1145
1146
  }
1146
1147
  return allow(change, gate, { task_id: taskId });
1147
1148
  }
1148
- function pinned_artifact_ref_matches(changeRoot, refItem, role, taskId, chainId, kind = "worker_report") {
1149
- return shared_pinned_artifact_ref_reasons(changeRoot, refItem, `${role}_ref`, { kind, role, taskId, chainId }).length === 0;
1150
- }
1151
- function executor_worker_test_run_refs_valid(changeRoot, ev, taskId, chainId) {
1152
- return worker_test_run_reasons(changeRoot, ev, taskId, chainId).length === 0;
1153
- }
1154
- function report_origin_matches(refItem, expected) {
1155
- return isObject(refItem) && typeof expected === "string" && refItem.origin_packet_fingerprint === expected;
1156
- }
1157
- function report_input_refs_match(refItem, refs) {
1158
- return isObject(refItem) && refItem.input_ref_digest === worker_input_ref_digest(refs);
1159
- }
1160
- function report_input_digest_matches(refItem, expectedDigest) {
1161
- return isObject(refItem) && refItem.input_ref_digest === expectedDigest;
1162
- }
1163
- function chain_green_ids(ev) {
1164
- if (Array.isArray(ev.green_test_run_evidence_refs)) {
1165
- return ev.green_test_run_evidence_refs.map(String).filter(Boolean).sort();
1166
- }
1167
- const greenId = isObject(ev.green_test_run_evidence_ref)
1168
- ? String(ev.green_test_run_evidence_ref.evidence_id ?? "")
1169
- : String(ev.green_test_run_evidence_ref ?? "");
1170
- return greenId ? [greenId] : [];
1171
- }
1172
- function active_pre_edit_input_refs(evidences, active) {
1173
- const preIds = Array.isArray(active.pre_edit_evidence_refs) ? active.pre_edit_evidence_refs.map(String) : [];
1174
- return preIds.map((evidenceId) => {
1175
- const ev = live_pass(evidences, { kind: "test_run" }).find((item) => String(item.evidence_id ?? "") === evidenceId);
1176
- return ev
1177
- ? { kind: "test_run", evidence_id: evidenceId, phase: ev.phase, semantic_status: ev.semantic_status }
1178
- : { kind: "test_run", evidence_id: evidenceId };
1179
- });
1180
- }
1181
- function closed_apply_worker_chain_valid(repoRoot, changeRoot, ev, evidences, taskId, chainId) {
1182
- const active = live_pass(evidences, { gate: "task_complete", kind: "apply_worker_chain", task_id: taskId })
1183
- .find((item) => item.chain_state === "active" && item.apply_worker_chain_id === chainId);
1184
- if (!active)
1185
- return false;
1186
- if (pre_edit_evidence_ref_reasons(evidences, active, taskId).length > 0)
1187
- return false;
1188
- if (!pinned_artifact_ref_matches(changeRoot, ev.executor_report_ref, "executor", taskId, chainId))
1189
- return false;
1190
- if (!pinned_artifact_ref_matches(changeRoot, ev.task_code_review_report_ref, "code-reviewer", taskId, chainId))
1191
- return false;
1192
- if (!pinned_artifact_ref_matches(changeRoot, ev.verifier_report_ref, "verifier", taskId, chainId))
1193
- return false;
1194
- if (!report_origin_matches(ev.executor_report_ref, active.executor_packet_fingerprint))
1195
- return false;
1196
- if (!report_input_digest_matches(ev.executor_report_ref, apply_worker_executor_input_ref_digest(evidences, active)))
1197
- return false;
1198
- if (!report_input_refs_match(ev.task_code_review_report_ref, [ev.executor_report_ref]))
1199
- return false;
1200
- if (typeof ev.observed_freshness_fingerprint !== "string" && !(isObject(ev.observed_freshness_fingerprint) && typeof ev.observed_freshness_fingerprint.fingerprint_digest === "string"))
1201
- return false;
1202
- const verifierReport = read_pinned_artifact_json(changeRoot, ev.verifier_report_ref);
1203
- const verifierObserved = verifierReport?.observed_freshness_fingerprint ?? (isObject(ev.verifier_report_ref) ? ev.verifier_report_ref.observed_freshness_fingerprint : undefined);
1204
- if (!fingerprint_matches(verifierObserved, ev.observed_freshness_fingerprint))
1205
- return false;
1206
- const greenIds = chain_green_ids(ev);
1207
- if (greenIds.length === 0)
1208
- return false;
1209
- const greenRuns = greenIds.map((greenId) => live_pass(evidences, { gate: "task_complete", kind: "test_run", task_id: taskId }).find((item) => (String(item.evidence_id ?? "") === greenId
1210
- && item.semantic_status === "expected_success"
1211
- && (item.phase === undefined || item.phase === "green")
1212
- && item.apply_execution_chain === "executor_worker"
1213
- && item.apply_worker_chain_id === chainId
1214
- && executor_worker_test_run_refs_valid(changeRoot, item, taskId, chainId)))).filter((item) => isObject(item));
1215
- if (greenRuns.length !== greenIds.length)
1216
- return false;
1217
- if (!report_input_refs_match(ev.verifier_report_ref, [
1218
- ev.executor_report_ref,
1219
- ev.task_code_review_report_ref,
1220
- ...greenRuns.map((green) => ({ kind: "test_run", evidence_id: String(green.evidence_id ?? ""), phase: green.phase, semantic_status: green.semantic_status })),
1221
- ...active_pre_edit_input_refs(evidences, active),
1222
- ]))
1223
- return false;
1224
- const currentFreshness = compute_apply_worker_freshness(repoRoot, changeRoot, evidences, taskId, chainId, {
1225
- executor_report_ref: ev.executor_report_ref,
1226
- task_code_review_report_ref: ev.task_code_review_report_ref,
1227
- green_test_run_evidence_ref: greenIds[0],
1228
- green_test_run_evidence_refs: greenIds,
1229
- verifier_report_ref: ev.verifier_report_ref,
1230
- });
1231
- if (Array.isArray(currentFreshness.unexpected_guard_owned_dirty_paths) && currentFreshness.unexpected_guard_owned_dirty_paths.length > 0)
1232
- return false;
1233
- if (Array.isArray(currentFreshness.protected_dirty_paths) && currentFreshness.protected_dirty_paths.length > 0)
1234
- return false;
1235
- if (!greenRuns.every((green) => fingerprint_matches(green.implementation_fingerprint, currentFreshness.implementation_fingerprint)))
1236
- return false;
1237
- if (!fingerprint_matches(isObject(ev.task_code_review_report_ref) ? ev.task_code_review_report_ref.observed_implementation_fingerprint : undefined, currentFreshness.implementation_fingerprint))
1238
- return false;
1239
- return fingerprint_matches(currentFreshness, verifierObserved);
1240
- }
1241
- function pinned_takeover_baseline_valid(changeRoot, refItem, taskId, chainId) {
1242
- return shared_pinned_artifact_ref_reasons(changeRoot, refItem, "serial_takeover_baseline_ref", { kind: "status_report", role: "verifier", taskId, chainId }).length === 0;
1243
- }
1244
- function abandoned_apply_worker_chain_valid(repoRoot, changeRoot, ev, active, taskId, chainId) {
1245
- if (!active)
1246
- return false;
1247
- if ("restored_implementation_fingerprint" in ev) {
1248
- const activeScope = Array.isArray(active.declared_task_write_scope) ? active.declared_task_write_scope.map(String).filter(Boolean) : [];
1249
- const current = apply_worker_implementation_fingerprint(repoRoot, changeRoot, [], { declaredTaskWriteScope: activeScope });
1250
- if (fingerprint_matches(ev.restored_implementation_fingerprint, active.source_implementation_fingerprint)
1251
- && fingerprint_matches(current, active.source_implementation_fingerprint))
1252
- return true;
1253
- }
1254
- if ("serial_takeover_baseline_ref" in ev) {
1255
- return pinned_takeover_baseline_valid(changeRoot, ev.serial_takeover_baseline_ref, taskId, chainId)
1256
- && Array.isArray(ev.successor_green_evidence_refs)
1257
- && ev.successor_green_evidence_refs.length > 0
1258
- && ev.successor_green_evidence_refs.every((item) => typeof item === "string" && item.length > 0);
1259
- }
1260
- return false;
1261
- }
1262
- function serial_completion_green(ev) {
1263
- return (ev.runner_origin === undefined || ev.runner_origin === "main-thread")
1264
- && ev.apply_execution_chain !== "executor_worker"
1265
- && typeof ev.apply_worker_chain_id !== "string";
1266
- }
1267
- function apply_worker_chain_completion_state(repoRoot, changeRoot, evidences, taskId) {
1268
- const chains = find_pass(evidences, { gate: "task_complete", kind: "apply_worker_chain", task_id: taskId });
1269
- const activeByChain = new Map();
1270
- const activeCounts = new Map();
1271
- for (const ev of chains) {
1272
- if (String(ev.chain_state ?? "") !== "active")
1273
- continue;
1274
- const chainId = String(ev.apply_worker_chain_id ?? "");
1275
- if (!chainId)
1276
- continue;
1277
- const bucket = activeCounts.get(chainId) ?? [];
1278
- bucket.push(ev);
1279
- activeCounts.set(chainId, bucket);
1280
- if (!activeByChain.has(chainId))
1281
- activeByChain.set(chainId, ev);
1282
- }
1283
- const chainIdsWithActive = new Set([...activeByChain.keys()]);
1284
- const chainIdsWithTerminal = new Set(chains
1285
- .filter((ev) => {
1286
- const chainId = String(ev.apply_worker_chain_id ?? "");
1287
- if (!chainId)
1288
- return false;
1289
- if (String(ev.chain_state ?? "") === "closed")
1290
- return closed_apply_worker_chain_valid(repoRoot, changeRoot, ev, evidences, taskId, chainId);
1291
- if (String(ev.chain_state ?? "") === "abandoned")
1292
- return abandoned_apply_worker_chain_valid(repoRoot, changeRoot, ev, activeByChain.get(chainId), taskId, chainId);
1293
- return false;
1294
- })
1295
- .map((ev) => String(ev.apply_worker_chain_id ?? ""))
1296
- .filter(Boolean));
1297
- const chainIdsWithOpenActive = new Set([...activeByChain.keys()].filter((chainId) => !chainIdsWithTerminal.has(chainId)));
1298
- const validClosedChainIds = new Set(chains
1299
- .filter((ev) => {
1300
- const chainId = String(ev.apply_worker_chain_id ?? "");
1301
- return chainId
1302
- && String(ev.chain_state ?? "") === "closed"
1303
- && closed_apply_worker_chain_valid(repoRoot, changeRoot, ev, evidences, taskId, chainId);
1304
- })
1305
- .map((ev) => String(ev.apply_worker_chain_id ?? ""))
1306
- .filter(Boolean));
1307
- const validClosedGreenEvidenceIds = new Set(chains
1308
- .filter((ev) => {
1309
- const chainId = String(ev.apply_worker_chain_id ?? "");
1310
- return chainId
1311
- && String(ev.chain_state ?? "") === "closed"
1312
- && closed_apply_worker_chain_valid(repoRoot, changeRoot, ev, evidences, taskId, chainId);
1313
- })
1314
- .flatMap(chain_green_ids));
1315
- const takeoverBaselines = [];
1316
- const reasons = [];
1317
- const duplicateActives = [...activeCounts.entries()]
1318
- .filter(([chainId]) => chainIdsWithOpenActive.has(chainId))
1319
- .map(([, items]) => items)
1320
- .filter((items) => items.length > 1)
1321
- .flatMap((items) => items.map((ev) => String(ev.evidence_id ?? ev.apply_worker_chain_id ?? "")))
1322
- .filter(Boolean)
1323
- .sort();
1324
- const openActiveValues = [...chainIdsWithOpenActive].map((chainId) => activeByChain.get(chainId)).filter((ev) => isObject(ev));
1325
- const parallelActives = openActiveValues.length > 1
1326
- ? openActiveValues.map((ev) => String(ev.evidence_id ?? ev.apply_worker_chain_id ?? "")).filter(Boolean).sort()
1327
- : [];
1328
- const activeConflicts = [...new Set([...duplicateActives, ...parallelActives])].sort();
1329
- if (activeConflicts.length > 0) {
1330
- reasons.push(reason("apply_worker_chain_active_conflict", `task ${taskId} has conflicting active apply worker chain markers: ${renderList(activeConflicts)}`, activeConflicts));
1331
- }
1332
- const terminalsByChain = new Map();
1333
- for (const ev of chains) {
1334
- const state = String(ev.chain_state ?? "");
1335
- if (state !== "closed" && state !== "abandoned")
1336
- continue;
1337
- const chainId = String(ev.apply_worker_chain_id ?? "");
1338
- if (!chainId)
1339
- continue;
1340
- const bucket = terminalsByChain.get(chainId) ?? [];
1341
- bucket.push(ev);
1342
- terminalsByChain.set(chainId, bucket);
1343
- }
1344
- const duplicateTerminals = [...terminalsByChain.values()]
1345
- .filter((items) => items.length > 1)
1346
- .flatMap((items) => items.map((ev) => String(ev.evidence_id ?? ev.apply_worker_chain_id ?? "")))
1347
- .filter(Boolean)
1348
- .sort();
1349
- if (duplicateTerminals.length > 0) {
1350
- reasons.push(reason("apply_worker_chain_terminal_conflict", `task ${taskId} has duplicate apply worker chain terminal markers: ${renderList(duplicateTerminals)}`, duplicateTerminals));
1351
- }
1352
- const invalidTerminals = chains
1353
- .filter((ev) => String(ev.chain_state ?? "") === "closed" || String(ev.chain_state ?? "") === "abandoned")
1354
- .filter((ev) => {
1355
- const chainId = String(ev.apply_worker_chain_id ?? "");
1356
- if (!chainId)
1357
- return true;
1358
- if (String(ev.chain_state ?? "") === "closed")
1359
- return !closed_apply_worker_chain_valid(repoRoot, changeRoot, ev, evidences, taskId, chainId);
1360
- const valid = abandoned_apply_worker_chain_valid(repoRoot, changeRoot, ev, activeByChain.get(chainId), taskId, chainId);
1361
- if (valid && isObject(ev.serial_takeover_baseline_ref)) {
1362
- const baseline = read_pinned_artifact_json(changeRoot, ev.serial_takeover_baseline_ref);
1363
- if (baseline?.implementation_fingerprint !== undefined) {
1364
- takeoverBaselines.push({
1365
- fingerprint: baseline.implementation_fingerprint,
1366
- successorGreenRefs: Array.isArray(ev.successor_green_evidence_refs) ? ev.successor_green_evidence_refs.map(String).filter(Boolean) : [],
1367
- });
1368
- }
1369
- }
1370
- return !valid;
1371
- })
1372
- .map((ev) => String(ev.evidence_id ?? ev.apply_worker_chain_id ?? ""))
1373
- .filter(Boolean)
1374
- .sort();
1375
- if (invalidTerminals.length > 0) {
1376
- reasons.push(reason("apply_worker_chain_terminal_invalid", `task ${taskId} has invalid apply worker chain terminal markers: ${renderList(invalidTerminals)}`, invalidTerminals));
1377
- }
1378
- for (const chainId of [...chainIdsWithOpenActive].sort()) {
1379
- if (!chainIdsWithTerminal.has(chainId)) {
1380
- reasons.push(reason("apply_worker_chain_active", `task ${taskId} has active apply worker chain and cannot use non-chain task completion: ${chainId}`, [chainId]));
1381
- }
1382
- }
1383
- for (const ev of evidences) {
1384
- if (!isObject(ev) || ev._invalid || ev.status !== "pass" || ev.task_id !== taskId)
1385
- continue;
1386
- const chainId = typeof ev.apply_worker_chain_id === "string" ? ev.apply_worker_chain_id : "";
1387
- if (!chainId)
1388
- continue;
1389
- if (!chainIdsWithActive.has(chainId)) {
1390
- reasons.push(reason("apply_worker_chain_missing_active", `task ${taskId} references apply_worker_chain_id=${repr(chainId)} without a matching active marker`, [chainId]));
1391
- }
1392
- }
1393
- if (duplicateTerminals.length > 0 || activeConflicts.length > 0) {
1394
- validClosedChainIds.clear();
1395
- validClosedGreenEvidenceIds.clear();
1396
- }
1397
- return { reasons, validClosedChainIds, validClosedGreenEvidenceIds, takeoverBaselines };
1398
- }
1399
1149
  export function apply_worker_chain_lifecycle_reasons(repoRoot, changeRoot, evidences, taskId) {
1400
- return apply_worker_chain_completion_state(repoRoot, changeRoot, evidences, taskId).reasons;
1150
+ return shared_apply_worker_chain_lifecycle_reasons(repoRoot, changeRoot, evidences, taskId);
1401
1151
  }
1402
1152
  export function check_task_complete(change, status, changeRoot, evidences, taskId) {
1403
1153
  const gate = "task_complete";
@@ -1413,8 +1163,8 @@ export function check_task_complete(change, status, changeRoot, evidences, taskI
1413
1163
  const task = parse_tasks(changeRoot)[taskId];
1414
1164
  if (!task)
1415
1165
  return block(change, gate, [reason("unknown_task", `task ${taskId} not found`)], { task_id: taskId });
1416
- const workerChainState = apply_worker_chain_completion_state(get_repo_root(status), changeRoot, evidences, taskId);
1417
- reasons.push(...workerChainState.reasons);
1166
+ const workerChainState = apply_worker_chain_lifecycle_state(get_repo_root(status), changeRoot, evidences, taskId);
1167
+ reasons.push(...workerChainState.completionReasons);
1418
1168
  const reopenHistory = task_reopen_history(evidences, taskId);
1419
1169
  if (task.checked && reopenHistory.pass.length > 1) {
1420
1170
  reasons.push(reason("reopen_lifecycle_exhausted", `task ${taskId} has multiple task_reopen histories in this change; v1 allows at most one`, [taskId]));
@@ -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[];