wicked-crew 0.7.17 → 0.7.19

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.
@@ -193,6 +193,17 @@ export declare const OpenPathSchema: z.ZodObject<{
193
193
  path: string;
194
194
  runId?: string | undefined;
195
195
  }>;
196
+ /** `POST /memory/retire` body (DES-MEM-FACETED-001). `scope_prefix` is REQUIRED and, after trim,
197
+ * non-empty (enforced in the route so the 400 names the total-wipe guard): estate `memory.erase`
198
+ * is subtree-scoped and refuses an empty prefix. `.strict()` for the FINDING-031 posture — an
199
+ * unknown field is a 400, never silently ignored into a broader erase than the caller sent. */
200
+ export declare const RetireMemorySchema: z.ZodObject<{
201
+ scope_prefix: z.ZodString;
202
+ }, "strict", z.ZodTypeAny, {
203
+ scope_prefix: string;
204
+ }, {
205
+ scope_prefix: string;
206
+ }>;
196
207
  /** The actor/audit deps (task #88) — threaded from `createServer`. */
197
208
  export interface SecurityDeps {
198
209
  audit: AuditLog;
@@ -247,6 +258,12 @@ export interface RuntimeDeps {
247
258
  * Injectable so route tests never shell out to git; defaults to {@link gitReprovisionWorktree}. */
248
259
  reprovisionWorktree?: WorktreeReprovisioner;
249
260
  openWithOs?: (target: string) => Promise<void>;
261
+ /** The estate-MCP client behind `/proposals*` AND `/memory*` (DES-MEM-FACETED-001) — one seam for
262
+ * every operator-facing estate tool (`proposal.*` + `memory.recall`/`memory.coverage`/
263
+ * `memory.erase`). Injectable so route tests never spawn `wicked-estate-mcp`; defaults to the
264
+ * real spawn-per-call client (NON-`--readonly`, `WICKED_MEMORY_DB` pinned to the operator global
265
+ * store — memory.erase is a WRITE). */
266
+ callEstateTool?: (tool: string, args: Record<string, unknown>) => Promise<unknown>;
250
267
  /** Seat sign-in presence probe (seat sign-in) — injectable so route tests never read the
251
268
  * developer's real dotfiles. Defaults to the file/env heuristic in seat-signin.ts. */
252
269
  signedIn?: (seatKey: string, workerConfigRoot?: string) => boolean | null;
@@ -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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"}
@@ -14,6 +14,7 @@ import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
14
14
  import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
15
15
  import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
16
16
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
17
+ import { callEstateTool, EstateMcpError } from '../core/estate-mcp-client.js';
17
18
  import { SeatHealthTracker } from './seat-health.js';
18
19
  import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
19
20
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
@@ -86,6 +87,59 @@ function invalidBody(err, what) {
86
87
  : what;
87
88
  return { error, details: err.issues };
88
89
  }
90
+ /** First value of a possibly-repeated query param, WITHOUT trimming — `''` is PRESERVED because a
91
+ * blank `scope`/`scope_prefix` is meaningful to estate (root / whole-subtree), unlike an omitted
92
+ * one. Returns undefined only when the param is absent. */
93
+ function firstQueryValue(v) {
94
+ return Array.isArray(v) ? v[0] : v;
95
+ }
96
+ /** A JSON object whose every value is a string (a facet/intent tuple, axis→value). */
97
+ function isStringRecord(v) {
98
+ return (typeof v === 'object' &&
99
+ v !== null &&
100
+ !Array.isArray(v) &&
101
+ Object.values(v).every((x) => typeof x === 'string'));
102
+ }
103
+ /** A JSON object whose every value is a number (a coverage breakdown map). */
104
+ function isNumberRecord(v) {
105
+ return (typeof v === 'object' &&
106
+ v !== null &&
107
+ !Array.isArray(v) &&
108
+ Object.values(v).every((x) => typeof x === 'number'));
109
+ }
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
+ /**
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.
118
+ */
119
+ function shapeMemoryItem(raw) {
120
+ const r = typeof raw === 'object' && raw !== null ? raw : {};
121
+ const str = (v) => (typeof v === 'string' ? v : '');
122
+ const item = {
123
+ id: str(r['memory_id']),
124
+ content: str(r['content']),
125
+ tier: str(r['tier']),
126
+ scope: str(r['scope']),
127
+ facets: isStringRecord(r['facets']) ? r['facets'] : {},
128
+ };
129
+ if (typeof r['score'] === 'number')
130
+ item.score = r['score'];
131
+ return item;
132
+ }
133
+ /** Shape the estate `memory.coverage` result (`{ total, by_tier, by_kind }`) into the wire
134
+ * {@link MemoryCoverageResponse}. A shape estate never produces is an UPSTREAM fault — thrown as an
135
+ * {@link EstateMcpError} (→ 502), never silently coerced to zeroes. */
136
+ function shapeMemoryCoverage(raw) {
137
+ const r = typeof raw === 'object' && raw !== null ? raw : undefined;
138
+ if (r === undefined || typeof r['total'] !== 'number' || !isNumberRecord(r['by_tier']) || !isNumberRecord(r['by_kind'])) {
139
+ throw new EstateMcpError('memory.coverage returned an unexpected shape');
140
+ }
141
+ return { total: r['total'], by_tier: r['by_tier'], by_kind: r['by_kind'] };
142
+ }
89
143
  // Closed facet vocabularies for the `GET /governance/rules` browse filters (wiki-mgmt) — the
90
144
  // wire contract's `RuleBrowseQuery`. Sets, not zod: the query is otherwise free-form and only
91
145
  // these three facets have a vocabulary to enforce.
@@ -212,6 +266,13 @@ export const OpenPathSchema = z.object({
212
266
  path: z.string().min(1),
213
267
  runId: z.string().min(1).optional(),
214
268
  }).strict();
269
+ /** `POST /memory/retire` body (DES-MEM-FACETED-001). `scope_prefix` is REQUIRED and, after trim,
270
+ * non-empty (enforced in the route so the 400 names the total-wipe guard): estate `memory.erase`
271
+ * is subtree-scoped and refuses an empty prefix. `.strict()` for the FINDING-031 posture — an
272
+ * unknown field is a 400, never silently ignored into a broader erase than the caller sent. */
273
+ export const RetireMemorySchema = z.object({
274
+ scope_prefix: z.string(),
275
+ }).strict();
215
276
  /**
216
277
  * A SKIN-OWNED settings key (crew#323): one lowercase segment under the `studio.` namespace,
217
278
  * e.g. `studio.appearance`, `studio.notifications`. The daemon never interprets these values —
@@ -282,6 +343,10 @@ runtime = {}) {
282
343
  });
283
344
  const deliverExec = runtime.deliverExec ?? runDeliverScript;
284
345
  const reprovisionWorktree = runtime.reprovisionWorktree ?? gitReprovisionWorktree;
346
+ // The estate MCP client behind `/proposals*` AND `/memory*` (DES-MEM-FACETED-001) — one seam. The
347
+ // default is the real spawn-per-call `wicked-estate-mcp` client; route tests inject a stub so no
348
+ // process is ever spawned.
349
+ const estateTool = runtime.callEstateTool ?? callEstateTool;
285
350
  /** Repo root for a repo ref, from the registry — shared by the reprovision path below. */
286
351
  const repoRootOf = async (repoRef) => (await adapter.listRepos()).find((r) => r.id === repoRef)?.root_path;
287
352
  // The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
@@ -2636,6 +2701,207 @@ runtime = {}) {
2636
2701
  });
