@selesai/code 0.5.16 → 0.5.18

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.
@@ -88,8 +88,6 @@ function disableSubagentOutput(input: Record<string, unknown>): void {
88
88
  }
89
89
 
90
90
  export interface WorkflowAdapterOptions {
91
- toolNames: { start: string; resume: string; end: string };
92
- toolLabels: { start: string; resume: string; end: string };
93
91
  commandName: string;
94
92
  commandDescription: string;
95
93
  }
@@ -108,14 +106,20 @@ interface WorkflowController {
108
106
  }
109
107
 
110
108
  const WORKFLOW_ARTIFACT_TOOL = "write_workflow_artifact";
109
+ const WORKFLOW_CONTROL_MESSAGE = "selesai-workflow-control";
111
110
  // ponytail: shared across all module copies so prototype + quick registration
112
111
  // can race. Module-level state alone is not enough when the loader runs each
113
112
  // extension in its own module record.
114
113
  const WORKFLOW_GLOBAL = Symbol.for("selesai.workflow.registry.v1");
115
- type WorkflowRegistry = { controllers: WorkflowController[]; writerRegisteredFor: WeakSet<object> };
114
+ type WorkflowRegistry = {
115
+ controllers: WorkflowController[];
116
+ writerRegisteredFor: WeakSet<object>;
117
+ toolsRegisteredFor: WeakSet<object>;
118
+ };
116
119
  const registry: WorkflowRegistry = ((globalThis as any)[WORKFLOW_GLOBAL] ??= {
117
120
  controllers: [],
118
121
  writerRegisteredFor: new WeakSet(),
122
+ toolsRegisteredFor: new WeakSet(),
119
123
  });
120
124
 
121
125
  // ponytail: tests call this between cases to drop pi references and reset
@@ -371,21 +375,43 @@ function applyFooter(ctx: ExtensionContext, footer: FooterState): void {
371
375
  ctx.ui.setStatus(footer.statusKey, footerText(footer, ctx));
372
376
  }
373
377
 
374
- function continueAgent(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
375
- // ponytail: agent is mid-turn (streaming) when tool_result fires, so a bare
376
- // sendUserMessage() throws "Agent is already processing". deliverAs:"followUp"
377
- // is the framework-native continuation. In idle state (e.g. session resume)
378
- // it sends a direct turn.
379
- if (ctx.isIdle()) {
380
- pi.sendUserMessage(prompt);
381
- } else {
382
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
378
+ function continueAgent(pi: ExtensionAPI, _ctx: ExtensionContext, prompt: string): void {
379
+ // Engine prompts are hidden custom messages. steer delivers after the current
380
+ // tool batch; triggerTurn starts the continuation when the agent is idle.
381
+ pi.sendMessage(
382
+ { customType: WORKFLOW_CONTROL_MESSAGE, content: prompt, display: false },
383
+ { triggerTurn: true, deliverAs: "steer" },
384
+ );
385
+ }
386
+
387
+ function isSoleToolCall(ctx: ExtensionContext, toolCallId: unknown): boolean {
388
+ if (typeof toolCallId !== "string") return false;
389
+ try {
390
+ const manager = (ctx as any)?.sessionManager;
391
+ if (!manager || typeof manager.getBranch !== "function") return false;
392
+ const entries = manager.getBranch();
393
+ if (!Array.isArray(entries)) return false;
394
+ const assistant = [...entries].reverse().find((entry: any) =>
395
+ entry?.type === "message" && entry.message?.role === "assistant",
396
+ )?.message;
397
+ if (!assistant || !Array.isArray(assistant.content)) return false;
398
+ const calls = assistant.content.filter((part: any) => part?.type === "toolCall");
399
+ return calls.length === 1 && calls[0]?.id === toolCallId;
400
+ } catch {
401
+ return false;
383
402
  }
384
403
  }
385
404
 
386
- // ponytail: apply side effects for effects that carry entry/footer/prompt.
387
- // Terminal/blocked/idle/noOp/alreadyActive carry none — the adapter's call
388
- // site formats the tool result from the effect's domain fields + config.
405
+ function transitionBatchBlock(ctx: ExtensionContext, toolCallId: unknown): { block: true; reason: string } | undefined {
406
+ if (isSoleToolCall(ctx, toolCallId)) return undefined;
407
+ return {
408
+ block: true,
409
+ reason: "Workflow transition tools must be called alone. Retry this call without any other tool calls in the same assistant turn.",
410
+ };
411
+ }
412
+
413
+ // Apply side effects for effects that carry entry/footer/prompt. Engine prompts
414
+ // are hidden custom messages; tool results remain concise and user-facing.
389
415
  function applyEffect(
390
416
  pi: ExtensionAPI,
391
417
  ctx: ExtensionContext,
@@ -461,7 +487,7 @@ function commandHelp(config: WorkflowConfig, options: WorkflowAdapterOptions): s
461
487
  `${command} resume — list resumable runs.`,
462
488
  `${command} resume <run-id|artifact-dir|workflow.json> — explicitly resume one.`,
463
489
  `Phases advance when their artifacts are written. Runs never auto-resume after reload.`,
464
- `Only one run may be attached; ${options.toolNames.end} explicitly completes a terminal-ready run.`,
490
+ "Only one run may be attached; end_workflow explicitly completes a terminal-ready run.",
465
491
  ].join("\n");
466
492
  }
467
493
 
@@ -506,11 +532,10 @@ function registerSharedArtifactWriter(pi: ExtensionAPI): void {
506
532
  details: { mode: controller.config.mode, phase: snap.phase, path, persistenceError: true },
507
533
  };
508
534
  }
