wicked-crew 0.2.1 → 0.3.1

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.
@@ -1,10 +1,161 @@
1
1
  import { createRequire } from 'node:module';
2
+ import { execFile } from 'node:child_process';
3
+ import { mkdir, access, readFile, writeFile, chmod, rm } from 'node:fs/promises';
4
+ import { join, dirname, resolve, isAbsolute, relative, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { homedir } from 'node:os';
7
+ import { promisify } from 'node:util';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { DEFAULT_SETTINGS } from './types.js';
10
+ const execFileAsync = promisify(execFile);
11
+ /** Resolved path under the user's home directory. */
12
+ function wickedDir(...parts) {
13
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '/tmp';
14
+ return join(home, '.wicked', ...parts);
15
+ }
16
+ /**
17
+ * Workflow overlay directory — mirrors the Rust `workflow_overlay_dir()` logic in
18
+ * `pipeline.rs`. The Rust actor reads drop-in workflow JSONs from this path at startup
19
+ * and (with registerWorkflow NAPI) at runtime. TS must write to the same location so
20
+ * the files are picked up on the next daemon start.
21
+ * • `$WICKED_WORKFLOWS_DIR` — explicit override (matches Rust env check)
22
+ * • `~/.config/wicked-core/workflows` — default (matches Rust default)
23
+ */
24
+ function workflowOverlayDir() {
25
+ if (process.env.WICKED_WORKFLOWS_DIR)
26
+ return process.env.WICKED_WORKFLOWS_DIR;
27
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '/tmp';
28
+ return join(home, '.config', 'wicked-core', 'workflows');
29
+ }
30
+ function settingsFilePath() {
31
+ return join(homedir(), '.config', 'wicked-core', 'settings.json');
32
+ }
33
+ /** Find the wicked-core standalone binary for the gate-hook command.
34
+ * Checks common install locations so the Rust actor can build a correct
35
+ * hook command even when loaded as a napi addon (where current_exe() = node).
36
+ */
37
+ function locateWickedCoreExe() {
38
+ const exeName = process.platform === 'win32' ? 'wicked-core.exe' : 'wicked-core';
39
+ const candidates = [];
40
+ // User-local install (cargo install / manual).
41
+ const home = process.env.HOME ?? process.env.USERPROFILE;
42
+ if (home) {
43
+ candidates.push(join(home, '.local', 'bin', exeName));
44
+ candidates.push(join(home, '.cargo', 'bin', exeName));
45
+ }
46
+ // Monorepo dev build.
47
+ candidates.push(join(dirname(fileURLToPath(import.meta.url)), '../../..', 'wicked-core', 'target', 'release', exeName));
48
+ // PATH lookup.
49
+ const pathDirs = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':');
50
+ for (const dir of pathDirs) {
51
+ candidates.push(join(dir, exeName));
52
+ }
53
+ const { existsSync } = require('node:fs');
54
+ return candidates.find((p) => existsSync(p));
55
+ }
2
56
  // The native addon is a CommonJS cdylib (`index.node`); load it with a CJS
3
57
  // require even though this daemon is ESM. This module is the ONLY place that
4
58
  // touches wicked-core-ts (DES-STUDIO-001 §5.2/§5.3), so the FINALIZING
5
59
  // `subscribe` seam has a blast radius of exactly one file.
6
60
  const require = createRequire(import.meta.url);
7
61
  const { Core } = require('wicked-core-ts');
