Skip to content

Commit c8cdcc7

Browse files
authored
feat(workflow): save scope, root resolution, and size guideline (#51)
## Related Issue No linked issue — the problem is explained below. ## Problem `/workflow save` had three limitations: 1. It always saved into the project scope — there was no way to keep a saved workflow in the user's home skills directory. 2. It used the session working directory as the project root. Saving from a repository subdirectory placed the skill where the skill scanner (which resolves the closest `.git` ancestor) would never find it — the saved skill was invisible until the session was reopened at the repository root. 3. The workflow size guideline in force during the run was not persisted, so a re-run of the saved skill lost the fan-out expectation. ## What changed - `/workflow save <name> [--personal]`: `--personal` saves into the home skills directory; project scope stays the default. - `writeSavedWorkflowSkill` now takes the working directory and resolves the repository root itself via the skill scanner's own `findProjectRoot` (now exported) — the same rule the scanner uses to look skills up, so a save from a subdirectory lands where it will be found. - Saved workflows persist the size guideline into the skill frontmatter and state it in the body (the body is what the model reads on invocation; frontmatter alone is inert). The TUI caches the resolved guideline at startup, and the resolver is exported from the SDK. - Docs: the `/workflow save` row in the slash-command reference, and the `PYTHINKER_CODE_DISABLE_WORKFLOWS` / `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` rows in the env-var reference. - Rider: removes an unused mission-control streaming helper and narrows another to module scope (no behavior change). Tests: the save suite covers scope selection, root resolution from a subdirectory, and guideline persistence; the TUI suite covers `--personal` parsing. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update.
1 parent 38e3504 commit c8cdcc7

14 files changed

Lines changed: 230 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code-sdk": minor
3+
---
4+
5+
The saved-workflow write helper now takes the working directory and resolves the repository root itself, saved workflows can carry a size guideline, and the workflow size guideline resolver is exported.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
`/workflow save` accepts `--personal` to save into the home skills directory, resolves the repository root when saving from a subdirectory so the saved skill is discoverable, and persists the workflow size guideline into the saved skill.

apps/pythinker-code/src/tui/commands/dynamic-workflow.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
savedWorkflowSkillName,
33
writeSavedWorkflowSkill,
44
type PermissionMode,
5+
type SavedWorkflowScope,
56
} from '@pythoughts/pythinker-code-sdk';
67

