@crewhaus/spec 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +1253 -60
  2. package/dist/index.js +257 -11
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1110,7 +1110,8 @@ const continuityBlock = z.union([z.boolean(), continuityObject]).optional();
1110
1110
  /**
1111
1111
  * v0.3.0 Goal 3 (§4.1) — the top-level `thredz:` block: ONE knob that flips
1112
1112
  * the memory fabric's wiki backend to a hosted Thredz wiki over the published
1113
- * `thredz-mcp` stdio server (npm, v0.2.0 — 25 tools incl. `goal_*`/`task_*`).
1113
+ * `thredz-mcp` stdio server (npm, v0.3.0 — 27 tools incl. `goal_*`/`task_*`
1114
+ * and the `wiki_space_*` pair).
1114
1115
  *
1115
1116
  * Forms:
1116
1117
  * - boolean shorthand: `thredz: true` ≡ `{ api_key: "$THREDZ_API_KEY" }`
@@ -1142,6 +1143,22 @@ const thredzObject = z
1142
1143
  api_key: z.string().min(1),
1143
1144
  base_url: z.string().url().optional(),
1144
1145
  visibility: z.enum(["private", "shared"]).optional(),
1146
+ /**
1147
+ * 0.5.0 — a Thredz **wiki space** (Pro/Scale) to scope this agent's memory
1148
+ * to; becomes the synthesized server's `THREDZ_DEFAULT_SPACE`. A `shared`
1149
+ * space is readable by every wiki-enabled key on the account; an
1150
+ * `individual` space only by the key that owns it.
1151
+ *
1152
+ * The space TYPE is chosen when the space is created (over the API, or via
1153
+ * the model-callable `wiki_space_create`), not here — so this cannot be
1154
+ * validated at compile time and `visibility` is not cross-checked against
1155
+ * it. Inside a space the space's type decides visibility outright.
1156
+ *
1157
+ * ONE individual space per API KEY is a hard Thredz limit, so per-agent
1158
+ * private memory means a per-agent `api_key`. That is one `thredz:` block
1159
+ * per spec today; a crew gives each role its own (see the role-keyed form).
1160
+ */
1161
+ space: z.string().min(1).optional(),
1145
1162
  goals: z.boolean().optional(),
1146
1163
  agents: z
1147
1164
  .union([
@@ -1162,6 +1179,48 @@ const thredzObject = z
1162
1179
  })
1163
1180
  .strict();
1164
1181
  const thredzBlock = z.union([z.boolean(), z.string().min(1), thredzObject]).optional();
