wicked-crew 0.13.2 → 0.14.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 (61) hide show
  1. package/dist/api/capture.d.ts +3 -3
  2. package/dist/api/capture.d.ts.map +1 -1
  3. package/dist/api/capture.js +12 -5
  4. package/dist/api/capture.js.map +1 -1
  5. package/dist/api/diagnostics.d.ts +18 -2
  6. package/dist/api/diagnostics.d.ts.map +1 -1
  7. package/dist/api/diagnostics.js +54 -9
  8. package/dist/api/diagnostics.js.map +1 -1
  9. package/dist/api/gate-cache.d.ts +7 -0
  10. package/dist/api/gate-cache.d.ts.map +1 -1
  11. package/dist/api/gate-cache.js +2 -0
  12. package/dist/api/gate-cache.js.map +1 -1
  13. package/dist/api/policy-landing.d.ts +15 -2
  14. package/dist/api/policy-landing.d.ts.map +1 -1
  15. package/dist/api/policy-landing.js +55 -7
  16. package/dist/api/policy-landing.js.map +1 -1
  17. package/dist/api/routes.d.ts +12 -0
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +421 -82
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/core/adapter.d.ts.map +1 -1
  22. package/dist/core/adapter.js +52 -1
  23. package/dist/core/adapter.js.map +1 -1
  24. package/dist/core/reassign-refusal.d.ts +7 -0
  25. package/dist/core/reassign-refusal.d.ts.map +1 -0
  26. package/dist/core/reassign-refusal.js +42 -0
  27. package/dist/core/reassign-refusal.js.map +1 -0
  28. package/dist/decisions/derive.d.ts +8 -0
  29. package/dist/decisions/derive.d.ts.map +1 -1
  30. package/dist/decisions/derive.js +27 -1
  31. package/dist/decisions/derive.js.map +1 -1
  32. package/dist/decisions/ingest.d.ts.map +1 -1
  33. package/dist/decisions/ingest.js +5 -1
  34. package/dist/decisions/ingest.js.map +1 -1
  35. package/dist/decisions/land.d.ts +5 -1
  36. package/dist/decisions/land.d.ts.map +1 -1
  37. package/dist/decisions/land.js +87 -17
  38. package/dist/decisions/land.js.map +1 -1
  39. package/dist/interactive/bridge-pool.d.ts +8 -2
  40. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  41. package/dist/interactive/bridge-pool.js +7 -1
  42. package/dist/interactive/bridge-pool.js.map +1 -1
  43. package/dist/studio/assets/index-BA69zWzx.js +791 -0
  44. package/dist/studio/assets/index-GSpTTvH0.css +32 -0
  45. package/dist/studio/editors/wicked-deck/editor.json +1 -1
  46. package/dist/studio/editors/wicked-deck/index.html +1 -1
  47. package/dist/studio/editors/wicked-doc/editor.json +1 -1
  48. package/dist/studio/editors/wicked-doc/index.html +1 -1
  49. package/dist/studio/editors/wicked-page/editor.json +1 -1
  50. package/dist/studio/editors/wicked-page/index.html +1 -1
  51. package/dist/studio/editors/wicked-video/editor.json +1 -1
  52. package/dist/studio/editors/wicked-video/index.html +1 -1
  53. package/dist/studio/index.html +2 -2
  54. package/dist/studio/testid-inventory.json +636 -95
  55. package/dist/watch/checks/discovery.d.ts.map +1 -1
  56. package/dist/watch/checks/discovery.js +2 -1
  57. package/dist/watch/checks/discovery.js.map +1 -1
  58. package/endpoint-manifest.json +15 -1
  59. package/package.json +4 -4
  60. package/dist/studio/assets/index-4EBan87x.css +0 -32
  61. package/dist/studio/assets/index-CjssTXGs.js +0 -771
