@crewhaus/ir 0.1.8 → 0.2.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/dist/index.d.ts CHANGED
@@ -69,11 +69,14 @@ export type IrToolConfigs = Readonly<Record<string, unknown>>;
69
69
  */
70
70
  export type IrCompaction = {
71
71
  readonly model?: string;
72
- /** Pillar 2 — when true, target emitters wire `compaction-curator`
73
- * as a pre-pass before the autocompact threshold check. The spec
74
- * layer accepts this verbatim (validated in `packages/spec`); the
75
- * IR holds it as an opt-in flag with no default so emitters can
76
- * distinguish "user said false" from "user didn't say". */
72
+ /** Pillar 2 — RESERVED, not yet wired at runtime. Intended to make
73
+ * target emitters wire `compaction-curator` as a pre-pass before the
74
+ * autocompact threshold check, but no emitter or runtime-core path
75
+ * consumes this field today setting it is currently a no-op. The
76
+ * spec layer accepts this verbatim (validated in `packages/spec`) and
77
+ * it lowers here unchanged so the value round-trips once wiring lands;
78
+ * the IR holds it as an opt-in flag with no default so emitters can
79
+ * eventually distinguish "user said false" from "user didn't say". */
77
80
  readonly curate?: boolean;
78
81
  /** Cosine threshold for the curator's dedupe pass. Curator's own
79
82
  * default (0.92, `DEFAULT_DEDUPE_THRESHOLD` in
@@ -83,6 +86,74 @@ export type IrCompaction = {
83
86
  * reorder without trimming. */
84
87
  readonly relevanceTopK?: number;
85
88
  };
89
+ /**
90
+ * Item 22 — per-candidate circuit-breaker tuning lowered from the spec's
91
+ * `agent.circuit_breaker` block. Field names mirror `CircuitBreakerOptions`
92
+ * in `@crewhaus/circuit-breaker` exactly; every field optional so the
93
+ * breaker package's own defaults apply per knob. Carried on the agent
94
+ * blocks of the shapes wired for the failover chain (cli, channel,
95
+ * managed). Absent when the spec omits the block — declaring it WITHOUT
96
+ * `modelFallbacks` still breaker-wraps the single primary adapter.
97
+ */
98
+ export type IrCircuitBreaker = {
99
+ readonly failureThreshold?: number;
100
+ readonly windowMs?: number;
101
+ readonly cooldownMs?: number;
102
+ };
103
+ /**
104
+ * Item 26 — two-tier turn-difficulty router config. `fast`/`default` are full
105
+ * model-router grammar strings; `routing` tunes the per-turn escalation
106
+ * thresholds (all optional — runtime defaults apply per knob). Carried on the
107
+ * agent blocks of the failover-capable shapes (cli, channel, managed). Absent
108
+ * when the spec omits `model_tiers` — codegen gates on presence so an unset
109
+ * block leaves bundles byte-identical.
110
+ */
111
+ export type IrModelTiers = {
112
+ readonly fast: string;
113
+ readonly default: string;
114
+ readonly routing?: {
115
+ readonly contextTokenThreshold?: number;
116
+ readonly toolsToDefault?: boolean;
117
+ readonly firstTurnToDefault?: boolean;
118
+ readonly priorToolDensityThreshold?: number;
119
+ };
120
+ };
121
+ /** One declared candidate in a `model_pool`. */
122
+ export type IrModelPoolCandidate = {
123
+ readonly model: string;
124
+ readonly tags: readonly string[];
125
+ };
126
+ /**
127
+ * Adaptive model routing — the N-candidate `model_pool` with a per-turn
128
+ * selection `policy` (`static` | `heuristic` | `learned`). Carried on the
129
+ * agent blocks of the routing-capable shapes (cli, channel, managed), mutually
130
+ * exclusive with `modelTiers`/`modelFallbacks` (enforced in the spec). Absent
131
+ * when the spec omits `model_pool` — codegen gates on presence so an unset
132
+ * block leaves bundles byte-identical. `policy` and each candidate's `tags` are
133
+ * always present (spec defaults them); every other knob is carried verbatim.
134
+ */
135
+ export type IrModelPool = {
136
+ readonly candidates: readonly IrModelPoolCandidate[];
137
+ readonly policy: "static" | "heuristic" | "learned";
138
+ readonly objective?: {
139
+ readonly quality?: number;
140
+ readonly cost?: number;
141
+ readonly latency?: number;
142
+ };
143
+ readonly routing?: {
144
+ readonly contextTokenThreshold?: number;
145
+ readonly toolsToDefault?: boolean;
146
+ readonly firstTurnToDefault?: boolean;
147
+ readonly priorToolDensityThreshold?: number;
148
+ readonly strongTag?: string;
149
+ readonly cheapTag?: string;
150
+ };
151
+ readonly learning?: {
152
+ readonly minSamplesPerArm?: number;
153
+ readonly costRefUsd?: number;
154
+ readonly latencyRefMs?: number;
155
+ };
156
+ };
86
157
  /**
87
158
  * Section 55 (Track A) — named failure taxonomy. Cross-cutting; carried
88
159
  * through to runtime-core so `recovery-engine` can consult the user's
@@ -100,10 +171,30 @@ export type IrCompaction = {
100
171
  export type IrFailureTaxonomyEntry = {
101
172
  readonly class: string;
102
173
  readonly pattern: string;
103
- readonly recovery: "retry" | "compact" | "continue" | "tombstone" | "fail";
174
+ /** Item 23 `switch-model` reroutes onto the next provider failover
175
+ * candidate mid-turn (see recovery-engine). */
176
+ readonly recovery: "retry" | "compact" | "continue" | "tombstone" | "switch-model" | "fail";
104
177
  readonly hint?: string;
105
178
  };
106
179
  export type IrFailureTaxonomy = readonly IrFailureTaxonomyEntry[];