1182
+ /**
1183
+ * 0.5.0 — the CREW-ONLY superset of {@link thredzObject}. `roles` fans the
1184
+ * block out per role so each role can carry its OWN `api_key` and its own
1185
+ * `space`; every other field at this level is the DEFAULT a role inherits and
1186
+ * may override.
1187
+ *
1188
+ * WHY THE MAP LIVES HERE, under `thredz.roles.<role>`, and NOT on the role as
1189
+ * `roles.<role>.thredz` — this is load-bearing, not taste. Two security
1190
+ * surfaces prefix-match on `["thredz"]`:
1191
+ *
1192
+ * - `@crewhaus/spec-patch`'s `OPTIMIZABLE_PATHS.crew` allows `["roles"]`
1193
+ * (whole-role replacement) and matches by PREFIX. Under `roles.*`, a
1194
+ * role's `api_key` would become optimizer-reachable and
1195
+ * `optimize --write-back` could rewrite a credential.
1196
+ * - the hangar's spec editor denies the `["thredz"]` prefix outright
1197
+ * ("thredz crosses the harness boundary to a hosted wiki"). Under
1198
+ * `roles.*`, `api_key` would be editable from a browser.
1199
+ *
1200
+ * Keeping the fan-out under `thredz.` inherits both protections with zero
1201
+ * code change. The `^thredz:` header/badge regexes keep matching too.
1202
+ *
1203
+ * `api_key` is optional ONLY here: a pure fan-out crew gives every role its
1204
+ * own key and needs no crew-wide one. The refinement below enforces that at
1205
+ * least one of the two exists.
1206
+ */
1207
+ const crewThredzObject = thredzObject
1208
+ .extend({
1209
+ api_key: z.string().min(1).optional(),
1210
+ roles: z.record(safeName, thredzObject.partial({ api_key: true })).optional(),
1211
+ })
1212
+ .strict()
1213
+ .superRefine((t, ctx) => {
1214
+ const fanOut = t.roles !== undefined && Object.keys(t.roles).length > 0;
1215
+ if (!fanOut && t.api_key === undefined) {
1216
+ ctx.addIssue({
1217
+ code: "custom",
1218
+ message: "thredz.api_key is required unless thredz.roles gives each role its own key (one individual wiki space per API key is a hard Thredz limit, so per-role private memory needs per-role keys)",
1219
+ });
1220
+ }
1221
+ });
1222
+ /** The crew mount: the shorthands still work, plus the role-keyed superset. */
1223
+ const crewThredzBlock = z.union([z.boolean(), z.string().min(1), crewThredzObject]).optional();
1165
1224
  /**
1166
1225
  * v0.3.0 Goal 2 (§3.3, PR 17) — the top-level `learning:` block: continual
1167
1226
  * learning as a first-class capability. Presence (with `enabled` not `false`)
@@ -1325,6 +1384,57 @@ const observabilityBlock = z
1325
1384
  })
1326
1385
  .strict()
1327
1386
  .optional();
1387
+ /**
1388
+ * "Watch me" — the sampled phase-2 judge pass of `crewhaus watchme report`,
1389
+ * the ONE model-spending analysis phase. Absent ⇒ deterministic-only reports
1390
+ * (the field defaults below still resolve at lower time, so a budgeted judge
1391
+ * always names a model).
1392
+ */
1393
+ const watchmeJudgeBlock = z
1394
+ .object({
1395
+ /** Judge model for the sampled phase-2 quality pass. Refused at runtime
1396
+ * if unpriced (dream-engine pattern) — the budget cap must be
1397
+ * enforceable. */
1398
+ model: z.string().min(1).default("claude-haiku-4-5"),
1399
+ /** Fraction of ungraded turns escalated to the judge. */
1400
+ sample_rate: z.number().min(0).max(1).default(0.15),
1401
+ /** Per-report spend cap. 0 (default) = deterministic-only reports. */
1402
+ budget_usd: z.number().min(0).default(0),
1403
+ })
1404
+ .strict();
1405
+ /**
1406
+ * "Watch me" — observe this harness's interactions and learn from them
1407
+ * (design/watch-me.md). Presence turns on the live capture tap; `crewhaus
1408
+ * watchme report` distills the watched sessions post-hoc. Carried on the
1409
+ * three interactive-loop shapes (cli, channel, managed); the strict unions
1410
+ * reject the key loudly elsewhere (research/crew are a named deferral,
1411
+ * design/watch-me.md §13.1).
1412
+ *
1413
+ * Deliberately a SIBLING of `observability:`, not a sub-key of it:
1414
+ * observability controls the generic telemetry subscribers (ring buffer,
1415
+ * printers, metrics, cost, alerts, otel) while watchme is a learning feature
1416
+ * with its own durable store and spec-synthesis outputs. Capture is
1417
+ * INDEPENDENT of `observability.trace.level` — that knob controls the ring
1418
+ * buffer + printers only, never the watchme tap.
1419
+ *
1420
+ * Every knob defaults, so a bare `watchme: {}` is a complete declaration.
1421
+ * NO `watchme.*` path is optimizer-tunable — see the exclusion note beside
1422
+ * OPTIMIZABLE_PATHS in `@crewhaus/spec-patch`.
1423
+ */
1424
+ const watchmeBlock = z
1425
+ .object({
1426
+ enabled: z.boolean().default(true),
1427
+ /** "full" = write the .events.jsonl trace sibling; "mirrors" = rely on the
1428
+ * default-on advisor mirrors only (retro-analysis grade, no extra file). */
1429
+ capture: z.enum(["full", "mirrors"]).default("full"),
1430
+ judge: watchmeJudgeBlock.optional(),
1431
+ /** "user" additionally registers this harness in the global registry at run time. */
1432
+ scope: z.enum(["harness", "user"]).default("harness"),
1433
+ /** Publish redacted distilled findings to the wiki/Thredz at report time. */
1434
+ share: z.boolean().default(false),
1435
+ })
1436
+ .strict()
1437
+ .optional();
1328
1438
  /**
1329
1439
  * Section 47 — blockchain subsystem blocks (cross-cutting). Any shape may
1330
1440
  * declare any subset of `chains` / `wallets` / `contracts` /
@@ -1396,10 +1506,13 @@ const contractsBlock = z.array(contractBindingSchema).optional();
1396
1506
  const transactionPolicyBlock = transactionPolicySchema.optional();
1397
1507
  /**
1398
1508
  * Phase 3 §3.3 — CLI banner with optional tagline rotation. When set,
1399
- * the compiled cli-target bundle prints this banner on cold start
1400
- * (suppressed under `--resume` / `--continue` so resumed sessions
1401
- * don't re-banner). Static mode picks the first tagline; random mode
1402
- * picks one uniformly per startup.
1509
+ * BOTH cli surfaces print this banner on cold start — the compiled
1510
+ * bundle and `crewhaus run` (which used to ignore the block entirely,
1511
+ * making an authored banner invisible to anyone who ran the spec
1512
+ * directly). Suppressed under `--resume` / `--continue` so resumed
1513
+ * sessions don't re-banner, and under `CREWHAUS_RESUMED=1` for a
1514
+ * wrapper re-invoking a compiled bundle. Static mode picks the first
1515
+ * tagline; random mode picks one uniformly per startup.
1403
1516
  */