@@ -30,6 +30,7 @@ import { SeatContainerStatus, isContainerLoginCli, isContainerSeatFloor, loginDe
30
30
  import { ChatTurnIndex } from './chat-turns.js';
31
31
  import { ChatScopeIndex, chatScopeDeps, chatScopeStatement, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
32
32
  import { ASK_TURN_BUDGET_SECS, AskPathIndex, answerStep, isAskTurn } from './ask-paths.js';
33
+ import { reassignRefusedSeats } from '../core/reassign-refusal.js';
33
34
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
34
35
  import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isGitStopped, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
35
36
  import { DocRunIndex } from '../interactive/doc-run-index.js';
@@ -99,7 +100,7 @@ import { LOCAL_ACTOR, trustAtLeast } from './auth.js';
99
100
  // value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
100
101
  export { API_PREFIX } from './api-prefix.js';
101
102
  import { API_PREFIX } from './api-prefix.js';
102
- import { landPolicyRule, policyProposalToRule } from './policy-landing.js';
103
+ import { landPolicyRule, policyProposalToRule, withdrawLandedRule } from './policy-landing.js';
103
104
  import { DecisionError, DecisionService } from '../decisions/land.js';
104
105
  import { ingestDecision } from '../decisions/ingest.js';
105
106
  import { registerDecisionRoutes } from '../decisions/routes.js';
@@ -548,6 +549,22 @@ export const RetireMemorySchema = z.object({
548
549
  export const RetireMemoryItemSchema = z.object({
549
550
  memory_id: z.string().min(1).max(512),
550
551
  }).strict();
552
+ /** `POST /memory/edit` body (wicked-studio's Memory page): ONE memory's new words. `.strict()`: an
553
+ * unknown field is a 400, never read as something wider than one memory. */
554
+ export const EditMemorySchema = z.object({
555
+ memory_id: z.string().min(1).max(512),
556
+ content: z.string().min(1).max(4000),
557
+ }).strict();
558
+ /** estate `memory.capture`'s `kind` for a tier — the kind natural to it, as estate's own proposal
559
+ * approve picks (`kind_for_tier`), so an edited memory keeps the kind its tier implies. */
560
+ const KIND_FOR_TIER = {
561
+ working: 'working', episodic: 'episode', semantic: 'fact', procedural: 'skill', archival: 'archive',
562
+ };
563
+ /** The id `POST /memory/edit` probes erase-by-id with: no memory carries it, so the erase deletes nothing. */
564
+ const MEMORY_EDIT_PROBE_ID = 'crew:erase-by-id-probe';
565
+ /** The memory ids with an edit in flight: estate has no compare-and-swap, and two edits of one memory
566
+ * racing would each write a copy. One daemon, so one edit per memory at a time is enough. */
567
+ const memoryEditsInFlight = new Set();
551
568
  /** The error an estate from before wicked-estate#188 answers to `memory.erase {id}`: it knows only the
552
569
  * subtree erase, so it refuses the call (deleting nothing) for want of a `scope_prefix`. */
553
570
  const ESTATE_NO_ERASE_BY_ID = /scope_prefix \(non-empty\) required/i;
@@ -1036,6 +1053,13 @@ runtime = {}) {
1036
1053
  if (isAskTurn(view))
1037
1054
  view.session.ask_turn = true;
1038
1055
  }
1056
+ // crew#1058: the seats a reassign of each unit is refused for (the work's authors under a
1057
+ // distinct-evaluator contract) — the same rule the reassign route applies. ABSENT when none.
1058
+ for (const unit of view.units) {
1059
+ const refused = reassignRefusedSeats(view, unit.ord);
1060
+ if (refused.length > 0)
1061
+ unit.reassign_refused = refused;
1062
+ }
1039
1063
  return view;
1040
1064
  };
1041
1065
  // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
@@ -1572,6 +1596,15 @@ runtime = {}) {
1572
1596
  };
1573
1597
  if (!worktreeLive)
1574
1598
  return fromBranchOr409();
1599
+ // crew#1057: the delivered-worktree sweep (`git worktree remove`) deletes the worktree's files,
1600
+ // its `.git` link among them, BEFORE the directory itself. A bound run's worktree with no `.git`
1601
+ // link is being swept: git run in it finds the registered repo above it instead, and answers
1602
+ // about that checkout (an empty diff) or fails with no named error ("no merge base between
1603
+ // origin/main and HEAD"). Such a worktree is gone, checked before the read and again after it.
1604
+ const bound = resolved.session.repo_ref !== null;
1605
+ const worktreeIntact = () => existsSync(workdir) && (!bound || existsSync(join(workdir, '.git')));
1606
+ if (!worktreeIntact())
1607
+ return fromBranchOr409();
1575
1608
  // Narrowing is WORKTREE-scoped: a contained-but-outside-the-worktree path (extra write
1576
1609
  // root / repo root) is a valid FILE read but has no meaning as a diff pathspec — rejected
1577
1610
  // explicitly here rather than handing git a `../`-prefixed pathspec and surfacing its
@@ -1592,22 +1625,31 @@ runtime = {}) {
1592
1625
  try {
1593
1626
  if (defaultBase !== undefined) {
1594
1627
  try {
1595
- return { ...(await worktreeDiff(workdir, rel, defaultBase)), source: 'worktree', base: defaultBase };
1628
+ const read = await worktreeDiff(workdir, rel, defaultBase);
1629
+ if (!worktreeIntact())
1630
+ return fromBranchOr409();
1631
+ return { ...read, source: 'worktree', base: defaultBase };
1596
1632
  }
1597
1633
  catch (err) {
1598
1634
  if (!(err instanceof UnresolvableDiffBaseError))
1599
1635
  throw err;
1600
1636
  }
1601
1637
  }
1602
- return { ...(await worktreeDiff(workdir, rel, rawBase)), source: 'worktree' };
1638
+ const read = await worktreeDiff(workdir, rel, rawBase);
1639
+ if (!worktreeIntact())
1640
+ return fromBranchOr409();
1641
+ return { ...read, source: 'worktree' };
1603
1642
  }
1604
1643
  catch (err) {
1605
1644
  // The worktree was removed WHILE this request ran (the delivered-worktree sweep, an engine
1606
1645
  // reap). Whatever git said — a spawn into the vanished cwd fails ENOENT (the 0.9.1 release
1607
1646
  // smoke), a git already running exits 128 "Unable to read current working directory" (the
1608
1647
  // 0.9.3 one) — a diff that failed over a worktree that is gone now serves the run branch, as
1609
- // for a worktree that was already gone. A named base error stays the caller's 400 (codex r1).
1610
- if (!(err instanceof UnresolvableDiffBaseError) && !existsSync(workdir))
1648
+ // for a worktree that was already gone. A ref the CALLER named that does not resolve stays the
1649
+ // caller's 400 (codex r1). The `merge-base` literal names no ref: when the worktree is being
1650
+ // swept, its failure is the sweep's, not the caller's (crew#1057).
1651
+ const callersRef = err instanceof UnresolvableDiffBaseError && err.base !== MERGE_BASE_LITERAL;
1652
+ if (!callersRef && !worktreeIntact())
1611
1653
  return fromBranchOr409();
1612
1654
  return diffError(err);
1613
1655
  }
@@ -3711,12 +3753,14 @@ runtime = {}) {
3711
3753
  // detection (issue #419) — otherwise a gate served from the durable row after a restart would
3712
3754
  // silently drop the warning that the live/replay paths carry.
3713
3755
  const refusal = detectRefusal(durableGate.prompt);
3756
+ const gateKind = typeof durableGate['gate_kind'] === 'string' && durableGate['gate_kind'] !== '' ? durableGate['gate_kind'] : undefined;
3714
3757
  const entry = {
3715
3758
  ord: durableGate.ord ?? 0,
3716
3759
  prompt: durableGate.prompt,
3717
3760
  lifecycle: 'open',
3718
3761
  receivedAt: new Date(durableGate.created_at).toISOString(),
3719
3762
  ...(refusal !== undefined ? { refusal } : {}),
3763
+ ...(gateKind !== undefined ? { gateKind } : {}),
3720
3764
  };
3721
3765
  gateCache.adopt(id, entry);
3722
3766
  return entry;
@@ -4021,6 +4065,26 @@ runtime = {}) {
4021
4065
  return reply.code(409).send({ error: message(err) });
4022
4066
  }