78
import { getDataDir } from '#/utils/paths';
@@ -16,7 +17,7 @@ import {
1617
import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui';
1718
import { formatErrorMessage } from '../utils/event-payload';
1819
import type { SlashCommandHost } from './dispatch';
19-
import { isDynamicWorkflowDisabled } from './workflow-availability';
20+
import { currentWorkflowSizeGuideline, isDynamicWorkflowDisabled } from './workflow-availability';
2021

2122
export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args: string): Promise<void> {
2223
if (isDynamicWorkflowDisabled()) {
@@ -121,18 +122,29 @@ function withWorkerModelInstruction(prompt: string, model: string | undefined):
121122
}
122123

123124
/**
124-
* `/workflow save <name>` writes the last run back out as a skill, so a fan-out
125-
* that worked can be re-run by name instead of re-described.
125+
* `/workflow save <name> [--personal]` writes the last run back out as a
126+
* skill, so a fan-out that worked can be re-run by name instead of
127+
* re-described. Project scope is the default; `--personal` keeps the skill in
128+
* the user's home skills directory instead of the repository.
126129
*
127130
* Returns true when the input was a `save` subcommand and has been handled.
128131
*/
129132
async function handleSaveSubcommand(host: SlashCommandHost, input: string): Promise<boolean> {
130133
const match = /^save(?:\s+(.*))?$/iu.exec(input);
131134
if (match === null) return false;
132135

133-
const name = match[1]?.trim() ?? '';
134-
if (name.length === 0) {
135-
host.showError('Usage: /workflow save <name>');
136+
const tokens = (match[1] ?? '').split(/\s+/u).filter((token) => token.length > 0);
137+
// A name may contain spaces, so the flag is only recognised at either end.
138+
// Anywhere else — or twice — it is a typo rather than part of the name, and
139+
// folding it in would silently save under a different name and scope.
140+
const personalFirst = tokens[0] === '--personal';
141+
const personalLast = !personalFirst && tokens.at(-1) === '--personal';
142+
if (personalFirst) tokens.shift();
143+
else if (personalLast) tokens.pop();
144+
const scope: SavedWorkflowScope = personalFirst || personalLast ? 'personal' : 'project';
145+
const name = tokens.join(' ');
146+
if (name.length === 0 || tokens.includes('--personal')) {
147+
host.showError('Usage: /workflow save <name> [--personal]');
136148
return true;
137149
}
138150

@@ -150,8 +162,8 @@ async function handleSaveSubcommand(host: SlashCommandHost, input: string): Prom
150162

151163
try {
152164
const dir = await writeSavedWorkflowSkill({
153-
scope: 'project',
154-
projectRoot: host.state.appState.workDir,
165+
scope,
166+
workDir: host.state.appState.workDir,
155167
brandHomeDir: getDataDir(),
156168
workflow: {
157169
name,
@@ -161,6 +173,7 @@ async function handleSaveSubcommand(host: SlashCommandHost, input: string): Prom
161173
model: stringArg(args, 'model'),
162174
effort: stringArg(args, 'effort'),
163175
outputSchema: recordArg(args, 'output_schema'),
176+
sizeGuideline: currentWorkflowSizeGuideline(),
164177
},
165178
});
166179
// The skill registry is built once when the session opens, so the file just

apps/pythinker-code/src/tui/commands/workflow-availability.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
resolveWorkflowSizeGuideline,
3+
type WorkflowSizeGuideline,
4+
} from '@pythoughts/pythinker-code-sdk';
5+
16
const DISABLE_WORKFLOWS_ENV = 'PYTHINKER_CODE_DISABLE_WORKFLOWS';
27
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
38
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
@@ -23,3 +28,18 @@ export function setDynamicWorkflowDisabled(configValue: boolean | undefined, env
2328
export function isDynamicWorkflowDisabled(): boolean {
2429
return disabled;
2530
}
31+
32+
let sizeGuideline: WorkflowSizeGuideline | undefined;
33+
34+
/** Cache the resolved guideline. Call once at startup with the value from `harness.getConfig()`. */
35+
export function setWorkflowSizeGuideline(
36+
configValue: WorkflowSizeGuideline | undefined,
37+
env = process.env,
38+
): void {
39+
sizeGuideline = resolveWorkflowSizeGuideline({ workflowSizeGuideline: configValue }, env);
40+
}
41+
42+
/** The guideline in force for this session, for surfaces that persist it (e.g. `/workflow save`). */
43+
export function currentWorkflowSizeGuideline(): WorkflowSizeGuideline | undefined {
44+
return sizeGuideline;
45+
}

apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -773,7 +773,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
773773
}
774774

775775
/** Item list from the completed tool-call `items` argument. */
776-
export function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] {
776+
function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] {
777777
const items = args['items'];
778778
if (!Array.isArray(items)) return [];
779779
// Blank entries are dropped by the engine before any agent is launched, so
@@ -833,11 +833,6 @@ export function dynamicWorkflowPartialItemsFromArguments(argumentsText: string):
833833
return items;
834834
}
835835

836-
/** Count of `items` parsed so far from streaming arguments. */
837-
export function dynamicWorkflowPartialItemsCountFromArguments(argumentsText: string): number {
838-
return dynamicWorkflowPartialItemsFromArguments(argumentsText).length;
839-
}
840-
841836
/** Description from the completed tool-call `description` argument. */
842837
export function dynamicWorkflowDescriptionFromArgs(args: Record<string, unknown>): string {
843838
const description = args['description'];

apps/pythinker-code/src/tui/pythinker-tui.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
import {
5858
isDynamicWorkflowDisabled,
5959
setDynamicWorkflowDisabled,
60+
setWorkflowSizeGuideline,
6061
} from './commands/workflow-availability';
6162
import * as slashCommands from './commands/dispatch';
6263
import { BannerComponent } from './components/chrome/banner';
@@ -746,7 +747,9 @@ export class PythinkerTUI {
746747

747748
private async init(): Promise<boolean> {
748749
setExperimentalFeatures(await this.harness.getExperimentalFeatures());
749-
setDynamicWorkflowDisabled((await this.harness.getConfig()).disableWorkflows);
750+
const pythinkerConfig = await this.harness.getConfig();
751+
setDynamicWorkflowDisabled(pythinkerConfig.disableWorkflows);
752+
setWorkflowSizeGuideline(pythinkerConfig.workflowSizeGuideline);
750753
await this.authFlow.refreshAvailableModels();
751754
void this.refreshProviderModelsInBackground();
752755

apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
66

77
import { handleDynamicWorkflowCommand } from '#/tui/commands/index';
88
import type { SlashCommandHost } from '#/tui/commands/dispatch';
9-
import { setDynamicWorkflowDisabled } from '#/tui/commands/workflow-availability';
9+
import { setDynamicWorkflowDisabled, setWorkflowSizeGuideline } from '#/tui/commands/workflow-availability';
1010
import { currentTheme } from '#/tui/theme';
1111

1212
const ENTER = '\r';
@@ -497,6 +497,59 @@ describe('/workflow save', () => {
497497

498498
await handleDynamicWorkflowCommand(host, 'save');
499499

500-
expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name>');
500+
expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]');
501+
});
502+
503+
it('asks for a name when given only the --personal flag', async () => {
504+
const { host } = makeHost({ permissionMode: 'auto' });
505+
506+
await handleDynamicWorkflowCommand(host, 'save --personal');
507+
508+
expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]');
509+
});
510+
511+
it('saves --personal into the data dir and records the size guideline', async () => {
512+
const home = await fs.mkdtemp(join(tmpdir(), 'workflow-home-'));
513+
vi.stubEnv('PYTHINKER_CODE_HOME', home);
514+
// Explicit empty env: the default is process.env, where an exported
515+
// PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE would override 'small' and fail
516+
// this test for reasons unrelated to the change under test.
517+
setWorkflowSizeGuideline('small', {});
518+
try {
519+
const { host, session } = makeHost({
520+
permissionMode: 'auto',
521+
lastDynamicWorkflowArgs: { description: 'Audit routes for missing auth' },
522+
});
523+
524+
await handleDynamicWorkflowCommand(host, 'save --personal Audit Routes');
525+
526+
const saved = await fs.readFile(join(home, 'skills', 'audit-routes', 'SKILL.md'), 'utf8');
527+
expect(saved).toContain('name: "audit-routes"');
528+
expect(saved).toContain('size-guideline: "small"');
529+
// The body line is what shapes the re-run; the frontmatter alone is inert.
530+
expect(saved).toContain('at most about 5 subagents');
531+
expect(session.reloadSkills).toHaveBeenCalledOnce();
532+
expect(host.showError).not.toHaveBeenCalled();
533+
} finally {
534+
vi.unstubAllEnvs();
535+
// The module-level cache cannot return to unset; the resolved default
536+
// ('medium') matches what TUI startup would have cached in production.
537+
setWorkflowSizeGuideline(undefined, {});
538+
await fs.rm(home, { recursive: true, force: true });
539+
}
540+
});
541+
542+
it('rejects --personal when it is repeated or not at either end', async () => {
543+
for (const input of [
544+
'save Audit --personal Routes',
545+
'save --personal Audit --personal',
546+
'save --personal --personal',
547+
]) {
548+
const { host } = makeHost({ permissionMode: 'auto' });
549+
550+
await handleDynamicWorkflowCommand(host, input);
551+
552+
expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]');
553+
}
501554
});
502555
});