180
+ /**
181
+ * Item 27 — run-level spend cap with a degradation ladder, lowered from the
182
+ * spec's `budget` block. `usdMicros` is the dollar ceiling in USD-micros
183
+ * (1 USD = 1_000_000) — the unit the runtime meters in. `onExceed` decides
184
+ * the behaviour when accrued spend reaches the cap: `stop` ends the run
185
+ * before the next turn; `degrade` re-resolves the primary model to `model`
186
+ * (one cheaper rung) and continues. Carried on the interactive shapes that
187
+ * loop (cli, channel, managed); absent when the spec omits the block.
188
+ */
189
+ export type IrBudget = {
190
+ readonly usdMicros: number;
191
+ readonly onExceed: {
192
+ readonly kind: "stop";
193
+ } | {
194
+ readonly kind: "degrade";
195
+ readonly model: string;
196
+ };
197
+ };
107
198
  /**
108
199
  * Pillar 3 (FR-004) — per-target security fabric configuration the
109
200
  * compiler lowers from the spec's `security` block. Today it carries the
@@ -160,8 +251,56 @@ export type IrFeedback = {
160
251
  readonly location: string;
161
252
  };
162
253
  readonly autoDistill?: boolean;
254
+ /** Item 1 — gate for the CLI REPL's one-keystroke exit rating prompt.
255
+ * Absent → prompt (the block's presence opts in); `false` → never. */
256
+ readonly exitPrompt?: boolean;
163
257
  readonly channelReactions?: boolean;
164
258
  };
259
+ /**
260
+ * Feature #53 — cross-session memory config, lowered from `spec.memory`.
261
+ * Presence of the block wires Remember/Recall into the target; the auto-*
262
+ * switches gate auto-capture (summarize durable outcomes at teardown) and
263
+ * auto-recall (inject top-K memories into the system prompt at session start).
264
+ * Carried on the interactive shapes that run a chat loop (IrV0/cli,
265
+ * IrChannelV0, IrManagedV0, IrResearchV0). Absent when the spec omits `memory`.
266
+ */
267
+ export type IrMemory = {
268
+ readonly enabled?: boolean;
269
+ readonly autoCapture?: boolean;
270
+ readonly autoCaptureThreshold?: number;
271
+ readonly autoRecall?: boolean;
272
+ readonly recallK?: number;
273
+ };
274
+ /** Ops item 37 — a mitigation-ladder rung the runtime SLO monitor walks on a
275
+ * sustained breach, in declared order. See {@link IrSlo}. */
276
+ export type IrSloMitigation = "alert" | "pause-intake" | "rollback";
277
+ /**
278
+ * Ops item 37 — production SLO targets + the mitigation ladder, lowered from
279
+ * `spec.observability.slo`. Every target is optional (declare only the ones you
280
+ * care about); an omitted target is never evaluated by the monitor. `windowMs`
281
+ * is the rolling window a breach must persist before the ladder fires (spec's
282
+ * `window_seconds` × 1000; the monitor defaults it when absent). `mitigation`
283
+ * defaults to `["alert"]` at lower time so an observe-only spec still warns.
284
+ * Absent from the IR when the spec omits the block.
285
+ */
286
+ export type IrSlo = {
287
+ readonly errorRate?: number;
288
+ readonly p95LatencyMs?: number;
289
+ readonly ttftMs?: number;
290
+ readonly costPerHourUsd?: number;
291
+ readonly egressBlockRate?: number;
292
+ readonly windowMs?: number;
293
+ readonly mitigation: ReadonlyArray<IrSloMitigation>;
294
+ };
295
+ /**
296
+ * Ops item 37 — cross-cutting observability config, lowered from
297
+ * `spec.observability`. Today it carries one sub-block, `slo`. Carried on the
298
+ * interactive/daemon shapes that run a chat loop (IrV0/cli, IrChannelV0,
299
+ * IrManagedV0). Absent when the spec omits the `observability` block.
300
+ */
301
+ export type IrObservability = {
302
+ readonly slo?: IrSlo;
303
+ };
165
304
  /**
166
305
  * Track F (Section 57) — typed message schemas (Σ) for multi-agent
167
306
  * communication. Source: AgentFlow (arxiv 2604.20801). A typed graph
@@ -216,6 +355,16 @@ export type IrV0 = {
216
355
  /** Model max OUTPUT tokens for one turn (spec `agent.max_tokens`).
217
356
  * Optional; when absent the runtime default applies. */
218
357
  readonly maxTokens?: number;
358
+ /** Item 22 — ordered failover models (spec `agent.model_fallbacks`).
359
+ * Absent when the spec omits the block; the runtime then keeps its
360
+ * single-adapter path. */
361
+ readonly modelFallbacks?: readonly string[];
362
+ /** Item 22 — breaker tuning (spec `agent.circuit_breaker`). */
363
+ readonly circuitBreaker?: IrCircuitBreaker;
364
+ /** Item 26 — two-tier turn-difficulty router. Absent → single-model. */
365
+ readonly modelTiers?: IrModelTiers;
366
+ /** Adaptive model routing — N-candidate pool. Absent → single-model. */
367
+ readonly modelPool?: IrModelPool;
219
368
  };
220
369
  readonly tools: readonly string[];
221
370
  readonly toolConfigs: IrToolConfigs;
@@ -226,12 +375,19 @@ export type IrV0 = {
226
375
  readonly cli?: IrCliOptions;
227
376
  /** Section 55 (Track A) — named failure taxonomy. Optional. */
228
377
  readonly failureTaxonomy?: IrFailureTaxonomy;
378
+ /** Item 27 — run-level spend cap + degradation ladder. Optional. */
379
+ readonly budget?: IrBudget;
229
380
  /** Pillar 3 (FR-004) — security fabric config (intent-gate judge
230
381
  * selection). Optional; absent when the spec omits the `security`
231
382
  * block. */
232
383
  readonly security?: IrSecurity;
233
384
  /** Response-feedback config. Optional; absent when the spec omits `feedback`. */
234
385
  readonly feedback?: IrFeedback;
386
+ /** #53 cross-session memory config. Optional; absent when the spec omits `memory`. */
387
+ readonly memory?: IrMemory;
388
+ /** Ops item 37 — SLO targets + mitigation ladder. Optional; absent when the
389
+ * spec omits the `observability` block. */
390
+ readonly observability?: IrObservability;
235
391
  /** §47 cross-cutting blockchain subsystem (slice 0). All optional. */
236
392
  readonly chains?: readonly IrChainBinding[];
237
393
  readonly wallets?: readonly IrWalletBinding[];
@@ -463,6 +619,14 @@ export type IrChannelV0 = {
463
619
  readonly agent: {
464
620
  readonly model: string;
465
621
  readonly instructions: string;
622
+ /** Item 22 — ordered failover models (spec `agent.model_fallbacks`). */
623
+ readonly modelFallbacks?: readonly string[];
624
+ /** Item 22 — breaker tuning (spec `agent.circuit_breaker`). */
625
+ readonly circuitBreaker?: IrCircuitBreaker;
626
+ /** Item 26 — two-tier turn-difficulty router. Absent → single-model. */
627
+ readonly modelTiers?: IrModelTiers;
628
+ /** Adaptive model routing — N-candidate pool. Absent → single-model. */
629
+ readonly modelPool?: IrModelPool;
466
630
  };
467
631
  readonly tools: readonly string[];
468
632
  readonly toolConfigs: IrToolConfigs;
@@ -476,9 +640,16 @@ export type IrChannelV0 = {
476
640
  readonly gateway?: IrChannelGateway;
477
641
  /** Section 55 (Track A) — named failure taxonomy. Optional. */
478
642
  readonly failureTaxonomy?: IrFailureTaxonomy;
643
+ /** Item 27 — run-level spend cap + degradation ladder. Optional. */
644
+ readonly budget?: IrBudget;
479
645
  /** Response-feedback config. `feedback.channelReactions` gates Slack 👍/👎
480
646
  * → user_feedback codegen in this target. Absent when spec omits it. */
481
647
  readonly feedback?: IrFeedback;
648
+ /** #53 cross-session memory config. Optional; absent when the spec omits `memory`. */
649
+ readonly memory?: IrMemory;
650
+ /** Ops item 37 — SLO targets + mitigation ladder. Optional; absent when the
651
+ * spec omits the `observability` block. */
652
+ readonly observability?: IrObservability;
482
653
  /** §47 cross-cutting blockchain subsystem (slice 0). All optional. */
483
654
  readonly chains?: readonly IrChainBinding[];
484
655
  readonly wallets?: readonly IrWalletBinding[];
@@ -505,12 +676,28 @@ export type IrManagedV0 = {
505
676
  readonly agent: {
506
677
  readonly model: string;
507
678
  readonly instructions: string;
679
+ /** Item 22 — ordered failover models (spec `agent.model_fallbacks`). */
680
+ readonly modelFallbacks?: readonly string[];
681
+ /** Item 22 — breaker tuning (spec `agent.circuit_breaker`). */
682
+ readonly circuitBreaker?: IrCircuitBreaker;
683
+ /** Item 26 — two-tier turn-difficulty router. Absent → single-model. */
684
+ readonly modelTiers?: IrModelTiers;
685
+ /** Adaptive model routing — N-candidate pool. Absent → single-model. */
686
+ readonly modelPool?: IrModelPool;
508
687
  };
509
688
  readonly tenants: readonly IrManagedTenant[];
510
689
  readonly permissions: IrPermissions;
511
690
  readonly compaction: IrCompaction;
512
691
  /** Section 55 (Track A) — named failure taxonomy. Optional. */
513
692
  readonly failureTaxonomy?: IrFailureTaxonomy;
693
+ /** Item 27 — run-level spend cap + degradation ladder. Optional. */
694
+ readonly budget?: IrBudget;
695
+ /** #53 cross-session memory config. Optional; absent when the spec omits `memory`. */
696
+ readonly memory?: IrMemory;
697
+ /** Ops item 37 — SLO targets + mitigation ladder. Optional; absent when the
698
+ * spec omits the `observability` block. The managed daemon's `pause-intake`
699
+ * rung reuses its `budget_exceeded` 429 path. */
700
+ readonly observability?: IrObservability;
514
701
  };
515
702
  /**
516
703
  * Section 19 — Graph IR. A `target: "graph"` spec lowers into a fixed
@@ -700,6 +887,8 @@ export type IrResearchV0 = {
700
887
  readonly compaction: IrCompaction;
701
888
  /** Section 55 (Track A) — named failure taxonomy. Optional. */
702
889
  readonly failureTaxonomy?: IrFailureTaxonomy;
890
+ /** #53 cross-session memory config. Optional; absent when the spec omits `memory`. */
891
+ readonly memory?: IrMemory;
703
892
  /** §47 cross-cutting blockchain subsystem (slice 0). All optional. */
704
893
  readonly chains?: readonly IrChainBinding[];
705
894
  readonly wallets?: readonly IrWalletBinding[];
@@ -969,3 +1158,4 @@ export type Bundle = {
969
1158
  readonly content: string;
970
1159
  }>;
971
1160
  };
1161
+ export { type BundleReadmeOptions, type BundleReadmeSection, type CollectedSecretRefs, type EmitReadmeOptions, GENERATED_README_MARKER, collectSecretRefs, renderBundleReadme, } from "./readme";
package/dist/index.js CHANGED
@@ -1 +1,3 @@
1
- export {};
1
+ // Generated-bundle README renderer (item 42) — pure functions over the IR,
2
+ // shared by every target emitter. See ./readme.ts for the module docs.
3
+ export { GENERATED_README_MARKER, collectSecretRefs, renderBundleReadme, } from "./readme";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Generated-bundle README renderer (AUTOMATION-OPPORTUNITIES.md item 42).
3
+ *
4
+ * Every target emitter drops a `README.md` into its compiled bundle so a
5
+ * user who cd's into an out-dir can see — without reading generated code —
6
+ * what the harness is, which tools/MCP servers it wires, WHICH ENV VARS it
7
+ * needs, and how to launch it. The renderer lives here in `@crewhaus/ir`
8
+ * because it is a pure function over the lowered IR and this is the one
9
+ * package every `target-*` emitter already depends on (adding it here
10
+ * creates zero new dependency edges; `packages/compiler` sits *downstream*
11
+ * of the emitters, so it cannot be the seam).
12
+ *
13
+ * Security invariant: secret refs lowered to `{ kind: "literal" }` are
14
+ * NEVER printed — the README only names `{ kind: "env" }` variables. A
15
+ * literal credential is already a spec smell (see `lowerCredential` in
16
+ * `packages/compiler`), and the README must not widen the blast radius by
17
+ * copying the value into a second, more-readable artifact.
18
+ */
19
+ import type { IrNode } from "./index";
20
+ /**
21
+ * Common emitter option controlling README.md emission. Default ON;
22
+ * `crewhaus compile --no-readme` threads `readme: false` through
23
+ * `CompileOptions` to every target emitter.
24
+ */
25
+ export type EmitReadmeOptions = {
26
+ readonly readme?: boolean;
27
+ };
28
+ /**
29
+ * Machine-checkable marker embedded in every generated README. The CLI's
30
+ * `compile` write path uses it to distinguish a previously-generated
31
+ * README (safe to overwrite on recompile) from a user-authored one
32
+ * (kept, with a notice).
33
+ */
34
+ export declare const GENERATED_README_MARKER = "<!-- crewhaus:generated-readme -->";
35
+ /** A README section: markdown heading text + markdown body. */
36
+ export type BundleReadmeSection = {
37
+ readonly heading: string;
38
+ readonly body: string;
39
+ };
40
+ export type BundleReadmeOptions = {
41
+ /** One-line description under the title. Defaults to a generated line. */
42
+ readonly description?: string;
43
+ /**
44
+ * Replace the default per-target Run section (e.g. the cf-worker
45
+ * emitters substitute a `wrangler deploy` flow, the claude-plugin
46
+ * emitter an Install note).
47
+ */
48
+ readonly usage?: BundleReadmeSection;
49
+ /** Include the `.crewhaus/` runtime-data note. Default true. */
50
+ readonly includeWorkspaceNote?: boolean;
51
+ /** Extra sections appended at the end (e.g. claude-plugin's Origin). */
52
+ readonly extraSections?: readonly BundleReadmeSection[];
53
+ };
54
+ /**
55
+ * Every env var / redacted-literal count referenced by a lowered IR's
56
+ * secret-shaped fields (`IrSecretRef`), gathered via a recursive walk so
57
+ * variant-specific nesting (channel credentials, chain `rpcUrls`, wallet
58
+ * `keyRef`, pipeline `retrieve.apiKey`, …) is covered without coupling to
59
+ * each variant's shape — the same discipline as `collectToolNames` in
60
+ * `packages/compiler`.
61
+ */
62
+ export type CollectedSecretRefs = {
63
+ /** Deduped, sorted `{ kind: "env" }` variable names. */
64
+ readonly envNames: readonly string[];
65
+ /** How many `{ kind: "literal" }` refs were found. Values are REDACTED. */
66
+ readonly literalCount: number;
67
+ };
68
+ export declare function collectSecretRefs(ir: unknown): CollectedSecretRefs;
69
+ /**
70
+ * Render the generated bundle README from a lowered IR. Pure; no I/O.
71
+ * Deterministic for a given IR (tables and env lists are sorted) so
72
+ * recompiles diff cleanly.
73
+ */
74
+ export declare function renderBundleReadme(ir: IrNode, opts?: BundleReadmeOptions): string;
package/dist/readme.js ADDED
@@ -0,0 +1,381 @@
1
+ import { OPAQUE_TOKEN_RE, maskCredentialTokens } from "./redact";
2
+ /**
3
+ * Machine-checkable marker embedded in every generated README. The CLI's
4
+ * `compile` write path uses it to distinguish a previously-generated
5
+ * README (safe to overwrite on recompile) from a user-authored one
6
+ * (kept, with a notice).
7
+ */
8
+ export const GENERATED_README_MARKER = "<!-- crewhaus:generated-readme -->";
9
+ export function collectSecretRefs(ir) {
10
+ const envNames = new Set();
11
+ let literalCount = 0;
12
+ const visit = (node) => {
13
+ if (Array.isArray(node)) {
14
+ for (const item of node)
15
+ visit(item);
16
+ return;
17
+ }
18
+ if (node === null || typeof node !== "object")
19
+ return;
20
+ const record = node;
21
+ // Match the IrSecretRef discriminants exactly. Other IR unions also
22
+ // carry a `kind` key (`IrChainFinality`, `IrSchemaRef`, triggers) but
23
+ // none uses the values "env"/"literal", so this cannot misfire.
24
+ if (record["kind"] === "env" && typeof record["name"] === "string") {
25
+ envNames.add(record["name"]);
26
+ }
27
+ else if (record["kind"] === "literal" && typeof record["value"] === "string") {
28
+ literalCount += 1;
29
+ }
30
+ for (const value of Object.values(record))
31
+ visit(value);
32
+ };
33
+ visit(ir);
34
+ return { envNames: [...envNames].sort(), literalCount };
35
+ }
36
+ /**
37
+ * Per-shape launch one-liner, derived from each emitter's output layout:
38
+ * shapes that emit a `daemon.ts` entrypoint (channel / managed / crew /
39
+ * voice) launch that; every other shape's entrypoint is `agent.ts`.
40
+ */
41
+ const RUN_COMMANDS = {
42
+ cli: "bun agent.ts",
43
+ workflow: "bun agent.ts",
44
+ channel: "bun daemon.ts",
45
+ graph: "bun agent.ts",
46
+ managed: "bun daemon.ts",
47
+ pipeline: "bun agent.ts",
48
+ crew: "bun daemon.ts",
49
+ research: "bun agent.ts",
50
+ batch: "bun agent.ts",
51
+ voice: "bun daemon.ts",
52
+ browser: "bun agent.ts",
53
+ eval: "bun agent.ts",
54
+ onchain: "bun agent.ts",
55
+ "onchain-game": "bun agent.ts",
56
+ };
57
+ /**
58
+ * Definitionally outward-reaching tool names, in both the spec-key
59
+ * (camelCase) and registered (PascalCase) forms the IR can carry. Mirrors
60
+ * `OUTWARD_TOOL_NAMES` in `@crewhaus/tool-builder` — the canonical rule —
61
+ * but kept inline (exactly as `IrVectorBackend` mirrors `vector-store`)
62
+ * so the runtime-agnostic IR keeps its zero package dependencies. Keep in
63
+ * sync when a name is added or removed.
64
+ */
65
+ const OUTWARD_TOOL_NAMES = new Set([
66
+ "fetch",
67
+ "Fetch",
68
+ "webFetch",
69
+ "WebFetch",
70
+ "webSearch",
71
+ "WebSearch",
72
+ "sendMessage",
73
+ "SendMessage",
74
+ "evmSendTransaction",
75
+ "EvmSendTransaction",
76
+ "imageGenerate",
77
+ "ImageGenerate",
78
+ ]);
79
+ /** Tools executed inside the §18 sandbox (see `target-cli`'s sandbox gate). */
80
+ const SANDBOXED_TOOL_NAMES = new Set(["python", "javascript", "shell"]);
81
+ /**
82
+ * Tools that `requireJustification: true` by default (the Pillar 3 intent
83
+ * gate — see AGENTS.md). Both name forms, same mirroring caveat as above.
84
+ */
85
+ const JUSTIFICATION_GATED_TOOL_NAMES = new Set([
86
+ "sendMessage",
87
+ "SendMessage",
88
+ "evmSendTransaction",
89
+ "EvmSendTransaction",
90
+ "imageGenerate",
91
+ "ImageGenerate",
92
+ ]);
93
+ /** Keys whose array items scope the `tools` lists nested beneath them. */
94
+ const NESTED_TOOL_CONTEXTS = {
95
+ steps: "step",
96
+ nodes: "node",
97
+ roles: "role",
98
+ subAgents: "sub-agent",
99
+ };
100
+ /**
101
+ * Recursive walk gathering every string under a `tools` key together with
102
+ * the context that declares it ("agent" at the top level; `step \`x\`` /
103
+ * `node \`x\`` / `role \`x\`` / `sub-agent \`x\`` when nested). Mirrors
104
+ * `collectToolNames` in `packages/compiler`, with context tracking added.
105
+ */
106
+ function collectToolUsage(ir) {
107
+ const usage = new Map();
108
+ const add = (tool, context) => {
109
+ const contexts = usage.get(tool) ?? new Set();
110
+ contexts.add(context);
111
+ usage.set(tool, contexts);
112
+ };
113
+ const visit = (node, context) => {
114
+ if (Array.isArray(node)) {
115
+ for (const item of node)
116
+ visit(item, context);
117
+ return;
118
+ }
119
+ if (node === null || typeof node !== "object")
120
+ return;
121
+ for (const [key, value] of Object.entries(node)) {
122
+ if (key === "tools" && Array.isArray(value)) {
123
+ for (const v of value)
124
+ if (typeof v === "string")
125
+ add(v, context);
126
+ continue;
127
+ }
128
+ const label = NESTED_TOOL_CONTEXTS[key];
129
+ if (label !== undefined && Array.isArray(value)) {
130
+ for (const item of value) {
131
+ const name = item?.name;
132
+ // The context label lands in a table cell verbatim — escape the
133
+ // interpolated name (F5) but not the intentional backticks here.
134
+ visit(item, typeof name === "string" ? `${label} \`${escapeCell(name)}\`` : label);
135
+ }
136
+ continue;
137
+ }
138
+ visit(value, context);
139
+ }
140
+ };
141
+ visit(ir, "agent");
142
+ return usage;
143
+ }
144
+ /** Every tool name carrying a `tool_config` blob, across nested variants. */
145
+ function collectConfiguredToolNames(ir) {
146
+ const configured = new Set();
147
+ const visit = (node) => {
148
+ if (Array.isArray(node)) {
149
+ for (const item of node)
150
+ visit(item);
151
+ return;
152
+ }
153
+ if (node === null || typeof node !== "object")
154
+ return;
155
+ for (const [key, value] of Object.entries(node)) {
156
+ if (key === "toolConfigs" && value !== null && typeof value === "object") {
157
+ for (const name of Object.keys(value))
158
+ configured.add(name);
159
+ continue;
160
+ }
161
+ visit(value);
162
+ }
163
+ };
164
+ visit(ir);
165
+ return configured;
166
+ }
167
+ /** Deduped models across variant shapes (agent / steps / nodes / roles). */
168
+ function collectModels(ir) {
169
+ switch (ir.target) {
170
+ case "workflow":
171
+ return [...new Set(ir.steps.map((s) => s.model))];
172
+ case "graph":
173
+ return [...new Set(ir.nodes.map((n) => n.model))];
174
+ case "crew":
175
+ return [...new Set(ir.roles.map((r) => r.model))];
176
+ default:
177
+ return [ir.agent.model];
178
+ }
179
+ }
180
+ /**
181
+ * Adversarial-review F5 — escape an interpolated value for a GFM table
182
+ * cell: `|` would split the cell, a raw newline would end the row, and a
183
+ * stray backtick would terminate the code-span the tables wrap values in.
184
+ */
185
+ function escapeCell(value) {
186
+ return value.replace(/\|/g, "\\|").replace(/`/g, "\\`").replace(/\r?\n/g, " ");
187
+ }
188
+ /**
189
+ * Adversarial-review F2 — words in a CLI flag name that mark its VALUE as a
190
+ * credential (`--token=…`, `--api-key …`). Matched per dash-separated word
191
+ * so `--keyboard-layout` stays visible while `--api-key` masks.
192
+ */
193
+ const CREDENTIAL_FLAG_WORDS = new Set([
194
+ "key",
195
+ "apikey",
196
+ "token",
197
+ "secret",
198
+ "password",
199
+ "pass",
200
+ "passwd",
201
+ "pwd",
202
+ "auth",
203
+ "authorization",
204
+ "credential",
205
+ "credentials",
206
+ "bearer",
207
+ ]);
208
+ function isCredentialFlag(arg) {
209
+ const m = arg.match(/^--?([A-Za-z][A-Za-z0-9-]*)$/);
210
+ if (m?.[1] === undefined)
211
+ return false;
212
+ return m[1]
213
+ .toLowerCase()
214
+ .split("-")
215
+ .some((word) => CREDENTIAL_FLAG_WORDS.has(word));
216
+ }
217
+ /** Render a stdio server's launch line with credential-valued flags masked
218
+ * (`--token=v` and `--token v` forms) and known token shapes (`sk-…`,
219
+ * `ghp_…`, …) masked out of every other arg. */
220
+ function maskStdioCommand(command, args) {
221
+ const masked = [];
222
+ let maskNext = false;
223
+ for (const arg of args) {
224
+ if (maskNext) {
225
+ masked.push("***");
226
+ maskNext = false;
227
+ continue;
228
+ }
229
+ const eq = arg.indexOf("=");
230
+ if (arg.startsWith("-") && eq > 0 && isCredentialFlag(arg.slice(0, eq))) {
231
+ masked.push(`${arg.slice(0, eq)}=***`);
232
+ continue;
233
+ }
234
+ if (isCredentialFlag(arg)) {
235
+ masked.push(arg);
236
+ maskNext = true;
237
+ continue;
238
+ }
239
+ masked.push(maskCredentialTokens(arg));
240
+ }
241
+ return [command, ...masked].join(" ");
242
+ }
243
+ /**
244
+ * Mask the credential-bearing parts of an sse URL: userinfo passwords
245
+ * (`https://user:***@`), ALL query-parameter values (`?apikey=***` — key
246
+ * names stay, values never render), and path segments shaped like opaque
247
+ * credentials (Alchemy/Infura-style `/v2/<key>` → `/v2/***`). Regex-based
248
+ * so a not-quite-parseable URL still gets masked rather than printed raw.
249
+ */
250
+ function maskUrlCredentials(url) {
251
+ // Userinfo: keep the user, mask the password.
252
+ const out = url.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@:]+):([^/@]+)@/i, "$1:***@");
253
+ const q = out.indexOf("?");
254
+ const query = q === -1 ? "" : out.slice(q).replace(/([?&;][^=&;#]*)=([^&;#]*)/g, "$1=***");
255
+ let base = q === -1 ? out : out.slice(0, q);
256
+ const prefix = base.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i)?.[0] ?? "";
257
+ const path = base
258
+ .slice(prefix.length)
259
+ .split("/")
260
+ .map((seg) => (OPAQUE_TOKEN_RE.test(seg) ? "***" : maskCredentialTokens(seg)))
261
+ .join("/");
262
+ base = `${prefix}${path}`;
263
+ return `${base}${query}`;
264
+ }
265
+ function toolScopeHint(name) {
266
+ if (name.startsWith("mcp__"))
267
+ return "external (MCP)";
268
+ if (OUTWARD_TOOL_NAMES.has(name))
269
+ return "external";
270
+ return "built-in";
271
+ }
272
+ function toolNotes(name, configured) {
273
+ const notes = [];
274
+ if (SANDBOXED_TOOL_NAMES.has(name))
275
+ notes.push("sandboxed");
276
+ if (JUSTIFICATION_GATED_TOOL_NAMES.has(name))
277
+ notes.push("justification-gated by default");
278
+ if (configured.has(name))
279
+ notes.push("configured via `tool_config`");
280
+ return notes.length > 0 ? notes.join("; ") : "—";
281
+ }
282
+ function renderToolsSection(ir) {
283
+ const usage = collectToolUsage(ir);
284
+ if (usage.size === 0)
285
+ return undefined;
286
+ const configured = collectConfiguredToolNames(ir);
287
+ const rows = [...usage.keys()].sort().map((name) => {
288
+ const contexts = [...(usage.get(name) ?? new Set())].sort().join(", ");
289
+ return `| \`${escapeCell(name)}\` | ${contexts} | ${toolScopeHint(name)} | ${toolNotes(name, configured)} |`;
290
+ });
291
+ return ["| Tool | Used by | Scope | Notes |", "| --- | --- | --- | --- |", ...rows].join("\n");
292
+ }
293
+ function renderMcpSection(ir) {
294
+ const servers = ir.mcp_servers;
295
+ if (servers === undefined)
296
+ return undefined;
297
+ const entries = Object.entries(servers);
298
+ if (entries.length === 0)
299
+ return undefined;
300
+ // Endpoint column: command for stdio, URL for sse. Env values and sse
301
+ // headers are intentionally NOT rendered — they can carry credentials —
302
+ // and the command/URL themselves are masked (adversarial-review F2):
303
+ // credential-valued flags, known token shapes in args, URL userinfo
304
+ // passwords, ALL query-parameter values, and opaque path segments.
305
+ const rows = entries.map(([name, cfg]) => cfg.transport === "stdio"
306
+ ? `| \`${escapeCell(name)}\` | stdio | \`${escapeCell(maskStdioCommand(cfg.command, cfg.args))}\` |`
307
+ : `| \`${escapeCell(name)}\` | sse | \`${escapeCell(maskUrlCredentials(cfg.url))}\` |`);
308
+ return ["| Server | Transport | Endpoint |", "| --- | --- | --- |", ...rows].join("\n");
309
+ }
310
+ function renderEnvSection(ir) {
311
+ const { envNames, literalCount } = collectSecretRefs(ir);
312
+ const models = collectModels(ir);
313
+ const lines = [];
314
+ if (envNames.length > 0) {
315
+ lines.push("Set these before launching — the bundle reads them from `process.env` at runtime:", "", ...envNames.map((name) => `- \`${name}\``));
316
+ }
317
+ else {
318
+ lines.push("No spec-declared environment variables.");
319
+ }
320
+ if (literalCount > 0) {
321
+ lines.push("", `> ${literalCount} secret-shaped value(s) were supplied as literals in the spec and are compiled into the bundle — they are not shown here. Prefer \`$UPPER_SNAKE_CASE\` env references so credentials stay out of compiled artifacts.`);
322
+ }
323
+ lines.push("", `The model provider's API key must also be present (e.g. \`ANTHROPIC_API_KEY\` for Anthropic \`claude-*\` models; model(s) in use: ${models
324
+ .map((m) => `\`${m}\``)
325
+ .join(", ")}).`);
326
+ return lines.join("\n");
327
+ }
328
+ function defaultUsageSection(ir) {
329
+ return {
330
+ heading: "Run",
331
+ body: ["```sh", RUN_COMMANDS[ir.target], "```"].join("\n"),
332
+ };
333
+ }
334
+ const WORKSPACE_NOTE = [
335
+ "Launch the bundle from inside this directory — it reads and writes workspace state under `.crewhaus/` relative to the working directory:",
336
+ "",
337
+ "- `.crewhaus/sessions/` — session transcripts (one file per session)",
338
+ "- `.crewhaus/feedback/` — response ratings collected by the feedback tooling",
339
+ ].join("\n");
340
+ /**
341
+ * Render the generated bundle README from a lowered IR. Pure; no I/O.
342
+ * Deterministic for a given IR (tables and env lists are sorted) so
343
+ * recompiles diff cleanly.
344
+ */
345
+ export function renderBundleReadme(ir, opts = {}) {
346
+ const description = opts.description ?? `Compiled CrewHaus bundle for the \`${ir.target}\` target shape.`;
347
+ const models = collectModels(ir);
348
+ const harnessRows = [
349
+ "| | |",
350
+ "| --- | --- |",
351
+ `| Name | \`${escapeCell(ir.name)}\` |`,
352
+ `| Target | \`${ir.target}\` |`,
353
+ `| ${models.length > 1 ? "Models" : "Model"} | ${models
354
+ .map((m) => `\`${escapeCell(m)}\``)
355
+ .join(", ")} |`,
356
+ ].join("\n");
357
+ const sections = [{ heading: "Harness", body: harnessRows }];
358
+ const tools = renderToolsSection(ir);
359
+ if (tools !== undefined)
360
+ sections.push({ heading: "Tools", body: tools });
361
+ const mcp = renderMcpSection(ir);
362
+ if (mcp !== undefined)
363
+ sections.push({ heading: "MCP servers", body: mcp });
364
+ sections.push({ heading: "Environment variables", body: renderEnvSection(ir) });
365
+ sections.push(opts.usage ?? defaultUsageSection(ir));
366
+ if (opts.includeWorkspaceNote !== false) {
367
+ sections.push({ heading: "Runtime data", body: WORKSPACE_NOTE });
368
+ }
369
+ sections.push(...(opts.extraSections ?? []));
370
+ return [
371
+ GENERATED_README_MARKER,
372
+ "",
373
+ `# ${ir.name}`,
374
+ "",
375
+ description,
376
+ "",
377
+ "Generated by CrewHaus — do not edit by hand; recompile from the spec instead.",
378
+ "",
379
+ ...sections.flatMap((s) => [`## ${s.heading}`, "", s.body, ""]),
380
+ ].join("\n");
381
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Credential redaction for HUMAN-READABLE renderings of spec/IR content —
3
+ * changelog diff lines (`spec-patch`) and generated bundle READMEs (`ir`).
4
+ * Two layers:
5
+ *
6
+ * 1. `isCredentialKey` — key-based: a value stored under a key that NAMES
7
+ * a credential (`api_key`, `botToken`, `headers`, `env`, …) is redacted
8
+ * wholesale, whatever the value looks like.
9
+ * 2. `maskCredentialTokens` — value-based: strings that are NOT under a
10
+ * credential key (instructions prose, command args, URLs) are scanned
11
+ * for well-known credential token shapes (`sk-…`, `ghp_…`, `xoxb-…`,
12
+ * `AKIA…`, `Bearer <token>`) plus high-length opaque tokens preceded by
13
+ * key-ish context words. Deliberately conservative: a bare 32-char
14
+ * identifier with no "key/token/secret/password" context is left alone
15
+ * so normal prose never gets chewed up.
16
+ *
17
+ * KEEP IN SYNC: this module is intentionally duplicated as
18
+ * `packages/ir/src/redact.ts` and `packages/spec-patch/src/redact.ts`.
19
+ * `@crewhaus/ir` keeps ZERO package dependencies (its `readme.ts` already
20
+ * mirrors `OUTWARD_TOOL_NAMES` from tool-builder for the same reason) and
21
+ * `spec-patch` is spec-layer infrastructure that must not grow an edge onto
22
+ * the IR layer — so neither package can host the single copy without a new
23
+ * dependency edge. Change one file, change both.
24
+ */
25
+ /** Placeholder rendered in place of a value under a credential-carrying key. */
26
+ export declare const REDACTED_VALUE = "[redacted]";
27
+ /** Placeholder substituted for a credential-shaped token inside a string. */
28
+ export declare const MASKED_TOKEN = "***";
29
+ /**
30
+ * Whether a property key names a credential. Suffix matches require a word
31
+ * boundary (snake/kebab/camel) so `api_key` / `botToken` / `GITHUB_TOKEN` /
32
+ * `signingSecret` redact while `monkey` / `max_tokens` don't.
33
+ */
34
+ export declare function isCredentialKey(key: string): boolean;
35
+ /** A path segment / standalone word shaped like an opaque credential
36
+ * (Alchemy/Infura-style `/v2/<key>`): 32+ chars of token alphabet. */
37
+ export declare const OPAQUE_TOKEN_RE: RegExp;
38
+ /**
39
+ * Mask credential-shaped tokens inside a string. Non-credential text is
40
+ * returned unchanged (hit/no-hit cases are unit-tested in both packages).
41
+ */
42
+ export declare function maskCredentialTokens(text: string): string;
package/dist/redact.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Credential redaction for HUMAN-READABLE renderings of spec/IR content —
3
+ * changelog diff lines (`spec-patch`) and generated bundle READMEs (`ir`).
4
+ * Two layers:
5
+ *
6
+ * 1. `isCredentialKey` — key-based: a value stored under a key that NAMES
7
+ * a credential (`api_key`, `botToken`, `headers`, `env`, …) is redacted
8
+ * wholesale, whatever the value looks like.
9
+ * 2. `maskCredentialTokens` — value-based: strings that are NOT under a
10
+ * credential key (instructions prose, command args, URLs) are scanned
11
+ * for well-known credential token shapes (`sk-…`, `ghp_…`, `xoxb-…`,
12
+ * `AKIA…`, `Bearer <token>`) plus high-length opaque tokens preceded by
13
+ * key-ish context words. Deliberately conservative: a bare 32-char
14
+ * identifier with no "key/token/secret/password" context is left alone
15
+ * so normal prose never gets chewed up.
16
+ *
17
+ * KEEP IN SYNC: this module is intentionally duplicated as
18
+ * `packages/ir/src/redact.ts` and `packages/spec-patch/src/redact.ts`.
19
+ * `@crewhaus/ir` keeps ZERO package dependencies (its `readme.ts` already
20
+ * mirrors `OUTWARD_TOOL_NAMES` from tool-builder for the same reason) and
21
+ * `spec-patch` is spec-layer infrastructure that must not grow an edge onto
22
+ * the IR layer — so neither package can host the single copy without a new
23
+ * dependency edge. Change one file, change both.
24
+ */
25
+ /** Placeholder rendered in place of a value under a credential-carrying key. */
26
+ export const REDACTED_VALUE = "[redacted]";
27
+ /** Placeholder substituted for a credential-shaped token inside a string. */
28
+ export const MASKED_TOKEN = "***";
29
+ /**
30
+ * Keys that carry credentials, matched case-insensitively after lowercasing.
31
+ * Aligned with the spec schema's credential carriers (`botToken`,
32
+ * `signingSecret`, `appToken`, `secretToken`, `accessToken`, `appSecret`,
33
+ * `retrieve.apiKey`, wallet `keyRef`) and the compiler's `lowerCredential` /
34
+ * `lowerWalletKeyRef` call sites — see `packages/compiler/src/index.ts` §12.
35
+ * `headers` and `env` are container keys: everything under them redacts.
36
+ */
37
+ const CREDENTIAL_KEY_EXACT = new Set([
38
+ "key",
39
+ "apikey",
40
+ "api_key",
41
+ "api-key",
42
+ "token",
43
+ "secret",
44
+ "password",
45
+ "passwd",
46
+ "pwd",
47
+ "authorization",
48
+ "auth",
49
+ "credential",
50
+ "credentials",
51
+ "headers",
52
+ "env",
53
+ "keyref",
54
+ "key_ref",
55
+ "key-ref",
56
+ "privatekey",
57
+ "private_key",
58
+ "private-key",
59
+ ]);
60
+ /**
61
+ * Whether a property key names a credential. Suffix matches require a word
62
+ * boundary (snake/kebab/camel) so `api_key` / `botToken` / `GITHUB_TOKEN` /
63
+ * `signingSecret` redact while `monkey` / `max_tokens` don't.
64
+ */
65
+ export function isCredentialKey(key) {
66
+ const k = key.toLowerCase();
67
+ if (CREDENTIAL_KEY_EXACT.has(k))
68
+ return true;
69
+ if (/[_-](?:key|token|secret|password)$/.test(k))
70
+ return true;
71
+ return /[a-z0-9](?:Key|Token|Secret|Password)$/.test(key);
72
+ }
73
+ /** A path segment / standalone word shaped like an opaque credential
74
+ * (Alchemy/Infura-style `/v2/<key>`): 32+ chars of token alphabet. */
75
+ export const OPAQUE_TOKEN_RE = /^[A-Za-z0-9_-]{32,}$/;
76
+ /** Well-known credential token shapes, masked wherever they appear. */
77
+ const TOKEN_SHAPE_RES = [
78
+ /\bsk-[A-Za-z0-9_-]{8,}/g, // OpenAI/Anthropic/Stripe-style secret keys
79
+ /\bgh[oprsu]_[A-Za-z0-9]{16,}/g, // GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_)
80
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
81
+ /\bAKIA[A-Z0-9]{12,}/g, // AWS access key ids
82
+ ];
83
+ /** `Bearer <token>` — the scheme word is kept, the token is masked. */
84
+ const BEARER_RE = /\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi;
85
+ /**
86
+ * Generic 32+-char opaque token, masked ONLY when preceded by a key-ish
87
+ * context word — `key: XXXX…`, `token=XXXX…` — so hashes/ids in ordinary
88
+ * prose survive. Group 1 (the context) is kept; the token is masked.
89
+ */
90
+ const CONTEXTUAL_OPAQUE_RE = /\b((?:api[-_ ]?)?(?:key|token|secret|password|credential)s?\b["'\s:=-]{0,5})([A-Za-z0-9+/_-]{32,})/gi;
91
+ /**
92
+ * Mask credential-shaped tokens inside a string. Non-credential text is
93
+ * returned unchanged (hit/no-hit cases are unit-tested in both packages).
94
+ */
95
+ export function maskCredentialTokens(text) {
96
+ let out = text;
97
+ for (const re of TOKEN_SHAPE_RES)
98
+ out = out.replace(re, MASKED_TOKEN);
99
+ out = out.replace(BEARER_RE, `$1 ${MASKED_TOKEN}`);
100
+ out = out.replace(CONTEXTUAL_OPAQUE_RE, `$1${MASKED_TOKEN}`);
101
+ return out;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/ir",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Canonical typed intermediate representation",
6
6
  "main": "dist/index.js",