4023
4067
  });
4068
+ /** crew#1058: the runs with a gated reassign (re-seat, then approve) in flight — one at a time. */
4069
+ const gatedReassigns = new Set();
4070
+ /**
4071
+ * codex r1 on #1060: whether approving the run's open gate ACCEPTS the parked unit's work rather
4072
+ * than re-running it (wicked-core `confirm_gate`: a `judge_unavailable` / `qe_reviewer_unavailable`
4073
+ * denial on the cursor unit is judged by the operator; an open `review_adjudication` gate lands
4074
+ * with carried items). Says what the approve does, or `null` when it re-runs the unit.
4075
+ */
4076
+ async function approveAcceptsWork(run, ord) {
4077
+ const unit = run.units.find((u) => u.ord === ord);
4078
+ const denial = unit?.denial;
4079
+ if (unit?.status === 'rejected' && (denial?.source === 'judge_unavailable' || denial?.source === 'qe_reviewer_unavailable')) {
4080
+ return 'is the operator\'s judgment of the work as it stands (no judge could rule)';
4081
+ }
4082
+ const open = await resolveOpenGate(run.session.id).catch(() => null);
4083
+ if (open !== null && open !== 'no-log' && open.gateKind === 'review_adjudication') {
4084
+ return 'lands the reviewed work with its carried items';
4085
+ }
4086
+ return null;
4087
+ }
4024
4088
  // The manual operator lever (crew#442): recycles a run's CURSOR unit via the same
4025
4089
  // `adapter.reassignUnit` path the stall-watchdog's automatic escalation uses, for when a
4026
4090
  // wedged unit needs recovering and auto-escalation is off, exhausted, or itself failed. Scoped
@@ -4037,17 +4101,16 @@ runtime = {}) {
4037
4101
  if (!run)
4038
4102
  return reply.code(404).send({ error: 'Run not found' });
4039
4103
  // Wave 6 (F-7R2-007 root fix): a run PARKED at a gate on a dead seat — the stall watchdog's
4040
- // escalation gate, or a verdict escalation — used to answer 409 here, so the studio's control
4041
- // needed two calls (approve, then race the engine's re-dispatch of the same dead seat with a
4042
- // reassign). The ENGINE's `reassign_unit` accepts ONLY an Executing run (wicked-core actor.rs:
4043
- // "run X is not Executing (status: AwaitingHuman)") — there is no reassign-in-place for a gated
4044
- // run — so this route performs APPROVE-THEN-REASSIGN in one call: the approve resumes the run
4045
- // (the engine re-dispatches the cursor unit to its current seat), and the reassign IMMEDIATELY
4046
- // supersedes that turn (cancels its epoch, closes the seat's session, bumps the attempt) and
4047
- // re-dispatches to the requested seat or the council's pick. The dead seat is dispatched for
4048
- // the gap between the two engine calls — a bounded window the engine contract leaves open (a
4049
- // reassign-as-approval engine op would close it; documented in the PR). Audited as BOTH a gate
4050
- // decision (`via: 'reassign'`) and a reassign, so the "who approved" trail has no side door.
4104
+ // escalation gate, or a verdict escalation — is reassigned and approved in ONE call. crew#1058:
4105
+ // with a named seat it is REASSIGN-THEN-APPROVE. The engine re-seats a parked run's cursor unit
4106
+ // without dispatching it (wicked-core#773, `reseat_parked_unit`, core-ts >= 0.13.0) and refuses
4107
+ // a seat it cannot honour (an author's seat for a review under a distinct-evaluator contract,
4108
+ // a ballot-only seat, a held plan) with the run untouched, so a refusal is a 409 with the gate
4109
+ // still open; the approve then dispatches the unit once, on the new seat. The old order
4110
+ // (approve, then reassign) approved first, so a refused reassign left the failed seat running
4111
+ // again. Without a named seat (the council's pick) the engine needs a running unit to re-route,
4112
+ // so that path stays approve-then-reassign. Audited as BOTH a gate decision
4113
+ // (`via: 'reassign'`) and a reassign, so the "who approved" trail has no side door.
4051
4114
  // A steering-author propose gate is NOT approvable this way: its approve LANDS the proposal
4052
4115
  // through `POST /runs/:id/gate` (crew#388), and a reassign is not a review of the rules.
4053
4116
  const gated = run.session.status === 'awaiting_human';
@@ -4090,21 +4153,69 @@ runtime = {}) {
4090
4153
  }
4091
4154
  const cursor = resolveCursorUnit(run);
4092
4155
  const ord = cursor?.ord ?? run.session.unit_ix;
