wicked-crew 0.7.19 → 0.7.21

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.
@@ -4,6 +4,7 @@ import { CoreAdapter } from '../core/adapter.js';
4
4
  import { type GateCache } from './gate-cache.js';
5
5
  import type { ElicitationCache } from './elicitation-cache.js';
6
6
  import { QeGateCache } from '../qe/gate-events.js';
7
+ import type { ConformanceRule, SteeringType } from '../core/types.js';
7
8
  import { SeatHealthTracker } from './seat-health.js';
8
9
  import { type ProjectRoutesDeps } from '../projects/routes.js';
9
10
  import { InteractiveBridgePool } from '../interactive/bridge-pool.js';
@@ -18,6 +19,30 @@ import { DeliveryDerivationCache } from './delivery-cache.js';
18
19
  import { type DeliverExec, type WorktreeReprovisioner } from './post-hoc-deliver.js';
19
20
  import { type AuthMode } from './auth.js';
20
21
  export { API_PREFIX } from './api-prefix.js';
22
+ /**
23
+ * Map an APPROVED policy proposal (estate `proposal.approve` → `handed_off`) into a steering
24
+ * ConformanceRule (DES-MEM-FACETED-001 §5.2). A policy proposal's `kind_type` is
25
+ * `policy:<steering_type>` and its `payload` is `{ rule, severity }`; estate writes NOTHING for it
26
+ * (the AW-11 "no rules.write on estate" invariant) and hands the payload back for crew — the ONE
27
+ * governed rules-write path — to land. Returns the rule to upsert plus the resolved steering type,
28
+ * or a loud `error` string when the proposal cannot be shaped into a valid rule (a malformed
29
+ * `kind_type` / missing `rule` / out-of-enum `severity`) — the caller reports it as a failed
30
+ * landing, never a silent drop (the crew#388 anti-silent-loss doctrine).
31
+ *
32
+ * The rule id is DETERMINISTIC (`proposal:<id>`) so a re-driven landing UPSERTS the same rule
33
+ * (idempotent) instead of minting a duplicate; it sits OUTSIDE the reserved `PAT-/POL-` namespace,
34
+ * which UI/chat/proposal-authored rules are free to do (INV-C1). The rule carries NO `effect`
35
+ * (recall-only — exactly what `{rule, severity}` supports: an enforcement rule would need a
36
+ * non-blank `applies_to`, which the payload does not carry, and INV-S3 fails such a rule closed).
37
+ * `steering_type` is validated against the vocabulary here so the engine's INV-S1 never rejects it
38
+ * as an unknown page.
39
+ */
40
+ export declare function policyProposalToRule(proposalId: string, kindType: string, payload: unknown, facets: Record<string, string> | undefined): {
41
+ rule: ConformanceRule;
42
+ steeringType: SteeringType;
43
+ } | {
44
+ error: string;
45
+ };
21
46
  /**
22
47
  * The launch body. Every field but `problem` is optional, which is what made stripping dangerous:
23
48
  * omitting one is a legitimate request that gets an engine default, so a MISSPELLED one was
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/api/routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/api/routes.ts"],"names":[],"mappings":"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"}
@@ -100,6 +100,70 @@ function isStringRecord(v) {
100
100
  !Array.isArray(v) &&
101
101
  Object.values(v).every((x) => typeof x === 'string'));
102
102
  }
103
+ /**
104
+ * Map an APPROVED policy proposal (estate `proposal.approve` → `handed_off`) into a steering
105
+ * ConformanceRule (DES-MEM-FACETED-001 §5.2). A policy proposal's `kind_type` is
106
+ * `policy:<steering_type>` and its `payload` is `{ rule, severity }`; estate writes NOTHING for it
107
+ * (the AW-11 "no rules.write on estate" invariant) and hands the payload back for crew — the ONE
108
+ * governed rules-write path — to land. Returns the rule to upsert plus the resolved steering type,
109
+ * or a loud `error` string when the proposal cannot be shaped into a valid rule (a malformed
110
+ * `kind_type` / missing `rule` / out-of-enum `severity`) — the caller reports it as a failed
111
+ * landing, never a silent drop (the crew#388 anti-silent-loss doctrine).
112
+ *
113
+ * The rule id is DETERMINISTIC (`proposal:<id>`) so a re-driven landing UPSERTS the same rule
114
+ * (idempotent) instead of minting a duplicate; it sits OUTSIDE the reserved `PAT-/POL-` namespace,
115
+ * which UI/chat/proposal-authored rules are free to do (INV-C1). The rule carries NO `effect`
116
+ * (recall-only — exactly what `{rule, severity}` supports: an enforcement rule would need a
117
+ * non-blank `applies_to`, which the payload does not carry, and INV-S3 fails such a rule closed).
118
+ * `steering_type` is validated against the vocabulary here so the engine's INV-S1 never rejects it
119
+ * as an unknown page.
120
+ */
121
+ export function policyProposalToRule(proposalId, kindType, payload, facets) {
122
+ const steeringType = kindType.startsWith('policy:') ? kindType.slice('policy:'.length) : '';
123
+ if (!STEERING_TYPES.has(steeringType)) {
124
+ return {
125
+ error: `the approved proposal's kind_type ${JSON.stringify(kindType)} does not name a steering ` +
126
+ `type — expected \`policy:<${STEERING_TYPE_VALUES.join('|')}>\``,
127
+ };
128
+ }
129
+ const body = typeof payload === 'object' && payload !== null && !Array.isArray(payload)
130
+ ? payload
131
+ : {};
132
+ const statement = typeof body['rule'] === 'string' ? body['rule'].trim() : '';
133
+ if (statement === '') {
134
+ return {
135
+ error: 'the approved policy proposal payload has no `rule` string to make a rule statement from',
136
+ };
137
+ }
138
+ const rawSeverity = body['severity'];
139
+ const severity = rawSeverity === 'info' || rawSeverity === 'warn' || rawSeverity === 'error' || rawSeverity === 'critical'
140
+ ? rawSeverity
141
+ : undefined;
142
+ if (rawSeverity !== undefined && severity === undefined) {
143
+ return {
144
+ error: `the approved policy proposal payload has an invalid severity ${JSON.stringify(rawSeverity)} — ` +
145
+ 'expected info|warn|error|critical',
146
+ };
147
+ }
148
+ const language = facets?.['language'];
149
+ const rule = {
150
+ id: `proposal:${proposalId}`,
151
+ rule_type: 'policy',
152
+ statement,
153
+ // A policy proposal SHOULD carry severity; a missing one defaults to `warn` (the middle band)
154
+ // rather than failing the landing, but a present-but-garbage one fails loud above.
155
+ severity: severity ?? 'warn',
156
+ // REQUIRED engine-side (f32, no serde default, INV-C2 `[0,1]`); the payload carries none, so a
157
+ // fixed authority — the same 0.8 the steering-author landing defaults to.
158
+ confidence: 0.8,
159
+ // Only the `language` facet maps onto the engine's Targets facet object; `repo`/`project`
160
+ // facets have no ConformanceRule slot and are dropped (see the route's open question).
161
+ targets: typeof language === 'string' && language !== '' ? { language } : {},
162
+ provenance: { source: 'proposal', source_kinds: [] },
163
+ steering_type: steeringType,
164
+ };
165
+ return { rule, steeringType: steeringType };
166
+ }
103
167
  /** A JSON object whose every value is a number (a coverage breakdown map). */
