From 42646206cc8b5d0cd271dfb314cecb7d7232f2ac Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 8 Sep 2026 03:21:58 +0200 Subject: [PATCH 1/2] feat(receipts): project-aware verification hint and shared code-extension list The TaskCompleted refusal used to list every ecosystem's commands. It now names only the commands of the ecosystem detected for the project: root markers in the working directory and in the first two path segments of the files changed in the session (monorepos), plus the extensions of those files. bun test and bunx tsc on a Bun repo, npm or pnpm or yarn test per lockfile, pytest with mypy or pyright when configured, go test and go vet, cargo test and cargo check, php artisan test or phpunit or pest with phpstan when configured, swift test, flutter test or dart test; the generic list only when nothing is detected. The SubagentStop advisory uses the same hint. A single shared code-extension list (code-extensions.ts) now feeds session change tracking, the completion gate and the hint; Dart files were gated but never tracked, so the Dart branch could not fire, fixed. Paths coming from session state are confined to the working directory before any disk read; search directories are deduplicated. Verified on 30 real fixture projects through the hook binary; tests 1837 to 1857. --- MEMORY/LESSON.md | 4 +- README.md | 6 +- src/runtime/lifecycle/agent-memory.ts | 9 +- src/runtime/lifecycle/code-extensions.ts | 27 +++++ src/runtime/lifecycle/receipt-hint.ts | 139 +++++++++++++++++++++++ src/runtime/lifecycle/task-completed.ts | 20 ++-- src/runtime/lifecycle/track-changes.ts | 6 +- test/receipt-hint.test.ts | 118 +++++++++++++++++++ test/task-completed.test.ts | 14 +++ test/track-changes.test.ts | 34 ++++++ 10 files changed, 357 insertions(+), 20 deletions(-) create mode 100644 src/runtime/lifecycle/code-extensions.ts create mode 100644 src/runtime/lifecycle/receipt-hint.ts create mode 100644 test/receipt-hint.test.ts create mode 100644 test/track-changes.test.ts diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md index 45e1fab..81b0555 100644 --- a/MEMORY/LESSON.md +++ b/MEMORY/LESSON.md @@ -20,8 +20,6 @@ - [2026-08-12 12:15] J'ai reçu une task-notification « sniper terminé — RAS, aucun bug, zéro modification », je l'ai prise pour la fin de son travail, et j'ai conclu qu'il avait esquivé la prototype pollution que je lui avais explicitement demandée. J'ai alors (a) sondé moi-même et trouvé le défaut réel — 9 clés héritées corrompues sur 10 —, (b) lancé un agent d'écriture (`sniper-faster`) pour le corriger, (c) accusé le sniper dans mon rapport au proprio, (d) écrit une leçon sur son « PASS sans exécution ». TOUT ÇA ÉTAIT FAUX : le sniper n'avait pas fini. Il a trouvé le même défaut, l'a reproduit, corrigé en `Object.create(null)` et couvert par un test `5b` — c'est LUI l'auteur de l'écriture de 12:10:36 que j'ai attribuée à un autre agent sans vérifier. J'ai donc lancé un écrivain concurrent PENDANT que le sniper écrivait, violant ma propre règle « sniper après, jamais pendant ». Collision évitée de justesse parce que l'agent de fix a re-vérifié sa prémisse sur disque et s'est arrêté. → Une task-notification n'est PAS une fin de travail : elle se déclenche à chaque fois qu'un agent s'arrête sans enfant vivant, et l'agent peut reprendre. Avant de conclure qu'un agent a raté quelque chose, ou de lancer quoi que ce soit sur son périmètre : lui DEMANDER son état, et attribuer toute écriture constatée à un auteur PROUVÉ (mtime + qui était actif), jamais au premier suspect. Accuser un agent à tort coûte un correctif redondant, un rapport faux au proprio, et une leçon à réécrire. [TRIGGERS tool:Agent keyword:notification,terminé,idle,RAS,zéro modification,concurrent,sniper,accusé,attribution] -- [2026-08-12 12:07] Un exécuteur s'est déclaré « idle/available » sans avoir écrit une seule ligne : le gate de fraîcheur APEX DU HARNAIS QU'ON CORRIGEAIT avait bloqué son `Write` (y compris dans le scratchpad), il avait lancé un `research-expert` pour se débloquer — et la notification de fin de CE sous-agent est remontée au LEAD, pas à son parent. L'exécuteur attendait donc un signal déjà arrivé, ailleurs. Interblocage silencieux : aucune erreur, aucun timeout, juste un agent qui ne repart jamais. Débloqué en lui renvoyant le verdict à la main. → Un « idle » sans livrable n'est pas une fin de tâche, c'est une alarme : demander l'état réel AVANT de conclure quoi que ce soit, et vérifier soi-même `git status` + le scratchpad plutôt que de croire un statut. Corollaire structurel : la notification d'un sous-agent lancé par un sous-agent remonte au lead — quand un agent délégué en spawne un autre, prévoir que c'est le lead qui recevra le signal et devra le relayer (arrivé 3 fois dans la même session ; à chaque fois le parent attendait un verdict déjà chez moi). SUITE 12:28 — l'autre bout du même tuyau : DEUX challengers d'affilée ont rédigé leur rapport en SORTIE TEXTE, qui ne remonte pas au lead. Le premier avait terminé son analyse complète depuis longtemps ; je l'ai cru muet, puis mort — `ListAgents` répondait « No reachable agents » alors qu'il a répondu normalement à la sonde suivante. → Deux règles : (a) inscrire dans le BRIEF INITIAL de tout agent que son SEUL canal de retour est `SendMessage` vers `team-lead`, sa sortie texte étant invisible ; (b) ne jamais conclure à la mort d'un agent sur `ListAgents` — le sonder par message d'abord, c'est gratuit et ça a détrompé deux fois. SUITE 12:15 — corollaire opérationnel confirmé deux fois dans la même session : un agent annoncé terminé ou « idle » peut encore écrire (cf. la leçon 12:15 : le sniper a réécrit `src/runtime/mcp-tool-name.ts` à 12:10:36 APRÈS m'avoir notifié « terminé, zéro modification »). D'où deux gardes systématiques : (a) tout mandat qui affirme « reproduit à l'instant » impose à son destinataire de re-vérifier la prémisse sur DISQUE avant sa première écriture et de s'arrêter si elle est tombée — c'est ce réflexe, et lui seul, qui a évité une écriture concurrente ce jour-là ; (b) encadrer toute mesure d'un `stat` des mtimes AVANT et APRÈS — mtimes identiques = mesure valide, sinon elle est à refaire. [TRIGGERS tool:Agent keyword:idle,available,bloqué,notification,sous-agent,attente,freshness,gate,concurrent,mtime,prémisse] - - [2026-09-02 12:11] Un mandat détaillé (RED commands, « 25 fichiers modifiés », « version 0.1.90 ») décrivait un état ANTÉRIEUR à la PR #100 : arbre propre, 0.1.91 déjà publiée, les 3 RED déjà verts. Re-mesurer git status + version npm + chaque RED command AVANT le moindre brief a évité de relancer 7 « points restants » déjà faits. → Un mandat écrit n'est pas une mesure : rejouer ses commandes de preuve sur le HEAD courant d'abord, et re-baseliner le périmètre sur l'écart réel. [TRIGGERS keyword:mandat,RED,re-baseline,déjà fait,périmètre,prompt obsolète] - [2026-09-02 12:11] Payloads Cursor AUTHENTIQUES : Cursor journalise chaque exécution de hook (INPUT JSON complet + OUTPUT + diagnostics) dans `~/Library/Application Support/Cursor/logs/**/cursor.hooks*.log` ; le runtime réel des hooks est dans le worker `~/Library/Application Support/Cursor/User/globalStorage/anysphere.cursor-agent-worker/agent-cli/.local/share/cursor-agent/versions//{index.js,190.index.js}`, pas seulement dans Cursor.app. Piège : `find | xargs grep` casse sur l'espace de « Application Support » (résultat vide silencieux) → `-print0 | xargs -0` ou glob Python. [TRIGGERS keyword:cursor,hooks log,payload authentique,capture,agent-worker,Application Support,xargs] @@ -105,3 +103,5 @@ - [2026-09-07 16:16] J'ai affirmé au propriétaire que ses agents tournaient sur le modèle de session, déduit de l'absence de champ `model` dans les définitions lues. Faux : les transcriptions réelles (`"model":` dans tasks/*.output) montrent Sonnet pour les exécuteurs et Opus pour le challenger. Même faute de méthode que les reçus : conclure de l'absence d'une trace au lieu de lire la mesure disponible. → Toute question « quel modèle / quelle version / quel binaire a tourné » se répond dans la transcription ou le log d'exécution, jamais par déduction depuis la configuration. Et les parseurs de sortie d'outil se calibrent sur des captures RÉELLES (cargo aligne `Finished` à 12 colonnes, `pytest -q` n'a pas de `=`, PHPUnit dit « OK, but… »), pas sur des exemples de doc. [TRIGGERS keyword:quel modèle,sonnet,opus,transcription,capture réelle,format de sortie,parseur] - [2026-09-07 16:27] Dans le brief de la 5e passe j'ai dicté moi-même la regex des options (`(?:--?[\w-]+(?:[= ]\S+)?\s+)*`) en affirmant « pas de forme (X*)* » : elle avait DEUX ambiguïtés (`--?` contre `[\w-]+` sur le tiret, valeur `[= ]\S+` avalant le drapeau suivant) → 17,8 s sur 9 000 caractères, trouvé par le sniper au chronomètre. → Une regex répétée sur des tokens reçoit toujours un test de temps (entrée hostile de 10 000 caractères sans correspondance finale, budget < 50 ms) dans les tests, pas une assertion verbale dans le brief ; et chaque alternance doit rendre le premier caractère de chaque token non ambigu (`--?[\w][\w-]*`, valeur `(?!-)`). [TRIGGERS keyword:regex,ReDoS,backtracking,quantificateur,options,PREFIX,OPTS] + +- [2026-09-08 02:47] Le nouveau `receiptHint` dérivait ses dossiers de recherche des chemins de fichiers modifiés (liste de session) sans garde de confinement : un chemin avec `..` faisait lire des marqueurs hors du dépôt. Le sniper l'a vu, pas le brief. → Tout chemin issu d'un état persistant ou d'un payload est non fiable : avant `existsSync`/`readFileSync`, appliquer l'idiome déjà présent dans le dépôt (`rel = relative(root, p)`, rejeter si `rel.startsWith("..")` ou `isAbsolute(rel)`), et l'écrire dans le brief dès qu'un module lit le disque à partir de chemins reçus. [TRIGGERS keyword:relative,resolve,existsSync,readFileSync,traversal,chemin,modifiedFiles,confinement] diff --git a/README.md b/README.md index 6d5ed90..68d5434 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,11 @@ Features shipped since 0.1.44, each with its own test: `build`/`test`), Rust (`cargo check`/`clippy`/`test`), PHP (`phpstan`, `phpunit`/`pest`/`php artisan test`), Swift (`swift build`/`test`), and Dart/ Flutter (`dart`/`flutter test`); `TaskCompleted` **refuses** a "done" over - modified code files without a fresh passing receipt. Commands are matched + modified code files without a fresh passing receipt — the refusal names the + commands of the detected ecosystem (project markers and the extensions of + the files changed in the session), falling back to the full cross-language + list only when nothing is detected (`src/runtime/lifecycle/receipt-hint.ts`). + Commands are matched after quote/heredoc stripping (a tool name mentioned in a commit message or heredoc body is never a receipt) — and the recognised runner must be the LAST command of the line: the unquoted text is split into shell list diff --git a/src/runtime/lifecycle/agent-memory.ts b/src/runtime/lifecycle/agent-memory.ts index 26c1bbd..3edddec 100644 --- a/src/runtime/lifecycle/agent-memory.ts +++ b/src/runtime/lifecycle/agent-memory.ts @@ -7,6 +7,7 @@ import { loadSessionState, sanitizeSessionId, saveSessionState, sessionsDir } fr import { defaultStateDir, trackFile } from "../paths"; import { freshReceiptFromFile } from "../../tracking/receipts"; import { attributeFiles, filesWrittenByAgent } from "./agent-files"; +import { receiptHint } from "./receipt-hint"; /** The `changes` block written by `track-changes.ts` into unified session state. */ interface Changes { @@ -21,6 +22,12 @@ function memoryDir(home: string): string { const SKIP_AGENTS = /(sniper|sniper-faster|explore-codebase|research-expert|claude-code-guide|Explore|Plan)/; +/** Collapse {@link receiptHint}'s full sentence into a short imperative clause for the advisory note. */ +function shortReceiptHint(cwd: string, files: readonly string[]): string { + const match = /^Run (.+?) \(exit 0, 0 failures\), then re-complete\.$/.exec(receiptHint(cwd, files)); + return match ? `run ${match[1]} before reporting done.` : "run your test suite and static checker before reporting done."; +} + /** Append the agent completion record to `agent-history.jsonl` (best effort). */ function recordHistory(home: string, agentId: string, agentType: string, ts: string): void { const dir = memoryDir(home); @@ -73,7 +80,7 @@ export function trackAgentMemory(data: Record, home: string = h // Window = TTL×5, matching the TaskCompleted receipt gate. const windowMs = resolveTtlSec(process.env) * 1000 * 5; const noReceipt = freshReceiptFromFile(trackFile(sessionId, defaultStateDir(process.cwd())), windowMs, now) === null; - const note = noReceipt ? " NO VERIFICATION RECEIPT — run your static checker + test suite before reporting done." : ""; + const note = noReceipt ? ` NO VERIFICATION RECEIPT — ${shortReceiptHint(hookCwd, present)}` : ""; return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${present.length} code file(s): ${present.join(", ")}. Run sniper agent now.${note}`); } } diff --git a/src/runtime/lifecycle/code-extensions.ts b/src/runtime/lifecycle/code-extensions.ts new file mode 100644 index 0000000..20f3660 --- /dev/null +++ b/src/runtime/lifecycle/code-extensions.ts @@ -0,0 +1,27 @@ +import { extname } from "node:path"; + +/** + * Dot-prefixed source-code extensions tracked across the lifecycle hooks + * (sniper change-tracking, SOLID/receipt validation, ecosystem detection). + * Single source of truth — replaces three lists that had independently + * drifted: `track-changes.ts` `CODE_EXT` (missing `dart`), `task-completed.ts` + * `CODE_EXTENSIONS` (missing `.mts`/`.cts`/`.mjs`/`.cjs`), and `receipt-hint.ts` + * `ECOSYSTEMS`' JS/TS extensions (listed `.mts`/`.cts` but the gate upstream + * never let them through). Keep the bash-write guard's own `CODE_EXT` in + * `src/policy/guards/bash-write-patterns.ts` separate — different concern + * (policy layer, not lifecycle hooks). + */ +export const CODE_EXTENSIONS: ReadonlySet = new Set([ + ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", + ".py", ".go", ".rs", ".java", ".php", ".cpp", ".c", ".rb", + ".swift", ".kt", ".dart", ".vue", ".svelte", ".astro", +]); + +/** + * Whether `path` has a tracked source-code extension (case-insensitive). + * @param path - File path to check. + * @returns True when `extname(path)` (lower-cased) is in {@link CODE_EXTENSIONS}. + */ +export function isCodeFile(path: string): boolean { + return CODE_EXTENSIONS.has(extname(path).toLowerCase()); +} diff --git a/src/runtime/lifecycle/receipt-hint.ts b/src/runtime/lifecycle/receipt-hint.ts new file mode 100644 index 0000000..d44bfa1 --- /dev/null +++ b/src/runtime/lifecycle/receipt-hint.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync } from "node:fs"; +import { extname, isAbsolute, join, relative, resolve } from "node:path"; + +/** Injectable filesystem existence check (defaults to a real disk check). */ +type Exists = (p: string) => boolean; + +/** Injectable UTF-8 file reader, for config-content checks (defaults to a real disk read). */ +type ReadText = (p: string) => string; + +/** One verification-command family (JS/TS, Python, Go, ...). */ +interface Ecosystem { + readonly extensions: readonly string[]; + readonly rootMarkers: readonly string[]; + readonly defaultCommand: string; + readonly detect: (dir: string, exists: Exists, readText: ReadText) => string; +} + +/** The generic, project-agnostic refusal — used only when nothing at all was detected. */ +const GENERIC_HINT = + "Run your test suite and static checker (bun test + tsc, pytest + mypy, go test + go vet, " + + "cargo test + cargo check, phpunit/pest + phpstan, swift test, dart test) with exit 0 and 0 " + + "failures, then re-complete."; + +/** Best-effort read; an unreadable/missing config file never crashes the gate. */ +function safeRead(path: string, readText: ReadText): string { + try { + return readText(path); + } catch { + return ""; + } +} + +/** JS/TS: the lockfile picks the test runner; the typechecker is appended only when a tsconfig exists. */ +function detectJsTs(dir: string, exists: Exists): string { + const bun = exists(join(dir, "bun.lock")) || exists(join(dir, "bun.lockb")); + const pm = bun ? "bun" : exists(join(dir, "pnpm-lock.yaml")) ? "pnpm" : exists(join(dir, "yarn.lock")) ? "yarn" : "npm"; + if (!exists(join(dir, "tsconfig.json"))) return `${pm} test`; + return `${pm} test + ${bun ? "bunx tsc --noEmit" : "tsc"}`; +} + +/** Python: pytest, plus pyright (`pyrightconfig.json`) or mypy (`mypy.ini` / `[tool.mypy]`) when configured. */ +function detectPython(dir: string, exists: Exists, readText: ReadText): string { + if (exists(join(dir, "pyrightconfig.json"))) return "pytest + pyright"; + const pyproject = join(dir, "pyproject.toml"); + const hasToolMypy = exists(pyproject) && safeRead(pyproject, readText).includes("[tool.mypy]"); + if (exists(join(dir, "mypy.ini")) || hasToolMypy) return "pytest + mypy"; + return "pytest"; +} + +/** PHP: prefer `php artisan test`, then Pest, then plain PHPUnit; append PHPStan when configured. */ +function detectPhp(dir: string, exists: Exists): string { + const base = exists(join(dir, "artisan")) ? "php artisan test" : exists(join(dir, "tests", "Pest.php")) ? "vendor/bin/pest" : "vendor/bin/phpunit"; + const phpstan = exists(join(dir, "phpstan.neon")) || exists(join(dir, "phpstan.neon.dist")); + return phpstan ? `${base} + vendor/bin/phpstan analyse` : base; +} + +/** Dart/Flutter: `flutter test` only when `pubspec.yaml` declares a `flutter:` key. */ +function detectDart(dir: string, exists: Exists, readText: ReadText): string { + const pubspec = join(dir, "pubspec.yaml"); + if (!exists(pubspec)) return "dart test"; + return /flutter:/.test(safeRead(pubspec, readText)) ? "flutter test" : "dart test"; +} + +/** The seven detectable ecosystems, in the same order as {@link GENERIC_HINT}. */ +const ECOSYSTEMS: readonly Ecosystem[] = [ + { extensions: [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"], rootMarkers: ["package.json"], defaultCommand: "bun test + tsc", detect: detectJsTs }, + { extensions: [".py"], rootMarkers: ["pyproject.toml", "setup.py", "requirements.txt", "pytest.ini"], defaultCommand: "pytest + mypy", detect: detectPython }, + { extensions: [".go"], rootMarkers: ["go.mod"], defaultCommand: "go test + go vet", detect: () => "go test ./... + go vet ./..." }, + { extensions: [".rs"], rootMarkers: ["Cargo.toml"], defaultCommand: "cargo test + cargo check", detect: () => "cargo test + cargo check" }, + { extensions: [".php"], rootMarkers: ["composer.json"], defaultCommand: "phpunit/pest + phpstan", detect: detectPhp }, + { extensions: [".swift"], rootMarkers: ["Package.swift"], defaultCommand: "swift test", detect: () => "swift test" }, + { extensions: [".dart"], rootMarkers: ["pubspec.yaml"], defaultCommand: "dart test", detect: detectDart }, +]; + +/** + * `cwd` plus, per modified file, its first two directory levels (owner-specified + * monorepo reach). Same containment idiom as `contains` in + * `src/adapters/cursor/context.ts` and `src/policy/prd/prd-paths.ts` + * (`!rel.startsWith("..") && !isAbsolute(rel)`); a modified file path that + * resolves outside `cwd` is skipped, never turned into a search dir. + * Deduped via `Set` (insertion order preserved) — thousands of modified + * files in a few directories would otherwise repeat the same `existsSync` + * probes per ecosystem marker. + */ +function searchDirs(cwd: string, files: readonly string[]): string[] { + const dirs = new Set([cwd]); + for (const f of files) { + const rel = relative(cwd, resolve(cwd, f)); + if (rel.startsWith("..") || isAbsolute(rel)) continue; + const [first, second] = rel.split(/[\\/]/).filter(Boolean); + if (first) dirs.add(join(cwd, first)); + if (first && second) dirs.add(join(cwd, first, second)); + } + return [...dirs]; +} + +/** + * Detect which language ecosystem(s) the modified files belong to (project + * markers in `cwd` and each file's first two directory levels, unioned with + * the files' own extensions) and name only the relevant verification + * commands — never the full cross-language list unless nothing matches. + * + * Deliberately NOT built on {@link detectProjectType} from + * `../../policy/detect-project`: that helper returns a single, first-match-wins + * `ProjectType` (nextjs beats nuxt beats ... beats go/rust/swift/generic) meant + * for framework/skill routing — a dir with both `package.json` and `go.mod` + * (a JS+Go monorepo root) collapses to one framework and would silently drop + * the other ecosystem's test command. `receiptHint` instead needs the UNION of + * every matching ecosystem across MULTIPLE searched dirs, plus per-ecosystem + * command shape (`detectJsTs`'s lockfile → package-manager pick, `detectPython`'s + * pyright/mypy config, `detectPhp`'s artisan/pest/phpunit + phpstan, + * `detectDart`'s `flutter:` key in `pubspec.yaml`) that `detectProjectType` has + * no equivalent for. Reusing it would be a lossy wrapper, not a simplification; + * see `src/policy/nearest-manifest.ts` `projectCaps` for a case where reuse DOES + * fit (three independent yes/no capability checks against one already-resolved + * dir, not a cross-directory language union). + * @param cwd - The hook's working directory (searched first). + * @param modifiedFiles - Files changed during the session (relative or absolute). + * @param exists - Injectable `fs.existsSync` (real disk by default). + * @param readText - Injectable UTF-8 file reader, for config-content checks (real disk by default). + * @returns One sentence naming the detected ecosystem's commands, or the generic list. + */ +export function receiptHint( + cwd: string, + modifiedFiles: readonly string[], + exists: Exists = existsSync, + readText: ReadText = (p) => readFileSync(p, "utf-8"), +): string { + const dirs = searchDirs(cwd, modifiedFiles); + const extensions = new Set(modifiedFiles.map((f) => extname(f))); + const parts: string[] = []; + for (const eco of ECOSYSTEMS) { + const markerDir = dirs.find((d) => eco.rootMarkers.some((m) => exists(join(d, m)))); + if (markerDir) parts.push(eco.detect(markerDir, exists, readText)); + else if (eco.extensions.some((e) => extensions.has(e))) parts.push(eco.defaultCommand); + } + if (parts.length === 0) return GENERIC_HINT; + return `Run ${parts.join(" and ")} (exit 0, 0 failures), then re-complete.`; +} diff --git a/src/runtime/lifecycle/task-completed.ts b/src/runtime/lifecycle/task-completed.ts index f5cfdb4..7e5c67f 100644 --- a/src/runtime/lifecycle/task-completed.ts +++ b/src/runtime/lifecycle/task-completed.ts @@ -8,12 +8,8 @@ import { countLines } from "../../policy/file-size"; import { loadSessionState, sanitizeSessionId } from "../home-state"; import { defaultStateDir, trackFile } from "../paths"; import { freshReceiptFromFile } from "../../tracking/receipts"; - -/** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */ -const CODE_EXTENSIONS = new Set([ - ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".php", - ".cpp", ".c", ".rb", ".swift", ".kt", ".dart", ".vue", ".svelte", ".astro", -]); +import { receiptHint } from "./receipt-hint"; +import { CODE_EXTENSIONS } from "./code-extensions"; /** Freshness multiple on `FUSE_ENFORCE_TTL_SEC` for receipts (no new env var); a tsc+test run precedes the "done" by more than one edit window. */ const RECEIPT_TTL_MULTIPLIER = 5; @@ -37,15 +33,14 @@ function codeFiles(files: string[]): string[] { * `{"continue":false,"stopReason":…}`, which halts the teammate with the reason * shown to the user. Returns that JSON, or `null` when the session is clear. */ -function receiptGate(sid: string, files: string[], now: number, stateDir: string): string | null { - if (codeFiles(files).length === 0) return null; +function receiptGate(sid: string, files: string[], now: number, stateDir: string, cwd: string): string | null { + const code = codeFiles(files); + if (code.length === 0) return null; const windowMs = resolveTtlSec(process.env) * 1000 * RECEIPT_TTL_MULTIPLIER; if (freshReceiptFromFile(trackFile(sid, stateDir), windowMs, now)) return null; const stopReason = "VERIFICATION RECEIPT REQUIRED: code files changed but no fresh passing verification receipt " + - "exists. Run your test suite and static checker (bun test + tsc, pytest + mypy, go test + go vet, " + - "cargo test + cargo check, phpunit/pest + phpstan, swift test, dart test) with exit 0 and 0 failures, " + - "then re-complete."; + "exists. " + receiptHint(cwd, code); return JSON.stringify({ continue: false, stopReason }); } @@ -84,7 +79,8 @@ export function validateTaskSolid(payload: Record, home: string if (files.length === 0) return ""; const max = resolveMaxLines(); const violations = collectViolations(files, max); - if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? ""; + const cwd = typeof payload.cwd === "string" ? payload.cwd : process.cwd(); + if (violations.length === 0) return receiptGate(sid, files, now, stateDir, cwd) ?? ""; const taskId = String(payload.task_id ?? ""); const subject = String(payload.task_subject ?? ""); const msg = diff --git a/src/runtime/lifecycle/track-changes.ts b/src/runtime/lifecycle/track-changes.ts index f7a9c95..551482a 100644 --- a/src/runtime/lifecycle/track-changes.ts +++ b/src/runtime/lifecycle/track-changes.ts @@ -5,9 +5,7 @@ import { loadSessionState, sanitizeSessionId, saveSessionState, sessionsDir } fr import { onceExclusive } from "../inject-dedup"; import { BURST_DEDUP_MS } from "../burst-window"; import { sniperRequiredNotice } from "../notices"; - -/** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */ -const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/; +import { isCodeFile } from "./code-extensions"; /** Shape of the `changes` block persisted in per-session state. */ interface Changes { @@ -28,7 +26,7 @@ interface Changes { * @returns The native hook stdout (possibly empty when not a code file). */ export function trackSessionChanges(sessionIdRaw: unknown, filePath: string, home: string = homedir(), now: number = Date.now()): string { - if (!filePath || !CODE_EXT.test(filePath)) return ""; + if (!filePath || !isCodeFile(filePath)) return ""; const sid = sanitizeSessionId(sessionIdRaw) ?? "unknown"; const state = loadSessionState(sid, home); const prev = (state.changes as Changes | undefined) ?? { cumulativeCodeFiles: 0, modifiedFiles: [] }; diff --git a/test/receipt-hint.test.ts b/test/receipt-hint.test.ts new file mode 100644 index 0000000..e400ee0 --- /dev/null +++ b/test/receipt-hint.test.ts @@ -0,0 +1,118 @@ +import { test, expect } from "bun:test"; +import { join } from "node:path"; +import { receiptHint } from "../src/runtime/lifecycle/receipt-hint"; + +const CWD = "/repo"; + +/** Build an `exists` fn true only for the given files (paths relative to {@link CWD}). */ +function fs(...relPaths: string[]): (p: string) => boolean { + const set = new Set(relPaths.map((p) => join(CWD, p))); + return (p: string) => set.has(p); +} + +test("receiptHint: bun project with tsconfig → bun test + bunx tsc --noEmit", () => { + const exists = fs("package.json", "bun.lock", "tsconfig.json"); + expect(receiptHint(CWD, ["a.ts"], exists)).toBe("Run bun test + bunx tsc --noEmit (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: pnpm project without tsconfig → pnpm test only", () => { + const exists = fs("package.json", "pnpm-lock.yaml"); + expect(receiptHint(CWD, ["a.ts"], exists)).toBe("Run pnpm test (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: python project with mypy.ini → pytest + mypy", () => { + const exists = fs("pyproject.toml", "mypy.ini"); + expect(receiptHint(CWD, ["a.py"], exists)).toBe("Run pytest + mypy (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: python project with pyrightconfig.json → pytest + pyright", () => { + const exists = fs("pyproject.toml", "pyrightconfig.json"); + expect(receiptHint(CWD, ["a.py"], exists)).toBe("Run pytest + pyright (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: go.mod → go test ./... + go vet ./...", () => { + const exists = fs("go.mod"); + expect(receiptHint(CWD, ["a.go"], exists)).toBe("Run go test ./... + go vet ./... (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: Cargo.toml → cargo test + cargo check", () => { + const exists = fs("Cargo.toml"); + expect(receiptHint(CWD, ["a.rs"], exists)).toBe("Run cargo test + cargo check (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: composer + artisan → php artisan test", () => { + const exists = fs("composer.json", "artisan"); + expect(receiptHint(CWD, ["a.php"], exists)).toBe("Run php artisan test (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: composer + tests/Pest.php (no artisan) → vendor/bin/pest", () => { + const exists = fs("composer.json", "tests/Pest.php"); + expect(receiptHint(CWD, ["a.php"], exists)).toBe("Run vendor/bin/pest (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: composer + phpstan.neon → phpstan appended", () => { + const exists = fs("composer.json", "phpstan.neon"); + expect(receiptHint(CWD, ["a.php"], exists)).toBe("Run vendor/bin/phpunit + vendor/bin/phpstan analyse (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: Package.swift → swift test", () => { + const exists = fs("Package.swift"); + expect(receiptHint(CWD, ["a.swift"], exists)).toBe("Run swift test (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: pubspec.yaml with flutter key → flutter test", () => { + const exists = fs("pubspec.yaml"); + const readText = (): string => "name: app\nflutter:\n sdk: flutter\n"; + expect(receiptHint(CWD, ["lib/a.dart"], exists, readText)).toBe("Run flutter test (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: pubspec.yaml without flutter key → dart test", () => { + const exists = fs("pubspec.yaml"); + const readText = (): string => "name: app\n"; + expect(receiptHint(CWD, ["a.dart"], exists, readText)).toBe("Run dart test (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: monorepo — no root marker, but api/go.mod matches modified api/main.go", () => { + const exists = fs("api/go.mod"); + expect(receiptHint(CWD, ["api/main.go"], exists)).toBe("Run go test ./... + go vet ./... (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: extension-only .py with no markers → pytest + mypy default", () => { + const exists = fs(); + expect(receiptHint(CWD, ["a.py"], exists)).toBe("Run pytest + mypy (exit 0, 0 failures), then re-complete."); +}); + +test("receiptHint: nothing detected → the generic cross-language list", () => { + const exists = fs(); + expect(receiptHint(CWD, ["a.txt"], exists)).toBe( + "Run your test suite and static checker (bun test + tsc, pytest + mypy, go test + go vet, " + + "cargo test + cargo check, phpunit/pest + phpstan, swift test, dart test) with exit 0 and 0 " + + "failures, then re-complete.", + ); +}); + +test("receiptHint: multiple ecosystems combine with 'and'", () => { + const exists = fs("pyproject.toml", "mypy.ini", "go.mod"); + expect(receiptHint(CWD, ["a.py", "a.go"], exists)).toBe( + "Run pytest + mypy and go test ./... + go vet ./... (exit 0, 0 failures), then re-complete.", + ); +}); + +test("receiptHint: dedupes search dirs — 2000 files across 3 dirs call `exists` fewer than 100 times", () => { + let calls = 0; + const real = fs("pkg/go.mod"); + const exists = (p: string): boolean => { + calls += 1; + return real(p); + }; + // Nested 2 levels deep so `searchDirs`' (first, second) pair collapses to + // one of 3 shared subdirs (pkg/sub, api/sub, web/sub) regardless of the + // 2000 distinct filenames — proving the Set dedupe, not just few inputs. + const files: string[] = []; + for (let i = 0; i < 2000; i += 1) { + const dir = ["pkg", "api", "web"][i % 3]; + files.push(`${dir}/sub/file${i}.go`); + } + expect(receiptHint(CWD, files, exists)).toBe("Run go test ./... + go vet ./... (exit 0, 0 failures), then re-complete."); + expect(calls).toBeLessThan(100); +}); diff --git a/test/task-completed.test.ts b/test/task-completed.test.ts index 4cadd63..2d2476c 100644 --- a/test/task-completed.test.ts +++ b/test/task-completed.test.ts @@ -55,3 +55,17 @@ test("validateTaskSolid: a session with no tracked files returns empty", () => { const home = root(); expect(validateTaskSolid({ session_id: "s3" }, home)).toBe(""); }); + +test("validateTaskSolid: no receipt + a Python project → refusal names pytest, not the generic list", () => { + const home = root(); + const stateDir = root(); + const cwd = root(); + writeFileSync(join(cwd, "pyproject.toml"), "[project]\nname = \"x\"\n"); + const small = join(cwd, "a.py"); + writeFileSync(small, "x = 1\n"); + saveSessionState("s-py", { changes: { modifiedFiles: [small] } }, home); + const parsed = JSON.parse(validateTaskSolid({ session_id: "s-py", task_id: "t", task_subject: "s", cwd }, home, T, stateDir)) as { continue: boolean; stopReason: string }; + expect(parsed.continue).toBe(false); + expect(parsed.stopReason).toContain("pytest"); + expect(parsed.stopReason).not.toContain("go test"); +}); diff --git a/test/track-changes.test.ts b/test/track-changes.test.ts new file mode 100644 index 0000000..2f1ec70 --- /dev/null +++ b/test/track-changes.test.ts @@ -0,0 +1,34 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { trackSessionChanges } from "../src/runtime/lifecycle/track-changes"; +import { loadSessionState } from "../src/runtime/home-state"; + +/** Isolated fake `$HOME` per test — avoids polluting the real session-state dir. */ +function fakeHome(): string { + return mkdtempSync(join(tmpdir(), "fuse-track-changes-")); +} + +test("trackSessionChanges: .dart file is now tracked (shared CODE_EXTENSIONS)", () => { + const home = fakeHome(); + try { + const out = trackSessionChanges("sess-dart", "lib/main.dart", home, Date.now()); + expect(out).toContain("SNIPER VALIDATION REQUIRED"); + const state = loadSessionState("sess-dart", home); + const changes = state.changes as { cumulativeCodeFiles: number; modifiedFiles: string[] }; + expect(changes.cumulativeCodeFiles).toBe(1); + expect(changes.modifiedFiles).toContain("lib/main.dart"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("trackSessionChanges: non-code extension is ignored", () => { + const home = fakeHome(); + try { + expect(trackSessionChanges("sess-txt", "notes.txt", home, Date.now())).toBe(""); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); From 20a05a8861db13311251ca6d31fbe506d3098fc8 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 8 Sep 2026 03:23:38 +0200 Subject: [PATCH 2/2] chore: update CHANGELOG to 0.1.97 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d6b61..557e269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.97] - 2026-09-08 + +### Added + +- **Project-aware verification hint** (`src/runtime/lifecycle/receipt-hint.ts`) — the `TaskCompleted` refusal used to list every ecosystem's commands regardless of project. It now names only the commands of the ecosystem detected for the project: root markers in the working directory and in the first two path segments of the files changed in the session (monorepos), plus the extensions of those files. `bun test`/`bunx tsc` on a Bun repo, `npm`/`pnpm`/`yarn test` per lockfile, `pytest` with `mypy`/`pyright` when configured, `go test`/`go vet`, `cargo test`/`cargo check`, `php artisan test`/`phpunit`/`pest` with `phpstan` when configured, `swift test`, `flutter test`/`dart test`; the generic list only when nothing is detected. The `SubagentStop` advisory uses the same hint. A single shared code-extension list (`code-extensions.ts`) now feeds session change tracking, the completion gate, and the hint — Dart files were gated but never tracked, so the Dart branch could not fire; fixed. Paths from session state are confined to the working directory before any disk read; search directories are deduplicated. Verified on 30 real fixture projects through the hook binary. Tests: 1837 to 1857. + ## [0.1.96] - 2026-09-07 ### Added diff --git a/package.json b/package.json index 7d64e1f..c27f759 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.96", + "version": "0.1.97", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts",