@pugi/cli 0.1.0-beta.1 → 0.1.0-beta.10

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.
@@ -17,8 +17,10 @@ import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
17
17
  import { webFetchTool } from '../tools/web-fetch.js';
18
18
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
19
19
  import { signatureForPlanReview } from '../core/repl/ask.js';
20
- import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
20
+ import { buildRuntimeConfig, fetchPersonaRoster, loadRuntimeConfig, openPugiSession, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitDelegate, submitSync, submitTripleReview, } from '@pugi/sdk';
21
21
  import { PUGI_TAGLINE } from '@pugi/personas';
22
+ import { resolveRoster, renderRosterTable } from './commands/roster.js';
23
+ import { runDelegateCommand } from './commands/delegate.js';
22
24
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
23
25
  import { runDeployCommand } from '../commands/deploy.js';
24
26
  import { runJobsCommand } from '../commands/jobs.js';
@@ -27,9 +29,14 @@ import { runPrivacyCommand } from './commands/privacy.js';
27
29
  import { runUndoCommand } from './commands/undo.js';
28
30
  import { runBudgetCommand } from './commands/budget.js';
29
31
  import { runSkillsCommand } from './commands/skills.js';
32
+ import { installDefaultSkills } from '../core/skills/defaults.js';
30
33
  import { runAgentsCommand } from './commands/agents.js';
34
+ import { runLspCommand } from './commands/lsp.js';
35
+ import { runPatchCommand } from './commands/patch.js';
36
+ import { runWorktreeCommand } from './commands/worktree.js';
31
37
  import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
32
38
  import { runReviewConsensus } from './commands/review-consensus.js';
39
+ import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
33
40
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
34
41
  import { slugForCwd } from '../core/repl/history.js';
35
42
  import { dispatchEdit, } from '../core/edits/index.js';
@@ -44,7 +51,7 @@ import { dispatchEdit, } from '../core/edits/index.js';
44
51
  * packages/pugi-sdk/package.json); the publish workflow validates the
45
52
  * three are in lockstep.
46
53
  */
