wicked-crew 0.9.3 → 0.9.5

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 (90) hide show
  1. package/dist/api/delivery-assurance.d.ts.map +1 -1
  2. package/dist/api/delivery-assurance.js +11 -3
  3. package/dist/api/delivery-assurance.js.map +1 -1
  4. package/dist/api/discovery-source.d.ts +4 -4
  5. package/dist/api/discovery-source.d.ts.map +1 -1
  6. package/dist/api/discovery-source.js +14 -8
  7. package/dist/api/discovery-source.js.map +1 -1
  8. package/dist/api/eval-sample.d.ts +12 -12
  9. package/dist/api/governance-steering.d.ts +2 -2
  10. package/dist/api/governance-steering.js +2 -2
  11. package/dist/api/governance-steering.js.map +1 -1
  12. package/dist/api/mcp.d.ts +4 -4
  13. package/dist/api/onboarding-capture.d.ts +1 -1
  14. package/dist/api/onboarding-capture.d.ts.map +1 -1
  15. package/dist/api/onboarding-capture.js +1 -1
  16. package/dist/api/onboarding-capture.js.map +1 -1
  17. package/dist/api/routes.d.ts +133 -131
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +25 -34
  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 +8 -1
  23. package/dist/api/server.js.map +1 -1
  24. package/dist/api/testing.js +1 -1
  25. package/dist/api/testing.js.map +1 -1
  26. package/dist/campaigns/plan.d.ts +8 -1
  27. package/dist/campaigns/plan.d.ts.map +1 -1
  28. package/dist/campaigns/plan.js +15 -3
  29. package/dist/campaigns/plan.js.map +1 -1
  30. package/dist/campaigns/routes.d.ts +8 -8
  31. package/dist/core/adapter.d.ts +44 -89
  32. package/dist/core/adapter.d.ts.map +1 -1
  33. package/dist/core/adapter.js +114 -610
  34. package/dist/core/adapter.js.map +1 -1
  35. package/dist/core/builtin-catalog.d.ts +24 -0
  36. package/dist/core/builtin-catalog.d.ts.map +1 -0
  37. package/dist/core/builtin-catalog.js +54 -0
  38. package/dist/core/builtin-catalog.js.map +1 -0
  39. package/dist/core/deliver-text.d.ts +1 -1
  40. package/dist/core/deliver-text.d.ts.map +1 -1
  41. package/dist/core/deliver-text.js +3 -2
  42. package/dist/core/deliver-text.js.map +1 -1
  43. package/dist/core/deliver.d.ts +0 -6
  44. package/dist/core/deliver.d.ts.map +1 -1
  45. package/dist/core/deliver.js +0 -6
  46. package/dist/core/deliver.js.map +1 -1
  47. package/dist/core/run-identity.d.ts +1 -1
  48. package/dist/core/run-identity.d.ts.map +1 -1
  49. package/dist/core/run-identity.js +7 -3
  50. package/dist/core/run-identity.js.map +1 -1
  51. package/dist/editors/csp.js +2 -2
  52. package/dist/editors/csp.js.map +1 -1
  53. package/dist/editors/pack-install.d.ts +35 -14
  54. package/dist/editors/pack-install.d.ts.map +1 -1
  55. package/dist/editors/pack-install.js +171 -24
  56. package/dist/editors/pack-install.js.map +1 -1
  57. package/dist/editors/registry.d.ts +151 -13
  58. package/dist/editors/registry.d.ts.map +1 -1
  59. package/dist/editors/registry.js +257 -20
  60. package/dist/editors/registry.js.map +1 -1
  61. package/dist/editors/routes.d.ts +10 -5
  62. package/dist/editors/routes.d.ts.map +1 -1
  63. package/dist/editors/routes.js +30 -20
  64. package/dist/editors/routes.js.map +1 -1
  65. package/dist/projects/routes.d.ts +2 -2
  66. package/dist/qe/acceptance.d.ts +5 -0
  67. package/dist/qe/acceptance.d.ts.map +1 -1
  68. package/dist/qe/acceptance.js +4 -2
  69. package/dist/qe/acceptance.js.map +1 -1
  70. package/dist/qe/author-workflow.d.ts +6 -83
  71. package/dist/qe/author-workflow.d.ts.map +1 -1
  72. package/dist/qe/author-workflow.js +6 -300
  73. package/dist/qe/author-workflow.js.map +1 -1
  74. package/dist/qe/ledger.d.ts +20 -0
  75. package/dist/qe/ledger.d.ts.map +1 -1
  76. package/dist/qe/ledger.js +28 -2
  77. package/dist/qe/ledger.js.map +1 -1
  78. package/dist/skills/core-closure.d.ts +3 -5
  79. package/dist/skills/core-closure.d.ts.map +1 -1
  80. package/dist/skills/core-closure.js +3 -5
  81. package/dist/skills/core-closure.js.map +1 -1
  82. package/dist/standing-orders/routes.d.ts +4 -4
  83. package/dist/standing-orders/store.d.ts +2 -2
  84. package/dist/studio/assets/{index-BMRUMU4X.js → index-U8MkVhYB.js} +111 -111
  85. package/dist/studio/editors/wicked-page/editor.json +1 -1
  86. package/dist/studio/editors/wicked-page/index.html +1 -1
  87. package/dist/studio/index.html +1 -1
  88. package/dist/studio/testid-inventory.json +75 -3
  89. package/endpoint-manifest.json +5 -2
  90. package/package.json +4 -4
@@ -1,7 +1,8 @@
1
+ import { builtinDefs as deriveBuiltinDefs } from './builtin-catalog.js';
1
2
  import { createRequire } from 'node:module';
2
3
  import { attachEngineBus, detachEngineBus } from './bus.js';
3
4
  import { mkdir, access, readFile, writeFile, chmod, rm, link, rename, realpath, stat } from 'node:fs/promises';
4
- import { existsSync, readdirSync, readFileSync, renameSync } from 'node:fs';
5
+ import { existsSync } from 'node:fs';
5
6
  import { join, dirname, resolve, isAbsolute, relative, sep } from 'node:path';
6
7
  import { isPlainRunId, WALKTHROUGH_AUTHOR_SUBDIR, walkthroughRootDir } from './walkthrough-root.js';
7
8
  import { crewStateHome, isDefaultStateHome } from '../projects/state-home.js';
@@ -11,10 +12,8 @@ import { randomUUID } from 'node:crypto';
11
12
  import { DEFAULT_SETTINGS } from './types.js';
12
13
  import { BASE_SKILL_REF_SHAPE } from '../skills/base-skill.js';
13
14
  import { execCapped } from './exec.js';
14
- import { BUG_FIX_SWEEP_INSTRUCTIONS, composeDeliverWorkflow, DELIVER_PHASE_ID, deliverPresetStep, deliverIdentityFor, deliverRepoFor, EVIDENCE_FLOOR_PIN, isGitHubLogin, pushIdentityOf, readDeliverOriginUrl } from './deliver.js';
15
+ import { composeDeliverWorkflow, DELIVER_PHASE_ID, deliverPresetStep, deliverIdentityFor, deliverRepoFor, isGitHubLogin, pushIdentityOf, readDeliverOriginUrl } from './deliver.js';
15
16
  import { engineCampaignDef, engineRosterJson } from './engine-roster.js';
16
- import { QE_AUTHOR_TESTS_WORKFLOW_DEF } from '../qe/author-workflow.js';
17
- import { CAMPAIGN_WORKFLOW_PREFIX } from '../campaigns/plan.js';
18
17
  import { composeDeliverableFloor } from './deliverable-floor.js';
19
18
  import { deliverCredentialsProbe } from './deliver-credentials.js';
20
19
  import { isSyntheticWorkflowId, resolveRunIdentity, verifiedEvidenceCatalog, wireIdentity, withPresetSystemFlag, withSystemFlag, } from './run-identity.js';
@@ -44,20 +43,6 @@ function parseEngineJson(raw, method) {
44
43
  throw new Error(`${method}: engine returned invalid JSON (${e instanceof Error ? e.message : String(e)})`);
45
44
  }
46
45
  }
47
- /**
48
- * Workflow overlay directory — mirrors the Rust `workflow_overlay_dir()` logic in
49
- * `pipeline.rs`. The Rust actor reads drop-in workflow JSONs from this path at startup
50
- * and (with registerWorkflow NAPI) at runtime. TS must write to the same location so
51
- * the files are picked up on the next daemon start.
52
- * • `$WICKED_WORKFLOWS_DIR` — explicit override (matches Rust env check)
53
- * • `~/.config/wicked-core/workflows` — default (matches Rust default)
54
- */
55
- function workflowOverlayDir() {
56
- if (process.env.WICKED_WORKFLOWS_DIR)
57
- return process.env.WICKED_WORKFLOWS_DIR;
58
- const home = process.env.HOME ?? process.env.USERPROFILE ?? '/tmp';
59
- return join(home, '.config', 'wicked-core', 'workflows');
60
- }
61
46
  /** Where the daemon persists system settings. Exported so tests write fixtures to the ONE
62
47
  * spelling of this path (the tests/ tree is audited against spelling core paths itself).
63
48
  *
@@ -167,83 +152,6 @@ async function publishSettingsFile(linkPath, body, replace) {
167
152
  * the number a third time so a change to either side breaks a test rather than a deployment.
168
153
  */
