Build Log 1 — Proving the Riskiest Assumption

Published on
A blueprint-style chain receding into fog with one amber-lit link holding the whole chain, a faint four-stage pipeline ghosted behind it, marker-blueprint style

Every migration has one assumption that sinks the whole plan if it's wrong. Mine had eight steps mapped out in Build Log 0, and every one of them sat on top of a single bet I had never actually tested.

The bet: a spec-kit lifecycle hook can write my state file, and the SpecKit Companion GUI lights up from it, with zero changes to the GUI.

That sounds small. It isn't. spec-kit hooks are prompt-driven and agent-mediated. They fire only if the user runs a spec-kit command and the agent decides to obey the hook. No daemon, no guaranteed callback. If that chain doesn't hold, nothing else in the migration matters. So step 1 was never going to be a feature. It's a spike: prove the chain or break it, in the smallest PR I can write, before building anything on top of it.

What I built

The whole thing is three files living beside the VS Code GUI, in the same repo, mirroring spec-kit's own bundled git extension: manifest, command-markdown, script.

# 📃 speckit-extension/extension.yml
extension:
  id: companion
hooks:
  after_specify:
    command: speckit.companion.capture
    optional: false # 👇 the agent runs this automatically, not "on offer"
  • extension.yml registers id: companion and one after_specify hook.
  • commands/speckit.companion.capture.md is the command the hook runs. It carries no logic. It just says "run this script."
  • scripts/write-context.py is a stdlib-only Python writer.

The writer creates or updates specs/<NNN>-<slug>/.spec-context.json with the values the Companion expects: currentStep: specify, status: specified, and an appended transition tagged by: extension. It's a read-merge-write, so it preserves keys it doesn't own (like reviewComments), keeps transitions append-only, and writes atomically with a temp-and-rename.

The only change on the GUI side was adding implemented to the canonical status enum so the JSON schema matched the TypeScript type. That's it. One hook, no fallback, no status or resume commands. All of that is later steps.

Does it actually work?

Two halves: the deterministic one, and the live one.

The deterministic half ran the writer exactly the way the hook invokes it, resolving the active feature directory through .specify/feature.json, and produced a valid canonical file. The probe and regression tests pass: append-only transitions, unknown-key preservation, and a no-backward-clobber guard I'll come back to.

The live half is where I lost an afternoon. My first specify extension add failed with "No such command 'extension'." The stock PyPI specify-cli (v1.0.0) only ships init, check, and version. The entire extension subsystem lives in the github-source build. If you hit the same wall, this is the fix:

# 👇 the extension subsystem isn't on PyPI yet, install from source
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git --force
specify extension add ./speckit-extension --dev
Terminal output showing the SpecKit Companion extension installed successfully via specify extension add

That --dev install copied the extension into .specify/extensions/companion/, wrote the registry entry, and emitted the per-agent commands. Then I ran it on a real feature in command-center, /speckit.specify for a queue as a child of articles change, and watched the chain run. spec-kit wrote the spec on main. Then, with no prompt and no nudge from me, the companion hook fired on its own:

Extension Hooks
  Automatic Hook: companion
  Executing: /speckit-companion-capture

⏺ Skill(speckit-companion-capture) — Successfully loaded skill
⏺ Bash: python3 speckit-extension/scripts/write-context.py --feature-dir specs/11-queue-child-of-articles --step specify --status specified --by extension
  ⎿ Error: can't open file 'command-center/speckit-extension/scripts/write-context.py': No such file or directory
⏺ The writer script isn't at the expected path. Let me locate it.
⏺ Bash: python3 .specify/extensions/companion/scripts/write-context.py --feature-dir specs/11-queue-child-of-articles --step specify --status specified --by extension
  ⎿ [companion] Updated specs/11-queue-child-of-articles/.spec-context.json (currentStep=specify, status=specified, by=extension)

That auto-fire is the whole point of the spike. Because I registered the hook as optional: false, the agent ran it automatically. The tell: the bundled git extension's commit hook is optional: true, so it only offers itself with a "To execute" line. Companion's just ran. The riskiest link holds.

It bit me on the way, though. The command pointed at the dev-source path, speckit-extension/scripts/write-context.py, instead of the installed .specify/extensions/companion/scripts/write-context.py, so it errored with No such file or directory. The agent found the real script and recovered, so the capture still landed. But it's a real bug: the command-markdown hardcodes the repo-layout path instead of the install location. spec-kit's own git extension gets this right by referencing .specify/extensions/git/scripts/…. I only caught it by running the thing on a real repo instead of the one I built it in.

Here's the file it wrote, on a plain spec-kit project running on main:

{
  "workflow": "speckit",
  "specName": "Queue as a Child of Articles",
  "branch": "main",
  "currentStep": "specify",
  "status": "specified",
  "updated": "2026-06-07",
  "stepHistory": {
    "specify": {
      "startedAt": "2026-06-07T18:23:18.063Z",
      "completedAt": "2026-06-07T18:23:18.063Z"
    }
  },
  "transitions": [
    {
      "step": "specify",
      "substep": null,
      "from": null,
      "by": "extension",
      "at": "2026-06-07T18:23:18.063Z"
    }
  ]
}

workflow: "speckit", not sdd, with branch: "main", proves it works on a vanilla spec-kit project with no SDD and no special branch setup. The by: "extension" transition is the hook's own write, and stepHistory is the timeline the Companion GUI reads.

Terminal showing the companion hook auto-firing, the write-context.py path error, and the agent recovering at the installed script path
SpecKit Companion sidebar showing the spec at specify / specified after the hook fired

Why I built it this way

A few choices I'd defend to anyone copying this:

  • Mirror the bundled git extension. spec-kit's /speckit.specify template already reads .specify/extensions.yml and runs registered hooks. Match its shape and the whole thing rides machinery that already exists. Nothing custom at runtime.
  • Python, stdlib-only. It matches spec-kit's own runtime and stays cross-platform without a second script for Windows.
  • Schema owned by the GUI repo, written directly. No vendored copy means no cross-repo drift. That's why the only GUI change was one enum value.
  • Branching stays with the git extension. No double-branching, no fighting over who owns the branch.
  • A code-review catch I kept. The writer must never drag a shipped spec backward to specify. That's the exact "mis-renders a shipped spec" failure ADR 0003 flagged. A monotonic guard makes sure a stray hook can't regress finished work.

What's next

First, a quick fix. Point the capture command at the installed .specify/extensions/companion/scripts/… path so the hook runs clean on any repo, not just the one I built it in.

Then Build Log 2: full lifecycle capture. Hooks on after_plan, after_tasks, and after_implement, plus a derive-from-files fallback that reconstructs state from the artifacts on disk and git history when a hook didn't fire. That's the honest state engine the whole product reads from.

Want to follow along? The public backlog tracks every step as it merges. Watch the repo, and I'll see you at the next one.