@try-works/dsh-recursive-mode 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,134 @@
1
+ /**
2
+ * guard-log.ts — T15: the rolling enforcement evidence trace.
3
+ *
4
+ * WHY A FILE, and not one of the two obvious alternatives (plan §4.0):
5
+ * - NOT `ctx.session` events: the plugin is ZERO-EMISSION by contract
6
+ * (tests/no-emission.spec.ts). Appending session events to record that a
7
+ * guard fired would break the plugin's central invariant.
8
+ * - NOT `ctx.storageDomain`: this is evidence a human reads (why was this lock
9
+ * refused?) and it must survive a restart and an out-of-band `cat`. It is
10
+ * append-only, bounded, git-ignored, and derived-from-nothing — the
11
+ * control-plane config dir is exactly where the plan puts it.
12
+ *
13
+ * Every function here is BEST-EFFORT and never throws: the writers run inside a
14
+ * `tools/pre-execute` guard and inside the synchronous, observe-only
15
+ * `fs/observed` event, whose contract explicitly forbids throwing. A failed log
16
+ * write must never change an enforcement verdict or break a session.
17
+ *
18
+ * The log is BOUNDED: once it exceeds GUARD_LOG_MAX_RECORDS it is rewritten with
19
+ * only the newest records. A bounded log is the whole posture — evidence that
20
+ * cannot grow without bound.
21
+ */
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import type { GuardRule } from './enforcement.ts'
25
+
26
+ /**
27
+ * One logged guard decision (the JSONL record shape the board/tests read).
28
+ * `rule` is always set (the guard's own machine-readable reason for the
29
+ * verdict); `transition` is present whenever the transition gate was consulted.
30
+ */
31
+ export interface GuardDecisionRecord {
32
+ at: string
33
+ runId: string
34
+ tool: string
35
+ kind: 'allow' | 'deny' | 'ask'
36
+ rule: GuardRule
37
+ reason?: string
38
+ transition?: { passed: boolean; failures: string[] }
39
+ }
40
+
41
+ /** One logged observed-write tamper (a LOCKED artifact whose hash no longer matches). */
42
+ export interface ObservedTamperRecord {
43
+ at: string
44
+ runId: string
45
+ path: string
46
+ reason: string
47
+ }
48
+
49
+ /** Newest-N retention cap for both logs (rewrite-on-exceed, never unbounded). */
50
+ export const GUARD_LOG_MAX_RECORDS = 500
51
+
52
+ /** The rolling decision log path under the control-plane root. */
53
+ export function guardDecisionLogPath(root: string): string {
54
+ return join(root, '.recursive', 'config', 'guard-decisions.jsonl')
55
+ }
56
+
57
+ /** The rolling observed-tamper log path under the control-plane root. */
58
+ export function observedTamperLogPath(root: string): string {
59
+ return join(root, '.recursive', 'config', 'observed-tampers.jsonl')
60
+ }
61
+
62
+ /** Parse a JSONL file, skipping torn/partial/failed lines. Never throws. */
63
+ function readRecords<T>(path: string): T[] {
64
+ try {
65
+ if (!existsSync(path)) return []
66
+ const out: T[] = []
67
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
68
+ const trimmed = line.trim()
69
+ if (!trimmed) continue
70
+ try {
71
+ out.push(JSON.parse(trimmed) as T)
72
+ } catch {
73
+ // A torn final line (concurrent writer / partial flush) is skipped, not fatal.
74
+ }
75
+ }
76
+ return out
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The newest `limit` records, NEWEST FIRST — `[0]` is the most recent record.
84
+ * The file is appended oldest→newest, so a read takes the tail and reverses it.
85
+ * This ordering is load-bearing for callers that read `[0]` as "the last thing
86
+ * the guard decided".
87
+ */
88
+ function readNewest<T>(path: string, limit: number): T[] {
89
+ const all = readRecords<T>(path)
90
+ const size = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : all.length
91
+ return all.slice(-size).reverse()
92
+ }
93
+
94
+ /**
95
+ * Append one record, creating the config dir as needed, then trim the file to
96
+ * the newest GUARD_LOG_MAX_RECORDS when it has grown past the cap.
97
+ */
98
+ function appendRecord(path: string, record: unknown): void {
99
+ try {
100
+ mkdirSync(dirname(path), { recursive: true })
101
+ appendFileSync(path, JSON.stringify(record) + '\n', 'utf8')
102
+ const total = readRecords<unknown>(path).length
103
+ if (total > GUARD_LOG_MAX_RECORDS) {
104
+ const kept = readRecords<unknown>(path).slice(-GUARD_LOG_MAX_RECORDS)
105
+ writeFileSync(path, kept.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8')
106
+ }
107
+ } catch {
108
+ // Best-effort evidence only: never throw into a guard or an observe-only event.
109
+ }
110
+ }
111
+
112
+ /** Log one guard decision (allows included — the trace shows what, and why). */
113
+ export function appendGuardDecision(root: string, record: GuardDecisionRecord): void {
114
+ if (!root || root.trim() === '') return
115
+ appendRecord(guardDecisionLogPath(root), record)
116
+ }
117
+
118
+ /** Log one observed-write tamper. */
119
+ export function appendObservedTamper(root: string, record: ObservedTamperRecord): void {
120
+ if (!root || root.trim() === '') return
121
+ appendRecord(observedTamperLogPath(root), record)
122
+ }
123
+
124
+ /** The newest `limit` guard decisions for `root`, newest first. Never throws. */
125
+ export function readGuardDecisions(root: string, limit = 20): GuardDecisionRecord[] {
126
+ if (!root || root.trim() === '') return []
127
+ return readNewest<GuardDecisionRecord>(guardDecisionLogPath(root), limit)
128
+ }
129
+
130
+ /** The newest `limit` observed tampers for `root`, newest first. Never throws. */
131
+ export function readObservedTampers(root: string, limit = 20): ObservedTamperRecord[] {
132
+ if (!root || root.trim() === '') return []
133
+ return readNewest<ObservedTamperRecord>(observedTamperLogPath(root), limit)
134
+ }
package/src/handoff.ts CHANGED
@@ -134,6 +134,68 @@ export function replyPath(input: { root: string; runId: string; delegationId: st
134
134
  return join(subagentsDir(root, runId), delegationId, 'child-' + childId, 'reply.md')
135
135
  }
136
136
 
137
+ /**
138
+ * ⚠ FU-17 — THE WORK BRIEF'S SLICE: what a child is told when it is delegated the phase's ACTUAL WORK rather
139
+ * than a review of it.
140
+ *
141
+ * WHY THIS IS SEPARATE FROM THE REVIEWER'S SLICE, and the reason is not tidiness. A reviewer is told what to
142
+ * look FOR — anti-patterns, a verdict vocabulary, "do not be satisfied by prose". A worker must be told what to
143
+ * PRODUCE, and above all **the standard its output will be judged against**, because the phase artifact is
144
+ * linted for required sections and a child that was never told them cannot meet them. That standard already
145
+ * exists in one place (`getArtifactRequiredSections`), so this composes it rather than restating it — a second
146
+ * copy would drift from the linter, which is the defect this project has fixed more than once.
147
+ *
148
+ * ⚠ IT ALSO TELLS THE CHILD WHO DECIDES. The main agent verifies and records what it accepted
149
+ * (`## Subagent Contribution Verification`: reviewed action records, main-agent verification performed, an
150
+ * acceptance decision, refresh handling, repair performed). Saying so up front is not politeness: a child that
151
+ * believes its own output is final writes a different, worse submission than one that knows a parent will
152
+ * check it against named sections.
153
+ */
154
+ export function buildWorkSlice(input: {
155
+ /** The main agent's task for this child, verbatim. */
156
+ instruction: string
157
+ /** The run-relative artifact the work contributes to, e.g. `03-implementation-summary.md`. */
158
+ artifactFile: string
159
+ /** The phase key, for the brief's own traceability back to the run. */
160
+ phase: string
161
+ /** Required sections for that artifact — pass `getArtifactRequiredSections(artifactFile, profile)`. */
162
+ requiredSections: readonly string[]
163
+ /** Optional: the phase's lint rules, so the child sees the gate fields too. */
164
+ lintNotes?: readonly string[]
165
+ }): string {
166
+ const sections = input.requiredSections.length > 0
167
+ ? input.requiredSections.map((section) => '- ' + section).join('\n')
168
+ : '- (no section map for this artifact — follow the existing artifact\'s own shape)'
169
+ const lines: string[] = [
170
+ 'You are doing the WORK for phase `' + input.phase + '`, not reviewing it.',
171
+ '',
172
+ '## Your Task',
173
+ '',
174
+ input.instruction.trim(),
175
+ '',
176
+ '## What You Are Contributing To',
177
+ '',
178
+ '`' + input.artifactFile + '` — the phase artifact. Write the content INTO your `reply.md` submission; the',
179
+ 'main agent is the author of record for the artifact itself and will place your work there.',
180
+ '',
181
+ '## The Standard It Will Be Judged Against',
182
+ '',
183
+ 'The artifact must contain these sections. Yours must supply the ones your task touches, in this vocabulary:',
184
+ '',
185
+ sections,
186
+ '',
187
+ '## How This Will Be Checked',
188
+ '',
189
+ 'An INDEPENDENT reviewer and the MAIN agent both judge your submission against the sections above. If it falls',
190
+ 'short, feedback comes back to YOU — the same child — so state your assumptions and your evidence plainly',
191
+ 'rather than leaving a gap someone else has to guess at.',
192
+ ]
193
+ if (input.lintNotes !== undefined && input.lintNotes.length > 0) {
194
+ lines.push('', '## Phase Gates', '', input.lintNotes.map((note) => '- ' + note).join('\n'))
195
+ }
196
+ return lines.join('\n')
197
+ }
198
+
137
199
  /** Child-scoped disposable scratch (Phase B R5, PROPOSAL 10.7). */
138
200
  export function childScratchPath(input: { root: string; runId: string; childId: string }): string {
139
201
  const { root, runId, childId } = input
package/src/hooks.ts ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * T27 — a hook registry: named points, an ordered chain, and declarative failure
3
+ * behaviour.
4
+ *
5
+ * WHY. Enforcement is currently two listeners inlined in `apply()`: no ordering, no
6
+ * timeout, no failure policy, and no way for a SIBLING plugin to participate without
7
+ * patching this one. The harness's version is the most reusable idea in the audit —
8
+ * named points, a chain ordered by `priority`, a `continue | deny | hold` decision,
9
+ * per-binding `timeout_ms`, and `on_error` defaults that differ by the KIND of point —
10
+ * with *hook logic lives in the sibling, never the harness* as the governing rule.
11
+ *
12
+ * THE FIVE POINTS map onto DSH seams:
13
+ * `pre_turn` → `agent/pre-step`, before `next()`
14
+ * `pre_generate` → the policy section callback
15
+ * `post_generate` → a post-step listener — **OBSERVE ONLY** (see below)
16
+ * `pre_trigger` → `tools/pre-execute`
17
+ * `post_trigger` → `tools/post-execute`
18
+ *
19
+ * ⚠ OBSERVE-ONLY POINTS CANNOT DENY, and the registry ENFORCES that rather than
20
+ * documenting it. `post_generate` and `post_trigger` run after the model message has
21
+ * already streamed, so a "deny" there cannot un-send it — it would be a veto that
22
+ * vetoes nothing. A deny from an observing point is therefore downgraded to
23
+ * `continue` and REPORTED as an annotation, so the attempt is visible on the audit
24
+ * trail instead of silently ignored or silently obeyed.
25
+ *
26
+ * ⚠ FAILURE DEFAULTS DIFFER BY POINT, because the right answer does. A GATING point
27
+ * that cannot decide must not proceed — `fail_closed`. An OBSERVING point that fails
28
+ * must not break the turn it was only watching — `fail_open`. Getting this backwards
29
+ * in either direction is the classic hook bug: one bricks the run, the other hides
30
+ * the failure.
31
+ *
32
+ * ⚠ HOOKS RUN ON AT-LEAST-ONCE PATHS AND MUST BE IDEMPOTENT. The same point can be
33
+ * reached more than once for one logical step (a retry, a resumed turn), so a hook
34
+ * that appends, increments or writes is a hook that eventually double-counts. The
35
+ * chain reports every hook it ran, so a caller can SEE repeats rather than guess.
36
+ *
37
+ * ⚠ HOOK INPUT IS UNTRUSTED, and MUTATIONS ARE SILENT. A hook receives data derived
38
+ * from a model turn; it must not be trusted to be well-formed. Anything a hook changes
39
+ * about behaviour must come back as an `annotation`, because a hook that quietly
40
+ * mutates shared state leaves nothing for the next reader to find.
41
+ *
42
+ * Hooks must never START A TURN: a hook that drives the loop it is embedded in
43
+ * re-enters itself, and the chain's timeout is the only thing that would stop it.
44
+ */
45
+ import { canonicalInput } from './identity.ts'
46
+
47
+ /** The five named points, mapped onto DSH seams (see the module comment). */
48
+ export const HOOK_POINTS = ['pre_turn', 'pre_generate', 'post_generate', 'pre_trigger', 'post_trigger'] as const
49
+
50
+ export type HookPoint = (typeof HOOK_POINTS)[number]
51
+
52
+ /** What a hook decided. `hold` means "stop and wait", distinct from a refusal. */
53
+ export type HookDecision = 'continue' | 'deny' | 'hold'
54
+
55
+ /**
56
+ * Points that may STOP the work, versus points that only observe it. A deny from an
57
+ * observing point is downgraded, because the thing it would veto has already happened.
58
+ */
59
+ export const GATING_POINTS: readonly HookPoint[] = ['pre_turn', 'pre_generate', 'pre_trigger']
60
+ export const OBSERVING_POINTS: readonly HookPoint[] = ['post_generate', 'post_trigger']
61
+
62
+ export function isGating(point: HookPoint): boolean {
63
+ return GATING_POINTS.includes(point)
64
+ }
65
+
66
+ export function isObserving(point: HookPoint): boolean {
67
+ return OBSERVING_POINTS.includes(point)
68
+ }
69
+
70
+ /** What a hook returns. `void` means "no opinion", i.e. continue. */
71
+ export interface HookOutcome {
72
+ decision: HookDecision
73
+ /** Why — required in spirit for a deny or a hold, and carried verbatim. */
74
+ reason?: string
75
+ /**
76
+ * What the hook CHANGED or learned, returned rather than mutated in place. Silent
77
+ * mutation leaves the next reader nothing to find.
78
+ */
79
+ annotations?: Record<string, unknown>
80
+ }
81
+
82
+ export interface HookRunContext {
83
+ readonly point: HookPoint
84
+ /** The binding's own deadline in ms, so a hook can bail before it is cut off. */
85
+ readonly timeoutMs: number
86
+ /** Monotonic-ish clock from the registry, for a hook that wants to time itself. */
87
+ readonly now: () => number
88
+ }
89
+
90
+ export interface Hook<I = unknown> {
91
+ /** Stable name: it is what the audit trail and the board show. */
92
+ readonly name: string
93
+ /**
94
+ * Higher runs FIRST. Ties break by REGISTRATION ORDER, which is what makes the
95
+ * chain reproducible — an ordering that depends on object key order or on
96
+ * scheduling is an ordering nobody can reason about.
97
+ */
98
+ readonly priority: number
99
+ /** Per-binding deadline; falls back to the point's default. */
100
+ readonly timeoutMs?: number
101
+ /**
102
+ * What to do when this hook throws or times out. Defaults by POINT: fail_closed on
103
+ * a gating point, fail_open on an observing one.
104
+ */
105
+ readonly onError?: 'fail_closed' | 'fail_open'
106
+ readonly run: (input: I, context: HookRunContext) => HookOutcome | void | Promise<HookOutcome | void>
107
+ }
108
+
109
+ /** One hook's contribution to a chain run — the audit trail. */
110
+ export interface HookRunRecord {
111
+ name: string
112
+ decision: HookDecision
113
+ durationMs: number
114
+ /** Present when the hook failed or timed out; the chain's policy decided what next. */
115
+ error?: string
116
+ annotations?: Record<string, unknown>
117
+ /** True when the hook's own decision was overridden (an observing point denying). */
118
+ downgraded?: boolean
119
+ }
120
+
121
+ export interface HookChainResult {
122
+ point: HookPoint
123
+ decision: HookDecision
124
+ reason?: string
125
+ /** Every hook that ran, in order; hooks after a short-circuit are absent by design. */
126
+ ran: HookRunRecord[]
127
+ }
128
+
129
+ /** Registry configuration: per-point deadlines. */
130
+ export interface HookRegistryOptions {
131
+ /** Default deadline per point when a binding does not set one. */
132
+ timeoutMs?: Partial<Record<HookPoint, number>>
133
+ /** Clock injection, so a test can drive timeouts without sleeping. */
134
+ now?: () => number
135
+ }
136
+
137
+ const DEFAULT_TIMEOUT_MS = 5_000
138
+
139
+ /**
140
+ * A hook as STORED. The registry holds hooks of many input types in one chain, so the
141
+ * input is erased here and re-narrowed at `register`/`run`: `Hook<string>` is not
142
+ * assignable to `Hook<unknown>` under contravariant parameter checking, and pretending
143
+ * otherwise would need an `any`. The public API stays generic; only the shelf is erased.
144
+ */
145
+ interface StoredHook {
146
+ name: string
147
+ priority: number
148
+ timeoutMs?: number
149
+ onError?: 'fail_closed' | 'fail_open'
150
+ run: (input: unknown, context: HookRunContext) => HookOutcome | void | Promise<HookOutcome | void>
151
+ }
152
+
153
+ interface Bound {
154
+ point: HookPoint
155
+ hook: StoredHook
156
+ /** Registration sequence: the deterministic tie-break. */
157
+ seq: number
158
+ }
159
+
160
+ export interface HookRegistry {
161
+ readonly points: readonly HookPoint[]
162
+ /** Register one hook. Returns a disposer, so a sibling can withdraw cleanly. */
163
+ register<I>(point: HookPoint, hook: Hook<I>): () => void
164
+ /** Run the chain for a point. Never throws: failure policy decides the outcome. */
165
+ run<I>(point: HookPoint, input: I): Promise<HookChainResult>
166
+ /** The registered chain, in execution order — what the board would show. */
167
+ list(point?: HookPoint): Array<{ point: HookPoint; name: string; priority: number; onError: 'fail_closed' | 'fail_open'; timeoutMs: number }>
168
+ clear(point?: HookPoint): void
169
+ }
170
+
171
+ /**
172
+ * Build a registry. `now` is injectable so a timeout is testable without waiting for
173
+ * one: real deadlines in a test are a flake waiting to happen, which this codebase has
174
+ * already paid for once.
175
+ */
176
+ export function createHookRegistry(options: HookRegistryOptions = {}): HookRegistry {
177
+ const bound: Bound[] = []
178
+ let seq = 0
179
+ const now = options.now ?? (() => Date.now())
180
+
181
+ const timeoutFor = (point: HookPoint, hook: StoredHook): number => {
182
+ const own = hook.timeoutMs
183
+ if (typeof own === 'number' && own > 0) return own
184
+ return options.timeoutMs?.[point] ?? DEFAULT_TIMEOUT_MS
185
+ }
186
+
187
+ const policyFor = (point: HookPoint, hook: StoredHook): 'fail_closed' | 'fail_open' =>
188
+ hook.onError ?? (isGating(point) ? 'fail_closed' : 'fail_open')
189
+
190
+ const chainFor = (point: HookPoint): Bound[] =>
191
+ bound
192
+ .filter((entry) => entry.point === point)
193
+ // Priority DESC, then registration order ASC. Both keys are total, so the
194
+ // result cannot depend on the sort implementation or on insertion luck.
195
+ .sort((a, b) => (b.hook.priority - a.hook.priority) || (a.seq - b.seq))
196
+
197
+ async function run<I>(point: HookPoint, input: I): Promise<HookChainResult> {
198
+ const ran: HookRunRecord[] = []
199
+ for (const entry of chainFor(point)) {
200
+ const hook = entry.hook
201
+ const timeoutMs = timeoutFor(point, entry.hook)
202
+ const started = now()
203
+
204
+ let outcome: HookOutcome | void
205
+ let error: string | undefined
206
+ try {
207
+ // `Promise.resolve` so a synchronous hook and an async one take the same
208
+ // path; the timeout is a race because a hung promise cannot be cancelled.
209
+ outcome = await withTimeout(hook.run(input, { point, timeoutMs, now }), timeoutMs)
210
+ } catch (err) {
211
+ error = err instanceof Error ? err.message : String(err)
212
+ outcome = undefined
213
+ }
214
+ const durationMs = Math.max(0, now() - started)
215
+
216
+ if (error !== undefined) {
217
+ const policy = policyFor(point, entry.hook)
218
+ ran.push({ name: hook.name, decision: policy === 'fail_closed' ? 'deny' : 'continue', durationMs, error })
219
+ if (policy === 'fail_closed') {
220
+ return { point, decision: 'deny', reason: 'hook ' + hook.name + ' failed on a gating point: ' + error, ran }
221
+ }
222
+ // fail_open: the hook is SKIPPED and the chain continues — an observing hook
223
+ // must not break the turn it was only watching.
224
+ continue
225
+ }
226
+
227
+ const decision = outcome?.decision ?? 'continue'
228
+ const annotations = outcome?.annotations
229
+ const record: HookRunRecord = {
230
+ name: hook.name,
231
+ decision,
232
+ durationMs,
233
+ ...(annotations === undefined ? {} : { annotations }),
234
+ }
235
+
236
+ // OBSERVE-ONLY POINTS CANNOT DENY: the message has already streamed, so the
237
+ // override is applied HERE, recorded, and surfaced — not ignored.
238
+ if (decision !== 'continue' && isObserving(point)) {
239
+ record.decision = 'continue'
240
+ record.downgraded = true
241
+ record.annotations = { ...(annotations ?? {}), overriddenDecision: decision, overriddenReason: outcome?.reason ?? null }
242
+ ran.push(record)
243
+ continue
244
+ }
245
+
246
+ ran.push(record)
247
+ if (decision === 'deny' || decision === 'hold') {
248
+ // FIRST decisive hook short-circuits: later hooks are not run, and their
249
+ // absence from `ran` is the evidence that they were not.
250
+ return { point, decision, ...(outcome?.reason === undefined ? {} : { reason: outcome.reason }), ran }
251
+ }
252
+ }
253
+ return { point, decision: 'continue', ran }
254
+ }
255
+
256
+ return {
257
+ points: HOOK_POINTS,
258
+ register<I>(point: HookPoint, hook: Hook<I>): () => void {
259
+ if (!HOOK_POINTS.includes(point)) throw new Error('unknown hook point: ' + String(point))
260
+ if (typeof hook?.name !== 'string' || hook.name.trim() === '') {
261
+ throw new Error('a hook must have a non-empty name: it is what the audit trail shows')
262
+ }
263
+ if (!Number.isFinite(hook.priority)) {
264
+ throw new Error('hook ' + hook.name + ' must declare a finite priority: ordering must be declarative')
265
+ }
266
+ const entry: Bound = { point, hook: hook as unknown as StoredHook, seq: seq++ }
267
+ bound.push(entry)
268
+ return () => {
269
+ const index = bound.indexOf(entry)
270
+ if (index >= 0) bound.splice(index, 1)
271
+ }
272
+ },
273
+ run,
274
+ list(point?: HookPoint) {
275
+ const points = point === undefined ? HOOK_POINTS : [point]
276
+ return points.flatMap((p) => chainFor(p).map((entry) => ({
277
+ point: p,
278
+ name: entry.hook.name,
279
+ priority: entry.hook.priority,
280
+ onError: policyFor(p, entry.hook),
281
+ timeoutMs: timeoutFor(p, entry.hook),
282
+ })))
283
+ },
284
+ clear(point?: HookPoint) {
285
+ if (point === undefined) { bound.length = 0; return }
286
+ for (let i = bound.length - 1; i >= 0; i -= 1) if (bound[i]!.point === point) bound.splice(i, 1)
287
+ },
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Race a hook against its deadline.
293
+ *
294
+ * A hung promise cannot be cancelled, so a timeout does not stop the hook — it stops
295
+ * the CHAIN from waiting for it, which is the part that can brick a run. That is
296
+ * stated here rather than implied, because "timed out" and "stopped" are different
297
+ * claims.
298
+ */
299
+ async function withTimeout<T>(value: T | Promise<T>, timeoutMs: number): Promise<T> {
300
+ let timer: ReturnType<typeof setTimeout> | undefined
301
+ try {
302
+ return await Promise.race([
303
+ Promise.resolve(value),
304
+ new Promise<never>((_resolve, reject) => {
305
+ timer = setTimeout(() => reject(new Error('hook timed out after ' + timeoutMs + 'ms')), timeoutMs)
306
+ }),
307
+ ])
308
+ } finally {
309
+ if (timer !== undefined) clearTimeout(timer)
310
+ }
311
+ }
312
+
313
+ /** A stable fingerprint of one hook binding, for a board that lists the chain. */
314
+ export function hookFingerprint(entry: { point: HookPoint; name: string; priority: number; onError: string }): string {
315
+ return canonicalInput({ point: entry.point, name: entry.name, priority: entry.priority, onError: entry.onError })
316
+ }