wicked-crew 0.3.2 → 0.4.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 (44) hide show
  1. package/dist/api/elicitation-cache.d.ts +93 -0
  2. package/dist/api/elicitation-cache.d.ts.map +1 -0
  3. package/dist/api/elicitation-cache.js +128 -0
  4. package/dist/api/elicitation-cache.js.map +1 -0
  5. package/dist/api/evidence.d.ts +37 -38
  6. package/dist/api/evidence.d.ts.map +1 -1
  7. package/dist/api/evidence.js +40 -36
  8. package/dist/api/evidence.js.map +1 -1
  9. package/dist/api/gate-cache.d.ts +29 -13
  10. package/dist/api/gate-cache.d.ts.map +1 -1
  11. package/dist/api/gate-cache.js +82 -36
  12. package/dist/api/gate-cache.js.map +1 -1
  13. package/dist/api/requirements.d.ts +12 -3
  14. package/dist/api/requirements.d.ts.map +1 -1
  15. package/dist/api/requirements.js +34 -37
  16. package/dist/api/requirements.js.map +1 -1
  17. package/dist/api/routes.d.ts +2 -1
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +388 -41
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/api/server.d.ts.map +1 -1
  22. package/dist/api/server.js +13 -6
  23. package/dist/api/server.js.map +1 -1
  24. package/dist/core/adapter.d.ts +94 -6
  25. package/dist/core/adapter.d.ts.map +1 -1
  26. package/dist/core/adapter.js +386 -86
  27. package/dist/core/adapter.js.map +1 -1
  28. package/dist/core/exec.d.ts +37 -0
  29. package/dist/core/exec.d.ts.map +1 -0
  30. package/dist/core/exec.js +97 -0
  31. package/dist/core/exec.js.map +1 -0
  32. package/dist/core/repoPaths.d.ts +37 -0
  33. package/dist/core/repoPaths.d.ts.map +1 -0
  34. package/dist/core/repoPaths.js +48 -0
  35. package/dist/core/repoPaths.js.map +1 -0
  36. package/dist/core/types.d.ts +81 -2
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/core/types.js.map +1 -1
  39. package/dist/studio/assets/index-Ci_R6ARr.js +423 -0
  40. package/dist/studio/assets/index-Dio_c1Q3.css +32 -0
  41. package/dist/studio/index.html +2 -2
  42. package/package.json +14 -13
  43. package/dist/studio/assets/index-CjUiA3ex.css +0 -32
  44. package/dist/studio/assets/index-DlHYzFvv.js +0 -420
@@ -1,13 +1,12 @@
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, 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';
@@ -61,10 +60,46 @@ const require = createRequire(import.meta.url);
61
60
  const { Core } = require('wicked-core-ts');
62
61
  // ── Built-in workflow definitions (crew#44) ──────────────────────────────────
63
62
  // Static mirrors of wicked-core workflow defs: feature, bug, migration, survey-repo,
64
- // repo-graph, domain-graph-slice, memories, onboarding, and chat.
63
+ // domain-graph-slice, memories, collab, onboarding, chat, and domain-extraction.
65
64
  // Swap for `this.core.listWorkflowsJson()` / `this.core.getWorkflowJson(id)` once
66
65
  // the wicked-core-ts NAPI methods land.