1404
1517
  const cliBannerBlock = z
1405
1518
  .object({
@@ -1602,6 +1715,8 @@ const cliSchema = z
1602
1715
  thredz: thredzBlock,
1603
1716
  learning: learningBlock,
1604
1717
  observability: observabilityBlock,
1718
+ // "Watch me" — observe-and-learn (sibling of observability, see watchmeBlock).
1719
+ watchme: watchmeBlock,
1605
1720
  cli: cliOptionsBlock,
1606
1721
  chains: chainsBlock,
1607
1722
  wallets: walletsBlock,
@@ -1710,6 +1825,15 @@ const whatsappChannelSchema = z
1710
1825
  phoneNumberId: z.string().min(1),
1711
1826
  accessToken: z.string().min(1),
1712
1827
  appSecret: z.string().min(1),
1828
+ // The token Meta presents on the GET callback-URL verification handshake
1829
+ // (`hub.verify_token`). Optional: a daemon serving an already-verified
1830
+ // subscription does not need it, and without it the handshake fails
1831
+ // closed rather than echoing an unauthenticated challenge.
1832
+ verifyToken: z
1833
+ .string()
1834
+ .min(1)
1835
+ .optional()
1836
+ .describe("shared token echoed back on Meta's GET callback-URL verification handshake; required to verify a new webhook subscription"),
1713
1837
  })
1714
1838
  .strict();
1715
1839
  const imessageChannelSchema = z
@@ -1792,6 +1916,8 @@ const channelSchema = z
1792
1916
  thredz: thredzBlock,
1793
1917
  learning: learningBlock,
1794
1918
  observability: observabilityBlock,
1919
+ // "Watch me" — observe-and-learn (sibling of observability, see watchmeBlock).
1920
+ watchme: watchmeBlock,
1795
1921
  heartbeat: heartbeatBlock,
1796
1922
  // Loop contract 0.4 (Batch F) — cron/interval wake trigger (the general
1797
1923
  // temporal surface beside the interval-only `heartbeat`).
@@ -1818,9 +1944,28 @@ const graphNodeSchema = z
1818
1944
  tools: z.array(z.string().min(1)).optional(),