62
+ // ── Built-in workflow definitions (crew#44) ──────────────────────────────────
63
+ // Static mirrors of wicked-core workflow defs: feature, bug, migration, survey-repo,
64
+ // repo-graph, domain-graph-slice, memories, onboarding, and chat.
65
+ // Swap for `this.core.listWorkflowsJson()` / `this.core.getWorkflowJson(id)` once
66
+ // the wicked-core-ts NAPI methods land.
67
+ const BUILTIN_WORKFLOWS = [
68
+ {
69
+ id: 'chat',
70
+ is_system: true,
71
+ phases: [
72
+ { id: 'explore', 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 },
73
+ ],
74
+ },
75
+ {
76
+ id: 'onboarding',
77
+ is_system: true,
78
+ 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 },
82
+ ],
83
+ },
84
+ {
85
+ id: 'feature',
86
+ phases: [
87
+ { 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
+ { 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
+ { 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 },
91
+ { 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
+ { 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
+ ],
94
+ },
95
+ {
96
+ id: 'bug',
97
+ phases: [
98
+ { 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
+ { 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
+ { 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 },
102
+ ],
103
+ },
104
+ {
105
+ id: 'migration',
106
+ phases: [
107
+ { 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
+ { 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
+ { 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 },
111
+ { 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
+ ],
113
+ },
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
+ {
123
+ id: 'survey-repo',
124
+ is_system: true,
125
+ phases: [
126
+ { id: 'structure', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
127
+ { id: 'stack', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['structure'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
128
+ { id: 'conventions', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['stack'], role: 'neutral', skill_ref: null, allowed_skills: [], validator_pin: null },
129
+ ],
130
+ },
131
+ {
132
+ id: 'domain-graph-slice',
133
+ is_system: true,
134
+ phases: [
135
+ { id: 'identify', 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 },
136
+ { id: 'extract', kind: 'build', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['identify'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
137
+ { id: 'validate', kind: 'review', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['extract'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: null },
138
+ ],
139
+ },
140
+ {
141
+ id: 'memories',
142
+ is_system: true,
143
+ phases: [
144
+ { id: 'gather', 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 },
145
+ { id: 'store', kind: 'build', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['gather'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
146
+ ],
147
+ },
148
+ {
149
+ id: 'collab',
150
+ is_system: true,
151
+ phases: [
152
+ { id: 'propose', kind: 'recon', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: [], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
153
+ { id: 'critique', kind: 'review', gate_type: 'value', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['propose'], role: 'evaluator', skill_ref: null, allowed_skills: [], validator_pin: null },
154
+ { id: 'revise', kind: 'recon', gate_type: 'strategy', gate: 'auto', executes_code: false, verified_evidence: false, required_deliverables: [], depends_on: ['critique'], role: 'creator', skill_ref: null, allowed_skills: [], validator_pin: null },
155
+ { 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
+ ],
157
+ },
158
+ ];
8
159
  /**
9
160
  * The single isolation boundary over wicked-core-ts. It holds the ONE `Core`
10
161
  * handle, makes the ONE `subscribe()` call for the whole process, parses each
@@ -17,6 +168,8 @@ export class CoreAdapter {
17
168
  subscription;
18
169
  listeners = new Set();
19
170
  closed = false;
171
+ /** Built-in workflow ids whose overlay JSON has been written this process lifetime. */
172
+ _builtinOverlayWritten = new Set();
20
173
  /** `true` when this adapter armed the event-driven exec seam (for readiness/reporting). */
21
174
  engineExec;
22
175
  /** The bus db the exec seam runs over when armed (else `undefined`). */
@@ -38,6 +191,15 @@ export class CoreAdapter {
38
191
  }
39
192
  this.engineExec = armExec;
40
193
  this.busDbPath = armExec ? opts.busDbPath : undefined;
194
+ // Give the Rust actor the path to the wicked-core standalone binary so the gate-hook
195
+ // command works when wicked-core is loaded as a napi-rs addon (where current_exe()
196
+ // returns the Node.js interpreter, not wicked-core). The actor checks WICKED_CORE_EXE
197
+ // first, so this env wins over the current_exe() fallback.
198
+ if (!process.env['WICKED_CORE_EXE']) {
199
+ const wcExe = locateWickedCoreExe();
200
+ if (wcExe)
201
+ process.env['WICKED_CORE_EXE'] = wcExe;
202
+ }
41
203
  this.core = opts.stub ? Core.spawnStub(opts.dbPath) : Core.spawn(opts.dbPath);
42
204
  // The ONE subscribe() for the process. Error-first callback (index.d.ts:56):
43
205
  // one JSON string per CoreEvent, in emission order. A throw in a listener is
@@ -78,7 +240,7 @@ export class CoreAdapter {
78
240
  return this.core.ping();
79
241
  }
80
242
  /** Launch an interactive, resumable run → the run id. */
81
- launchRun(input) {
243
+ async launchRun(input) {
82
244
  const opts = {
83
245
  problem: input.problem,
84
246
  sessionId: input.sessionId,
@@ -90,6 +252,20 @@ export class CoreAdapter {
90
252
  opts.humanConfirm = input.humanConfirm;
91
253
  if (input.repoRef !== undefined)
92
254
  opts.repoRef = input.repoRef;
255
+ if (input.workflow !== undefined) {
256
+ opts.workflow = input.workflow;
257
+ // Ensure built-in workflow definitions are present in the Rust overlay dir on first use.
258
+ // Uses a dedicated helper (not registerWorkflow) to avoid adding built-ins to userWorkflows,
259
+ // which would duplicate them in listWorkflows(). The write is skipped after the first call
260
+ // per process lifetime.
261
+ const builtinDef = BUILTIN_WORKFLOWS.find((w) => w.id === input.workflow);
262
+ if (builtinDef && !this._builtinOverlayWritten.has(input.workflow)) {
263
+ // Mark before await so concurrent launchRun() calls for the same builtin
264
+ // don't both pass the has() check and race to write the same file.
265
+ this._builtinOverlayWritten.add(input.workflow);
266
+ await this._writeBuiltinOverlay(builtinDef);
267
+ }
268
+ }
93
269
  return this.core.launchRun(opts);
94
270
  }
95
271
  /** Resume a run from its persisted cursor → the status token. */
@@ -104,26 +280,336 @@ export class CoreAdapter {
104
280
  cancelRun(runId) {
105
281
  return this.core.cancelRun(runId);
106
282
  }
283
+ /** Inject an operator message into a run's active PTY worker(s). target="all" or a CLI key. */
284
+ injectWorkerMessage(runId, message, target) {
285
+ if (typeof this.core.injectWorkerMessage !== 'function') {
286
+ return Promise.reject(new Error('Operator message injection is not yet supported by this wicked-core build'));
287
+ }
288
+ return this.core.injectWorkerMessage(runId, message, target);
289
+ }
107
290
  /** Run ids on the store. */
108
291
  async sessions() {
109
292
  return JSON.parse(await this.core.sessions());
110
293
  }
111
294
  /** Every run + its ordered units. */
112
295
  async sessionsDetail() {
113
- return JSON.parse(await this.core.sessionsDetail());
296
+ const views = JSON.parse(await this.core.sessionsDetail());
297
+ // The Rust core always stores workflow_id as 'wf-<session-uuid>' (an instance ID, not the
298
+ // definition name). Patch it back to the definition name so the studio's chat/work filters work.
299
+ // phase_ref is only set on executed units and uses format 'wf-<uuid>:unit-N' (not the phase id).
300
+ // The phase id is reliably embedded in the unit id as '<session-uuid>:<phase-id>'.
301
+ for (const view of views) {
302
+ if (view.session.workflow_id?.startsWith('wf-')) {
303
+ const phases = [...view.units].sort((a, b) => a.ord - b.ord).map((u) => {
304
+ const colonIdx = u.id.indexOf(':');
305
+ return colonIdx >= 0 ? u.id.slice(colonIdx + 1) : '';
306
+ });
307
+ if (view.units.length === 1) {
308
+ // Single-unit chat sessions have phase id 'explore' (from the chat workflow def).
309
+ // 'u1' is ambiguous — it appears on any single-unit run without an explicit workflow,
310
+ // including Do Work runs, so we leave those unpatched rather than misclassify them.
311
+ const phase = phases[0] ?? '';
312
+ if (phase === 'explore')
313
+ view.session.workflow_id = 'chat';
314
+ }
315
+ else {
316
+ // Multi-unit: match against builtin workflow defs by phase sequence
317
+ const match = BUILTIN_WORKFLOWS.find((def) => def.phases.length === phases.length &&
318
+ def.phases.every((p, i) => p.id === phases[i]));
319
+ if (match)
320
+ view.session.workflow_id = match.id;
321
+ }
322
+ }
323
+ }
324
+ return views;
114
325
  }
115
326
  /** A unit's captured transcript (string, or `null`). */
116
327
  async workOutput(unitId) {
117
328
  return JSON.parse(await this.core.workOutput(unitId));
118
329
  }
119
- /** Register a git repo → the persisted `RepoEntry`. */
330
+ /** repo id → onboarding run id (in-memory; graph persists on disk across restarts). */
331
+ 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();
338
+ /** Register a local git repo → the persisted `RepoEntry`. */
120
339
  async registerRepo(name, rootPath) {
121
340
  return JSON.parse(await this.core.registerRepo(name, rootPath));
122
341
  }
342
+ /**
343
+ * Clone a remote git URL, register it, then launch an `onboarding` workflow run.
344
+ * `checkoutPath` overrides the default clone destination (`~/.wicked/repos/<name>`).
345
+ */
346
+ async cloneAndRegisterRepo(name, gitUrl, checkoutPath) {
347
+ const reposRoot = wickedDir('repos');
348
+ let cloneDir;
349
+ if (checkoutPath) {
350
+ // Expand leading ~/ so callers can use home-relative paths.
351
+ const expanded = checkoutPath.startsWith('~/')
352
+ ? join(homedir(), checkoutPath.slice(2))
353
+ : checkoutPath;
354
+ if (!isAbsolute(expanded)) {
355
+ throw new Error('checkoutPath must be an absolute path (or start with ~/)');
356
+ }
357
+ cloneDir = resolve(expanded);
358
+ }
359
+ else {
360
+ cloneDir = join(reposRoot, name);
361
+ // Defense-in-depth: name validated by schema, but guard direct calls too.
362
+ // Use relative() instead of startsWith(root+'/') so this works cross-platform
363
+ // (Windows uses backslash separators, making a literal '/' suffix check unreliable).
364
+ const rel = relative(reposRoot, cloneDir);
365
+ if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
366
+ throw new Error('Unsafe repo name: would escape the repos directory');
367
+ }
368
+ }
369
+ // Ensure the parent exists (first-run, nested checkoutPath, etc.) before the
370
+ // atomic create below — recursive mkdir is safe for parents since we are not
371
+ // the intended owner of those directories.
372
+ await mkdir(resolve(cloneDir, '..'), { recursive: true });
373
+ // Atomic exclusive mkdir: succeeds only if we created the directory, throws
374
+ // EEXIST if it already existed. This is race-safe — recursive mkdir would
375
+ // silently succeed for existing dirs, making weMadeDir unreliable.
376
+ let weMadeDir = false;
377
+ try {
378
+ await mkdir(cloneDir);
379
+ weMadeDir = true;
380
+ }
381
+ catch (err) {
382
+ const code = err.code;
383
+ if (code !== 'EEXIST')
384
+ throw err;
385
+ // Directory already existed — verify it is actually a directory (not a file).
386
+ await access(join(cloneDir, '.'));
387
+ }
388
+ let needsClone = true;
389
+ try {
390
+ await access(join(cloneDir, '.git'));
391
+ needsClone = false;
392
+ }
393
+ catch { /* not yet cloned */ }
394
+ if (needsClone) {
395
+ try {
396
+ await execFileAsync('git', ['clone', '--', gitUrl, cloneDir], {
397
+ timeout: 5 * 60 * 1000,
398
+ });
399
+ }
400
+ catch (err) {
401
+ // Cleanup is best-effort: swallow any cleanup error so the original
402
+ // clone failure is what the caller sees.
403
+ if (weMadeDir) {
404
+ await rm(cloneDir, { recursive: true, force: true }).catch(() => { });
405
+ }
406
+ else {
407
+ // Pre-existing dir: remove a partially-written .git so the next call
408
+ // doesn't incorrectly skip cloning against a broken working tree.
409
+ await rm(join(cloneDir, '.git'), { recursive: true, force: true }).catch(() => { });
410
+ }
411
+ throw err;
412
+ }
413
+ }
414
+ const entry = await this.registerRepo(name, cloneDir);
415
+ const runId = await this.launchOnboardingRun(entry.id, name);
416
+ return { repoId: entry.id, runId };
417
+ }
418
+ /**
419
+ * Launch the built-in `onboarding` workflow for a registered repo.
420
+ * The run's `workdir` = the repo root; Tool phases run estate commands there.
421
+ * Returns the run id so the UI can navigate directly to it.
422
+ */
423
+ 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);
430
+ const runId = randomUUID();
431
+ const chained = this._onboardingChain.then(() => this._doOnboardingLaunch(repoId, repoName, runId));
432
+ this._onboardingChain = chained.catch(() => undefined);
433
+ try {
434
+ await chained;
435
+ return runId;
436
+ }
437
+ finally {
438
+ this.onboardingInFlight.delete(repoId);
439
+ }
440
+ }
441
+ 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
+ }
481
+ this.repoOnboardRunIds.set(repoId, runId);
482
+ }
483
+ /** Return the onboarding run id for a repo (undefined if not launched this session). */
484
+ getOnboardRunId(repoId) {
485
+ return this.repoOnboardRunIds.get(repoId);
486
+ }
123
487
  /** List every registered repo. */
124
488
  async listRepos() {
125
489
  return JSON.parse(await this.core.listRepos());
126
490
  }
491
+ // ── Governance reads (crew#40) ──────────────────────────────────────────────
492
+ /** All registered governance policies. */
493
+ async listPolicies() {
494
+ return JSON.parse(await this.core.listPolicies());
495
+ }
496
+ /** All conformance rules on the store. */
497
+ async listConformanceRules() {
498
+ return JSON.parse(await this.core.listConformanceRules());
499
+ }
500
+ /** All recorded conformance claims (governance decisions). */
501
+ async listConformanceClaims() {
502
+ return JSON.parse(await this.core.listConformanceClaims());
503
+ }
504
+ /** Front-half coverage gate report; null when the store has no graph nodes. */
505
+ async getCoverageReport() {
506
+ return JSON.parse(await this.core.getCoverageReport());
507
+ }
508
+ // ── Governance writes (crew#42) ────────────────────────────────────────────
509
+ /** Upsert a governance policy via the single-writer actor. */
510
+ async upsertPolicy(policy) {
511
+ await this.core.upsertPolicy(JSON.stringify(policy));
512
+ }
513
+ /** Upsert a conformance rule via the single-writer actor. */
514
+ async upsertConformanceRule(rule) {
515
+ await this.core.upsertConformanceRule(JSON.stringify(rule));
516
+ }
517
+ /** Recall conformance rules matching a facet query (read-only, does not block actor). */
518
+ async recallRulesPreview(query) {
519
+ const cleanQuery = {};
520
+ for (const [k, v] of Object.entries(query)) {
521
+ // Fastify may parse duplicate params as arrays — take the first string value only.
522
+ const scalar = Array.isArray(v) ? v[0] : v;
523
+ if (typeof scalar === 'string' && scalar.length > 0)
524
+ cleanQuery[k] = scalar;
525
+ }
526
+ const json = await this.core.recallRulesPreview(JSON.stringify(cleanQuery));
527
+ return JSON.parse(json);
528
+ }
529
+ // ── Workflow viewer + builder (crew#44) ────────────────────────────────────
530
+ // Built-ins are static TypeScript mirrors of workflow.rs. User-registered
531
+ // workflows are added to `userWorkflows` and persisted to disk; the Rust actor
532
+ // picks them up via `register_workflow` NAPI (when available) for immediate use.
533
+ userWorkflows = new Map();
534
+ listWorkflows() {
535
+ // Builtins first (stable ordering), but user-registered workflows take precedence when
536
+ // ids conflict — consistent with getWorkflow() which prefers userWorkflows.get().
537
+ const seen = new Set();
538
+ const result = [];
539
+ for (const w of BUILTIN_WORKFLOWS) {
540
+ const override = this.userWorkflows.get(w.id);
541
+ if (!seen.has(w.id)) {
542
+ seen.add(w.id);
543
+ result.push(override ?? w);
544
+ }
545
+ }
546
+ for (const w of this.userWorkflows.values()) {
547
+ if (!seen.has(w.id)) {
548
+ seen.add(w.id);
549
+ result.push(w);
550
+ }
551
+ }
552
+ return result;
553
+ }
554
+ getWorkflow(id) {
555
+ return this.userWorkflows.get(id) ?? BUILTIN_WORKFLOWS.find((w) => w.id === id) ?? null;
556
+ }
557
+ /** Write a built-in workflow definition to the Rust overlay dir (and hot-register when possible).
558
+ * Unlike registerWorkflow(), this does NOT touch userWorkflows, avoiding duplicates in listWorkflows(). */
559
+ async _writeBuiltinOverlay(def) {
560
+ const dir = workflowOverlayDir();
561
+ await mkdir(dir, { recursive: true });
562
+ const overlayDef = { ...def };
563
+ delete overlayDef.is_system;
564
+ await writeFile(join(dir, `${def.id}.json`), JSON.stringify(overlayDef, null, 2), 'utf8');
565
+ const core = this.core;
566
+ if (typeof core['registerWorkflow'] === 'function') {
567
+ await core['registerWorkflow'](JSON.stringify(overlayDef));
568
+ }
569
+ }
570
+ /**
571
+ * Register a user-authored workflow: persist to the Rust workflow overlay dir
572
+ * (`~/.config/wicked-core/workflows/<id>.json` or `$WICKED_WORKFLOWS_DIR`),
573
+ * update the in-memory registry, and (when core supports it) register in the
574
+ * Rust actor so runs using this workflow work immediately without a restart.
575
+ */
576
+ async registerWorkflow(def) {
577
+ const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
578
+ if (!SAFE_ID.test(def.id) || def.id.length > 128) {
579
+ throw new Error('workflow id must start with a letter/digit and contain only letters, digits, dots, hyphens, and underscores');
580
+ }
581
+ const dir = workflowOverlayDir();
582
+ await mkdir(dir, { recursive: true });
583
+ const path = join(dir, `${def.id}.json`);
584
+ // Strip `is_system` before writing — the Rust core's overlay format does not recognise that
585
+ // field and silently drops any workflow whose JSON it cannot fully deserialise.
586
+ const overlayDef = { ...def };
587
+ 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.
592
+ const core = this.core;
593
+ if (typeof core['registerWorkflow'] === 'function') {
594
+ await core['registerWorkflow'](JSON.stringify(overlayDef));
595
+ }
596
+ return def.id;
597
+ }
598
+ /**
599
+ * Save an inline script to `~/.wicked/scripts/<name>.<ext>`, make it executable,
600
+ * and return the absolute path. Tool-executor phases use this path as their command.
601
+ */
602
+ async saveScript(name, content, lang) {
603
+ const ext = lang === 'python' ? 'py' : 'sh';
604
+ const dir = wickedDir('scripts');
605
+ await mkdir(dir, { recursive: true });
606
+ const filename = `${name.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`;
607
+ const path = join(dir, filename);
608
+ const shebang = lang === 'python' ? '#!/usr/bin/env python3\n' : '#!/usr/bin/env bash\n';
609
+ await writeFile(path, shebang + content, 'utf8');
610
+ await chmod(path, 0o755);
611
+ return path;
612
+ }
127
613
  // ── PTY terminal sessions (DES-TERMINAL-001 §6) ────────────────────────────
128
614
  // Thin wrappers over the four core-ts terminal methods. Output does NOT return
129
615
  // here — it arrives as `terminalOutput` CoreEvents on the single subscription
@@ -150,6 +636,31 @@ export class CoreAdapter {
150
636
  closeTerminal(id) {
151
637
  return this.core.closeTerminal(id);
152
638
  }
639
+ // ── System settings ───────────────────────────────────────────────────────
640
+ async getSettings() {
641
+ try {
642
+ const raw = await readFile(settingsFilePath(), 'utf8');
643
+ const parsed = JSON.parse(raw);
644
+ // Validate numeric fields; drop anything out-of-range rather than propagate bad values.
645
+ if ('graphNodeLimit' in parsed) {
646
+ const v = parsed.graphNodeLimit;
647
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > 10000)
648
+ delete parsed.graphNodeLimit;
649
+ }
650
+ return { ...DEFAULT_SETTINGS, ...parsed };
651
+ }
652
+ catch {
653
+ return { ...DEFAULT_SETTINGS };
654
+ }
655
+ }
656
+ async updateSettings(patch) {
657
+ const current = await this.getSettings();
658
+ const next = { ...current, ...patch };
659
+ const path = settingsFilePath();
660
+ await mkdir(dirname(path), { recursive: true });
661
+ await writeFile(path, JSON.stringify(next, null, 2), 'utf8');
662
+ return next;
663
+ }
153
664
  /**
154
665
  * Tear down the single subscription (stop delivery, release the pump thread +
155
666
  * callback) so the process can exit cleanly. Idempotent.