509
- // Most workflows pause at a user-controlled artifact boundary. Task's
510
- // plan → reuse → handoff → loop transitions queue the next phase prompt
511
- // immediately when continueAfterArtifact is true.
512
- const queueNextPhase = controller.config.continueAfterArtifact === true && eff.kind === "advanced";
513
- applyControllerEffect(controller, ctx, eff, { queuePrompt: queueNextPhase });
535
+ // Every valid artifact boundary is an engine transition. Queue exactly
536
+ // one hidden continuation and terminate this assistant turn.
537
+ const transition = eff.kind === "advanced" || eff.kind === "terminalReady" || eff.kind === "terminalNeedsArtifacts";
538
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: transition });
514
539
  if (eff.kind === "blocked" && eff.reason) {
515
540
  return {
516
541
  content: [{ type: "text", text: `Wrote ${path}, but it is not approved: ${eff.reason}. Re-write it via write_workflow_artifact to add the required marker.` }],
@@ -520,14 +545,10 @@ function registerSharedArtifactWriter(pi: ExtensionAPI): void {
520
545
  const advanced = eff.kind === "advanced";
521
546
  return {
522
547
  content: [{ type: "text", text: advanced
523
- ? queueNextPhase
524
- ? `Wrote ${path}. Phase advanced to ${eff.phase}; the next workflow phase is queued.`
525
- : `Wrote ${path}. Phase advanced to ${eff.phase}; wait for the user to continue the workflow.`
548
+ ? `Wrote ${path}. Phase advanced to ${eff.phase}; the next workflow phase is queued.`
526
549
  : `Wrote ${path}.` }],
527
550
  details: { mode: controller.config.mode, phase: snap.phase, path, file, advanced },
528
- // Stop only at user-controlled boundaries. Task deliberately queues
529
- // its loop prompt, so terminating here would discard that continuation.
530
- terminate: advanced && !queueNextPhase,
551
+ terminate: transition,
531
552
  };
532
553
  },
533
554
  renderResult(result, _options, theme) {
@@ -538,6 +559,156 @@ function registerSharedArtifactWriter(pi: ExtensionAPI): void {
538
559
  } satisfies ToolDefinition);
539
560
  }
540
561
 
562
+ function controllerForMode(pi: ExtensionAPI, mode: string): WorkflowController | undefined {
563
+ return registry.controllers.find((controller) =>
564
+ controller.pi === pi && controller.config.mode === mode && isRegisteredController(controller),
565
+ );
566
+ }
567
+
568
+ function unknownMode(mode: string) {
569
+ return {
570
+ content: [{ type: "text" as const, text: `Unknown workflow mode: ${mode}. Use an installed mode (prototype, quick, or task).` }],
571
+ details: { rejected: true, mode },
572
+ };
573
+ }
574
+
575
+ async function startController(controller: WorkflowController, goal: string, ctx: ExtensionContext) {
576
+ const { pi, config, sm, deps } = controller;
577
+ const other = activeControllersFor(pi).find((candidate) => candidate.sm !== sm);
578
+ if (other) {
579
+ return {
580
+ content: [{ type: "text" as const, text: `A ${other.config.mode} workflow is already active (phase: ${other.sm.snapshot.phase}). Close it before starting ${config.mode}.` }],
581
+ details: { phase: other.sm.snapshot.phase, alreadyActive: true },
582
+ };
583
+ }
584
+ const before = checkpoint(controller);
585
+ const eff = await sm.start(goal, deps);
586
+ if (eff.kind === "started") {
587
+ controller.run = {
588
+ version: 1,
589
+ id: basename(sm.snapshot.artifactDir),
590
+ mode: config.mode,
591
+ status: "active",
592
+ goal: sm.snapshot.userPrompt,
593
+ artifactDir: sm.snapshot.artifactDir,
594
+ phase: sm.snapshot.phase,
595
+ autoArmed: sm.snapshot.autoArmed,
596
+ createdAt: new Date().toISOString(),
597
+ updatedAt: new Date().toISOString(),
598
+ };
599
+ try {
600
+ await persistAfter(controller, before);
601
+ } catch (error) {
602
+ return {
603
+ content: [{ type: "text" as const, text: `Could not start durable workflow: ${error instanceof Error ? error.message : String(error)}` }],
604
+ details: { persistenceError: true },
605
+ };
606
+ }
607
+ controller.seenToolCallIds.clear();
608
+ controller.lastBlockedKey = undefined;
609
+ }
610
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
611
+ if (eff.kind === "alreadyActive") {
612
+ return {
613
+ content: [{ type: "text" as const, text: `A ${config.mode} workflow is already active (phase: ${eff.phase}). Do not call start_workflow again. Phases auto-advance as artifacts land; call end_workflow only from the final phase.` }],
614
+ details: { phase: eff.phase, alreadyActive: true },
615
+ };
616
+ }
617
+ return {
618
+ content: [{ type: "text" as const, text: `${config.footerLabel} workflow started. ${eff.prompt}` }],
619
+ details: { mode: config.mode, phase: eff.phase },
620
+ };
621
+ }
622
+
623
+ async function endController(controller: WorkflowController, ctx: ExtensionContext) {
624
+ const { config, sm, deps } = controller;
625
+ const before = checkpoint(controller);
626
+ const eff = await sm.end(deps);
627
+ if (eff.kind === "closed") {
628
+ try {
629
+ await persistAfter(controller, before);
630
+ } catch (error) {
631
+ return {
632
+ content: [{ type: "text" as const, text: `Cannot end: workflow state could not be persisted: ${error instanceof Error ? error.message : String(error)}` }],
633
+ details: { persistenceError: true },
634
+ };
635
+ }
636
+ }
637
+ applyControllerEffect(controller, ctx, eff);
638
+ if (eff.kind === "closed") {
639
+ controller.seenToolCallIds.clear();
640
+ controller.lastBlockedKey = undefined;
641
+ return {
642
+ content: [{ type: "text" as const, text: `Workflow ended and closed. It can no longer be used. Artifacts saved at ${eff.artifactDir}.` }],
643
+ details: { closed: true, mode: config.mode, phase: eff.phase, artifactDir: eff.artifactDir },
644
+ terminate: true,
645
+ };
646
+ }
647
+ if (eff.kind === "endBlocked") {
648
+ if (eff.reason) {
649
+ return {
650
+ content: [{ type: "text" as const, text: `Cannot end: ${eff.missing} is incomplete: ${eff.reason}.` }],
651
+ details: { mode: config.mode, phase: eff.phase, blocked: eff.missing, reason: eff.reason },
652
+ };
653
+ }
654
+ const isArtifactFile = !eff.missing.includes(" ") && eff.missing.includes(".");
655
+ const hint = isArtifactFile
656
+ ? ` Write ${sm.snapshot.artifactDir}/${eff.missing} first.`
657
+ : ` Continue through the phases to ${config.phases[config.phases.length - 1]}.`;
658
+ return {
659
+ content: [{ type: "text" as const, text: `Cannot end: ${eff.missing}.${hint}` }],
660
+ details: { mode: config.mode, phase: eff.phase, blocked: eff.missing },
661
+ };
662
+ }
663
+ return {
664
+ content: [{ type: "text" as const, text: `No active ${config.mode} workflow to end.` }],
665
+ details: { active: false, mode: config.mode },
666
+ };
667
+ }
668
+
669
+ function registerSharedWorkflowTools(pi: ExtensionAPI): void {
670
+ if (registry.toolsRegisteredFor.has(pi)) return;
671
+ registry.toolsRegisteredFor.add(pi);
672
+ pi.registerTool({
673
+ name: "start_workflow",
674
+ label: "Start Workflow",
675
+ description: "Start a durable workflow. Select prototype, quick, or task; do not call while another workflow is active.",
676
+ parameters: Type.Object({
677
+ mode: Type.String({ description: "Workflow mode: prototype, quick, task, or another installed mode." }),
678
+ goal: Type.String(),
679
+ }),
680
+ async execute(_id, params, _signal, _onUpdate, ctx) {
681
+ const controller = controllerForMode(pi, params.mode);
682
+ return controller ? startController(controller, params.goal, ctx) : unknownMode(params.mode);
683
+ },
684
+ } satisfies ToolDefinition);
685
+ pi.registerTool({
686
+ name: "resume_workflow",
687
+ label: "Resume Workflow",
688
+ description: "Resume a durable workflow by mode and selected run id, artifact directory, or workflow.json path.",
689
+ parameters: Type.Object({
690
+ mode: Type.String({ description: "Workflow mode: prototype, quick, task, or another installed mode." }),
691
+ run: Type.String(),
692
+ }),
693
+ async execute(_id, params, _signal, _onUpdate, ctx) {
694
+ const controller = controllerForMode(pi, params.mode);
695
+ return controller ? resumeController(controller, ctx, params.run, "end_workflow") : unknownMode(params.mode);
696
+ },
697
+ } satisfies ToolDefinition);
698
+ pi.registerTool({
699
+ name: "end_workflow",
700
+ label: "End Workflow",
701
+ description: "Close a terminal-ready durable workflow. Select its mode.",
702
+ parameters: Type.Object({
703
+ mode: Type.String({ description: "Workflow mode: prototype, quick, task, or another installed mode." }),
704
+ }),
705
+ async execute(_id, params, _signal, _onUpdate, ctx) {
706
+ const controller = controllerForMode(pi, params.mode);
707
+ return controller ? endController(controller, ctx) : unknownMode(params.mode);
708
+ },
709
+ } satisfies ToolDefinition);
710
+ }
711
+
541
712
  export function createWorkflowExtension(
542
713
  config: WorkflowConfig,
543
714
  options: WorkflowAdapterOptions,
@@ -558,171 +729,12 @@ export function createWorkflowExtension(
558
729
  const controller: WorkflowController = { pi, config, sm, deps, seenToolCallIds: new Set() };
559
730
  registry.controllers.push(controller);
560
731
  registerSharedArtifactWriter(pi);
561
- const { start, resume, end } = options.toolNames;
732
+ registerSharedWorkflowTools(pi);
562
733
  const { mode, footerLabel } = config;
734
+ const end = "end_workflow";
563
735
 
564
- // ── start tool ──
565
- pi.registerTool({
566
- name: start,
567
- label: options.toolLabels.start,
568
- description: `Start the ${mode} workflow for the given goal. Sets up ${config.phases[0]} as the first phase and returns its prompt. Do not call if a workflow is already active.`,
569
- parameters: Type.Object({
570
- goal: Type.String({
571
- description: `What the user wants the ${mode} workflow to build or accomplish.`,
572
- }),
573
- }),
574
- async execute(_id, params, _signal, _onUpdate, ctx) {
575
- const other = activeControllersFor(pi).find((c) => c.sm !== sm);
576
- if (other) {
577
- return {
578
- content: [{ type: "text", text: `A ${other.config.mode} workflow is already active (phase: ${other.sm.snapshot.phase}). Close it before starting ${mode}.` }],
579
- details: { phase: other.sm.snapshot.phase, alreadyActive: true },
580
- };
581
- }
582
- const before = checkpoint(controller);
583
- const eff = await sm.start(params.goal, deps);
584
- if (eff.kind === "started") {
585
- controller.run = {
586
- version: 1,
587
- id: basename(sm.snapshot.artifactDir),
588
- mode,
589
- status: "active",
590
- goal: sm.snapshot.userPrompt,
591
- artifactDir: sm.snapshot.artifactDir,
592
- phase: sm.snapshot.phase,
593
- autoArmed: sm.snapshot.autoArmed,
594
- createdAt: new Date().toISOString(),
595
- updatedAt: new Date().toISOString(),
596
- };
597
- try {
598
- await persistAfter(controller, before);
599
- } catch (error) {
600
- return {
601
- content: [{ type: "text", text: `Could not start durable workflow: ${error instanceof Error ? error.message : String(error)}` }],
602
- details: { persistenceError: true },
603
- };
604
- }
605
- controller.seenToolCallIds.clear();
606
- controller.lastBlockedKey = undefined;
607
- }
608
- // The start tool result already contains the grilling prompt. Queueing
609
- // it again creates a duplicate autonomous turn.
610
- applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
611
- if (eff.kind === "alreadyActive") {
612
- return {
613
- content: [
614
- {
615
- type: "text",
616
- text: `A ${mode} workflow is already active (phase: ${eff.phase}). Do not call ${start} again. Phases auto-advance as artifacts land; call ${end} only from the final phase.`,
617
- },
618
- ],
619
- details: { phase: eff.phase, alreadyActive: true },
620
- };
621
- }
622
- return {
623
- content: [
624
- { type: "text", text: `${footerLabel} workflow started. ${eff.prompt}` },
625
- ],
626
- details: { phase: eff.phase },
627
- };
628
- },
629
- renderResult(_result, _options, theme) {
630
- return new Text(
631
- theme.fg("warning", `● ${footerLabel} started · 1/${config.phases.length} ${config.phases[0]}`),
632
- 0,
633
- 0,
634
- );
635
- },
636
- } satisfies ToolDefinition);
637
-
638
- // ── explicit resume tool ──
639
- pi.registerTool({
640
- name: resume,
641
- label: options.toolLabels.resume,
642
- description: `Resume an explicitly selected ${mode} workflow run. Pass a run id, artifact directory, or workflow.json path.`,
643
- parameters: Type.Object({ run: Type.String({ description: "Run id, artifact directory, or workflow.json path." }) }),
644
- async execute(_id, params, _signal, _onUpdate, ctx) {
645
- return resumeController(controller, ctx, params.run, end);
646
- },
647
- renderResult(result, _options, theme) {
648
- const d = result.details as { rejected?: boolean; persistenceError?: boolean; phase?: string };
649
- const failed = d.rejected || d.persistenceError;
650
- return new Text(theme.fg(failed ? "warning" : "success", failed ? "○ workflow resume rejected" : `✓ workflow resumed · ${d.phase}`), 0, 0);
651
- },
652
- } satisfies ToolDefinition);
653
-
654
- // ── end tool ──
655
- pi.registerTool({
656
- name: end,
657
- label: options.toolLabels.end,
658
- description: `Close the ${mode} workflow. Must be called when the final phase is complete. Marks the workflow finished and stops the agent loop.`,
659
- parameters: Type.Object({}),
660
- async execute(_id, _params, _signal, _onUpdate, ctx) {
661
- const before = checkpoint(controller);
662
- const eff = await sm.end(deps);
663
- if (eff.kind === "closed") {
664
- try {
665
- await persistAfter(controller, before);
666
- } catch (error) {
667
- return {
668
- content: [{ type: "text", text: `Cannot end: workflow state could not be persisted: ${error instanceof Error ? error.message : String(error)}` }],
669
- details: { persistenceError: true },
670
- };
671
- }
672
- }
673
- applyControllerEffect(controller, ctx, eff);
674
- if (eff.kind === "closed") {
675
- controller.seenToolCallIds.clear();
676
- controller.lastBlockedKey = undefined;
677
- }
678
- switch (eff.kind) {
679
- case "closed":
680
- return {
681
- content: [{ type: "text", text: `Workflow ended and closed. It can no longer be used. Artifacts saved at ${eff.artifactDir}.` }],
682
- details: { closed: true, phase: eff.phase, artifactDir: eff.artifactDir },
683
- terminate: true,
684
- };
685
- case "endBlocked": {
686
- // ponytail: missing is either a filename (existence failure),
687
- // a phase description like "not at terminal (audit)", or — Plan 4 —
688
- // a filename whose validator failed (reason set). Only suggest
689
- // writing when it's an actual artifact file existence miss.
690
- if (eff.reason) {
691
- return {
692
- content: [{ type: "text", text: `Cannot end: ${eff.missing} is incomplete: ${eff.reason}.` }],
693
- details: { phase: eff.phase, blocked: eff.missing, reason: eff.reason },
694
- };
695
- }
696
- const isArtifactFile =
697
- !eff.missing.includes(" ") && eff.missing.includes(".");
698
- const hint = isArtifactFile
699
- ? ` Write ${sm.snapshot.artifactDir}/${eff.missing} first.`
700
- : ` Continue through the phases to ${config.phases[config.phases.length - 1]}.`;
701
- return {
702
- content: [{ type: "text", text: `Cannot end: ${eff.missing}.${hint}` }],
703
- details: { phase: eff.phase, blocked: eff.missing },
704
- };
705
- }
706
- default:
707
- return {
708
- content: [{ type: "text", text: `No active ${mode} workflow to end.` }],
709
- details: { active: false },
710
- };
711
- }
712
- },
713
- renderResult(result, _options, theme) {
714
- const d = result.details as { closed?: boolean; artifactDir?: string };
715
- if (!d.closed) {
716
- return new Text(theme.fg("dim", "○ no workflow to end"), 0, 0);
717
- }
718
- return new Text(
719
- theme.fg("success", `✓ ${footerLabel} closed · ${d.artifactDir}`),
720
- 0,
721
- 0,
722
- );
723
- },
724
- } satisfies ToolDefinition);
725
-
736
+ // Workflow lifecycle tools are registered once per Pi instance below.
737
+ // They dispatch by `mode`; mode-specific tool aliases are intentionally absent.
726
738
  // ── session_start: disk state is never auto-attached. A session entry is
727
739
  // merely a convenience pointer for rendering a stale-but-useful footer. ──
728
740
  pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
@@ -747,13 +759,23 @@ export function createWorkflowExtension(
747
759
  }
748
760
  });
749
761
 
750
- // ── tool_call: enforce parent-owned artifact boundaries. ──
751
- pi.on("tool_call", (event: any, _ctx: ExtensionContext) => {
762
+ // ── tool_call: enforce parent-owned artifact boundaries and exclusive
763
+ // transition calls. The session manager exposes the current assistant
764
+ // message, so fail closed when its sole-call shape cannot be proven. ──
765
+ pi.on("tool_call", (event: any, ctx: ExtensionContext) => {
752
766
  if (!isRegisteredController(controller)) return;
753
767
  const tool = event.toolName;
754
- if (tool !== "subagent" && tool !== "write" && tool !== "edit") return;
755
768
  const snap = sm.snapshot;
756
769
  if (!snap.active) return;
770
+ const commentatorTransition = tool === "subagent" &&
771
+ event.input?.agent === "commentator" && snap.phase === "loop" &&
772
+ !isSubagentManagementAction(event.input);
773
+ const endTransition = tool === "end_workflow" && event.input?.mode === mode;
774
+ if (tool === WORKFLOW_ARTIFACT_TOOL || endTransition || commentatorTransition) {
775
+ const blocked = transitionBatchBlock(ctx, event.toolCallId);
776
+ if (blocked) return blocked;
777
+ }
778
+ if (tool !== "subagent" && tool !== "write" && tool !== "edit") return;
757
779
  if (tool === "write" || tool === "edit") {
758
780
  return {
759
781
  block: true,
@@ -846,8 +868,11 @@ export function createWorkflowExtension(
846
868
  }
847
869
  // The only artifact written in this handler is loop-complete.md, which
848
870
  // is engine-owned. Parent-written phase artifacts advance inside
849
- // write_workflow_artifact instead.
850
- applyControllerEffect(controller, ctx, eff);
871
+ // write_workflow_artifact instead. A clean review is an exclusive
872
+ // transition boundary, so terminate and let the hidden steer continue.
873
+ const transition = eff.kind === "advanced" || eff.kind === "terminalReady" || eff.kind === "terminalNeedsArtifacts";
874
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: transition });
875
+ return transition ? { terminate: true } : undefined;
851
876
  });
852
877
 
853
878
  // ── /<command> ──
@@ -932,7 +957,7 @@ export function createWorkflowExtension(
932
957
  const closed = sm.closeCurrent();
933
958
  if (closed) {
934
959
  // Detach only: the on-disk active record remains resumable. Only
935
- // end_*_workflow writes status: completed.
960
+ // end_workflow({ mode }) writes status: completed.
936
961
  applyEntry(pi, config, { ...closed, done: false }, controller.run);
937
962
  ctx.ui.notify(`Detached ${mode} workflow at ${closed.artifactDir}; it remains resumable.`, "info");
938
963
  }
@@ -16,8 +16,6 @@ const MODES = [prototypeMode, quickMode, taskMode] as const;
16
16
  export default function workflowModesExtension(pi: ExtensionAPI): void {
17
17
  for (const mode of MODES) {
18
18
  createWorkflowExtension(mode.config, {
19
- toolNames: mode.toolNames,
20
- toolLabels: mode.toolLabels,
21
19
  commandName: mode.commandName,
22
20
  commandDescription: mode.commandDescription,
23
21
  })(pi);
@@ -135,7 +135,7 @@ Review uncommitted changes for correctness, plan adherence, and over-engineering
135
135
  2. If that review lists actionable issues, call the subagent tool with { agent: "builder", task: "...", output: false } (do NOT pass a model parameter). Instruct it to fix every issue in the workspace, never in ${artifactDir}, then return its completion summary inline.
136
136
  3. Re-dispatch the commentator and overwrite the parent-owned review artifact through write_workflow_artifact until it ends with WORKFLOW_REVIEW_STATUS: clean.
137
137
 
138
- Once ${artifactDir}/review.md exists and ends with the WORKFLOW_REVIEW_STATUS: clean marker, call end_workflow to complete the workflow.`,
138
+ Once ${artifactDir}/review.md exists and ends with the WORKFLOW_REVIEW_STATUS: clean marker, call end_workflow with { mode: "prototype" } to complete the workflow.`,
139
139
  };
140
140
 
141
141
  const config: WorkflowConfig = {
@@ -172,8 +172,6 @@ export const prototypeMode: WorkflowModeRegistration = {
172
172
  commandName: "workflow-prototype",
173
173
  commandDescription:
174
174
  "Run the prototype workflow (grill → research → plan → reuse → handoff → loop → audit)",
175
- toolNames: { start: "start_workflow", resume: "resume_workflow", end: "end_workflow" },
176
- toolLabels: { start: "Start Workflow", resume: "Resume Workflow", end: "End Workflow" },
177
175
  };
178
176
 
179
177
  export default prototypeMode;
@@ -118,7 +118,7 @@ Review uncommitted changes for correctness, plan adherence, and over-engineering
118
118
  2. If that review lists actionable issues, call the subagent tool with { agent: "builder", task: "...", output: false } (do NOT pass a model parameter). Instruct it to fix every issue in the workspace, never in ${artifactDir}, then return its completion summary inline.
119
119
  3. Re-run the commentator and overwrite the parent-owned review artifact through write_workflow_artifact until it ends with WORKFLOW_REVIEW_STATUS: clean.
120
120
 
121
- Once ${artifactDir}/review.md exists and ends with the WORKFLOW_REVIEW_STATUS: clean marker, call end_quick_workflow to complete the workflow.`,
121
+ Once ${artifactDir}/review.md exists and ends with the WORKFLOW_REVIEW_STATUS: clean marker, call end_workflow with { mode: "quick" } to complete the workflow.`,
122
122
  };
123
123
 
124
124
  const config: WorkflowConfig = {
@@ -152,8 +152,6 @@ export const quickMode: WorkflowModeRegistration = {
152
152
  commandName: "workflow-quick",
153
153
  commandDescription:
154
154
  "Run the quick workflow (grill → plan → reuse → handoff → loop → audit)",
155
- toolNames: { start: "start_quick_workflow", resume: "resume_quick_workflow", end: "end_quick_workflow" },
156
- toolLabels: { start: "Start Quick Workflow", resume: "Resume Quick Workflow", end: "End Quick Workflow" },
157
155
  };
158
156
 
159
157
  export default quickMode;
@@ -68,7 +68,7 @@ After the builder returns, call the subagent tool with { agent: "commentator", t
68
68
  OR
69
69
  WORKFLOW_REVIEW_STATUS: blocking
70
70
 
71
- If a review is blocking, call the builder again with the recorded issues. This repeats up to ${loopMaxIterations ?? 3} round(s). When a review is clean, the engine writes loop-complete.md and the workflow becomes terminal-ready. Do NOT write loop-complete.md yourself. Call end_task_workflow to complete the workflow.`,
71
+ If a review is blocking, call the builder again with the recorded issues. This repeats up to ${loopMaxIterations ?? 3} round(s). When a review is clean, the engine writes loop-complete.md and the workflow becomes terminal-ready. Do NOT write loop-complete.md yourself. Call end_workflow with { mode: "task" } to complete the workflow.`,
72
72
  };
73
73
 
74
74
  const config: WorkflowConfig = {
@@ -90,8 +90,6 @@ const config: WorkflowConfig = {
90
90
  closeValidators: { "loop-complete.md": loopCompleteValidator },
91
91
  closeArtifacts: ["loop-complete.md"],
92
92
  loopMaxIterations: 3,
93
- // Each parent-owned artifact automatically queues the next phase prompt.
94
- continueAfterArtifact: true,
95
93
  statusKey: "task",
96
94
  entryType: "task-phase",
97
95
  footerLabel: "task",
@@ -102,16 +100,6 @@ export const taskMode: WorkflowModeRegistration = {
102
100
  commandName: "workflow-task",
103
101
  commandDescription:
104
102
  "Run the task workflow (plan → reuse → handoff → build↔review loop)",
105
- toolNames: {
106
- start: "start_task_workflow",
107
- resume: "resume_task_workflow",
108
- end: "end_task_workflow",
109
- },
110
- toolLabels: {
111
- start: "Start Task Workflow",
112
- resume: "Resume Task Workflow",
113
- end: "End Task Workflow",
114
- },
115
103
  };
116
104
 
117
105
  export default taskMode;
@@ -1,7 +1,7 @@
1
1
  // ponytail: pure workflow state machine. No node:fs, no pi API, no pi-tui,
2
2
  // no typebox. Transitions are pure given two injected predicates
3
3
  // (artifactExists, shouldSkip). Side effects (appendEntry, setStatus,
4
- // sendUserMessage) are NOT called here — every method returns a domain
4
+ // sendMessage) are NOT called here — every method returns a domain
5
5
  // Effect the adapter pattern-matches on and applies. The adapter owns
6
6
  // all pi/fs wiring; this module owns the phase graph + gating + reentrancy.
7
7
 
@@ -47,8 +47,6 @@ export interface WorkflowModeRegistration {
47
47
  config: WorkflowConfig;
48
48
  commandName: string;
49
49
  commandDescription: string;
50
- toolNames: { start: string; resume: string; end: string };
51
- toolLabels: { start: string; resume: string; end: string };
52
50
  }
53
51
 
54
52
  export interface WorkflowConfig {
@@ -82,9 +80,6 @@ export interface WorkflowConfig {
82
80
  // ponytail: Plan 3 — engine-owned loop. Max review rounds before the loop
83
81
  // stops and asks the user instead of silently passing. Default 3.
84
82
  loopMaxIterations?: number;
85
- // Most workflows pause at artifact boundaries. A mode can opt into queuing
86
- // the next phase prompt after the parent writes a valid artifact.
87
- continueAfterArtifact?: boolean;
88
83
  }
89
84
 
90
85
  export interface WorkflowDeps {