@bookedsolid/rea 0.30.1 → 0.31.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.
- package/dist/cli/audit-specialists.d.ts +106 -24
- package/dist/cli/audit-specialists.js +239 -64
- package/dist/cli/delegation-advisory.d.ts +161 -0
- package/dist/cli/delegation-advisory.js +433 -0
- package/dist/cli/doctor.d.ts +110 -39
- package/dist/cli/doctor.js +302 -90
- package/dist/cli/hook.d.ts +6 -0
- package/dist/cli/hook.js +13 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/install/settings-merge.js +25 -0
- package/dist/cli/roster.d.ts +119 -0
- package/dist/cli/roster.js +141 -0
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +46 -0
- package/dist/policy/profiles.d.ts +23 -0
- package/dist/policy/profiles.js +16 -0
- package/dist/policy/types.d.ts +61 -0
- package/hooks/delegation-advisory.sh +162 -0
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +12 -0
- package/profiles/bst-internal.yaml +13 -0
- package/profiles/client-engagement.yaml +11 -0
- package/profiles/lit-wc.yaml +10 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +11 -0
- package/profiles/open-source.yaml +11 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live `.claude/agents/` roster discovery (0.31.0+).
|
|
3
|
+
*
|
|
4
|
+
* 0.29.0 shipped the delegation-telemetry observability layer. 0.31.0
|
|
5
|
+
* closes the loop with the *nudge* — and the nudge needs to know what
|
|
6
|
+
* counts as a "real" specialist delegation versus a built-in helper.
|
|
7
|
+
*
|
|
8
|
+
* # Why discovery, not a hardcoded list
|
|
9
|
+
*
|
|
10
|
+
* `src/cli/doctor.ts` already carries an `EXPECTED_AGENTS` constant,
|
|
11
|
+
* but it is a deliberately-frozen 10-entry subset — the minimum roster
|
|
12
|
+
* `rea init` guarantees, pinned so a regression that drops a curated
|
|
13
|
+
* agent from the install manifest trips a doctor failure. It is NOT
|
|
14
|
+
* the live roster: this repo ships 23 agents, consumers add their own,
|
|
15
|
+
* and the curated set grows release over release. Keying the
|
|
16
|
+
* delegation nudge off `EXPECTED_AGENTS` would mean a session that
|
|
17
|
+
* delegated exclusively to `principal-engineer` and `data-architect`
|
|
18
|
+
* (both real, curated, neither in the frozen subset) still got nudged
|
|
19
|
+
* — exactly the false positive that erodes trust in an advisory.
|
|
20
|
+
*
|
|
21
|
+
* So the roster is discovered at read time: every `*.md` file under
|
|
22
|
+
* `<baseDir>/.claude/agents/` is a curated specialist. The basename
|
|
23
|
+
* (sans `.md`) is the `subagent_type` Claude Code's `Agent` tool
|
|
24
|
+
* reports, so the mapping is direct.
|
|
25
|
+
*
|
|
26
|
+
* # The exempt set
|
|
27
|
+
*
|
|
28
|
+
* Claude Code ships built-in helpers — `general-purpose`, `Explore`,
|
|
29
|
+
* `Plan`, `output-style-setup`, `statusline-setup` — that are dispatched
|
|
30
|
+
* through the same `Agent` tool but are NOT curated specialists. A
|
|
31
|
+
* session that only ever delegated to those has not actually routed
|
|
32
|
+
* work to the engineering team. The exempt set is policy-configurable
|
|
33
|
+
* (`policy.delegation_advisory.exempt_subagents`) with a 5-entry
|
|
34
|
+
* built-in default; this module takes the resolved list as an argument
|
|
35
|
+
* rather than reading policy itself, so it stays a pure filesystem
|
|
36
|
+
* helper with no policy-loader dependency.
|
|
37
|
+
*
|
|
38
|
+
* # Skills
|
|
39
|
+
*
|
|
40
|
+
* The `Skill` delegation tool is intentionally NOT roster-gated. A
|
|
41
|
+
* skill invocation (`deep-dive`, `due-diligence`, …) is always a real
|
|
42
|
+
* delegation signal — there is no "built-in skill" equivalent of
|
|
43
|
+
* `general-purpose`. The advisory's "did this session delegate"
|
|
44
|
+
* predicate counts every `Skill` signal and every non-exempt `Agent`
|
|
45
|
+
* signal whose `subagent_type` is in the discovered roster.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Default exempt subagent names — Claude Code's built-in helper agents
|
|
49
|
+
* that are dispatched through the `Agent` tool but are not curated
|
|
50
|
+
* specialists. Mirrors the schema-layer default in
|
|
51
|
+
* `DelegationAdvisoryPolicySchema` (`src/policy/loader.ts`). Exported
|
|
52
|
+
* so callers that have no policy in scope (the bash-hook fallback
|
|
53
|
+
* path) can still apply the same filter.
|
|
54
|
+
*/
|
|
55
|
+
export declare const DEFAULT_EXEMPT_SUBAGENTS: readonly string[];
|
|
56
|
+
export interface RosterDiscoveryResult {
|
|
57
|
+
/**
|
|
58
|
+
* Sorted list of discovered curated-specialist names — the basename
|
|
59
|
+
* (sans `.md`) of every file under `.claude/agents/`. Empty when the
|
|
60
|
+
* directory is absent or unreadable.
|
|
61
|
+
*/
|
|
62
|
+
roster: string[];
|
|
63
|
+
/**
|
|
64
|
+
* Absolute path actually scanned. Returned for diagnostics — doctor
|
|
65
|
+
* surfaces it, the `rea hook` subcommand echoes it in `--json` mode.
|
|
66
|
+
*/
|
|
67
|
+
agentsDir: string;
|
|
68
|
+
/**
|
|
69
|
+
* `true` when `.claude/agents/` exists and was read. `false` when it
|
|
70
|
+
* is absent or a read error occurred — callers treat that as "no
|
|
71
|
+
* roster, every Agent delegation is non-exempt-but-also-unverifiable"
|
|
72
|
+
* and fall back to the exempt-list-only filter.
|
|
73
|
+
*/
|
|
74
|
+
discovered: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Discover the curated-specialist roster by listing `*.md` files under
|
|
78
|
+
* `<baseDir>/.claude/agents/`. Pure filesystem read — never throws; an
|
|
79
|
+
* absent or unreadable directory yields `discovered: false` with an
|
|
80
|
+
* empty roster.
|
|
81
|
+
*
|
|
82
|
+
* The `.md` extension match is case-insensitive (`.MD` on a
|
|
83
|
+
* case-preserving-but-insensitive filesystem still counts) and the
|
|
84
|
+
* basename is taken verbatim — `rea-orchestrator.md` →
|
|
85
|
+
* `rea-orchestrator`. Subdirectories and non-`.md` files (READMEs,
|
|
86
|
+
* `.DS_Store`, editor swap files) are skipped.
|
|
87
|
+
*/
|
|
88
|
+
export declare function discoverRoster(baseDir: string): RosterDiscoveryResult;
|
|
89
|
+
/**
|
|
90
|
+
* The delegation-nudge predicate, factored out so the CLI subcommand,
|
|
91
|
+
* the `audit specialists` reader, and the doctor smoke check all apply
|
|
92
|
+
* identical logic.
|
|
93
|
+
*
|
|
94
|
+
* Returns `true` when the given `(delegation_tool, subagent_type)` pair
|
|
95
|
+
* counts as a REAL delegation — i.e. the kind that should suppress the
|
|
96
|
+
* advisory nudge:
|
|
97
|
+
*
|
|
98
|
+
* - `Skill` → always counts. There is no "built-in skill" exemption.
|
|
99
|
+
* - `Agent` → counts when the `subagent_type` is NOT in `exempt` AND
|
|
100
|
+
* (the roster was discovered AND contains the name, OR the roster
|
|
101
|
+
* was NOT discovered — in which case we cannot verify and fall
|
|
102
|
+
* back to "non-exempt name counts"). The non-discovered fallback
|
|
103
|
+
* is deliberately permissive: a consumer who deleted
|
|
104
|
+
* `.claude/agents/` has bigger problems than a missed nudge, and a
|
|
105
|
+
* false negative (no nudge when one was due) is far less corrosive
|
|
106
|
+
* than a false positive.
|
|
107
|
+
*
|
|
108
|
+
* `exempt` comparison is case-sensitive — the built-in helper names
|
|
109
|
+
* (`Explore`, `Plan`) are capitalized and the curated specialists are
|
|
110
|
+
* kebab-case, so there is no realistic collision, and a case-folded
|
|
111
|
+
* compare would risk a curated `Plan-something` agent being wrongly
|
|
112
|
+
* exempted.
|
|
113
|
+
*/
|
|
114
|
+
export declare function countsAsRealDelegation(args: {
|
|
115
|
+
delegationTool: 'Agent' | 'Skill';
|
|
116
|
+
subagentType: string;
|
|
117
|
+
roster: RosterDiscoveryResult;
|
|
118
|
+
exempt: readonly string[];
|
|
119
|
+
}): boolean;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live `.claude/agents/` roster discovery (0.31.0+).
|
|
3
|
+
*
|
|
4
|
+
* 0.29.0 shipped the delegation-telemetry observability layer. 0.31.0
|
|
5
|
+
* closes the loop with the *nudge* — and the nudge needs to know what
|
|
6
|
+
* counts as a "real" specialist delegation versus a built-in helper.
|
|
7
|
+
*
|
|
8
|
+
* # Why discovery, not a hardcoded list
|
|
9
|
+
*
|
|
10
|
+
* `src/cli/doctor.ts` already carries an `EXPECTED_AGENTS` constant,
|
|
11
|
+
* but it is a deliberately-frozen 10-entry subset — the minimum roster
|
|
12
|
+
* `rea init` guarantees, pinned so a regression that drops a curated
|
|
13
|
+
* agent from the install manifest trips a doctor failure. It is NOT
|
|
14
|
+
* the live roster: this repo ships 23 agents, consumers add their own,
|
|
15
|
+
* and the curated set grows release over release. Keying the
|
|
16
|
+
* delegation nudge off `EXPECTED_AGENTS` would mean a session that
|
|
17
|
+
* delegated exclusively to `principal-engineer` and `data-architect`
|
|
18
|
+
* (both real, curated, neither in the frozen subset) still got nudged
|
|
19
|
+
* — exactly the false positive that erodes trust in an advisory.
|
|
20
|
+
*
|
|
21
|
+
* So the roster is discovered at read time: every `*.md` file under
|
|
22
|
+
* `<baseDir>/.claude/agents/` is a curated specialist. The basename
|
|
23
|
+
* (sans `.md`) is the `subagent_type` Claude Code's `Agent` tool
|
|
24
|
+
* reports, so the mapping is direct.
|
|
25
|
+
*
|
|
26
|
+
* # The exempt set
|
|
27
|
+
*
|
|
28
|
+
* Claude Code ships built-in helpers — `general-purpose`, `Explore`,
|
|
29
|
+
* `Plan`, `output-style-setup`, `statusline-setup` — that are dispatched
|
|
30
|
+
* through the same `Agent` tool but are NOT curated specialists. A
|
|
31
|
+
* session that only ever delegated to those has not actually routed
|
|
32
|
+
* work to the engineering team. The exempt set is policy-configurable
|
|
33
|
+
* (`policy.delegation_advisory.exempt_subagents`) with a 5-entry
|
|
34
|
+
* built-in default; this module takes the resolved list as an argument
|
|
35
|
+
* rather than reading policy itself, so it stays a pure filesystem
|
|
36
|
+
* helper with no policy-loader dependency.
|
|
37
|
+
*
|
|
38
|
+
* # Skills
|
|
39
|
+
*
|
|
40
|
+
* The `Skill` delegation tool is intentionally NOT roster-gated. A
|
|
41
|
+
* skill invocation (`deep-dive`, `due-diligence`, …) is always a real
|
|
42
|
+
* delegation signal — there is no "built-in skill" equivalent of
|
|
43
|
+
* `general-purpose`. The advisory's "did this session delegate"
|
|
44
|
+
* predicate counts every `Skill` signal and every non-exempt `Agent`
|
|
45
|
+
* signal whose `subagent_type` is in the discovered roster.
|
|
46
|
+
*/
|
|
47
|
+
import fs from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
/**
|
|
50
|
+
* Default exempt subagent names — Claude Code's built-in helper agents
|
|
51
|
+
* that are dispatched through the `Agent` tool but are not curated
|
|
52
|
+
* specialists. Mirrors the schema-layer default in
|
|
53
|
+
* `DelegationAdvisoryPolicySchema` (`src/policy/loader.ts`). Exported
|
|
54
|
+
* so callers that have no policy in scope (the bash-hook fallback
|
|
55
|
+
* path) can still apply the same filter.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
58
|
+
'general-purpose',
|
|
59
|
+
'Explore',
|
|
60
|
+
'Plan',
|
|
61
|
+
'output-style-setup',
|
|
62
|
+
'statusline-setup',
|
|
63
|
+
];
|
|
64
|
+
/**
|
|
65
|
+
* Discover the curated-specialist roster by listing `*.md` files under
|
|
66
|
+
* `<baseDir>/.claude/agents/`. Pure filesystem read — never throws; an
|
|
67
|
+
* absent or unreadable directory yields `discovered: false` with an
|
|
68
|
+
* empty roster.
|
|
69
|
+
*
|
|
70
|
+
* The `.md` extension match is case-insensitive (`.MD` on a
|
|
71
|
+
* case-preserving-but-insensitive filesystem still counts) and the
|
|
72
|
+
* basename is taken verbatim — `rea-orchestrator.md` →
|
|
73
|
+
* `rea-orchestrator`. Subdirectories and non-`.md` files (READMEs,
|
|
74
|
+
* `.DS_Store`, editor swap files) are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export function discoverRoster(baseDir) {
|
|
77
|
+
const agentsDir = path.join(baseDir, '.claude', 'agents');
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ENOENT (no .claude/agents/), ENOTDIR, EACCES — all collapse to
|
|
84
|
+
// "no roster discovered". The caller falls back to the exempt-list
|
|
85
|
+
// filter alone.
|
|
86
|
+
return { roster: [], agentsDir, discovered: false };
|
|
87
|
+
}
|
|
88
|
+
const roster = [];
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
// Only regular files. A directory named `foo.md` is not an agent.
|
|
91
|
+
if (!entry.isFile())
|
|
92
|
+
continue;
|
|
93
|
+
const name = entry.name;
|
|
94
|
+
if (!name.toLowerCase().endsWith('.md'))
|
|
95
|
+
continue;
|
|
96
|
+
const base = name.slice(0, name.length - 3);
|
|
97
|
+
if (base.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
roster.push(base);
|
|
100
|
+
}
|
|
101
|
+
roster.sort();
|
|
102
|
+
return { roster, agentsDir, discovered: true };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The delegation-nudge predicate, factored out so the CLI subcommand,
|
|
106
|
+
* the `audit specialists` reader, and the doctor smoke check all apply
|
|
107
|
+
* identical logic.
|
|
108
|
+
*
|
|
109
|
+
* Returns `true` when the given `(delegation_tool, subagent_type)` pair
|
|
110
|
+
* counts as a REAL delegation — i.e. the kind that should suppress the
|
|
111
|
+
* advisory nudge:
|
|
112
|
+
*
|
|
113
|
+
* - `Skill` → always counts. There is no "built-in skill" exemption.
|
|
114
|
+
* - `Agent` → counts when the `subagent_type` is NOT in `exempt` AND
|
|
115
|
+
* (the roster was discovered AND contains the name, OR the roster
|
|
116
|
+
* was NOT discovered — in which case we cannot verify and fall
|
|
117
|
+
* back to "non-exempt name counts"). The non-discovered fallback
|
|
118
|
+
* is deliberately permissive: a consumer who deleted
|
|
119
|
+
* `.claude/agents/` has bigger problems than a missed nudge, and a
|
|
120
|
+
* false negative (no nudge when one was due) is far less corrosive
|
|
121
|
+
* than a false positive.
|
|
122
|
+
*
|
|
123
|
+
* `exempt` comparison is case-sensitive — the built-in helper names
|
|
124
|
+
* (`Explore`, `Plan`) are capitalized and the curated specialists are
|
|
125
|
+
* kebab-case, so there is no realistic collision, and a case-folded
|
|
126
|
+
* compare would risk a curated `Plan-something` agent being wrongly
|
|
127
|
+
* exempted.
|
|
128
|
+
*/
|
|
129
|
+
export function countsAsRealDelegation(args) {
|
|
130
|
+
if (args.delegationTool === 'Skill')
|
|
131
|
+
return true;
|
|
132
|
+
// Agent path.
|
|
133
|
+
if (args.exempt.includes(args.subagentType))
|
|
134
|
+
return false;
|
|
135
|
+
if (!args.roster.discovered) {
|
|
136
|
+
// Roster unverifiable — a non-exempt Agent name is the best signal
|
|
137
|
+
// we have. Count it.
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return args.roster.roster.includes(args.subagentType);
|
|
141
|
+
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -288,6 +288,19 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
288
288
|
skip_merge?: boolean | undefined;
|
|
289
289
|
} | undefined;
|
|
290
290
|
}>>;
|
|
291
|
+
delegation_advisory: z.ZodOptional<z.ZodObject<{
|
|
292
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
293
|
+
threshold: z.ZodDefault<z.ZodNumber>;
|
|
294
|
+
exempt_subagents: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
295
|
+
}, "strict", z.ZodTypeAny, {
|
|
296
|
+
enabled: boolean;
|
|
297
|
+
threshold: number;
|
|
298
|
+
exempt_subagents: string[];
|
|
299
|
+
}, {
|
|
300
|
+
enabled?: boolean | undefined;
|
|
301
|
+
threshold?: number | undefined;
|
|
302
|
+
exempt_subagents?: string[] | undefined;
|
|
303
|
+
}>>;
|
|
291
304
|
}, "strict", z.ZodTypeAny, {
|
|
292
305
|
version: string;
|
|
293
306
|
profile: string;
|
|
@@ -361,6 +374,11 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
361
374
|
skip_merge?: boolean | undefined;
|
|
362
375
|
} | undefined;
|
|
363
376
|
} | undefined;
|
|
377
|
+
delegation_advisory?: {
|
|
378
|
+
enabled: boolean;
|
|
379
|
+
threshold: number;
|
|
380
|
+
exempt_subagents: string[];
|
|
381
|
+
} | undefined;
|
|
364
382
|
}, {
|
|
365
383
|
version: string;
|
|
366
384
|
profile: string;
|
|
@@ -434,6 +452,11 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
434
452
|
skip_merge?: boolean | undefined;
|
|
435
453
|
} | undefined;
|
|
436
454
|
} | undefined;
|
|
455
|
+
delegation_advisory?: {
|
|
456
|
+
enabled?: boolean | undefined;
|
|
457
|
+
threshold?: number | undefined;
|
|
458
|
+
exempt_subagents?: string[] | undefined;
|
|
459
|
+
} | undefined;
|
|
437
460
|
}>;
|
|
438
461
|
/**
|
|
439
462
|
* Async policy loader with TTL cache and mtime-based invalidation.
|
package/dist/policy/loader.js
CHANGED
|
@@ -289,6 +289,45 @@ const AttributionPolicySchema = z
|
|
|
289
289
|
co_author: AttributionCoAuthorSchema.optional(),
|
|
290
290
|
})
|
|
291
291
|
.strict();
|
|
292
|
+
/**
|
|
293
|
+
* 0.31.0 — delegation-advisory nudge policy. Drives the
|
|
294
|
+
* `delegation-advisory.sh` PostToolUse hook (matcher
|
|
295
|
+
* `Bash|Edit|Write|MultiEdit|NotebookEdit`): when a session crosses
|
|
296
|
+
* `threshold` write-class tool calls without a `rea.delegation_signal`
|
|
297
|
+
* record (to a non-exempt subagent), the hook emits a one-time stderr
|
|
298
|
+
* advisory. The hook is advisory-only — exit 0 always except HALT.
|
|
299
|
+
*
|
|
300
|
+
* Defaults live here at the schema layer, not in the hook: a vanilla
|
|
301
|
+
* install with no `delegation_advisory` block gets `enabled: false`
|
|
302
|
+
* (silent no-op), `threshold: 25`, and the 5-entry built-in exempt
|
|
303
|
+
* list. The `bst-internal*` profiles pin `enabled: true`; OSS profiles
|
|
304
|
+
* leave it `false` so consumers opt in.
|
|
305
|
+
*
|
|
306
|
+
* `threshold` is a positive integer — a single write-class count
|
|
307
|
+
* rather than the 0.29.0 design memo's "15 edits + 5 Bash" split.
|
|
308
|
+
* Modeling the threshold as one number keeps the hook's counter file
|
|
309
|
+
* a single integer and the policy surface a single knob; the
|
|
310
|
+
* distinction between an Edit and a Bash call doesn't change the
|
|
311
|
+
* signal the nudge exists to send ("you've done a lot solo").
|
|
312
|
+
*
|
|
313
|
+
* Strict mode rejects unknown keys so a typo (`thresholds`,
|
|
314
|
+
* `exempt_subagent`) fails loudly at policy load.
|
|
315
|
+
*/
|
|
316
|
+
const DelegationAdvisoryPolicySchema = z
|
|
317
|
+
.object({
|
|
318
|
+
enabled: z.boolean().default(false),
|
|
319
|
+
threshold: z.number().int().positive().default(25),
|
|
320
|
+
exempt_subagents: z
|
|
321
|
+
.array(z.string())
|
|
322
|
+
.default([
|
|
323
|
+
'general-purpose',
|
|
324
|
+
'Explore',
|
|
325
|
+
'Plan',
|
|
326
|
+
'output-style-setup',
|
|
327
|
+
'statusline-setup',
|
|
328
|
+
]),
|
|
329
|
+
})
|
|
330
|
+
.strict();
|
|
292
331
|
const PolicySchema = z
|
|
293
332
|
.object({
|
|
294
333
|
version: z.string(),
|
|
@@ -341,6 +380,13 @@ const PolicySchema = z
|
|
|
341
380
|
// `AttributionCoAuthorSchema` fails closed when `enabled: true` but
|
|
342
381
|
// `name`/`email` are empty so we never ship a half-configured trailer.
|
|
343
382
|
attribution: AttributionPolicySchema.optional(),
|
|
383
|
+
// 0.31.0 delegation-advisory nudge — drives the
|
|
384
|
+
// `delegation-advisory.sh` PostToolUse hook. `.optional()` so a
|
|
385
|
+
// vanilla install with no block sees the hook as a silent no-op
|
|
386
|
+
// (the hook reads `enabled` via `rea hook policy-get` and exits 0
|
|
387
|
+
// when unset/false). When the block IS present the inner schema
|
|
388
|
+
// supplies defaults for any omitted field.
|
|
389
|
+
delegation_advisory: DelegationAdvisoryPolicySchema.optional(),
|
|
344
390
|
})
|
|
345
391
|
.strict();
|
|
346
392
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
|
@@ -108,6 +108,19 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
108
108
|
skip_merge?: boolean | undefined;
|
|
109
109
|
} | undefined;
|
|
110
110
|
}>>;
|
|
111
|
+
delegation_advisory: z.ZodOptional<z.ZodObject<{
|
|
112
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
113
|
+
threshold: z.ZodOptional<z.ZodNumber>;
|
|
114
|
+
exempt_subagents: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
115
|
+
}, "strict", z.ZodTypeAny, {
|
|
116
|
+
enabled?: boolean | undefined;
|
|
117
|
+
threshold?: number | undefined;
|
|
118
|
+
exempt_subagents?: string[] | undefined;
|
|
119
|
+
}, {
|
|
120
|
+
enabled?: boolean | undefined;
|
|
121
|
+
threshold?: number | undefined;
|
|
122
|
+
exempt_subagents?: string[] | undefined;
|
|
123
|
+
}>>;
|
|
111
124
|
}, "strict", z.ZodTypeAny, {
|
|
112
125
|
autonomy_level?: AutonomyLevel | undefined;
|
|
113
126
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -142,6 +155,11 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
142
155
|
skip_merge?: boolean | undefined;
|
|
143
156
|
} | undefined;
|
|
144
157
|
} | undefined;
|
|
158
|
+
delegation_advisory?: {
|
|
159
|
+
enabled?: boolean | undefined;
|
|
160
|
+
threshold?: number | undefined;
|
|
161
|
+
exempt_subagents?: string[] | undefined;
|
|
162
|
+
} | undefined;
|
|
145
163
|
}, {
|
|
146
164
|
autonomy_level?: AutonomyLevel | undefined;
|
|
147
165
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -176,6 +194,11 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
176
194
|
skip_merge?: boolean | undefined;
|
|
177
195
|
} | undefined;
|
|
178
196
|
} | undefined;
|
|
197
|
+
delegation_advisory?: {
|
|
198
|
+
enabled?: boolean | undefined;
|
|
199
|
+
threshold?: number | undefined;
|
|
200
|
+
exempt_subagents?: string[] | undefined;
|
|
201
|
+
} | undefined;
|
|
179
202
|
}>;
|
|
180
203
|
export type Profile = z.infer<typeof ProfileSchema>;
|
|
181
204
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -100,6 +100,22 @@ export const ProfileSchema = z
|
|
|
100
100
|
})
|
|
101
101
|
.strict()
|
|
102
102
|
.optional(),
|
|
103
|
+
// 0.31.0+ delegation-advisory nudge. `bst-internal*` profiles pin
|
|
104
|
+
// `enabled: true`; external profiles ship `enabled: false`. The
|
|
105
|
+
// profile-layer schema mirrors the policy-loader's
|
|
106
|
+
// `DelegationAdvisoryPolicySchema` but leaves every field optional
|
|
107
|
+
// — defaults are applied at the policy-loader layer when the
|
|
108
|
+
// materialized file is parsed, so a profile that only declares
|
|
109
|
+
// `enabled` doesn't need to also restate `threshold`. Strict mode
|
|
110
|
+
// still rejects typos at init time.
|
|
111
|
+
delegation_advisory: z
|
|
112
|
+
.object({
|
|
113
|
+
enabled: z.boolean().optional(),
|
|
114
|
+
threshold: z.number().int().positive().optional(),
|
|
115
|
+
exempt_subagents: z.array(z.string()).optional(),
|
|
116
|
+
})
|
|
117
|
+
.strict()
|
|
118
|
+
.optional(),
|
|
103
119
|
})
|
|
104
120
|
.strict();
|
|
105
121
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -367,6 +367,59 @@ export interface AttributionCoAuthorPolicy {
|
|
|
367
367
|
email?: string;
|
|
368
368
|
skip_merge?: boolean;
|
|
369
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Delegation-advisory nudge policy (0.31.0+).
|
|
372
|
+
*
|
|
373
|
+
* 0.29.0 shipped the delegation-telemetry *observability* layer (the
|
|
374
|
+
* `Agent|Skill` PreToolUse capture hook + `rea audit specialists`
|
|
375
|
+
* reader). 0.31.0 closes the loop with the *nudge*: the
|
|
376
|
+
* `delegation-advisory.sh` PostToolUse hook (matcher
|
|
377
|
+
* `Bash|Edit|Write|MultiEdit|NotebookEdit`) counts the current
|
|
378
|
+
* session's write-class tool calls and, when that count crosses
|
|
379
|
+
* `threshold` WITHOUT a `rea.delegation_signal` record landing in the
|
|
380
|
+
* session, prints a one-time stderr advisory: "this session has done a
|
|
381
|
+
* lot of work without delegating to a specialist".
|
|
382
|
+
*
|
|
383
|
+
* The advisory is purely informational — the hook always exits 0
|
|
384
|
+
* (except under HALT, which exits 2 to keep the kill-switch contract
|
|
385
|
+
* uniform). It NEVER blocks a tool call.
|
|
386
|
+
*
|
|
387
|
+
* Profile defaults: `enabled: true` for the `bst-internal*` profiles
|
|
388
|
+
* (BST's own delegation discipline is load-bearing); `enabled: false`
|
|
389
|
+
* for every external profile (`open-source*`, `minimal`,
|
|
390
|
+
* `client-engagement`, `lit-wc`) — OSS consumers opt in per-repo via
|
|
391
|
+
* `.rea/policy.yaml`, since "you should delegate more" is an opinion
|
|
392
|
+
* not every team shares.
|
|
393
|
+
*/
|
|
394
|
+
export interface DelegationAdvisoryPolicy {
|
|
395
|
+
/**
|
|
396
|
+
* Master switch. When `false` (or the whole block is omitted) the
|
|
397
|
+
* `delegation-advisory.sh` hook is a silent no-op. Default `false` at
|
|
398
|
+
* the schema layer; `bst-internal*` profiles pin `true`.
|
|
399
|
+
*/
|
|
400
|
+
enabled?: boolean;
|
|
401
|
+
/**
|
|
402
|
+
* Write-class tool-call count at which the advisory fires. The
|
|
403
|
+
* `delegation-advisory.sh` hook maintains a per-session counter file
|
|
404
|
+
* and emits the nudge the first time the counter reaches this value
|
|
405
|
+
* with zero delegation signals recorded for the session. Default
|
|
406
|
+
* `25` — a session that has run 25 Bash/Edit/Write/MultiEdit/
|
|
407
|
+
* NotebookEdit calls without once dispatching a specialist is doing
|
|
408
|
+
* meaningful work solo. Must be a positive integer.
|
|
409
|
+
*/
|
|
410
|
+
threshold?: number;
|
|
411
|
+
/**
|
|
412
|
+
* Subagent / skill names that do NOT count as "real delegation" for
|
|
413
|
+
* the purpose of suppressing the advisory. A session that only ever
|
|
414
|
+
* delegated to `general-purpose` / `Explore` / `Plan` (the built-in
|
|
415
|
+
* Claude Code helpers) has not actually routed work to a curated
|
|
416
|
+
* specialist, so those signals don't reset the nudge. Default:
|
|
417
|
+
* `["general-purpose", "Explore", "Plan", "output-style-setup",
|
|
418
|
+
* "statusline-setup"]`. A delegation signal whose `subagent_type` is
|
|
419
|
+
* in this list is ignored when deciding whether to fire.
|
|
420
|
+
*/
|
|
421
|
+
exempt_subagents?: string[];
|
|
422
|
+
}
|
|
370
423
|
/**
|
|
371
424
|
* G9 — injection tier escalation knobs. The classifier bucketed matches into
|
|
372
425
|
* `clean` / `suspicious` / `likely_injection`; this block governs what happens
|
|
@@ -472,4 +525,12 @@ export interface Policy {
|
|
|
472
525
|
* trailer are no-ops. See `AttributionPolicy` for the full contract.
|
|
473
526
|
*/
|
|
474
527
|
attribution?: AttributionPolicy;
|
|
528
|
+
/**
|
|
529
|
+
* Delegation-advisory nudge (0.31.0+). When `enabled: true`, the
|
|
530
|
+
* `delegation-advisory.sh` PostToolUse hook emits a one-time stderr
|
|
531
|
+
* advisory when a session crosses `threshold` write-class tool calls
|
|
532
|
+
* without dispatching a curated specialist. Advisory only — never
|
|
533
|
+
* blocks. See `DelegationAdvisoryPolicy` for the full contract.
|
|
534
|
+
*/
|
|
535
|
+
delegation_advisory?: DelegationAdvisoryPolicy;
|
|
475
536
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse hook: delegation-advisory.sh
|
|
3
|
+
# 0.31.0+ — delegation-telemetry completion (the *nudge*).
|
|
4
|
+
#
|
|
5
|
+
# Fires AFTER every write-class tool call. The settings.json matcher is
|
|
6
|
+
# `Bash|Edit|Write|MultiEdit|NotebookEdit`. Reads the Claude Code hook
|
|
7
|
+
# payload from stdin, pipes it to `rea hook delegation-advisory`, and
|
|
8
|
+
# exits 0.
|
|
9
|
+
#
|
|
10
|
+
# 0.29.0 shipped the delegation-telemetry *observability* layer
|
|
11
|
+
# (`delegation-capture.sh` + `rea audit specialists`). 0.31.0 closes the
|
|
12
|
+
# loop with the *nudge*: `rea hook delegation-advisory` maintains a
|
|
13
|
+
# per-session write-class counter and, the FIRST time that counter
|
|
14
|
+
# crosses `policy.delegation_advisory.threshold` while the session has
|
|
15
|
+
# recorded zero real delegation signals, prints a one-time stderr
|
|
16
|
+
# advisory ("this session has done a lot of work without delegating to
|
|
17
|
+
# a specialist").
|
|
18
|
+
#
|
|
19
|
+
# # Advisory, never gating
|
|
20
|
+
#
|
|
21
|
+
# This hook ALWAYS exits 0 (under normal operation). The advisory is a
|
|
22
|
+
# nudge — it never blocks a tool call. The ONLY non-zero exit is 2
|
|
23
|
+
# under HALT, to keep the kill-switch contract uniform with the rest of
|
|
24
|
+
# the hook tree.
|
|
25
|
+
#
|
|
26
|
+
# # Synchronous, NOT detached
|
|
27
|
+
#
|
|
28
|
+
# Unlike `delegation-capture.sh` (which backgrounds `rea hook
|
|
29
|
+
# delegation-signal` with `& disown` because the audit write must not
|
|
30
|
+
# block tool dispatch), this hook runs the CLI SYNCHRONOUSLY. The
|
|
31
|
+
# advisory text must reach the operator's stderr before the hook
|
|
32
|
+
# returns — backgrounding it would race the hook's own exit and the
|
|
33
|
+
# message could be lost or interleaved with the next tool call's
|
|
34
|
+
# output. The CLI is cheap on the hot path: below the threshold it
|
|
35
|
+
# only bumps an integer counter file and exits, no audit scan, no
|
|
36
|
+
# roster discovery.
|
|
37
|
+
#
|
|
38
|
+
# # CLI-resolution trust boundary
|
|
39
|
+
#
|
|
40
|
+
# Same 2-tier sandboxed resolution `delegation-capture.sh`,
|
|
41
|
+
# `protected-paths-bash-gate.sh`, and `blocked-paths-bash-gate.sh` use:
|
|
42
|
+
# 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
|
|
43
|
+
# published artifact)
|
|
44
|
+
# 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
|
|
45
|
+
# dogfood install)
|
|
46
|
+
# PATH lookup is INTENTIONALLY OMITTED — agent-controlled $PATH would
|
|
47
|
+
# let a forged `rea` binary intercept this hook on every write-class
|
|
48
|
+
# tool call. A realpath sandbox check ensures the resolved CLI lives
|
|
49
|
+
# INSIDE realpath(CLAUDE_PROJECT_DIR) with an ancestor package.json
|
|
50
|
+
# declaring `@bookedsolid/rea`.
|
|
51
|
+
#
|
|
52
|
+
# Exit codes:
|
|
53
|
+
# 0 — always (under normal operation). Disabled-by-policy,
|
|
54
|
+
# below-threshold, already-fired, just-fired — all exit 0.
|
|
55
|
+
# 2 — HALT active.
|
|
56
|
+
|
|
57
|
+
set -uo pipefail
|
|
58
|
+
|
|
59
|
+
# 1. HALT check. Even though this hook is advisory, refusing to run
|
|
60
|
+
# while frozen matches the rest of the hook tree and keeps the
|
|
61
|
+
# kill-switch contract uniform.
|
|
62
|
+
# shellcheck source=_lib/halt-check.sh
|
|
63
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
64
|
+
check_halt
|
|
65
|
+
REA_ROOT=$(rea_root)
|
|
66
|
+
|
|
67
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
68
|
+
|
|
69
|
+
# 2. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
70
|
+
# PATH lookup is omitted on purpose (see header). Other install
|
|
71
|
+
# shapes silently drop the advisory — matching the bash-gate
|
|
72
|
+
# posture; the nudge is a convenience, not a security claim.
|
|
73
|
+
REA_ARGV=()
|
|
74
|
+
RESOLVED_CLI_PATH=""
|
|
75
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
76
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
77
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
78
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
79
|
+
# rea repo dogfood: the project IS @bookedsolid/rea.
|
|
80
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
81
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
85
|
+
# No rea CLI in scope — drop the advisory silently. This is the
|
|
86
|
+
# expected state during bootstrap (consumer ran `rea init` but
|
|
87
|
+
# hasn't installed the npm package yet) or in non-rea repos. A
|
|
88
|
+
# noisy stderr warning here would fire on every write-class tool
|
|
89
|
+
# call and drown legitimate output.
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# 3. Realpath sandbox check — mirrors delegation-capture.sh §3 and
|
|
94
|
+
# protected-paths-bash-gate.sh §6. The resolved CLI MUST live inside
|
|
95
|
+
# realpath(CLAUDE_PROJECT_DIR) AND have an ancestor package.json
|
|
96
|
+
# declaring `@bookedsolid/rea` as its `name`. Catches symlink-out
|
|
97
|
+
# attacks where an attacker writes
|
|
98
|
+
# node_modules/@bookedsolid/rea → /tmp/forged-tree.
|
|
99
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
100
|
+
# Node not on PATH — we can't verify the CLI shape. Fail safe by
|
|
101
|
+
# dropping the advisory (it is not a security claim; the rest of
|
|
102
|
+
# the Bash gate suite refuses on this path).
|
|
103
|
+
exit 0
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
sandbox_check=$(node -e '
|
|
107
|
+
const fs = require("fs");
|
|
108
|
+
const path = require("path");
|
|
109
|
+
const cli = process.argv[1];
|
|
110
|
+
const projDir = process.argv[2];
|
|
111
|
+
let real, realProj;
|
|
112
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
113
|
+
process.stdout.write("bad:realpath");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
117
|
+
process.stdout.write("bad:realpath-proj");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
const sep = path.sep;
|
|
121
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
122
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
123
|
+
process.stdout.write("bad:cli-escapes-project");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// Walk up looking for package.json with the protected name.
|
|
127
|
+
let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
|
|
128
|
+
let found = false;
|
|
129
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
130
|
+
const pj = path.join(cur, "package.json");
|
|
131
|
+
if (fs.existsSync(pj)) {
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
134
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
135
|
+
} catch (e) { /* keep walking */ }
|
|
136
|
+
}
|
|
137
|
+
cur = path.dirname(cur);
|
|
138
|
+
}
|
|
139
|
+
if (!found) {
|
|
140
|
+
process.stdout.write("bad:no-rea-pkg-json");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write("ok");
|
|
144
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
145
|
+
|
|
146
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
147
|
+
# CLI failed the sandbox check — silent drop. The forensic
|
|
148
|
+
# breadcrumb in stderr is intentional but trimmed so this doesn't
|
|
149
|
+
# become spammy on every tool call.
|
|
150
|
+
printf 'rea: delegation-advisory skipped (sandbox check: %s)\n' "$sandbox_check" >&2
|
|
151
|
+
exit 0
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# 4. Read stdin and pipe to the CLI SYNCHRONOUSLY. The advisory must
|
|
155
|
+
# print before this hook returns — see the "Synchronous" note in
|
|
156
|
+
# the header. We pass CLAUDE_PROJECT_DIR through explicitly so the
|
|
157
|
+
# CLI resolves the same REA_ROOT this shim did. The CLI's own exit
|
|
158
|
+
# code is the hook's exit code: 0 normally, 2 under HALT (the CLI
|
|
159
|
+
# re-checks HALT itself for defense-in-depth).
|
|
160
|
+
INPUT=$(cat)
|
|
161
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-advisory
|
|
162
|
+
exit $?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|