169
154
  const STUDIO_SETTINGS_MAX_BYTES = 512 * 1024;
170
- /** Read user-registered workflow overlays from `dir` (the same dir `registerWorkflow` writes to).
171
- *
172
- * Skips: files whose id matches a built-in (those are `_writeBuiltinOverlay` artifacts written FOR
173
- * the Rust actor, not user workflows — including them would duplicate a built-in in `listWorkflows`),
174
- * non-`.json` files, and any file that does not parse into a `{id, phases[]}` shape (the Rust actor
175
- * skips an unreadable overlay too, so crew must not surface one it can't). A missing dir yields `[]`.
176
- *
177
- * Exported so the FINDING-002 restart-hydration path is unit-testable without spawning a Core. */
178
- export function readOverlayWorkflows(dir, builtinIds) {
179
- let files;
180
- try {
181
- files = readdirSync(dir);
182
- }
183
- catch {
184
- return []; // no overlay dir yet → nothing registered
185
- }
186
- const out = [];
187
- for (const file of files) {
188
- if (!file.endsWith('.json'))
189
- continue;
190
- const id = file.slice(0, -'.json'.length);
191
- if (builtinIds.has(id))
192
- continue; // a built-in overlay, not a user workflow
193
- // A composed campaign-node workflow (TH-9): persisted here so the Rust actor reloads it at
194
- // startup (a fresh-process resumeCampaign re-dispatches pending nodes by workflow id), but
195
- // it is one node's private plumbing, not a user workflow — keep it out of the catalog.
196
- if (id.startsWith(CAMPAIGN_WORKFLOW_PREFIX))
197
- continue;
198
- try {
199
- const def = JSON.parse(readFileSync(join(dir, file), 'utf8'));
200
- if (def && typeof def.id === 'string' && Array.isArray(def.phases))
201
- out.push(def);
202
- }
203
- catch {
204
- // Unparseable overlay — core would skip it at load, so crew skips it too.
205
- }
206
- }
207
- return out;
208
- }
209
- /** Ask CORE's parser which drop-in defs it will actually honour (crew#718).
210
- *
211
- * {@link readOverlayWorkflows} checks a `{id, phases[]}` shape, which is not the engine's contract:
212
- * `WorkflowRegistry::load_dir` runs `refuse_reserved_id` + `WorkflowDef::validate()` and SKIPS the
213
- * file it rejects, loudly, on the daemon's stderr. So the catalog listed defs the engine had thrown
214
- * away at boot and the launch then 400'd `unknown workflow` — the MCP S8 dogfood's F-3, on a
215
- * hand-authored drop-in whose `executes_code` phase pinned no validator ("gate evaluates nothing").
216
- *
217
- * The same doctrine {@link CoreAdapter.registerWorkflow} states for the WRITE path applies here:
218
- * core's parser is the authority, so it is what we ask. Enumerating `validate()`'s rules in
219
- * TypeScript would be a second copy of core's schema — the drift that produced this defect.
220
- * `register` is the engine's `registerWorkflow` binding, which validates before it registers and
221
- * is idempotent on id, so re-offering a def the engine already loaded at boot is a same-content
222
- * overwrite; a def added to the dir after boot becomes launchable rather than being listed and
223
- * refused.
224
- *
225
- * `is_system` is stripped first, exactly as the two write paths do: it is crew's own display flag
226
- * and core's strict def parser rejects the key.
227
- *
228
- * Pure (no adapter state) so it is unit-testable without spawning a Core, like
229
- * {@link readOverlayWorkflows}.
230
- */
231
- export async function judgeOverlayWorkflows(defs, register) {
232
- const accepted = [];
233
- const refused = [];
234
- for (const def of defs) {
235
- const overlayDef = { ...def };
236
- delete overlayDef.is_system;
237
- try {
238
- await register(JSON.stringify(overlayDef));
239
- accepted.push(def);
240
- }
241
- catch (err) {
242
- refused.push({ id: def.id, reason: err instanceof Error ? err.message : String(err) });
243
- }
244
- }
245
- return { accepted, refused };
246
- }
247
155
  // The native addon is a CommonJS cdylib (`index.node`); load it with a CJS
248
156
  // require even though this daemon is ESM. This module is the ONLY place that
249
157
  // touches wicked-core-ts (DES-STUDIO-001 §5.2/§5.3), so the FINALIZING
@@ -432,241 +340,9 @@ function addonAtLeast(maj, min, pat) {
432
340
  return false;
433
341
  }
434
342
  }