docs/configuration/env-vars.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
131131
| `PYTHINKER_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
132132
| `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
133133
| `PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins` | URL or local path |
134+
| `PYTHINKER_CODE_DISABLE_WORKFLOWS` | Disable Dynamic Workflow: the `DynamicWorkflow` tool is not registered and `/workflow` is hidden; takes higher priority than `config.toml` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
135+
| `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` | Override the advisory Dynamic Workflow size guideline injected into the tool guidance; takes higher priority than `config.toml` | `small`, `medium`, `large`, `unrestricted` |
134136
| `PYTHINKER_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` |
135137
| `PYTHINKER_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy |
136138
| `PYTHINKER_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path |

docs/reference/slash-commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Some commands are only available in the idle state. Executing these commands whi
5252
| `/workflow [on\|off]` || Toggle Dynamic Workflow mode without sending a prompt. Without arguments, flips the current state; explicitly passing `on`/`off` forces the setting. | No |
5353
| `/workflow <task>` || Turn Dynamic Workflow mode on, then send `<task>` as a normal prompt. If the turn completes normally, Dynamic Workflow mode turns off automatically. In `manual` permission mode, Pythinker Code asks whether to switch to `auto` or `yolo` before starting. | No |
5454
| `/workflow model [alias\|off]` || Ask Dynamic Workflow subagents to run on `alias` instead of the session model, so workers can use a cheaper or faster model than the agent orchestrating them. Without arguments, shows the current setting; `off` clears it. Lasts for the session. | No |
55+
| `/workflow save <name> [--personal]` || Save the last Dynamic Workflow that ran in this session as a skill, immediately invocable under its generated skill name — `Audit Routes` becomes `/audit-routes`. Saves into the project (`<repo root>/.pythinker-code/skills/`) by default; `--personal` saves into your home skills directory instead. | No |
5556
| `/goal [...]` || Start or manage an autonomous goal | See below |
5657