104
168
  function isNumberRecord(v) {
105
169
  return (typeof v === 'object' &&
@@ -107,14 +171,13 @@ function isNumberRecord(v) {
107
171
  !Array.isArray(v) &&
108
172
  Object.values(v).every((x) => typeof x === 'number'));
109
173
  }
110
- /** The broad-browse recall token budget for `GET /memory` when the caller passes no `limit` — larger
111
- * than estate's own 2000 default so a browse returns a meaningful slice, not a conversational one. */
112
- const MEMORY_BROWSE_TOKEN_BUDGET = 8000;
113
174
  /**
114
- * Shape one estate `memory.recall` item (`{ memory_id, scope, content, tier, score }`) into the wire
115
- * {@link MemoryItem}. estate recall does NOT surface per-item facets, so `facets` defaults to `{}`
116
- * (mapped defensively so a future estate that DOES carry them rides through); `score` is dropped
117
- * when absent. String fields missing on the wire degrade to `''` rather than throwing on one item.
175
+ * Shape one estate `memory.list` item (`{ memory_id, scope, content, tier, facets, created_at }`)
176
+ * into the wire {@link MemoryItem}. `memory.list` (the management browse) surfaces per-item
177
+ * `facets` — the reason the surface can filter by facet at all; `score` is absent (list is not
178
+ * relevance-ranked) and simply omitted. String fields missing on the wire degrade to `''` rather
179
+ * than throwing on one item. (The same shape parses a `memory.recall` item unchanged — recall
180
+ * carries no facets, so `facets` degrades to `{}`.)
118
181
  */
119
182
  function shapeMemoryItem(raw) {
120
183
  const r = typeof raw === 'object' && raw !== null ? raw : {};
@@ -2745,23 +2808,95 @@ runtime = {}) {
2745
2808
  }
2746
2809
  });