1819
1945
  tool_config: toolConfigBlock,
1820
1946
  /**
1821
- * When true, the node calls `ctx.requestApproval(prompt)` before
1822
- * returning. The engine pauses, persists a checkpoint, and waits for
1823
- * `resume(checkpointId, decision)` from the operator/CLI.
1947
+ * A human approval gate on this node, and a PRE-condition: the node
1948
+ * calls `ctx.requestApproval(prompt)` BEFORE its model turn, so the
1949
+ * prompt is answered against the UPSTREAM state (which the `hitl_pause`
1950
+ * event and the bundle's pause report both print) and no tokens are
1951
+ * spent until the human answers. The engine pauses, persists a
1952
+ * checkpoint, and waits for `resume(checkpointId, decision)` from the
1953
+ * operator/CLI; the resumed run replays this node from the top and
1954
+ * makes its FIRST model call.
1955
+ *
1956
+ * The decision string is recorded at `state["<node>_decision"]`, which
1957
+ * every downstream node reads as part of the upstream state. (NOTE:
1958
+ * `edges[].when.key` cannot name it yet — that key must name a declared
1959
+ * node; see the `graphEdgeWhenSchema` note below.) A rejecting decision
1960
+ * — `reject`, `no`, `deny`, `decline`, `abort`, `cancel`, `stop`, `veto`
1961
+ * (trimmed, case-insensitive) — cancels this node's turn entirely, so
1962
+ * the node records only its decision and no output; any other string,
1963
+ * including free text, approves it. To halt the run on a rejection,
1964
+ * guard the node's outgoing edge with `when: { key: <node>, exists:
1965
+ * true }` — a cancelled node records no output, so no edge matches.
1966
+ *
1967
+ * To have a human approve a node's OWN output, put the gate on the
1968
+ * DOWNSTREAM node: its upstream state is exactly that output.
1824
1969
  */
1825
1970
  hitl: z
1826
1971
  .object({
@@ -1857,8 +2002,16 @@ const graphAnyNodeSchema = z.union([graphNodeSchema, graphJudgeNodeSchema]);
1857
2002
  * - `equals` — take the edge when `state[key] === equals` (string/number/
1858
2003
  * boolean strict equality).
1859
2004
  * - `exists: true` — take the edge when `state[key] !== undefined` (the
1860
- * node has produced output; pairs with hitl `_decision` gating in a
1861
- * follow-up).
2005
+ * node has produced output which, for a `hitl:` node, is FALSE when
2006
+ * the operator rejected the gate, since a rejected node records only
2007
+ * `state["<node>_decision"]`).
2008
+ *
2009
+ * GAP (unchanged by the pre-condition HITL fix): `key` may not yet name a
2010
+ * hitl node's `<node>_decision` record — the cross-check below pins it to a
2011
+ * declared node name, so a rejection can be observed via `exists` on the
2012
+ * node itself but not matched on the decision string. Widening it means
2013
+ * touching the three mirrored checks (this one, ir-passes' graph
2014
+ * wellformedness, target-graph's validateGraph).
1862
2015
  *
1863
2016
  * Lowered to `IrGraphEdge.when` and emitted as a graph-engine
1864
2017
  * `EdgeCondition` (`(state) => state[key] === equals` / `!== undefined`).
@@ -1979,6 +2132,23 @@ const managedSchema = z
1979
2132
  expose: exposeBlock,
1980
2133
  // Loop contract 0.4 (Batch B, G02) — in-loop output evaluation.
1981
2134
  evaluation: evaluationBlock,
2135
+ // NEW-inloop-coverage — human-rating capture on the GATEWAY shape.
2136
+ //
2137
+ // WHAT MANAGED SUPPORTS: the daemon serves a `feedback.submit` JSON-RPC
2138
+ // method (params = the user-supplied FeedbackRecord subset) that appends
2139
+ // a standard record to `.crewhaus/feedback/<tenant>.jsonl` — the exact
2140
+ // sink `crewhaus distill` / `optimize --ratings` / `judge calibrate`
2141
+ // already read; and `autoDistill: true` registers the janitor step that
2142
+ // turns those ratings into versioned `<name>-ratings` registry datasets
2143
+ // on the daemon's own clock (D39), because a gateway daemon never runs a
2144
+ // `crewhaus run` teardown.
2145
+ //
2146
+ // WHAT IT CANNOT SUPPORT: `exitPrompt` is meaningless here (there is no
2147
+ // REPL to exit — the compiler warns when it is set), and
2148
+ // `channelReactions` is the channel shape's own inbound-reaction gate
2149
+ // (also warned). Both parse for schema uniformity across shapes rather
2150
+ // than being silently honoured.
2151
+ feedback: feedbackBlock,
1982
2152
  memory: memoryBlock,
1983
2153
  // Loop contract 0.4 (Batch E, G22) — agent-shape RAG over doc sources.
1984
2154
  knowledge: knowledgeBlock,
@@ -1986,6 +2156,9 @@ const managedSchema = z
1986
2156
  thredz: thredzBlock,
1987
2157
  learning: learningBlock,
1988
2158
  observability: observabilityBlock,
2159
+ // "Watch me" — observe-and-learn (sibling of observability, see watchmeBlock).
2160
+ // Parse + lower ONLY on this shape in v1: compile() warns accepted-but-unwired.
2161
+ watchme: watchmeBlock,
1989
2162
  // Loop contract 0.4 (Batch F) — cron/interval wake trigger.
1990
2163
  schedule: scheduleBlock,
1991
2164
  })