67
- const BUILTIN_WORKFLOWS = [
66
+ /**
67
+ * The ids wicked-core seeds itself, in `WorkflowRegistry::with_defaults()`.
68
+ *
69
+ * `launchRun`'s generic drop-in overlay write SKIPS every id in this set (`onboarding` is written
70
+ * by the onboarding path instead — see the end of this comment, it is the one deliberate exception).
71
+ * A file in that dir shadows the compiled built-in
72
+ * *wholesale* — `register` overwrites by id and `load_dir` runs after `with_defaults` — so writing
73
+ * this hand-transcribed mirror over the real def silently replaces it with a copy missing whatever
74
+ * the def has grown since the mirror was transcribed. That is not hypothetical: the mirror predated
75
+ * the evidence floors, so the write took `validator_pin` back off `feature.adversarial-review`,
76
+ * `bug.verify` and `migration.verify` — the entire content of core's gate-floor change, undone by a
77
+ * file write, with no error and a workflow still reporting the right id and phases (FINDING-049).
78
+ *
79
+ * The write exists for the ids core does NOT seed (chat, survey-repo, domain-graph-slice,
80
+ * memories, domain-extraction): for those the overlay is the only reason they resolve at all, so
81
+ * it stays.
82
+ *
83
+ * The exception: `onboarding` is core-seeded AND still written, by the onboarding path rather than
84
+ * by the generic one. Deliberate — that def's executor cmds are baked with runtime `--db` paths, so
85
+ * it shadows core's copy with a real customization rather than a stale transcription. It is the one
86
+ * shadow that earns its keep, and the reason this set gates the generic write specifically.
87
+ */
88
+ const CORE_SEEDED_WORKFLOWS = new Set(['feature', 'bug', 'migration', 'onboarding', 'collab']);
89
+ /**
90
+ * The content-address of core's built-in evidence floor (`builtin_floors::EVIDENCE_FLOOR_PIN`),
91
+ * carried on the Evaluator phase of feature/bug/migration.
92
+ *
93
+ * Duplicating a hash is a real cost, paid because the alternative is worse. What `listWorkflows()`
94
+ * serves IS what `GET /api/v1/workflows` and the work-mode selector show, and a `null` here reads
95
+ * as "this phase is ungated" — the opposite of the truth for the three phases core gates. Reporting
96
+ * a gate that exists is the honest failure direction; the drift guard in
97
+ * `tests/armed-workflow-served.test.ts` fails loudly on a developer machine the moment core's value
98
+ * moves. This is display only: as of FINDING-049 these defs are never written to core's overlay dir
99
+ * (see CORE_SEEDED_WORKFLOWS), so a stale value here cannot reach the engine.
100
+ */
101
+ const EVIDENCE_FLOOR_PIN = '2fcde907d57f3ee2';
102
+ export const BUILTIN_WORKFLOWS = [
68
103
  {
69
104
  id: 'chat',
70
105
  is_system: true,
@@ -76,9 +111,19 @@ const BUILTIN_WORKFLOWS = [
76
111
  id: 'onboarding',
77
112
  is_system: true,
78
113
  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 },
114
+ { 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 },
115
+ { 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 },
116
+ // index → annotate, and NOT a third `domain` phase running `wicked-core domain-graph`. That
117
+ // phase could never pass: domain-graph fails closed below 1.0 front-half coverage, and nothing
118
+ // in this workflow annotates a single symbol, so coverage was 0.0 on every repo — every
119
+ // registration ended sessionFailed after the two phases that matter had both succeeded
120
+ // (FINDING-068). domain-graph belongs to `domain-extraction`, downstream of the agentic
121
+ // extract+coverage phases that produce its precondition. Mirrors core's `onboarding_def()`.
122
+ //
123
+ // The `{repo_root}` / `{code_graph_db}` placeholders are core's, substituted per run from the
124
+ // launch's `repoRef` (wicked-core#179). This package used to bake absolute paths in here and
125
+ // write the result to one shared overlay file per launch — which concurrent registrations
126
+ // raced, indexing one repo's tree under another repo's name (FINDING-075, #196).
82
127
  ],
83
128
  },
84
129
  {
@@ -87,7 +132,7 @@ const BUILTIN_WORKFLOWS = [
87
132
  { 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
133
  { 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
134
  { 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 },
135
+ { 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
136
  { 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
137
  { 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
138
  ],
@@ -98,7 +143,7 @@ const BUILTIN_WORKFLOWS = [
98
143
  { 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
144
  { 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
145
  { 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 },
146
+ { 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
147
  ],
103
148
  },
104
149
  {
@@ -107,18 +152,10 @@ const BUILTIN_WORKFLOWS = [
107
152
  { 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
153
  { 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
154
  { 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 },
155
+ { 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
156
  { 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
157
  ],
113
158
  },
114
- {
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
159
  {
123
160
  id: 'survey-repo',
124
161
  is_system: true,
@@ -155,7 +192,123 @@ const BUILTIN_WORKFLOWS = [
155
192
  { 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
193
  ],
157
194
  },
195
+ // The one workflow that ARMS the dual-validator gate: `coverage` carries an approved
196
+ // `validator_pin`, so layer 1 is live here and inert in every entry above. Transcribed
197
+ // field-for-field from the source of truth, `wicked-core/workflows/domain-extraction.json`
198
+ // (core ships it as a *drop-in*, not a seeded built-in, and exposes no dump command — hence a
199
+ // hand-transcribed mirror, like every other entry in this array).
200
+ //
201
+ // The pin is a content hash over the validator's criterion + script + approved flag. Core
202
+ // re-derives it in `domain_extraction.rs` and fails its own test if it drifts; if that test ever
203
+ // forces core's constant to change, THIS literal must change with it or crew will write an
204
+ // overlay that fails closed at plan time.
205
+ //
206
+ // Running it needs a one-time, idempotent `wicked-core seed-domain-validators` to vault + approve
207
+ // that validator. That step is deliberately manual — approval is an audited act a human/council
208
+ // owns, not something a daemon does unattended — and until it is run, a launch fails CLOSED at
209
+ // plan time rather than running the phase ungated. Not `is_system`: this is an operator-selectable
210
+ // work mode, unlike the dedicated-entry-point workflows above.
211
+ {
212
+ id: 'domain-extraction',
213
+ phases: [
214
+ { id: 'survey', kind: 'recon', gate_type: null, gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: ['legacy-graph.digest.txt'], depends_on: [], role: 'neutral', skill_ref: 'wicked-garden-domain', allowed_skills: [], validator_pin: null },
215
+ { id: 'analyze', kind: 'recon', gate_type: null, gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: ['analysis-report.json'], depends_on: ['survey'], role: 'neutral', skill_ref: 'wicked-garden-domain', allowed_skills: [], validator_pin: null },
216
+ { id: 'extract', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: ['annotations.jsonl'], depends_on: ['analyze'], role: 'creator', skill_ref: 'wicked-garden-domain-extractor', allowed_skills: [], validator_pin: null },
217
+ { 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: 'adaf3e9b6d088f1a' },
218
+ { id: 'domain-graph', kind: 'build', gate_type: 'strategy', gate: { human_confirm: { unconditional: false } }, executes_code: false, verified_evidence: false, required_deliverables: ['requirements_graph.json'], depends_on: ['coverage'], role: 'neutral', skill_ref: 'wicked-garden-domain-modeler', allowed_skills: [], validator_pin: null },
219
+ ],
220
+ },
158
221
  ];
222
+ /**
223
+ * Chat is not available in this deployment at all — a capability gap, never a bad request.
224
+ *
225
+ * It arrives two ways and both mean the same thing to an operator: the addon predates the binding
226
+ * (no method to call), or the engine was spawned without the ACP runner and says so when called.
227
+ * Only the first is knowable before the call, which is why this is a thrown type rather than a
228
+ * capability flag.
229
+ *
230
+ * Typed rather than left to the caller to sniff out of the message text: the route used to regex
231
+ * the message for one of the two phrasings, so the other fell through to `400` and told an operator
232
+ * to fix a request that was already correct.
233
+ */
234
+ export class ChatUnsupportedError extends Error {
235
+ constructor(message) {
236
+ super(message);
237
+ this.name = 'ChatUnsupportedError';
238
+ }
239
+ }
240
+ /**
241
+ * `resolveElicitation` is not available in this deployment — the NAPI binding has not
242
+ * landed in the installed `wicked-core-ts` yet (DES-002 §4 P-1 stub).
243
+ *
244
+ * Routes map this to HTTP 501 so an operator knows to upgrade rather than to fix a
245
+ * call that was already correct.
246
+ */
247
+ export class ElicitationUnsupportedError extends Error {
248
+ constructor(message) {
249
+ super(message);
250
+ this.name = 'ElicitationUnsupportedError';
251
+ }
252
+ }
253
+ /** The engine's own way of reporting a build that cannot do chat, raised at call time. */
254
+ const ENGINE_CHAT_UNSUPPORTED = /chat unsupported/i;
255
+ /**
256
+ * Quarantine a pre-#197 `onboarding.json` left in the overlay dir.
257
+ *
258
+ * This package used to write that file on every launch, baked with ONE repo's absolute paths. It no
259
+ * longer does — core declares `{repo_root}` / `{code_graph_db}` and binds them per run
260
+ * (wicked-core#179). But the overlay dir is PERSISTENT STATE, and the engine's `load_dir` registers
261
+ * whatever it finds there, replacing a compiled def by id, wholesale.
262
+ *
263
+ * So an upgraded deployment keeps running the last file the old code wrote. Not intermittently:
264
+ * EVERY onboarding run indexes whichever repo happened to be registered last before the upgrade.
265
+ * Observed exactly that on this host after #197 merged — three fresh registrations in three
266
+ * different orgs all indexed `agentic-products/eliza`, the last repo seeded before the fix.
267
+ *
268
+ * Renamed rather than deleted. The file is almost certainly machine-written, but the overlay dir is
269
+ * an operator-facing extension point and silently destroying something out of it is not this
270
+ * process's call. The rename is enough to stop the shadow, and leaves the evidence in place.
271
+ */
272
+ function quarantineStaleOnboardingOverlay() {
273
+ const stale = join(workflowOverlayDir(), 'onboarding.json');
274
+ if (!existsSync(stale))
275
+ return; // the ordinary case on a clean install
276
+ // Only a PRE-#197 artifact, never an operator's override. `registerWorkflow()` writes user
277
+ // definitions into this same directory, and parking one on every boot would delete a deliberate
278
+ // customization each time it was re-registered.
279
+ //
280
+ // The signature is specific: old crew baked one repo's ABSOLUTE paths into the tool commands. A
281
+ // def carrying `{repo_root}` / `{code_graph_db}`, or agent phases, or relative commands, is not
282
+ // what this is looking for and is left alone. An operator who hand-writes absolute paths into a
283
+ // shared def has written the same bug, and gets the same treatment for the same reason.
284
+ let bakedPaths;
285
+ try {
286
+ const def = JSON.parse(readFileSync(stale, 'utf8'));
287
+ bakedPaths = (def.phases ?? [])
288
+ .flatMap((p) => (p.executor?.type === 'tool' ? (p.executor.cmd ?? []) : []))
289
+ .filter((arg) => arg.startsWith('/'));
290
+ }
291
+ catch {
292
+ // Unparseable: not ours to judge. The engine reports its own load failure.
293
+ return;
294
+ }
295
+ if (bakedPaths.length === 0)
296
+ return;
297
+ const parked = `${stale}.superseded-by-crew197`;
298
+ try {
299
+ renameSync(stale, parked);
300
+ console.warn(`[onboarding] removed a stale overlay that would have hijacked every onboarding run: ` +
301
+ `${stale} → ${parked}. It baked ${bakedPaths[0]} into a def shared by every repo, which is ` +
302
+ `what a pre-#197 crew wrote; the engine resolves it in preference to the built-in ` +
303
+ `(FINDING-075).`);
304
+ }
305
+ catch (err) {
306
+ // Loud, and non-fatal: the daemon still starts, but every onboarding on this host is wrong
307
+ // until the file goes, so the operator has to be told rather than left to discover it.
308
+ 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, ` +
309
+ `whatever repo the run names (FINDING-075).`);
310
+ }
311
+ }
159
312
  /**
160
313
  * The single isolation boundary over wicked-core-ts. It holds the ONE `Core`
161
314
  * handle, makes the ONE `subscribe()` call for the whole process, parses each
@@ -181,6 +334,9 @@ export class CoreAdapter {
181
334
  // it publishes `task.dispatched` / consumes `task.completed` around WHATEVER step runner the engine
182
335
  // wires (the production wrapped-CLI runner OR the deterministic stub), so a stub engine can arm it
183
336
  // for a fast, offline, deterministic proof of the event path.
337
+ // BEFORE the Core spawns: the actor reads the overlay dir at startup, so a stale
338
+ // `onboarding.json` has to be out of the way by then or it shadows the built-in def.
339
+ quarantineStaleOnboardingOverlay();
184
340
  const armExec = opts.engineExec === true;
185
341
  if (armExec) {
186
342
  if (!opts.busDbPath || opts.busDbPath.length === 0) {
@@ -254,11 +410,17 @@ export class CoreAdapter {
254
410
  opts.repoRef = input.repoRef;
255
411
  if (input.workflow !== undefined) {
256
412
  opts.workflow = input.workflow;
257
- // Ensure built-in workflow definitions are present in the Rust overlay dir on first use.
413
+ // Ensure DROP-IN workflow definitions are present in the Rust overlay dir on first use.
258
414
  // Uses a dedicated helper (not registerWorkflow) to avoid adding built-ins to userWorkflows,
259
415
  // which would duplicate them in listWorkflows(). The write is skipped after the first call
260
416
  // per process lifetime.
261
- const builtinDef = BUILTIN_WORKFLOWS.find((w) => w.id === input.workflow);
417
+ //
418
+ // Ids core seeds itself are excluded: writing them shadows the real def with this stale
419
+ // mirror — see CORE_SEEDED_WORKFLOWS. Core resolves those from its own registry, so there is
420
+ // nothing to write and never was.
421
+ const builtinDef = CORE_SEEDED_WORKFLOWS.has(input.workflow)
422
+ ? undefined
423
+ : BUILTIN_WORKFLOWS.find((w) => w.id === input.workflow);
262
424
  if (builtinDef && !this._builtinOverlayWritten.has(input.workflow)) {
263
425
  // Mark before await so concurrent launchRun() calls for the same builtin
264
426
  // don't both pass the has() check and race to write the same file.
@@ -287,6 +449,23 @@ export class CoreAdapter {
287
449
  }
288
450
  return this.core.injectWorkerMessage(runId, message, target);
289
451
  }
452
+ /**
453
+ * A run's recorded event history, oldest first — or `null` when this wicked-core build has no
454
+ * event-log read binding.
455
+ *
456
+ * `null` rather than `[]` on purpose. An empty history is a real, ordinary answer (a run that
457
+ * emitted nothing, or one predating the log), and collapsing "nothing happened" into "I cannot
458
+ * tell you what happened" is how a missing capability gets reported to an operator as an absent
459
+ * gate — the FINDING-050 shape, distinct causes wearing one message. Callers branch on it.
460
+ */
461
+ async runEvents(runId) {
462
+ if (typeof this.core.runEvents !== 'function')
463
+ return null;
464
+ // `RecordedEvent`, not `CoreEvent`: the binding's contract is the `/ws` frame PLUS a capture-time
465
+ // `ts` and an ordering `seq`, and consumers (the evidence bundle) need both. Typing this as the
466
+ // bare frame made every caller widen or cast to get at fields the engine always sends.
467
+ return JSON.parse(await this.core.runEvents(runId));
468
+ }
290
469
  /** Run ids on the store. */
291
470
  async sessions() {
292
471
  return JSON.parse(await this.core.sessions());
@@ -327,14 +506,84 @@ export class CoreAdapter {
327
506
  async workOutput(unitId) {
328
507
  return JSON.parse(await this.core.workOutput(unitId));
329
508
  }
509
+ // ── Chat sessions (core#134 / crew#165) ────────────────────────────────────
510
+ async chatOpen(chatId, clis, cwd) {
511
+ const raw = await this.core.chatOpen(chatId, JSON.stringify(clis), cwd ?? null);
512
+ return JSON.parse(raw);
513
+ }
514
+ async chatSend(chatId, text, targets, cwd) {
515
+ const raw = await this.core.chatSend(chatId, text, targets === undefined ? null : JSON.stringify(targets), cwd ?? null);
516
+ return JSON.parse(raw);
517
+ }
518
+ async chatSeats(chatId) {
519
+ return JSON.parse(await this.core.chatSeats(chatId));
520
+ }
521
+ /**
522
+ * Every live chat, so an operator can find the ones nothing is going to close (FINDING-027).
523
+ *
524
+ * Chat sessions are a warm pool that deliberately outlives the page, and the only client that
525
+ * knew a chat's id is the tab that minted it. Without this an orphaned seat is unreclaimable
526
+ * short of restarting the daemon — the leak is real but invisible, which is the worse half.
527
+ *
528
+ * `idleSecs` is `number | null`, not `number`. The Rust side uses `u64::MAX` for "no activity
529
+ * timestamp"; as an f64 that arrives as 18446744073709552000, which no caller can test for by
530
+ * equality and every caller can accidentally do arithmetic on. The binding maps it to `null`.
531
+ */
532
+ async chatList() {
533
+ const list = this.core.chatList;
534
+ if (typeof list !== 'function') {
535
+ throw new ChatUnsupportedError('Listing chats is not yet supported by this wicked-core build');
536
+ }
537
+ try {
538
+ return JSON.parse(await list.call(this.core));
539
+ }
540
+ catch (err) {
541
+ // A build without the ACP runner has the binding and refuses at call time, so the presence
542
+ // check above cannot catch it. Classified here rather than at the route because this file is
543
+ // the only one that touches the addon (DES-STUDIO-001 §5.2) — matching engine wording anywhere
544
+ // else would spread that coupling.
545
+ const text = err instanceof Error ? err.message : String(err);
546
+ if (ENGINE_CHAT_UNSUPPORTED.test(text))
547
+ throw new ChatUnsupportedError(text);
548
+ throw err;
549
+ }
550
+ }
551
+ async chatClose(chatId) {
552
+ await this.core.chatClose(chatId);
553
+ }
554
+ /**
555
+ * Forward the operator's elicitation response to the actor (DES-002 §4 P-1).
556
+ *
557
+ * NAPI flat signature: `resolve_elicitation(run_id, elicitation_id, action, response)`.
558
+ * `response` is `null` for `decline` and `cancel` actions; a non-empty string for `accept`.
559
+ *
560
+ * Throws `ElicitationUnsupportedError` until the NAPI binding is present in the installed
561
+ * `wicked-core-ts`. Routes map that to HTTP 501.
562
+ */
563
+ async resolveElicitation(_runId, _elicitationId, _action, _response) {
564
+ // Consume stub params to satisfy @typescript-eslint/no-unused-vars; the
565
+ // parameter names are part of the public interface and must not be dropped.
566
+ void _runId;
567
+ void _elicitationId;
568
+ void _action;
569
+ void _response;
570
+ // The NAPI binding (`this.core.resolveElicitation`) will land with the actor-side
571
+ // work in a follow-on. Until then, every call throws so the route surfaces 501 and
572
+ // an operator knows to upgrade rather than to keep retrying.
573
+ throw new ElicitationUnsupportedError('resolveElicitation is not yet bound in this wicked-core build; upgrade wicked-core-ts to enable it');
574
+ }
330
575
  /** repo id → onboarding run id (in-memory; graph persists on disk across restarts). */
331
576
  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();
577
+ /**
578
+ * repo id → the in-flight launch, so a concurrent caller joins it instead of starting a second.
579
+ *
580
+ * A `Set` of ids was not enough. The id was added here but the run id was only recorded in
581
+ * `repoOnboardRunIds` AFTER the launch resolved, so a second caller arriving mid-flight saw
582
+ * "in flight" with no run id to return, fell through, and launched a DUPLICATE run against the
583
+ * same repo. Holding the promise makes the second caller await the first and receive its run id —
584
+ * the dedup the `Set` was named for.
585
+ */
586
+ onboardingInFlight = new Map();
338
587
  /** Register a local git repo → the persisted `RepoEntry`. */
339
588
  async registerRepo(name, rootPath) {
340
589
  return JSON.parse(await this.core.registerRepo(name, rootPath));
@@ -393,7 +642,7 @@ export class CoreAdapter {
393
642
  catch { /* not yet cloned */ }
394
643
  if (needsClone) {
395
644
  try {
396
- await execFileAsync('git', ['clone', '--', gitUrl, cloneDir], {
645
+ await execCapped('git', ['clone', '--', gitUrl, cloneDir], {
397
646
  timeout: 5 * 60 * 1000,
398
647
  });
399
648
  }
@@ -421,63 +670,52 @@ export class CoreAdapter {
421
670
  * Returns the run id so the UI can navigate directly to it.
422
671
  */
423
672
  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);
673
+ // Join an in-flight launch for THIS repo rather than starting a second one. Concurrency across
674
+ // DIFFERENT repos is the point and is untouched; two launches for the SAME repo are a duplicate.
675
+ const inFlight = this.onboardingInFlight.get(repoId);
676
+ if (inFlight)
677
+ return inFlight;
430
678
  const runId = randomUUID();
431
- const chained = this._onboardingChain.then(() => this._doOnboardingLaunch(repoId, repoName, runId));
432
- this._onboardingChain = chained.catch(() => undefined);
679
+ // Launches are NOT serialized. They used to be, through an `_onboardingChain` promise, because
680
+ // each rewrote the shared `onboarding` overlay before launching. That chain never worked: its
681
+ // own comment claimed "after launch the def is baked into the run's units", and the def is
682
+ // actually resolved at DISPATCH — after the launch call returns. So it serialized the writer and
683
+ // left the reader racing, which is how three concurrent registrations indexed one repo under
684
+ // three names (FINDING-075, #196).
685
+ //
686
+ // Nothing is shared now: core binds each run's repo into its own units from `repoRef`
687
+ // (wicked-core#179). Concurrent registration is the point — it is a requirement of the corpus
688
+ // this platform is tested against, not an optimisation.
689
+ const launch = this._doOnboardingLaunch(repoId, repoName, runId).then(() => runId);
690
+ this.onboardingInFlight.set(repoId, launch);
433
691
  try {
434
- await chained;
435
- return runId;
692
+ return await launch;
436
693
  }
437
694
  finally {
438
695
  this.onboardingInFlight.delete(repoId);
439
696
  }
440
697
  }
441
698
  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
- }
699
+ // No overlay write. This used to rewrite core's `onboarding` def with THIS repo's absolute paths
700
+ // and persist it to one shared file (`~/.config/wicked-core/workflows/onboarding.json`), then
701
+ // hot-register it — the one place a core-seeded id was deliberately shadowed.
702
+ //
703
+ // That shadow was the defect. The engine resolves a workflow at DISPATCH time, after this call
704
+ // returns, so concurrent launches raced on the single file and the last writer won: two repos in
705
+ // two different orgs had a third org's tree indexed into a third org's database, each reported
706
+ // under its own name (FINDING-075, #196). Serializing the writes does not fix it — the chain
707
+ // serializes the producer and leaves the consumer racing.
708
+ //
709
+ // Core now declares `{repo_root}` / `{code_graph_db}` on the phases and binds them per run from
710
+ // `repoRef`, which this call already passes (wicked-core#179). Nothing is shared, so nothing can
711
+ // be raced, and onboarding launches may run concurrently.
712
+ await this.launchRun({
713
+ problem: `Onboard repository: ${repoName}`,
714
+ sessionId: runId,
715
+ clisJson: JSON.stringify(CoreAdapter.roster()),
716
+ workflow: 'onboarding',
717
+ repoRef: repoId,
718
+ });
481
719
  this.repoOnboardRunIds.set(repoId, runId);
482
720
  }
483
721
  /** Return the onboarding run id for a repo (undefined if not launched this session). */
@@ -514,6 +752,28 @@ export class CoreAdapter {
514
752
  async upsertConformanceRule(rule) {
515
753
  await this.core.upsertConformanceRule(JSON.stringify(rule));
516
754
  }
755
+ /**
756
+ * Withdraw a policy from enforcement. Resolves `true` if a policy with that id existed.
757
+ *
758
+ * Retire, not delete (FINDING-038): the node stays readable so a past decision citing this id is
759
+ * still explicable, but governance stops selecting it. The boolean is what lets the route answer
760
+ * 404 instead of reporting a success that removed nothing.
761
+ */
762
+ async retirePolicy(id) {
763
+ const retire = this.core.retirePolicy;
764
+ if (typeof retire !== 'function') {
765
+ throw new Error('Retiring a policy is not yet supported by this wicked-core build');
766
+ }
767
+ return JSON.parse(await retire.call(this.core, id));
768
+ }
769
+ /** Withdraw a conformance rule from recall. Same contract as {@link retirePolicy}. */
770
+ async retireConformanceRule(id) {
771
+ const retire = this.core.retireConformanceRule;
772
+ if (typeof retire !== 'function') {
773
+ throw new Error('Retiring a conformance rule is not yet supported by this wicked-core build');
774
+ }
775
+ return JSON.parse(await retire.call(this.core, id));
776
+ }
517
777
  /** Recall conformance rules matching a facet query (read-only, does not block actor). */
518
778
  async recallRulesPreview(query) {
519
779
  const cleanQuery = {};
@@ -561,11 +821,29 @@ export class CoreAdapter {
561
821
  await mkdir(dir, { recursive: true });
562
822
  const overlayDef = { ...def };
563
823
  delete overlayDef.is_system;
564
- await writeFile(join(dir, `${def.id}.json`), JSON.stringify(overlayDef, null, 2), 'utf8');
824
+ const json = JSON.stringify(overlayDef);
565
825
  const core = this.core;
566
- if (typeof core['registerWorkflow'] === 'function') {
567
- await core['registerWorkflow'](JSON.stringify(overlayDef));
826
+ const register = core['registerWorkflow'];
827
+ // Same validate-before-persist ordering as registerWorkflow (FINDING-002). This path had the
828
+ // identical defect — write first, validate last — which is the P3 shape this campaign keeps
829
+ // finding: N paths, one hardened. A mirror that drifted far enough for core to reject it would
830
+ // otherwise leave an unparseable *.json in the dispatch overlay dir, and core would skip it at
831
+ // the next load. Letting the rejection propagate instead fails the launch with core's own
832
+ // reason, which beats dispatching against a workflow core will silently drop.
833
+ if (typeof register === 'function') {
834
+ await register.call(this.core, json);
568
835
  }
836
+ // Deliberately NOT the refusal registerWorkflow makes when the binding is absent, and the
837
+ // difference is the input, not the caller:
838
+ // - a user def is arbitrary runtime input no test has ever seen, so unvalidatable means
839
+ // unsafe to persist;
840
+ // - a built-in mirror is asserted field-for-field against wicked-core's own
841
+ // workflows/<id>.json by tests/builtin-overlay-shadow.test.ts, so its parseability is
842
+ // established at build time rather than needing a runtime check.
843
+ // Refusing here would also break DELIVERY: this write is the only way core resolves a drop-in
844
+ // id, so a refusal turns a silent ungating into a hard "unknown workflow" — exactly the
845
+ // regression FINDING-084's first attempted fix caused.
846
+ await writeFile(join(dir, `${def.id}.json`), JSON.stringify(overlayDef, null, 2), 'utf8');
569
847
  }
570
848
  /**
571
849
  * Register a user-authored workflow: persist to the Rust workflow overlay dir
@@ -585,14 +863,36 @@ export class CoreAdapter {
585
863
  // field and silently drops any workflow whose JSON it cannot fully deserialise.
586
864
  const overlayDef = { ...def };
587
865
  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.
866
+ const json = JSON.stringify(overlayDef);
867
+ // VALIDATE BEFORE PERSISTING (FINDING-002). This ordering is the whole fix.
868
+ //
869
+ // The write used to come first and `registerWorkflow` last, so core's parser — the only thing
870
+ // that actually knows the overlay schema — ran AFTER the state was already mutated. Observed
871
+ // end to end: POST /api/v1/workflows answered
872
+ // 400 invalid workflow JSON: unknown field `name`, expected `id` or `phases`
873
+ // and the file was on disk anyway, `name` included, and served from `userWorkflows` as though
874
+ // registered. On the next daemon start core could not deserialise its own overlay file:
875
+ // wicked-core: skipping workflow file .../probe-002-persist.json
876
+ // and the workflow VANISHED while its file remained. That is FINDING-002's root cause: not
877
+ // "registration is not durable" but "a rejected request persisted a def core cannot read".
878
+ //
879
+ // Core's parser is the authority, so it is what we ask. Enumerating the accepted fields in TS
880
+ // instead would be a second copy of core's schema — the exact drift this codebase keeps paying
881
+ // for, and `is_system` above is already one hand-maintained instance of it.
592
882
  const core = this.core;
593
- if (typeof core['registerWorkflow'] === 'function') {
594
- await core['registerWorkflow'](JSON.stringify(overlayDef));
883
+ const register = core['registerWorkflow'];
884
+ if (typeof register !== 'function') {
885
+ // No validator, so no safe way to persist: an unvalidated def written here is a file core
886
+ // may silently skip at load. Refusing is the honest outcome — and it is loud, unlike the
887
+ // vanishing act it replaces. `registerWorkflow` has been declared (non-optional) in
888
+ // wicked-core-ts since 0.4.0, so this is a real floor, not a routine path.
889
+ throw new Error('this wicked-core build exposes no registerWorkflow binding, so a workflow cannot be ' +
890
+ 'validated before it is written; refusing to persist an unvalidated definition');
595
891
  }
892
+ // Throws on a def core rejects — before anything is written or registered.
893
+ await register.call(this.core, json);
894
+ await writeFile(path, JSON.stringify(overlayDef, null, 2), 'utf8');
895
+ this.userWorkflows.set(def.id, def);
596
896
  return def.id;
597
897
  }
598
898
  /**