@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
package/src/guard-log.ts
ADDED
|
@@ -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
|
+
}
|