wicked-crew 0.3.2 → 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 (56) hide show
  1. package/dist/api/api-prefix.d.ts +13 -0
  2. package/dist/api/api-prefix.d.ts.map +1 -0
  3. package/dist/api/api-prefix.js +13 -0
  4. package/dist/api/api-prefix.js.map +1 -0
  5. package/dist/api/elicitation-cache.d.ts +93 -0
  6. package/dist/api/elicitation-cache.d.ts.map +1 -0
  7. package/dist/api/elicitation-cache.js +128 -0
  8. package/dist/api/elicitation-cache.js.map +1 -0
  9. package/dist/api/evidence.d.ts +48 -38
  10. package/dist/api/evidence.d.ts.map +1 -1
  11. package/dist/api/evidence.js +48 -39
  12. package/dist/api/evidence.js.map +1 -1
  13. package/dist/api/gate-cache.d.ts +29 -13
  14. package/dist/api/gate-cache.d.ts.map +1 -1
  15. package/dist/api/gate-cache.js +82 -36
  16. package/dist/api/gate-cache.js.map +1 -1
  17. package/dist/api/requirements.d.ts +12 -3
  18. package/dist/api/requirements.d.ts.map +1 -1
  19. package/dist/api/requirements.js +34 -37
  20. package/dist/api/requirements.js.map +1 -1
  21. package/dist/api/routes.d.ts +3 -1
  22. package/dist/api/routes.d.ts.map +1 -1
  23. package/dist/api/routes.js +468 -51
  24. package/dist/api/routes.js.map +1 -1
  25. package/dist/api/server.d.ts.map +1 -1
  26. package/dist/api/server.js +13 -6
  27. package/dist/api/server.js.map +1 -1
  28. package/dist/api/unit-output.d.ts +62 -0
  29. package/dist/api/unit-output.d.ts.map +1 -0
  30. package/dist/api/unit-output.js +108 -0
  31. package/dist/api/unit-output.js.map +1 -0
  32. package/dist/core/adapter.d.ts +136 -6
  33. package/dist/core/adapter.d.ts.map +1 -1
  34. package/dist/core/adapter.js +512 -89
  35. package/dist/core/adapter.js.map +1 -1
  36. package/dist/core/bridge-path.d.ts +17 -6
  37. package/dist/core/bridge-path.d.ts.map +1 -1
  38. package/dist/core/bridge-path.js +17 -6
  39. package/dist/core/bridge-path.js.map +1 -1
  40. package/dist/core/exec.d.ts +37 -0
  41. package/dist/core/exec.d.ts.map +1 -0
  42. package/dist/core/exec.js +97 -0
  43. package/dist/core/exec.js.map +1 -0
  44. package/dist/core/repoPaths.d.ts +37 -0
  45. package/dist/core/repoPaths.d.ts.map +1 -0
  46. package/dist/core/repoPaths.js +48 -0
  47. package/dist/core/repoPaths.js.map +1 -0
  48. package/dist/core/types.d.ts +90 -2
  49. package/dist/core/types.d.ts.map +1 -1
  50. package/dist/core/types.js.map +1 -1
  51. package/dist/studio/assets/index-DaaUU8Ep.css +32 -0
  52. package/dist/studio/assets/index-Fu5DRC00.js +423 -0
  53. package/dist/studio/index.html +2 -2
  54. package/package.json +14 -13
  55. package/dist/studio/assets/index-CjUiA3ex.css +0 -32
  56. package/dist/studio/assets/index-DlHYzFvv.js +0 -420
@@ -1,18 +1,34 @@
1
1
  import { createRequire } from 'node:module';
2
- import { execFile } from 'node:child_process';
3
2
  import { mkdir, access, readFile, writeFile, chmod, rm } from 'node:fs/promises';
