@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.9

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,14 +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';
31
- // α7.7 lsp/patch/worktree command modules ship behind the α7.7
32
- // implementer PR (in-flight). The dispatchers below print a clean
33
- // "deferred" message so `pugi --help` still lists them without the
34
- // REPL crashing at module load. When α7.7 lands, restore the real
35
- // imports + delete the inline stubs.
34
+ import { runLspCommand } from './commands/lsp.js';
35
+ import { runPatchCommand } from './commands/patch.js';
36
+ import { runWorktreeCommand } from './commands/worktree.js';
36
37
  import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
37
38
  import { runReviewConsensus } from './commands/review-consensus.js';
39
+ import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
38
40
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
39
41
  import { slugForCwd } from '../core/repl/history.js';
40
42
  import { dispatchEdit, } from '../core/edits/index.js';
@@ -49,7 +51,7 @@ import { dispatchEdit, } from '../core/edits/index.js';
49
51
  * packages/pugi-sdk/package.json); the publish workflow validates the
50
52
  * three are in lockstep.
51
53
  */
52
- const PUGI_CLI_VERSION = "0.1.0-beta.8";
54
+ const PUGI_CLI_VERSION = "0.1.0-beta.9";
53
55
  const handlers = {
54
56
  accounts,
55
57
  agents: dispatchAgents,
@@ -58,6 +60,7 @@ const handlers = {
58
60
  budget: dispatchBudget,
59
61
  code: runEngineTask('code'),
60
62
  config: dispatchConfig,
63
+ delegate: dispatchDelegate,
61
64
  deploy: dispatchDeploy,
62
65
  doctor,
63
66
  explain: runEngineTask('explain'),
@@ -76,6 +79,7 @@ const handlers = {
76
79
  privacy: dispatchPrivacy,
77
80
  review,
78
81
  resume,
82
+ roster: dispatchRoster,
79
83
  sessions,
80
84
  skills: dispatchSkills,
81
85
  sync,
@@ -254,6 +258,59 @@ async function dispatchPrivacy(args, flags, _session) {
254
258
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
255
259
  });
256
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
+ }
257
314
  async function dispatchUndo(args, flags, session) {
258
315
  await runUndoCommand(args, {
259
316
  workspaceRoot: process.cwd(),
@@ -328,13 +385,11 @@ async function dispatchWeb(args, flags, _session) {
328
385
  * dispatch table stays narrow. The runner spawns + tears down the LSP
329
386
  * server per invocation (no daemon yet — that ships in α7.7b).
330
387
  */
331
- async function dispatchLsp(_args, flags, _session) {
332
- const msg = 'pugi lsp ships in alpha 7.7 (in-flight). Run `pugi --help` for current surface.';
333
- if (flags.json)
334
- console.log(JSON.stringify({ ok: false, code: 'deferred', message: msg }));
335
- else
336
- console.log(msg);
337
- process.exitCode = 6;
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;
338
393
  }
339
394
  /**
340
395
  * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
@@ -342,28 +397,40 @@ async function dispatchLsp(_args, flags, _session) {
342
397
  * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
343
398
  * security taxonomy so CI loops can alert on hostile patches without
344
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`.
345
404
  */
346
- async function dispatchPatch(_args, flags, _session) {
347
- const msg = 'pugi patch ships in alpha 7.7 (in-flight). Run `pugi --help` for current surface.';
348
- if (flags.json)
349
- console.log(JSON.stringify({ ok: false, code: 'deferred', message: msg }));
350
- else
351
- console.log(msg);
352
- process.exitCode = 6;
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;
353
414
  }
354
415
  /**
355
416
  * α7.7: `pugi worktree <op>` — manual scratch worktree management.
356
417
  * The `pugi build` and `pugi review --consensus` paths use the same
357
418
  * primitives internally (`createWorktree` / `promoteWorktree`); this
358
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>`.
359
424
  */
360
- async function dispatchWorktree(_args, flags, _session) {
361
- const msg = 'pugi worktree ships in alpha 7.7 (in-flight). Run `pugi --help` for current surface.';
362
- if (flags.json)
363
- console.log(JSON.stringify({ ok: false, code: 'deferred', message: msg }));
364
- else
365
- console.log(msg);
366
- process.exitCode = 6;
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;
367
434
  }
368
435
  export async function runCli(argv) {
369
436
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
@@ -450,11 +517,13 @@ function parseArgs(argv) {
450
517
  // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
451
518
  // accidental `Verb(noun)` shapes producing stuck `running` rows.
452
519
  // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
453
- // for development/testing. Will flip к default ON when backend
520
+ // for development/testing. Will flip to default ON when backend
454
521
  // emits real tool events (filed as α6.13.X follow-up).
455
522
  noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
456
523
  ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
457
524
  : true,
525
+ noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
526
+ decompose: false,
458
527
  };
459
528
  const args = [];
460
529
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -487,7 +556,7 @@ function parseArgs(argv) {
487
556
  else if (arg === '--consensus') {
488
557
  // α6.7: customer-facing 3-model consensus review. Routes through
489
558
  // the SSE-based runtime gate rather than the legacy artifact
490
- // writer. The triple flag stays unset так the existing
559
+ // writer. The triple flag stays unset so the existing
491
560
  // performRemoteTripleReview path is never accidentally entered.
492
561
  flags.consensus = true;
493
562
  }
@@ -510,10 +579,21 @@ function parseArgs(argv) {
510
579
  flags.noToolStream = true;
511
580
  }
512
581
  else if (arg === '--tool-stream') {
513
- // Opt-in для α6.12 dev/testing — backend tool events not live yet,
514
- // 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
515
584
  flags.noToolStream = false;
516
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
+ }
517
597
  else if (arg.startsWith('--privacy=')) {
518
598
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
519
599
  }
@@ -590,6 +670,15 @@ async function help(_args, flags, _session) {
590
670
  ' pugi ask "<question>" Surface a yes/no question modal locally.',
591
671
  ' pugi plan-review <task> Generate + present a plan-review modal.',
592
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
+ '',
593
682
  'Deploy:',
594
683
  ' pugi deploy --target vercel <vercelProject> --project <id>',
595
684
  ' Trigger a Vercel deployment from the bound Git source.',
@@ -612,6 +701,8 @@ async function help(_args, flags, _session) {
612
701
  ' PUGI_SKIP_SPLASH=1.',
613
702
  ' --no-tool-stream Hide the live tool stream pane (α6.12).',
614
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.',
615
706
  '',
616
707
  PUGI_TAGLINE,
617
708
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -681,6 +772,7 @@ async function init(_args, flags, _session) {
681
772
  ensureDir(pugiDir, created, skipped);
682
773
  ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
683
774
  ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
775
+ ensureDir(resolve(pugiDir, 'skills'), created, skipped);
684
776
  writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
685
777
  schema: 1,
686
778
  workflow: {
@@ -750,17 +842,50 @@ async function init(_args, flags, _session) {
750
842
  // Ensure `.pugi/` is git-ignored so users do not accidentally commit
751
843
  // local audit logs, artifacts, or triple-review request payloads.
752
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
+ }
753
868
  const payload = {
754
869
  status: 'initialized',
755
870
  root: cwd,
756
871
  created,
757
872
  skipped,
873
+ defaultSkills,
758
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
+ ];
759
883
  writeOutput(flags, payload, [
760
884
  'Pugi initialized',
761
885
  `Root: ${cwd}`,
762
886
  created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
763
887
  skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
888
+ ...defaultSkillLines,
764
889
  ].join('\n'));
765
890
  }
766
891
  async function idea(args, flags, session) {
@@ -2105,6 +2230,26 @@ function runEngineTask(kind) {
2105
2230
  const config = credential
2106
2231
  ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
2107
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
+ }
2108
2253
  // Offline fallback: preserves the local-first invariant. `plan` /
2109
2254
  // `build` / `explain` drop back to their pre-Sprint-2 stub
2110
2255
  // behaviour so an operator without an API key (or with --offline)
@@ -2157,6 +2302,17 @@ function runEngineTask(kind) {
2157
2302
  throw new Error(`pugi ${label} requires a prompt`);
2158
2303
  }
2159
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
+ }
2160
2316
  // Narrow `config` for the type checker — the offline branches above
2161
2317
  // return whenever `config` is null, so by this point it must be set.
2162
2318
  if (!config) {
@@ -2266,6 +2422,41 @@ function runEngineTask(kind) {
2266
2422
  statusEvents,
2267
2423
  });
2268
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
+ }
2269
2460
  // Pull the headline metrics out of `eventRefs` so the summary and
2270
2461
  // JSON envelope match without re-parsing strings in two places.
2271
2462
  const metrics = parseEventRefs(result.eventRefs);
@@ -2325,6 +2516,20 @@ function runEngineTask(kind) {
2325
2516
  reason: dr.reason,
2326
2517
  detail: dr.detail,
2327
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,
2328
2533
  // The full event stream is useful for cabinet UI replay. We surface
2329
2534
  // it in JSON mode only — text mode operators want the summary, not
2330
2535
  // 30 turn-level lines.
@@ -2334,6 +2539,13 @@ function runEngineTask(kind) {
2334
2539
  if (kind === 'plan' && planArtifact) {
2335
2540
  textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
2336
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
+ }
2337
2549
  textLines.push(`Pugi ${label}: ${result.status}`);
2338
2550
  textLines.push(`Summary: ${result.summary}`);
2339
2551
  if (result.filesChanged.length > 0) {