@@ -2136,7 +2309,7 @@ const crewSchema = z
2136
2309
  // surface, §2.7).
2137
2310
  memory: memoryBlock,
2138
2311
  continuity: continuityBlock,
2139
- thredz: thredzBlock,
2312
+ thredz: crewThredzBlock,
2140
2313
  learning: learningBlock,
2141
2314
  // Loop contract 0.4 (Batch C, G26) — crew joins the observability-carrying
2142
2315
  // shapes (cli/channel/managed): the orchestrator's cost/trace/metrics/
@@ -2287,6 +2460,22 @@ const browserDriverSchema = z
2287
2460
  .strict()
2288
2461
  .default({ width: 1280, height: 720 }),
2289
2462
  startUrl: z.string().url().optional(),
2463
+ /**
2464
+ * SECURITY — opt in to private/loopback navigation targets. Default false:
2465
+ * the Navigate tool refuses private/loopback/link-local/metadata hosts
2466
+ * before `driver.goto`, and the chromium backend routes every request
2467
+ * through a DNS-pinning proxy that refuses the same floor at the
2468
+ * connection layer. Together they stop a prompt-injected page from
2469
+ * reaching the host's own services.
2470
+ *
2471
+ * Set true ONLY when the browser legitimately must reach a private target
2472
+ * the operator controls AND the page content is trusted — an intranet app
2473
+ * under test, or a locally-served fixture page (what the browser runtime
2474
+ * smoke does). It relaxes BOTH layers for this spec, so it stays a
2475
+ * per-spec reviewed decision and never a global switch. The http/https
2476
+ * scheme allowlist is NOT waived.
2477
+ */
2478
+ allowPrivateTargets: z.boolean().default(false),
2290
2479
  })
2291
2480
  .strict();
2292
2481
  const browserSchema = z
