Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions assets/skills/spok-ci-commit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@ itself — never from memory, never from a scan of whatever directory you happen
- This artifact-derived list is the authority on what belongs in the commit.

2. **Confirm it against the repository:**
- Run `git -C <work-root> status --porcelain` and `git -C <work-root> diff` to see what
actually changed.
- Run `git -C <work-root> status --porcelain --untracked-files=all` and
`git -C <work-root> diff` to see what actually changed.
- The dispatching prompt may name exact generated capability directories in the work root.
Exclude only the generated capability directories named in the dispatching prompt, including
their descendants, from both the artifact-derived and changed-path lists, and never stage them.
When the prompt names none, exclude none. Every other unexplained changed path remains a blocker.
- Stage exactly the **intersection** of the artifact-derived list and the changed paths.
- **Fail loudly instead of falling back to a directory scan.** If the intersection is
empty, if the artifacts name paths that are unchanged, or if the repository carries
Expand Down
5 changes: 5 additions & 0 deletions assets/skills/spok-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ Then repeat this loop until the CLI returns `state: "complete"`:

4. Dispatch the step through `step.runner`.

`spok flow next` has already ensured `step.skill` is installed for
`step.runner`, materializing it from the Spok distribution when missing —
never preflight or install skills yourself; a missing capability surfaces
as a `capability_unavailable` block instead of a ready step.

Detect the active harness once: a non-empty `CODEX_HOME` means `codex`;
otherwise it is `claude`.

Expand Down
9 changes: 8 additions & 1 deletion harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,21 @@ interface ComplexityOffender {
length: number;
}

// Git exports these into hook processes; from a linked worktree they redirect
// every nested `git` the tests spawn into this repo, so its hooks run there too.
const GIT_HOOK_ENV_VARS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_COMMON_DIR'];
const CHILD_ENV = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !GIT_HOOK_ENV_VARS.includes(key)),
);