47
- const PUGI_CLI_VERSION = "0.1.0-beta.1";
54
+ const PUGI_CLI_VERSION = "0.1.0-beta.10";
48
55
  const handlers = {
49
56
  accounts,
50
57
  agents: dispatchAgents,
@@ -53,6 +60,7 @@ const handlers = {
53
60
  budget: dispatchBudget,
54
61
  code: runEngineTask('code'),
55
62
  config: dispatchConfig,
63
+ delegate: dispatchDelegate,
56
64
  deploy: dispatchDeploy,
57
65
  doctor,
58
66
  explain: runEngineTask('explain'),
@@ -64,11 +72,14 @@ const handlers = {
64
72
  jobs,
65
73
  login,
66
74
  logout,
75
+ lsp: dispatchLsp,
76
+ patch: dispatchPatch,
67
77
  plan: runEngineTask('plan'),
68
78
  'plan-review': dispatchPlanReview,
69
79
  privacy: dispatchPrivacy,
70
80
  review,
71
81
  resume,
82
+ roster: dispatchRoster,
72
83
  sessions,
73
84
  skills: dispatchSkills,
74
85
  sync,
@@ -76,6 +87,7 @@ const handlers = {
76
87
  version,
77
88
  web: dispatchWeb,
78
89
  whoami,
90
+ worktree: dispatchWorktree,
79
91
  };
80
92
  /**
81
93
  * α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
@@ -246,6 +258,59 @@ async function dispatchPrivacy(args, flags, _session) {
246
258
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
247
259
  });
248
260
  }
261
+ /**
262
+ * `pugi roster` - α7.5 Phase 1.
263
+ *
264
+ * List the live Tier 1 personas with display name, role, and routing
265
+ * tag. Walks the remote /api/pugi/sessions/roster endpoint when a
266
+ * credential is available; falls back to the local @pugi/personas
267
+ * roster when offline so the operator can still see who is on the team.
268
+ */
269
+ async function dispatchRoster(_args, flags, _session) {
270
+ const credential = resolveActiveCredential();
271
+ const config = credential
272
+ ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
273
+ : null;
274
+ const { rows, warning } = await resolveRoster(config);
275
+ const payload = {
276
+ ok: true,
277
+ personas: rows,
278
+ warning,
279
+ };
280
+ const text = (warning ? `# warning: ${warning}\n\n` : '') +
281
+ renderRosterTable(rows);
282
+ writeOutput(flags, payload, text);
283
+ }
284
+ /**
285
+ * `pugi delegate <slug> "<brief>"` - α7.5 Phase 1.
286
+ *
287
+ * Open a fresh REPL session and POST the brief to one Tier 1 persona,
288
+ * bypassing Mira's coordinator pass. Non-interactive: the CLI prints
289
+ * the dispatch id on success and exits; the operator (or a script) can
290
+ * subscribe to the session stream separately if they want the live
291
+ * lifecycle. Interactive operators use `/delegate` from inside the REPL
292
+ * instead so the dispatch lifecycle surfaces inline.
293
+ */
294
+ async function dispatchDelegate(args, flags, _session) {
295
+ await runDelegateCommand(args, {
296
+ workspaceCwd: process.cwd(),
297
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
298
+ resolveConfig: () => {
299
+ const credential = resolveActiveCredential();
300
+ if (!credential)
301
+ return null;
302
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
303
+ },
304
+ fetchRoster: fetchPersonaRoster,
305
+ submitDelegate,
306
+ openSession: async (config, workspaceCwd) => {
307
+ const result = await openPugiSession(config, { workspaceCwd });
308
+ if (result.status === 'ok')
309
+ return { sessionId: result.response.sessionId };
310
+ return { error: `${result.status}: ${result.message}` };
311
+ },
312
+ });
313
+ }
249
314
  async function dispatchUndo(args, flags, session) {
250
315
  await runUndoCommand(args, {
251
316
  workspaceRoot: process.cwd(),
@@ -314,6 +379,59 @@ async function dispatchWeb(args, flags, _session) {
314
379
  }
315
380
  writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
316
381
  }
382
+ /**
383
+ * α7.7: `pugi lsp <op> <file> [args]` — direct LSP queries. Delegated
384
+ * to the standalone runner in `./commands/lsp.ts` so the giant cli.ts
385
+ * dispatch table stays narrow. The runner spawns + tears down the LSP
386
+ * server per invocation (no daemon yet — that ships in α7.7b).
387
+ */
388
+ async function dispatchLsp(args, flags, _session) {
389
+ const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
390
+ console.log(result.text);
391
+ if (result.exitCode !== 0)
392
+ process.exitCode = result.exitCode;
393
+ }
394
+ /**
395
+ * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
396
+ * Routes through the same security gate as the Layer A/B/C applicators
397
+ * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
398
+ * security taxonomy so CI loops can alert on hostile patches without
399
+ * confusing them with operator typos.
400
+ *
401
+ * R1 fix (2026-05-26, PR #413 r1): pass `flags.dryRun` through so the
402
+ * top-level parser's consumption of `--dry-run` does not silently
403
+ * disable dry-run mode on `pugi patch --dry-run < diff.patch`.
404
+ */
405
+ async function dispatchPatch(args, flags, _session) {
406
+ const result = await runPatchCommand(args, {
407
+ cwd: process.cwd(),
408
+ json: flags.json,
409
+ dryRun: flags.dryRun,
410
+ });
411
+ console.log(result.text);
412
+ if (result.exitCode !== 0)
413
+ process.exitCode = result.exitCode;
414
+ }
415
+ /**
416
+ * α7.7: `pugi worktree <op>` — manual scratch worktree management.
417
+ * The `pugi build` and `pugi review --consensus` paths use the same
418
+ * primitives internally (`createWorktree` / `promoteWorktree`); this
419
+ * surface is the operator escape hatch for debug + experiment flows.
420
+ *
421
+ * R1 fix (2026-05-26, PR #413 r1): forward `flags.dryRun` so the
422
+ * top-level parser's consumption of `--dry-run` does not silently
423
+ * disable dry-run mode on `pugi worktree promote --dry-run <path>`.
424
+ */
425
+ async function dispatchWorktree(args, flags, _session) {
426
+ const result = await runWorktreeCommand(args, {
427
+ cwd: process.cwd(),
428
+ json: flags.json,
429
+ dryRun: flags.dryRun,
430
+ });
431
+ console.log(result.text);
432
+ if (result.exitCode !== 0)
433
+ process.exitCode = result.exitCode;
434
+ }
317
435
  export async function runCli(argv) {
318
436
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
319
437
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
@@ -399,11 +517,13 @@ function parseArgs(argv) {
399
517
  // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
400
518
  // accidental `Verb(noun)` shapes producing stuck `running` rows.
401
519
  // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
402
- // for development/testing. Will flip к default ON when backend
520
+ // for development/testing. Will flip to default ON when backend
403
521
  // emits real tool events (filed as α6.13.X follow-up).
404
522
  noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
405
523
  ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
406
524
  : true,
525
+ noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
526
+ decompose: false,
407
527
  };
408
528
  const args = [];
409
529
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -436,7 +556,7 @@ function parseArgs(argv) {
436
556
  else if (arg === '--consensus') {
437
557
  // α6.7: customer-facing 3-model consensus review. Routes through
438
558
  // the SSE-based runtime gate rather than the legacy artifact
439
- // writer. The triple flag stays unset так the existing
559
+ // writer. The triple flag stays unset so the existing
440
560
  // performRemoteTripleReview path is never accidentally entered.
441
561
  flags.consensus = true;
442
562
  }
@@ -459,10 +579,21 @@ function parseArgs(argv) {
459
579
  flags.noToolStream = true;
460
580
  }
461
581
  else if (arg === '--tool-stream') {
462
- // Opt-in для α6.12 dev/testing — backend tool events not live yet,
463
- // pane shows синтесайз heuristic OR empty placeholder
582
+ // Opt-in for α6.12 dev/testing — backend tool events not live yet,
583
+ // pane shows synthesized heuristic OR empty placeholder
464
584
  flags.noToolStream = false;
465
585
  }
586
+ else if (arg === '--no-defaults') {
587
+ // Init-only flag: skip the bundled default-skills install. Parsed
588
+ // at the global level for consistency with --no-splash / --no-tool-stream.
589
+ flags.noDefaults = true;
590
+ }
591
+ else if (arg === '--decompose') {
592
+ // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
593
+ // it. Parsed globally for symmetry with the rest of the flag
594
+ // grammar; `runEngineTask('plan')` is the single consumer.
595
+ flags.decompose = true;
596
+ }
466
597
  else if (arg.startsWith('--privacy=')) {
467
598
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
468
599
  }
@@ -478,8 +609,20 @@ function parseArgs(argv) {
478
609
  }
479
610
  }
480
611
  const isBareInvocation = args.length === 0;
612
+ const command = args.shift() ?? 'help';
613
+ // Sprint α6.X CEO dogfood 2026-05-26 (P0 hot-fix): trailing `--help`
614
+ // / `-h` on ANY sub-command must route to the help printer rather
615
+ // than dispatching the real engine. Before this guard `pugi build
616
+ // --help` burned 86k tokens running the actual build loop because
617
+ // the dispatcher saw `--help` as an opaque arg and forwarded it
618
+ // through to the engine. Re-routing here means `pugi <cmd> --help`
619
+ // becomes `pugi help <cmd>` deterministically across the entire
620
+ // command tree.
621
+ if (args.includes('--help') || args.includes('-h')) {
622
+ return { command: 'help', args: [command], flags, isBareInvocation: false };
623
+ }
481
624
  return {
482
- command: args.shift() ?? 'help',
625
+ command,
483
626
  args,
484
627
  flags,
485
628
  isBareInvocation,
@@ -527,6 +670,15 @@ async function help(_args, flags, _session) {
527
670
  ' pugi ask "<question>" Surface a yes/no question modal locally.',
528
671
  ' pugi plan-review <task> Generate + present a plan-review modal.',
529
672
  '',
673
+ 'Persona dispatch (α7.5):',
674
+ ' pugi roster List the live Tier 1 personas + roles.',
675
+ ' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
676
+ '',
677
+ 'Plan decomposition (α6.8):',
678
+ ' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
679
+ ' Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md',
680
+ ' plus manifest.md with the dependency DAG.',
681
+ '',
530
682
  'Deploy:',
531
683
  ' pugi deploy --target vercel <vercelProject> --project <id>',
532
684
  ' Trigger a Vercel deployment from the bound Git source.',
@@ -549,6 +701,8 @@ async function help(_args, flags, _session) {
549
701
  ' PUGI_SKIP_SPLASH=1.',
550
702
  ' --no-tool-stream Hide the live tool stream pane (α6.12).',
551
703
  ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
704
+ ' --no-defaults Skip bundled default-skills install on',
705
+ ' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
552
706
  '',
553
707
  PUGI_TAGLINE,
554
708
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -618,6 +772,7 @@ async function init(_args, flags, _session) {
618
772
  ensureDir(pugiDir, created, skipped);
619
773
  ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
620
774
  ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
775
+ ensureDir(resolve(pugiDir, 'skills'), created, skipped);
621
776
  writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
622
777
  schema: 1,
623
778
  workflow: {
@@ -687,17 +842,50 @@ async function init(_args, flags, _session) {
687
842
  // Ensure `.pugi/` is git-ignored so users do not accidentally commit
688
843
  // local audit logs, artifacts, or triple-review request payloads.
689
844
  ensurePugiGitIgnore(cwd, created, skipped);
845
+ // Bundled default skills (brand-voice, endpoint-probe, readme-sync).
846
+ // Skipped when --no-defaults is passed OR when PUGI_INIT_NO_DEFAULTS=1.
847
+ // Idempotent: a skill whose target directory already exists is left
848
+ // alone so re-running `pugi init` after the operator customised one of
849
+ // the defaults does not clobber their edits.
850
+ let defaultSkills = [];
851
+ if (!flags.noDefaults) {
852
+ try {
853
+ defaultSkills = await installDefaultSkills({
854
+ workspaceRoot: cwd,
855
+ log: (line) => process.stderr.write(line),
856
+ });
857
+ }
858
+ catch (error) {
859
+ // Default-skills install is a convenience layer. A failure here
860
+ // (bad sha256 hashing, permission error on .pugi/skills/) must not
861
+ // leave `pugi init` in a half-state where settings.json exists but
862
+ // the operator sees an unexplained crash. Log the error to stderr
863
+ // and continue — the operator can still install skills manually.
864
+ const message = error instanceof Error ? error.message : String(error);
865
+ process.stderr.write(`[pugi init] default-skills install failed: ${message}\n`);
866
+ }
867
+ }
690
868
  const payload = {
691
869
  status: 'initialized',
692
870
  root: cwd,
693
871
  created,
694
872
  skipped,
873
+ defaultSkills,
695
874
  };
875
+ const defaultSkillLines = flags.noDefaults
876
+ ? ['Default skills: skipped (--no-defaults)']
877
+ : defaultSkills.length === 0
878
+ ? ['Default skills: none installed']
879
+ : [
880
+ 'Default skills:',
881
+ ...defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
882
+ ];
696
883
  writeOutput(flags, payload, [
697
884
  'Pugi initialized',
698
885
  `Root: ${cwd}`,
699
886
  created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
700
887
  skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
888
+ ...defaultSkillLines,
701
889
  ].join('\n'));
702
890
  }
703
891
  async function idea(args, flags, session) {
@@ -2042,6 +2230,26 @@ function runEngineTask(kind) {
2042
2230
  const config = credential
2043
2231
  ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
2044
2232
  : envConfig;
2233
+ // α6.8 EXTEND PR1 v2: `--decompose` gating runs BEFORE the offline
2234
+ // fallback. Two reasons:
2235
+ // 1. The flag is plan-only — surfacing the rejection for
2236
+ // `pugi build --decompose` before we drop into `offlineBuild`
2237
+ // means the operator gets a deterministic error instead of a
2238
+ // silent no-op stub.
2239
+ // 2. The decompose post-processor depends on the engine's final
2240
+ // text. The offline plan stub does not invoke the engine, so
2241
+ // `pugi plan --decompose --offline` would silently skip the
2242
+ // decomposition step. Refusing the combination up front is the
2243
+ // cheapest way to keep the contract honest.
2244
+ if (flags.decompose && kind !== 'plan') {
2245
+ throw new Error(`--decompose is only valid for \`pugi plan\` (got \`pugi ${label}\`)`);
2246
+ }
2247
+ if (flags.decompose && flags.offline) {
2248
+ throw new Error('--decompose requires the engine — drop --offline (decomposition needs the model to emit a fenced JSON block)');
2249
+ }
2250
+ if (flags.decompose && !config) {
2251
+ throw new Error('--decompose requires the engine — run `pugi login` or set PUGI_API_KEY (decomposition needs the model to emit a fenced JSON block)');
2252
+ }
2045
2253
  // Offline fallback: preserves the local-first invariant. `plan` /
2046
2254
  // `build` / `explain` drop back to their pre-Sprint-2 stub
2047
2255
  // behaviour so an operator without an API key (or with --offline)
@@ -2094,6 +2302,17 @@ function runEngineTask(kind) {
2094
2302
  throw new Error(`pugi ${label} requires a prompt`);
2095
2303
  }
2096
2304
  }
2305
+ // α6.8 EXTEND PR1: when `--decompose` is set, augment the user
2306
+ // prompt with the decomposition-request suffix BEFORE the adapter
2307
+ // run. The system prompt for `plan` already constrains the model
2308
+ // to read-only tools + a plan deliverable; the suffix layers the
2309
+ // JSON-emission contract on top so the post-run parser can lift
2310
+ // the structured payload out of the final answer. The plan-only /
2311
+ // engine-required gates fired before the offline fallback above,
2312
+ // so by here we know we are on the engine path with a plan task.
2313
+ if (flags.decompose && kind === 'plan') {
2314
+ prompt = `${prompt}\n${DECOMPOSE_PROMPT_SUFFIX}`;
2315
+ }
2097
2316
  // Narrow `config` for the type checker — the offline branches above
2098
2317
  // return whenever `config` is null, so by this point it must be set.
2099
2318
  if (!config) {
@@ -2203,6 +2422,41 @@ function runEngineTask(kind) {
2203
2422
  statusEvents,
2204
2423
  });
2205
2424
  }
2425
+ // α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
2426
+ // the parse on a `done` plan (a blocked/failed plan is already
2427
+ // captured in plan.md with its reason; no JSON to extract). The
2428
+ // model's final answer arrives via `result.summary` — on success
2429
+ // the adapter prefix is empty so it is the raw final text. We
2430
+ // strip any leading/trailing whitespace then run the parser
2431
+ // against the contents. On parse failure we surface a non-fatal
2432
+ // structured error in the payload — the operator still gets the
2433
+ // plan.md artifact and can re-run.
2434
+ //
2435
+ // TODO(α7.x): `result.summary` is currently a string contract that
2436
+ // doubles as both "human-readable headline" and "raw final model
2437
+ // text". Split into `{ summary, finalText }` on the adapter so the
2438
+ // parser does not have to assume the prefix is empty. Tracked in
2439
+ // PR #423 v2 retro (P2.6, Claude review).
2440
+ let decomposeArtifact = null;
2441
+ let decomposeError = null;
2442
+ if (flags.decompose && kind === 'plan' && result.status === 'done') {
2443
+ const parsed = parseDecompositionFromText(result.summary);
2444
+ if (parsed.ok) {
2445
+ decomposeArtifact = writeDecomposition({
2446
+ root,
2447
+ sessionId: session.id,
2448
+ // Persist the OPERATOR's original prompt, not the prompt+suffix
2449
+ // we sent to the engine. The suffix is plumbing; the manifest
2450
+ // header reads naturally only with the operator text.
2451
+ prompt: args.join(' ').trim() || prompt,
2452
+ decomposition: parsed.decomposition,
2453
+ rationale: parsed.rationale,
2454
+ });
2455
+ }
2456
+ else {
2457
+ decomposeError = { reason: parsed.reason, detail: parsed.detail };
2458
+ }
2459
+ }
2206
2460
  // Pull the headline metrics out of `eventRefs` so the summary and
2207
2461
  // JSON envelope match without re-parsing strings in two places.
2208
2462
  const metrics = parseEventRefs(result.eventRefs);
@@ -2262,6 +2516,20 @@ function runEngineTask(kind) {
2262
2516
  reason: dr.reason,
2263
2517
  detail: dr.detail,
2264
2518
  })),
2519
+ // α6.8 EXTEND PR1: decompose artifacts (only present when
2520
+ // `--decompose` was passed AND the model emitted a parseable
2521
+ // JSON block). The `error` shape lands when the model returned
2522
+ // unparseable output; the operator can re-run with a tighter
2523
+ // prompt without losing the plain plan.md artifact.
2524
+ decompose: decomposeArtifact !== null
2525
+ ? {
2526
+ manifest: relative(root, decomposeArtifact.manifestPath),
2527
+ planDir: relative(root, decomposeArtifact.planDir),
2528
+ splits: decomposeArtifact.splitPaths,
2529
+ }
2530
+ : decomposeError !== null
2531
+ ? { error: decomposeError }
2532
+ : undefined,
2265
2533
  // The full event stream is useful for cabinet UI replay. We surface
2266
2534
  // it in JSON mode only — text mode operators want the summary, not
2267
2535
  // 30 turn-level lines.
@@ -2271,6 +2539,13 @@ function runEngineTask(kind) {
2271
2539
  if (kind === 'plan' && planArtifact) {
2272
2540
  textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
2273
2541
  }
2542
+ if (decomposeArtifact !== null) {
2543
+ textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
2544
+ textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
2545
+ }
2546
+ else if (decomposeError !== null) {
2547
+ textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
2548
+ }
2274
2549
  textLines.push(`Pugi ${label}: ${result.status}`);
2275
2550
  textLines.push(`Summary: ${result.summary}`);
2276
2551
  if (result.filesChanged.length > 0) {