4093
- try {
4094
- if (gated) {
4095
- const who = actorOf(req);
4096
- adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
4097
- const status = await adapter.confirmGate(id, true);
4098
- audit.record('gate.decided', actorOf(req), {
4099
- runId: id,
4100
- detail: { approve: true, via: 'reassign', status },
4101
- });
4102
- }
4103
- await adapter.reassignUnit(id, ord, requestedCli ?? null);
4156
+ const approve = async () => {
4157
+ if (typeof adapter.confirmGate !== 'function')
4158
+ return; // refused above for a gated run
4159
+ const who = actorOf(req);
4160
+ adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
4161
+ const status = await adapter.confirmGate(id, true);
4162
+ audit.record('gate.decided', actorOf(req), {
4163
+ runId: id,
4164
+ detail: { approve: true, via: 'reassign', status },
4165
+ });
4166
+ };
4167
+ const reassigned = (approved) => {
4104
4168
  audit.record('run.reassigned', actorOf(req), {
4105
4169
  runId: id,
4106
- detail: { ord, ...(requestedCli !== undefined ? { cli: requestedCli } : {}), ...(gated ? { approved: true } : {}) },
4170
+ detail: { ord, ...(requestedCli !== undefined ? { cli: requestedCli } : {}), ...(gated ? { approved } : {}) },
4107
4171
  });
4172
+ };
4173
+ if (gated && requestedCli !== undefined) {
4174
+ // codex r1 on #1060: an approve that ACCEPTS the parked work instead of re-running the unit —
4175
+ // a `judge_unavailable` / `qe_reviewer_unavailable` denial (the operator judges, the unit
4176
+ // counts) or a `review_adjudication` gate (lands with carried items) — must not follow a
4177
+ // re-seat: the new seat would be recorded on work it never did, and never run.
4178
+ const accepts = await approveAcceptsWork(run, ord);
4179
+ if (accepts !== null) {
4180
+ return reply.code(409).send({
4181
+ error: `run ${id} is parked at a gate whose approve ${accepts}, so a reassign would not run unit ${ord} on '${requestedCli}' — approve, send back or stop it via POST /runs/${id}/gate. Nothing was changed.`,
4182
+ });
4183
+ }
4184
+ // codex r1 on #1060: the re-seat and the approve are two engine commands; a second reassign of
4185
+ // the same run between them would dispatch a seat neither response names.
4186
+ if (gatedReassigns.has(id)) {
4187
+ return reply.code(409).send({ error: `a reassign of run ${id} is already in flight. Nothing was changed.` });
4188
+ }
4189
+ gatedReassigns.add(id);
4190
+ try {
4191
+ try {
4192
+ await adapter.reassignUnit(id, ord, requestedCli);
4193
+ }
4194
+ catch (err) {
4195
+ // The engine refused the seat with the run untouched: nothing was approved.
4196
+ return reply.code(409).send({ error: `${message(err)}. Nothing was approved; the gate is still open.` });
4197
+ }
4198
+ try {
4199
+ await approve();
4200
+ }
4201
+ catch (err) {
4202
+ reassigned(false);
4203
+ return reply.code(409).send({
4204
+ error: `unit ${ord} was re-seated on ${requestedCli}, but approving the gate failed: ${message(err)} — read the run's gate before answering again`,
4205
+ });
4206
+ }
4207
+ reassigned(true);
4208
+ return reply.send({ status: 'ok', ord, cli: requestedCli, approved: true });
4209
+ }
4210
+ finally {
4211
+ gatedReassigns.delete(id);
4212
+ }
4213
+ }
4214
+ try {
4215
+ if (gated)
4216
+ await approve();
4217
+ await adapter.reassignUnit(id, ord, requestedCli ?? null);
4218
+ reassigned(true);
4108
4219
  return reply.send({
4109
4220
  status: 'ok',
4110
4221
  ord,
@@ -5768,15 +5879,13 @@ runtime = {}) {
5768
5879
  // { outcome:'handed_off', payload, landing } — a POLICY proposal, LANDED here as a steering rule.
5769
5880
  //
5770
5881
  // The policy→steering landing (DES-MEM-FACETED-001 §5.2): estate performs NO rules-write (the
5771
- // AW-11 invariant) — it marks the proposal approved and hands its `{ rule, severity }` payload
5772
- // back, and CREW is the one governed rules-write path. On a `handed_off` outcome we recover the
5773
- // proposal's `kind_type` (which carries the `policy:<steering_type>` the approve response does
5774
- // NOT echo), shape a `ConformanceRule` from it, and upsert it via the single-writer actor. The
5775
- // posture mirrors the steering-author landing (crew#388): the estate decision (approve) already
5776
- // stands, so a landing failure is reported LOUD in-band as `landing.outcome:"failed"` — never a
5777
- // 500, never the silent no-op that crew#388 exists to end — and `payload` is still returned for
5778
- // backward compatibility. The MEMORY path is UNTOUCHED: a `promoted` outcome returns immediately
5779
- // with no extra estate call and no landing.
5882
+ // AW-11 invariant) — CREW is the one governed rules-write path. The route reads the pending row
5883
+ // (its `kind_type` carries the `policy:<steering_type>`), shapes a `ConformanceRule` from it,
5884
+ // upserts it via the single-writer actor, and only THEN asks estate to mark the proposal
5885
+ // approved. A landing failure is reported LOUD in-band as `landing.outcome:"failed"` (never a
5886
+ // 500, never the silent no-op crew#388 exists to end) and leaves the proposal PENDING, so it can
5887
+ // be approved again; an approve estate refuses after the rule landed retires the rule. A MEMORY
5888
+ // proposal is passed straight to estate's approve.
5780
5889
  app.post(`${V}/proposals/:id/approve`, {
5781
5890
  config: {
5782
5891
  manifest: {
@@ -5800,9 +5909,38 @@ runtime = {}) {
5800
5909
  if (!edit.success) {
5801
5910
  return reply.code(400).send(invalidBody(edit.error, 'Invalid approve body'));
5802
5911
  }
5803
- if (edit.data.content !== undefined || edit.data.reach !== undefined) {
5912
+ // A POLICY proposal accepted with a `reach` (no content) is not a copy: crew lands the rule
5913
+ // itself, so the person's reach rides into the landing below (the policy landing reads it).
5914
+ let policyReach;
5915
+ // The pending read the accept-with-edit makes anyway, done once and shared with it.
5916
+ let editTool = estateTool;
5917
+ if (edit.data.content === undefined && edit.data.reach !== undefined) {
5918
+ let listed;
5919
+ try {
5920
+ listed = (await estateTool('proposal.list', { state: 'pending' }));
5921
+ }
5922
+ catch (err) {
5923
+ return estateUpstreamError(reply, err);
5924
+ }
5925
+ let reused = false;
5926
+ editTool = async (tool, args) => {
5927
+ if (!reused && tool === 'proposal.list' && args['state'] === 'pending') {
5928
+ reused = true;
5929
+ return listed;
5930
+ }
5931
+ return estateTool(tool, args);
5932
+ };
5933
+ const kind = listed.proposals.find((p) => p.id === id)?.kind_type;
5934
+ if (typeof kind === 'string' && kind.startsWith('policy:')) {
5935
+ if (edit.data.reach === 'pattern') {
5936
+ return reply.code(400).send({ error: 'a rule\'s reach is "project" (its project) or "operator" (every project)' });
5937
+ }
5938
+ policyReach = edit.data.reach;
5939
+ }
5940
+ }
5941
+ if (policyReach === undefined && (edit.data.content !== undefined || edit.data.reach !== undefined)) {
5804
5942
  try {
5805
- const done = await approveEdited(estateTool, id, edit.data, (runId) => projects.index.projectOf(runId));
5943
+ const done = await approveEdited(editTool, id, edit.data, (runId) => projects.index.projectOf(runId));
5806
5944
  return reply.code(done.status).send(done.body);
5807
5945
  }
5808
5946
  catch (err) {
@@ -5812,6 +5950,18 @@ runtime = {}) {
5812
5950
  if (editInFlight(id)) {
5813
5951
  return reply.code(409).send({ error: `proposal ${id} is already being accepted` });
5814
5952
  }
5953
+ // Read the pending row FIRST: a POLICY proposal is landed before estate marks it approved, so
5954
+ // a landing that fails leaves it pending and re-approvable (one decision from the operator's
5955
+ // view: approved WITH its rule, or still pending). The row also carries the `kind_type`
5956
+ // (`policy:<steering_type>`), facets and provenance the approve response does not echo.
5957
+ let row;
5958
+ try {
5959
+ const pending = (await estateTool('proposal.list', { state: 'pending' }));
5960
+ row = pending.proposals.find((p) => p.id === id);
5961
+ }
5962
+ catch (err) {
5963
+ return estateUpstreamError(reply, err);
5964
+ }
5815
5965
  // DC-S4a: a proposal THIS ledger filed for a decision lands through `remember()` — the one
5816
5966
  // landing path, with the human check inside it (§4.5). A payload naming an id the ledger does
5817
5967
  // not know is an ordinary policy proposal (no ORIGIN claim) and takes the path below.
@@ -5820,24 +5970,22 @@ runtime = {}) {
5820
5970
  // The ledger links a proposal to its decision only once the `offered` outcome is written; a
5821
5971
  // proposal crew filed but has not linked yet (the publish window, or a crash in it) names a
5822
5972
  // decision this ledger HOLDS — it must still land through `remember()` (codex on DC-S4a).
5823
- try {
5824
- const pending = (await estateTool('proposal.list', { state: 'pending' }));
5825
- const named = pending.proposals.find((p) => p.id === id)?.payload?.decision?.id;
5826
- if (typeof named === 'string' && decisions.deps.ledger.record(named) !== null)
5827
- decisionId = named;
5828
- }
5829
- catch (err) {
5830
- return estateUpstreamError(reply, err);
5831
- }
5973
+ const named = row?.payload?.decision?.id;
5974
+ if (typeof named === 'string' && decisions.deps.ledger.record(named) !== null)
5975
+ decisionId = named;
5832
5976
  }
5833
5977
  if (decisions !== null && decisionId !== null) {
5834
5978
  try {
5835
- const landed = await decisions.remember(decisionId, actorOf(req), { how: 'needs-you' });
5979
+ const landed = await decisions.remember(decisionId, actorOf(req), {
5980
+ how: 'needs-you',
5981
+ ...(policyReach !== undefined ? { edits: { scope: policyReach === 'operator' ? 'everywhere' : 'project' } } : {}),
5982
+ });
5836
5983
  const view = decisions.deps.ledger.view(decisionId);
5837
5984
  const landing = {
5838
5985
  outcome: 'landed',
5839
5986
  ruleId: landed.rule_id,
5840
5987
  ...(view !== null ? { steering_type: view.edits?.steering_type ?? view.derived.steering_type } : {}),
5988
+ reach: landed.project !== undefined ? 'project' : 'operator',
5841
5989
  ...(landed.project !== undefined ? { project: landed.project } : {}),
5842
5990
  };
5843
5991
  return {
@@ -5852,50 +6000,50 @@ runtime = {}) {
5852
6000
  throw err;
5853
6001
  }
5854
6002
  }
5855
- let approved;
5856
- try {
5857
- approved = (await estateTool('proposal.approve', { id }));
5858
- }
5859
- catch (err) {
5860
- return estateUpstreamError(reply, err);
5861
- }
5862
- // MEMORY proposal (or any future non-policy outcome): estate already promoted it — pass through
5863
- // verbatim, exactly as before. No extra estate round-trip on the memory path.
5864
- if (approved.outcome !== 'handed_off') {
6003
+ // Not a pending POLICY proposal (a memory, a knowledge row, or an id that is not pending, which
6004
+ // estate refuses in its own words): estate decides it — pass through verbatim.
6005
+ if (row === undefined || typeof row.kind_type !== 'string' || !row.kind_type.startsWith('policy:')) {
6006
+ let approved;
6007
+ try {
6008
+ approved = (await estateTool('proposal.approve', { id }));
6009
+ }
6010
+ catch (err) {
6011
+ return estateUpstreamError(reply, err);
6012
+ }
6013
+ if (approved.outcome === 'handed_off') {
6014
+ // Estate handed off a policy this route did not see pending (a row filed between the list
6015
+ // and the approve): it is approved with no rule. Say so; never a silent drop.
6016
+ const error = `estate approved policy proposal ${id} before crew could land its rule; land it by hand via ` +
6017
+ `POST ${V}/governance/rules`;
6018
+ audit.record('governance.steering.landing_failed', actorOf(req), { detail: { proposalId: id, error } });
6019
+ return { outcome: 'handed_off', payload: approved.payload, landing: { outcome: 'failed', error } };
6020
+ }
5865
6021
  return approved;
5866
6022
  }
5867
- // POLICY proposal — land the handed-off payload as a steering rule.
6023
+ // POLICY proposal — land the rule, THEN approve it in estate.
6024
+ const policyRow = { ...row, kind_type: row.kind_type };
5868
6025
  const landFailed = (error) => {
5869
6026
  audit.record('governance.steering.landing_failed', actorOf(req), {
5870
6027
  detail: { proposalId: id, error },
5871
6028
  });
5872
- return { outcome: 'handed_off', payload: approved.payload, landing: { outcome: 'failed', error } };
6029
+ // Nothing was approved: the proposal is still pending, so the operator can approve it again.
6030
+ return {
6031
+ outcome: 'handed_off',
6032
+ payload: policyRow.payload,
6033
+ landing: { outcome: 'failed', error: `${error} (the proposal is still pending)` },
6034
+ };
5873
6035
  };
5874
6036
  // A pre-steering engine would SILENTLY DROP `steering_type` and the other steering fields
5875
6037
  // (ConformanceRule has no deny_unknown_fields), persisting a rule that enforces differently than
5876
- // authored — the same guard POST /governance/rules applies. Fail loud (before any further estate
5877
- // round-trip), persist nothing.
6038
+ // authored — the same guard POST /governance/rules applies. Fail loud, persist nothing.
5878
6039
  if (!adapter.steeringSupported()) {
5879
6040
  return landFailed(`${new SteeringUnsupportedError('Landing an approved policy proposal').message} — the ` +
5880
6041
  `installed engine would silently drop the rule's steering fields; upgrade wicked-core-ts (>= 0.7.5)`);
5881
6042
  }
5882
- // Recover the proposal's `kind_type` + `facets`: `proposal.approve` does not echo them, and the
5883
- // steering type lives ONLY in `kind_type` (`policy:<type>`). The proposal is `approved` now, so
5884
- // read it back from the approved queue.
5885
- let proposal;
5886
- try {
5887
- const listed = (await estateTool('proposal.list', { state: 'approved' }));
5888
- proposal = listed.proposals.find((p) => p.id === id);
5889
- }
5890
- catch (err) {
5891
- return landFailed(`the policy proposal was approved but its record could not be re-read to land a steering ` +
5892
- `rule (${message(err)}); land it by hand via POST ${V}/governance/rules`);
5893
- }
5894
- if (proposal === undefined || typeof proposal.kind_type !== 'string') {
5895
- return landFailed(`the policy proposal was approved but could not be found in the approved queue to recover ` +
5896
- `its steering type; land it by hand via POST ${V}/governance/rules`);
5897
- }
5898
- const built = policyProposalToRule(id, proposal.kind_type, approved.payload, isStringRecord(proposal.facets) ? proposal.facets : undefined);
6043
+ // A proposal with no `project` facet keeps the project of the run that filed it.
6044
+ const runId = policyRow.provenance?.['run_id'];
6045
+ const runProject = typeof runId === 'string' && runId !== '' ? projects.index.projectOf(runId) : undefined;
6046
+ const built = policyProposalToRule(id, policyRow.kind_type, policyRow.payload, isStringRecord(policyRow.facets) ? policyRow.facets : undefined, { ...(runProject !== undefined ? { runProject } : {}), ...(policyReach !== undefined ? { reach: policyReach } : {}) });
5899
6047
  if ('error' in built) {
5900
6048
  return landFailed(built.error);
5901
6049
  }
@@ -5906,7 +6054,22 @@ runtime = {}) {
5906
6054
  });
5907
6055
  if (landing.outcome === 'failed')
5908
6056
  return landFailed(landing.error ?? 'the landing failed');
5909
- return { outcome: 'handed_off', payload: approved.payload, landing };
6057
+ // The rule is in the store: now mark the proposal approved. An approve estate does not confirm
6058
+ // is an upstream error and nothing is undone — estate may have committed it, and a concurrent
6059
+ // approve may share the rule. The proposal is then pending with its rule live (the same state a
6060
+ // crash here leaves): approving again re-lands the same rule id and finishes; rejecting it
6061
+ // retires the rule (see the reject route).
6062
+ let approved;
6063
+ try {
6064
+ approved = (await estateTool('proposal.approve', { id }));
6065
+ }
6066
+ catch (err) {
6067
+ audit.record('governance.steering.landing_failed', actorOf(req), {
6068
+ detail: { proposalId: id, ruleId: built.rule.id, error: `rule landed; estate did not confirm the approval: ${message(err)}` },
6069
+ });
6070
+ return estateUpstreamError(reply, err);
6071
+ }
6072
+ return { outcome: 'handed_off', payload: approved.outcome === 'handed_off' ? approved.payload : policyRow.payload, landing };
5910
6073
  });
5911
6074
  // POST /proposals/:id/reject → proposal.reject → { ok: true }.
5912
6075
  app.post(`${V}/proposals/:id/reject`,
@@ -5932,12 +6095,19 @@ runtime = {}) {
5932
6095
  throw err;
5933
6096
  }
5934
6097
  }
6098
+ let rejected;
5935
6099
  try {
5936
- return (await estateTool('proposal.reject', { id }));
6100
+ rejected = (await estateTool('proposal.reject', { id }));
5937
6101
  }
5938
6102
  catch (err) {
5939
6103
  return estateUpstreamError(reply, err);
5940
6104
  }
6105
+ // A policy proposal's rule lands before its approval, so a still-pending proposal may already
6106
+ // have a live rule (a crash or an unconfirmed approve in between): rejecting it withdraws it.
6107
+ const withdrawn = await withdrawLandedRule(adapter, id.trim());
6108
+ if (withdrawn !== null)
6109
+ return reply.code(502).send({ error: withdrawn });
6110
+ return rejected;
5941
6111
  });
5942
6112
  // ── Memory management (governed-knowledge surface, DES-MEM-FACETED-001) ───────
5943
6113
  // Browse + retire the EXISTING operator memory store — the studio counterpart to /proposals:
@@ -6146,6 +6316,175 @@ runtime = {}) {
6146
6316
  return estateUpstreamError(reply, err);
6147
6317
  }
6148
6318
  });
6319
+ // POST /memory/edit { memory_id, content } → memory.list (find it) → memory.capture (the new words,
6320
+ // the old tier/scope/facets) → memory.erase { id: old } → { memory_id: new, retired: old }.
6321
+ // estate has no in-place update and no transaction across tools, so an edit is a replacement made in
6322
+ // steps. Erase-by-id is probed BEFORE anything is written (an id no memory has, so nothing is deleted):
6323
+ // an estate without it answers 501 with nothing changed. After a write, any step that does not answer
6324
+ // as expected is reconciled by re-reading the store, never assumed: a replacement that landed is a
6325
+ // 200; a new copy beside a surviving old memory is taken back out (and checked); whatever remains is
6326
+ // named in the 502.
6327
+ app.post(`${V}/memory/edit`, {
6328
+ config: {
6329
+ manifest: {
6330
+ requestType: 'EditMemoryBody',
6331
+ responseType: 'EditMemoryResponse',
6332
+ statusCodes: [200, 400, 404, 409, 501, 502],
6333
+ },
6334
+ },
6335
+ }, async (req, reply) => {
6336
+ const parsed = EditMemorySchema.safeParse(req.body);
6337
+ if (!parsed.success) {
6338
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid edit request'));
6339
+ }
6340
+ const memoryId = parsed.data.memory_id.trim();
6341
+ const content = parsed.data.content.trim();
6342
+ if (memoryId === '')
6343
+ return reply.code(400).send({ error: '`memory_id` must not be empty' });
6344
+ if (content === '')
6345
+ return reply.code(400).send({ error: '`content` must not be empty' });
6346
+ if (memoryEditsInFlight.has(memoryId)) {
6347
+ return reply.code(409).send({ error: `memory ${memoryId} is already being edited` });
6348
+ }
6349
+ memoryEditsInFlight.add(memoryId);
6350
+ // Every estate call here is crew's own (no client argument reaches estate unchecked), so an
6351
+ // estate refusal is an upstream fault: 502, never the 400 `estateUpstreamError` gives -32602.
6352
+ const upstream = (what, err) => reply.code(502).send({ error: `${what}: ${message(err)}` });
6353
+ const listAll = async () => {
6354
+ const raw = await estateTool('memory.list', {});
6355
+ const items = typeof raw === 'object' && raw !== null ? raw['items'] : undefined;
6356
+ if (!Array.isArray(items))
6357
+ throw new EstateMcpError('memory.list returned an unexpected shape');
6358
+ return items.map(shapeMemoryItem);
6359
+ };
6360
+ const deletedCount = (raw) => (typeof raw === 'object' && raw !== null ? raw['deleted_count'] : undefined);
6361
+ try {
6362
+ // 1. The memory as it is: its tier, scope and facets ride onto the replacement.
6363
+ let before;
6364
+ try {
6365
+ before = await listAll();
6366
+ }
6367
+ catch (err) {
6368
+ return upstream('reading the memory store failed; nothing changed', err);
6369
+ }
6370
+ const old = before.find((m) => m.id === memoryId);
6371
+ if (old === undefined)
6372
+ return reply.code(404).send({ error: `no memory ${memoryId}` });
6373
+ if (old.content.trim() === content) {
6374
+ return reply.code(400).send({ error: 'the new words are the same as the memory\'s; nothing to change' });
6375
+ }
6376
+ // 2. Erase-by-id must exist before anything is written (wicked-estate#188). The probe names an
6377
+ // id no memory carries, so it deletes nothing.
6378
+ try {
6379
+ await estateTool('memory.erase', { id: MEMORY_EDIT_PROBE_ID });
6380
+ }
6381
+ catch (err) {
6382
+ if (err instanceof EstateMcpError && err.code === -32602 && ESTATE_NO_ERASE_BY_ID.test(err.message)) {
6383
+ return reply.code(501).send({
6384
+ error: 'this wicked-estate cannot erase one memory by id (it needs wicked-estate#188), so it cannot replace one; nothing changed',
6385
+ code: 'estate_upgrade_required',
6386
+ });
6387
+ }
6388
+ return upstream('checking that the store can erase one memory failed; nothing changed', err);
6389
+ }
6390
+ // 3. The new words, as a new memory in the old one's place.
6391
+ const captureArgs = { content, scope: old.scope, facets: old.facets };
6392
+ if (old.tier !== '') {
6393
+ captureArgs['tier'] = old.tier;
6394
+ const kind = KIND_FOR_TIER[old.tier];
6395
+ if (kind !== undefined)
6396
+ captureArgs['kind'] = kind;
6397
+ }
6398
+ const beforeIds = new Set(before.map((m) => m.id));
6399
+ let newId = null;
6400
+ let captureErr = null;
6401
+ try {
6402
+ const captured = await estateTool('memory.capture', captureArgs);
6403
+ const id = typeof captured === 'object' && captured !== null ? captured['memory_id'] : undefined;
6404
+ if (typeof id === 'string' && id !== '')
6405
+ newId = id;
6406
+ else
6407
+ captureErr = new EstateMcpError('memory.capture returned no memory_id');
6408
+ }
6409
+ catch (err) {
6410
+ captureErr = err;
6411
+ }
6412
+ if (newId === null) {
6413
+ // The capture may still have written (a lost answer): look before saying nothing changed.
6414
+ let now;
6415
+ try {
6416
+ now = await listAll();
6417
+ }
6418
+ catch (err) {
6419
+ return reply.code(502).send({
6420
+ error: `writing the new words did not answer as expected (${message(captureErr)}) and the store could not be re-read (${message(err)}); a copy of the new words may exist beside ${memoryId}`,
6421
+ });
6422
+ }
6423
+ const copies = now.filter((m) => !beforeIds.has(m.id) && m.content.trim() === content && m.scope === old.scope);
6424
+ if (copies.length !== 1) {
6425
+ return reply.code(502).send({
6426
+ error: copies.length === 0
6427
+ ? `writing the new words failed (${message(captureErr)}); nothing changed`
6428
+ : `writing the new words did not answer as expected (${message(captureErr)}); ${copies.length} copies of them now exist beside ${memoryId}: ${copies.map((m) => m.id).join(', ')}`,
6429
+ });
6430
+ }
6431
+ newId = copies[0].id;
6432
+ }
6433
+ // 4. The old memory out.
6434
+ let eraseErr = null;
6435
+ let deleted;
6436
+ try {
6437
+ deleted = deletedCount(await estateTool('memory.erase', { id: memoryId }));
6438
+ }
6439
+ catch (err) {
6440
+ eraseErr = err;
6441
+ }
6442
+ if (eraseErr === null && deleted === 1) {
6443
+ const body = { memory_id: newId, retired: memoryId };
6444
+ return body;
6445
+ }
6446
+ const why = eraseErr !== null ? `retiring ${memoryId} failed (${message(eraseErr)})` : `retiring ${memoryId} removed ${String(deleted)} memories, expected 1`;
6447
+ // 5. Reconcile: what is in the store now decides the answer, never the failed call.
6448
+ const present = async () => new Set((await listAll()).map((m) => m.id));
6449
+ let ids;
6450
+ try {
6451
+ ids = await present();
6452
+ }
6453
+ catch (err) {
6454
+ return reply.code(502).send({ error: `${why}, and the store could not be re-read (${message(err)}); ${newId} was written and ${memoryId} may remain` });
6455
+ }
6456
+ if (!ids.has(memoryId) && ids.has(newId)) {
6457
+ // The retire landed even though its answer did not: the edit is complete.
6458
+ const body = { memory_id: newId, retired: memoryId };
6459
+ return body;
6460
+ }
6461
+ if (!ids.has(newId)) {
6462
+ return reply.code(502).send({ error: ids.has(memoryId) ? `${why}; nothing changed` : `${why}; neither ${memoryId} nor ${newId} is in the store now` });
6463
+ }
6464
+ // Both are there: take the new copy back out, then check that it is gone.
6465
+ try {
6466
+ await estateTool('memory.erase', { id: newId });
6467
+ }
6468
+ catch {
6469
+ // Checked below: the store, not the call, says whether it is gone.
6470
+ }
6471
+ try {
6472
+ ids = await present();
6473
+ }
6474
+ catch (err) {
6475
+ return reply.code(502).send({ error: `${why}; taking ${newId} back out could not be checked (${message(err)}), so both ${memoryId} and ${newId} may remain` });
6476
+ }
6477
+ return reply.code(502).send({
6478
+ error: ids.has(newId) ? `${why}; taking ${newId} back out failed, so both ${memoryId} and ${newId} remain` : `${why}; the new copy was taken back out, so nothing changed`,
6479
+ });
6480
+ }
6481
+ catch (err) {
6482
+ return upstream('editing the memory failed', err);
6483
+ }
6484
+ finally {
6485
+ memoryEditsInFlight.delete(memoryId);
6486
+ }
6487
+ });
6149
6488
  // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
6150
6489
  registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
6151
6490
  // crew#371: a project's requirements / domain / coverage, folded over its repos' own reads.