HookStack
Back to catalogue
DocumentationStop

Force implementation doc at stop

No code change ships without a doc/implementation/ trace

Blocks the session from ending if source files were modified but no doc/implementation/ file was created or updated. Detects changes via git diff and untracked files; ignores tests, config, and internal .claude/ scripts. No-op on non-git projects.

What does the Force implementation doc at stop hook do?

Force implementation doc at stop is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. No code change ships without a doc/implementation/ trace.

As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Zero documentation debt after an agent session
  • Onboarding — always find a doc/implementation/<feature>.md explaining the why and how
  • Maintenance — never guess what the agent did from git diff alone

Tags

#documentation#knowledge-transfer#onboarding#definition-of-done

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-force-implementation-doc.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/stop-force-implementation-doc.mjs

#!/usr/bin/env node
// @hookstack stop-force-implementation-doc
// Bloque la fin de session si du code source a été modifié sans qu'un fichier
// okf/implementation/ ait été créé ou mis à jour. Garantit zéro dette de doc. (Stop)
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

function defaultExec(cmd, opts) {
	return execSync(cmd, { encoding: "utf8", timeout: 10_000, ...opts });
}

const CODE_EXTS =
	/\.(ts|tsx|mjs|cjs|js|jsx|css|scss|sass|py|go|rs|java|rb|php|swift|kt)$/;
const SKIP_PATH =
	/^(node_modules\/|\.git\/|dist\/|build\/|out\/|\.next\/|tests?\/|__tests?__\/|doc\/|docs\/|\.claude\/|public\/)/;
const SKIP_NAME = /(\.(test|spec)\.[a-z]+$|\.json$|\.md$|\.lock$|\.txt$|\.env)/;

function isSourceFile(p) {
	return CODE_EXTS.test(p) && !SKIP_PATH.test(p) && !SKIP_NAME.test(p);
}

function isImplDoc(p) {
	return p.startsWith("okf/implementation/");
}

export function run(
	_input,
	{
		exec = defaultExec,
		projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
	} = {},
) {
	let files;
	try {
		const modified = exec("git diff --name-only HEAD", { cwd: projectDir });
		const untracked = exec("git ls-files --others --exclude-standard", {
			cwd: projectDir,
		});
		files = [
			...new Set(
				[...modified.split("\n"), ...untracked.split("\n")]
					.map((l) => l.trim())
					.filter(Boolean),
			),
		];
	} catch {
		return null; // pas un dépôt git ou git absent → no-op
	}

	if (!files.some(isSourceFile)) return null;
	if (files.some(isImplDoc)) return null;

	return {
		exitCode: 2,
		message:
			`[force-implementation-doc] Source code was modified but okf/implementation/ was not updated.\n` +
			`→ Create or update okf/implementation/<feature-name>.md explaining your technical choices\n` +
			`  and the implementation before ending this session.\n`,
	};
}

/* v8 ignore next 6 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result?.message) process.stderr.write(result.message);
	if (result?.exitCode) process.exit(result.exitCode);
}

Learn more

Related hooks