435
- // ── Built-in workflow definitions (crew#44) ──────────────────────────────────
436
- // Static mirrors of wicked-core workflow defs: feature, bug, migration and domain-extraction, plus
437
- // crew's own defs. `chat` and `onboarding` are engine built-in PRESETS (DES-TEAMING-002 M3/M4),
438
- // launched by the same name with no def here; `survey-repo`, `memories`, `domain-graph-slice` and
439
- // `collab` are deleted (nothing launched them).
440
- // Swap for `this.core.listWorkflowsJson()` / `this.core.getWorkflowJson(id)` once
441
- // the wicked-core-ts NAPI methods land.
442
- /**
443
- * The ids wicked-core seeds itself, in `WorkflowRegistry::with_defaults()`.
444
- *
445
- * `launchRun`'s generic drop-in overlay write SKIPS every id in this set.
446
- * A file in that dir shadows the compiled built-in
447
- * *wholesale* — `register` overwrites by id and `load_dir` runs after `with_defaults` — so writing
448
- * this hand-transcribed mirror over the real def silently replaces it with a copy missing whatever
449
- * the def has grown since the mirror was transcribed. That is not hypothetical: the mirror predated
450
- * the evidence floors, so the write took `validator_pin` back off `feature.adversarial-review`,
451
- * `bug.verify` and `migration.verify` — the entire content of core's gate-floor change, undone by a
452
- * file write, with no error and a workflow still reporting the right id and phases (FINDING-049).
453
- *
454
- * The write exists for the ids core does NOT seed (domain-extraction and crew's own defs): for
455
- * those the overlay is the only reason they resolve at all, so it stays.
456
- */
457
- const CORE_SEEDED_WORKFLOWS = new Set(['feature', 'bug', 'migration']);
458
- // `EVIDENCE_FLOOR_PIN` (imported from ./deliver.js, defined once) is carried on the Evaluator phase
459
- // of feature/bug/migration AND, since wicked-core F-039, on their code-writing Creator phases
460
- // (`build`/`fix`/`execute`) — so the gate that was supposed to make the change re-derives its diff
461
- // and a distinct seat judges it, instead of approving nothing. What `listWorkflows()` serves IS what
462
- // `GET /api/v1/workflows` and the work-mode selector show, and a `null` here reads as "this phase
463
- // is ungated" — the opposite of the truth. Since wicked-core#414 the engine also REFUSES a mirror
464
- // that lags (a code phase whose gate evaluates nothing is rejected as authored), so these values
465
- // are part of the contract with the engine, not display only: the drift guards in
466
- // `tests/armed-workflow-served.test.ts` and `tests/builtin-overlay-shadow.test.ts` fail loudly
467
- // the moment core's defs move. As of FINDING-049 these defs are never written to core's overlay dir
468
- // (see CORE_SEEDED_WORKFLOWS).
469
- // `is_system` is NOT spelled on these defs: `withSystemFlag` stamps it from `SYSTEM_WORKFLOWS`
470
- // (core/run-identity.ts), the one list keyed by name that also classifies served runs (seam X2).
471
- /** QE-IN-APP-WORKFLOWS (operator ruling 2026-10-10): what a workflow that makes application changes
472
- * requires — the defaults plus `qe_acceptance` — exactly as core's `feature`/`bug`/`migration`/
473
- * `mcp-server` defs declare it (the mirror guards compare field for field). */
474
- const APP_CHANGE_INSTRUMENTS = ['distinct_evaluator', 'judge', 'qe_acceptance'];
475
- export const BUILTIN_WORKFLOWS = [
476
- {
477
- id: 'feature',
478
- phases: [
479
- { 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 },
480
- { 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 },
481
- { 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: EVIDENCE_FLOOR_PIN },
482
- { 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 },
483
- { 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: EVIDENCE_FLOOR_PIN },
484
- { 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 },
485
- ],
486
- required_instruments: APP_CHANGE_INSTRUMENTS,
487
- },
488
- {
489
- id: 'bug',
490
- phases: [
491
- { 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 },
492
- { 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 },
493
- // DES-L9 (BC-60, core#432): the retired-behaviour sweep — the SAME literal core's `bug_def()` carries (wicked-core #522); pinned by a test
494
- // so the two carriers cannot drift. `builtin-overlay-shadow.test.ts` tolerates exactly this one field while core MAIN has not merged #522
495
- // (crew CI compares this mirror with core main); row 6.9 (the `^0.7.27` pin) removes that tolerance.
496
- { id: 'fix', kind: 'build', instructions: BUG_FIX_SWEEP_INSTRUCTIONS, 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: EVIDENCE_FLOOR_PIN },
497
- { 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 },
498
- ],
499
- required_instruments: APP_CHANGE_INSTRUMENTS,
500
- },
501
- {
502
- id: 'migration',
503
- phases: [
504
- { 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 },
505
- { 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: EVIDENCE_FLOOR_PIN },
506
- { 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 },
507
- { 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 },
508
- { 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 },
509
- ],
510
- required_instruments: APP_CHANGE_INSTRUMENTS,
511
- },
512
- {
513
- // capture-learnings (DES-MEM-FACETED-001 write side, onboarding): survey a just-indexed repo,
514
- // then propose its durable learnings — BOTH faceted MEMORIES and repo POLICIES — as inert estate
515
- // `proposal.submit` proposals (through garden's estate shim) a human later reviews.
516
- //
517
- // ONE workflow, not four. "Go multi-workflow" is realized as multi-PHASE composition inside a
518
- // single governed run, NOT as separate churn-analysis / hotspot-read / derive-memories /
519
- // derive-policies RUNS, because:
520
- // • Context threading: the learning method is a dependent chain (churn ranking → hotspot
521
- // cross-reference → capture). Crew threads each phase's output into the next phase's prompt
522
- // automatically (plan.rs folds prior context); separate runs share NOTHING, so a split would
523
- // sever that thread and each run would re-establish repo understanding from scratch.
524
- // • Council cost: every phase convenes a ~6-seat council (the ecosystem's spikiest operation,
525
- // serialized on purpose). Four runs multiply that; three phases in one run pay it once each.
526
- // • The Memories-vs-Policies review split is a proposal-KIND concern, not a workflow-identity
527
- // one: `kind_type` routes memory→studio Memories and policy:<type>→Steering downstream, so a
528
- // SINGLE `capture` phase emits both from the one shared understanding — splitting derive-
529
- // memories / derive-policies would re-run a council over the same context for no new evidence.
530
- // • Reuse already lives below the run: the reusable unit is the SKILL (and hotspot-read is
531
- // already a reusable capability via `wicked-garden-search`).
532
- //
533
- // The METHOD lives in the garden skill `wicked-garden-repo-learn`, referenced per-phase by
534
- // `skill_ref` — the engine emits only a short `Invoke your skill "wicked-garden:repo-learn"…`
535
- // directive and the worker loads SKILL.md from the installed plugin. The bounded git-churn
536
- // sampling, the estate tool names (reached through the shim), and the proposal payload schemas that used to sit inline as
537
- // ~600-column prose now live in that skill; the inline `instructions` here are a one-line phase
538
- // ORIENTATION only. That matters because a governed worker's prompt rides a single PTY line capped
539
- // at 1022 bytes (>=1023B is SILENTLY discarded — wicked-core execute_wrapped.rs), and the planner
540
- // folds this text onto that line alongside the run intent, so long inline prose here would blow
541
- // the line. Crew-only (NOT core-seeded), so the overlay write is the only def the engine resolves
542
- // — no core mirror, and deliberately NOT in builtin-overlay-shadow's MIRRORED_IDS.
543
- //
544
- // The shim's `wicked-estate-mcp --readonly` opens the operator GLOBAL memory store and permits
545
- // `proposal.submit` (a safe write, provenance server-stamped from the WICKED_RUN_* markers on the
546
- // worker env — DES-L4 PR-③/⑦; there is no CLI-registered estate MCP on the worker any more),
547
- // so proposals land in the same queue the studio Memories/Policies surfaces review. Onboarding IS
548
- // about the repo, so the skill tags learnings `repo:`/`project:`.
549
- id: 'capture-learnings',
550
- phases: [
551
- { id: 'churn', kind: 'recon', instructions: "Phase 1/3 CHURN: produce a ranked list of this repo's most actively-changed files and directories over the last ~12 months, plus the repo's real name (manifest or git remote) and parent project. Use the skill's bounded/sampled git-churn method — never stream the whole history. Do not read code deeply yet; the next phase targets these areas.", gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: 'wicked-garden-repo-learn', allowed_skills: [], validator_pin: null },
552
- { id: 'hotspots', kind: 'recon', instructions: "Phase 2/3 HOTSPOTS: cross-reference the prior churn ranking with wicked-estate hotspot / blast-radius signals to find the load-bearing code, then READ it through the estate shim (`wicked-garden run scripts/_estate_client.py --readonly call …`, the skill's grounding path) to build a real technical understanding of how the system fits together — not a file listing. Reuse wicked-garden-search for the hotspot signals; follow the skill.", gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['churn'], role: 'neutral', skill_ref: 'wicked-garden-repo-learn', allowed_skills: [], validator_pin: null },
553
- // (BC-80, wicked-core#535) `requires_capture_report`: the engine's capture-report floor reads
554
- // the phase's output marker, so a capture run whose skill loaded and never ran can no longer
555
- // report `completed` with 0 proposals — a missing marker, a failed submission and proposals
556
- // derived but never submitted each deny into the human gate. An honest 0 passes. The
557
- // instruction MANDATES the marker; garden's repo-learn skill carries the same contract, and
558
- // the counts land on the unit (`capture_report`), which `GET /runs/:id` serves.
559
- { id: 'capture', kind: 'build', instructions: "Phase 3/3 CAPTURE: from the prior churn + hotspot understanding, submit durable learnings as estate proposals through the shim's `propose` per the skill's capture contract — BOTH memories (facts / how-it-works) and policies (enforced conventions), one proposal per item, tagged repo/project. Each is inert until human review; never include secrets or personal data. END with `wicked-capture-report {\"derived\": N, \"submitted\": M, \"failed\": K}` — always, even on a degrade or a legitimate 0 (which is acceptable).", gate_type: 'value', gate: 'auto', executes_code: false, requires_capture_report: true, verified_evidence: false, required_deliverables: [], depends_on: ['hotspots'], role: 'creator', skill_ref: 'wicked-garden-repo-learn', allowed_skills: [], validator_pin: null },
560
- ],
561
- },
562
- {
563
- // "Add with chat" for the Steering surface (STEERING program) — the dedicated entry point
564
- // behind POST /governance/steering/author. TH-12 propose-as-gate: the run analyzes the
565
- // operator's intent + the named files/dirs, then the TERMINAL `propose` phase emits the
566
- // PROPOSED steering rules and its unconditional human gate pauses the run `awaiting_human`
567
- // (core evaluates a terminal phase's own gate before finalize — seam finding #4), so the
568
- // operator approves/amends/rejects via the standard POST /runs/:id/gate. Approved rules land
569
- // CREW-SIDE on that approve: the gate handler writes them through the governed rules seam
570
- // with `provenance.source: "chat"` (api/steering-landing.ts, crew#388) — the run itself must
571
- // never write the store, which is why both phases say so out loud. The propose phase hands
572
- // the rules back in its REPLY — one ```json fenced array — and the landing reads them from the
573
- // unit's stored output (#789). It used to be told to write the array to a file in the per-run
574
- // inbox under the home config; a seat's sandbox refuses a write outside its workspace (codex's
575
- // "approval request failed"), so every author run reported a failed write and the rules only
576
- // landed through the transcript anyway. The reply is now the designed path, not a fallback.
577
- //
578
- // Crew-authored drop-in: NOT in CORE_SEEDED_WORKFLOWS, so launchRun's
579
- // `_writeBuiltinOverlay` write is the only way core resolves the id — the same delivery
580
- // mechanism every crew drop-in uses.
581
- id: 'steering-author',
582
- phases: [
583
- { id: 'analyze', kind: 'recon', instructions: 'Read the operator intent and every file or directory listed in the problem statement. Identify candidate steering rules: durable, prescriptive statements a coding agent must follow, each classified into one steering type (architecture, development, security, testing, operations, compliance, design-ux). For each candidate note the statement, steering type, severity, and the evidence in the source material. Analysis only — do not write any rule to any store, and do not emit final rule JSON yet.', 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 },
584
- { id: 'propose', kind: 'recon', instructions: 'From the prior analysis, emit the PROPOSED steering rules as one JSON array. Each entry is a conformance-rule object: id (PAT-<digits> for rule_type "pattern", POL-<digits> for "policy"), rule_type, statement, severity (info|warn|error|critical), confidence (a NUMBER 0..1), steering_type (default to the type named in the problem statement), provenance {"source":"chat"}, and — only where the source material supports them — the enforcement fields applies_to (array of phase tokens or globs), excludes, weight, obligations (array of strings), criteria (ONE string, never a list). Omit targets, effect and trigger unless you can express them in the store schema exactly: targets is a {language, layer, framework} facet OBJECT (never a file list — files belong in applies_to), and trigger is a structured condition object (never prose). Put that JSON array in your reply, ONCE, as a single ```json fenced block (the whole array, valid JSON): your reply IS the proposal — crew reads the array from it when the human approves. Do not write it to any file and do not create files for it. This output is a PROPOSAL for the human gate: rules land in the governance store only after approval, written crew-side — do not write any rule to any store yourself.', gate_type: 'value', gate: { human_confirm: { unconditional: true } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['analyze'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
585
- ],
586
- },
587
- // The governed test-authoring workflow (wave 6 — F-7R2-003/004/005/012/014/015, R4-r2): recon →
588
- // author (creator, evidence-floor pinned) → verify (a TOOL phase that RUNS every produced test
589
- // under the repository's own harness and fails the unit when one fails or never ran) → review
590
- // (evaluator ≠ creator). Delivery is appended per run by the engine-side composition (`deliver:
591
- // "pr"`), never performed by a worker. Def + verify script live in `qe/author-workflow.ts` so the
592
- // e2e can assert the script's behaviour without reaching into this array. Crew-only drop-in (NOT
593
- // core-seeded); the `wicked-garden-qe` skill carries the method (plan/author/review actions).
594
- QE_AUTHOR_TESTS_WORKFLOW_DEF,
595
- // The one workflow that ARMS the dual-validator gate: `coverage` carries an approved
596
- // `validator_pin`, so layer 1 is live here and inert in every entry above. Transcribed
597
- // field-for-field from the source of truth, `wicked-core/workflows/domain-extraction.json`
598
- // (core ships it as a *drop-in*, not a seeded built-in, and exposes no dump command — hence a
599
- // hand-transcribed mirror, like every other entry in this array).
600
- //
601
- // The pin is a content hash over the validator's criterion + script + approved flag. Core
602
- // re-derives it in `domain_extraction.rs` and fails its own test if it drifts; if that test ever
603
- // forces core's constant to change, THIS literal must change with it or crew will write an
604
- // overlay that fails closed at plan time.
605
- //
606
- // Running it needs a one-time, idempotent `wicked-core seed-domain-validators` to vault + approve
607
- // that validator. That step is deliberately manual — approval is an audited act a human/council
608
- // owns, not something a daemon does unattended — and until it is run, a launch fails CLOSED at
609
- // plan time rather than running the phase ungated. Not `is_system`: this is an operator-selectable
610
- // work mode, unlike the dedicated-entry-point workflows above.
611
- {
612
- id: 'domain-extraction',
613
- phases: [
614
- // required_deliverables reconciled with core (wicked-core/workflows/domain-extraction.json):
615
- // survey/analyze/extract annotate the estate STORE and domain-graph now PERSISTS the graph
616
- // into the store (not a JSON file), so their evidence is DB state — verified by the coverage
617
- // gate (reads the store) and domain-graph's fail-closed-on-coverage<1.0 — not a worktree file.
618
- // Only coverage emits a genuine standalone report the deterministic floor reads. Declaring
619
- // phantom files failed every phase under core's FINDING-101 deliverable gate.
620
- //
621
- // `coverage` is `executes_code: true` (wicked-core#414): it WRITES `coverage-report.json`
622
- // into the worktree for its pinned validator to read, and an `executes_code: false` phase
623
- // may write nothing there — the worktree guard has no exemptions, declared deliverables
624
- // included. Its role stays `evaluator`; the deliver default keys off code-writing
625
- // NON-EVALUATOR phases (`executes_code && role !== 'evaluator'`), so this def never delivers.
626
- { 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 },
627
- { 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 },
628
- { 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 },
629
- { id: 'coverage', kind: 'test', gate_type: 'execution', gate: { human_confirm_if: 'verdict_not_pass' }, executes_code: true, verified_evidence: true, required_deliverables: ['coverage-report.json'], depends_on: ['extract'], role: 'evaluator', skill_ref: 'wicked-garden-domain-coverage', allowed_skills: [], validator_pin: 'bfe4020a365c598b' },
630
- // domain-graph is a DETERMINISTIC Tool that runs `wicked-core domain-graph`, which PERSISTS the
631
- // domain/requirement/rule graph into the repo store (core#237) — not an LLM skill that could hit
632
- // a non-persisting hermetic fallback. Mirrors wicked-core/workflows/domain-extraction.json.
633
- { 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 },
634
- ],
635
- },
636
- // The MCP-server drop-in (DES-mcp-server-workflow): transcribed field for field — every
637
- // `instructions` string verbatim, the gates, pins, roles, skill_refs, depends_on and the install
638
- // phase's `executor` — from the source of truth, `wicked-core/workflows/mcp-server.json` (core ships
639
- // it as a drop-in, not a seeded built-in, so it is NOT in CORE_SEEDED_WORKFLOWS: launchRun writes
640
- // this mirror to the overlay and hot-registers it, as for domain-extraction). It carries only the
641
- // evidence-floor pin, so no seed step is needed. Deliver is composed per run and placed BEFORE
642
- // `install` (`composeDeliverWorkflow` / `placeDeliverBeforeInstall`), so the pull request exists
643
- // when the install gate asks. The install gate is `consent_before` (core#801, crew#888): the engine
644
- // pauses with `gateKind: 'consent'` BEFORE the install runs and nothing pauses after it; the
645
- // command execs the admitted garden at `WICKED_GARDEN_ROOT` (core#802), never PATH. The install
646
- // Tool phase runs on the daemon host as the daemon user (the garden install script builds into
647
- // ~/.wicked/mcp-servers/<key>, registers the server in this daemon's MCP tools and writes the CLI
648
- // configurations through wicked-installer). core#820: an `install-plan` Tool phase dry-runs the
649
- // install first (`--dry-run --json`, one plan line), the gate offers its choices
650
- // (`consent:worker` default, `consent:operator`, `reject`) with each one's write targets, and the
651
- // install runs with the chosen `--target "$WICKED_CONSENT_CHOICE"`. Field-for-field equal to
652
- // core's `workflows/mcp-server.json` (tests/builtin-overlay-shadow.test.ts).
653
- // Not `is_system`: an operator-selectable work mode.
654
- {
655
- id: 'mcp-server',
656
- phases: [
657
- { id: "scope", kind: "recon", gate_type: "value", gate: { "human_confirm": { "unconditional": false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: "neutral", skill_ref: "wicked-garden-mcp-scaffold", allowed_skills: [], validator_pin: null, instructions: "Decide what this MCP server exposes (tools, resources, prompts), the upstream's authentication scheme (bearer, API key, basic, OAuth2 client credentials — the server reads ONE secret variable <SERVER>_TOKEN and every other auth parameter from its committed mcp-server.config.json), the transport (stdio by default; httpStream only when the intent asks for a hosted server), and the target: this run's repository, at its root when the repository is new and near-empty, or in a named subdirectory when it already holds code. Say whether an OpenAPI document was given (URL or file), and whether the operator wants the server installed for running when the run completes (the install phase asks again at its gate). If the intent names a repository that is not this run's, say that the operator must create it with an initial commit and register it before a run can deliver into it; do not try to create one." },
658
- { id: "source-discovery", kind: "recon", gate_type: null, gate: "auto", executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ["scope"], role: "neutral", skill_ref: "wicked-garden-mcp-scaffold", allowed_skills: [], validator_pin: null, instructions: "With an OpenAPI document: run the skill's openapi action against the conversion service and report every tool it yields with its class (read, write, destructive), its input schema summary and its request mapping, plus the skipped operations and why. Without one: inventory the integration surface (SDK calls, CLI, database, events) into a candidate tool list with the same columns. Do not write code in this phase." },
659
- { id: "design", kind: "recon", gate_type: "strategy", gate: { "human_confirm": { "unconditional": false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ["source-discovery"], role: "neutral", skill_ref: "wicked-garden-mcp-scaffold", allowed_skills: [], validator_pin: null, instructions: "Produce the design: the final tool table (name, description, input schema, annotations, class), the resources and prompts if any, the authentication plan (the one secret variable, the committed non-secret parameters, which header, what fails at startup), and the observability plan (span names and attributes, the three instruments, log fields, what leaves the process and only when). Check each against the MCP-server steering rules MCPS-1001 to MCPS-1007 (recall them with rules.recall, scope wiki:governance, when an estate store is present; otherwise use the checklist in the skill) and against MCP-D2 and MCP-D4. If a rule cannot be recalled, say so, cite the skill's checklist instead, and never state what a rule id means when you could not read it. Name every deviation and why." },
660
- { id: "build", kind: "build", gate_type: "execution", gate: "auto", executes_code: true, verified_evidence: false, required_deliverables: [], depends_on: ["design"], role: "creator", skill_ref: "wicked-garden-mcp-scaffold", allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN, instructions: "Scaffold the TypeScript server with the skill (--lang typescript) into the target directory. Branch A, with a tools.json from the conversion service: the generated tools ride the skeleton's REST runtime; adapt names, descriptions and annotations to the design. Branch B, without one: write each tool by hand with a zod schema on the same skeleton. Either way: the one secret variable and the committed mcp-server.config.json the design named, the conformance smoke and one contract test per tool under npm test, the typecheck, lint and test scripts present, an SPDX header on every source file, telemetry exporters armed only by OTEL_EXPORTER_OTLP_ENDPOINT. Build it and run the skill's probe; put the probe JSON in your output." },
661
- { 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: "wicked-garden-qe-contract-testing-engineer", allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN, instructions: "Judge the verify floor's report of the repository's own checks (typecheck, lint, test) and the tests themselves: every tool in the design has a contract test, the conformance smoke covers initialize, tools/list, tools/call, the missing-credential startup failure and the httpStream 401 path, and nothing in the tree holds a secret value. A tool without a test, or a smoke that does not exercise the failure paths, is a FAIL with the tool named." },
662
- { id: "security-review", kind: "review", gate_type: "execution", gate: { "human_confirm": { "unconditional": false } }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ["test"], role: "evaluator", skill_ref: "wicked-garden-platform-security-engineer", allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN, instructions: "Review the diff cold against MCPS-1004 and MCPS-1005 and MCP-D2: the one secret read from the environment only and never logged, returned or accepted as an argument; non-secret auth parameters in the committed config; authentication on every tool and resource (authenticate on httpStream, canAccess on every tool); input validated before the handler runs; upstream requests pinned to the configured base URL with allowlisted arguments and no redirects; a rate limit that answers a user error. Cite file and line for every finding." },
663
- { id: "observability-review", kind: "review", gate_type: "execution", gate: { "human_confirm_if": "verdict_not_pass" }, executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ["test"], role: "evaluator", skill_ref: "wicked-garden-qe-observability-test-engineer", allowed_skills: [], validator_pin: EVIDENCE_FLOOR_PIN, instructions: "Review the diff cold against MCPS-1002 and MCPS-1003: one span per tool call with the server, tool, request id and outcome attributes and a child span per upstream request; the three instruments recorded; logs through loglayer with trace and span ids stamped, a request-scoped child logger per call, console output on stderr only; nothing exported unless the operator sets an OTLP endpoint. Cite file and line for every finding." },
664
- { id: "install-plan", kind: "build", gate_type: "execution", gate: "auto", executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ["security-review", "observability-review"], role: "neutral", skill_ref: null, allowed_skills: [], validator_pin: null, executor: { "type": "tool", "cmd": ["bash", "-c", "exec \"${WICKED_GARDEN_ROOT:?the engine handed this Tool phase no garden root; refusing to resolve wicked-garden from PATH}/scripts/wicked-garden\" run scripts/mcp/install.py --from-run --dry-run --json"] }, instructions: "Plan the install without writing anything: resolve, on this host and as the daemon user, every file and directory each install choice would write — Install for workers (the default: the staged copy under ~/.wicked/mcp-servers/<key>/current, the wicked-crew MCP tools registry and the worker homes' CLI configurations under ~/.wicked-worker) and Also install into my CLIs (the worker set plus your own CLI configurations, such as ~/.claude.json, ~/.codex/config.toml and the opencode configuration) — and print them as one JSON plan line, so the install's consent gate can list them before you answer." },
665
- { id: "install", kind: "build", gate_type: "execution", gate: "consent_before", executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ["install-plan"], role: "neutral", skill_ref: null, allowed_skills: [], validator_pin: null, executor: { "type": "tool", "cmd": ["bash", "-c", "exec \"${WICKED_GARDEN_ROOT:?the engine handed this Tool phase no garden root; refusing to resolve wicked-garden from PATH}/scripts/wicked-garden\" run scripts/mcp/install.py --from-run --target \"${WICKED_CONSENT_CHOICE:?the consent gate recorded no install choice}\" --json"] }, instructions: "Install for running — or update the installed copy when this server key is already installed. Nothing has been installed yet: this asks before the install runs, and each answer lists exactly the files it would write (from the install-plan dry run). Install for workers (the default) builds the server from this run's tree into ~/.wicked/mcp-servers/<key>/current, registers it in wicked-crew's MCP tools under its key (an existing entry is re-registered in place; the first use of a changed tool still asks) and writes it into the worker homes' CLI MCP configurations — program-owned files only. Also install into my CLIs does the same and also writes it into your own CLI MCP configurations through wicked-installer (one entry per key, never a second copy; CLI entries carry no secret). Both probe tools/list. The one secret variable <SERVER>_TOKEN is named, never written: when the daemon cannot resolve it the registration is reported as pending with the remedy. Writes happen on the daemon host as the daemon user. Decline for not now — the pull request already delivered stays as it is." },
666
- ],
667
- required_instruments: APP_CHANGE_INSTRUMENTS,
668
- },
669
- ].map(withSystemFlag);
343
+ // ── Built-in workflows (X-MIG M11) ───────────────────────────────────────────
344
+ // No mirrors: the built-ins are the engine's built-in presets, read once at boot and laid over the
345
+ // phase catalog (`core/builtin-catalog.ts`, `loadBuiltinCatalog`). One source; nothing to drift.
670
346
  /**
671
347
  * Chat is not available in this deployment at all — a capability gap, never a bad request.
672
348
  *
@@ -806,95 +482,6 @@ export class GovernanceReplayUnsupportedError extends Error {
806
482
  }
807
483
  /** The engine's own way of reporting a build that cannot do chat, raised at call time. */
808
484
  const ENGINE_CHAT_UNSUPPORTED = /chat unsupported/i;
809
- /**
810
- * Quarantine a pre-#197 `onboarding.json` left in the overlay dir.
811
- *
812
- * This package used to write that file on every launch, baked with ONE repo's absolute paths. It no
813
- * longer does — core declares `{repo_root}` / `{code_graph_db}` and binds them per run
814
- * (wicked-core#179). But the overlay dir is PERSISTENT STATE, and the engine's `load_dir` registers
815
- * whatever it finds there, replacing a compiled def by id, wholesale.
816
- *
817
- * So an upgraded deployment keeps running the last file the old code wrote. Not intermittently:
818
- * EVERY onboarding run indexes whichever repo happened to be registered last before the upgrade.
819
- * Observed exactly that on this host after #197 merged — three fresh registrations in three
820
- * different orgs all indexed `agentic-products/eliza`, the last repo seeded before the fix.
821
- *
822
- * Renamed rather than deleted. The file is almost certainly machine-written, but the overlay dir is
823
- * an operator-facing extension point and silently destroying something out of it is not this
824
- * process's call. The rename is enough to stop the shadow, and leaves the evidence in place.
825
- */
826
- function quarantineStaleOnboardingOverlay() {
827
- const stale = join(workflowOverlayDir(), 'onboarding.json');
828
- if (!existsSync(stale))
829
- return; // the ordinary case on a clean install
830
- // Only a PRE-#197 artifact, never an operator's override. `registerWorkflow()` writes user
831
- // definitions into this same directory, and parking one on every boot would delete a deliberate
832
- // customization each time it was re-registered.
833
- //
834
- // The signature is specific: old crew baked one repo's ABSOLUTE paths into the tool commands. A
835
- // def carrying `{repo_root}` / `{code_graph_db}`, or agent phases, or relative commands, is not
836
- // what this is looking for and is left alone. An operator who hand-writes absolute paths into a
837
- // shared def has written the same bug, and gets the same treatment for the same reason.
838
- let bakedPaths;
839
- try {
840
- const def = JSON.parse(readFileSync(stale, 'utf8'));
841
- bakedPaths = (def.phases ?? [])
842
- .flatMap((p) => (p.executor?.type === 'tool' ? (p.executor.cmd ?? []) : []))
843
- .filter((arg) => arg.startsWith('/'));
844
- }
845
- catch {
846
- // Unparseable: not ours to judge. The engine reports its own load failure.
847
- return;
848
- }
849
- if (bakedPaths.length === 0)
850
- return;
851
- const parked = `${stale}.superseded-by-crew197`;
852
- try {
853
- renameSync(stale, parked);
854
- console.warn(`[onboarding] removed a stale overlay that would have hijacked every onboarding run: ` +
855
- `${stale} → ${parked}. It baked ${bakedPaths[0]} into a def shared by every repo, which is ` +
856
- `what a pre-#197 crew wrote; the engine resolves it in preference to the built-in ` +
857
- `(FINDING-075).`);
858
- }
859
- catch (err) {
860
- // Loud, and non-fatal: the daemon still starts, but every onboarding on this host is wrong
861
- // until the file goes, so the operator has to be told rather than left to discover it.
862
- 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, ` +
863
- `whatever repo the run names (FINDING-075).`);
864
- }
865
- }
866
- /**
867
- * The drop-in ids crew wrote into the overlay dir before DES-TEAMING-002 wave 1 retired them:
868
- * `chat` is the engine's built-in preset now, and `survey-repo`, `memories` and
869
- * `domain-graph-slice` are deleted. Exported for the quarantine test.
870
- */
871
- export const RETIRED_OVERLAY_IDS = ['chat', 'survey-repo', 'memories', 'domain-graph-slice'];
872
- /**
873
- * Park the overlay files older crews wrote for {@link RETIRED_OVERLAY_IDS}.
874
- *
875
- * `_writeBuiltinOverlay` wrote each of these on first launch, and the overlay dir is PERSISTENT: on
876
- * an upgraded host the engine's startup `load_dir` would keep the deleted workflows launchable, and
877
- * `hydrateFromOverlay` would serve them on `GET /workflows` as user workflows, where they are no
878
- * longer on the system list (review of wicked-crew#688). Renamed, not deleted, like the onboarding
879
- * quarantine above: the evidence stays, and a name retired by operator decision (2026-09-26) is
880
- * not a workflow any more — save a preset instead.
881
- */
882
- function quarantineRetiredOverlays() {
883
- for (const id of RETIRED_OVERLAY_IDS) {
884
- const stale = join(workflowOverlayDir(), `${id}.json`);
885
- if (!existsSync(stale))
886
- continue;
887
- const parked = `${stale}.retired-des-teaming-002`;
888
- try {
889
- renameSync(stale, parked);
890
- console.warn(`[workflows] parked ${stale} → ${parked}: \`${id}\` is retired (DES-TEAMING-002 wave 1` +
891
- `${id === 'chat' ? '; chat is the engine\'s built-in preset' : '; the workflow is deleted'}).`);
892
- }
893
- catch (err) {
894
- console.error(`[workflows] FAILED to park the retired overlay ${stale}: ${err instanceof Error ? err.message : String(err)}. The engine will keep resolving \`${id}\` from it until it is removed by hand.`);
895
- }
896
- }
897
- }
898
485
  /** The ids of a workflow's phases that carry a HUMAN gate (`human_confirm` unconditional, the
899
486
  * conditional `human_confirm_if`, or `consent_before` — crew#888, which pauses BEFORE the phase
900
487
  * runs) — i.e. the phases that will PAUSE for a person.
@@ -935,8 +522,6 @@ export class CoreAdapter {
935
522
  listeners = new Set();
936
523
  launchListeners = new Set();
937
524
  closed = false;
938
- /** Built-in workflow ids whose overlay JSON has been written this process lifetime. */
939
- _builtinOverlayWritten = new Set();
940
525
  /**
941
526
  * The estate db the Core actor was spawned over (`CoreAdapterOptions.dbPath` — the `--db`
942
527
  * flag / `~/.wicked-crew/core.db` default). Kept because some engine bindings take the db
@@ -1029,10 +614,6 @@ export class CoreAdapter {
1029
614
  // it publishes `task.dispatched` / consumes `task.completed` around WHATEVER step runner the engine
1030
615
  // wires (the production wrapped-CLI runner OR the deterministic stub), so a stub engine can arm it
1031
616
  // for a fast, offline, deterministic proof of the event path.
1032
- // BEFORE the Core spawns: the actor reads the overlay dir at startup, so a stale
1033
- // `onboarding.json` has to be out of the way by then or it shadows the built-in def.
1034
- quarantineStaleOnboardingOverlay();
1035
- quarantineRetiredOverlays();
1036
617
  // DES-TEAMING-002 T0: the engine gets the daemon's bus on EVERY boot (`WICKED_BUS_DB`); exec
1037
618
  // mediation stays a separate switch (`WICKED_BUS_EXEC`), set only under `engineExec`, and the
1038
619
  // engine's bus gate-judge path follows that switch — never the mere presence of a bus.
@@ -1692,29 +1273,6 @@ export class CoreAdapter {
1692
1273
  workflowId = composed.id;
1693
1274
  }
1694
1275
  opts.workflow = workflowId;
1695
- if (workflowId === input.workflow) {
1696
- // Ensure DROP-IN workflow definitions are present in the Rust overlay dir on first use.
1697
- // Uses a dedicated helper (not registerWorkflow) to avoid adding built-ins to userWorkflows,
1698
- // which would duplicate them in listWorkflows(). The write is skipped after the first call
1699
- // per process lifetime.
1700
- //
1701
- // Ids core seeds itself are excluded: writing them shadows the real def with this stale
1702
- // mirror — see CORE_SEEDED_WORKFLOWS. Core resolves those from its own registry, so there is
1703
- // nothing to write and never was.
1704
- //
1705
- // Skipped entirely on the per-run deliver path above (workflowId !== input.workflow):
1706
- // the composed def carries the base's full phase list, so the run resolves its own
1707
- // registration and the base drop-in is not consulted.
1708
- const builtinDef = CORE_SEEDED_WORKFLOWS.has(input.workflow)
1709
- ? undefined
1710
- : BUILTIN_WORKFLOWS.find((w) => w.id === input.workflow);
1711
- if (builtinDef && !this._builtinOverlayWritten.has(input.workflow)) {
1712
- // Mark before await so concurrent launchRun() calls for the same builtin
1713
- // don't both pass the has() check and race to write the same file.
1714
- this._builtinOverlayWritten.add(input.workflow);
1715
- await this._writeBuiltinOverlay(builtinDef);
1716
- }
1717
- }
1718
1276
  }
1719
1277
  else if (input.deliver === 'pr' && input.plan === undefined) {
1720
1278
  // (A plan carries its deliver step above.) Fail loud, not silent: dropping the option would run to completion with the caller
@@ -1839,11 +1397,9 @@ export class CoreAdapter {
1839
1397
  * (snake_case) — `campaigns/plan.ts` is the one producer of it in this daemon.
1840
1398
  *
1841
1399
  * `workflows` are the composed per-node Tool workflows the def's run_specs reference; they
1842
- * are armed FIRST (hot-register + overlay persist) so the engine can resolve them both now
1843
- * and after a restart — a campaign is durable, so its node workflows must be too. Unlike a
1844
- * deliver-composed def they are NOT purely ephemeral (`_armPerRunWorkflow`), and unlike a
1845
- * user workflow they never enter `userWorkflows` or the catalog (the hydration filter in
1846
- * `readOverlayWorkflows` skips the `campaign-` prefix). */
1400
+ * are armed FIRST, as system presets (X-MIG M11), so the engine resolves them both now and
1401
+ * after a restart — a campaign is durable, so its node workflows must be too. They never enter
1402
+ * `userWorkflows` or the work-mode catalog. */
1847
1403
  async launchCampaign(def, workflows = []) {
1848
1404
  const surface = this._campaigns('Launching a campaign');
1849
1405
  // (review-L1-598 M1) `denial_gate` is a 0.7.27 def field; an older addon's serde IGNORES it and
@@ -1867,21 +1423,22 @@ export class CoreAdapter {
1867
1423
  // the campaign is what the daemon can account for: pinned under `def.id` until its terminal frame.
1868
1424
  return this.handedToEngine('campaign', def.id, () => surface.launchCampaign(JSON.stringify(engineDef)));
1869
1425
  }
1870
- /** Arm one composed campaign-node workflow: validate-then-persist (the FINDING-002 ordering —
1871
- * core's parser is the authority and runs BEFORE the overlay write), overlay file for
1872
- * restart durability, no `userWorkflows` entry. */
1426
+ /** Arm one composed campaign-node workflow as a PRESET (X-MIG M11): the preset store is the
1427
+ * engine's durable record, so a resumed campaign finds its node by name after a restart without
1428
+ * any file overlay. The node's one Tool phase becomes one `run` step carrying its command; the
1429
+ * engine composes it (its step rules apply), and the name keeps it a system preset, off every
1430
+ * picker (`isSystemWorkflow`). */
1873
1431
  async _armCampaignWorkflow(def) {
1874
- const core = this.core;
1875
- const register = core['registerWorkflow'];
1876
- if (typeof register !== 'function') {
1877
- // Unreachable on any addon that carries launchCampaign (registerWorkflow predates it by
1878
- // several releases), but the guard keeps the failure loud if that ever un-holds.
1432
+ if (!this.presetsSupported())
1879
1433
  throw new CampaignsUnsupportedError('Arming a campaign node workflow');
1880
- }
1881
- await register.call(this.core, JSON.stringify(def));
1882
- const dir = workflowOverlayDir();
1883
- await mkdir(dir, { recursive: true });
1884
- await writeFile(join(dir, `${def.id}.json`), JSON.stringify(def, null, 2), 'utf8');
1434
+ const steps = def.phases.map((p) => ({
1435
+ catalog: 'run',
1436
+ id: p.id,
1437
+ kind: p.kind,
1438
+ gate_type: p.gate_type,
1439
+ ...(p.executor !== undefined ? { executor: p.executor } : {}),
1440
+ }));
1441
+ await this.putPreset(def.id, steps, undefined, 'crew-campaign');
1885
1442
  }
1886
1443
  /** Resume a campaign from its persisted state → the campaign status token. */
1887
1444
  resumeCampaign(id) {
@@ -2000,7 +1557,7 @@ export class CoreAdapter {
2000
1557
  * with no name (a user plan, free text, unknown) keeps the engine's id.
2001
1558
  */
2002
1559
  async sessionsDetail() {
2003
- const views = JSON.parse(await this.core.sessionsDetail());
1560
+ const views = JSON.parse(await this.engineSessionsDetail());
2004
1561
  const needsLaunch = (v) => v.session.team_plan == null &&
2005
1562
  typeof v.session.workflow_id === 'string' &&
2006
1563
  isSyntheticWorkflowId(v.session.workflow_id);
@@ -2021,6 +1578,41 @@ export class CoreAdapter {
2021
1578
  }
2022
1579
  return views;
2023
1580
  }
1581
+ /** The engine's sessions-detail fold running now, and the one every caller that arrived during it shares. */
1582
+ detailRunning = null;
1583
+ detailNext = null;
1584
+ /**
1585
+ * The engine's whole-store sessions-detail read, at most one running and one queued (crew#944).
1586
+ * Each call is a fold on the engine's single actor, and the binding holds a libuv worker thread
1587
+ * blocked on the actor's reply until it is served. So N concurrent run reads used to queue N
1588
+ * folds and pin the whole pool, and `/health` (its ping, its state-home readdir) waited behind
1589
+ * all of them. A caller that arrives while a fold runs shares the NEXT fold, which starts after
1590
+ * the running one ends. It never takes the running one's answer, so every caller still reads
1591
+ * a fold that started after it asked (a write it saw committed is in it). Each caller parses its
1592
+ * own copy of the JSON, because callers decorate the views they get in place.
1593
+ */
1594
+ engineSessionsDetail() {
1595
+ if (this.detailNext !== null)
1596
+ return this.detailNext;
1597
+ if (this.detailRunning === null)
1598
+ return this.startSessionsDetail();
1599
+ const next = this.detailRunning
1600
+ .catch(() => undefined)
1601
+ .then(() => {
1602
+ this.detailNext = null;
1603
+ return this.startSessionsDetail();
1604
+ });
1605
+ this.detailNext = next;
1606
+ return next;
1607
+ }
1608
+ startSessionsDetail() {
1609
+ const running = this.core.sessionsDetail().finally(() => {
1610
+ if (this.detailRunning === running)
1611
+ this.detailRunning = null;
1612
+ });
1613
+ this.detailRunning = running;
1614
+ return running;
1615
+ }
2024
1616
  /** A unit's captured transcript (string, or `null`). */
2025
1617
  async workOutput(unitId) {
2026
1618
  return JSON.parse(await this.core.workOutput(unitId));
@@ -2780,82 +2372,46 @@ export class CoreAdapter {
2780
2372
  return parseEngineJson(await importFn.call(this.core, JSON.stringify({ name, samples, ...(knowledgeDb !== undefined ? { knowledgeDb } : {}) })), 'governanceCorpusImport');
2781
2373
  }
2782
2374
  // ── Workflow viewer + builder (crew#44) ────────────────────────────────────
2783
- // Built-ins are static TypeScript mirrors of workflow.rs. User-registered
2784
- // workflows are added to `userWorkflows` and persisted to disk; the Rust actor
2785
- // picks them up via `register_workflow` NAPI (when available) for immediate use.
2786
- userWorkflows = new Map();
2787
- /** Whether {@link hydrateFromOverlay} has run this process lifetime. */
2788
- overlayHydrated = false;
2789
- /** Load user-registered workflows persisted to the overlay dir into `userWorkflows`, ONCE.
2790
- *
2791
- * FINDING-002 residual: `registerWorkflow` writes each def to the overlay dir AND to the in-memory
2792
- * `userWorkflows` Map, but the Map is process-local and empty on every daemon restart, and nothing
2793
- * read the dir back. So after a restart the Rust actor (which DOES load the overlay dir at startup)
2794
- * would launch a user workflow that `listWorkflows()`/`GET /workflows` no longer showed — it
2795
- * vanished from the registry while remaining runnable. Hydrating from the same dir the writer uses
2796
- * makes the two views agree again. */
2797
- hydrateFromOverlay() {
2798
- if (this.overlayHydrated)
2799
- return;
2800
- this.overlayHydrated = true;
2801
- const builtinIds = new Set(BUILTIN_WORKFLOWS.map((w) => w.id));
2802
- for (const def of readOverlayWorkflows(workflowOverlayDir(), builtinIds)) {
2803
- if (!this.userWorkflows.has(def.id))
2804
- this.userWorkflows.set(def.id, def);
2805
- }
2806
- }
2807
- /** Core's verdict on each hydrated drop-in, run ONCE per process (the same lifetime as
2808
- * {@link hydrateFromOverlay} and as core's own boot-time `load_dir`). */
2809
- overlayVerdicts = null;
2810
- /** id → core's refusal reason, for the drop-ins `judgeOverlayOnce` dropped. */
2811
- overlayRefusals = new Map();
2812
- /** Drop the refused defs from `userWorkflows` and remember why, asking core's parser (crew#718).
2813
- *
2814
- * A build with no `registerWorkflow` binding has no validator to ask, so nothing is judged and
2815
- * the catalog reads exactly as it did before — the same floor {@link registerWorkflow} draws,
2816
- * on the read side where there is no state to corrupt. `registerWorkflow` has been declared
2817
- * (non-optional) in wicked-core-ts since 0.4.0. */
2818
- judgeOverlayOnce() {
2819
- this.overlayVerdicts ??= (async () => {
2820
- this.hydrateFromOverlay();
2821
- const core = this.core;
2822
- const register = core['registerWorkflow'];
2823
- if (typeof register !== 'function')
2375
+ // Built-ins are the engine's built-in presets laid over its phase catalog (X-MIG M11:
2376
+ // `loadBuiltinCatalog`, read once at boot). User-registered workflows are added to
2377
+ // `userWorkflows` and hot-registered with the engine.
2378
+ /** The built-in presets' defs as crew serves them (`core/builtin-catalog.ts`). Empty until
2379
+ * {@link loadBuiltinCatalog} ran — a directly-driven adapter (a test double, the CLI) serves none. */
2380
+ builtinDefs = [];
2381
+ /**
2382
+ * Read the engine's built-in presets and its phase catalog ONCE and derive the built-in workflows
2383
+ * crew serves (`GET /workflows`, the deliver text, delivery candidacy, acceptance). The built-ins
2384
+ * are compiled into the engine, so one read holds until restart. An engine without presets or the
2385
+ * catalog binding serves none (logged), never a guess.
2386
+ */
2387
+ async loadBuiltinCatalog(warn = (m) => console.warn(m)) {
2388
+ try {
2389
+ if (!this.presetsSupported())
2824
2390
  return;
2825
- const { refused } = await judgeOverlayWorkflows([...this.userWorkflows.values()], (json) => register.call(this.core, json));
2826
- for (const r of refused) {
2827
- this.userWorkflows.delete(r.id);
2828
- this.overlayRefusals.set(r.id, r.reason);
2829
- console.warn(`wicked-crew: drop-in workflow '${r.id}' is NOT in the catalog — the engine refused it: ${r.reason}`);
2830
- }
2831
- })();
2832
- return this.overlayVerdicts;
2391
+ const [presets, entries] = await Promise.all([this.listPresets(), this.catalog()]);
2392
+ this.builtinDefs = deriveBuiltinDefs(presets, entries, warn).map(withSystemFlag);
2393
+ }
2394
+ catch (err) {
2395
+ warn(`wicked-crew: could not read the engine's built-in presets — no built-in workflow is served: ${err instanceof Error ? err.message : String(err)}`);
2396
+ }
2833
2397
  }
2834
- /** The workflow catalog `GET /workflows` serves: the defs the ENGINE accepted, plus the drop-ins
2835
- * it refused with its own reason, so an unlaunchable def is named instead of offered (crew#718). */
2398
+ userWorkflows = new Map();
2399
+ /** The workflow catalog `GET /workflows` serves. `unavailable` stays on the wire, always empty:
2400
+ * it named the operator drop-ins the engine refused (crew#718), and drop-ins are retired
2401
+ * (X-MIG M11 — a file in the old overlay dir never runs; the engine names it once at boot). */
2836
2402
  async workflowCatalog() {
2837
- await this.judgeOverlayOnce();
2838
- return {
2839
- workflows: this.listWorkflows(),
2840
- unavailable: [...this.overlayRefusals].map(([id, reason]) => ({ id, reason })),
2841
- };
2842
- }
2843
- /** Core's refusal reason for `id`, or `null` when the engine never refused it (crew#718). */
2844
- async workflowRefusal(id) {
2845
- await this.judgeOverlayOnce();
2846
- return this.overlayRefusals.get(id) ?? null;
2403
+ return { workflows: this.listWorkflows(), unavailable: [] };
2847
2404
  }
2848
2405
  listWorkflows() {
2849
- this.hydrateFromOverlay();
2850
- // Builtins first (stable ordering), but user-registered workflows take precedence when
2851
- // ids conflict — consistent with getWorkflow() which prefers userWorkflows.get().
2406
+ // Built-ins first, and a built-in WINS a name conflict (X-MIG M11, codex r2 on crew A): a launch
2407
+ // naming it resolves the engine's preset before any registered def, so the reader must serve
2408
+ // what runs. Consistent with getWorkflow().
2852
2409
  const seen = new Set();
2853
2410
  const result = [];
2854
- for (const w of BUILTIN_WORKFLOWS) {
2855
- const override = this.userWorkflows.get(w.id);
2411
+ for (const w of this.builtinDefs) {
2856
2412
  if (!seen.has(w.id)) {
2857
2413
  seen.add(w.id);
2858
- result.push(withSystemFlag(override ?? w));
2414
+ result.push(withSystemFlag(w));
2859
2415
  }
2860
2416
  }
2861
2417
  for (const w of this.userWorkflows.values()) {
@@ -2866,50 +2422,22 @@ export class CoreAdapter {
2866
2422
  }
2867
2423
  return result;
2868
2424
  }
2425
+ /** The runtime-registered defs only (the user's and crew's seams'), without the built-in presets. */
2426
+ listRuntimeWorkflows() {
2427
+ return [...this.userWorkflows.values()].map(withSystemFlag);
2428
+ }
2869
2429
  getWorkflow(id) {
2870
- this.hydrateFromOverlay();
2871
- const def = this.userWorkflows.get(id) ?? BUILTIN_WORKFLOWS.find((w) => w.id === id) ?? null;
2430
+ // Preset first, as the engine resolves a launch (see listWorkflows).
2431
+ const def = this.builtinDefs.find((w) => w.id === id) ?? this.userWorkflows.get(id) ?? null;
2872
2432
  return def === null ? null : withSystemFlag(def);
2873
2433
  }
2874
- /** Write a built-in workflow definition to the Rust overlay dir (and hot-register when possible).
2875
- * Unlike registerWorkflow(), this does NOT touch userWorkflows, avoiding duplicates in listWorkflows(). */
2876
- async _writeBuiltinOverlay(def) {
2877
- const dir = workflowOverlayDir();
2878
- await mkdir(dir, { recursive: true });
2879
- const overlayDef = { ...def };
2880
- delete overlayDef.is_system;
2881
- const json = JSON.stringify(overlayDef);
2882
- const core = this.core;
2883
- const register = core['registerWorkflow'];
2884
- // Same validate-before-persist ordering as registerWorkflow (FINDING-002). This path had the
2885
- // identical defect — write first, validate last — which is the P3 shape this campaign keeps
2886
- // finding: N paths, one hardened. A mirror that drifted far enough for core to reject it would
2887
- // otherwise leave an unparseable *.json in the dispatch overlay dir, and core would skip it at
2888
- // the next load. Letting the rejection propagate instead fails the launch with core's own
2889
- // reason, which beats dispatching against a workflow core will silently drop.
2890
- if (typeof register === 'function') {
2891
- await register.call(this.core, json);
2892
- }
2893
- // Deliberately NOT the refusal registerWorkflow makes when the binding is absent, and the
2894
- // difference is the input, not the caller:
2895
- // - a user def is arbitrary runtime input no test has ever seen, so unvalidatable means
2896
- // unsafe to persist;
2897
- // - a built-in mirror is asserted field-for-field against wicked-core's own
2898
- // workflows/<id>.json by tests/builtin-overlay-shadow.test.ts, so its parseability is
2899
- // established at build time rather than needing a runtime check.
2900
- // Refusing here would also break DELIVERY: this write is the only way core resolves a drop-in
2901
- // id, so a refusal turns a silent ungating into a hard "unknown workflow" — exactly the
2902
- // regression FINDING-084's first attempted fix caused.
2903
- await writeFile(join(dir, `${def.id}.json`), JSON.stringify(overlayDef, null, 2), 'utf8');
2904
- }
2905
2434
  /**
2906
2435
  * Arm a PER-RUN composed workflow def (crew#293 deliver, crew#311 deliverable floor) with the
2907
2436
  * engine — hot registration
2908
- * ONLY. Deliberately neither of the other two paths:
2437
+ * ONLY. Deliberately not the other path:
2909
2438
  * - not `registerWorkflow()`: the composed def must not enter `userWorkflows` or the overlay
2910
2439
  * dir — it is launch input for one run, and persisting it would grow the catalog and the
2911
- * overlay dir by one entry per delivered run;
2912
- * - not `_writeBuiltinOverlay()`: same reason, no file.
2440
+ * overlay dir by one entry per delivered run.
2913
2441
  * The engine's `registerWorkflow` binding validates the def server-side and makes it visible
2914
2442
  * to the next `launchRun` with no restart — exactly the lifetime a per-run def needs. (The
2915
2443
  * def is consumed at PLANNING time; a later daemon restart resumes the run from its persisted
@@ -2929,56 +2457,32 @@ export class CoreAdapter {
2929
2457
  await register.call(this.core, JSON.stringify(def));
2930
2458
  }
2931
2459
  /**
2932
- * Register a user-authored workflow: persist to the Rust workflow overlay dir
2933
- * (`~/.config/wicked-core/workflows/<id>.json` or `$WICKED_WORKFLOWS_DIR`),
2934
- * update the in-memory registry, and (when core supports it) register in the
2935
- * Rust actor so runs using this workflow work immediately without a restart.
2460
+ * Register a RUNTIME workflow def with the engine (X-MIG M11): crew's interactive seams that still
2461
+ * hold a def (review, theme) register it at boot. The engine validates it and holds it in memory
2462
+ * for this process; nothing is written to disk (operator drop-ins are retired, and a user saves a
2463
+ * preset instead). Refused for a built-in preset's name, which a launch would never resolve to it.
2936
2464
  */
2937
2465
  async registerWorkflow(def) {
2938
2466
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
2939
2467
  if (!SAFE_ID.test(def.id) || def.id.length > 128) {
2940
2468
  throw new Error('workflow id must start with a letter/digit and contain only letters, digits, dots, hyphens, and underscores');
2941
2469
  }
2942
- const dir = workflowOverlayDir();
2943
- await mkdir(dir, { recursive: true });
2944
- const path = join(dir, `${def.id}.json`);
2945
- // Strip `is_system` before writing — the Rust core's overlay format does not recognise that
2946
- // field and silently drops any workflow whose JSON it cannot fully deserialise.
2947
- const overlayDef = { ...def };
2948
- delete overlayDef.is_system;
2949
- const json = JSON.stringify(overlayDef);
2950
- // VALIDATE BEFORE PERSISTING (FINDING-002). This ordering is the whole fix.
2951
- //
2952
- // The write used to come first and `registerWorkflow` last, so core's parser — the only thing
2953
- // that actually knows the overlay schema — ran AFTER the state was already mutated. Observed
2954
- // end to end: POST /api/v1/workflows answered
2955
- // 400 invalid workflow JSON: unknown field `name`, expected `id` or `phases`
2956
- // and the file was on disk anyway, `name` included, and served from `userWorkflows` as though
2957
- // registered. On the next daemon start core could not deserialise its own overlay file:
2958
- // wicked-core: skipping workflow file .../probe-002-persist.json
2959
- // and the workflow VANISHED while its file remained. That is FINDING-002's root cause: not
2960
- // "registration is not durable" but "a rejected request persisted a def core cannot read".
2961
- //
2962
- // Core's parser is the authority, so it is what we ask. Enumerating the accepted fields in TS
2963
- // instead would be a second copy of core's schema — the exact drift this codebase keeps paying
2964
- // for, and `is_system` above is already one hand-maintained instance of it.
2470
+ if (this.builtinDefs.some((w) => w.id === def.id)) {
2471
+ // A def of a built-in preset's name would never run: a launch resolves the preset first.
2472
+ throw new Error(`workflow id '${def.id}' is a built-in preset: a registered def of that name never runs (a launch resolves the preset first) — save your own preset under another name (PUT /presets/:name)`);
2473
+ }
2474
+ // `is_system` is crew's display flag; core's strict def parser rejects the key.
2475
+ const engineDef = { ...def };
2476
+ delete engineDef.is_system;
2965
2477
  const core = this.core;
2966
2478
  const register = core['registerWorkflow'];
2967
2479
  if (typeof register !== 'function') {
2968
- // No validator, so no safe way to persist: an unvalidated def written here is a file core
2969
- // may silently skip at load. Refusing is the honest outcome — and it is loud, unlike the
2970
- // vanishing act it replaces. `registerWorkflow` has been declared (non-optional) in
2971
- // wicked-core-ts since 0.4.0, so this is a real floor, not a routine path.
2972
- throw new Error('this wicked-core build exposes no registerWorkflow binding, so a workflow cannot be ' +
2973
- 'validated before it is written; refusing to persist an unvalidated definition');
2974
- }
2975
- // Throws on a def core rejects — before anything is written or registered.
2976
- await register.call(this.core, json);
2977
- await writeFile(path, JSON.stringify(overlayDef, null, 2), 'utf8');
2480
+ // No validator: refusing is the honest outcome (declared non-optional since core-ts 0.4.0).
2481
+ throw new Error('this wicked-core build exposes no registerWorkflow binding, so a workflow cannot be validated and registered');
2482
+ }
2483
+ // Throws on a def core rejects — before crew's registry holds it.
2484
+ await register.call(this.core, JSON.stringify(engineDef));
2978
2485
  this.userWorkflows.set(def.id, def);
2979
- // (crew#718) This def just passed core's own validator, so any earlier refusal under the same
2980
- // id is stale — the catalog must offer the replacement, not keep naming the version it refused.
2981
- this.overlayRefusals.delete(def.id);
2982
2486
  return def.id;
2983
2487
  }
2984
2488
  /**