3
+ import { existsSync, readdirSync, readFileSync, renameSync } from 'node:fs';
4
4
  import { join, dirname, resolve, isAbsolute, relative, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { homedir } from 'node:os';
7
- import { promisify } from 'node:util';
8
7
  import { randomUUID } from 'node:crypto';
9
8
  import { DEFAULT_SETTINGS } from './types.js';
10
- const execFileAsync = promisify(execFile);
9
+ import { execCapped } from './exec.js';
11
10
  /** Resolved path under the user's home directory. */
12
11
  function wickedDir(...parts) {
13
12
  const home = process.env.HOME ?? process.env.USERPROFILE ?? '/tmp';
14
13
  return join(home, '.wicked', ...parts);
15
14
  }
15
+ /**
16
+ * Parse a JSON string a napi binding returned. The bindings type their return loosely (`unknown`
17
+ * until a `ts_return_type` regen), so guard BOTH a non-string return and invalid JSON with an error
18
+ * that names the method — a bare `JSON.parse` throw is an unactionable "Unexpected token" (crew#227
19
+ * review). At the current engine contract `raw` is always a valid JSON string.
20
+ */
21
+ function parseEngineJson(raw, method) {
22
+ if (typeof raw !== 'string') {
23
+ throw new Error(`${method}: expected a JSON string from the engine, got ${typeof raw}`);
24
+ }
25
+ try {
26
+ return JSON.parse(raw);
27
+ }
28
+ catch (e) {
29
+ throw new Error(`${method}: engine returned invalid JSON (${e instanceof Error ? e.message : String(e)})`);
30
+ }
31
+ }
16
32
  /**
17
33
  * Workflow overlay directory — mirrors the Rust `workflow_overlay_dir()` logic in
18
34
  * `pipeline.rs`. The Rust actor reads drop-in workflow JSONs from this path at startup
@@ -30,6 +46,40 @@ function workflowOverlayDir() {
30
46
  function settingsFilePath() {
31
47
  return join(homedir(), '.config', 'wicked-core', 'settings.json');
32
48
  }
49
+ /** Read user-registered workflow overlays from `dir` (the same dir `registerWorkflow` writes to).
50
+ *
51
+ * Skips: files whose id matches a built-in (those are `_writeBuiltinOverlay` artifacts written FOR
52
+ * the Rust actor, not user workflows — including them would duplicate a built-in in `listWorkflows`),
53
+ * non-`.json` files, and any file that does not parse into a `{id, phases[]}` shape (the Rust actor
54
+ * skips an unreadable overlay too, so crew must not surface one it can't). A missing dir yields `[]`.
55
+ *
56
+ * Exported so the FINDING-002 restart-hydration path is unit-testable without spawning a Core. */
57
+ export function readOverlayWorkflows(dir, builtinIds) {
58
+ let files;
59
+ try {
60
+ files = readdirSync(dir);
61
+ }
62
+ catch {
63
+ return []; // no overlay dir yet → nothing registered
64
+ }
65
+ const out = [];
66
+ for (const file of files) {
67
+ if (!file.endsWith('.json'))
68
+ continue;
69
+ const id = file.slice(0, -'.json'.length);
70
+ if (builtinIds.has(id))
71
+ continue; // a built-in overlay, not a user workflow
72
+ try {
73
+ const def = JSON.parse(readFileSync(join(dir, file), 'utf8'));
74
+ if (def && typeof def.id === 'string' && Array.isArray(def.phases))
75
+ out.push(def);
76
+ }
77
+ catch {
78
+ // Unparseable overlay — core would skip it at load, so crew skips it too.
79
+ }
80
+ }
81
+ return out;
82
+ }
33
83
  /** Find the wicked-core standalone binary for the gate-hook command.
34
84
  * Checks common install locations so the Rust actor can build a correct
35
85
  * hook command even when loaded as a napi addon (where current_exe() = node).
@@ -61,10 +111,46 @@ const require = createRequire(import.meta.url);
61
111
  const { Core } = require('wicked-core-ts');
62
112
  // ── Built-in workflow definitions (crew#44) ──────────────────────────────────
63
113
  // Static mirrors of wicked-core workflow defs: feature, bug, migration, survey-repo,
64
- // repo-graph, domain-graph-slice, memories, onboarding, and chat.
114
+ // domain-graph-slice, memories, collab, onboarding, chat, and domain-extraction.
65
115
  // Swap for `this.core.listWorkflowsJson()` / `this.core.getWorkflowJson(id)` once
66
116
  // the wicked-core-ts NAPI methods land.
67
- const BUILTIN_WORKFLOWS = [
117
+ /**
118
+ * The ids wicked-core seeds itself, in `WorkflowRegistry::with_defaults()`.
119
+ *
120
+ * `launchRun`'s generic drop-in overlay write SKIPS every id in this set (`onboarding` is written
121
+ * by the onboarding path instead — see the end of this comment, it is the one deliberate exception).
122
+ * A file in that dir shadows the compiled built-in
123
+ * *wholesale* — `register` overwrites by id and `load_dir` runs after `with_defaults` — so writing
124
+ * this hand-transcribed mirror over the real def silently replaces it with a copy missing whatever
125
+ * the def has grown since the mirror was transcribed. That is not hypothetical: the mirror predated
126
+ * the evidence floors, so the write took `validator_pin` back off `feature.adversarial-review`,
127
+ * `bug.verify` and `migration.verify` — the entire content of core's gate-floor change, undone by a
128
+ * file write, with no error and a workflow still reporting the right id and phases (FINDING-049).
129
+ *
130
+ * The write exists for the ids core does NOT seed (chat, survey-repo, domain-graph-slice,
131
+ * memories, domain-extraction): for those the overlay is the only reason they resolve at all, so
132
+ * it stays.
133
+ *
134
+ * The exception: `onboarding` is core-seeded AND still written, by the onboarding path rather than
135
+ * by the generic one. Deliberate — that def's executor cmds are baked with runtime `--db` paths, so
136
+ * it shadows core's copy with a real customization rather than a stale transcription. It is the one
137
+ * shadow that earns its keep, and the reason this set gates the generic write specifically.
138
+ */
139
+ const CORE_SEEDED_WORKFLOWS = new Set(['feature', 'bug', 'migration', 'onboarding', 'collab']);
140
+ /**
141
+ * The content-address of core's built-in evidence floor (`builtin_floors::EVIDENCE_FLOOR_PIN`),
142
+ * carried on the Evaluator phase of feature/bug/migration.
143
+ *
144
+ * Duplicating a hash is a real cost, paid because the alternative is worse. What `listWorkflows()`
145
+ * serves IS what `GET /api/v1/workflows` and the work-mode selector show, and a `null` here reads
146
+ * as "this phase is ungated" — the opposite of the truth for the three phases core gates. Reporting
147
+ * a gate that exists is the honest failure direction; the drift guard in
148
+ * `tests/armed-workflow-served.test.ts` fails loudly on a developer machine the moment core's value
149
+ * moves. This is display only: as of FINDING-049 these defs are never written to core's overlay dir
150
+ * (see CORE_SEEDED_WORKFLOWS), so a stale value here cannot reach the engine.
151
+ */
152
+ const EVIDENCE_FLOOR_PIN = '2fcde907d57f3ee2';
153
+ export const BUILTIN_WORKFLOWS = [
68
154
  {
69
155
  id: 'chat',
70
156
  is_system: true,
@@ -76,9 +162,19 @@ const BUILTIN_WORKFLOWS = [
76
162
  id: 'onboarding',
77
163
  is_system: true,
78
164
  phases: [
79
- { id: 'index', executor: { type: 'tool', cmd: ['wicked-estate', 'index'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
80
- { id: 'annotate', executor: { type: 'tool', cmd: ['wicked-estate', 'clusters', '--annotate'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['index'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
81
- { id: 'domain', executor: { type: 'tool', cmd: ['wicked-core', 'domain-graph'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['annotate'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
165
+ { id: 'index', executor: { type: 'tool', cmd: ['wicked-estate', 'index', '{repo_root}', '--db', '{code_graph_db}'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
166
+ { id: 'annotate', executor: { type: 'tool', cmd: ['wicked-estate', 'clusters', '--annotate', '--db', '{code_graph_db}'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['index'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
167
+ // index → annotate, and NOT a third `domain` phase running `wicked-core domain-graph`. That
168
+ // phase could never pass: domain-graph fails closed below 1.0 front-half coverage, and nothing
169
+ // in this workflow annotates a single symbol, so coverage was 0.0 on every repo — every
170
+ // registration ended sessionFailed after the two phases that matter had both succeeded
171
+ // (FINDING-068). domain-graph belongs to `domain-extraction`, downstream of the agentic
172
+ // extract+coverage phases that produce its precondition. Mirrors core's `onboarding_def()`.
173
+ //
174
+ // The `{repo_root}` / `{code_graph_db}` placeholders are core's, substituted per run from the
175
+ // launch's `repoRef` (wicked-core#179). This package used to bake absolute paths in here and
176
+ // write the result to one shared overlay file per launch — which concurrent registrations
177
+ // raced, indexing one repo's tree under another repo's name (FINDING-075, #196).
82
178
  ],
83
179
  },
84
180
  {
@@ -87,7 +183,7 @@ const BUILTIN_WORKFLOWS = [
87
183
  { id: 'clarify', kind: 'recon', gate_type: 'value', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
88
184
  { id: 'design', kind: 'recon', gate_type: 'strategy', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['clarify'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
89
185
  { id: 'build', kind: 'build', gate_type: 'execution', gate: 'auto', executes_code: true, verified_evidence: false, required_deliverables: [], depends_on: ['design'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
90
- { id: 'adversarial-review', kind: 'review', gate_type: 'execution', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['build'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: null },
186
+ { id: 'adversarial-review', kind: 'review', gate_type: 'execution', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['build'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN },
91
187
  { id: 'test', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: false, verified_evidence: true, required_deliverables: [], depends_on: ['build'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
92
188
  { id: 'review', kind: 'review', gate_type: 'execution', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['test'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
93
189
  ],
@@ -98,7 +194,7 @@ const BUILTIN_WORKFLOWS = [
98
194
  { id: 'triage', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
99
195
  { id: 'reproduce', kind: 'test', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['triage'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
100
196
  { id: 'fix', kind: 'build', gate_type: 'execution', gate: 'auto', executes_code: true, verified_evidence: false, required_deliverables: [], depends_on: ['reproduce'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
101
- { id: 'verify', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: false, verified_evidence: true, required_deliverables: [], depends_on: ['fix'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: null },
197
+ { id: 'verify', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: false, verified_evidence: true, required_deliverables: [], depends_on: ['fix'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN },
102
198
  ],
103
199
  },
104
200
  {
@@ -107,25 +203,24 @@ const BUILTIN_WORKFLOWS = [
107
203
  { id: 'plan', kind: 'recon', gate_type: 'strategy', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
108
204
  { id: 'execute', kind: 'build', gate_type: 'execution', gate: 'auto', executes_code: true, verified_evidence: false, required_deliverables: [], depends_on: ['plan'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
109
205
  { id: 'cutover', kind: 'build', gate_type: 'execution', gate: { human_confirm: { unconditional: true } }, executes_code: true, verified_evidence: false, required_deliverables: [], depends_on: ['execute'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
110
- { id: 'verify', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: false, verified_evidence: true, required_deliverables: [], depends_on: ['cutover'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: null },
206
+ { id: 'verify', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: false, verified_evidence: true, required_deliverables: [], depends_on: ['cutover'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN },
111
207
  { id: 'cleanup', kind: 'build', gate_type: null, gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['verify'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
112
208
  ],
113
209
  },
114
210
  {
115
- id: 'repo-graph',
116
- is_system: true,
117
- phases: [
118
- { id: 'index', executor: { type: 'tool', cmd: ['wicked-estate', 'index'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
119
- { id: 'annotate', executor: { type: 'tool', cmd: ['wicked-estate', 'clusters', '--annotate'] }, kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['index'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
120
- ],
121
- },
122
- {
211
+ // MUST stay byte-identical to wicked-core/workflows/survey-repo.json — crew's overlay write is the
212
+ // ONLY def the engine resolves at runtime (core does not seed survey-repo), so a stale mirror here
213
+ // silently runs the OLD def. The pre-fix mirror carried 3 phases with no `instructions` and no
214
+ // `synthesize`, so survey-repo ran 3 near-identical prompts and produced no run-level synthesis —
215
+ // exactly FINDING-011, still live because the fix only landed in the core JSON the runtime ignores.
216
+ // Guarded by builtin-overlay-shadow.test.ts (survey-repo is now in MIRRORED_IDS).
123
217
  id: 'survey-repo',
124
218
  is_system: true,
125
219
  phases: [
126
- { id: 'structure', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
127
- { id: 'stack', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['structure'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
128
- { id: 'conventions', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['stack'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
220
+ { id: 'structure', kind: 'recon', instructions: 'Map the repository layout only: top-level directories, entry points, and where source, tests, config, and docs live. Do not analyze languages, dependencies, or conventions — later phases cover those.', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
221
+ { id: 'stack', kind: 'recon', instructions: 'Identify the technology stack from the manifests (package.json, Cargo.toml, pyproject.toml, ...): languages, frameworks, build tools, key dependencies. Build on the structure summary provided as prior context; do not re-map the layout.', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['structure'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
222
+ { id: 'conventions', kind: 'recon', instructions: 'Identify the working conventions: naming, module boundaries, test placement and style, lint/format configuration, CI expectations. Build on the prior phases\' outputs provided as context; do not re-survey structure or stack.', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['stack'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
223
+ { id: 'synthesize', kind: 'recon', instructions: 'Do not re-survey the repository. Merge the three prior phase outputs provided as context into one coherent survey — structure, then stack, then conventions — resolving overlaps and flagging any contradictions between them.', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['structure', 'stack', 'conventions'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
129
224
  ],
130
225
  },
131
226
  {
@@ -155,7 +250,132 @@ const BUILTIN_WORKFLOWS = [
155
250
  { id: 'verdict', kind: 'review', gate_type: 'value', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['revise'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: null },
156
251
  ],
157
252
  },
253
+ // The one workflow that ARMS the dual-validator gate: `coverage` carries an approved
254
+ // `validator_pin`, so layer 1 is live here and inert in every entry above. Transcribed
255
+ // field-for-field from the source of truth, `wicked-core/workflows/domain-extraction.json`
256
+ // (core ships it as a *drop-in*, not a seeded built-in, and exposes no dump command — hence a
257
+ // hand-transcribed mirror, like every other entry in this array).
258
+ //
259
+ // The pin is a content hash over the validator's criterion + script + approved flag. Core
260
+ // re-derives it in `domain_extraction.rs` and fails its own test if it drifts; if that test ever
261
+ // forces core's constant to change, THIS literal must change with it or crew will write an
262
+ // overlay that fails closed at plan time.
263
+ //
264
+ // Running it needs a one-time, idempotent `wicked-core seed-domain-validators` to vault + approve
265
+ // that validator. That step is deliberately manual — approval is an audited act a human/council
266
+ // owns, not something a daemon does unattended — and until it is run, a launch fails CLOSED at
267
+ // plan time rather than running the phase ungated. Not `is_system`: this is an operator-selectable
268
+ // work mode, unlike the dedicated-entry-point workflows above.
269
+ {
270
+ id: 'domain-extraction',
271
+ phases: [
272
+ // required_deliverables reconciled with core (wicked-core/workflows/domain-extraction.json):
273
+ // survey/analyze/extract annotate the estate STORE and domain-graph now PERSISTS the graph
274
+ // into the store (not a JSON file), so their evidence is DB state — verified by the coverage
275
+ // gate (reads the store) and domain-graph's fail-closed-on-coverage<1.0 — not a worktree file.
276
+ // Only coverage emits a genuine standalone report the deterministic floor reads. Declaring
277
+ // phantom files failed every phase under core's FINDING-101 deliverable gate.
278
+ { id: 'survey', kind: 'recon', gate_type: null, gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: 'wicked-garden-domain', allowed_skills: [], validator_pin: null },
279
+ { id: 'analyze', kind: 'recon', gate_type: null, gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['survey'], role: 'neutral', skill_ref: 'wicked-garden-domain', allowed_skills: [], validator_pin: null },
280
+ { id: 'extract', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['analyze'], role: 'creator', skill_ref: 'wicked-garden-domain-extractor', allowed_skills: [], validator_pin: null },
281
+ { id: 'coverage', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: false, verified_evidence: true, required_deliverables: ['coverage-report.json'], depends_on: ['extract'], role: 'evaluator', skill_ref: 'wicked-garden-domain-coverage', allowed_skills: [], validator_pin: '49b61ba3ab5264e4' },
282
+ // domain-graph is a DETERMINISTIC Tool that runs `wicked-core domain-graph`, which PERSISTS the
283
+ // domain/requirement/rule graph into the repo store (core#237) — not an LLM skill that could hit
284
+ // a non-persisting hermetic fallback. Mirrors wicked-core/workflows/domain-extraction.json.
285
+ { id: 'domain-graph', executor: { type: 'tool', cmd: ['wicked-core', 'domain-graph', '--db', '{code_graph_db}', '--out', 'requirements_graph.json'] }, kind: 'build', gate_type: 'strategy', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['coverage'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
286
+ ],
287
+ },
158
288
  ];
289
+ /**
290
+ * Chat is not available in this deployment at all — a capability gap, never a bad request.
291
+ *
292
+ * It arrives two ways and both mean the same thing to an operator: the addon predates the binding
293
+ * (no method to call), or the engine was spawned without the ACP runner and says so when called.
294
+ * Only the first is knowable before the call, which is why this is a thrown type rather than a
295
+ * capability flag.
296
+ *
297
+ * Typed rather than left to the caller to sniff out of the message text: the route used to regex
298
+ * the message for one of the two phrasings, so the other fell through to `400` and told an operator
299
+ * to fix a request that was already correct.
300
+ */
301
+ export class ChatUnsupportedError extends Error {
302
+ constructor(message) {
303
+ super(message);
304
+ this.name = 'ChatUnsupportedError';
305
+ }
306
+ }
307
+ /**
308
+ * `resolveElicitation` is not available in this deployment — the NAPI binding has not
309
+ * landed in the installed `wicked-core-ts` yet (DES-002 §4 P-1 stub).
310
+ *
311
+ * Routes map this to HTTP 501 so an operator knows to upgrade rather than to fix a
312
+ * call that was already correct.
313
+ */
314
+ export class ElicitationUnsupportedError extends Error {
315
+ constructor(message) {
316
+ super(message);
317
+ this.name = 'ElicitationUnsupportedError';
318
+ }
319
+ }
320
+ /** The engine's own way of reporting a build that cannot do chat, raised at call time. */
321
+ const ENGINE_CHAT_UNSUPPORTED = /chat unsupported/i;
322
+ /**
323
+ * Quarantine a pre-#197 `onboarding.json` left in the overlay dir.
324
+ *
325
+ * This package used to write that file on every launch, baked with ONE repo's absolute paths. It no
326
+ * longer does — core declares `{repo_root}` / `{code_graph_db}` and binds them per run
327
+ * (wicked-core#179). But the overlay dir is PERSISTENT STATE, and the engine's `load_dir` registers
328
+ * whatever it finds there, replacing a compiled def by id, wholesale.
329
+ *
330
+ * So an upgraded deployment keeps running the last file the old code wrote. Not intermittently:
331
+ * EVERY onboarding run indexes whichever repo happened to be registered last before the upgrade.
332
+ * Observed exactly that on this host after #197 merged — three fresh registrations in three
333
+ * different orgs all indexed `agentic-products/eliza`, the last repo seeded before the fix.
334
+ *
335
+ * Renamed rather than deleted. The file is almost certainly machine-written, but the overlay dir is
336
+ * an operator-facing extension point and silently destroying something out of it is not this
337
+ * process's call. The rename is enough to stop the shadow, and leaves the evidence in place.
338
+ */
339
+ function quarantineStaleOnboardingOverlay() {
340
+ const stale = join(workflowOverlayDir(), 'onboarding.json');
341
+ if (!existsSync(stale))
342
+ return; // the ordinary case on a clean install
343
+ // Only a PRE-#197 artifact, never an operator's override. `registerWorkflow()` writes user
344
+ // definitions into this same directory, and parking one on every boot would delete a deliberate
345
+ // customization each time it was re-registered.
346
+ //
347
+ // The signature is specific: old crew baked one repo's ABSOLUTE paths into the tool commands. A
348
+ // def carrying `{repo_root}` / `{code_graph_db}`, or agent phases, or relative commands, is not
349
+ // what this is looking for and is left alone. An operator who hand-writes absolute paths into a
350
+ // shared def has written the same bug, and gets the same treatment for the same reason.
351
+ let bakedPaths;
352
+ try {
353
+ const def = JSON.parse(readFileSync(stale, 'utf8'));
354
+ bakedPaths = (def.phases ?? [])
355
+ .flatMap((p) => (p.executor?.type === 'tool' ? (p.executor.cmd ?? []) : []))
356
+ .filter((arg) => arg.startsWith('/'));
357
+ }
358
+ catch {
359
+ // Unparseable: not ours to judge. The engine reports its own load failure.
360
+ return;
361
+ }
362
+ if (bakedPaths.length === 0)
363
+ return;
364
+ const parked = `${stale}.superseded-by-crew197`;
365
+ try {
366
+ renameSync(stale, parked);
367
+ console.warn(`[onboarding] removed a stale overlay that would have hijacked every onboarding run: ` +
368
+ `${stale} → ${parked}. It baked ${bakedPaths[0]} into a def shared by every repo, which is ` +
369
+ `what a pre-#197 crew wrote; the engine resolves it in preference to the built-in ` +
370
+ `(FINDING-075).`);
371
+ }
372
+ catch (err) {
373
+ // Loud, and non-fatal: the daemon still starts, but every onboarding on this host is wrong
374
+ // until the file goes, so the operator has to be told rather than left to discover it.
375
+ console.error(`[onboarding] FAILED to remove the stale overlay at ${stale}: ${err instanceof Error ? err.message : String(err)}. Until it is removed by hand, every onboarding run will index the repo baked into it, ` +
376
+ `whatever repo the run names (FINDING-075).`);
377
+ }
378
+ }
159
379
  /**
160
380
  * The single isolation boundary over wicked-core-ts. It holds the ONE `Core`
161
381
  * handle, makes the ONE `subscribe()` call for the whole process, parses each
@@ -163,6 +383,21 @@ const BUILTIN_WORKFLOWS = [
163
383
  * endpoint and the WS fan-out funnel through this stable API — so when the
164
384
  * in-flight core-ts subscribe/teardown signature lands, only this file changes.
165
385
  */
386
+ /** The ids of a workflow's phases that carry a HUMAN gate (`human_confirm` unconditional, or the
387
+ * conditional `human_confirm_if`) — i.e. the phases that will PAUSE for a person.
388
+ *
389
+ * FINDING-023 (residual): core#208 made a workflow's phase gate deliberately WIN over a run-level
390
+ * `humanConfirm: none` (it pauses, with a self-disclosing note), but there was no way to learn a
391
+ * workflow's gates BEFORE launching it — an operator picking `none` for an unattended run only found
392
+ * out when it paused. Surfacing this on `GET /workflows/:id` is that missing launch-time signal.
393
+ * `'auto'` (the string form) is NOT a human gate. */
394
+ export function humanGatePhaseIds(wf) {
395
+ return wf.phases
396
+ .filter((p) => typeof p.gate === 'object' &&
397
+ p.gate !== null &&
398
+ ('human_confirm' in p.gate || 'human_confirm_if' in p.gate))
399
+ .map((p) => p.id);
400
+ }
166
401
  export class CoreAdapter {
167
402
  core;
168
403
  subscription;
@@ -181,6 +416,9 @@ export class CoreAdapter {
181
416
  // it publishes `task.dispatched` / consumes `task.completed` around WHATEVER step runner the engine
182
417
  // wires (the production wrapped-CLI runner OR the deterministic stub), so a stub engine can arm it
183
418
  // for a fast, offline, deterministic proof of the event path.
419
+ // BEFORE the Core spawns: the actor reads the overlay dir at startup, so a stale
420
+ // `onboarding.json` has to be out of the way by then or it shadows the built-in def.
421
+ quarantineStaleOnboardingOverlay();
184
422
  const armExec = opts.engineExec === true;
185
423
  if (armExec) {
186
424
  if (!opts.busDbPath || opts.busDbPath.length === 0) {
@@ -254,11 +492,17 @@ export class CoreAdapter {
254
492
  opts.repoRef = input.repoRef;
255
493
  if (input.workflow !== undefined) {
256
494
  opts.workflow = input.workflow;
257
- // Ensure built-in workflow definitions are present in the Rust overlay dir on first use.
495
+ // Ensure DROP-IN workflow definitions are present in the Rust overlay dir on first use.
258
496
  // Uses a dedicated helper (not registerWorkflow) to avoid adding built-ins to userWorkflows,
259
497
  // which would duplicate them in listWorkflows(). The write is skipped after the first call
260
498
  // per process lifetime.
261
- const builtinDef = BUILTIN_WORKFLOWS.find((w) => w.id === input.workflow);
499
+ //
500
+ // Ids core seeds itself are excluded: writing them shadows the real def with this stale
501
+ // mirror — see CORE_SEEDED_WORKFLOWS. Core resolves those from its own registry, so there is
502
+ // nothing to write and never was.
503
+ const builtinDef = CORE_SEEDED_WORKFLOWS.has(input.workflow)
504
+ ? undefined
505
+ : BUILTIN_WORKFLOWS.find((w) => w.id === input.workflow);
262
506
  if (builtinDef && !this._builtinOverlayWritten.has(input.workflow)) {
263
507
  // Mark before await so concurrent launchRun() calls for the same builtin
264
508
  // don't both pass the has() check and race to write the same file.
@@ -287,6 +531,23 @@ export class CoreAdapter {
287
531
  }
288
532
  return this.core.injectWorkerMessage(runId, message, target);
289
533
  }
534
+ /**
535
+ * A run's recorded event history, oldest first — or `null` when this wicked-core build has no
536
+ * event-log read binding.
537
+ *
538
+ * `null` rather than `[]` on purpose. An empty history is a real, ordinary answer (a run that
539
+ * emitted nothing, or one predating the log), and collapsing "nothing happened" into "I cannot
540
+ * tell you what happened" is how a missing capability gets reported to an operator as an absent
541
+ * gate — the FINDING-050 shape, distinct causes wearing one message. Callers branch on it.
542
+ */
543
+ async runEvents(runId) {
544
+ if (typeof this.core.runEvents !== 'function')
545
+ return null;
546
+ // `RecordedEvent`, not `CoreEvent`: the binding's contract is the `/ws` frame PLUS a capture-time
547
+ // `ts` and an ordering `seq`, and consumers (the evidence bundle) need both. Typing this as the
548
+ // bare frame made every caller widen or cast to get at fields the engine always sends.
549
+ return JSON.parse(await this.core.runEvents(runId));
550
+ }
290
551
  /** Run ids on the store. */
291
552
  async sessions() {
292
553
  return JSON.parse(await this.core.sessions());
@@ -327,14 +588,84 @@ export class CoreAdapter {
327
588
  async workOutput(unitId) {
328
589
  return JSON.parse(await this.core.workOutput(unitId));
329
590
  }
591
+ // ── Chat sessions (core#134 / crew#165) ────────────────────────────────────
592
+ async chatOpen(chatId, clis, cwd) {
593
+ const raw = await this.core.chatOpen(chatId, JSON.stringify(clis), cwd ?? null);
594
+ return JSON.parse(raw);
595
+ }
596
+ async chatSend(chatId, text, targets, cwd) {
597
+ const raw = await this.core.chatSend(chatId, text, targets === undefined ? null : JSON.stringify(targets), cwd ?? null);
598
+ return JSON.parse(raw);
599
+ }
600
+ async chatSeats(chatId) {
601
+ return JSON.parse(await this.core.chatSeats(chatId));
602
+ }
603
+ /**
604
+ * Every live chat, so an operator can find the ones nothing is going to close (FINDING-027).
605
+ *
606
+ * Chat sessions are a warm pool that deliberately outlives the page, and the only client that
607
+ * knew a chat's id is the tab that minted it. Without this an orphaned seat is unreclaimable
608
+ * short of restarting the daemon — the leak is real but invisible, which is the worse half.
609
+ *
610
+ * `idleSecs` is `number | null`, not `number`. The Rust side uses `u64::MAX` for "no activity
611
+ * timestamp"; as an f64 that arrives as 18446744073709552000, which no caller can test for by
612
+ * equality and every caller can accidentally do arithmetic on. The binding maps it to `null`.
613
+ */
614
+ async chatList() {
615
+ const list = this.core.chatList;
616
+ if (typeof list !== 'function') {
617
+ throw new ChatUnsupportedError('Listing chats is not yet supported by this wicked-core build');
618
+ }
619
+ try {
620
+ return JSON.parse(await list.call(this.core));
621
+ }
622
+ catch (err) {
623
+ // A build without the ACP runner has the binding and refuses at call time, so the presence
624
+ // check above cannot catch it. Classified here rather than at the route because this file is
625
+ // the only one that touches the addon (DES-STUDIO-001 §5.2) — matching engine wording anywhere
626
+ // else would spread that coupling.
627
+ const text = err instanceof Error ? err.message : String(err);
628
+ if (ENGINE_CHAT_UNSUPPORTED.test(text))
629
+ throw new ChatUnsupportedError(text);
630
+ throw err;
631
+ }
632
+ }
633
+ async chatClose(chatId) {
634
+ await this.core.chatClose(chatId);
635
+ }
636
+ /**
637
+ * Forward the operator's elicitation response to the actor (DES-002 §4 P-1).
638
+ *
639
+ * NAPI flat signature: `resolve_elicitation(run_id, elicitation_id, action, response)`.
640
+ * `response` is `null` for `decline` and `cancel` actions; a non-empty string for `accept`.
641
+ *
642
+ * Throws `ElicitationUnsupportedError` until the NAPI binding is present in the installed
643
+ * `wicked-core-ts`. Routes map that to HTTP 501.
644
+ */
645
+ async resolveElicitation(_runId, _elicitationId, _action, _response) {
646
+ // Consume stub params to satisfy @typescript-eslint/no-unused-vars; the
647
+ // parameter names are part of the public interface and must not be dropped.
648
+ void _runId;
649
+ void _elicitationId;
650
+ void _action;
651
+ void _response;
652
+ // The NAPI binding (`this.core.resolveElicitation`) will land with the actor-side
653
+ // work in a follow-on. Until then, every call throws so the route surfaces 501 and
654
+ // an operator knows to upgrade rather than to keep retrying.
655
+ throw new ElicitationUnsupportedError('resolveElicitation is not yet bound in this wicked-core build; upgrade wicked-core-ts to enable it');
656
+ }
330
657
  /** repo id → onboarding run id (in-memory; graph persists on disk across restarts). */
331
658
  repoOnboardRunIds = new Map();
332
- /** repo ids with an onboarding run in flight — guards against concurrent double-launch. */
333
- onboardingInFlight = new Set();
334
- /** Serializes onboarding launches: they rewrite the SHARED 'onboarding' overlay with
335
- * repo-specific paths, so two repos launching concurrently must not interleave between
336
- * overlay registration and launchRun (after launch the def is baked into the run's units). */
337
- _onboardingChain = Promise.resolve();
659
+ /**
660
+ * repo id → the in-flight launch, so a concurrent caller joins it instead of starting a second.
661
+ *
662
+ * A `Set` of ids was not enough. The id was added here but the run id was only recorded in
663
+ * `repoOnboardRunIds` AFTER the launch resolved, so a second caller arriving mid-flight saw
664
+ * "in flight" with no run id to return, fell through, and launched a DUPLICATE run against the
665
+ * same repo. Holding the promise makes the second caller await the first and receive its run id —
666
+ * the dedup the `Set` was named for.
667
+ */
668
+ onboardingInFlight = new Map();
338
669
  /** Register a local git repo → the persisted `RepoEntry`. */
339
670
  async registerRepo(name, rootPath) {
340
671
  return JSON.parse(await this.core.registerRepo(name, rootPath));
@@ -393,7 +724,7 @@ export class CoreAdapter {
393
724
  catch { /* not yet cloned */ }
394
725
  if (needsClone) {
395
726
  try {
396
- await execFileAsync('git', ['clone', '--', gitUrl, cloneDir], {
727
+ await execCapped('git', ['clone', '--', gitUrl, cloneDir], {
397
728
  timeout: 5 * 60 * 1000,
398
729
  });
399
730
  }
@@ -421,63 +752,52 @@ export class CoreAdapter {
421
752
  * Returns the run id so the UI can navigate directly to it.
422
753
  */
423
754
  async launchOnboardingRun(repoId, repoName) {
424
- if (this.onboardingInFlight.has(repoId)) {
425
- const existing = this.repoOnboardRunIds.get(repoId);
426
- if (existing)
427
- return existing;
428
- }
429
- this.onboardingInFlight.add(repoId);
755
+ // Join an in-flight launch for THIS repo rather than starting a second one. Concurrency across
756
+ // DIFFERENT repos is the point and is untouched; two launches for the SAME repo are a duplicate.
757
+ const inFlight = this.onboardingInFlight.get(repoId);
758
+ if (inFlight)
759
+ return inFlight;
430
760
  const runId = randomUUID();
431
- const chained = this._onboardingChain.then(() => this._doOnboardingLaunch(repoId, repoName, runId));
432
- this._onboardingChain = chained.catch(() => undefined);
761
+ // Launches are NOT serialized. They used to be, through an `_onboardingChain` promise, because
762
+ // each rewrote the shared `onboarding` overlay before launching. That chain never worked: its
763
+ // own comment claimed "after launch the def is baked into the run's units", and the def is
764
+ // actually resolved at DISPATCH — after the launch call returns. So it serialized the writer and
765
+ // left the reader racing, which is how three concurrent registrations indexed one repo under
766
+ // three names (FINDING-075, #196).
767
+ //
768
+ // Nothing is shared now: core binds each run's repo into its own units from `repoRef`
769
+ // (wicked-core#179). Concurrent registration is the point — it is a requirement of the corpus
770
+ // this platform is tested against, not an optimisation.
771
+ const launch = this._doOnboardingLaunch(repoId, repoName, runId).then(() => runId);
772
+ this.onboardingInFlight.set(repoId, launch);
433
773
  try {
434
- await chained;
435
- return runId;
774
+ return await launch;
436
775
  }
437
776
  finally {
438
777
  this.onboardingInFlight.delete(repoId);
439
778
  }
440
779
  }
441
780
  async _doOnboardingLaunch(repoId, repoName, runId) {
442
- {
443
- // Bake THIS repo's absolute paths into the onboarding def (core#120). The static def's
444
- // relative commands are triple-wrong at runtime: the run's workdir is the per-run WORKTREE
445
- // (not the root the graph endpoint reads), and estate's default db location/name
446
- // (.wicked-estate/graph.db) differs from the endpoint's (.codegraph/estate.db). Rewritten
447
- // per launch and hot-registered so the running actor sees it — never restart-dependent.
448
- const repoEntries = await this.listRepos();
449
- const repoEntry = repoEntries.find((r) => r.id === repoId);
450
- if (!repoEntry)
451
- throw new Error(`repo ${repoId} not registered`);
452
- const dbPath = join(repoEntry.root_path, '.codegraph', 'estate.db');
453
- const base = BUILTIN_WORKFLOWS.find((w) => w.id === 'onboarding');
454
- const requirementsGraphPath = join(repoEntry.root_path, '.wicked-estate', 'requirements', 'requirements_graph.json');
455
- const CMDS = {
456
- index: ['wicked-estate', 'index', repoEntry.root_path, '--db', dbPath],
457
- annotate: ['wicked-estate', 'clusters', '--annotate', '--db', dbPath],
458
- // The real domain front-end (writes what /repos/:id/domain-graph reads). Fails
459
- // closed with an actionable message until the domain-extraction front-half has
460
- // annotated the graph — that message surfacing in the unit output is correct.
461
- domain: ['wicked-core', 'domain-graph', '--db', dbPath, '--out', requirementsGraphPath],
462
- };
463
- const def = {
464
- ...base,
465
- phases: base.phases.map((ph) => CMDS[ph.id] ? { ...ph, executor: { type: 'tool', cmd: CMDS[ph.id] } } : ph),
466
- };
467
- // _writeBuiltinOverlay persists the overlay AND hot-registers it in the actor.
468
- await this._writeBuiltinOverlay(def);
469
- // Mark the builtin as written BEFORE launchRun: its generic once-guard would otherwise
470
- // see 'onboarding' as unwritten and clobber the baked def with the static mirror in the
471
- // window between this write and the engine resolving the workflow (observed live).
472
- this._builtinOverlayWritten.add('onboarding');
473
- await this.launchRun({
474
- problem: `Onboard repository: ${repoName}`,
475
- sessionId: runId,
476
- clisJson: JSON.stringify(CoreAdapter.roster()),
477
- workflow: 'onboarding',
478
- repoRef: repoId,
479
- });
480
- }
781
+ // No overlay write. This used to rewrite core's `onboarding` def with THIS repo's absolute paths
782
+ // and persist it to one shared file (`~/.config/wicked-core/workflows/onboarding.json`), then
783
+ // hot-register it — the one place a core-seeded id was deliberately shadowed.
784
+ //
785
+ // That shadow was the defect. The engine resolves a workflow at DISPATCH time, after this call
786
+ // returns, so concurrent launches raced on the single file and the last writer won: two repos in
787
+ // two different orgs had a third org's tree indexed into a third org's database, each reported
788
+ // under its own name (FINDING-075, #196). Serializing the writes does not fix it — the chain
789
+ // serializes the producer and leaves the consumer racing.
790
+ //
791
+ // Core now declares `{repo_root}` / `{code_graph_db}` on the phases and binds them per run from
792
+ // `repoRef`, which this call already passes (wicked-core#179). Nothing is shared, so nothing can
793
+ // be raced, and onboarding launches may run concurrently.
794
+ await this.launchRun({
795
+ problem: `Onboard repository: ${repoName}`,
796
+ sessionId: runId,
797
+ clisJson: JSON.stringify(CoreAdapter.roster()),
798
+ workflow: 'onboarding',
799
+ repoRef: repoId,
800
+ });
481
801
  this.repoOnboardRunIds.set(repoId, runId);
482
802
  }
483
803
  /** Return the onboarding run id for a repo (undefined if not launched this session). */
@@ -505,6 +825,25 @@ export class CoreAdapter {
505
825
  async getCoverageReport() {
506
826
  return JSON.parse(await this.core.getCoverageReport());
507
827
  }
828
+ /**
829
+ * Coverage for ONE registered repo, computed over that repo's OWN code graph (FINDING-009). Unlike
830
+ * {@link getCoverageReport} — which reads the daemon store and reports a vacuous `coverage: 1.0` that
831
+ * names no repo — this resolves `repoRef` in the registry and recomputes over its `code_graph_db`.
832
+ * The core rejects an unknown repo (never a silent vacuous report), so this throws for a bad ref.
833
+ */
834
+ async getCoverageReportForRepo(repoRef) {
835
+ // The napi binding returns a JSON string (`serde_json::to_string`); parse it with a guard that
836
+ // names the method on either a non-string return or invalid JSON (Copilot #227).
837
+ return parseEngineJson(await this.core.getCoverageReportForRepo(repoRef), 'getCoverageReportForRepo');
838
+ }
839
+ /**
840
+ * Node-count-by-kind summary of ONE registered repo's code graph, over that repo's OWN store
841
+ * (#122) — what the estate graph actually holds for the repo, so an operator can see it was
842
+ * populated. The core rejects an unknown repo, so this throws for a bad ref.
843
+ */
844
+ async getGraphKindsForRepo(repoRef) {
845
+ return parseEngineJson(await this.core.getGraphKindsForRepo(repoRef), 'getGraphKindsForRepo');
846
+ }
508
847
  // ── Governance writes (crew#42) ────────────────────────────────────────────
509
848
  /** Upsert a governance policy via the single-writer actor. */
510
849
  async upsertPolicy(policy) {
@@ -514,6 +853,28 @@ export class CoreAdapter {
514
853
  async upsertConformanceRule(rule) {
515
854
  await this.core.upsertConformanceRule(JSON.stringify(rule));
516
855
  }
856
+ /**
857
+ * Withdraw a policy from enforcement. Resolves `true` if a policy with that id existed.
858
+ *
859
+ * Retire, not delete (FINDING-038): the node stays readable so a past decision citing this id is
860
+ * still explicable, but governance stops selecting it. The boolean is what lets the route answer
861
+ * 404 instead of reporting a success that removed nothing.
862
+ */
863
+ async retirePolicy(id) {
864
+ const retire = this.core.retirePolicy;
865
+ if (typeof retire !== 'function') {
866
+ throw new Error('Retiring a policy is not yet supported by this wicked-core build');
867
+ }
868
+ return JSON.parse(await retire.call(this.core, id));
869
+ }
870
+ /** Withdraw a conformance rule from recall. Same contract as {@link retirePolicy}. */
871
+ async retireConformanceRule(id) {
872
+ const retire = this.core.retireConformanceRule;
873
+ if (typeof retire !== 'function') {
874
+ throw new Error('Retiring a conformance rule is not yet supported by this wicked-core build');
875
+ }
876
+ return JSON.parse(await retire.call(this.core, id));
877
+ }
517
878
  /** Recall conformance rules matching a facet query (read-only, does not block actor). */
518
879
  async recallRulesPreview(query) {
519
880
  const cleanQuery = {};
@@ -531,7 +892,28 @@ export class CoreAdapter {
531
892
  // workflows are added to `userWorkflows` and persisted to disk; the Rust actor
532
893
  // picks them up via `register_workflow` NAPI (when available) for immediate use.
533
894
  userWorkflows = new Map();
895
+ /** Whether {@link hydrateFromOverlay} has run this process lifetime. */
896
+ overlayHydrated = false;
897
+ /** Load user-registered workflows persisted to the overlay dir into `userWorkflows`, ONCE.
898
+ *
899
+ * FINDING-002 residual: `registerWorkflow` writes each def to the overlay dir AND to the in-memory
900
+ * `userWorkflows` Map, but the Map is process-local and empty on every daemon restart, and nothing
901
+ * read the dir back. So after a restart the Rust actor (which DOES load the overlay dir at startup)
902
+ * would launch a user workflow that `listWorkflows()`/`GET /workflows` no longer showed — it
903
+ * vanished from the registry while remaining runnable. Hydrating from the same dir the writer uses
904
+ * makes the two views agree again. */
905
+ hydrateFromOverlay() {
906
+ if (this.overlayHydrated)
907
+ return;
908
+ this.overlayHydrated = true;
909
+ const builtinIds = new Set(BUILTIN_WORKFLOWS.map((w) => w.id));
910
+ for (const def of readOverlayWorkflows(workflowOverlayDir(), builtinIds)) {
911
+ if (!this.userWorkflows.has(def.id))
912
+ this.userWorkflows.set(def.id, def);
913
+ }
914
+ }
534
915
  listWorkflows() {
916
+ this.hydrateFromOverlay();
535
917
  // Builtins first (stable ordering), but user-registered workflows take precedence when
536
918
  // ids conflict — consistent with getWorkflow() which prefers userWorkflows.get().
537
919
  const seen = new Set();
@@ -552,6 +934,7 @@ export class CoreAdapter {
552
934
  return result;
553
935
  }
554
936
  getWorkflow(id) {
937
+ this.hydrateFromOverlay();
555
938
  return this.userWorkflows.get(id) ?? BUILTIN_WORKFLOWS.find((w) => w.id === id) ?? null;
556
939
  }
557
940
  /** Write a built-in workflow definition to the Rust overlay dir (and hot-register when possible).
@@ -561,11 +944,29 @@ export class CoreAdapter {
561
944
  await mkdir(dir, { recursive: true });
562
945
  const overlayDef = { ...def };
563
946
  delete overlayDef.is_system;
564
- await writeFile(join(dir, `${def.id}.json`), JSON.stringify(overlayDef, null, 2), 'utf8');
947
+ const json = JSON.stringify(overlayDef);
565
948
  const core = this.core;
566
- if (typeof core['registerWorkflow'] === 'function') {
567
- await core['registerWorkflow'](JSON.stringify(overlayDef));
949
+ const register = core['registerWorkflow'];
950
+ // Same validate-before-persist ordering as registerWorkflow (FINDING-002). This path had the
951
+ // identical defect — write first, validate last — which is the P3 shape this campaign keeps
952
+ // finding: N paths, one hardened. A mirror that drifted far enough for core to reject it would
953
+ // otherwise leave an unparseable *.json in the dispatch overlay dir, and core would skip it at
954
+ // the next load. Letting the rejection propagate instead fails the launch with core's own
955
+ // reason, which beats dispatching against a workflow core will silently drop.
956
+ if (typeof register === 'function') {
957
+ await register.call(this.core, json);
568
958
  }
959
+ // Deliberately NOT the refusal registerWorkflow makes when the binding is absent, and the
960
+ // difference is the input, not the caller:
961
+ // - a user def is arbitrary runtime input no test has ever seen, so unvalidatable means
962
+ // unsafe to persist;
963
+ // - a built-in mirror is asserted field-for-field against wicked-core's own
964
+ // workflows/<id>.json by tests/builtin-overlay-shadow.test.ts, so its parseability is
965
+ // established at build time rather than needing a runtime check.
966
+ // Refusing here would also break DELIVERY: this write is the only way core resolves a drop-in
967
+ // id, so a refusal turns a silent ungating into a hard "unknown workflow" — exactly the
968
+ // regression FINDING-084's first attempted fix caused.
969
+ await writeFile(join(dir, `${def.id}.json`), JSON.stringify(overlayDef, null, 2), 'utf8');
569
970
  }
570
971
  /**
571
972
  * Register a user-authored workflow: persist to the Rust workflow overlay dir
@@ -585,14 +986,36 @@ export class CoreAdapter {
585
986
  // field and silently drops any workflow whose JSON it cannot fully deserialise.
586
987
  const overlayDef = { ...def };
587
988
  delete overlayDef.is_system;
588
- await writeFile(path, JSON.stringify(overlayDef, null, 2), 'utf8');
589
- this.userWorkflows.set(def.id, def);
590
- // Hot-register in the Rust actor when the NAPI method is available.
591
- // Falls back gracefully if running against an older core build.
989
+ const json = JSON.stringify(overlayDef);
990
+ // VALIDATE BEFORE PERSISTING (FINDING-002). This ordering is the whole fix.
991
+ //
992
+ // The write used to come first and `registerWorkflow` last, so core's parser — the only thing
993
+ // that actually knows the overlay schema — ran AFTER the state was already mutated. Observed
994
+ // end to end: POST /api/v1/workflows answered
995
+ // 400 invalid workflow JSON: unknown field `name`, expected `id` or `phases`
996
+ // and the file was on disk anyway, `name` included, and served from `userWorkflows` as though
997
+ // registered. On the next daemon start core could not deserialise its own overlay file:
998
+ // wicked-core: skipping workflow file .../probe-002-persist.json
999
+ // and the workflow VANISHED while its file remained. That is FINDING-002's root cause: not
1000
+ // "registration is not durable" but "a rejected request persisted a def core cannot read".
1001
+ //
1002
+ // Core's parser is the authority, so it is what we ask. Enumerating the accepted fields in TS
1003
+ // instead would be a second copy of core's schema — the exact drift this codebase keeps paying
1004
+ // for, and `is_system` above is already one hand-maintained instance of it.
592
1005
  const core = this.core;
593
- if (typeof core['registerWorkflow'] === 'function') {
594
- await core['registerWorkflow'](JSON.stringify(overlayDef));
1006
+ const register = core['registerWorkflow'];
1007
+ if (typeof register !== 'function') {
1008
+ // No validator, so no safe way to persist: an unvalidated def written here is a file core
1009
+ // may silently skip at load. Refusing is the honest outcome — and it is loud, unlike the
1010
+ // vanishing act it replaces. `registerWorkflow` has been declared (non-optional) in
1011
+ // wicked-core-ts since 0.4.0, so this is a real floor, not a routine path.
1012
+ throw new Error('this wicked-core build exposes no registerWorkflow binding, so a workflow cannot be ' +
1013
+ 'validated before it is written; refusing to persist an unvalidated definition');
595
1014
  }
1015
+ // Throws on a def core rejects — before anything is written or registered.
1016
+ await register.call(this.core, json);
1017
+ await writeFile(path, JSON.stringify(overlayDef, null, 2), 'utf8');
1018
+ this.userWorkflows.set(def.id, def);
596
1019
  return def.id;
597
1020
  }
598
1021
  /**