2747
2810
  // POST /proposals/:id/approve → proposal.approve →
2748
- // { outcome:'promoted', active_id } — a MEMORY proposal, now an active memory (complete);
2749
- // { outcome:'handed_off', payload } — a POLICY proposal, returned VERBATIM.
2811
+ // { outcome:'promoted', active_id } — a MEMORY proposal, now an active memory (complete);
2812
+ // { outcome:'handed_off', payload, landing } — a POLICY proposal, LANDED here as a steering rule.
2813
+ //
2814
+ // The policy→steering landing (DES-MEM-FACETED-001 §5.2): estate performs NO rules-write (the
2815
+ // AW-11 invariant) — it marks the proposal approved and hands its `{ rule, severity }` payload
2816
+ // back, and CREW is the one governed rules-write path. On a `handed_off` outcome we recover the
2817
+ // proposal's `kind_type` (which carries the `policy:<steering_type>` the approve response does
2818
+ // NOT echo), shape a `ConformanceRule` from it, and upsert it via the single-writer actor. The
2819
+ // posture mirrors the steering-author landing (crew#388): the estate decision (approve) already
2820
+ // stands, so a landing failure is reported LOUD in-band as `landing.outcome:"failed"` — never a
2821
+ // 500, never the silent no-op that crew#388 exists to end — and `payload` is still returned for
2822
+ // backward compatibility. The MEMORY path is UNTOUCHED: a `promoted` outcome returns immediately
2823
+ // with no extra estate call and no landing.
2750
2824
  app.post(`${V}/proposals/:id/approve`, { config: { manifest: { responseType: 'ApproveProposalResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
2751
- const { id } = req.params;
2752
- if (id.trim() === '') {
2825
+ // Normalize ONCE and use the trimmed value throughout — an id like `%20pol1%20`
2826
+ // decodes to a padded, non-empty string that would otherwise ride upstream as-is
2827
+ // and into `proposal:<id>` derivations.
2828
+ const id = req.params.id.trim();
2829
+ if (id === '') {
2753
2830
  return reply.code(400).send({ error: '`id` is required' });
2754
2831
  }
2832
+ let approved;
2755
2833
  try {
2756
- // TODO: route handed_off policy payload to steering (crew steering-write, DES-MEM-FACETED-001
2757
- // §5.2). Policy→steering routing is OUT OF SCOPE here: a `handed_off` outcome is passed
2758
- // through as-is for a later steering-write to consume; memory proposals return `promoted` and
2759
- // need nothing more.
2760
- return (await estateTool('proposal.approve', { id }));
2834
+ approved = (await estateTool('proposal.approve', { id }));
2761
2835
  }
2762
2836
  catch (err) {
2763
2837
  return estateUpstreamError(reply, err);
2764
2838
  }
2839
+ // MEMORY proposal (or any future non-policy outcome): estate already promoted it — pass through
2840
+ // verbatim, exactly as before. No extra estate round-trip on the memory path.
2841
+ if (approved.outcome !== 'handed_off') {
2842
+ return approved;
2843
+ }
2844
+ // POLICY proposal — land the handed-off payload as a steering rule.
2845
+ const landFailed = (error) => {
2846
+ audit.record('governance.steering.landing_failed', actorOf(req), {
2847
+ detail: { proposalId: id, error },
2848
+ });
2849
+ return { outcome: 'handed_off', payload: approved.payload, landing: { outcome: 'failed', error } };
2850
+ };
2851
+ // A pre-steering engine would SILENTLY DROP `steering_type` and the other steering fields
2852
+ // (ConformanceRule has no deny_unknown_fields), persisting a rule that enforces differently than
2853
+ // authored — the same guard POST /governance/rules applies. Fail loud (before any further estate
2854
+ // round-trip), persist nothing.
2855
+ if (!adapter.steeringSupported()) {
2856
+ return landFailed(`${new SteeringUnsupportedError('Landing an approved policy proposal').message} — the ` +
2857
+ `installed engine would silently drop the rule's steering fields; upgrade wicked-core-ts (>= 0.7.5)`);
2858
+ }
2859
+ // Recover the proposal's `kind_type` + `facets`: `proposal.approve` does not echo them, and the
2860
+ // steering type lives ONLY in `kind_type` (`policy:<type>`). The proposal is `approved` now, so
2861
+ // read it back from the approved queue.
2862
+ let proposal;
2863
+ try {
2864
+ const listed = (await estateTool('proposal.list', { state: 'approved' }));
2865
+ proposal = listed.proposals.find((p) => p.id === id);
2866
+ }
2867
+ catch (err) {
2868
+ return landFailed(`the policy proposal was approved but its record could not be re-read to land a steering ` +
2869
+ `rule (${message(err)}); land it by hand via POST ${V}/governance/rules`);
2870
+ }
2871
+ if (proposal === undefined || typeof proposal.kind_type !== 'string') {
2872
+ return landFailed(`the policy proposal was approved but could not be found in the approved queue to recover ` +
2873
+ `its steering type; land it by hand via POST ${V}/governance/rules`);
2874
+ }
2875
+ const built = policyProposalToRule(id, proposal.kind_type, approved.payload, isStringRecord(proposal.facets) ? proposal.facets : undefined);
2876
+ if ('error' in built) {
2877
+ return landFailed(built.error);
2878
+ }
2879
+ try {
2880
+ await adapter.upsertConformanceRule(built.rule);
2881
+ }
2882
+ catch (err) {
2883
+ return landFailed(`the store refused the steering rule derived from the approved policy proposal: ${message(err)}`);
2884
+ }
2885
+ audit.record('governance.rule.upserted', actorOf(req), {
2886
+ detail: {
2887
+ id: built.rule.id,
2888
+ source: 'proposal',
2889
+ via: 'proposal-approve',
2890
+ steeringType: built.steeringType,
2891
+ proposalId: id,
2892
+ },
2893
+ });
2894
+ const landing = {
2895
+ outcome: 'landed',
2896
+ ruleId: built.rule.id,
2897
+ steering_type: built.steeringType,
2898
+ };
2899
+ return { outcome: 'handed_off', payload: approved.payload, landing };
2765
2900
  });
2766
2901
  // POST /proposals/:id/reject → proposal.reject → { ok: true }.
2767
2902
  app.post(`${V}/proposals/:id/reject`, { config: { manifest: { responseType: 'RejectProposalResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
@@ -2779,38 +2914,41 @@ runtime = {}) {
2779
2914
  // ── Memory management (governed-knowledge surface, DES-MEM-FACETED-001) ───────
2780
2915
  // Browse + retire the EXISTING operator memory store — the studio counterpart to /proposals:
2781
2916
  // proposals DECIDE learnings not yet stored; these MANAGE what already is. The estate memory
2782
- // tools live ONLY on the estate MCP (memory.recall / memory.coverage / memory.erase), reached
2917
+ // tools live ONLY on the estate MCP (memory.list / memory.coverage / memory.erase), reached
2783
2918
  // through the SAME `estateTool` seam and `estateUpstreamError` ladder as /proposals — a client
2784
2919
  // mistake (non-integer limit, malformed facets JSON, empty retire scope_prefix, or estate's own
2785
2920
  // -32602 invalid-params) is a 400; an upstream estate/transport fault, or a malformed estate
2786
2921
  // response, is a 502.
2787
2922
  //
2788
2923
  // GRANULARITY, stated honestly (the estate contract, not a convenience wrapper):
2789
- // • BROWSE is query-based. estate has no "list all"; `memory.recall` returns a token-budgeted,
2790
- // relevance-ranked slice. Broad browse = an empty `query` + `scope_prefix` (subtree filter,
2791
- // "" = every memory) + a large token budget. `limit` IS that token budget — estate exposes no
2792
- // row-count cap — so it bounds the SIZE of the slice, not a memory count.
2793
- // • recall returns { memory_id, scope, content, tier, score } and does NOT surface per-item
2794
- // facets, so MemoryItem.facets is always {} (mapped defensively for a future estate).
2924
+ // • BROWSE lists the COMPLETE in-scope set. `memory.list` (DES-MEM-FACETED-001) returns every
2925
+ // memory under `scope_prefix` ("" / omitted = all), each carrying its facets — NOT
2926
+ // relevance-ranked or facet-intent-filtered. This is why an operator can see faceted memories
2927
+ // at all: `memory.recall` retrieves nothing for an empty query and EXCLUDES faceted memories
2928
+ // under empty intent, so it can never inventory the store. `query`/`facets`/`limit` are then
2929
+ // applied HERE as post-filters over that complete set (see below) — filtering, not retrieval.
2930
+ // • list returns { memory_id, scope, content, tier, facets, created_at }; facets ride through to
2931
+ // MemoryItem.facets, powering the surface's facet filter.
2795
2932
  // • RETIRE is SUBTREE-scoped, never per-id. estate exposes NO per-memory delete: `memory.erase`
2796
2933
  // hard-deletes EVERY memory whose scope equals or descends from `scope_prefix`, and refuses an
2797
2934
  // empty prefix (a total-wipe guard). So retire takes a `scope_prefix` and reports how many
2798
2935
  // memories the subtree wipe removed — the UI must show the operator the subtree, not one row.
2799
- // GET /memory?query=&scope=&scope_prefix=&facets=<json>&limit= → memory.recall → { memories }.
2936
+ // GET /memory?query=&scope_prefix=&facets=<json>&limit= → memory.list + post-filter → { memories }.
2800
2937
  app.get(`${V}/memory`, { config: { manifest: { responseType: 'ListMemoriesResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
2801
2938
  const q = req.query;
2802
- // `query` defaults to "" (broad browse). scope / scope_prefix forward verbatim WHEN PRESENT —
2803
- // a blank value is meaningful to estate (root / whole-subtree), so it is not dropped.
2804
- const args = { query: firstQueryValue(q.query) ?? '' };
2805
- const scope = firstQueryValue(q.scope);
2806
- if (scope !== undefined)
2807
- args.scope = scope;
2939
+ // `scope_prefix` is the ONLY estate-side argument to memory.list (a subtree filter; blank /
2940
+ // omitted = every memory). Forward verbatim WHEN PRESENT — a blank value is meaningful
2941
+ // (whole-subtree), so it is not dropped.
2942
+ const args = {};
2808
2943
  const scopePrefix = firstQueryValue(q.scope_prefix);
2809
2944
  if (scopePrefix !== undefined)
2810
2945
  args.scope_prefix = scopePrefix;
2811
- // `facets` is the recall `intent` tuple (axis→value). Present-but-blank is a client error
2946
+ // `facets` narrows the COMPLETE list crew-side (after the estate fetch) to memories carrying
2947
+ // every given axis:value (a management filter, NOT a recall intent). Present-but-blank is a
2948
+ // client error
2812
2949
  // (fail-loud, never a silent no-filter — FINDING-031); a present value must parse to a JSON
2813
- // object of string values (estate further validates each axis and answers -32602 → 400).
2950
+ // object of string values.
2951
+ let facetFilter;
2814
2952
  const rawFacets = firstQueryValue(q.facets);
2815
2953
  if (rawFacets !== undefined) {
2816
2954
  if (rawFacets.trim() === '') {
@@ -2826,27 +2964,42 @@ runtime = {}) {
2826
2964
  if (!isStringRecord(parsedFacets)) {
2827
2965
  return reply.code(400).send({ error: '`facets` must be a JSON object of axis:value strings' });
2828
2966
  }
2829
- args.intent = parsedFacets;
2830
- }
2831
- // `limit` is the recall token budget (estate has no row-count cap). Omitted/blank ⇒ the broad
2832
- // browse budget; a present value must be a positive integer (mirrors GET /audit's `limit`).
2967
+ facetFilter = parsedFacets;
2968
+ }
2969
+ // `limit` caps the returned ROW count over the complete set (memory.list has no cap of its
2970
+ // own). Omitted/blank ⇒ every matching memory; a present value must be a positive integer.
2971
+ // NOTE: a management browse deliberately fetches the COMPLETE in-scope set before filtering —
2972
+ // the operator memory store is operator-scale, and the studio surface filters that set
2973
+ // client-side, so a server-side cap would hide memories from the filter. Server-side paging
2974
+ // for a store large enough to need it is a future enhancement, not this surface's contract.
2833
2975
  const rawLimit = firstQueryValue(q.limit);
2834
- let tokenBudget = MEMORY_BROWSE_TOKEN_BUDGET;
2976
+ let limit;
2835
2977
  if (rawLimit !== undefined && rawLimit.trim() !== '') {
2836
2978
  const n = Number(rawLimit);
2837
2979
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
2838
- return reply.code(400).send({ error: '`limit` must be a positive integer (the recall token budget)' });
2980
+ return reply.code(400).send({ error: '`limit` must be a positive integer' });
2839
2981
  }
2840
- tokenBudget = n;
2982
+ limit = n;
2841
2983
  }
2842
- args.token_budget = tokenBudget;
2984
+ // `query`, when present, is a case-insensitive CONTENT substring filter over the complete set
2985
+ // — a management search that finds faceted memories too (unlike relevance recall).
2986
+ const query = (firstQueryValue(q.query) ?? '').trim().toLowerCase();
2843
2987
  try {
2844
- const raw = await estateTool('memory.recall', args);
2988
+ const raw = await estateTool('memory.list', args);
2845
2989
  const items = typeof raw === 'object' && raw !== null ? raw['items'] : undefined;
2846
2990
  if (!Array.isArray(items)) {
2847
- throw new EstateMcpError('memory.recall returned an unexpected shape');
2991
+ throw new EstateMcpError('memory.list returned an unexpected shape');
2992
+ }
2993
+ let memories = items.map(shapeMemoryItem);
2994
+ if (query !== '')
2995
+ memories = memories.filter((m) => m.content.toLowerCase().includes(query));
2996
+ if (facetFilter !== undefined) {
2997
+ const pairs = Object.entries(facetFilter);
2998
+ memories = memories.filter((m) => pairs.every(([k, v]) => m.facets[k] === v));
2848
2999
  }
2849
- const body = { memories: items.map(shapeMemoryItem) };
3000
+ if (limit !== undefined)
3001
+ memories = memories.slice(0, limit);
3002
+ const body = { memories };
2850
3003
  return body;
2851
3004
  }
2852
3005
  catch (err) {