2637
2702
  return { settings };
2638
2703
  });
2704
+ // ── Memory proposal queue (DES-MEM-FACETED-001 §5.0) ─────────────────────────
2705
+ // The estate proposal queue over HTTP, so a studio operator can review the learnings workers
2706
+ // propose and approve/reject them. The queue tools live ONLY on the estate MCP (JSON-RPC over
2707
+ // stdio, estate #162), reached through `proposalTool` — a spawn-per-call `wicked-estate-mcp`
2708
+ // client, NON-`--readonly` (this is the OPERATOR surface: it lists AND mutates), with
2709
+ // WICKED_MEMORY_DB pinned to the operator global store. A queue/estate failure is an UPSTREAM
2710
+ // fault (502), kept distinct from a client mistake (400: a bad `state`/`id`, or estate's own
2711
+ // -32602 invalid-params) — the FINDING-031 posture that a malformed filter is refused, never
2712
+ // silently honored as a different request.
2713
+ const estateUpstreamError = (reply, err) => {
2714
+ if (err instanceof EstateMcpError && err.code === -32602) {
2715
+ return reply.code(400).send({ error: err.message });
2716
+ }
2717
+ return reply.code(502).send({ error: message(err) });
2718
+ };
2719
+ // GET /proposals?kind_type=&state= → proposal.list → { proposals: Proposal[] }.
2720
+ app.get(`${V}/proposals`, { config: { manifest: { responseType: 'ListProposalsResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
2721
+ const q = req.query;
2722
+ const rawOf = (v) => Array.isArray(v) ? v[0] : v;
2723
+ const rawKind = rawOf(q.kind_type);
2724
+ const rawState = rawOf(q.state);
2725
+ // Fail-loud: a PRESENT-but-blank param (`?state=` / `?state=%20`) is a client error, NOT a
2726
+ // silent "no filter" — else a client accidentally issues a broader query than intended.
2727
+ if (rawKind !== undefined && rawKind.trim() === '') {
2728
+ return reply.code(400).send({ error: '`kind_type` must not be empty' });
2729
+ }
2730
+ if (rawState !== undefined && !['pending', 'approved', 'rejected'].includes(rawState.trim())) {
2731
+ return reply.code(400).send({ error: '`state` must be one of pending|approved|rejected' });
2732
+ }
2733
+ const kindType = rawKind?.trim() || undefined;
2734
+ const state = rawState?.trim() || undefined;
2735
+ const args = {};
2736
+ if (kindType !== undefined)
2737
+ args.kind_type = kindType;
2738
+ if (state !== undefined)
2739
+ args.state = state;
2740
+ try {
2741
+ return (await estateTool('proposal.list', args));
2742
+ }
2743
+ catch (err) {
2744
+ return estateUpstreamError(reply, err);
2745
+ }
2746
+ });
2747
+ // 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.
2750
+ 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() === '') {
2753
+ return reply.code(400).send({ error: '`id` is required' });
2754
+ }
2755
+ 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 }));
2761
+ }
2762
+ catch (err) {
2763
+ return estateUpstreamError(reply, err);
2764
+ }
2765
+ });
2766
+ // POST /proposals/:id/reject → proposal.reject → { ok: true }.
2767
+ app.post(`${V}/proposals/:id/reject`, { config: { manifest: { responseType: 'RejectProposalResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
2768
+ const { id } = req.params;
2769
+ if (id.trim() === '') {
2770
+ return reply.code(400).send({ error: '`id` is required' });
2771
+ }
2772
+ try {
2773
+ return (await estateTool('proposal.reject', { id }));
2774
+ }
2775
+ catch (err) {
2776
+ return estateUpstreamError(reply, err);
2777
+ }
2778
+ });
2779
+ // ── Memory management (governed-knowledge surface, DES-MEM-FACETED-001) ───────
2780
+ // Browse + retire the EXISTING operator memory store — the studio counterpart to /proposals:
2781
+ // 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
2783
+ // through the SAME `estateTool` seam and `estateUpstreamError` ladder as /proposals — a client
2784
+ // mistake (non-integer limit, malformed facets JSON, empty retire scope_prefix, or estate's own
2785
+ // -32602 invalid-params) is a 400; an upstream estate/transport fault, or a malformed estate
2786
+ // response, is a 502.
2787
+ //
2788
+ // 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).
2795
+ // • RETIRE is SUBTREE-scoped, never per-id. estate exposes NO per-memory delete: `memory.erase`
2796
+ // hard-deletes EVERY memory whose scope equals or descends from `scope_prefix`, and refuses an
2797
+ // empty prefix (a total-wipe guard). So retire takes a `scope_prefix` and reports how many
2798
+ // 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 }.
2800
+ app.get(`${V}/memory`, { config: { manifest: { responseType: 'ListMemoriesResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
2801
+ 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;
2808
+ const scopePrefix = firstQueryValue(q.scope_prefix);
2809
+ if (scopePrefix !== undefined)
2810
+ args.scope_prefix = scopePrefix;
2811
+ // `facets` is the recall `intent` tuple (axis→value). Present-but-blank is a client error
2812
+ // (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).
2814
+ const rawFacets = firstQueryValue(q.facets);
2815
+ if (rawFacets !== undefined) {
2816
+ if (rawFacets.trim() === '') {
2817
+ return reply.code(400).send({ error: '`facets` must not be empty' });
2818
+ }
2819
+ let parsedFacets;
2820
+ try {
2821
+ parsedFacets = JSON.parse(rawFacets);
2822
+ }
2823
+ catch {
2824
+ return reply.code(400).send({ error: '`facets` must be a JSON object of axis:value strings' });
2825
+ }
2826
+ if (!isStringRecord(parsedFacets)) {
2827
+ return reply.code(400).send({ error: '`facets` must be a JSON object of axis:value strings' });
2828
+ }
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`).
2833
+ const rawLimit = firstQueryValue(q.limit);
2834
+ let tokenBudget = MEMORY_BROWSE_TOKEN_BUDGET;
2835
+ if (rawLimit !== undefined && rawLimit.trim() !== '') {
2836
+ const n = Number(rawLimit);
2837
+ 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)' });
2839
+ }
2840
+ tokenBudget = n;
2841
+ }
2842
+ args.token_budget = tokenBudget;
2843
+ try {
2844
+ const raw = await estateTool('memory.recall', args);
2845
+ const items = typeof raw === 'object' && raw !== null ? raw['items'] : undefined;
2846
+ if (!Array.isArray(items)) {
2847
+ throw new EstateMcpError('memory.recall returned an unexpected shape');
2848
+ }
2849
+ const body = { memories: items.map(shapeMemoryItem) };
2850
+ return body;
2851
+ }
2852
+ catch (err) {
2853
+ return estateUpstreamError(reply, err);
2854
+ }
2855
+ });
2856
+ // GET /memory/coverage?scope_prefix= → memory.coverage → { total, by_tier, by_kind }.
2857
+ app.get(`${V}/memory/coverage`, { config: { manifest: { responseType: 'MemoryCoverageResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
2858
+ const q = req.query;
2859
+ const scopePrefix = firstQueryValue(q.scope_prefix);
2860
+ const args = {};
2861
+ if (scopePrefix !== undefined)
2862
+ args.scope_prefix = scopePrefix;
2863
+ try {
2864
+ return shapeMemoryCoverage(await estateTool('memory.coverage', args));
2865
+ }
2866
+ catch (err) {
2867
+ return estateUpstreamError(reply, err);
2868
+ }
2869
+ });
2870
+ // POST /memory/retire { scope_prefix } → memory.erase → { erased }. SUBTREE-scoped (no per-id
2871
+ // delete in estate); an empty scope_prefix is refused at the route (a 400 BEFORE any spawn) — the
2872
+ // same total-wipe guard estate enforces with -32602.
2873
+ app.post(`${V}/memory/retire`, {
2874
+ config: {
2875
+ manifest: {
2876
+ requestType: 'RetireMemoryBody',
2877
+ responseType: 'RetireMemoryResponse',
2878
+ statusCodes: [200, 400, 502],
2879
+ },
2880
+ },
2881
+ }, async (req, reply) => {
2882
+ const parsed = RetireMemorySchema.safeParse(req.body);
2883
+ if (!parsed.success) {
2884
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid retire request'));
2885
+ }
2886
+ const scopePrefix = parsed.data.scope_prefix.trim();
2887
+ if (scopePrefix === '') {
2888
+ return reply.code(400).send({
2889
+ error: '`scope_prefix` must not be empty — memory.erase is subtree-scoped (no per-id delete) and refuses a total wipe',
2890
+ });
2891
+ }
2892
+ try {
2893
+ const raw = await estateTool('memory.erase', { scope_prefix: scopePrefix });
2894
+ const deleted = typeof raw === 'object' && raw !== null ? raw['deleted_count'] : undefined;
2895
+ if (typeof deleted !== 'number') {
2896
+ throw new EstateMcpError('memory.erase returned an unexpected shape');
2897
+ }
2898
+ const body = { erased: deleted };
2899
+ return body;
2900
+ }
2901
+ catch (err) {
2902
+ return estateUpstreamError(reply, err);
2903
+ }
2904
+ });
2639
2905
  // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
2640
2906
  registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
2641
2907
  // ── Campaigns (crew#342 + TH-9) — the engine's durable Run-DAG scheduler over REST ──────────