async function run(
description: string,
cmd: string[],
opts?: { extract?: (output: string) => string | undefined; noExit?: boolean },
): Promise<RunResult> {
if (VERBOSE) console.log(`${DIM} → ${cmd.join(' ')}${RESET}`);

const proc = Bun.spawn(cmd, { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
const proc = Bun.spawn(cmd, { cwd: ROOT, env: CHILD_ENV, stdout: 'pipe', stderr: 'pipe' });
Comment thread
0xjgv marked this conversation as resolved.
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
Expand Down
140 changes: 134 additions & 6 deletions src/commands/workflow/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import path from 'node:path';
import { execFile } from 'node:child_process';
import { existsSync, promises as fs, readFileSync } from 'node:fs';
import { promisify } from 'node:util';
import { AI_TOOLS } from '../../core/config.js';
import { PROJECT_CONFIG_FILE_NAMES, readProjectConfig } from '../../core/project-config.js';
import { ensureVendoredSkill } from '../../core/skill-vendor.js';
import { FileSystemUtils } from '../../utils/file-system.js';

const execFileAsync = promisify(execFile);
Expand Down Expand Up @@ -153,6 +155,7 @@ export interface WorkflowState {
status: FlowRunState;
steps: FlowStep[];
repairAttempts: number;
materializedCapabilityPaths: string[];
createdAt: string;
updatedAt: string;
}
Expand Down Expand Up @@ -320,10 +323,12 @@ const SELF_LEARN_STEP_DEFINITION_SPEC = {
interface FlowEvent {
schemaVersion: 1;
timestamp: string;
event: 'flow_status' | 'flow_next' | 'flow_complete';
event: 'flow_status' | 'flow_next' | 'flow_complete' | 'capability_materialized';
state: FlowRunState;
step?: string;
completedStep?: string;
skill?: string;
runner?: FlowRunner;
code?: string;
reason?: string;
}
Expand Down Expand Up @@ -472,11 +477,21 @@ function editingWorkRootClause(workRoot: string): string {
);
}

function materializedCapabilityClause(paths: string[]): string {
return [
'Generated capability directories in the work root:',
...paths.map((capabilityPath) => `- \`${capabilityPath}/\``),
'Exclude only these exact directories and their descendants from mismatch detection and staging. ' +
'Every other unexplained changed path remains a blocker.',
].join('\n');
}

/** The whole subagent prompt. The driver dispatches it verbatim and assembles nothing. */
function buildStepPrompt(
definition: StepDefinition,
rules: string[],
workRoot?: string
workRoot?: string,
materializedCapabilityPaths: string[] = []
): string {
const sections: string[] = [];

Expand All @@ -502,7 +517,12 @@ function buildStepPrompt(
if (clause) sections.push(clause);

if (workRoot) {
if (definition.completionKind === 'commit') sections.push(workRootClause(workRoot));
if (definition.completionKind === 'commit') {
sections.push(workRootClause(workRoot));
if (materializedCapabilityPaths.length > 0) {
sections.push(materializedCapabilityClause(materializedCapabilityPaths));
}
}
if (definition.id === 'simplify' || definition.id === REPAIR_STEP_ID) {
sections.push(editingWorkRootClause(workRoot));
}
Expand Down Expand Up @@ -623,6 +643,7 @@ function createInitialState(taskDir: string, profile: FlowProfile): WorkflowStat
stepFromDefinition(definition, index === 0 ? 'ready' : 'pending')
),
repairAttempts: 0,
materializedCapabilityPaths: [],
createdAt: timestamp,
updatedAt: timestamp,
};
Expand Down Expand Up @@ -768,6 +789,15 @@ function normalizeState(
status: 'ready',
steps,
repairAttempts,
materializedCapabilityPaths: Array.isArray(candidate.materializedCapabilityPaths)
? [
...new Set(
candidate.materializedCapabilityPaths
.filter((value): value is string => typeof value === 'string' && path.isAbsolute(value))
.map((value) => path.normalize(value))
),
]
: [],
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : initial.createdAt,
updatedAt: initial.updatedAt,
};
Expand Down Expand Up @@ -828,6 +858,7 @@ function flowBlockCode(reason: string): string {
if (reason.startsWith('Unknown flow profile:')) return 'unknown_flow_profile';
if (reason.startsWith('Flow profile mismatch:')) return 'flow_profile_mismatch';
if (reason.startsWith('Missing completed artifact for step ')) return 'missing_completed_artifact';
if (reason.startsWith('Capability unavailable for step ')) return 'capability_unavailable';
if (reason.startsWith('Expected step ')) return 'wrong_step';
if (reason.startsWith('Unknown workflow step:')) return 'unknown_step';
if (reason.startsWith('Expected output path ')) return 'wrong_output_path';
Expand All @@ -852,6 +883,59 @@ function flowBlockCode(reason: string): string {
return 'blocked';
}

function toolSkillsDir(toolId: FlowRunner): string {
const skillsDir = AI_TOOLS.find((tool) => tool.value === toolId)?.skillsDir;
if (!skillsDir) throw new Error(`No skills directory configured for tool: ${toolId}`);
return skillsDir;
}

const SKILLS_DIR_BY_RUNNER: Record<FlowRunner, string> = {
claude: toolSkillsDir('claude'),
codex: toolSkillsDir('codex'),
};

/**
* Lazy, step-local capability resolution: only the step `flow next` is about
* to hand out gets its skill ensured, for that step's runner only. Missing
* skills are materialized from the Spok distribution; the returned string is
* a blocking reason when that fails. Outside a Spok project there is nowhere
* to materialize into, so resolution is skipped and discovery falls back to
* whatever the harness already has installed.
*/
async function ensureStepCapability(
state: WorkflowState,
step: FlowStep
): Promise<string | undefined> {
const projectRoot = findProjectRootForTaskDir(state.taskDir);
if (!projectRoot) return;

const result = await ensureVendoredSkill(projectRoot, SKILLS_DIR_BY_RUNNER[step.runner], step.skill);
Comment thread
0xjgv marked this conversation as resolved.
if (result.status === 'unavailable') {
return (
`Capability unavailable for step ${step.id}: ${result.reason}. ` +
`Run spok init (or spok skills install --tools ${step.runner}) and retry.`
);
}

if (result.status === 'materialized') {
if (result.skillPath) {
const capabilityPath = path.dirname(result.skillPath);
if (!state.materializedCapabilityPaths.includes(capabilityPath)) {
state.materializedCapabilityPaths.push(capabilityPath);
}
}
await appendFlowEvent(state.taskDir, {
schemaVersion: 1,
timestamp: nowIso(),
event: 'capability_materialized',
state: 'ready',
step: step.id,
skill: step.skill,
runner: step.runner,
});
}
}

async function appendFlowEvent(taskDir: string, event: FlowEvent): Promise<void> {
try {
if (!(await pathIsDirectory(taskDir))) return;
Expand Down Expand Up @@ -975,14 +1059,18 @@ function withStepPrompt(
rules: string[],
repairAttempts: number,
profile: FlowProfile,
workRoot?: string
workRoot?: string,
materializedCapabilityPaths: string[] = []
): FlowStep | undefined {
if (!step) return step;

const definition = getDefinitionById(taskDir, step.id, repairAttempts, profile);
if (!definition) return step;

return { ...step, prompt: buildStepPrompt(definition, rules, workRoot) };
return {
...step,
prompt: buildStepPrompt(definition, rules, workRoot, materializedCapabilityPaths),
};
}

/** The most recently recorded work root: repair can move the work after implement. */
Expand All @@ -994,6 +1082,35 @@ function recordedWorkRoot(state: WorkflowState): string | undefined {
return workRoot;
}

function materializedCapabilitiesInWorkRoot(
state: WorkflowState,
workRoot: string | undefined
): string[] {
if (!workRoot) return [];

const projectRoot = findProjectRootForTaskDir(state.taskDir);
if (!projectRoot) return [];

const allowedCapabilityPaths = new Set(
state.steps.map((step) =>
FileSystemUtils.canonicalizeExistingPath(
path.join(projectRoot, SKILLS_DIR_BY_RUNNER[step.runner], 'skills', step.skill)
)
)
);
const resolvedRoot = FileSystemUtils.canonicalizeExistingPath(workRoot);
return state.materializedCapabilityPaths.flatMap((capabilityPath) => {
const resolvedCapabilityPath = FileSystemUtils.canonicalizeExistingPath(capabilityPath);
if (!allowedCapabilityPaths.has(resolvedCapabilityPath)) return [];

const relative = path.relative(resolvedRoot, resolvedCapabilityPath);
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
return [];
}
return [FileSystemUtils.toPosixPath(relative)];
});
}

/**
* Warns only where the gap bites: a state file written before work roots
* existed still reaches commit, it just reaches it unsteered.
Expand All @@ -1016,7 +1133,8 @@ function buildResponse(
memory?.rules ?? [],
state.repairAttempts,
state.profile,
workRoot
workRoot,
materializedCapabilitiesInWorkRoot(state, workRoot)
);
return {
state: state.status,
Expand Down Expand Up @@ -1505,6 +1623,16 @@ export async function getFlowNext(taskDirInput: string): Promise<FlowResponse> {
return response;
}

const currentStep = getCurrentStep(loaded.state);
if (currentStep) {
const capabilityBlock = await ensureStepCapability(loaded.state, currentStep);
if (capabilityBlock) {
const response = blockedResponse(loaded.state, capabilityBlock);
await recordFlowResponse(response, 'flow_next');
return response;
}
}

await writeState(loaded.state);
const response = buildResponse(loaded.state);
const nextResponse = {
Expand Down
Loading