5758
::: info

packages/agent-core/src/agent/dynamic-workflow/save-as-skill.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { constants, promises as fs } from 'node:fs';
22

33
import path from 'pathe';
44

5+
import type { WorkflowSizeGuideline } from '../../config';
56
import { resolveSafePath } from '../../services/fs/fsPathSafety';
7+
import { findProjectRoot } from '../../skill/scanner';
68
import { normalizeSkillName } from '../../skill/types';
9+
import { workflowSizeGuidelineTarget } from './size-guideline';
710

811
/**
912
* A saved workflow's name becomes both a directory name and a slash command,
@@ -40,6 +43,8 @@ export interface SavedWorkflow {
4043
readonly model?: string;
4144
readonly effort?: string;
4245
readonly outputSchema?: Record<string, unknown>;
46+
/** Size guideline in force when the workflow ran, so a re-run keeps the same fan-out expectation. */
47+
readonly sizeGuideline?: WorkflowSizeGuideline;
4348
}
4449

4550
/**
@@ -100,7 +105,22 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
100105
if (workflow.effort !== undefined) {
101106
lines.push(`effort: ${quoteYamlScalar(workflow.effort)}`);
102107
}
108+
if (workflow.sizeGuideline !== undefined) {
109+
lines.push(`size-guideline: ${quoteYamlScalar(workflow.sizeGuideline)}`);
110+
}
103111
lines.push('---', '', `# ${workflow.description}`);
112+
// The body is what the model reads on invocation, so the guideline has to be
113+
// stated there to shape the re-run; the frontmatter alone is inert metadata.
114+
const sizeTarget =
115+
workflow.sizeGuideline === undefined
116+
? undefined
117+
: workflowSizeGuidelineTarget(workflow.sizeGuideline);
118+
if (sizeTarget !== undefined) {
119+
lines.push(
120+
'',
121+
`Size guideline: aim for at most about ${String(sizeTarget)} subagents in this workflow, preferring fewer, larger items over many tiny ones.`,
122+
);
123+
}
104124
if (workflow.promptTemplate !== undefined) {
105125
const fence = renderFence(workflow.promptTemplate);
106126
lines.push('', '## Prompt template', '', fence, workflow.promptTemplate, fence);
@@ -125,6 +145,12 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
125145
* workflow can also keep one. The name is validated before any directory is
126146
* created, so a rejected name leaves nothing behind.
127147
*
148+
* Project scope resolves the closest `.git` ancestor of `workDir` — the same
149+
* rule the skill scanner uses to pick its project root — so a save made from a
150+
* repository subdirectory lands where the scanner will look for it. Without
151+
* that, the saved skill is invisible until the session is reopened at the
152+
* repository root.
153+
*
128154
* A validated name is not enough on its own. Agents work in repositories they
129155
* did not write, and a checked-out tree can already contain
130156
* `.pythinker-code/skills/<name>/SKILL.md` as a symlink pointing anywhere on
@@ -136,15 +162,16 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
136162
export async function writeSavedWorkflowSkill(input: {
137163
readonly scope: SavedWorkflowScope;
138164
readonly workflow: SavedWorkflow;
139-
readonly projectRoot: string;
165+
readonly workDir: string;
140166
readonly brandHomeDir: string;
141167
}): Promise<string> {
142168
const name = savedWorkflowSkillName(input.workflow.name);
143-
const root = input.scope === 'project' ? input.projectRoot : input.brandHomeDir;
169+
const projectRoot = input.scope === 'project' ? await findProjectRoot(input.workDir) : input.workDir;
170+
const root = input.scope === 'project' ? projectRoot : input.brandHomeDir;
144171
const dir = savedWorkflowSkillDir({
145172
scope: input.scope,
146173
name: input.workflow.name,
147-
projectRoot: input.projectRoot,
174+
projectRoot,
148175
brandHomeDir: input.brandHomeDir,
149176
});
150177
const content = renderSavedWorkflowSkill({ ...input.workflow, name });

0 commit comments

Comments
 (0)