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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions MEMORY/LESSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<v>/{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]
Expand Down Expand Up @@ -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]
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/runtime/lifecycle/agent-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -73,7 +80,7 @@ export function trackAgentMemory(data: Record<string, unknown>, 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}`);
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/runtime/lifecycle/code-extensions.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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());
}
Loading