From aae94db165f7d4da2674ab38db7d9932436d14da Mon Sep 17 00:00:00 2001 From: Flow Test Date: Sat, 22 Aug 2026 15:43:52 +0200 Subject: [PATCH 1/4] fix: keep git hook env out of harness child processes Git exports GIT_DIR/GIT_INDEX_FILE into hook processes; from a linked worktree that redirected every nested git the tests spawn into this repo, so its pre-commit hook ran inside temp repos and failed with 'Module not found harness.ts'. --- harness.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/harness.ts b/harness.ts index 3a955ec..324acd0 100644 --- a/harness.ts +++ b/harness.ts @@ -56,6 +56,13 @@ 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[], @@ -63,7 +70,7 @@ async function run( ): Promise { 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' }); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), From a24ba1e9a4e87019d5bfb45669bbfad9ce10f454 Mon Sep 17 00:00:00 2001 From: Flow Test Date: Sat, 22 Aug 2026 15:43:52 +0200 Subject: [PATCH 2/4] feat: resolve step capabilities lazily in spok flow next Ensure only the active step's skill for that step's runner before dispatch, materializing it from the Spok distribution into the worktree-local skills dir when missing (copy-if-missing, temp-then-rename). Unavailable capabilities block with code capability_unavailable and leave workflow state untouched; a materialization emits capability_materialized to flow-events.jsonl. Drop the hybrid dual-harness skill preflight from the spok-apply template: only the active harness's spok-flow entry marker is checked, everything else is owned by the flow engine. --- README.md | 229 +-------------------- a.txt | 1 + assets/skills/spok-flow/SKILL.md | 5 + src/commands/workflow/flow.ts | 61 +++++- src/core/skill-vendor.ts | 72 +++++++ src/core/templates/workflows/apply.ts | 34 ++- test/commands/workflow/flow.test.ts | 96 +++++++++ test/core/skill-vendor.test.ts | 84 ++++++++ test/features/tool-skill-artifacts.feature | 18 +- 9 files changed, 342 insertions(+), 258 deletions(-) create mode 100644 a.txt diff --git a/README.md b/README.md index 90f8081..8ae0569 100644 --- a/README.md +++ b/README.md @@ -1,228 +1 @@ ---- -version: 0.1.0 ---- -

- - - - Spok logo - - -

- -

- CI - - License: MIT -

- -**Lightweight spec-driven development for AI coding agents.** Spok helps you and your agent agree on what to build, ship one thin slice at a time, and fold the final behavior back into living specs. - -Spok is built around four workflow skills you give your AI coding assistant: - -```text -/spok-explore -> /spok-propose -> /spok-apply -> /spok-archive -``` - -That's the whole surface. Explore is a thinking-only mode for investigating ideas before a proposal. Propose creates a change with a chunked tasks list, apply ships one chunk end-to-end at a time, and archive folds your delta specs into the main specs. - -

- Follow @0xjgv on X for updates. -

- -## Quick Start - -**Requires [Bun](https://bun.sh) 1.3.0 or higher.** - -The npm package has not been published. Install Spok from source: - -```bash -mkdir -p "$HOME/.local/share" -git clone https://github.com/0xjgv/spok.git "$HOME/.local/share/spok" -cd "$HOME/.local/share/spok" -bun install -bun run build -mkdir -p "$HOME/.bun/bin" -ln -sf "$PWD/bin/spok.js" "$HOME/.bun/bin/spok" -spok version -``` - -Keep the source checkout at `$HOME/.local/share/spok`; the global `spok` command links to it. - -Then initialize Spok inside your project: - -```bash -cd your-project -spok init -``` - -Now tell your AI coding assistant: - -```text -/spok-propose -``` - -Use `/spok-explore ` first when you want to think through an idea before proposing work. - -`spok init` configures your AI coding assistants, installs the four workflow skills (`spok-explore`, `spok-propose`, `spok-apply`, `spok-archive`), and vendors the helper skills they call (`spok-flow`, `spok-create-scoped-chunks`, and the rest of the closure). - -> [!NOTE] -> Not sure if your tool is supported? [View the full list](docs/supported-tools.md). Spok supports 25+ tools and growing. - -## See It In Action - -```text -You: /spok-explore should we add dark mode? -AI: Investigates the existing UI and summarizes options without changing files. - -You: /spok-propose add-dark-mode -AI: Created spok/changes/add-dark-mode/ - + proposal.md - why we're doing this, what's changing - + specs/ - requirements and scenarios - + design.md - technical approach - + tasks.md - chunked checklist (3 chunks) - Run /spok-apply to ship the first chunk. - -You: /spok-apply -AI: Shipping chunk 1: Add theme context + CSS variables - [runs research -> design -> plan -> implement -> review -> commit] - + Chunk shipped. 2/3 remaining. - -You: /spok-apply -AI: Shipping chunk 2: Wire toggle component to localStorage - + Chunk shipped. 1/3 remaining. - -You: /spok-apply -AI: Shipping chunk 3: Apply theme to remaining surfaces - + Chunk shipped. 0/3 remaining. Run /spok-archive. - -You: /spok-archive -AI: Applied delta specs to spok/specs/ui/spec.md - Archived to spok/changes/archive/2026-05-26-add-dark-mode/ -``` - -## What Spok Creates - -Spok keeps planning artifacts next to your code: - -```text -spok/ -├── specs/ # Source-of-truth behavior specs -├── changes/ -│ └── add-dark-mode/ -│ ├── proposal.md # Intent, scope, and approach -│ ├── specs/ # Delta specs for this change -│ ├── design.md # Technical design -│ └── tasks.md # Chunked implementation checklist -└── config.toml # Optional project config -``` - -Each change is isolated until you archive it. During archive, Spok applies the delta specs to `spok/specs/` and moves the completed change into history. - -## Why Spok? - -AI coding assistants are powerful, but they get unpredictable when requirements live only in chat history. Spok adds a lightweight spec layer so the human and agent agree before implementation starts. - -- **Agree before you build** - capture intent, requirements, and design before code changes. -- **Stay organized** - keep every proposed change in its own folder with specs, design, and tasks. -- **Explore before proposing** - use `/spok-explore` as a thinking-only mode when the direction is still unclear. -- **Ship one chunk at a time** - `/spok-apply` runs a full research -> design -> plan -> implement -> review -> commit loop for one chunk, then stops. -- **Use your tools** - Spok works with 25+ AI coding tools and does not lock you into one IDE or model. - -## Docs - -- **[Getting Started](docs/getting-started.md)**: first steps -- **[Workflows](docs/workflows.md)**: combos and patterns -- **[Commands](docs/commands.md)**: slash commands & skills -- **[CLI](docs/cli.md)**: terminal reference -- **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths -- **[Concepts](docs/concepts.md)**: how it all fits -- **[Multi-Language](docs/multi-language.md)**: multi-language support -- **[Migration Guide](docs/migration-guide.md)**: upgrading from older Spok versions - -## Philosophy - -```text --> fluid, not rigid --> iterative, not waterfall --> easy, not complex --> built for brownfield, not just greenfield --> scalable from personal projects to enterprises -``` - -### How we compare - -**vs. [Spec Kit](https://github.com/github/spec-kit)** (GitHub) - Thorough but heavyweight. Rigid phase gates, lots of Markdown, Python setup. Spok is lighter and lets you iterate freely. - -**vs. [Kiro](https://kiro.dev)** (AWS) - Powerful but you're locked into their IDE and limited to Claude models. Spok works with the tools you already use. - -**vs. nothing** - AI coding without specs means vague prompts and unpredictable results. Spok brings predictability without the ceremony. - -## Updating Spok - -Pull and rebuild the source checkout: - -```bash -cd "$HOME/.local/share/spok" -git pull --ff-only -bun install -bun run build -``` - -Refresh agent instructions: - -Run this inside each project to regenerate AI guidance and ensure the latest skills are active: - -```bash -spok update -``` - -## Usage Notes - -**Model selection**: Spok works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation. - -**Context hygiene**: Spok benefits from a clean context window. Clear your context before starting implementation and maintain good context hygiene throughout your session. - -## Contributing - -**Small fixes** - Bug fixes, typo corrections, and minor improvements can be submitted directly as PRs. - -**Larger changes** - For new features, significant refactors, or architectural changes, please submit a Spok change proposal first so we can align on intent and goals before implementation begins. - -When writing proposals, keep the Spok philosophy in mind: we serve a wide variety of users across different coding agents, models, and use cases. Changes should work well for everyone. - -**AI-generated code is welcome** - as long as it's been tested and verified. PRs containing AI-generated code should mention the coding agent and model used (e.g., "Generated with Claude Code using claude-opus-4-5-20251101"). - -### Development - -- Install dependencies: `bun install` -- Build: `bun run build` -- Test: `bun run test` -- Develop CLI locally: `bun run dev` or `bun run dev:cli` -- Point global `spok` at this checkout: `ln -sf "$PWD/bin/spok.js" ~/.bun/bin/spok` -- Keep the linked CLI current while editing: run `bun run dev` in one terminal, then use `spok ...` in another -- Commit messages: short and imperative, one line ("Add verdict gate", not "feat: added verdict gates") - -## Other - -
-Telemetry - -Spok collects anonymous usage stats. - -We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI. - -**Opt-out:** `export SPOK_TELEMETRY=0` or `export DO_NOT_TRACK=1` - -
- -
-Maintainers & Advisors - -See [MAINTAINERS.md](MAINTAINERS.md) for the list of core maintainers and advisors who help guide the project. - -
- -## License - -MIT +# Test diff --git a/a.txt b/a.txt new file mode 100644 index 0000000..5626abf --- /dev/null +++ b/a.txt @@ -0,0 +1 @@ +one diff --git a/assets/skills/spok-flow/SKILL.md b/assets/skills/spok-flow/SKILL.md index 0a94acb..ef78a3a 100644 --- a/assets/skills/spok-flow/SKILL.md +++ b/assets/skills/spok-flow/SKILL.md @@ -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`. diff --git a/src/commands/workflow/flow.ts b/src/commands/workflow/flow.ts index fb52a8f..2617a78 100644 --- a/src/commands/workflow/flow.ts +++ b/src/commands/workflow/flow.ts @@ -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); @@ -320,10 +322,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; } @@ -828,6 +832,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'; @@ -852,6 +857,50 @@ 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 = { + 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(taskDir: string, step: FlowStep): Promise { + const projectRoot = findProjectRootForTaskDir(taskDir); + if (!projectRoot) return; + + const result = await ensureVendoredSkill(projectRoot, SKILLS_DIR_BY_RUNNER[step.runner], step.skill); + 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') { + await appendFlowEvent(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 { try { if (!(await pathIsDirectory(taskDir))) return; @@ -1505,6 +1554,16 @@ export async function getFlowNext(taskDirInput: string): Promise { return response; } + const currentStep = getCurrentStep(loaded.state); + if (currentStep) { + const capabilityBlock = await ensureStepCapability(loaded.state.taskDir, 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 = { diff --git a/src/core/skill-vendor.ts b/src/core/skill-vendor.ts index 6c6073f..5b0c86a 100644 --- a/src/core/skill-vendor.ts +++ b/src/core/skill-vendor.ts @@ -11,6 +11,7 @@ */ import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -50,6 +51,77 @@ export function getVendoredSkillNames(sourceDir: string = ASSETS_SKILLS_DIR): st } } +export type EnsureSkillStatus = 'present' | 'materialized' | 'unavailable'; + +export interface EnsureSkillResult { + status: EnsureSkillStatus; + /** SKILL.md path that satisfied the check; absent when unavailable. */ + skillPath?: string; + reason?: string; +} + +export interface EnsureSkillOptions { + sourceDir?: string; + homeDir?: string; +} + +function skillMarkerPath(root: string, toolSkillsDir: string, skillName: string): string { + return path.join(root, toolSkillsDir, 'skills', skillName, 'SKILL.md'); +} + +/** + * Ensure one vendored skill is discoverable for a tool, materializing it from + * the Spok distribution into the project when missing. + * + * Checked in order: project-local copy, then the distribution (copied into the + * project so the run is pinned to it), then a global `~/` install. + * The copy lands via temp-dir-then-rename so a concurrent run in the same + * project never observes a half-written skill. + */ +export async function ensureVendoredSkill( + projectRoot: string, + toolSkillsDir: string, + skillName: string, + options: EnsureSkillOptions = {} +): Promise { + const sourceDir = options.sourceDir ?? ASSETS_SKILLS_DIR; + const projectMarker = skillMarkerPath(projectRoot, toolSkillsDir, skillName); + if (fs.existsSync(projectMarker)) { + return { status: 'present', skillPath: projectMarker }; + } + + const srcSkill = path.join(sourceDir, skillName); + if (fs.existsSync(path.join(srcSkill, 'SKILL.md'))) { + const destSkill = path.dirname(projectMarker); + const tempSkill = `${destSkill}.tmp-${process.pid}`; + try { + await copyDir(srcSkill, tempSkill); + await fs.promises.rename(tempSkill, destSkill); + } catch (error) { + await fs.promises.rm(tempSkill, { recursive: true, force: true }).catch(() => {}); + // A concurrent run may have won the rename; only that makes the miss benign. + if (!fs.existsSync(projectMarker)) { + return { + status: 'unavailable', + reason: `could not materialize ${skillName}: ${(error as Error).message}`, + }; + } + } + return { status: 'materialized', skillPath: projectMarker }; + } + + const homeDir = options.homeDir ?? os.homedir(); + const globalMarker = skillMarkerPath(homeDir, toolSkillsDir, skillName); + if (fs.existsSync(globalMarker)) { + return { status: 'present', skillPath: globalMarker }; + } + + return { + status: 'unavailable', + reason: `skill ${skillName} is not installed for ${toolSkillsDir} and the Spok distribution has no vendored copy (${srcSkill})`, + }; +} + /** * Install the vendored skill closure into `//skills/`. * diff --git a/src/core/templates/workflows/apply.ts b/src/core/templates/workflows/apply.ts index add3bfc..30216cd 100644 --- a/src/core/templates/workflows/apply.ts +++ b/src/core/templates/workflows/apply.ts @@ -50,26 +50,22 @@ MUST prompt the user. - \`planningHome.changesDir\` and \`changeRoot\` — use these instead of guessing paths. - \`actionContext.mode\` — if it is \`workspace-planning\` and \`allowedEditRoots\` is empty, explain that workspace apply is not supported here, treat linked repos as read-only context, and STOP before staging. - Before staging, verify every harness that will execute the flow steps can - discover the Spok helper closure: + Before staging, verify only that the **active** harness can discover the + \`spok-flow\` entry skill: - Resolve the project root with \`git rev-parse --show-toplevel\`. - - Each harness's installation markers: - - Claude: \`/.claude/skills/spok-flow/SKILL.md\` or - \`~/.claude/skills/spok-flow/SKILL.md\`, and - \`/.claude/skills/spok-review-design/SKILL.md\` or - \`~/.claude/skills/spok-review-design/SKILL.md\`. - - Codex: \`/.agents/skills/spok-flow/SKILL.md\` or - \`~/.agents/skills/spok-flow/SKILL.md\`, and - \`/.agents/skills/spok-review-design/SKILL.md\` or - \`~/.agents/skills/spok-review-design/SKILL.md\`. - - Default execution (\`no arguments\` / \`\`) uses the current tool for - every step — check only that harness's markers. - - Before staging a hybrid run (\`hybrid\` / \`hybrid \`), both harnesses - execute steps — check both harnesses' markers. - - If the harness(es) that will execute the flow are missing any marker, - tell the user to run \`spok skills install --tools claude,codex\` (or just - \`--tools claude\` / \`--tools codex\` for a single-harness default run) and - STOP before staging. + - Claude: \`/.claude/skills/spok-flow/SKILL.md\` or + \`~/.claude/skills/spok-flow/SKILL.md\`. + - Codex: \`/.agents/skills/spok-flow/SKILL.md\` or + \`~/.agents/skills/spok-flow/SKILL.md\`. + - If the active harness's marker is missing, tell the user to run + \`spok init\` (or \`spok skills install --tools \`) and STOP before + staging. + + Do not preflight any other skill or the other harness — this holds for + hybrid runs (\`hybrid\` / \`hybrid \`) too. \`spok flow next\` ensures + each step's skill for that step's runner before dispatch, materializing it + from the Spok distribution when missing, and blocks with + \`capability_unavailable\` when it cannot. 3. **Parse the chunked tasks.md** diff --git a/test/commands/workflow/flow.test.ts b/test/commands/workflow/flow.test.ts index f901c09..83211f5 100644 --- a/test/commands/workflow/flow.test.ts +++ b/test/commands/workflow/flow.test.ts @@ -1789,3 +1789,99 @@ describe('commit SHA verification', () => { expect(result.reason).toContain('conflicts with recorded work root'); }); }); + +describe('lazy step capability resolution', () => { + const flow = useFlowHarness(); + + async function markProjectRoot(): Promise { + await fs.writeFile( + path.join(flow.projectRoot, 'spok', 'config.yaml'), + 'schema: spec-driven\n', + 'utf-8' + ); + } + + it('materializes the active step skill for its runner inside a Spok project', async () => { + await markProjectRoot(); + + const response = await getFlowNext(flow.taskDir); + + expect(response.state).toBe('ready'); + expect(response.step?.skill).toBe('spok-validate-problem'); + const marker = path.join( + flow.projectRoot, + '.claude/skills/spok-validate-problem/SKILL.md' + ); + await expect(fs.access(marker)).resolves.toBeUndefined(); + + const events = await readFlowEvents(flow.taskDir); + const materialized = events.filter((event) => event.event === 'capability_materialized'); + expect(materialized).toEqual([ + expect.objectContaining({ + step: 'validate-problem', + skill: 'spok-validate-problem', + runner: 'claude', + }), + ]); + + // Present on disk now: a second next must not materialize (or log) again. + await getFlowNext(flow.taskDir); + const eventsAfter = await readFlowEvents(flow.taskDir); + expect( + eventsAfter.filter((event) => event.event === 'capability_materialized') + ).toHaveLength(1); + }); + + it('materializes codex-runner skills into the codex skills dir on hybrid runs', async () => { + process.env.SPOK_FLOW_PROFILE = 'hybrid'; + await markProjectRoot(); + + const response = await getFlowNext(flow.taskDir); + + expect(response.state).toBe('ready'); + expect(response.step?.runner).toBe('codex'); + const marker = path.join( + flow.projectRoot, + '.agents/skills/spok-validate-problem/SKILL.md' + ); + await expect(fs.access(marker)).resolves.toBeUndefined(); + await expect( + fs.access(path.join(flow.projectRoot, '.claude/skills/spok-validate-problem')) + ).rejects.toThrow(); + }); + + it('skips capability resolution outside a Spok project', async () => { + const response = await getFlowNext(flow.taskDir); + + expect(response.state).toBe('ready'); + await expect( + fs.access(path.join(flow.projectRoot, '.claude')) + ).rejects.toThrow(); + }); + + it('blocks without consuming the step when the capability cannot be materialized, then resumes', async () => { + await markProjectRoot(); + // A file where the skills dir must go makes materialization fail deterministically. + const obstruction = path.join(flow.projectRoot, '.claude'); + await fs.writeFile(obstruction, 'not a directory', 'utf-8'); + + const blocked = await getFlowNext(flow.taskDir); + + expect(blocked.state).toBe('blocked'); + expect(blocked.reason).toContain('Capability unavailable for step validate-problem'); + await expect( + fs.access(path.join(flow.taskDir, WORKFLOW_STATE_FILE)) + ).rejects.toThrow(); + const events = await readFlowEvents(flow.taskDir); + expect(events.at(-1)).toMatchObject({ + event: 'flow_next', + state: 'blocked', + code: 'capability_unavailable', + }); + + await fs.rm(obstruction); + const resumed = await getFlowNext(flow.taskDir); + expect(resumed.state).toBe('ready'); + expect(resumed.step?.id).toBe('validate-problem'); + }); +}); diff --git a/test/core/skill-vendor.test.ts b/test/core/skill-vendor.test.ts index c82ef62..d145700 100644 --- a/test/core/skill-vendor.test.ts +++ b/test/core/skill-vendor.test.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { + ensureVendoredSkill, installVendoredSkills, getVendoredSkillNames, } from '../../src/core/skill-vendor.js'; @@ -186,4 +187,87 @@ describe('skill-vendor', () => { ).toBe(false); }); }); + + describe('ensureVendoredSkill', () => { + let projectRoot: string; + let homeDir: string; + + beforeEach(() => { + projectRoot = path.join(tempDir, 'project'); + homeDir = path.join(tempDir, 'home'); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + }); + + it('returns present without touching an existing project-local skill', async () => { + const marker = path.join(projectRoot, '.claude/skills/spok-flow/SKILL.md'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, '# pinned local copy\n'); + + const result = await ensureVendoredSkill(projectRoot, '.claude', 'spok-flow', { + sourceDir, + homeDir, + }); + + expect(result.status).toBe('present'); + expect(result.skillPath).toBe(marker); + expect(fs.readFileSync(marker, 'utf-8')).toBe('# pinned local copy\n'); + }); + + it('materializes a missing skill from the distribution into the project', async () => { + const result = await ensureVendoredSkill(projectRoot, '.agents', 'spok-helper', { + sourceDir, + homeDir, + }); + + expect(result.status).toBe('materialized'); + const skillDir = path.join(projectRoot, '.agents/skills/spok-helper'); + expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true); + expect( + fs.existsSync(path.join(skillDir, 'references/design_evidence_template.html')) + ).toBe(true); + + const again = await ensureVendoredSkill(projectRoot, '.agents', 'spok-helper', { + sourceDir, + homeDir, + }); + expect(again.status).toBe('present'); + }); + + it('falls back to a global install when the distribution lacks the skill', async () => { + const globalMarker = path.join(homeDir, '.claude/skills/spok-mystery/SKILL.md'); + fs.mkdirSync(path.dirname(globalMarker), { recursive: true }); + fs.writeFileSync(globalMarker, '# global\n'); + + const result = await ensureVendoredSkill(projectRoot, '.claude', 'spok-mystery', { + sourceDir, + homeDir, + }); + + expect(result.status).toBe('present'); + expect(result.skillPath).toBe(globalMarker); + }); + + it('reports unavailable when no source can provide the skill', async () => { + const result = await ensureVendoredSkill(projectRoot, '.claude', 'spok-mystery', { + sourceDir, + homeDir, + }); + + expect(result.status).toBe('unavailable'); + expect(result.reason).toContain('spok-mystery'); + }); + + it('reports unavailable when materialization cannot write into the project', async () => { + fs.writeFileSync(path.join(projectRoot, '.claude'), 'not a directory'); + + const result = await ensureVendoredSkill(projectRoot, '.claude', 'spok-flow', { + sourceDir, + homeDir, + }); + + expect(result.status).toBe('unavailable'); + expect(result.reason).toContain('spok-flow'); + }); + }); }); diff --git a/test/features/tool-skill-artifacts.feature b/test/features/tool-skill-artifacts.feature index ecb5446..a80142c 100644 --- a/test/features/tool-skill-artifacts.feature +++ b/test/features/tool-skill-artifacts.feature @@ -168,22 +168,20 @@ Feature: Tool skill artifacts And the workflow skill "spok-flow" under ".claude/skills" mentions "--dangerously-bypass-hook-trust" And the workflow skill "spok-flow" under ".claude/skills" mentions "claude -p" - Scenario: Hybrid apply preflights both harness skill closures + Scenario: Apply preflights only the active harness entry skill Given a new project When I initialize Spok for the tools "claude" - Then the workflow skill "spok-apply" under ".claude/skills" mentions "Before staging a hybrid run" - And the workflow skill "spok-apply" under ".claude/skills" mentions "~/.claude/skills/spok-flow/SKILL.md" + Then the workflow skill "spok-apply" under ".claude/skills" mentions "~/.claude/skills/spok-flow/SKILL.md" And the workflow skill "spok-apply" under ".claude/skills" mentions "~/.agents/skills/spok-flow/SKILL.md" - And the workflow skill "spok-apply" under ".claude/skills" mentions "~/.claude/skills/spok-review-design/SKILL.md" - And the workflow skill "spok-apply" under ".claude/skills" mentions "~/.agents/skills/spok-review-design/SKILL.md" - And the workflow skill "spok-apply" under ".claude/skills" mentions "spok skills install --tools claude,codex" + And the workflow skill "spok-apply" under ".claude/skills" mentions "Do not preflight any other skill or the other harness" + And the workflow skill "spok-apply" under ".claude/skills" does not mention "spok skills install --tools claude,codex" + And the workflow skill "spok-apply" under ".claude/skills" does not mention "spok-review-design/SKILL.md" - Scenario: Default apply preflights the current tool's skill closure + Scenario: Apply defers step capability resolution to the flow engine Given a new project When I initialize Spok for the tools "claude" - Then the workflow skill "spok-apply" under ".claude/skills" mentions "Default execution" - And the workflow skill "spok-apply" under ".claude/skills" mentions "check only that harness's markers." - And the workflow skill "spok-apply" under ".claude/skills" mentions "check both harnesses' markers." + Then the workflow skill "spok-apply" under ".claude/skills" mentions "spok flow next" + And the workflow skill "spok-apply" under ".claude/skills" mentions "capability_unavailable" Scenario: Inner flow implementation overrides standalone orchestration Given a new project From 9710b069b02e40e5754f90fc2f4ceadbdf552cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Sun, 23 Aug 2026 09:40:55 +0200 Subject: [PATCH 3/4] fix: restore README.md and drop a.txt leaked from test fixtures The failed pre-commit run in a24ba1e executed the tests under git's hook-exported GIT_DIR; their fixture-repo 'git add' calls staged README.md ('# Test') and a.txt into this repo's index, and the next commit swept them in. aae94db stops the leak; this restores the real README and removes a.txt. --- README.md | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++- a.txt | 1 - 2 files changed, 228 insertions(+), 2 deletions(-) delete mode 100644 a.txt diff --git a/README.md b/README.md index 8ae0569..90f8081 100644 --- a/README.md +++ b/README.md @@ -1 +1,228 @@ -# Test +--- +version: 0.1.0 +--- +

+ + + + Spok logo + + +

+ +

+ CI + + License: MIT +

+ +**Lightweight spec-driven development for AI coding agents.** Spok helps you and your agent agree on what to build, ship one thin slice at a time, and fold the final behavior back into living specs. + +Spok is built around four workflow skills you give your AI coding assistant: + +```text +/spok-explore -> /spok-propose -> /spok-apply -> /spok-archive +``` + +That's the whole surface. Explore is a thinking-only mode for investigating ideas before a proposal. Propose creates a change with a chunked tasks list, apply ships one chunk end-to-end at a time, and archive folds your delta specs into the main specs. + +

+ Follow @0xjgv on X for updates. +

+ +## Quick Start + +**Requires [Bun](https://bun.sh) 1.3.0 or higher.** + +The npm package has not been published. Install Spok from source: + +```bash +mkdir -p "$HOME/.local/share" +git clone https://github.com/0xjgv/spok.git "$HOME/.local/share/spok" +cd "$HOME/.local/share/spok" +bun install +bun run build +mkdir -p "$HOME/.bun/bin" +ln -sf "$PWD/bin/spok.js" "$HOME/.bun/bin/spok" +spok version +``` + +Keep the source checkout at `$HOME/.local/share/spok`; the global `spok` command links to it. + +Then initialize Spok inside your project: + +```bash +cd your-project +spok init +``` + +Now tell your AI coding assistant: + +```text +/spok-propose +``` + +Use `/spok-explore ` first when you want to think through an idea before proposing work. + +`spok init` configures your AI coding assistants, installs the four workflow skills (`spok-explore`, `spok-propose`, `spok-apply`, `spok-archive`), and vendors the helper skills they call (`spok-flow`, `spok-create-scoped-chunks`, and the rest of the closure). + +> [!NOTE] +> Not sure if your tool is supported? [View the full list](docs/supported-tools.md). Spok supports 25+ tools and growing. + +## See It In Action + +```text +You: /spok-explore should we add dark mode? +AI: Investigates the existing UI and summarizes options without changing files. + +You: /spok-propose add-dark-mode +AI: Created spok/changes/add-dark-mode/ + + proposal.md - why we're doing this, what's changing + + specs/ - requirements and scenarios + + design.md - technical approach + + tasks.md - chunked checklist (3 chunks) + Run /spok-apply to ship the first chunk. + +You: /spok-apply +AI: Shipping chunk 1: Add theme context + CSS variables + [runs research -> design -> plan -> implement -> review -> commit] + + Chunk shipped. 2/3 remaining. + +You: /spok-apply +AI: Shipping chunk 2: Wire toggle component to localStorage + + Chunk shipped. 1/3 remaining. + +You: /spok-apply +AI: Shipping chunk 3: Apply theme to remaining surfaces + + Chunk shipped. 0/3 remaining. Run /spok-archive. + +You: /spok-archive +AI: Applied delta specs to spok/specs/ui/spec.md + Archived to spok/changes/archive/2026-05-26-add-dark-mode/ +``` + +## What Spok Creates + +Spok keeps planning artifacts next to your code: + +```text +spok/ +├── specs/ # Source-of-truth behavior specs +├── changes/ +│ └── add-dark-mode/ +│ ├── proposal.md # Intent, scope, and approach +│ ├── specs/ # Delta specs for this change +│ ├── design.md # Technical design +│ └── tasks.md # Chunked implementation checklist +└── config.toml # Optional project config +``` + +Each change is isolated until you archive it. During archive, Spok applies the delta specs to `spok/specs/` and moves the completed change into history. + +## Why Spok? + +AI coding assistants are powerful, but they get unpredictable when requirements live only in chat history. Spok adds a lightweight spec layer so the human and agent agree before implementation starts. + +- **Agree before you build** - capture intent, requirements, and design before code changes. +- **Stay organized** - keep every proposed change in its own folder with specs, design, and tasks. +- **Explore before proposing** - use `/spok-explore` as a thinking-only mode when the direction is still unclear. +- **Ship one chunk at a time** - `/spok-apply` runs a full research -> design -> plan -> implement -> review -> commit loop for one chunk, then stops. +- **Use your tools** - Spok works with 25+ AI coding tools and does not lock you into one IDE or model. + +## Docs + +- **[Getting Started](docs/getting-started.md)**: first steps +- **[Workflows](docs/workflows.md)**: combos and patterns +- **[Commands](docs/commands.md)**: slash commands & skills +- **[CLI](docs/cli.md)**: terminal reference +- **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths +- **[Concepts](docs/concepts.md)**: how it all fits +- **[Multi-Language](docs/multi-language.md)**: multi-language support +- **[Migration Guide](docs/migration-guide.md)**: upgrading from older Spok versions + +## Philosophy + +```text +-> fluid, not rigid +-> iterative, not waterfall +-> easy, not complex +-> built for brownfield, not just greenfield +-> scalable from personal projects to enterprises +``` + +### How we compare + +**vs. [Spec Kit](https://github.com/github/spec-kit)** (GitHub) - Thorough but heavyweight. Rigid phase gates, lots of Markdown, Python setup. Spok is lighter and lets you iterate freely. + +**vs. [Kiro](https://kiro.dev)** (AWS) - Powerful but you're locked into their IDE and limited to Claude models. Spok works with the tools you already use. + +**vs. nothing** - AI coding without specs means vague prompts and unpredictable results. Spok brings predictability without the ceremony. + +## Updating Spok + +Pull and rebuild the source checkout: + +```bash +cd "$HOME/.local/share/spok" +git pull --ff-only +bun install +bun run build +``` + +Refresh agent instructions: + +Run this inside each project to regenerate AI guidance and ensure the latest skills are active: + +```bash +spok update +``` + +## Usage Notes + +**Model selection**: Spok works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation. + +**Context hygiene**: Spok benefits from a clean context window. Clear your context before starting implementation and maintain good context hygiene throughout your session. + +## Contributing + +**Small fixes** - Bug fixes, typo corrections, and minor improvements can be submitted directly as PRs. + +**Larger changes** - For new features, significant refactors, or architectural changes, please submit a Spok change proposal first so we can align on intent and goals before implementation begins. + +When writing proposals, keep the Spok philosophy in mind: we serve a wide variety of users across different coding agents, models, and use cases. Changes should work well for everyone. + +**AI-generated code is welcome** - as long as it's been tested and verified. PRs containing AI-generated code should mention the coding agent and model used (e.g., "Generated with Claude Code using claude-opus-4-5-20251101"). + +### Development + +- Install dependencies: `bun install` +- Build: `bun run build` +- Test: `bun run test` +- Develop CLI locally: `bun run dev` or `bun run dev:cli` +- Point global `spok` at this checkout: `ln -sf "$PWD/bin/spok.js" ~/.bun/bin/spok` +- Keep the linked CLI current while editing: run `bun run dev` in one terminal, then use `spok ...` in another +- Commit messages: short and imperative, one line ("Add verdict gate", not "feat: added verdict gates") + +## Other + +
+Telemetry + +Spok collects anonymous usage stats. + +We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI. + +**Opt-out:** `export SPOK_TELEMETRY=0` or `export DO_NOT_TRACK=1` + +
+ +
+Maintainers & Advisors + +See [MAINTAINERS.md](MAINTAINERS.md) for the list of core maintainers and advisors who help guide the project. + +
+ +## License + +MIT diff --git a/a.txt b/a.txt deleted file mode 100644 index 5626abf..0000000 --- a/a.txt +++ /dev/null @@ -1 +0,0 @@ -one From b8b13259bcff482b7d1c194623b656d5e01444f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Tue, 25 Aug 2026 13:00:27 +0200 Subject: [PATCH 4/4] fix: harden lazy capability materialization --- assets/skills/spok-ci-commit/SKILL.md | 8 +- src/commands/workflow/flow.ts | 87 +++++++++++++++++-- src/core/skill-vendor.ts | 73 +++++++++++++--- test/commands/workflow/flow.test.ts | 97 +++++++++++++++------- test/core/skill-vendor.test.ts | 41 +++++++++ test/features/tool-skill-artifacts.feature | 7 ++ test/skills/artifact-grounding.test.ts | 10 +++ 7 files changed, 270 insertions(+), 53 deletions(-) diff --git a/assets/skills/spok-ci-commit/SKILL.md b/assets/skills/spok-ci-commit/SKILL.md index 1fe9646..67ac094 100644 --- a/assets/skills/spok-ci-commit/SKILL.md +++ b/assets/skills/spok-ci-commit/SKILL.md @@ -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 status --porcelain` and `git -C diff` to see what - actually changed. + - Run `git -C status --porcelain --untracked-files=all` and + `git -C 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 diff --git a/src/commands/workflow/flow.ts b/src/commands/workflow/flow.ts index 2617a78..37268db 100644 --- a/src/commands/workflow/flow.ts +++ b/src/commands/workflow/flow.ts @@ -155,6 +155,7 @@ export interface WorkflowState { status: FlowRunState; steps: FlowStep[]; repairAttempts: number; + materializedCapabilityPaths: string[]; createdAt: string; updatedAt: string; } @@ -476,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[] = []; @@ -506,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)); } @@ -627,6 +643,7 @@ function createInitialState(taskDir: string, profile: FlowProfile): WorkflowStat stepFromDefinition(definition, index === 0 ? 'ready' : 'pending') ), repairAttempts: 0, + materializedCapabilityPaths: [], createdAt: timestamp, updatedAt: timestamp, }; @@ -772,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, }; @@ -876,8 +902,11 @@ const SKILLS_DIR_BY_RUNNER: Record = { * to materialize into, so resolution is skipped and discovery falls back to * whatever the harness already has installed. */ -async function ensureStepCapability(taskDir: string, step: FlowStep): Promise { - const projectRoot = findProjectRootForTaskDir(taskDir); +async function ensureStepCapability( + state: WorkflowState, + step: FlowStep +): Promise { + const projectRoot = findProjectRootForTaskDir(state.taskDir); if (!projectRoot) return; const result = await ensureVendoredSkill(projectRoot, SKILLS_DIR_BY_RUNNER[step.runner], step.skill); @@ -889,7 +918,13 @@ async function ensureStepCapability(taskDir: string, step: FlowStep): Promise + 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. @@ -1065,7 +1133,8 @@ function buildResponse( memory?.rules ?? [], state.repairAttempts, state.profile, - workRoot + workRoot, + materializedCapabilitiesInWorkRoot(state, workRoot) ); return { state: state.status, @@ -1556,7 +1625,7 @@ export async function getFlowNext(taskDirInput: string): Promise { const currentStep = getCurrentStep(loaded.state); if (currentStep) { - const capabilityBlock = await ensureStepCapability(loaded.state.taskDir, currentStep); + const capabilityBlock = await ensureStepCapability(loaded.state, currentStep); if (capabilityBlock) { const response = blockedResponse(loaded.state, capabilityBlock); await recordFlowResponse(response, 'flow_next'); diff --git a/src/core/skill-vendor.ts b/src/core/skill-vendor.ts index 5b0c86a..79e2218 100644 --- a/src/core/skill-vendor.ts +++ b/src/core/skill-vendor.ts @@ -12,6 +12,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'node:os'; +import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -69,6 +70,55 @@ function skillMarkerPath(root: string, toolSkillsDir: string, skillName: string) return path.join(root, toolSkillsDir, 'skills', skillName, 'SKILL.md'); } +function presentSkillResult( + projectMarker: string, + globalMarker: string +): EnsureSkillResult | undefined { + const marker = fs.existsSync(projectMarker) + ? projectMarker + : fs.existsSync(globalMarker) + ? globalMarker + : undefined; + return marker ? { status: 'present', skillPath: marker } : undefined; +} + +async function publishCopiedSkill( + tempSkill: string, + destSkill: string, + projectMarker: string +): Promise { + try { + await fs.promises.rename(tempSkill, destSkill); + return 'materialized'; + } catch { + if (fs.existsSync(projectMarker)) return 'present'; + } + + const staleSkill = `${tempSkill}.stale`; + let displacedStaleSkill = false; + try { + await fs.promises.rename(destSkill, staleSkill); + displacedStaleSkill = true; + } catch { + // Another materializer may have removed the incomplete destination. + } + + try { + await fs.promises.rename(tempSkill, destSkill); + return 'materialized'; + } catch (error) { + if (fs.existsSync(projectMarker)) return 'present'; + if (displacedStaleSkill) { + await fs.promises.rename(staleSkill, destSkill).catch(() => {}); + } + throw error; + } finally { + if (fs.existsSync(projectMarker)) { + await fs.promises.rm(staleSkill, { recursive: true, force: true }).catch(() => {}); + } + } +} + /** * Ensure one vendored skill is discoverable for a tool, materializing it from * the Spok distribution into the project when missing. @@ -90,28 +140,29 @@ export async function ensureVendoredSkill( return { status: 'present', skillPath: projectMarker }; } + const homeDir = options.homeDir ?? os.homedir(); + const globalMarker = skillMarkerPath(homeDir, toolSkillsDir, skillName); const srcSkill = path.join(sourceDir, skillName); if (fs.existsSync(path.join(srcSkill, 'SKILL.md'))) { const destSkill = path.dirname(projectMarker); - const tempSkill = `${destSkill}.tmp-${process.pid}`; + const suffix = `${process.pid}-${randomUUID()}`; + const tempSkill = `${destSkill}.tmp-${suffix}`; try { await copyDir(srcSkill, tempSkill); - await fs.promises.rename(tempSkill, destSkill); + const status = await publishCopiedSkill(tempSkill, destSkill, projectMarker); + return { status, skillPath: projectMarker }; } catch (error) { - await fs.promises.rm(tempSkill, { recursive: true, force: true }).catch(() => {}); - // A concurrent run may have won the rename; only that makes the miss benign. - if (!fs.existsSync(projectMarker)) { - return { + return ( + presentSkillResult(projectMarker, globalMarker) ?? { status: 'unavailable', reason: `could not materialize ${skillName}: ${(error as Error).message}`, - }; - } + } + ); + } finally { + await fs.promises.rm(tempSkill, { recursive: true, force: true }).catch(() => {}); } - return { status: 'materialized', skillPath: projectMarker }; } - const homeDir = options.homeDir ?? os.homedir(); - const globalMarker = skillMarkerPath(homeDir, toolSkillsDir, skillName); if (fs.existsSync(globalMarker)) { return { status: 'present', skillPath: globalMarker }; } diff --git a/test/commands/workflow/flow.test.ts b/test/commands/workflow/flow.test.ts index 83211f5..6db4de3 100644 --- a/test/commands/workflow/flow.test.ts +++ b/test/commands/workflow/flow.test.ts @@ -16,6 +16,7 @@ import { getFlowStatus, WORKFLOW_STATE_FILE, } from '../../../src/commands/workflow/flow.js'; +import * as skillVendor from '../../../src/core/skill-vendor.js'; interface FlowHarness { readonly projectRoot: string; @@ -1586,6 +1587,14 @@ async function advanceToCommit(flow: FlowHarness, workRoot: string): Promise { + await fs.writeFile( + path.join(flow.projectRoot, 'spok', 'config.yaml'), + 'schema: spec-driven\n', + 'utf-8' + ); +} + describe('work root attribution', () => { const flow = useFlowHarness(); const repo = useWorkRootRepo(); @@ -1793,16 +1802,8 @@ describe('commit SHA verification', () => { describe('lazy step capability resolution', () => { const flow = useFlowHarness(); - async function markProjectRoot(): Promise { - await fs.writeFile( - path.join(flow.projectRoot, 'spok', 'config.yaml'), - 'schema: spec-driven\n', - 'utf-8' - ); - } - it('materializes the active step skill for its runner inside a Spok project', async () => { - await markProjectRoot(); + await markFlowProjectRoot(flow); const response = await getFlowNext(flow.taskDir); @@ -1830,11 +1831,16 @@ describe('lazy step capability resolution', () => { expect( eventsAfter.filter((event) => event.event === 'capability_materialized') ).toHaveLength(1); + + const state = JSON.parse( + await fs.readFile(path.join(flow.taskDir, WORKFLOW_STATE_FILE), 'utf-8') + ); + expect(state.materializedCapabilityPaths).toEqual([path.dirname(marker)]); }); it('materializes codex-runner skills into the codex skills dir on hybrid runs', async () => { process.env.SPOK_FLOW_PROFILE = 'hybrid'; - await markProjectRoot(); + await markFlowProjectRoot(flow); const response = await getFlowNext(flow.taskDir); @@ -1859,29 +1865,58 @@ describe('lazy step capability resolution', () => { ).rejects.toThrow(); }); - it('blocks without consuming the step when the capability cannot be materialized, then resumes', async () => { - await markProjectRoot(); - // A file where the skills dir must go makes materialization fail deterministically. - const obstruction = path.join(flow.projectRoot, '.claude'); - await fs.writeFile(obstruction, 'not a directory', 'utf-8'); + it('blocks without consuming the step when no local or global capability is available, then resumes', async () => { + const ensureSkill = vi.spyOn(skillVendor, 'ensureVendoredSkill').mockResolvedValue({ + status: 'unavailable', + reason: 'test capability is unavailable', + }); + try { + await markFlowProjectRoot(flow); + + const blocked = await getFlowNext(flow.taskDir); + + expect(blocked.state).toBe('blocked'); + expect(blocked.reason).toContain('Capability unavailable for step validate-problem'); + await expect( + fs.access(path.join(flow.taskDir, WORKFLOW_STATE_FILE)) + ).rejects.toThrow(); + const events = await readFlowEvents(flow.taskDir); + expect(events.at(-1)).toMatchObject({ + event: 'flow_next', + state: 'blocked', + code: 'capability_unavailable', + }); - const blocked = await getFlowNext(flow.taskDir); + ensureSkill.mockRestore(); + const resumed = await getFlowNext(flow.taskDir); + expect(resumed.state).toBe('ready'); + expect(resumed.step?.id).toBe('validate-problem'); + } finally { + ensureSkill.mockRestore(); + } + }); +}); - expect(blocked.state).toBe('blocked'); - expect(blocked.reason).toContain('Capability unavailable for step validate-problem'); - await expect( - fs.access(path.join(flow.taskDir, WORKFLOW_STATE_FILE)) - ).rejects.toThrow(); - const events = await readFlowEvents(flow.taskDir); - expect(events.at(-1)).toMatchObject({ - event: 'flow_next', - state: 'blocked', - code: 'capability_unavailable', - }); +describe('materialized capability commit accounting', () => { + const flow = useFlowHarness(); + + it('names only flow capability directories in the commit prompt', async () => { + await markFlowProjectRoot(flow); + await advanceToCommit(flow, flow.projectRoot); + + const response = await getFlowNext(flow.taskDir); + + expect(response.step?.id).toBe('commit'); + expect(response.step?.prompt).toContain('Generated capability directories in the work root:'); + expect(response.step?.prompt).toContain('`.claude/skills/spok-validate-problem/`'); + expect(response.step?.prompt).toContain('Every other unexplained changed path remains a blocker.'); + + const statePath = path.join(flow.taskDir, WORKFLOW_STATE_FILE); + const state = JSON.parse(await fs.readFile(statePath, 'utf-8')); + state.materializedCapabilityPaths.push(path.join(flow.projectRoot, 'src')); + await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); - await fs.rm(obstruction); - const resumed = await getFlowNext(flow.taskDir); - expect(resumed.state).toBe('ready'); - expect(resumed.step?.id).toBe('validate-problem'); + const reloaded = await getFlowStatus(flow.taskDir); + expect(reloaded.nextStep?.prompt).not.toContain('`src/`'); }); }); diff --git a/test/core/skill-vendor.test.ts b/test/core/skill-vendor.test.ts index d145700..d7b4717 100644 --- a/test/core/skill-vendor.test.ts +++ b/test/core/skill-vendor.test.ts @@ -234,6 +234,33 @@ describe('skill-vendor', () => { expect(again.status).toBe('present'); }); + it('replaces a non-empty project skill directory without a marker', async () => { + const skillDir = path.join(projectRoot, '.agents/skills/spok-helper'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'stale.md'), 'incomplete install\n'); + + const result = await ensureVendoredSkill(projectRoot, '.agents', 'spok-helper', { + sourceDir, + homeDir, + }); + + expect(result.status).toBe('materialized'); + expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(skillDir, 'stale.md'))).toBe(false); + }); + + it('reports a concurrent materialization winner as present', async () => { + const results = await Promise.all([ + ensureVendoredSkill(projectRoot, '.agents', 'spok-helper', { sourceDir, homeDir }), + ensureVendoredSkill(projectRoot, '.agents', 'spok-helper', { sourceDir, homeDir }), + ]); + + expect(results.map((result) => result.status).sort()).toEqual([ + 'materialized', + 'present', + ]); + }); + it('falls back to a global install when the distribution lacks the skill', async () => { const globalMarker = path.join(homeDir, '.claude/skills/spok-mystery/SKILL.md'); fs.mkdirSync(path.dirname(globalMarker), { recursive: true }); @@ -248,6 +275,20 @@ describe('skill-vendor', () => { expect(result.skillPath).toBe(globalMarker); }); + it('falls back to a global install when project materialization fails', async () => { + const globalMarker = path.join(homeDir, '.claude/skills/spok-flow/SKILL.md'); + fs.mkdirSync(path.dirname(globalMarker), { recursive: true }); + fs.writeFileSync(globalMarker, '# global\n'); + fs.writeFileSync(path.join(projectRoot, '.claude'), 'not a directory'); + + const result = await ensureVendoredSkill(projectRoot, '.claude', 'spok-flow', { + sourceDir, + homeDir, + }); + + expect(result).toEqual({ status: 'present', skillPath: globalMarker }); + }); + it('reports unavailable when no source can provide the skill', async () => { const result = await ensureVendoredSkill(projectRoot, '.claude', 'spok-mystery', { sourceDir, diff --git a/test/features/tool-skill-artifacts.feature b/test/features/tool-skill-artifacts.feature index a80142c..1e21b0d 100644 --- a/test/features/tool-skill-artifacts.feature +++ b/test/features/tool-skill-artifacts.feature @@ -183,6 +183,13 @@ Feature: Tool skill artifacts Then the workflow skill "spok-apply" under ".claude/skills" mentions "spok flow next" And the workflow skill "spok-apply" under ".claude/skills" mentions "capability_unavailable" + Scenario: Commit accounting excludes only flow-generated capabilities + Given a new project + When I initialize Spok for the tools "claude" + Then the workflow skill "spok-ci-commit" under ".claude/skills" mentions "status --porcelain --untracked-files=all" + And the workflow skill "spok-ci-commit" under ".claude/skills" mentions "Exclude only the generated capability directories named in the dispatching prompt" + And the workflow skill "spok-ci-commit" under ".claude/skills" mentions "Every other unexplained changed path remains a blocker" + Scenario: Inner flow implementation overrides standalone orchestration Given a new project When I initialize Spok for the tools "claude" diff --git a/test/skills/artifact-grounding.test.ts b/test/skills/artifact-grounding.test.ts index 471a686..e681ab1 100644 --- a/test/skills/artifact-grounding.test.ts +++ b/test/skills/artifact-grounding.test.ts @@ -173,6 +173,16 @@ describe('spok-ci-commit grounding rules', () => { expect(body).toContain('**Fail loudly instead of falling back to a directory scan.**'); expect(body).toContain('do not widen the search to another directory'); }); + + it('excludes only capability paths named by the flow', async () => { + const body = await readSkill('spok-ci-commit'); + + expect(body).toContain('status --porcelain --untracked-files=all'); + expect(body).toContain( + 'Exclude only the generated capability directories named in the dispatching prompt' + ); + expect(body).toContain('Every other unexplained changed path remains a blocker'); + }); }); });