@@ -2517,6 +2706,48 @@ function crossFieldIssues(data) {
2517
2706
  if (roleNames.length > 0 && !roleNames.includes(data.entry)) {
2518
2707
  custom(["entry"], `crew.entry "${data.entry}" must name one of crew.roles (got: ${roleNames.join(", ")})`);
2519
2708
  }
2709
+ // 0.5.0 — the role-keyed thredz fan-out. Both checks are cross-field, so
2710
+ // they cannot live in the schema: `thredz.roles` and `roles` are siblings.
2711
+ const crewThredz = data.thredz;
2712
+ if (typeof crewThredz === "object" && crewThredz !== null && "roles" in crewThredz) {
2713
+ const fanOut = crewThredz.roles;
2714
+ const inheritedKey = crewThredz.api_key;
2715
+ if (fanOut !== undefined) {
2716
+ for (const name of Object.keys(fanOut)) {
2717
+ if (!roleNames.includes(name)) {
2718
+ custom(["thredz", "roles", name], `thredz.roles["${name}"]: no such role — crew.roles declares ${roleNames.join(", ")}`);
2719
+ }
2720
+ }
2721
+ // Every role must resolve to a key: its own, or the inherited one. A
2722
+ // role with neither would silently get NO hosted wiki while its
2723
+ // siblings got one, which is the kind of gap you find in production.
2724
+ if (inheritedKey === undefined) {
2725
+ for (const name of roleNames) {
2726
+ if (fanOut[name]?.api_key === undefined) {
2727
+ custom(["thredz", "roles", name], `role "${name}" has no Thredz api_key and thredz.api_key is not set — give it one, or set a crew-wide thredz.api_key for the roles that share a wiki`);
2728
+ }
2729
+ }
2730
+ }
2731
+ // Two role names that slugify to the same MCP server name would make
2732
+ // one silently overwrite the other's server entry.
2733
+ const slugs = new Map();
2734
+ for (const name of Object.keys(fanOut)) {
2735
+ const slug = name
2736
+ .toLowerCase()
2737
+ .replace(/[^a-z0-9]+/g, "-")
2738
+ .replace(/^-+|-+$/g, "");
2739
+ if (slug === "") {
2740
+ custom(["thredz", "roles", name], `thredz.roles["${name}"]: the role name has no characters usable in an MCP server name — rename the role`);
2741
+ continue;
2742
+ }
2743
+ const clash = slugs.get(slug);
2744
+ if (clash !== undefined) {
2745
+ custom(["thredz", "roles", name], `thredz.roles["${name}"] and thredz.roles["${clash}"] both reduce to the MCP server name "thredz-${slug}" — rename one`);
2746
+ }
2747
+ slugs.set(slug, name);
2748
+ }
2749
+ }
2750
+ }
2520
2751
  if (data.routing !== undefined && data.routing.kind === "match" && data.routing.match) {
2521
2752
  for (const [from, rules] of Object.entries(data.routing.match)) {
2522
2753
  if (!roleNames.includes(from)) {
@@ -2597,6 +2828,21 @@ function crossFieldIssues(data) {
2597
2828
  custom(["retrieve", "collection"], `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.collection`);
2598
2829
  }
2599
2830
  }
2831
+ // "Watch me" (design/watch-me.md §4.2) — `watchme.share: true` publishes
2832
+ // co-learning articles, so it conflicts with a thredz OBJECT that declares
2833
+ // an EXPLICIT `visibility: "private"`. The boolean/string shorthands
2834
+ // (default-private) get NO issue in v1 — publishing then lands
2835
+ // private-visibility articles, legal single-agent behaviour — and an
2836
+ // absent `thredz:` block is fine (publish degrades to the local wiki
2837
+ // store, a feature not an error).
2838
+ if (data.target === "cli" || data.target === "channel" || data.target === "managed") {
2839
+ const thredz = data.thredz;
2840
+ if (data.watchme?.share === true &&
2841
+ typeof thredz === "object" &&
2842
+ thredz.visibility === "private") {
2843
+ custom(["watchme", "share"], "watchme.share publishes co-learning articles; thredz.visibility: private blocks cross-agent sharing — set visibility: shared or drop watchme.share");
2844
+ }
2845
+ }
2600
2846
  return issues;
2601
2847
  }
2602
2848
  export function parseSpec(yamlText) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "User-facing spec schema (Zod) + YAML parser",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.4.0",
18
+ "@crewhaus/errors": "0.5.0",
19
19
  "yaml": "^2.6.0",
20
20
  "zod": "^3.23.8",
21
21
  "zod-to-json-schema": "^3.23.5"