diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e3c561..20ed2d3 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.93] - 2026-09-03 + +### Added + +- **Opt-in multi-agent PRD ownership coordination** (`src/prd/`) — coordinates file/task ownership across multiple sub-agents working from a shared PRD, entirely inert by default: requires both `FUSE_PRD=1` and a routing file (`apex/prd.json`) present before any behavior activates. + ## [0.1.92] - 2026-09-02 ### Fixed diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md index d456a16..14b3237 100644 --- a/MEMORY/LESSON.md +++ b/MEMORY/LESSON.md @@ -72,20 +72,6 @@ - [2026-07-29 13:26] J'ai affirmé au proprio « une seule des onze références a un design-system.md » et j'en ai déduit qu'un correctif serait purement préparatoire. J'avais mesuré `Projets-clients/refs-design` (la copie SOURCE) alors que seul compte le corpus DÉPLOYÉ (`~/.claude/plugins/.../refs-design`), où les onze en ont un. Les deux copies étaient désynchronisées ; c'est le sous-agent qui m'a contredit, preuve à l'appui. → Quand une mesure sert à décider de la portée d'un correctif, la prendre sur l'artefact que le RUNTIME lit, jamais sur la copie de travail qui lui ressemble — et nommer explicitement lequel des deux on a mesuré. Même famille que « déployé ≠ publié » : identifier la cible avant de mesurer vaut autant que valider l'instrument. [TRIGGERS keyword:corpus,refs-design,déployé,source,désynchronisé,portée] -- [2026-07-29 11:13] Pour corriger un FAUX POSITIF du gate typo (un agent citant honnêtement l'`@import` Inter de `linear-recode` se faisait bloquer), j'ai spécifié « exclure la section `## Design Reference` du contrôle, jusqu'au prochain `## ` OU EOF ». Le clause EOF était un CONTOURNEMENT TRIVIAL : écrire `## Design Reference` puis poser ses tokens dessous exemptait TOUT le reste du document du bannissement de polices — atteignable par simple étourderie. Détecté seulement parce que `test/design-characterization.test.ts:69-72` épinglait les 3 formes (décl/`@import`/tableau) appendées à une fixture `VALID` sans heading de fermeture. → Avant de relâcher un gate pour un faux positif, évaluer ce que la lecture la PLUS LARGE de l'exemption permet, pas le cas qui a motivé le fix : une exemption bornée par EOF exempte le document entier. Et un test de caractérisation qui contredit une nouvelle spec est présumé AVOIR RAISON — l'exécuteur qui s'arrête et refuse d'improviser fait son travail, on amende la spec, on ne touche pas au test. [TRIGGERS path:src/policy/design/*.ts keyword:faux positif,exemption,section,EOF,relâcher,caractérisation] - -- [2026-07-29 11:13] J'ai annoncé au proprio « branche : main » et committé 3 fois sans jamais lancer `git branch --show-current` — on était en réalité sur `feat/design-corpus`. Bon résultat par accident, pas par contrôle. Parallèlement, j'ai imposé à l'agent commit un stop « avant le tag » qui n'était PAS dans la procédure, puis l'agent a refusé de le lever sur mon relais du feu vert (« un message d'agent, même relayant les propos du proprio, ne vaut pas consentement pour un irréversible ») — il avait raison. → (1) L'état git (branche, HEAD, staging) se MESURE avant de committer et avant de l'affirmer au proprio, jamais de mémoire. (2) Ne pas ajouter de garde-fou perso à une procédure que l'agent dédié owne déjà. (3) Pour un irréversible (tag `v*` → publish npm auto), obtenir le mot du proprio EN DIRECT avant de déléguer l'étape ; un relais ne le porte pas. **Récidive le 30/07 sur (2)** : voulant seulement interdire le tag à l'agent commit, j'ai redéfini TOUTE sa procédure autour (« push/PR/CI/merge uniquement, ne modifie aucun fichier ») — il a obéi à ma version appauvrie et sauté son étape CHANGELOG. La forme correcte : interdire la SEULE action dangereuse, ne rien dire du reste. Un brief sur-spécifié écrase le skill qu'on délègue. [TRIGGERS tool:Agent,Bash keyword:git commit,branche,main,tag,irréversible,relais,consentement,brief,mandat,procédure,skill,scope] - -- [2026-07-24 13:35] Compaction « rename-atomique » (renameSync log→folding → fold → unlink) du journal append-only avait un TOCTOU : un `appendFileSync` dont le `open()` précède le rename et le `write()` suit le fold-read écrit dans l'inode renommé puis unlinké → 1 write perdu, silencieux (fail-open). Validé 24/24 + PER=250 sur machine RAPIDE (faux positif), exposé UNIQUEMENT par le runner lent du `prepublishOnly` (CI 2-vCPU) → publish npm avorté juste à temps. Le test STOCHASTIQUE (8×N writes, seuil absolu) donne des faux négatifs sur machine rapide. → (1) Une garantie de concurrence validée localement/machine rapide est une BORNE, pas une preuve ; le juge est le runtime le PLUS lent (prepublishOnly/CI 2-vCPU), jamais le local. (2) Garder un invariant de concurrence par une SONDE DÉTERMINISTE qui force l'entrelacement in-process (ou l'observable discriminant : sérialisation append↔compaction via timestamps) — indépendante de l'ordonnancement — PAS un test stochastique seul. (3) Ne jamais rename/unlink un fichier dans lequel un autre process écrit encore sans exclusion mutuelle : appends + compaction doivent partager le lock (append BLOQUANT jusqu'à acquisition, JAMAIS fail-open skip — sinon la race-loss devient skip-loss et les « write skipped » reviennent). [TRIGGERS path:src/tracking/*.ts keyword:TOCTOU,compaction,rename,appendFileSync,lost write,prepublishOnly] - -- [2026-07-24 13:35] Le proprio a dit « j'ai build+déployé le dist dans le marketplace » — FAUX : le binaire réellement exécuté par les hooks (`~/.claude/plugins/marketplaces/fusengine-plugins/plugins/node_modules/@fusengine/harness/dist/cli/bin.mjs`) était encore l'ancien 0.1.79 SANS le journal (build daté d'avant, version en retard). Mon propre check `grep … | head && echo NOUVEAU || echo ANCIEN` était bugué (le `head` masque l'échec du `grep` → affirmait NOUVEAU à tort). → (1) « J'ai déployé » ne se croit pas : vérifier le binaire RÉELLEMENT exécuté (grep d'un marqueur du fix + `stat` date + version du package.json installé) DANS le node_modules du marketplace, pas la parole ni le dist source. (2) `grep|head` en test booléen est un faux positif garanti — utiliser `if grep -rq … ; then` (exit code direct). (3) Tester en conditions réelles (déployer le dist local dans node_modules → fan-out d'agents réels + probe multi-process) AVANT le publish IRRÉVERSIBLE (tag `v*` → npm auto), jamais après. [TRIGGERS tool:Bash keyword:déployé,dist,bin.mjs,marketplace,grep head,npm publish] - -- [2026-07-23 21:18] J'ai promis au proprio une garantie « shasum dist identique => régression impossible », puis le rebuild a produit un hash DIFFÉRENT alors que `src/` n'avait pas bougé — tsdown régénère des IDs de chunks, le build N'EST PAS reproductible bit-pour-bit. Garantie annoncée sans valider l'instrument (même classe que la leçon execpolicy). → La garantie dure d'absence de régression = `git diff -- src/` VIDE (source de prod byte-identique, lisible par git), PAS le shasum du binaire buildé (bundler non-déterministe). Ne jamais promettre une bit-identité de build sans avoir prouvé que le build est reproductible. [TRIGGERS keyword:shasum,dist,reproductible,tsdown,garantie,git diff src] - -- [2026-07-23 21:42] Fix#1 d'un test de concurrence flaky remontait le compteur de skips par STDOUT du sous-process (`console.log(n)` → `Number(out.trim())`) : vert en local, mais en CI (runner lent, contention totale → tous skippent) la capture stdout inter-process a remonté 0 au lieu de N → nouvel échec `Expected 24 Received 0`. Le sniper avait jugé cette capture stdout « robuste » — elle ne l'est pas sous CI. → Pour de l'IPC de RÉSULTAT dans un test multi-process, ne jamais dépendre de stdout (buffering/timing close-vs-data/sortie parasite du runtime) : écrire dans un FICHIER par worker (Bun.write/writeFileSync) lu après `close`. Et un fix de flakiness doit être PROUVÉ dans le cas extrême simulé (contention forcée où landed=0), pas seulement sur la machine locale rapide où le cas ne se produit jamais. [TRIGGERS keyword:stdout,IPC,sous-process,flaky,CI,fichier worker,capture] - -- [2026-07-23 21:59] En sondant un « lock qui perd des writes » j'ai lu `JSON.parse(readFileSync).refsRead` BRUT et vu landed=0 → j'ai failli conclure à un vrai bug de concurrence (perte silencieuse) et alarmer le proprio. Or le track est une ENVELOPPE signée `{data:"",...}` : le vrai contenu est dans `.data`, lu correctement par `loadTrack()` (ce que le test utilise) → landed=24, aucun bug. → Une sonde qui contredit un code prouvé par tests doit d'ABORD être suspectée elle-même : reproduire via la MÊME primitive que le code de prod (`loadTrack`, pas un `JSON.parse` brut), et imprimer le contenu réel du fichier avant de conclure. Même classe que la leçon execpolicy : valider l'instrument avant la mesure. [TRIGGERS keyword:sonde,loadTrack,enveloppe data,perte silencieuse,instrument,faux bug] - - [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 +91,17 @@ - [2026-09-02 19:01] Le lot « isolation HOME des tests » a été livré « PASS » par l'exécutant ET son sniper, alors que la suite complète créait encore 5 fichiers par run dans le vrai home : tous deux n'avaient mesuré que les fichiers de test qu'ils venaient de toucher, pas la suite entière ; 4 autres fichiers de test appelaient le même chemin. → Une propriété globale (« aucun test n'écrit hors tmp ») se prouve par une mesure GLOBALE (compte avant/après sur `bun test` complet), jamais par la somme des fichiers modifiés ; l'exiger explicitement dans le brief ET la refaire soi-même avant tout « fait ». [TRIGGERS keyword:isolation,HOME,suite complète,avant/après,propriété globale,PASS partiel,fichiers touchés] - [2026-09-02 19:40] PR #101 poussée après 1311 tests verts ×3 en local → CI rouge : notre nouveau test « claude-code ne touche pas le registre » faisait `JSON.parse(stdout)` d'un hook claude-code qui n'a rien à injecter sur un runner sans `~/.claude/CLAUDE.md` (stdout vide → EOF). Il était vert chez le proprio uniquement parce que son home réel nourrit le hook. Reproduit en 10 s avec `HOME=$(mktemp -d) bun test `. → Avant tout push : rejouer les fichiers de test touchés avec un HOME vide temporaire (et idéalement la suite complète une fois) ; un test qui spawn le CLI avec le HOME réel dépend de la machine et n'est pas une preuve. Toute assertion sur la SORTIE d'un hook d'injection doit accepter « rien à injecter » ou poser elle-même l'entrée qu'elle attend. [TRIGGERS keyword:CI rouge,HOME vide,mktemp,runner,dépend du home,JSON.parse stdout,hermétique,vert local rouge CI] + +- [2026-09-02 22:40] Deux agents (un explore, un sniper) ont déclaré « plafond réel = 100 lignes » en lisant `src/config/limits.ts` (DEFAULT_MAX_LINES du harness pour LES PROJETS CLIENTS) et ont réclamé des découpes de fichiers de 116 et 168 lignes. Faux pour CE dépôt : le hook actif de la session bloque à 200 (message « SOLID 200-line hook » reçu par un exécutant), et `main` contient déjà des fichiers de 137 et 197 lignes mergés avec CI verte. → Le plafond applicable se MESURE : (1) le message du hook lors d'une écriture réelle, (2) le plus grand fichier committé sur HEAD ; jamais une constante lue dans le code du produit. Ne jamais accepter une découpe motivée par un plafond non mesuré (leçon « never invent a constraint »). [TRIGGERS keyword:FUSE_SOLID_MAX_LINES,plafond,100,200,limits.ts,découper,sniper fail,lignes] + +- [2026-09-03 08:34] +104 fichiers créés sous le vrai ~/.fuse-harness/state pendant la suite (one-shot.json, inject-dedup.json, deny-loop.json, track-*.log ; 13 000 accumulés) — tentation immédiate de l'imputer aux 5 lots PRD en cours. Témoin : `git archive HEAD` + `bun install` + suite complète sur l'export vierge → 211 fichiers créés par HEAD lui-même. Dette préexistante (tests appelant handleHook avec le homedir réel), pas une régression du jour. → Avant d'attribuer un effet de bord à un chantier en cours, mesurer le même effet sur un export propre de HEAD (5 min) ; consigner la dette et la séparer explicitement du périmètre. Fait projet : la suite pollue le home réel à chaque run tant que les tests one-shot/inject-dedup/deny-loop n'isolent pas HOME. [TRIGGERS keyword:fuse-harness/state,one-shot.json,inject-dedup,deny-loop,home réel,dette,export HEAD,attribution] + +- [2026-09-03 10:59] Pour prouver « zéro test disparu » entre main et la branche, `bun test` ne sert à rien : il n'imprime QUE les échecs, jamais les noms des tests verts — seul `bun test --reporter=junit --reporter-outfile=x.xml` donne la liste complète. Puis j'ai extrait les paires fichier/nom avec `grep -oE 'name="..."|file="..."' | paste - -` : les attributs n'ont pas le même ordre ni le même nombre selon les `testcase`, le couplage s'est décalé et j'ai obtenu **341 tests « disparus »** — un faux positif de régression majeur, remonté au proprio à une seconde près. La comparaison propre sur le multiset des NOMS seuls donne 0 disparu / 219 ajoutés (1312 → 1531 cas). → Un diff entre deux exécutions de tests se fait sur une clé PARSÉE (XML/JSON), jamais sur des attributs recollés par `paste` ; et tout résultat de régression spectaculaire se re-mesure par une seconde méthode indépendante AVANT d'être annoncé. Corollaire shell : dans une commande composée en arrière-plan, un `cd` persiste — mes deux rapports JUnit ont d'abord tourné dans le MÊME worktree, comparaison vide de sens ; ancrer chaque exécution sur un chemin absolu. + +- [2026-09-03 11:05] La doc du module PRD (`docs/prd.md` + la matrice reprise dans `docs/adapters.md`) a été écrite par un agent à partir du DESIGN, pas de mesures : 5 affirmations fausses, toutes trouvées par le challenger, aucune par les 4 snipers de lot ni par l'agent de doc lui-même — « any Bash command whose write target resolves under apex/prd/ » (en fait : redirections seules, `cp`/`mv`/`tee`/`sed -i` passaient), cellule Cursor `SubagentStart` « yes » (mesuré : `{"permission":"allow"}` identique avec et sans routeur, rien de livré), 3 cellules cline/gemini-cli/hermes « not wired » (elles émettent, mais dans une forme que 2 des 3 n'honorent pas → échec en OUVERT), et un chemin relatif dans l'exemple de deny quand le code émet l'absolu canonique. Le README, lui, était exact : il ne décrivait que le CLI, qui avait été réellement exécuté. → Une ligne de doc qui décrit un COMPORTEMENT se rédige depuis une sortie capturée, jamais depuis le design ni depuis la seule lecture du code ; une matrice de capacités par cible se remplit cellule par cellule à partir d'une exécution par cible. Corollaire d'ordonnancement : la doc s'écrit EN DERNIER, après le challenger — 3 des 5 cellules devaient de toute façon être réécrites après ses correctifs. CONFIRMÉ UNE 2e FOIS à 12:08 : l'agent de doc a mesuré puis documenté « cline : dispatché mais jamais délivré, gap réel non corrigé » ; j'ai décidé de fermer ce gap dans la foulée, et la cellule fraîchement écrite est redevenue fausse dans l'heure. La doc ne s'écrit pas après le challenger, elle s'écrit après la DERNIÈRE décision de correction — un défaut que la doc signale comme « connu, non corrigé » est une décision en attente, pas un fait stable : trancher fixer/ne pas fixer AVANT de faire rédiger la cellule. + +- [2026-09-03 11:13] Un agent d'écriture a rendu DEUX fois une notification qui n'était pas un rapport : « Waiting for the research-expert and explore-codebase agents » puis « Sniper is already running; I'll wait for its notification ». Il ne reprend pas seul quand ses propres enfants rendent — il faut le relancer par `SendMessage`. Vérification faite avant de le relancer : ses fichiers avaient bel et bien bougé (mtime, en-tête réécrit, `{stdout, exit}` comparés), donc le travail avançait ; conclure « il est bloqué / il a échoué » aurait été faux. → Sur une notification d'agent sans livrable, ne jamais présumer ni échec ni fin : mesurer d'abord l'état sur disque (mtime + contenu du défaut visé), puis le relancer par message en lui redemandant SEULEMENT le livrable manquant. Et pendant qu'un agent écrit, toute exécution de gates est contaminée : mes 3 échecs en HOME vide n'étaient que ses tests RED capturés en vol — re-mesurer sur arbre figé avant d'annoncer quoi que ce soit. + +- [2026-09-03 11:18] J'ai prescrit dans un mandat la SOLUTION et pas seulement l'objectif : « router les 3 sorties PRD par `respond(id, …)` au lieu de `blockResponse`/`contextResponse` en dur », en reprenant telle quelle la correction suggérée par le challenger. C'était faux pour 2 des 6 cibles : l'agent a prouvé par le code (`src/runtime/respond.ts:53-63` + le JSDoc de `blockResponse`) que pour claude-code/codex la forme `hookSpecificOutput.permissionDecision` n'existe qu'en PreToolUse et est IGNORÉE sur Stop/SubagentStop — appliquer mon mandat à la lettre aurait cassé le blocage réel sur les deux cibles principales. Il a limité le changement à kimi/gemini-cli/cline et épinglé les autres octet-pour-octet. → Dans un mandat, écrire l'OBJECTIF mesurable (« cline et gemini-cli doivent recevoir leur forme native, les 5 autres cibles inchangées octet-pour-octet ») et l'invariant, jamais l'implémentation ; si je cite une correction proposée par le challenger, la marquer explicitement « hypothèse à vérifier, pas une consigne » — sinon un exécutant docile l'applique et casse ce qu'elle prétendait protéger. + +- [2026-09-03 11:36] Le nouveau scanner de commandes Bash (`src/runtime/prd/prd-bash-targets.ts`) traitait `"..."` comme opaque au même titre que `'...'`. Or bash exécute la substitution de commande DANS les guillemets doubles ; seuls les simples la suppriment (POSIX §2.2/§2.6.3). Résultat : `echo "$(cp /etc/hosts )"` traversait intact la garde que ce lot existait précisément pour poser — trouvé par le sniper, pas par l'auteur ni par ses tests de chaînage/substitution, qui n'exerçaient que la forme NON quotée. → Tout scanner de commande shell doit distinguer les deux quotes : simple = opaque, double = transparente à `$(…)`, aux backticks et à `$VAR`. Et une batterie de cas « chaînage, substitution, quotes » n'est complète que si elle croise les axes (substitution DANS double quote, quote DANS substitution imbriquée) — les tester séparément laisse passer exactement la combinaison exploitable. [TRIGGERS path:src/**/*bash*.ts keyword:shell,quote,guillemets,substitution,$(,backtick,scanner,tokenize] diff --git a/README.md b/README.md index 19af161..49d56a9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ don't assume either the old or the "everything in one place" story): | `harness check` | cli-mode: check staged files in a pre-commit step, exit non-zero on a violation. For harnesses without hooks. | | `harness doctor` | Print the version + resolved path of the harness *actually executing*, and compare it to npm's latest — the fast way to catch a stale global (see Pinning). | | `harness --version` | Print the running version (bare, on stdout) and exit. | +| `harness prd status [--json]` | Read-only, works without `FUSE_PRD=1`. Print each task's router status and sub-task progress. | +| `harness prd validate [agent]` | Requires `FUSE_PRD=1`. Cross-check a task PRD against agent reports and promote matching sub-tasks/router entries to `validated`. | +| `harness prd compact ` | Requires `FUSE_PRD=1`. Collapse a fully-validated task PRD to its compacted shape. | Every invocation writes a `@fusengine/harness vX.Y.Z` banner to **stderr** (never stdout — the hook JSON contract stays clean) so you can see which version ran. @@ -313,6 +316,7 @@ non-zero exit is swallowed — a broken or absent player can never break a hook | `FUSE_LESSONS_THROTTLE_MIN` | Lessons-injection throttle, minutes (default `5`). | | `FUSE_ENFORCE_GEMINI_MCP` | **Opt-in (default off).** Blocks hand-written Tailwind UI (`.tsx/.jsx/.vue/.svelte`) until a `mcp__gemini-design__*` call is made this session. Read fresh per call (`src/policy/gemini-mcp-gate.ts`). | | `FUSE_DESIGN_GEMINI` | **Opt-in (default off), a *different* gate from the one above.** Enables the design-pipeline's own Gemini gates (`create_frontend` validation + "generate before hand-writing HTML/CSS") — inert unless a design agent is active (`src/policy/design/gates.ts:58-60`, see [docs/design.md](docs/design.md)). | +| `FUSE_PRD` | **Opt-in (default off).** Set to exactly `1` to activate task/agent PRD ownership coordination — still inert without a `prd.json` router under `/apex/`. See [PRD coordination](#prd-coordination-opt-in) below and [docs/prd.md](docs/prd.md). | | `FUSE_MCP_TTL_SEC` | MCP (Context7/Exa) cache freshness, seconds (default 48h, `src/runtime/mcp-key.ts`). | | `FUSE_WEBFETCH_TTL_SEC` | WebFetch cache freshness, seconds (default 24h — pages stale faster than docs). | | `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` | G0 cool-down (seconds, default `300`) for the `CONFIRM ` mechanism above — no token can be placed within this window of the last SubagentStart/Stop seen for the session. | @@ -358,6 +362,18 @@ registerGuard(({ tool, command }) => : null); ``` +## PRD coordination (opt-in) + +Set `FUSE_PRD=1` and write a router file so a lead can split one task +across several sub-agents, each restricted to writing only its own +report. Full walkthrough, guard behavior, and the CLI: [docs/prd.md](docs/prd.md). + +Minimal example — `.claude/apex/prd.json`: + +```json +{ "auth-refactor": { "prd": "prd/auth-refactor-prd.json", "status": "assigned" } } +``` + ## Subpath exports | Subpath | What | @@ -386,6 +402,7 @@ registerGuard(({ tool, command }) => | [docs/modules.md](https://github.com/fusengine/harness/blob/main/docs/modules.md) | cache · refs · state · memory · statusline · util | | [docs/adapters.md](https://github.com/fusengine/harness/blob/main/docs/adapters.md) | adapters, compatibility, `harness init`/`hook` wiring | | [docs/design.md](https://github.com/fusengine/harness/blob/main/docs/design.md) | design-agent pipeline — state machine, gates, opt-in Gemini | +| [docs/prd.md](https://github.com/fusengine/harness/blob/main/docs/prd.md) | PRD task/agent ownership coordination — opt-in, `FUSE_PRD=1` | | [CHANGELOG.md](https://github.com/fusengine/harness/blob/main/CHANGELOG.md) | release history | Run `bun run docs:api` for the generated typedoc API reference. diff --git a/docs/adapters.md b/docs/adapters.md index d0fc9b5..9c056d5 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,6 +30,26 @@ assuming a gate that works on Claude Code also works elsewhere. | **hermes** | `adapters/hermes/index.ts` | `pre_tool_call` proven: reuses the Claude stdin reader, blocks via `{decision:"block",reason}` (lines 12-36) | untested — no lifecycle dispatch wired for Hermes in this repo | `ask`/`inform` degrade to non-blocking `{context}` — Hermes "has no interactive ask state" (lines 27-28). | | **kimi** | `adapters/kimi/index.ts` | `PreToolUse` denies via the camelCase JSON channel `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` on stdout at **exit 0** (verified live against kimi-code v0.27.0 — exit 2 with stderr = reason also blocks, but is not required) — only `deny` is documented. `ask` is **downgraded to deny** prefixed `[downgraded from ask — Kimi Code has no interactive approval]`, with a `CONFIRM ` recourse appended (`runtime/confirm/`, see below); `inform` rides plain stdout text at exit 0, never wrapped in JSON. Blocking events: `UserPromptSubmit`, `PreToolUse`, `Stop`. | Observation only: `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `StopFailure`, `Interrupt`, `PreCompact`, `PostCompact`, `Notification` — Kimi delivers them but **ignores any response**, so no verdict can be returned from them. | A **hook** cannot request approval — Kimi's `ask` lives in a parallel, hook-unreachable system (`[[permission.rules]] decision = "ask"` in `config.toml`), hence the ask→deny downgrade. Hooks are configured **only** in the global `~/.kimi-code/config.toml` (no project-local hooks file), so `harness init` writes no kimi wiring — see [Kimi Code — manual wiring](#kimi-code--manual-wiring). **Fail-open by design**: any exit code other than 0/2, a timeout, or a crash lets the call through. Stdin payload is snake_case and carries only `hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input.command` and an undocumented `tool_call_id` (unused here) — no `transcript_path`, no `permission_mode`, no `tool_response`. Verified live against kimi-code v0.27.0 for `PreToolUse`/`Bash`. Instructions file is `AGENTS.md`, not `CLAUDE.md`. | +### PRD capabilities + +Opt-in (`FUSE_PRD=1`), documented in full at [prd.md](./prd.md). Coverage +splits by **capability**, not by adapter — a single per-adapter verdict +would be misleading here: + +| Capability | claude-code | codex | cursor | kimi | cline / gemini-cli / hermes | +|---|---|---|---|---|---| +| Write ownership block | full | full | consultative only (never blocks) | consultative only (never blocks) | full, if the consumer wires `PreToolUse` | +| Bash-under-`prd/` deny | yes | yes | yes | yes | yes | +| `SubagentStart` slice injection | yes | yes | **no** — the slice IS built (identical to claude-code's), but `adapters/cursor/respond.ts`'s `toCursorLifecycleResponse` collapses any non-denied `subagentStart` to bare `{"permission":"allow"}`, dropping it; measured byte-identical output with a matching PRD assignment, with no router at all, and with `FUSE_PRD` unset | delivered, ignored (observation-only) | gemini-cli: yes, delivered — routed through `respond()`'s native "inform" shape, then re-wrapped by `joinContextResponses` into the shared Claude-style `hookSpecificOutput.hookEventName/additionalContext` envelope plus a `[NOTE]` title line (not gemini-cli's own minimal shape, but the text arrives). hermes: yes, delivered — same Claude-style `contextResponse` as claude-code; Hermes's own documented non-blocking shape is `{context}`, not `hookSpecificOutput`, so whether a real Hermes client reads it is unverified. cline: yes, delivered in its own native `{contextModification}` shape — `joinContextResponses` keeps whichever envelope its parts came from instead of assuming the Claude one (measured 476 bytes where an earlier build emitted none; the six other targets stay byte-identical, gemini-cli 529 = 529, the Claude-family four 506 = 506) | +| `SubagentStop` block-once | yes | yes | delivered, response ignored | delivered, response ignored | yes — each in its OWN native block shape via `respond()`: gemini-cli `{"decision":"deny","reason"}`, cline `{"cancel":true,"errorMessage"}`, hermes `{"decision":"block","reason"}` (via `blockResponse`, which happens to match Hermes's own documented block contract). Block-once verified: 1st call blocks, an identical replay is silent (empty stdout) on all three, under both the default journal-based session track and legacy `FUSE_TRACK_JOURNAL=0` | +| Lead `Stop` block-once | yes | yes | not applicable | best effort — Kimi's `Stop` is a documented blocking event, but not verified live | yes — same per-adapter native shapes and block-once behavior as `SubagentStop` above (gemini-cli/cline/hermes), verified under both tracking modes | + +Cursor and Kimi never send an agent-identity field on a Write/Edit +payload, and a Cursor sub-agent's write runs under a session ID +unrelated to its own `SubagentStart` event — so per-write ownership +enforcement isn't possible on either target. Both stay consultative +permanently; the PostToolUse cross-check is the only enforcement there. + ## `CONFIRM ` — recourse for a degraded `ask` Both harnesses above downgrade `ask` to a hard `deny`: Kimi's binary diff --git a/docs/config.md b/docs/config.md index 64dbc26..7245575 100644 --- a/docs/config.md +++ b/docs/config.md @@ -27,6 +27,7 @@ float / `<= 0` all fall back to the default. | `FUSE_HARNESS_MARKETPLACES` | `fusengine-plugins` | marketplaces auto-scanned for `solid-*` skill refs when `FUSE_HARNESS_REFS` is unset | | `FUSE_ENFORCE_GEMINI_MCP` | _(off)_ | opt-in — blocks hand-written Tailwind UI (`.tsx/.jsx/.vue/.svelte`) until a `mcp__gemini-design__*` call is made this session (`policy/gemini-mcp-gate.ts`) | | `FUSE_DESIGN_GEMINI` | _(off)_ | opt-in — a **different** gate from the one above: enables the design-pipeline's own Gemini gates (`policy/design/gates.ts`), inert unless a design agent is active — see [design.md](./design.md) | +| `FUSE_PRD` | _(off)_ | opt-in — set to exactly `1` to activate the PRD task/agent ownership module (`policy/prd/prd-enabled.ts`); still inert without a `prd.json` router under `/apex/` — see [prd.md](./prd.md) | | `FUSE_MCP_TTL_SEC` | `172800` (48h) | Context7/Exa cache freshness (`runtime/mcp-key.ts`) | | `FUSE_WEBFETCH_TTL_SEC` | `86400` (24h) | WebFetch cache freshness — pages stale faster than docs | | `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` | `300` | G0 cool-down (seconds) for the `CONFIRM ` mechanism (`runtime/confirm/confirm-subagent.ts`) — no confirm token can be placed within this window of the last SubagentStart/SubagentStop seen for the session. Reuses `parseEnvInt` directly (not `resolveTtlSec`, whose default is hardcoded to 120 regardless of key) | diff --git a/docs/guards.md b/docs/guards.md index 6da68a6..35794ed 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -54,3 +54,35 @@ their own chain via `evaluateApex(ctx)`: Each is individually exported and overridable — pass your own array to `evaluateApex(ctx, gates)`. + +## PRD ownership + +Opt-in (`FUSE_PRD=1`), documented in full at [prd.md](./prd.md). It adds a +per-file write-ownership check on top of the chain above: only the +coordinator may write the router or a task PRD, and only the named agent +may write its own `prd/agents/-prd.json` report. + +This authorized PRD traffic is what actually needs a carve-out from +`protectedPathGuard` above: that guard already lists `.claude/apex/` +under its blocked fragments, so every PRD file lives inside a path the +chain blocks by default. When PRD is active, a write the ownership check +allows short-circuits past the rest of `evaluate()` — including the +file-size and APEX-freshness gates — the same way `Explore`/`Plan` +agents are already exempt from file-size today. Nothing changes when PRD +is off: `protectedPathGuard` keeps blocking `.claude/apex/**` exactly as +it always has. + +**A mixed `apply_patch` envelope never gets this short-circuit, even for +its legitimate file.** `prdPreGate` only allows-through a PURE-PRD +envelope, where every candidate file classifies in-scope +(`inScope.length === files.length`); the moment a single `apply_patch` +call mixes one PRD-scoped file with any other, unrelated file, +`prdPreGate` returns `null` for the whole call and it falls through to +`applyPatchGate`'s ordinary per-file `protectedPathGate` — which +unconditionally blocks the `.claude/apex/` file, same as if PRD were off. +Verified live: an `apply_patch` envelope touching only the agent's own +`prd/agents/-prd.json` report is allowed; the identical hunk for +that same file, bundled in ONE envelope with an unrelated `Add File:` for +a normal source file, is denied with `[BLOCKED] Protected path` — not +`[BLOCKED] PRD ownership` — even though the PRD file's own ownership +would otherwise have been legitimate. diff --git a/docs/index.md b/docs/index.md index 7d6c91f..08d906b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ for Bun; a built `dist` (`.mjs` + `.d.mts`) serves Node/bundler consumers. | [modules.md](./modules.md) | cache · freshness · refs · state · memory · statusline · util | | [adapters.md](./adapters.md) | adapters + `harness init`/`hook` wiring | | [design.md](./design.md) | design-agent pipeline — state machine, gates, opt-in Gemini | +| [prd.md](./prd.md) | PRD task/agent ownership coordination — opt-in, `FUSE_PRD=1` | Generated API reference: `bun run docs:api` → `docs/api/`. See also: [CHANGELOG](../CHANGELOG.md) · [CONTRIBUTING](../CONTRIBUTING.md) · [README](../README.md). diff --git a/docs/prd.md b/docs/prd.md new file mode 100644 index 0000000..8b86642 --- /dev/null +++ b/docs/prd.md @@ -0,0 +1,340 @@ +# PRD — task/agent ownership coordination + +**Opt-in, off by default.** When a lead splits one task across several +sub-agents, PRD gives every sub-agent a JSON file it — and only it — is +allowed to write, so two agents can never clobber each other's report. A +lead reads `harness prd status` to see who has finished what, and +`harness prd validate` promotes a task once every agent's report matches +what it was assigned. With the flag off, or with no router file on disk, +the module reads nothing, writes nothing, and every hook output is +byte-identical to a harness without PRD at all. + +## Turn it on + +Set `FUSE_PRD` to the exact string `1` in the harness's own `.env` — +`~/.claude/.env`, `~/.codex/.env`, `~/.cursor/.env`, `~/.kimi-code/.env` — +or in `/.env`. Then a coordinator (the lead session) creates the +router file so the module has something to enforce. + +| State | Router file | Result | +|---|---|---| +| `FUSE_PRD` unset | any | Inert. Zero reads, zero writes, unchanged stdout. | +| `FUSE_PRD=1` | missing | Still inert — activation needs **both** the flag and the router. Same output as the flag being unset. | +| `FUSE_PRD=1` | present | Active: ownership checks, the Bash-under-`prd/` deny, `SubagentStart` context, block-once on `SubagentStop`/`Stop`. | + +## File layout + +Everything lives under `//apex/` (`homeSeg` is `.claude`, +`.codex`, `.cursor`, `.kimi-code` — whichever the running harness resolves +to). One writer per file, by construction: + +| File | Written by | +|---|---| +| `apex/prd.json` (the router) | the coordinator only | +| `apex/prd/-prd.json` | the coordinator only | +| `apex/prd/agents/-prd.json` | that one agent — nobody else | +| `apex/prd/docs/.md` | the coordinator, free-form notes | + +## Full cycle: the `auth-refactor` example + +The fixtures behind this walkthrough are the exact files under +`test/fixtures/prd/` (`router.json`, `prd/auth-refactor-prd.json`, +`prd/agents/backend-expert-prd.json`). Every command output below was run +for real against those fixtures, copied into a throwaway project — not +invented. + +1. The coordinator writes `.claude/apex/prd.json`: + + ```json + { "auth-refactor": { "prd": "prd/auth-refactor-prd.json", "status": "assigned" } } + ``` + +2. The coordinator writes `.claude/apex/prd/auth-refactor-prd.json`, + naming two agents and what each owns: + + ```json + { + "backend-expert": { "files": ["src/auth/login.ts"], "sub-tasks": { "jwt-validation": { "status": "assigned" } } }, + "backend-expert-2": { "files": ["src/auth/session.ts"], "sub-tasks": { "session-store": { "status": "assigned" } } } + } + ``` + +3. Both `backend-expert` and `backend-expert-2` report the same base + `agent_type` (`backend-expert`), so a starting sub-agent's + `SubagentStart` context can't yet tell which of the two it is. It gets + **both** slices, prefixed with the exact disambiguation header from + `agentSlices`/`renderAgentSliceMarkdown` (`prd-context.ts`): + + ``` + Several assignments match your agent type. You are ONE of: backend-expert, backend-expert-2. The first report you write binds your name; write only that report. + + ## PRD assignment — task auth-refactor + Your files: src/auth/login.ts + Your sub-tasks: jwt-validation + Report to prd/agents/backend-expert-prd.json when done. + + ## PRD assignment — task auth-refactor + Your files: src/auth/session.ts + Your sub-tasks: session-store + Report to prd/agents/backend-expert-2-prd.json when done. + ``` + + Whichever of the two writes its report **first** binds its `agent_id` + to that name (`resolveOwnerBinding`, `prd-ownership.ts`) — the other + sub-agent is then left with the one remaining candidate. + +4. `backend-expert` finishes and writes **only** + `.claude/apex/prd/agents/backend-expert-prd.json`: + + ```json + { "auth-refactor": { "jwt-validation": { "status": "done", "modified": ["src/auth/login.ts"], "unchanged": [] } } } + ``` + +5. `backend-expert` then tries to write `backend-expert-2-prd.json` + instead — denied (exact reason string from `prd-ownership.ts`, + prefixed with the offending path by `prd-pre-gate.ts`). The prefix is + the file's **full canonical absolute path** (`canonicalFilePath()`), + never a project-relative one — measured against a real project root + below (yours will differ, but it is always absolute, never a bare + `.claude/apex/...` fragment): + + ``` + [BLOCKED] PRD ownership + /.claude/apex/prd/agents/backend-expert-2-prd.json: name doesn't match your agent_type, or already bound to another agent + Next: 1. Write only the files/report this agent owns per its PRD slice + 2. Run `harness prd status` to see the current assignment + ``` + +6. The coordinator checks progress — no flag needed for `status`. This is + the real output of `harness prd status --root ` at this point + (`backend-expert-2` hasn't reported yet): + + ``` + $ harness prd status --root + Task Router status Agents Sub-tasks done/total Violations + auth-refactor assigned 2 1/2 0 + ``` + +7. The coordinator tries to validate too early, **without** `FUSE_PRD=1` — + denied because `validate` writes: + + ``` + $ harness prd validate auth-refactor --root + prd validate requires FUSE_PRD=1 in .claude/.env or the project .env + ``` + (exit code `1`) + +8. With the flag set, `validate` now catches the real gap — + `backend-expert-2` still has no report: + + ``` + $ FUSE_PRD=1 harness prd validate auth-refactor --root + prd validate: 1 violation(s) + - auth-refactor/backend-expert-2/session-store: no "done" report from "backend-expert-2" for sub-task "session-store" + ``` + (exit code `1`) + +9. Once `backend-expert-2` also writes its own + `.claude/apex/prd/agents/backend-expert-2-prd.json` with a `"done"` + entry for `session-store`, the same command succeeds silently: + + ``` + $ FUSE_PRD=1 harness prd validate auth-refactor --root + ``` + (exit code `0`, no output — both agents' sub-tasks are now `validated` + and the router entry is promoted to `validated`) + +10. `status` now confirms it: + + ``` + $ harness prd status --root + Task Router status Agents Sub-tasks done/total Violations + auth-refactor validated 2 2/2 0 + ``` + +11. The coordinator compacts the finished task PRD (`compact` also + requires the flag, since it writes): + + ``` + $ FUSE_PRD=1 harness prd compact auth-refactor --root + compacted: backend-expert, backend-expert-2 + ``` + (exit code `0`) — each agent entry in + `.claude/apex/prd/auth-refactor-prd.json` is now + `{ "status": "validated", "files": [...], "validated-at": "2026-09-02T21:04:10.223Z" }`. + +## What the guards do + +Five enforcement points, all inert unless PRD is active: + +| Guard | When | Who it applies to | What the agent sees | +|---|---|---|---| +| Write/Edit ownership (`prd-pre-gate.ts`) | A `Write`/`Edit`/`apply_patch` targets the router, a task PRD, or `prd/agents/*.json` | claude-code / codex: enforced by identity. cursor / kimi: consultative — never blocked here | A `[BLOCKED] PRD ownership` prompt: `: `, where `` is one of `"router is coordinator-only"`, `"task PRD is coordinator-only"`, `"docs is coordinator-only"`, `"agent report is not the coordinator's to write"`, or `"name doesn't match your agent_type, or already bound to another agent"` | +| Bash-under-`prd/` deny (`prd-pre-gate.ts`) | A Bash `>`/`>>` redirect targeting `apex/prd/` (`shellOutputRedirects`), **or** one of 6 non-redirect write verbs whose write-target argument resolves there (`extraBashWriteTargets`, `prd-bash-targets.ts`): `cp`/`mv`/`install` (last positional arg), `tee` (every positional arg), `sed -i`/`perl -i` (last positional arg, in-place only), `dd of=` (the `of=` operand). Verified live: `cp ` and `dd if= of=` both deny; `touch ` — a write verb outside this list — is **not** caught here (falls through to the ordinary gate chain, which has no `.claude/apex/` case for `touch` either, so it currently allows). Best-effort static scanner, not a shell — see the module's own header for the `getopt_long` PERMUTE-mode edge case it does not track. | Everyone, coordinator included | `"PRD files must be written via Write/Edit/apply_patch, never Bash."` | +| PostToolUse cross-check (`prd-post-check.ts`) | Right after the router or a task PRD is written | Silent — no immediate message | Nothing at the time; a mismatch between what a task PRD assigned and what an agent actually reported is recorded, and can later trigger the lead's `Stop` block below | +| `SubagentStart` context (`prd-subagent-context.ts`) | A sub-agent's session starts | That sub-agent only | Its own file/sub-task slice injected as context (the `## PRD assignment` block in the example above) | +| `SubagentStop` block-once (`prd-subagent-stop.ts`) | A sub-agent tries to stop with an undone sub-task | That sub-agent, once | `"PRD sub-task(s) not done for on task \"\": . Finish the work (or ask the coordinator to reassign) before stopping."` — never fires on Cursor (identity is unlinkable there, see Known limitations) | +| Lead `Stop` block-once (`prd-stop-gate.ts`) | The lead tries to stop with an unresolved cross-check violation | The lead, once | `"PRD cross-check found unresolved violation(s): a task/sub-task is marked validated without a matching done report. Run \`harness prd status\` for details."` — never fires on Cursor | + +A malformed (unparseable JSON) router with `FUSE_PRD=1` active is its own +case: every in-scope PRD write is denied with +`"PRD router is malformed JSON — fix apex/prd.json or unset FUSE_PRD."`, +while every other file in the same session is unaffected. + +One edge case worth calling out: an `agent_id` with **no** `agent_type` at +all (not observed live, but a defined case) is always treated as a +sub-agent and denied outright on an agent-report write — reason +`"unidentifiable agent_type — cannot verify ownership"` — rather than +falling back to consultative mode. Presence of `agent_id` proves the +harness *can* send identity, so a missing `agent_type` is failed closed, +unlike Cursor/Kimi's structural absence of both fields. + +## Capability matrix + +Per capability, not per adapter — coverage splits unevenly across targets: + +| Capability | claude-code | codex | cursor | kimi | cline / gemini-cli / hermes | +|---|---|---|---|---|---| +| Write ownership block | full | full | consultative only (never blocks) | consultative only (never blocks) | full, if the consumer wires `PreToolUse` | +| Bash-under-`prd/` deny | yes | yes | yes | yes | yes | +| `SubagentStart` slice injection | yes | yes | **no** — the slice IS built (identical to claude-code's), but `adapters/cursor/respond.ts`'s `toCursorLifecycleResponse` collapses any non-denied `subagentStart` to bare `{"permission":"allow"}`, dropping it; measured byte-identical output with a matching PRD assignment, with no router at all, and with `FUSE_PRD` unset | delivered, ignored (observation-only) | gemini-cli: yes, delivered — routed through `respond()`'s native "inform" shape, then re-wrapped by `joinContextResponses` into the shared Claude-style `hookSpecificOutput.hookEventName/additionalContext` envelope plus a `[NOTE]` title line (not gemini-cli's own minimal shape, but the text arrives). hermes: yes, delivered — same Claude-style `contextResponse` as claude-code; Hermes's own documented non-blocking shape is `{context}`, not `hookSpecificOutput`, so whether a real Hermes client reads it is unverified. cline: yes, delivered in its own native `{contextModification}` shape — `joinContextResponses` keeps whichever envelope its parts came from instead of assuming the Claude one (measured 476 bytes where an earlier build emitted none; the six other targets stay byte-identical, gemini-cli 529 = 529, the Claude-family four 506 = 506) | +| `SubagentStop` block-once | yes | yes | delivered, response ignored | delivered, response ignored | yes — each in its OWN native block shape via `respond()`: gemini-cli `{"decision":"deny","reason"}`, cline `{"cancel":true,"errorMessage"}`, hermes `{"decision":"block","reason"}` (via `blockResponse`, which happens to match Hermes's own documented block contract). Block-once verified: 1st call blocks, an identical replay is silent (empty stdout) on all three, under both the default journal-based session track and legacy `FUSE_TRACK_JOURNAL=0` | +| Lead `Stop` block-once | yes | yes | not applicable (`stop` isn't gated on Cursor today) | best effort — Kimi's `Stop` is a documented blocking event, but this has not been verified live | yes — same per-adapter native shapes and block-once behavior as `SubagentStop` above (gemini-cli/cline/hermes), verified under both tracking modes | + +Cursor and Kimi never hard-block on file ownership: the harness cannot +correlate a sub-agent's write back to its own identity on either target +(see [Known limitations](#known-limitations)), so both stay consultative +and rely on the after-the-fact cross-check instead. + +## CLI + +``` +harness prd status [--json] [--id ] [--root ] +harness prd validate [agent] [--id ] [--root ] +harness prd compact [--id ] [--root ] +``` + +`--root` defaults to `cwd`. `--id` picks the harness (and therefore the +`homeSeg` — `.claude`, `.codex`, …) explicitly; without it, the CLI +auto-detects the *sole* `homeSeg` under `--root` that has an +`apex/prd.json` — zero or more than one match is a usage error, not a guess. + +- **`status`** — read-only, works **without** `FUSE_PRD=1`. +- **`validate`** — writes, so it **requires `FUSE_PRD=1`**. Runs the + cross-check; on success, flips the given agent's (or every named + agent's) sub-tasks to `validated` and promotes the router entry once + every agent is validated. +- **`compact`** — writes, so it **requires `FUSE_PRD=1`**. Collapses a + fully-validated task PRD to its compacted shape; refuses if any + sub-task isn't `validated` yet. + +Exit codes and the exact messages, as measured: + +| Case | Command | Exit | stderr | +|---|---|---|---| +| No router anywhere under `--root`, no `--id` | `status` | `2` | `no PRD router found under //apex/prd.json — pass --id ` | +| `--id` given but that harness has no router | `status` | `1` | `no PRD router at //apex/prd.json` | +| `validate`/`compact` without `FUSE_PRD=1` | either | `1` | `` prd requires FUSE_PRD=1 in /.env or the project .env `` | +| `validate` finds unready sub-tasks | `validate` | `1` | `prd validate: N violation(s)` + one ` - //: ` line per violation | +| `compact` before every sub-task is `validated` | `compact` | `1` | `prd compact: sub-task "" of agent "" is not validated` | +| Usage error (missing ``, unknown task/agent) | `validate`/`compact` | `2` | e.g. `no such task "" in PRD router`, `no such agent "" in task PRD for ""` | +| Success | any | `0` | (empty for `validate`; `compacted: ` or `nothing to compact` for `compact`) | + +Real `status` output against the `auth-refactor` fixture (`backend-expert` +already reported, `backend-expert-2` hasn't yet): + +``` +$ harness prd status --root +Task Router status Agents Sub-tasks done/total Violations +auth-refactor assigned 2 1/2 0 +``` + +Real `--json` output (same state): + +```json +{ + "router": { "auth-refactor": { "prd": "prd/auth-refactor-prd.json", "status": "assigned" } }, + "taskFiles": { + "auth-refactor": { + "backend-expert": { "files": ["src/auth/login.ts"], "sub-tasks": { "jwt-validation": { "status": "assigned" } } }, + "backend-expert-2": { "files": ["src/auth/session.ts"], "sub-tasks": { "session-store": { "status": "assigned" } } } + } + }, + "reports": { + "backend-expert": { "auth-refactor": { "jwt-validation": { "status": "done", "modified": ["src/auth/login.ts"], "unchanged": [] } } }, + "backend-expert-2": null + } +} +``` + +## FAQ + +**My sub-agent's write got refused — why?** +Its `agent_type` doesn't match a name in the task PRD, or that name is +already bound to a different `agent_id`. Run `harness prd status` and +check the task PRD names your agent exactly (`` or +`-`, n ≥ 2). + +**Why do I see two assignments in my context?** +Your `agent_type` matches more than one name in the task PRD (e.g. +`backend-expert` matches both `backend-expert` and `backend-expert-2`). +The context shows every matching slice with the disambiguation header +above so nothing is silently dropped. Nothing to fix — just write your +**one** report first: the first `prd/agents/-prd.json` write from +your `agent_id` binds that name to you, and every later write must match +the bound name. + +**Nothing happens at all — why?** +Check, in order: is `FUSE_PRD` set to exactly `"1"`? Does +`/apex/prd.json` exist? Is the harness one that sends agent +identity at all — Cursor and Kimi never do, so ownership stays +consultative there by design, not by bug. + +**How do I turn it off?** +Unset `FUSE_PRD` (or set it to anything other than `"1"`), or delete the +router file — either one alone returns the module to fully inert. + +## Known limitations + +- **Cursor's `SubagentStart` slice is built, then dropped.** The + injection runs the identical code path claude-code uses (confirmed by + running the same router/task-PRD state through both target ids), but + Cursor's own response contract has no context channel for a + non-denied `subagentStart`: `toCursorLifecycleResponse` always + collapses it to bare `{"permission":"allow"}`. Verified live: the + output is byte-identical whether the router has a matching assignment, + no router at all, or `FUSE_PRD` is unset entirely — a Cursor sub-agent + never sees its PRD slice. + (cline had the same symptom for a different, fixable reason: + `joinContextResponses` recognized only the Claude-shaped + `hookSpecificOutput.additionalContext` envelope and silently dropped + cline's native `{contextModification}` inform shape. That one is now + fixed — the merge keeps whichever envelope its parts came from, so + cline receives 476 bytes where it previously received none, and all + six other targets stay byte-identical. Cursor's gap below is + structural and remains.) +- **Cursor can't correlate a sub-agent's write to its own identity.** A + Cursor sub-agent's actual file-write event runs under a session ID + that shares nothing with the lead's or with the `SubagentStart` + event's own IDs. Per-write ownership checking on Cursor isn't possible + today — only the after-the-fact cross-check catches a mismatch. +- **Neither Cursor nor Kimi ever sends an agent identity field at all** + on a Write/Edit payload, so the harness can't tell who the lead even + is there. Both targets default to consultative mode permanently, + rather than guessing at a correlation. +- **No file lock on PRD writes.** The ownership guard makes two agents + writing the *same* file at once structurally impossible, but a write + from completely outside the guarded hook path (a human editing the + file directly in a terminal) isn't something any guard here can see. +- **Kimi's `SubagentStop` block-once is best-effort.** Kimi delivers the + `SubagentStop` event but ignores its response by protocol, so the + block never actually stops anything there. The lead `Stop` block + should work on Kimi (`Stop` is one of its documented blocking events) + but this has not been verified against a live Kimi session. +- **Block-once persistence is tracking-mode-independent, verified.** Both + `SubagentStop` and lead `Stop` block-once markers are written on + whichever side `trackJournalEnabled()` reads from — the default + journal-based session track, or the legacy full-snapshot one under + `FUSE_TRACK_JOURNAL=0` — so a replay is silent (empty stdout) after the + first block in both modes. Verified live in both modes; this used to + only hold under the (default) journal mode. diff --git a/docs/runtime.md b/docs/runtime.md index a4cf5da..1eaf6ca 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -114,6 +114,31 @@ than a new one — this is a heuristic tuned to the observed fan-out latency, no a protocol guarantee. See `deny-loop.ts`'s `dedupMs` param and `one-shot.ts`'s `burstFirst` for the two consumers. +## PRD coordination (`./runtime/prd`) + +Opt-in (`FUSE_PRD=1`), documented in full at [prd.md](./prd.md). Five +dispatch-point exports, each wired as one call into an existing dispatch +point, plus two internal helpers (`prd-identity.ts`'s +`resolvePrdIdentity`, `prd-candidate-files.ts`'s `prdCandidateFiles`) used +only by `prd-pre-gate.ts` — both re-exported from the `./runtime/prd` +barrel but not separate call sites: + +- `prd-pre-gate.ts`'s `prdPreGate` → one line in `handle-pre.ts`, ahead of + the rest of the PRE pipeline — allows an authorized PRD write (bypassing + `gate()` entirely) or returns the ownership deny. +- `prd-post-check.ts`'s `prdPostCheck` → one line in `handle-post.ts`, + after the existing per-file loop — runs the cross-check when a task PRD + or the router just changed. +- `prd-subagent-context.ts`'s `prdSubagentContext` → folded into + `lifecycle/dispatch.ts`'s `SubagentStart` case — injects the starting + agent's own file/sub-task slice. +- `prd-subagent-stop.ts`'s `prdSubagentStopGate` → `lifecycle/dispatch.ts`'s + `SubagentStop` case, ahead of `trackAgentMemory` (returned only when the + gate is a no-op) — one-shot block on an incomplete sub-task. +- `prd-stop-gate.ts`'s `prdStopGate` → `lifecycle/dispatch.ts`'s `Stop` + case (`core` scope), before `stopCore` — one-shot block on an + unresolved cross-check violation. + ## Sidechain evidence harvest (`SubagentStop`) Sub-agent `PostToolUse` hooks don't reliably fire on Claude Code (documented diff --git a/package.json b/package.json index c12f2ed..86de4c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.92", + "version": "0.1.93", "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", diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 3ca852d..13f6bfb 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -23,6 +23,7 @@ import { runDoctor, runningVersion, versionBanner } from "./doctor"; import { parseScope } from "./scope"; import { isMalformedCursorStdin, isOversize, oversizeStdout, readStdin, traceHook } from "./hook-io"; import { maybePlaySound } from "./hook-sound"; +import { runPrd } from "./prd"; const cmd = process.argv[2]; @@ -77,6 +78,8 @@ if (cmd === "--version" || cmd === "-v") { const dir = process.argv[3] ?? process.cwd(); process.stdout.write(JSON.stringify(runSecurityScan(dir), null, 2) + "\n"); process.exit(0); +} else if (cmd === "prd") { + process.exit(await runPrd(process.argv.slice(3), process.cwd(), process.env)); } else if (cmd === "codex-rules") { const outIdx = process.argv.indexOf("--out"); const outPath = outIdx !== -1 ? process.argv[outIdx + 1] : undefined; diff --git a/src/cli/index.ts b/src/cli/index.ts index 11d8ba3..760746c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1 +1,2 @@ export * from "./run"; +export * from "./prd"; diff --git a/src/cli/prd/compact.ts b/src/cli/prd/compact.ts new file mode 100644 index 0000000..ce8cdd2 --- /dev/null +++ b/src/cli/prd/compact.ts @@ -0,0 +1,53 @@ +/** + * `harness prd compact [--id] [--root]` — collapses every fully + * validated agent entry of a task-PRD to its compacted shape. Requires + * `FUSE_PRD=1`; refuses (exit 1) when any sub-task is not yet `validated`. + */ +import { writeTaskFile } from "../../policy/prd/prd-io"; +import { compactTaskFile } from "../../policy/prd/prd-compact"; +import { isCompacted, subTasksOf } from "../../policy/prd/prd-schema"; +import { requireFusePrd, resolveTaskFile, withPrdLock } from "./shared"; + +/** + * Run `harness prd compact`. Exit 0 on success (including a no-op compact), + * exit 1 when `FUSE_PRD` is unset, a sub-task is unvalidated, or the lock is + * held, exit 2 on usage/lookup errors. + */ +export async function runPrdCompact(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { + const resolved = requireFusePrd(argv, cwd, env, "compact"); + if (!resolved.ok) { + process.stderr.write(resolved.message + "\n"); + return resolved.code; + } + const { root, homeSeg } = resolved; + + const tf = await resolveTaskFile(argv, root, homeSeg, "usage: harness prd compact "); + if (!tf.ok) { + process.stderr.write(tf.message + "\n"); + return tf.code; + } + const { routerEntry, taskFile } = tf; + + for (const [agent, entry] of Object.entries(taskFile)) { + if (isCompacted(entry)) continue; + for (const [sub, status] of Object.entries(subTasksOf(entry))) { + if (status.status !== "validated") { + process.stderr.write(`prd compact: sub-task "${sub}" of agent "${agent}" is not validated\n`); + return 1; + } + } + } + + const result = await withPrdLock(root, homeSeg, async () => { + const now = new Date().toISOString(); + const { file, compacted } = compactTaskFile(taskFile, now); + await writeTaskFile(root, homeSeg, routerEntry.prd, file); + return compacted; + }); + if (!result.ok) { + process.stderr.write(`prd compact: ${result.message}\n`); + return 1; + } + process.stdout.write(result.value.length > 0 ? `compacted: ${result.value.join(", ")}\n` : "nothing to compact\n"); + return 0; +} diff --git a/src/cli/prd/format.ts b/src/cli/prd/format.ts new file mode 100644 index 0000000..a76d51e --- /dev/null +++ b/src/cli/prd/format.ts @@ -0,0 +1,23 @@ +/** + * Rendering helpers for `harness prd` output: an aligned ASCII table for the + * human-readable path, and pretty JSON for `--json`. + */ + +/** Pad `s` with trailing spaces to `width`. */ +function pad(s: string, width: number): string { + return s.length >= width ? s : s + " ".repeat(width - s.length); +} + +/** Render an aligned ASCII table from a header row + data rows. */ +export function renderTable(header: string[], rows: string[][]): string { + const all: string[][] = [header, ...rows]; + const widths = header.map((_, col) => Math.max(...all.map((r) => (r[col] ?? "").length))); + return all + .map((r) => r.map((cell, col) => pad(cell ?? "", widths[col] ?? 0)).join(" ").trimEnd()) + .join("\n"); +} + +/** Render `data` as pretty-printed JSON with a trailing newline. */ +export function renderJson(data: unknown): string { + return JSON.stringify(data, null, 2) + "\n"; +} diff --git a/src/cli/prd/index.ts b/src/cli/prd/index.ts new file mode 100644 index 0000000..2c7e9dd --- /dev/null +++ b/src/cli/prd/index.ts @@ -0,0 +1,23 @@ +/** + * `harness prd ` — CLI entry point for the PRD + * (task/agent ownership coordination) module. + */ +export * from "./resolve"; +export * from "./format"; +export { runPrdStatus } from "./status"; +export { runPrdValidate } from "./validate"; +export { runPrdCompact } from "./compact"; + +import { runPrdStatus } from "./status"; +import { runPrdValidate } from "./validate"; +import { runPrdCompact } from "./compact"; + +/** Dispatch `harness prd ...rest` to the matching `run*` function. */ +export async function runPrd(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { + const [sub, ...rest] = argv; + if (sub === "status") return runPrdStatus(rest, cwd, env); + if (sub === "validate") return runPrdValidate(rest, cwd, env); + if (sub === "compact") return runPrdCompact(rest, cwd, env); + process.stderr.write(`harness prd: unknown sub-command "${sub ?? ""}" (expected status|validate|compact)\n`); + return 2; +} diff --git a/src/cli/prd/resolve.ts b/src/cli/prd/resolve.ts new file mode 100644 index 0000000..7936d26 --- /dev/null +++ b/src/cli/prd/resolve.ts @@ -0,0 +1,88 @@ +/** + * Argument parsing + harness resolution for `harness prd `. Resolves + * `--root` (default `cwd`) and `--id`/`homeSeg` (default: the sole + * `HOME_DIR` (`config/dotenv.ts`) segment under which + * `//apex/prd.json` exists — ambiguous or absent is a caller + * error, never guessed). + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { HOME_DIR, loadDotenv } from "../../config/dotenv"; +import { harnessHomeSegment } from "../../policy/apex-target"; +import type { HarnessId } from "../../detect/interfaces/types"; + +const VALUE_FLAGS: ReadonlySet = new Set(["--id", "--root"]); +const BOOLEAN_FLAGS: ReadonlySet = new Set(["--json"]); + +/** Resolved `--root`/`--id` pair for a `prd` sub-command. */ +export interface PrdResolved { + root: string; + homeSeg: string; + id: HarnessId; +} + +/** Result of {@link resolvePrdArgs}: resolved args, or a usage error (exit 2). */ +export type PrdResolveResult = { ok: true; value: PrdResolved } | { ok: false; message: string }; + +/** Read `-- ` from argv, or `undefined` when absent. */ +export function readFlag(argv: string[], name: string): string | undefined { + const idx = argv.indexOf(name); + return idx !== -1 ? argv[idx + 1] : undefined; +} + +/** True when `--json` is present in argv. */ +export function hasJsonFlag(argv: string[]): boolean { + return argv.includes("--json"); +} + +/** Non-flag tokens in argv, skipping known flags and their values. */ +export function positionalArgs(argv: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + if (tok === undefined) continue; + if (VALUE_FLAGS.has(tok)) { i++; continue; } + if (BOOLEAN_FLAGS.has(tok)) continue; + out.push(tok); + } + return out; +} + +/** + * Resolve `{root, homeSeg, id}` for a `prd` sub-command. `--id` wins + * outright; otherwise auto-detects the sole `HOME_DIR` segment under which + * `//apex/prd.json` exists. + */ +export function resolvePrdArgs(argv: string[], cwd: string): PrdResolveResult { + const root = readFlag(argv, "--root") ?? cwd; + const idFlag = readFlag(argv, "--id"); + if (idFlag) { + return { ok: true, value: { root, homeSeg: harnessHomeSegment(idFlag), id: idFlag as HarnessId } }; + } + + const matches: Array<{ id: HarnessId; seg: string }> = []; + for (const [id, seg] of Object.entries(HOME_DIR) as Array<[HarnessId, string]>) { + if (existsSync(join(root, seg, "apex", "prd.json"))) matches.push({ id, seg }); + } + if (matches.length === 0) { + return { ok: false, message: `no PRD router found under ${root}//apex/prd.json — pass --id ` }; + } + if (matches.length > 1) { + return { ok: false, message: `ambiguous harness: PRD routers found for ${matches.map((m) => m.id).join(", ")} — pass --id` }; + } + const only = matches[0] as { id: HarnessId; seg: string }; + return { ok: true, value: { root, homeSeg: only.seg, id: only.id } }; +} + +/** + * Shared first step of every `prd` sub-command: {@link resolvePrdArgs}, then + * (on success) `loadDotenv` the resolved harness's home `.env` + `/.env` + * as a side effect on `env` — same env-loading contract as the `hook` branch + * (`bin.ts`'s `loadDotenv(id)` call). + */ +export function resolveAndLoadEnv(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): PrdResolveResult { + const resolved = resolvePrdArgs(argv, cwd); + if (resolved.ok) loadDotenv(resolved.value.id, env, homedir(), resolved.value.root); + return resolved; +} diff --git a/src/cli/prd/shared.ts b/src/cli/prd/shared.ts new file mode 100644 index 0000000..8f3a228 --- /dev/null +++ b/src/cli/prd/shared.ts @@ -0,0 +1,80 @@ +/** + * Shared plumbing for `status`/`validate`/`compact`: root+env resolution, + * the `FUSE_PRD` write gate, task-PRD lookup, and the `.lock`-guarded write. + * All return a result instead of calling `process.exit` themselves — + * callers own the exit code (extracted to kill a jscpd-measured clone + * across the three `run*` functions). + */ +import { join } from "node:path"; +import { prdDir } from "../../policy/prd/prd-paths"; +import { readRouter, readTaskFile } from "../../policy/prd/prd-io"; +import { isPrdFlagSet } from "../../policy/prd/prd-enabled"; +import type { PrdRouter, PrdRouterEntry, PrdTaskFile } from "../../policy/prd/interfaces/types"; +import { acquireLock } from "../../runtime/lifecycle/aipilot/apex-task-store"; +import { positionalArgs, resolveAndLoadEnv } from "./resolve"; + +/** `{root, homeSeg}` on success, or an exit code + message on failure. */ +export type PrdStepResult = { ok: true; root: string; homeSeg: string } | { ok: false; code: number; message: string }; + +/** {@link resolveAndLoadEnv}, narrowed to `{root, homeSeg}` — the common first step of every sub-command. */ +export function resolveRoot(argv: string[], cwd: string, env: NodeJS.ProcessEnv): PrdStepResult { + const resolved = resolveAndLoadEnv(argv, cwd, env); + if (!resolved.ok) return { ok: false, code: 2, message: resolved.message }; + return { ok: true, root: resolved.value.root, homeSeg: resolved.value.homeSeg }; +} + +/** {@link resolveRoot} + the `FUSE_PRD=1` write gate shared by `validate`/`compact`. */ +export function requireFusePrd(argv: string[], cwd: string, env: NodeJS.ProcessEnv, sub: string): PrdStepResult { + const resolved = resolveRoot(argv, cwd, env); + if (!resolved.ok) return resolved; + if (!isPrdFlagSet(env)) { + return { ok: false, code: 1, message: `prd ${sub} requires FUSE_PRD=1 in ${resolved.homeSeg}/.env or the project .env` }; + } + return resolved; +} + +/** Result of {@link loadTaskFileFor}: the resolved router/task-PRD, or a usage error. */ +export type PrdTaskLookup = + | { ok: true; router: PrdRouter; routerEntry: PrdRouterEntry; taskFile: PrdTaskFile } + | { ok: false; code: 2; message: string }; + +/** Reads the router + the named task's task-PRD file. Never calls `process.exit`. */ +export async function loadTaskFileFor(root: string, homeSeg: string, task: string): Promise { + const router = await readRouter(root, homeSeg); + const routerEntry = router?.[task]; + if (!router || !routerEntry) return { ok: false, code: 2, message: `no such task "${task}" in PRD router` }; + + const taskFile = await readTaskFile(root, homeSeg, routerEntry.prd); + if (!taskFile) return { ok: false, code: 2, message: `task PRD not found or malformed: ${routerEntry.prd}` }; + + return { ok: true, router, routerEntry, taskFile }; +} + +/** Result of {@link resolveTaskFile}: the `` positional plus its looked-up files, or a usage/lookup error. */ +export type PrdTaskArgLookup = + | { ok: true; task: string; agentArg: string | undefined; router: PrdRouter; routerEntry: PrdRouterEntry; taskFile: PrdTaskFile } + | { ok: false; code: 2; message: string }; + +/** Extracts ` [agent]` from argv, then {@link loadTaskFileFor} — the shared `validate`/`compact` entry lookup. */ +export async function resolveTaskFile(argv: string[], root: string, homeSeg: string, usage: string): Promise { + const [task, agentArg] = positionalArgs(argv); + if (!task) return { ok: false, code: 2, message: usage }; + const lookup = await loadTaskFileFor(root, homeSeg, task); + if (!lookup.ok) return lookup; + return { task, agentArg, ...lookup }; +} + +/** Result of {@link withPrdLock}. */ +export type PrdLockResult = { ok: true; value: T } | { ok: false; message: string }; + +/** Runs `fn` under the PRD `.lock` directory-lock. Never calls `process.exit`. */ +export async function withPrdLock(root: string, homeSeg: string, fn: () => Promise): Promise> { + const lockDir = join(prdDir(root, homeSeg), ".lock"); + const release = await acquireLock(lockDir); + if (!release) return { ok: false, message: `lock held at ${lockDir}` }; + try { + return { ok: true, value: await fn() }; + } finally { + await release(); + } +} diff --git a/src/cli/prd/status.ts b/src/cli/prd/status.ts new file mode 100644 index 0000000..4485acb --- /dev/null +++ b/src/cli/prd/status.ts @@ -0,0 +1,87 @@ +/** + * `harness prd status [--json] [--id ] [--root ]` — read-only + * PRD snapshot. Never requires `FUSE_PRD`. Exit 0 on success, exit 1 when no + * router is found, exit 2 on an unresolved/ambiguous `--id`. + */ +import { prdRouterPath } from "../../policy/prd/prd-paths"; +import { readAgentReport, readAllTaskFiles, readRouter } from "../../policy/prd/prd-io"; +import { crossCheckTask } from "../../policy/prd/prd-crosscheck"; +import { isCompacted, subTasksOf } from "../../policy/prd/prd-schema"; +import type { PrdAgentReportFile, PrdTaskFile } from "../../policy/prd/interfaces/types"; +import { hasJsonFlag } from "./resolve"; +import { renderJson, renderTable } from "./format"; +import { resolveRoot } from "./shared"; + +const HEADER: string[] = ["Task", "Router status", "Agents", "Sub-tasks done/total", "Violations"]; + +/** + * Sub-task readiness counts for the "done/total" column: a sub-task counts as + * done once it is `validated` OR its owning agent's own report already shows + * `"done"` for it (readiness view — a report can land well before the + * coordinator runs `prd validate`). A compacted (fully-validated) entry + * counts as one done/one total unit — its per-sub-task detail is gone. + */ +function subTaskCounts( + taskFile: PrdTaskFile, + reports: Record, + task: string, +): { done: number; total: number } { + let done = 0; + let total = 0; + for (const [agent, entry] of Object.entries(taskFile)) { + if (isCompacted(entry)) { + done++; + total++; + continue; + } + const report = reports[agent]; + for (const [sub, subTask] of Object.entries(subTasksOf(entry))) { + total++; + if (subTask.status === "validated" || report?.[task]?.[sub]?.status === "done") done++; + } + } + return { done, total }; +} + +/** + * Run `harness prd status`. Prints an aligned table (or `--json` dump of + * `{router, taskFiles, reports}`) and returns the process exit code. + */ +export async function runPrdStatus(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { + const resolved = resolveRoot(argv, cwd, env); + if (!resolved.ok) { + process.stderr.write(resolved.message + "\n"); + return resolved.code; + } + const { root, homeSeg } = resolved; + + const router = await readRouter(root, homeSeg); + if (!router) { + process.stderr.write(`no PRD router at ${prdRouterPath(root, homeSeg)}\n`); + return 1; + } + + const taskFiles = await readAllTaskFiles(root, homeSeg, router); + const agentNames = new Set(); + for (const tf of Object.values(taskFiles)) { + if (tf) for (const agent of Object.keys(tf)) agentNames.add(agent); + } + const reports: Record = {}; + for (const agent of agentNames) reports[agent] = await readAgentReport(root, homeSeg, agent); + + if (hasJsonFlag(argv)) { + process.stdout.write(renderJson({ router, taskFiles, reports })); + return 0; + } + + const rows: string[][] = []; + for (const [task, entry] of Object.entries(router)) { + const tf = taskFiles[task]; + const agentCount = tf ? Object.keys(tf).length : 0; + const { done, total } = tf ? subTaskCounts(tf, reports, task) : { done: 0, total: 0 }; + const violations = tf ? crossCheckTask(tf, reports as Record, task) : []; + rows.push([task, entry.status, String(agentCount), `${done}/${total}`, String(violations.length)]); + } + process.stdout.write(renderTable(HEADER, rows) + "\n"); + return 0; +} diff --git a/src/cli/prd/validate.ts b/src/cli/prd/validate.ts new file mode 100644 index 0000000..bd9f1fb --- /dev/null +++ b/src/cli/prd/validate.ts @@ -0,0 +1,90 @@ +/** + * `harness prd validate [agent] [--id] [--root]` — cross-checks a + * task-PRD against the named agent(s)' own report(s); on success flips the + * matching sub-tasks to `validated` and promotes the router entry when every + * agent is fully validated. Requires `FUSE_PRD=1`. + */ +import { readAgentReport, writeRouter, writeTaskFile } from "../../policy/prd/prd-io"; +import { incompleteSubTasks } from "../../policy/prd/prd-crosscheck"; +import { isCompacted, subTasksOf, withRouterStatus, withSubTaskValidated } from "../../policy/prd/prd-schema"; +import type { PrdAgentReportFile, PrdCrossCheckViolation, PrdTaskFile } from "../../policy/prd/interfaces/types"; +import { requireFusePrd, resolveTaskFile, withPrdLock } from "./shared"; + +function allValidated(taskFile: PrdTaskFile): boolean { + return Object.values(taskFile).every( + (entry) => isCompacted(entry) || Object.values(subTasksOf(entry)).every((s) => s.status === "validated"), + ); +} + +/** + * Sub-tasks of `agents` with no matching `done` entry in their owning + * agent's own report — the readiness gate `validate` enforces before + * flipping anything. Reuses `incompleteSubTasks` (distinct from + * `crossCheckTask`, which only flags an ALREADY-`validated` sub-task lacking + * a report; here nothing has been validated yet). + */ +function findUnreadySubTasks( + taskFile: PrdTaskFile, + reports: Record, + task: string, + agents: string[], +): PrdCrossCheckViolation[] { + const out: PrdCrossCheckViolation[] = []; + for (const agent of agents) { + const missing = incompleteSubTasks(taskFile, agent, task, reports[agent] ?? null); + for (const sub of missing) out.push({ task, agent, sub, reason: `no "done" report from "${agent}" for sub-task "${sub}"` }); + } + return out; +} + +/** + * Run `harness prd validate`. Exit 0 on success, exit 1 on cross-check + * violations or a missing `FUSE_PRD=1`/held lock, exit 2 on usage/lookup errors. + */ +export async function runPrdValidate(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { + const resolved = requireFusePrd(argv, cwd, env, "validate"); + if (!resolved.ok) { + process.stderr.write(resolved.message + "\n"); + return resolved.code; + } + const { root, homeSeg } = resolved; + + const tf = await resolveTaskFile(argv, root, homeSeg, "usage: harness prd validate [agent]"); + if (!tf.ok) { + process.stderr.write(tf.message + "\n"); + return tf.code; + } + const { task, agentArg, router, routerEntry, taskFile } = tf; + if (agentArg && !(agentArg in taskFile)) { + process.stderr.write(`no such agent "${agentArg}" in task PRD for "${task}"\n`); + return 2; + } + + const agents = agentArg ? [agentArg] : Object.keys(taskFile); + const reports: Record = {}; + for (const agent of Object.keys(taskFile)) reports[agent] = await readAgentReport(root, homeSeg, agent); + + const violations = findUnreadySubTasks(taskFile, reports, task, agents); + if (violations.length > 0) { + process.stderr.write(`prd validate: ${violations.length} violation(s)\n`); + for (const v of violations) process.stderr.write(` - ${v.task}/${v.agent}/${v.sub}: ${v.reason}\n`); + return 1; + } + + const result = await withPrdLock(root, homeSeg, async () => { + const now = new Date().toISOString(); + let updated = taskFile; + for (const agent of agents) { + const entry = updated[agent]; + if (!entry || isCompacted(entry)) continue; + for (const sub of Object.keys(subTasksOf(entry))) updated = withSubTaskValidated(updated, agent, sub, now); + } + await writeTaskFile(root, homeSeg, routerEntry.prd, updated); + if (allValidated(updated)) await writeRouter(root, homeSeg, withRouterStatus(router, task, "validated", now)); + }); + if (!result.ok) { + process.stderr.write(`prd validate: ${result.message}\n`); + return 1; + } + return 0; +} diff --git a/src/policy/prd/index.ts b/src/policy/prd/index.ts new file mode 100644 index 0000000..c22ad8a --- /dev/null +++ b/src/policy/prd/index.ts @@ -0,0 +1,36 @@ +/** Barrel for `src/policy/prd/**` — the public surface Lot B and Lot C import from. */ +export type { + PrdAgentEntryCompacted, PrdAgentEntryExpanded, PrdAgentReportFile, PrdAgentSubEntry, + PrdCrossCheckViolation, PrdIdentity, PrdOwnershipVerdict, PrdPathKind, PrdRouter, + PrdRouterEntry, PrdRouterStatus, PrdSubagentSlice, PrdSubStatus, PrdSubTask, PrdTaskAgentEntry, + PrdTaskFile, +} from "./interfaces/types"; + +export { + classifyPrdPath, isPrdScopedPath, prdAgentReportPath, prdDir, prdDocsPath, prdRouterPath, + prdTaskPath, +} from "./prd-paths"; + +export { + filesOf, isCompacted, parseAgentReportFile, parseRouter, parseTaskFile, subTasksOf, + validateTaskFileInvariant, withRouterStatus, withSubTaskValidated, +} from "./prd-schema"; + +export { + candidateAgentNames, evaluateWriteOwnership, matchesAgentName, resolveOwnerBinding, +} from "./prd-ownership"; + +export { + crossCheckRouter, crossCheckTask, hasAnyViolations, incompleteSubTasks, +} from "./prd-crosscheck"; + +export { canPromoteRouterEntry, compactAgentEntry, compactTaskFile } from "./prd-compact"; + +export { agentSlice, agentSlices, joinContextResponses, renderAgentSliceMarkdown } from "./prd-context"; + +export { + readAgentReport, readAgentReportSync, readAllTaskFiles, readRouter, readRouterSync, + readTaskFile, readTaskFileSync, writeAgentReport, writeRouter, writeTaskFile, +} from "./prd-io"; + +export { isPrdEnabled, isPrdFlagSet, prdProjectRoot, routerExistsSync } from "./prd-enabled"; diff --git a/src/policy/prd/interfaces/types.ts b/src/policy/prd/interfaces/types.ts new file mode 100644 index 0000000..79ed0e7 --- /dev/null +++ b/src/policy/prd/interfaces/types.ts @@ -0,0 +1,103 @@ +/** + * Shared shapes for the PRD (task/agent ownership coordination) module. Pure + * types only — no runtime code, no fs. Mirrors the JSON contract at + * `//apex/prd.json` and `//apex/prd/**`. + * + * FROZEN CONTRACT: this file is imported by both Lot B (runtime wiring) and + * Lot C (CLI). Do not rename or remove an export without flagging it to the + * other lots first. + */ + +/** Lifecycle status of a task in the router (`prd.json`). */ +export type PrdRouterStatus = "assigned" | "in-progress" | "validated"; + +/** One router entry: which task-PRD file backs a task, and its status. */ +export interface PrdRouterEntry { + prd: string; + status: PrdRouterStatus; + "validated-at"?: string; +} + +/** The router file itself (`prd.json`), keyed by task name. */ +export type PrdRouter = Record; + +/** Lifecycle status of one sub-task inside a task-PRD's agent entry. */ +export type PrdSubStatus = "assigned" | "validated"; + +/** One sub-task's status inside a task-PRD's (expanded) agent entry. */ +export interface PrdSubTask { + status: PrdSubStatus; + "validated-at"?: string; +} + +/** Pre-compaction shape of one agent's slice of a task-PRD. */ +export interface PrdAgentEntryExpanded { + files: string[]; + "sub-tasks": Record; +} + +/** Post-compaction shape of one agent's slice of a task-PRD (all sub-tasks validated). */ +export interface PrdAgentEntryCompacted { + status: "validated"; + files: string[]; + "validated-at": string; +} + +/** One agent's slice of a task-PRD, expanded or compacted. */ +export type PrdTaskAgentEntry = PrdAgentEntryExpanded | PrdAgentEntryCompacted; + +/** A task-PRD file (`prd/-prd.json`), keyed by agent name. */ +export type PrdTaskFile = Record; + +/** One sub-task's completion report inside an agent's own report file. */ +export interface PrdAgentSubEntry { + status: "done"; + modified: string[]; + unchanged: string[]; + "done-at"?: string; +} + +/** An agent's own report file (`prd/agents/-prd.json`), keyed by task then sub-task. */ +export type PrdAgentReportFile = Record>; + +/** Resolved identity of the current tool-use, for the ownership check. */ +export interface PrdIdentity { + agentId?: string; + agentType?: string; + /** + * true = structurally proven lead (Claude/Codex: no agent_id/agent_type on + * the event); false = structurally proven sub-agent; "unknown" = harness + * can't tell (Cursor/Kimi — see design doc Risks §1/§2). + */ + lead: boolean | "unknown"; +} + +/** PreToolUse ownership verdict for one in-scope PRD write. */ +export type PrdOwnershipVerdict = + | { allow: true; bind?: { agentId: string; name: string } } + | { allow: false; reason: string } + | { allow: "advisory" }; + +/** One cross-check mismatch between a task-PRD's assignment and an agent's own report. */ +export interface PrdCrossCheckViolation { + task: string; + agent: string; + sub: string; + reason: string; +} + +/** One sub-agent's rendered slice of a task-PRD, for the SubagentStart injection. */ +export interface PrdSubagentSlice { + task: string; + agent: string; + subTasks: string[]; + files: string[]; +} + +/** Classification of an in-scope PRD path (`isPrdScopedPath`/`classifyPrdPath`). */ +export type PrdPathKind = + | { kind: "router" } + | { kind: "task"; task: string } + | { kind: "agentReport"; agent: string } + | { kind: "docs"; task: string } + | { kind: "other" }; diff --git a/src/policy/prd/prd-compact.ts b/src/policy/prd/prd-compact.ts new file mode 100644 index 0000000..d1a6769 --- /dev/null +++ b/src/policy/prd/prd-compact.ts @@ -0,0 +1,28 @@ +/** Collapses fully-validated task-PRD agent entries to their compact shape. Pure — no fs. */ +import { isCompacted, subTasksOf } from "./prd-schema"; +import type { PrdTaskAgentEntry, PrdTaskFile } from "./interfaces/types"; + +/** Collapses one agent entry to the compacted shape ONLY when every sub-task is `validated`; else returns it unchanged. */ +export function compactAgentEntry(e: PrdTaskAgentEntry, at: string): PrdTaskAgentEntry { + if (isCompacted(e)) return e; + const subTasks = Object.values(subTasksOf(e)); + if (subTasks.length === 0 || !subTasks.every((s) => s.status === "validated")) return e; + return { status: "validated", files: e.files, "validated-at": at }; +} + +/** Compacts every eligible agent entry in a task-PRD; reports which agent names got collapsed. */ +export function compactTaskFile(taskFile: PrdTaskFile, at: string): { file: PrdTaskFile; compacted: string[] } { + const compacted: string[] = []; + const file: PrdTaskFile = {}; + for (const [agent, entry] of Object.entries(taskFile)) { + const next = compactAgentEntry(entry, at); + if (next !== entry) compacted.push(agent); + file[agent] = next; + } + return { file, compacted }; +} + +/** True when every agent in the task-PRD is already compacted+validated. */ +export function canPromoteRouterEntry(taskFile: PrdTaskFile): boolean { + return Object.values(taskFile).every((entry) => isCompacted(entry)); +} diff --git a/src/policy/prd/prd-context.ts b/src/policy/prd/prd-context.ts new file mode 100644 index 0000000..ce54631 --- /dev/null +++ b/src/policy/prd/prd-context.ts @@ -0,0 +1,131 @@ +/** + * The SubagentStart "slice" text builder: renders only the sub-agent's own + * PRD assignment, never the whole task. Pure — no fs. + */ +import { candidateAgentNames } from "./prd-ownership"; +import { filesOf, subTasksOf } from "./prd-schema"; +import type { PrdSubagentSlice, PrdTaskFile } from "./interfaces/types"; + +/** + * Builds the slice of `taskFile` owned by each candidate name matching + * `agentType` — one entry per candidate. Empty when no candidate matches; + * 2+ entries when the match is ambiguous (shared base type, e.g. + * `backend-expert`/`backend-expert-2`) so the caller can surface every + * possibility instead of silently dropping the assignment. + */ +export function agentSlices(taskFile: PrdTaskFile, agentType: string, task: string): PrdSubagentSlice[] { + const slices: PrdSubagentSlice[] = []; + for (const agent of candidateAgentNames(agentType, taskFile)) { + const entry = taskFile[agent]; + if (entry) slices.push({ task, agent, subTasks: Object.keys(subTasksOf(entry)), files: filesOf(entry) }); + } + return slices; +} + +/** + * Builds the one slice of `taskFile` owned by `agentType`, or `null` when no + * candidate matches, or the match is ambiguous (2+ candidates — see + * {@link agentSlices} to get all of them instead). + */ +export function agentSlice(taskFile: PrdTaskFile, agentType: string, task: string): PrdSubagentSlice | null { + const slices = agentSlices(taskFile, agentType, task); + return slices.length === 1 ? (slices[0] ?? null) : null; +} + +function renderSliceBlock(s: PrdSubagentSlice): string { + return [ + `## PRD assignment — task ${s.task}`, + `Your files: ${s.files.join(", ")}`, + `Your sub-tasks: ${s.subTasks.join(", ")}`, + `Report to prd/agents/${s.agent}-prd.json when done.`, + ].join("\n"); +} + +/** + * Renders one readable markdown block per slice: title, files, sub-tasks, + * report path. When 2+ slices share the same `task` (an ambiguous + * `agentSlices` result), prefixes that group with an explicit + * disambiguation header instead of silently rendering as if unambiguous. + */ +export function renderAgentSliceMarkdown(slices: PrdSubagentSlice[]): string { + const byTask = new Map(); + for (const s of slices) { + const group = byTask.get(s.task); + if (group) group.push(s); else byTask.set(s.task, [s]); + } + + const blocks: string[] = []; + for (const group of byTask.values()) { + if (group.length > 1) { + const names = group.map((s) => s.agent).join(", "); + blocks.push( + `Several assignments match your agent type. You are ONE of: ${names}. ` + + "The first report you write binds your name; write only that report.", + ); + } + for (const s of group) blocks.push(renderSliceBlock(s)); + } + return blocks.join("\n\n"); +} + +/** One rendered context part's extracted text, plus which envelope it came from. Local implementation detail — never exported (not part of the frozen `interfaces/types.ts` contract). */ +interface ExtractedContext { + text: string; + /** true = cline's native `{contextModification}` shape (see `respond()`'s `"cline"` branch in `runtime/respond.ts`); false = the Claude-family `hookSpecificOutput.additionalContext` envelope every other target (including gemini-cli, which omits `hookEventName` but keeps this same key path) uses. */ + native: boolean; +} + +/** True for a non-null JSON object `JSON.parse` can produce (excludes arrays/primitives/null). */ +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * Extracts the text + originating shape from one already-rendered context + * response (parse-then-narrow, no unchecked `as` cast on the parsed value — + * see {@link isRecord}), or `null` when the input fails to parse, or parses + * to neither known shape. + */ +function contextTextOf(response: string): ExtractedContext | null { + let parsed: unknown; + try { + parsed = JSON.parse(response); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + const hso = parsed.hookSpecificOutput; + const claudeText = isRecord(hso) && typeof hso.additionalContext === "string" ? hso.additionalContext : ""; + if (claudeText) return { text: claudeText, native: false }; + const clineText = typeof parsed.contextModification === "string" ? parsed.contextModification : ""; + if (clineText) return { text: clineText, native: true }; + return null; +} + +/** + * Joins two already-rendered `contextResponse(...)`-shaped JSON strings into + * one (same fold `dispatch-aipilot.ts`'s `combineContext` already does) — + * kept local to avoid a 3rd import edge into that file. + * + * Recognizes BOTH the Claude-family `hookSpecificOutput.additionalContext` + * envelope (claude-code/codex/kimi/cursor/hermes/gemini-cli) and cline's + * native `{contextModification}` shape (see `runtime/respond.ts`'s `"cline"` + * branch). A cline-shaped part used to parse to `""` here and get silently + * dropped by the old `.filter(Boolean)` — exactly how cline's PRD slice never + * reached the agent (0-byte `SubagentStart` injection) despite + * `prdSubagentContext` building it correctly. When any merged part is + * cline-native, the output is re-shaped as `{contextModification}` so + * cline's own hook consumer (which never reads `hookSpecificOutput`) can + * read it; every other target only ever produces Claude-family parts, so + * that branch never triggers for them and their output stays + * byte-identical. + */ +export function joinContextResponses(...parts: string[]): string { + const extracted = parts.map(contextTextOf).filter((e): e is ExtractedContext => e !== null); + if (extracted.length === 0) return ""; + const joined = extracted.map((e) => e.text).join("\n\n"); + if (extracted.some((e) => e.native)) return JSON.stringify({ contextModification: joined }); + return JSON.stringify({ + hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: joined }, + }); +} diff --git a/src/policy/prd/prd-crosscheck.ts b/src/policy/prd/prd-crosscheck.ts new file mode 100644 index 0000000..a8354d2 --- /dev/null +++ b/src/policy/prd/prd-crosscheck.ts @@ -0,0 +1,76 @@ +/** + * Cross-checks a task-PRD's declared status against the agents' own reports + * (and, for the router, against the task-PRD's own internal consistency). + * Pure — no fs. + */ +import { isCompacted, subTasksOf } from "./prd-schema"; +import type { + PrdAgentReportFile, PrdCrossCheckViolation, PrdRouter, PrdTaskFile, +} from "./interfaces/types"; + +/** Mismatches between a task-PRD's `validated` sub-tasks and the agents' own reports. */ +export function crossCheckTask( + taskFile: PrdTaskFile, + reports: Record, + task: string, +): PrdCrossCheckViolation[] { + const violations: PrdCrossCheckViolation[] = []; + for (const [agent, entry] of Object.entries(taskFile)) { + if (isCompacted(entry)) continue; + const report = reports[agent]; + for (const [sub, subTask] of Object.entries(subTasksOf(entry))) { + if (subTask.status !== "validated") continue; + if (report?.[task]?.[sub]?.status !== "done") { + violations.push({ task, agent, sub, reason: "validated without a matching done report" }); + } + } + } + return violations; +} + +function taskFullyValidated(taskFile: PrdTaskFile): boolean { + return Object.values(taskFile).every((entry) => { + if (isCompacted(entry)) return true; + return Object.values(subTasksOf(entry)).every((sub) => sub.status === "validated"); + }); +} + +/** + * Router-level consistency check: a router entry marked `validated` whose + * task-PRD is missing/unparseable, or not actually fully validated, is a + * violation. Non-`validated` router entries are never checked. + */ +export function crossCheckRouter( + router: PrdRouter, + taskFiles: Record, +): PrdCrossCheckViolation[] { + const violations: PrdCrossCheckViolation[] = []; + for (const [task, entry] of Object.entries(router)) { + if (entry.status !== "validated") continue; + const taskFile = taskFiles[task]; + if (!taskFile) { + violations.push({ task, agent: "*", sub: "*", reason: "router marked validated but task-PRD is missing or unparseable" }); + } else if (!taskFullyValidated(taskFile)) { + violations.push({ task, agent: "*", sub: "*", reason: "router marked validated but task-PRD is not fully validated" }); + } + } + return violations; +} + +/** Sub-tasks assigned to `agent` in `taskFile` for `task` not `done` in its own report. */ +export function incompleteSubTasks( + taskFile: PrdTaskFile, + agent: string, + task: string, + report: PrdAgentReportFile | null, +): string[] { + const entry = taskFile[agent]; + if (!entry || isCompacted(entry)) return []; + const doneSubs = report?.[task] ?? {}; + return Object.keys(subTasksOf(entry)).filter((sub) => doneSubs[sub]?.status !== "done"); +} + +/** True when the router has any `validated`-vs-task-PRD mismatch. */ +export function hasAnyViolations(router: PrdRouter, taskFiles: Record): boolean { + return crossCheckRouter(router, taskFiles).length > 0; +} diff --git a/src/policy/prd/prd-enabled.ts b/src/policy/prd/prd-enabled.ts new file mode 100644 index 0000000..7d3b00b --- /dev/null +++ b/src/policy/prd/prd-enabled.ts @@ -0,0 +1,39 @@ +/** + * The single activation check for the whole PRD module. Everything + * downstream short-circuits on `false` here — this is the only place the + * module's "totally inert when off" contract lives. The other file in this + * module that touches disk (besides `prd-io.ts`). + */ +import { existsSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { harnessHomeSegment } from "../apex-target"; +import { prdRouterPath } from "./prd-paths"; + +/** True only when `FUSE_PRD` is the exact string `"1"` — no truthy-string leniency. */ +export function isPrdFlagSet(env: NodeJS.ProcessEnv = process.env): boolean { + return env.FUSE_PRD === "1"; +} + +/** `CLAUDE_PROJECT_DIR` then `CURSOR_PROJECT_DIR` (first absolute value wins), else `cwd`. */ +export function prdProjectRoot(cwd: string, env: NodeJS.ProcessEnv = process.env): string { + for (const candidate of [env.CLAUDE_PROJECT_DIR, env.CURSOR_PROJECT_DIR]) { + if (candidate !== undefined && isAbsolute(candidate)) return candidate; + } + return cwd; +} + +/** True when the router file exists on disk. */ +export function routerExistsSync(root: string, homeSeg: string): boolean { + return existsSync(prdRouterPath(root, homeSeg)); +} + +/** + * Activation = `FUSE_PRD=1` AND the router is present. Short-circuits on the + * env check first, so a disabled module never touches disk. + */ +export function isPrdEnabled(cwd: string, id: string, env: NodeJS.ProcessEnv = process.env): boolean { + if (!isPrdFlagSet(env)) return false; + const root = prdProjectRoot(cwd, env); + const homeSeg = harnessHomeSegment(id); + return routerExistsSync(root, homeSeg); +} diff --git a/src/policy/prd/prd-io.ts b/src/policy/prd/prd-io.ts new file mode 100644 index 0000000..9685fdf --- /dev/null +++ b/src/policy/prd/prd-io.ts @@ -0,0 +1,89 @@ +/** + * Thin fail-closed wrappers over `util/json-io.ts` + the `prd-schema.ts` + * parsers. Both async (CLI, PostToolUse) and sync (SubagentStart/Stop, which + * must not float async work) variants. The only two files in this module + * that touch disk (besides `prd-enabled.ts`). + */ +import { existsSync, readFileSync } from "node:fs"; +import { readJsonFile, writeJsonFile } from "../../util/json-io"; +import { prdAgentReportPath, prdRouterPath, prdTaskPath } from "./prd-paths"; +import { parseAgentReportFile, parseRouter, parseTaskFile } from "./prd-schema"; +import type { PrdAgentReportFile, PrdRouter, PrdTaskFile } from "./interfaces/types"; + +function readJsonSync(path: string): unknown { + try { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +/** Reads the router file; `null` when missing or malformed. */ +export async function readRouter(root: string, homeSeg: string): Promise { + const raw = await readJsonFile(prdRouterPath(root, homeSeg)); + return raw === null ? null : parseRouter(raw); +} + +/** Sync twin of {@link readRouter}, for SubagentStart/Stop call sites. */ +export function readRouterSync(root: string, homeSeg: string): PrdRouter | null { + const raw = readJsonSync(prdRouterPath(root, homeSeg)); + return raw === null ? null : parseRouter(raw); +} + +/** Reads a task-PRD file; `null` when missing or malformed. */ +export async function readTaskFile(root: string, homeSeg: string, relPrd: string): Promise { + const raw = await readJsonFile(prdTaskPath(root, homeSeg, relPrd)); + return raw === null ? null : parseTaskFile(raw); +} + +/** Sync twin of {@link readTaskFile}. */ +export function readTaskFileSync(root: string, homeSeg: string, relPrd: string): PrdTaskFile | null { + const raw = readJsonSync(prdTaskPath(root, homeSeg, relPrd)); + return raw === null ? null : parseTaskFile(raw); +} + +/** Reads every task-PRD file named in `router`, keyed by task name (`null` per unreadable/malformed entry). */ +export async function readAllTaskFiles( + root: string, + homeSeg: string, + router: PrdRouter, +): Promise> { + const out: Record = {}; + for (const [task, entry] of Object.entries(router)) { + out[task] = await readTaskFile(root, homeSeg, entry.prd); + } + return out; +} + +/** Reads an agent's own report file; `null` when missing or malformed. */ +export async function readAgentReport(root: string, homeSeg: string, agent: string): Promise { + const raw = await readJsonFile(prdAgentReportPath(root, homeSeg, agent)); + return raw === null ? null : parseAgentReportFile(raw); +} + +/** Sync twin of {@link readAgentReport}. */ +export function readAgentReportSync(root: string, homeSeg: string, agent: string): PrdAgentReportFile | null { + const raw = readJsonSync(prdAgentReportPath(root, homeSeg, agent)); + return raw === null ? null : parseAgentReportFile(raw); +} + +/** Atomically writes the router file. */ +export async function writeRouter(root: string, homeSeg: string, router: PrdRouter): Promise { + await writeJsonFile(prdRouterPath(root, homeSeg), router); +} + +/** Atomically writes a task-PRD file. */ +export async function writeTaskFile(root: string, homeSeg: string, relPrd: string, file: PrdTaskFile): Promise { + await writeJsonFile(prdTaskPath(root, homeSeg, relPrd), file); +} + +/** Atomically writes an agent's own report file. */ +export async function writeAgentReport( + root: string, + homeSeg: string, + agent: string, + file: PrdAgentReportFile, +): Promise { + await writeJsonFile(prdAgentReportPath(root, homeSeg, agent), file); +} diff --git a/src/policy/prd/prd-ownership.ts b/src/policy/prd/prd-ownership.ts new file mode 100644 index 0000000..a1ca738 --- /dev/null +++ b/src/policy/prd/prd-ownership.ts @@ -0,0 +1,99 @@ +/** + * The PreToolUse ownership decision. Everything it needs is passed in — no + * I/O. Pure functions over already-loaded router/task-PRD/bindings data. + */ +import type { PrdIdentity, PrdOwnershipVerdict, PrdPathKind, PrdTaskFile } from "./interfaces/types"; + +/** True when `name === agentType`, or `"-"` with `n >= 2`. */ +export function matchesAgentName(name: string, agentType: string): boolean { + if (name === agentType) return true; + if (!name.startsWith(agentType)) return false; + const suffix = name.slice(agentType.length); + if (!suffix.startsWith("-")) return false; + const numPart = suffix.slice(1); + return /^\d+$/.test(numPart) && Number(numPart) >= 2; +} + +/** Agent names in `taskFile` whose name matches `agentType` (base or `-n` suffixed). */ +export function candidateAgentNames(agentType: string, taskFile: PrdTaskFile): string[] { + return Object.keys(taskFile).filter((name) => matchesAgentName(name, agentType)); +} + +/** + * agentId -> bound name resolution. `bindings` is the journal's `prdOwners` + * map (agentId -> name), read-only here. Returns the candidate already + * bound to this agentId, or the SOLE still-unbound candidate (free to bind + * now), or `null` (ambiguous: >1 free candidate, or 0 candidates at all). + */ +export function resolveOwnerBinding( + candidates: string[], + agentId: string, + bindings: Record, +): { name: string; alreadyBound: boolean } | null { + const boundToMe = bindings[agentId]; + if (boundToMe !== undefined && candidates.includes(boundToMe)) { + return { name: boundToMe, alreadyBound: true }; + } + const takenByOthers = new Set( + Object.entries(bindings).filter(([id]) => id !== agentId).map(([, name]) => name), + ); + const free = candidates.filter((c) => !takenByOthers.has(c)); + if (free.length !== 1) return null; + const [name] = free; + return name === undefined ? null : { name, alreadyBound: false }; +} + +const COORDINATOR_ONLY_LABEL: Record<"router" | "task" | "docs", string> = { + router: "router", + task: "task PRD", + docs: "docs", +}; + +function evaluateCoordinatorOnly(kind: "router" | "task" | "docs", identity: PrdIdentity): PrdOwnershipVerdict { + if (identity.lead === true) return { allow: true }; + if (identity.lead === "unknown") return { allow: "advisory" }; + return { allow: false, reason: `${COORDINATOR_ONLY_LABEL[kind]} is coordinator-only` }; +} + +function evaluateAgentReport( + agent: string, + identity: PrdIdentity, + taskFile: PrdTaskFile | null, + bindings: Record, +): PrdOwnershipVerdict { + if (identity.lead === true) return { allow: false, reason: "agent report is not the coordinator's to write" }; + if (identity.lead === "unknown") return { allow: "advisory" }; + if (identity.agentType === undefined || identity.agentId === undefined) { + return { allow: false, reason: "unidentifiable agent_type — cannot verify ownership" }; + } + const candidates = candidateAgentNames(identity.agentType, taskFile ?? {}); + const resolved = resolveOwnerBinding(candidates, identity.agentId, bindings); + if (!resolved || resolved.name !== agent) { + return { allow: false, reason: "name doesn't match your agent_type, or already bound to another agent" }; + } + return resolved.alreadyBound + ? { allow: true } + : { allow: true, bind: { agentId: identity.agentId, name: resolved.name } }; +} + +/** + * Top-level PreToolUse verdict for ONE file path already known to be in + * scope (`classifyPrdPath` returned non-`"other"`/non-`null` upstream). + */ +export function evaluateWriteOwnership(input: { + kind: PrdPathKind; + identity: PrdIdentity; + taskFile: PrdTaskFile | null; + bindings: Record; +}): PrdOwnershipVerdict { + const { kind, identity, taskFile, bindings } = input; + + if (kind.kind === "router" || kind.kind === "task" || kind.kind === "docs") { + return evaluateCoordinatorOnly(kind.kind, identity); + } + if (kind.kind === "agentReport") { + return evaluateAgentReport(kind.agent, identity, taskFile, bindings); + } + if (identity.lead === "unknown") return { allow: "advisory" }; + return { allow: false, reason: "not a recognized PRD file" }; +} diff --git a/src/policy/prd/prd-paths.ts b/src/policy/prd/prd-paths.ts new file mode 100644 index 0000000..abddb4d --- /dev/null +++ b/src/policy/prd/prd-paths.ts @@ -0,0 +1,88 @@ +/** + * Pure path builders and classifiers for the PRD file tree + * (`//apex/prd*`). No fs access — string/path compare only. + */ +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { PrdPathKind, PrdRouter } from "./interfaces/types"; + +const AGENT_REPORT_SUFFIX = "-prd.json"; +const DOCS_SUFFIX = ".md"; + +function apexDir(root: string, homeSeg: string): string { + return join(root, homeSeg, "apex"); +} + +/** Absolute path to the router (`//apex/prd.json`). */ +export function prdRouterPath(root: string, homeSeg: string): string { + return join(apexDir(root, homeSeg), "prd.json"); +} + +/** Absolute path to the PRD directory (`//apex/prd`). */ +export function prdDir(root: string, homeSeg: string): string { + return join(apexDir(root, homeSeg), "prd"); +} + +/** Absolute path to a task-PRD file, given the router entry's `prd` field. */ +export function prdTaskPath(root: string, homeSeg: string, relPrd: string): string { + return join(apexDir(root, homeSeg), relPrd); +} + +/** Absolute path to an agent's own report file (`prd/agents/-prd.json`). */ +export function prdAgentReportPath(root: string, homeSeg: string, agent: string): string { + return join(prdDir(root, homeSeg), "agents", `${agent}${AGENT_REPORT_SUFFIX}`); +} + +/** Absolute path to a task's free-form doc (`prd/docs/.md`). */ +export function prdDocsPath(root: string, homeSeg: string, task: string): string { + return join(prdDir(root, homeSeg), "docs", `${task}${DOCS_SUFFIX}`); +} + +function resolveAgainstRoot(filePath: string, root: string): string { + return isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath); +} + +/** + * True when `filePath` (absolute or root-relative) resolves under + * `//apex/prd/`, or is exactly the router itself. Normalizes + * via `path.resolve`/`relative`; a `..` that escapes the PRD dir is rejected. + */ +export function isPrdScopedPath(filePath: string, root: string, homeSeg: string): boolean { + const abs = resolveAgainstRoot(filePath, root); + if (abs === resolve(prdRouterPath(root, homeSeg))) return true; + const rel = relative(resolve(prdDir(root, homeSeg)), abs); + return rel !== "" && rel !== "." && !rel.startsWith("..") && !isAbsolute(rel); +} + +/** + * Classifies an in-scope PRD path into one of the 4 file kinds (router, + * task, agentReport, docs), or `"other"` when it is textually under + * `apex/prd/` but matches none of the router-declared task files. `null` + * when the path is not in scope at all (see {@link isPrdScopedPath}). + */ +export function classifyPrdPath( + filePath: string, + root: string, + homeSeg: string, + router: PrdRouter | null, +): PrdPathKind | null { + if (!isPrdScopedPath(filePath, root, homeSeg)) return null; + const abs = resolveAgainstRoot(filePath, root); + if (abs === resolve(prdRouterPath(root, homeSeg))) return { kind: "router" }; + + const dir = resolve(prdDir(root, homeSeg)); + const parts = relative(dir, abs).split(sep); + const [first, second] = parts; + + if (parts.length === 2 && first === "agents" && second?.endsWith(AGENT_REPORT_SUFFIX)) { + return { kind: "agentReport", agent: second.slice(0, -AGENT_REPORT_SUFFIX.length) }; + } + if (parts.length === 2 && first === "docs" && second?.endsWith(DOCS_SUFFIX)) { + return { kind: "docs", task: second.slice(0, -DOCS_SUFFIX.length) }; + } + if (router) { + for (const [task, entry] of Object.entries(router)) { + if (resolve(prdTaskPath(root, homeSeg, entry.prd)) === abs) return { kind: "task", task }; + } + } + return { kind: "other" }; +} diff --git a/src/policy/prd/prd-schema.ts b/src/policy/prd/prd-schema.ts new file mode 100644 index 0000000..1d0bb2c --- /dev/null +++ b/src/policy/prd/prd-schema.ts @@ -0,0 +1,151 @@ +/** + * Fail-closed parsers (malformed input -> `null`, never throw) and small + * immutable builders for the PRD JSON contract. Pure — no fs. + */ +import type { + PrdAgentEntryCompacted, PrdAgentReportFile, PrdRouter, PrdRouterEntry, PrdRouterStatus, + PrdSubTask, PrdTaskAgentEntry, PrdTaskFile, +} from "./interfaces/types"; + +const ROUTER_STATUSES: readonly PrdRouterStatus[] = ["assigned", "in-progress", "validated"]; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every((x) => typeof x === "string"); +} + +function parseRouterEntry(v: unknown): PrdRouterEntry | null { + if (!isRecord(v)) return null; + if (typeof v.prd !== "string") return null; + if (typeof v.status !== "string" || !ROUTER_STATUSES.includes(v.status as PrdRouterStatus)) return null; + const validatedAt = v["validated-at"]; + if (validatedAt !== undefined && typeof validatedAt !== "string") return null; + return validatedAt === undefined + ? { prd: v.prd, status: v.status as PrdRouterStatus } + : { prd: v.prd, status: v.status as PrdRouterStatus, "validated-at": validatedAt }; +} + +/** Parses the router file (`prd.json`); `null` on any malformed shape. */ +export function parseRouter(raw: unknown): PrdRouter | null { + if (!isRecord(raw)) return null; + const out: PrdRouter = {}; + for (const [task, v] of Object.entries(raw)) { + const entry = parseRouterEntry(v); + if (!entry) return null; + out[task] = entry; + } + return out; +} + +function parseSubTask(v: unknown): PrdSubTask | null { + if (!isRecord(v) || typeof v.status !== "string") return null; + if (v.status !== "assigned" && v.status !== "validated") return null; + const validatedAt = v["validated-at"]; + if (validatedAt !== undefined && typeof validatedAt !== "string") return null; + return validatedAt === undefined ? { status: v.status } : { status: v.status, "validated-at": validatedAt }; +} + +function parseAgentEntry(v: unknown): PrdTaskAgentEntry | null { + if (!isRecord(v) || !isStringArray(v.files)) return null; + if (v.status === "validated") { + if (typeof v["validated-at"] !== "string") return null; + return { status: "validated", files: v.files, "validated-at": v["validated-at"] }; + } + if (!isRecord(v["sub-tasks"])) return null; + const subTasks: Record = {}; + for (const [sub, sv] of Object.entries(v["sub-tasks"])) { + const parsed = parseSubTask(sv); + if (!parsed) return null; + subTasks[sub] = parsed; + } + return { files: v.files, "sub-tasks": subTasks }; +} + +/** Parses a task-PRD file; `null` on any malformed shape. */ +export function parseTaskFile(raw: unknown): PrdTaskFile | null { + if (!isRecord(raw)) return null; + const out: PrdTaskFile = {}; + for (const [agent, v] of Object.entries(raw)) { + const entry = parseAgentEntry(v); + if (!entry) return null; + out[agent] = entry; + } + return out; +} + +/** Parses an agent's own report file; `null` on any malformed shape. */ +export function parseAgentReportFile(raw: unknown): PrdAgentReportFile | null { + if (!isRecord(raw)) return null; + const out: PrdAgentReportFile = {}; + for (const [task, subs] of Object.entries(raw)) { + if (!isRecord(subs)) return null; + const parsedSubs: Record = {}; + for (const [sub, v] of Object.entries(subs)) { + if (!isRecord(v) || v.status !== "done" || !isStringArray(v.modified) || !isStringArray(v.unchanged)) { + return null; + } + const doneAt = v["done-at"]; + if (doneAt !== undefined && typeof doneAt !== "string") return null; + parsedSubs[sub] = doneAt === undefined + ? { status: "done", modified: v.modified, unchanged: v.unchanged } + : { status: "done", modified: v.modified, unchanged: v.unchanged, "done-at": doneAt }; + } + out[task] = parsedSubs; + } + return out; +} + +/** True when `e` is the post-compaction shape. */ +export function isCompacted(e: PrdTaskAgentEntry): e is PrdAgentEntryCompacted { + return "status" in e && e.status === "validated" && !("sub-tasks" in e); +} + +/** Sub-tasks of an agent entry; `{}` when already compacted. */ +export function subTasksOf(e: PrdTaskAgentEntry): Record { + return isCompacted(e) ? {} : e["sub-tasks"]; +} + +/** Files owned by an agent entry, expanded or compacted. */ +export function filesOf(e: PrdTaskAgentEntry): string[] { + return e.files; +} + +/** `["needs >= 2 agents", ...]` — empty when the task PRD satisfies the contract. */ +export function validateTaskFileInvariant(taskFile: PrdTaskFile): string[] { + const errors: string[] = []; + if (Object.keys(taskFile).length < 2) errors.push("needs >= 2 agents"); + return errors; +} + +/** Returns a new router with `task`'s status (and optional `validated-at`) updated. */ +export function withRouterStatus( + router: PrdRouter, + task: string, + status: PrdRouterStatus, + at?: string, +): PrdRouter { + const existing = router[task]; + if (!existing) return router; + const entry: PrdRouterEntry = at === undefined + ? { prd: existing.prd, status } + : { prd: existing.prd, status, "validated-at": at }; + return { ...router, [task]: entry }; +} + +/** Returns a new task-PRD file with one agent's sub-task flipped to `validated`. */ +export function withSubTaskValidated( + taskFile: PrdTaskFile, + agent: string, + sub: string, + at: string, +): PrdTaskFile { + const entry = taskFile[agent]; + if (!entry || isCompacted(entry)) return taskFile; + const subTask = entry["sub-tasks"][sub]; + if (!subTask) return taskFile; + const nextSubTasks = { ...entry["sub-tasks"], [sub]: { status: "validated" as const, "validated-at": at } }; + return { ...taskFile, [agent]: { files: entry.files, "sub-tasks": nextSubTasks } }; +} diff --git a/src/runtime/handle-post.ts b/src/runtime/handle-post.ts index 8c752f1..eb35c5c 100644 --- a/src/runtime/handle-post.ts +++ b/src/runtime/handle-post.ts @@ -14,6 +14,7 @@ import { recordCodexPostFailure } from "../tracking/codex-post-failure"; import { defaultStateDir } from "./paths"; import { fanOutFiles, firstFileMatch } from "./post-fanout"; import { postOutcome } from "./post-outcome"; +import { prdPostCheck } from "./prd"; import type { PreContext } from "./handle-pre"; import type { HandleOutcome } from "./handle"; @@ -75,6 +76,7 @@ export async function handlePost(ctx: PreContext): Promise { // tracking, validation, post-edit context, and notices. const files = fanOutFiles(event); for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd); + await prdPostCheck(id, event, opts.cwd, file, opts.now); // PRD cross-check — advisory only, never returns stdout const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null; if (seoDeny && !cursorAfterFileEdit) return { stdout: seoDeny, exit: 0 }; if (opts.scope === "solid") { diff --git a/src/runtime/handle-pre.ts b/src/runtime/handle-pre.ts index 0a8e366..b06c9b0 100644 --- a/src/runtime/handle-pre.ts +++ b/src/runtime/handle-pre.ts @@ -16,6 +16,7 @@ import { applyPatchGate } from "./apply-patch-gate"; import { isBypassPermissions } from "../adapters/codex/permission-mode"; import { evaluate } from "../policy/evaluate"; import { confirmGate } from "./confirm/confirm-gate"; +import { prdPreGate } from "./prd"; import type { HandleOptions, HandleOutcome } from "./handle"; /** Context the PreToolUse pipeline needs (resolved once by {@link handleHook}). */ @@ -85,6 +86,16 @@ export async function handlePre(ctx: PreContext): Promise { if (taskCtx) return { stdout: taskCtx, exit: 0 }; } + // PRD (task/agent ownership coordination): inert unless FUSE_PRD=1 AND a + // router is present — see runtime/prd/prd-pre-gate.ts. Runs BEFORE the + // apply_patch gate below: `applyPatchGate` runs `evaluate()` per file, whose + // `runGuards()` already includes `protectedPathGuard` — which unconditionally + // blocks every `.claude/apex/` path, PRD included. A PRD write this gate just + // authorized must short-circuit past both `applyPatchGate` and the gate chain + // further down, never reach either. + const prdOutcome = await prdPreGate(id, payload, event, opts.cwd, file, opts.now); + if (prdOutcome) return prdOutcome; + // Codex `apply_patch`: normalize.ts fanned the freeform patch into per-file // changes. OR their static verdicts — one violating hunk blocks the whole // envelope. `event.files` is undefined for every other tool/harness. diff --git a/src/runtime/lifecycle/dispatch.ts b/src/runtime/lifecycle/dispatch.ts index aae2872..5da93dc 100644 --- a/src/runtime/lifecycle/dispatch.ts +++ b/src/runtime/lifecycle/dispatch.ts @@ -17,6 +17,10 @@ import { cartoSessionStart } from "./cartographer/session-start"; import { dispatchLessons } from "./lessons/dispatch"; import { withSnapshot } from "./snapshot"; import { stopCore } from "./stop-core"; +import { prdSubagentContext, prdSubagentStopGate, prdStopGate } from "../prd"; +import { joinContextResponses } from "../../policy/prd"; +import { sanitizeSessionId } from "../home-state"; +import { defaultStateDir, trackFile } from "../paths"; /** Which plugin's hooks.json invoked the harness (selects SessionStart behavior). */ export type PluginScope = "core" | "solid" | "rules" | "carto" | "security" | "changelog" | "aipilot" | "lessons" | "seo" | "memory" | "tailwindcss"; @@ -70,11 +74,14 @@ export function dispatchLifecycle(input: LifecycleInput): string | null { if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code"); if (input.scope === "aipilot") return ""; if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code"); - return subagentCacheContext(input.payload.session_id); - case "Stop": + return joinContextResponses(subagentCacheContext(input.payload.session_id), prdSubagentContext(input.payload, input.cwd, input.id ?? "claude-code")); + case "Stop": { if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code"); - return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null; - case "SubagentStop": + if (input.scope !== "core") return null; + const prdBlock = prdStopGate(input.payload, input.cwd, input.id ?? "claude-code", trackFile(sanitizeSessionId(input.payload.session_id) ?? "unknown", defaultStateDir(input.cwd)), input.now); + return prdBlock || stopCore(input.payload, input.cwd, input.now); + } + case "SubagentStop": { // G0 counterpart of the SubagentStart branch above — the SAME // monotone max-write, never a decrement (see confirm-subagent.ts). markSubagentSeen(input.payload.session_id, input.now); @@ -84,7 +91,13 @@ export function dispatchLifecycle(input: LifecycleInput): string | null { // explore evidence even when sidechain PostToolUse hooks never fired // (#43612/#27655/#34692). SubagentStop is main-session-dispatched (reliable). harvestSubagentTrack(input.payload, input.cwd, input.now); - return trackAgentMemory(input.payload, undefined, input.now); + // null = PRD had nothing to say (off, unnamed agent, or genuinely done) + // -> normal trackAgentMemory handling; a non-null string (block, or "" + // on an already-blocked replay) must be returned AS-IS, never layered + // under a stale "agent completed" message. + const prdBlock = prdSubagentStopGate(input.payload, input.cwd, input.id ?? "claude-code", trackFile(sanitizeSessionId(input.payload.session_id) ?? "unknown", defaultStateDir(input.cwd)), input.now); + return prdBlock !== null ? prdBlock : trackAgentMemory(input.payload, undefined, input.now); + } case "TeammateIdle": return teammateIdleContext(input.payload, input.cwd, undefined, input.now); case "PostToolUseFailure": diff --git a/src/runtime/normalize.ts b/src/runtime/normalize.ts index cdb942b..415426e 100644 --- a/src/runtime/normalize.ts +++ b/src/runtime/normalize.ts @@ -33,6 +33,8 @@ export interface NormalizedEvent { permissionMode?: string; /** Codex logical tool-use identity, shared by sibling hook callbacks. */ toolUseId?: string; + /** Sub-agent identifier, if the tool-use came from one (Claude/Codex only — Cursor/Kimi never send this field, confirmed live). */ + agentId?: string; /** Harness-reported working directory used to scope Codex authorization. */ cwd?: string; /** Validated Cursor multi-root workspace paths in wire order. */ @@ -71,6 +73,7 @@ export function normalizeEvent(id: string, payload: Record): No ...extractCursorEvent(payload), sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "", agentType: str(payload.agent_type), + agentId: str(payload.agent_id), permissionMode: str(payload.permission_mode), }; } @@ -83,6 +86,7 @@ export function normalizeEvent(id: string, payload: Record): No input, sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "", agentType: str(payload.agent_type) ?? str(input.subagent_type), + agentId: str(payload.agent_id), permissionMode: str(payload.permission_mode), toolUseId: str(payload.tool_use_id), cwd: str(payload.cwd), diff --git a/src/runtime/prd/index.ts b/src/runtime/prd/index.ts new file mode 100644 index 0000000..877e84b --- /dev/null +++ b/src/runtime/prd/index.ts @@ -0,0 +1,8 @@ +/** Barrel for `src/runtime/prd/**` — the runtime wiring surface (design doc §2). */ +export { prdPreGate } from "./prd-pre-gate"; +export { prdPostCheck } from "./prd-post-check"; +export { prdSubagentContext } from "./prd-subagent-context"; +export { prdSubagentStopGate } from "./prd-subagent-stop"; +export { prdStopGate } from "./prd-stop-gate"; +export { resolvePrdIdentity } from "./prd-identity"; +export { prdCandidateFiles } from "./prd-candidate-files"; diff --git a/src/runtime/prd/prd-bash-targets.ts b/src/runtime/prd/prd-bash-targets.ts new file mode 100644 index 0000000..ab41ce3 --- /dev/null +++ b/src/runtime/prd/prd-bash-targets.ts @@ -0,0 +1,199 @@ +/** + * @module prd-bash-targets + * Local, PRD-module-only extension of Bash write-target detection BEYOND + * shell output redirects (`>`/`>>`, already covered by `shellOutputRedirects` + * in `bash-write-redirects.ts`). Static, best-effort heuristic scanner (never + * executes the command) for the write-target argument of common non-redirect + * write verbs: `cp`, `mv`, `install` (last positional = destination), `tee` + * (every positional = a destination), `sed -i` / `perl -i` (in-place edit — + * last positional = the edited file), and `dd of=` (the `of=` operand). + * + * Deliberately NOT merged into `bash-write-redirects.ts` or + * `protectedPathGuard` (`protected-path.ts`) — both are shared, harness-wide + * guards; changing them risks a global regression. This is a narrow, + * PRD-scoped sibling used ONLY by `prdPreGate`'s Bash branch, feeding its + * output through the SAME `isPrdScopedPath` check the redirect targets + * already go through — so a target outside the PRD tree is never affected. + * + * Known limitation: `--` (POSIX end-of-options, GNU coreutils/glibc + * `getopt_long`) is honored — a positional arg starting with `-` AFTER `--` + * is never mistaken for an option — but `getopt_long`'s default PERMUTE mode + * lets a value-taking option (`install -m 644 file dest`, `cp -t DIR a b`) + * land anywhere in argv; this scanner doesn't track which options consume a + * following value, so a bare value like `644` could in principle be + * mistaken for the destination if it were the LAST token. Best-effort only + * (mirrors the existing `protectedPathGuard#extractWriteTargets` + * precedent): can under/mis-detect an unusual invocation, never cause a + * false deny on an out-of-scope path (the caller still scope-checks every + * returned target). Not applied to `dd`, whose `if=`/`of=` operands are + * never getopt-parsed (coreutils docs: "the only options are + * --help/--version"). + * + * 2nd limitation (unfixed, flagged for owner): bundled `-i` (`perl -pi -e`, + * `sed -ni`) is missed — only a standalone `-i`/`-i` token is seen. + */ + +/** Chain separators this splits a command on (quote/paren-depth aware — never splits inside quotes or `$(...)`/backticks). */ +const CHAIN_CHARS = new Set([";", "&", "|", "\n"]); + +/** Balanced-paren scan for a `$(...)` body, quote-aware. @returns Index of the matching `)`, or `input.length` if unterminated. */ +function closingParen(input: string, start: number): number { + let depth = 1; + let quote: "'" | '"' | null = null; + for (let i = start; i < input.length; i++) { + const ch = input[i]; + if (ch === "\\" && quote !== "'") { i++; continue; } + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === "(") depth++; + else if (ch === ")" && --depth === 0) return i; + } + return input.length; +} + +/** + * Splits `input[start, end)` into "simple command" strings on unquoted chain + * separators, recursing into `$(...)`/backtick substitutions so a write verb + * hidden inside a subshell is still found. Bounded: every recursive call + * strictly narrows `[start, end)`. + * @param out - Accumulator for discovered simple-command strings. + */ +function scanCommands(input: string, start: number, end: number, out: string[]): void { + let cmdStart = start; + let quote: "'" | '"' | null = null; + const flush = (to: number): void => { + const seg = input.slice(cmdStart, to).trim(); + if (seg) out.push(seg); + }; + for (let i = start; i < end; i++) { + const ch = input[i]; + if (ch === "\\" && quote !== "'") { i++; continue; } + // Single quotes are fully opaque (bash: NO substitution inside `'...'`). + // Double quotes still perform command substitution (bash: `"$(...)"` and + // `` "`...`" `` both still execute) — only word-splitting/globbing is + // suppressed — so `$(`/backtick must still be checked while `quote==='"'`. + if (quote === "'") { if (ch === "'") quote = null; continue; } + if (ch === "$" && input[i + 1] === "(") { + const close = closingParen(input, i + 2); + scanCommands(input, i + 2, close, out); + i = close; + continue; + } + if (ch === "`") { + const close = input.indexOf("`", i + 1); + const safeClose = close === -1 || close > end ? end : close; + scanCommands(input, i + 1, safeClose, out); + i = safeClose; + continue; + } + if (quote === '"') { if (ch === '"') quote = null; continue; } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (CHAIN_CHARS.has(ch ?? "")) { + flush(i); + cmdStart = i + 1; + continue; + } + } + flush(end); +} + +/** + * Tokenizes one simple-command string into unquoted words (quotes unwrapped, + * backslash-escapes resolved outside single quotes) — same unquoting + * contract as `bash-write-redirects.ts`'s own `readTarget`. + */ +function tokenize(segment: string): string[] { + const tokens: string[] = []; + let cur = ""; + let quote: "'" | '"' | null = null; + let started = false; + for (let i = 0; i < segment.length; i++) { + const ch = segment[i] ?? ""; + if (ch === "\\" && quote !== "'") { + if (i + 1 < segment.length) { cur += segment[++i] ?? ""; started = true; } + continue; + } + if (quote) { + if (ch === quote) quote = null; else cur += ch; + continue; + } + if (ch === "'" || ch === '"') { quote = ch; started = true; continue; } + if (/\s/.test(ch)) { + if (started) { tokens.push(cur); cur = ""; started = false; } + continue; + } + cur += ch; + started = true; + } + if (started) tokens.push(cur); + return tokens; +} + +/** True for a short/long option token (never a bare `-`, the stdin/stdout idiom). */ +function isOption(t: string): boolean { + return t.startsWith("-") && t !== "-"; +} + +/** + * Positional (non-option) arguments, honoring `--` as POSIX end-of-options + * (see module doc): every token after a literal `--` is positional even if + * it starts with `-`. + */ +function positionalArgs(args: string[]): string[] { + const out: string[] = []; + let optionsEnded = false; + for (const t of args) { + if (!optionsEnded && t === "--") { optionsEnded = true; continue; } + if (!optionsEnded && isOption(t)) continue; + out.push(t); + } + return out; +} + +/** + * Write-target argument(s) of one verb invocation, given its ARGUMENTS (verb + * token excluded). Empty when the verb isn't covered, or has no destination. + * @param verb - The verb basename (leading directory component stripped). + */ +function verbWriteTargets(verb: string, args: string[]): string[] { + if (verb === "dd") { + const of = args.find((t) => t.startsWith("of=")); + return of ? [of.slice(3)] : []; + } + if (verb === "tee") return positionalArgs(args); + if (verb === "cp" || verb === "mv" || verb === "install") { + const p = positionalArgs(args); + return p.length > 0 ? [p[p.length - 1] ?? ""] : []; + } + if (verb === "sed" || verb === "perl") { + const inPlace = args.some((t) => t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place=")); + if (!inPlace) return []; + const p = positionalArgs(args); + return p.length > 0 ? [p[p.length - 1] ?? ""] : []; + } + return []; +} + +/** + * Extracts candidate write-target paths from a Bash command string, for the + * non-redirect write verbs this module covers (`cp`, `mv`, `install`, `tee`, + * `sed -i`/`perl -i`, `dd of=`). Read-only usages (a verb that never writes, + * or a write verb whose only in-scope path is a SOURCE argument) never + * contribute a target — callers still resolve/scope-check every returned + * path themselves (this function does no fs access, no scoping decision). + * @param command - The raw Bash command string. + * @returns Candidate write-target paths (possibly empty/duplicated). + */ +export function extraBashWriteTargets(command: string): string[] { + const segments: string[] = []; + scanCommands(command, 0, command.length, segments); + const out: string[] = []; + for (const seg of segments) { + const tokens = tokenize(seg); + const verbToken = tokens[0]; + if (!verbToken) continue; + const verb = verbToken.split("/").pop() ?? verbToken; + out.push(...verbWriteTargets(verb, tokens.slice(1))); + } + return out; +} diff --git a/src/runtime/prd/prd-candidate-files.ts b/src/runtime/prd/prd-candidate-files.ts new file mode 100644 index 0000000..aed7f1b --- /dev/null +++ b/src/runtime/prd/prd-candidate-files.ts @@ -0,0 +1,23 @@ +/** + * @module prd-candidate-files + * Shared candidate-path extraction for a hook event, used by both + * {@link prdPreGate} and {@link prdPostCheck} — the fanned-out + * `apply_patch`/`afterFileEdit` set when present, else the single + * `event.filePath`. + */ +import type { NormalizedEvent } from "../normalize"; + +/** + * @param event - The normalized hook event. + * @param restrictToWrite - When true (PreToolUse — the default), a bare + * `event.filePath` only counts for `Write`/`Edit` (never a `Read`, which must + * stay unaffected by the ownership gate). PostToolUse call sites pass + * `false`: they only ever see a real Write/Edit-shaped event already. + * @returns The candidate file paths (possibly empty). + */ +export function prdCandidateFiles(event: NormalizedEvent, restrictToWrite = true): string[] { + if (event.files?.length) return event.files.map((f) => f.filePath); + if (!event.filePath) return []; + if (restrictToWrite && event.tool !== "Write" && event.tool !== "Edit") return []; + return [event.filePath]; +} diff --git a/src/runtime/prd/prd-canon.ts b/src/runtime/prd/prd-canon.ts new file mode 100644 index 0000000..c21e94c --- /dev/null +++ b/src/runtime/prd/prd-canon.ts @@ -0,0 +1,50 @@ +/** + * @module prd-canon + * Best-effort symlink canonicalization for the paths `prdPreGate`/ + * `prdPostCheck` feed into lot A's pure `isPrdScopedPath`/`classifyPrdPath` + * (documented "string/path compare only — no fs", by design). Those + * comparisons need `root` and each candidate file path on the SAME + * representation — but on macOS, `process.cwd()` inside a spawned process + * resolves `/var` to its canonical `/private/var` while a caller-constructed + * absolute path (e.g. from `os.tmpdir()`) may still carry the unresolved + * `/var` alias, so two paths naming the identical file compare unequal. + * Same `realpathSync.native` + raw-path-on-failure idiom already used by + * `adapters/cursor/context.ts`'s `cursorAbsolutePath`. + */ +import { realpathSync } from "node:fs"; +import { isAbsolute, join, sep } from "node:path"; + +/** Canonicalizes an existing directory (e.g. the project root); the raw value on failure. */ +export function canonicalRoot(root: string): string { + try { + return realpathSync.native(root); + } catch { + return root; + } +} + +/** + * Canonicalizes an absolute file path that may not exist yet — including + * under a brand-new subdirectory (e.g. a task's FIRST `prd/docs/.md`, + * whose `docs/` dir doesn't exist yet either): walks up from the full path, + * dropping one segment at a time, until an ancestor resolves, then rejoins + * the non-existent tail onto that canonical prefix. `root` itself always + * resolves (guaranteed present by `isPrdEnabled`), so this always terminates + * on a real answer for any path actually under the project root. A relative + * path is returned as-is (no `root` to anchor a symlink comparison against). + */ +export function canonicalFilePath(path: string): string { + if (!isAbsolute(path)) return path; + const parts = path.split(sep); + for (let end = parts.length; end > 0; end--) { + const prefix = parts.slice(0, end).join(sep) || sep; + try { + const real = realpathSync.native(prefix); + const tail = parts.slice(end); + return tail.length > 0 ? join(real, ...tail) : real; + } catch { + continue; // try a shorter, more likely to exist, ancestor + } + } + return path; +} diff --git a/src/runtime/prd/prd-identity.ts b/src/runtime/prd/prd-identity.ts new file mode 100644 index 0000000..a117fb0 --- /dev/null +++ b/src/runtime/prd/prd-identity.ts @@ -0,0 +1,32 @@ +/** + * @module prd-identity + * Resolve a {@link PrdIdentity} from a normalized hook event. Structural, not + * field-presence-based: Cursor and Kimi NEVER carry `agent_id`/`agent_type` on + * a sub-agent's own tool-use (live-captured — see the PRD design doc Risks + * §1/§2), so on those two targets the absence of `agentId` is NOT evidence of + * being the lead — it is a harness ceiling, and the verdict must stay + * `"unknown"` (advisory, never a hard block) regardless of what the payload + * carries. Every other target (claude-code, codex, and any future harness + * sharing their schema) DOES carry `agent_id` on a real sub-agent call, so + * there `agentId`'s absence structurally proves "this is the lead". + */ +import type { NormalizedEvent } from "../normalize"; +import type { PrdIdentity } from "../../policy/prd/interfaces/types"; + +/** Harness targets whose payloads never carry per-event agent identity (live-confirmed for Cursor; documented schema for Kimi — design doc Risks §1/§2). */ +const NO_IDENTITY_SUPPORT: ReadonlySet = new Set(["cursor", "kimi"]); + +/** + * Resolve the PRD identity of the current tool-use. + * @param id - Harness target id (e.g. "claude-code", "codex", "cursor", "kimi"). + * @param event - The normalized hook event (reads `agentId`/`agentType`). + * @returns The resolved {@link PrdIdentity} — `lead: "unknown"` on Cursor/Kimi, + * `lead: true` when no `agentId` is present elsewhere, `lead: false` (with + * whatever `agentType` came along, possibly undefined — see design Risks §4) + * otherwise. + */ +export function resolvePrdIdentity(id: string, event: NormalizedEvent): PrdIdentity { + if (NO_IDENTITY_SUPPORT.has(id)) return { lead: "unknown", agentId: event.agentId, agentType: event.agentType }; + if (!event.agentId) return { lead: true }; + return { lead: false, agentId: event.agentId, agentType: event.agentType }; +} diff --git a/src/runtime/prd/prd-post-check.ts b/src/runtime/prd/prd-post-check.ts new file mode 100644 index 0000000..938dec6 --- /dev/null +++ b/src/runtime/prd/prd-post-check.ts @@ -0,0 +1,73 @@ +/** + * @module prd-post-check + * PostToolUse cross-check (design doc §2.2). Side-effect only — never + * produces stdout. Fires only when the touched path just resolved to a + * task-PRD or the router (the only two places a `validated` status is + * asserted), reloads every named agent's own report, and journals any NEW + * cross-check violation (deduped by `{task,agent,sub,reason}` so a repeated + * PostToolUse on the same file never re-appends the same finding). + */ +import { hashText } from "../../util/json-io"; +import { harnessHomeSegment } from "../../policy/apex-target"; +import { + classifyPrdPath, crossCheckTask, isPrdEnabled, prdProjectRoot, + readAgentReport, readRouter, readTaskFile, + type PrdAgentReportFile, type PrdCrossCheckViolation, +} from "../../policy/prd"; +import { withTrack } from "../../tracking/store"; +import { recordPrdViolation } from "../../tracking/session-state"; +import { prdCandidateFiles } from "./prd-candidate-files"; +import { canonicalFilePath, canonicalRoot } from "./prd-canon"; +import type { NormalizedEvent } from "../normalize"; + +/** Stable dedup key for one violation, shared across replays of the same PostToolUse. */ +function violationKey(v: PrdCrossCheckViolation): string { + return hashText(JSON.stringify([v.task, v.agent, v.sub, v.reason])); +} + +/** + * Run the PRD PostToolUse cross-check. Side-effect only — journals new + * violations, never returns anything to render. + */ +export async function prdPostCheck(id: string, event: NormalizedEvent, cwd: string, trackFilePath: string, now: number): Promise { + if (!isPrdEnabled(cwd, id)) return; + const root = canonicalRoot(prdProjectRoot(cwd)); // see prd-canon.ts — same representation as the candidate file paths below + const homeSeg = harnessHomeSegment(id); + const router = await readRouter(root, homeSeg); + if (!router) return; + + const files = prdCandidateFiles(event, false).map(canonicalFilePath); + const touchedTasks = new Set(); + for (const f of files) { + const kind = classifyPrdPath(f, root, homeSeg, router); + if (kind?.kind === "task") touchedTasks.add(kind.task); + else if (kind?.kind === "router") for (const task of Object.keys(router)) touchedTasks.add(task); + } + if (touchedTasks.size === 0) return; + + const violations: PrdCrossCheckViolation[] = []; + for (const task of touchedTasks) { + const entry = router[task]; + if (!entry) continue; + const taskFile = await readTaskFile(root, homeSeg, entry.prd); + if (!taskFile) continue; + const reports: Record = {}; + for (const agent of Object.keys(taskFile)) { + const report = await readAgentReport(root, homeSeg, agent); + if (report) reports[agent] = report; + } + violations.push(...crossCheckTask(taskFile, reports, task)); + } + if (violations.length === 0) return; + + await withTrack(trackFilePath, (t) => { + const known = new Set((t.prdViolations ?? []).map(violationKey)); + let next = t; + for (const v of violations) { + if (known.has(violationKey(v))) continue; + known.add(violationKey(v)); + next = recordPrdViolation(next, { ...v, ts: now }); + } + return next; + }); +} diff --git a/src/runtime/prd/prd-pre-gate.ts b/src/runtime/prd/prd-pre-gate.ts new file mode 100644 index 0000000..00721ff --- /dev/null +++ b/src/runtime/prd/prd-pre-gate.ts @@ -0,0 +1,172 @@ +/** + * @module prd-pre-gate + * PreToolUse orchestrator for the PRD module (design doc §2.0/§2.1). Inert + * unless {@link isPrdEnabled}: one env read + one `statSync`, zero further + * disk access. When active, denies a Bash write under `apex/prd/` for + * EVERYONE (no identity check needed — see below) — covering both shell + * output redirects (`>`/`>>`, `shellOutputRedirects`) AND the non-redirect + * write verbs `cp`, `mv`, `tee`, `sed -i`/`perl -i`, `install`, `dd of=` + * (`extraBashWriteTargets`, `prd-bash-targets.ts`) — then resolves ownership + * for Write/Edit/apply_patch/afterFileEdit targets and either short-circuits + * past `gate()`/`protectedPathGuard` with an allow, or returns the standard + * deny — never both, never a silent pass-through of a denied write. + */ +import { dirname } from "node:path"; +import type { Prompt } from "../../prompt/types"; +import { respond } from "../respond"; +import { withDenyNotice } from "../deny-notice"; +import { allowOutcome } from "../pre-allow"; +import { projectLayout } from "../../config/layout"; +import { harnessHomeSegment } from "../../policy/apex-target"; +import { shellOutputRedirects } from "../../policy/guards/bash-write-redirects"; +import { extraBashWriteTargets } from "./prd-bash-targets"; +import { + candidateAgentNames, classifyPrdPath, evaluateWriteOwnership, isPrdEnabled, isPrdScopedPath, + prdProjectRoot, readAllTaskFiles, readRouter, + type PrdOwnershipVerdict, type PrdPathKind, type PrdTaskFile, +} from "../../policy/prd"; +import { loadTrack, withTrack } from "../../tracking/store"; +import { recordPrdOwner } from "../../tracking/session-state"; +import { resolvePrdIdentity } from "./prd-identity"; +import { prdCandidateFiles } from "./prd-candidate-files"; +import { canonicalFilePath, canonicalRoot } from "./prd-canon"; +import type { NormalizedEvent } from "../normalize"; +import type { HandleOutcome } from "../handle"; + +const ACTIONS = ["Write only the files/report this agent owns per its PRD slice", "Run `harness prd status` to see the current assignment"]; + +function denyOutcome(id: string, event: NormalizedEvent, trackFilePath: string, now: number, reason: string): HandleOutcome { + const prompt: Prompt = { kind: "block", ruleId: "prd-ownership", title: "PRD ownership", reason, actions: ACTIONS }; + return { stdout: withDenyNotice(id, respond(id, prompt, event.eventName ?? "PreToolUse"), prompt, event.sessionId, dirname(trackFilePath), now), exit: 0 }; +} + +/** + * Narrows `taskFile` to the names `evaluateWriteOwnership` needs to correctly + * resolve a SPECIFIC requested agent-report name, WITHOUT changing lot A's + * pure `resolveOwnerBinding`/`evaluateWriteOwnership` (unmodified) — a lot B + * integration adapter around a real call-site gap, not a policy change. + * + * Two rules, matching the design's "first-come, exact-name-first" contract: + * - `target === agentType` (claiming YOUR OWN exact name): always legitimate + * on a cold start — narrow to just `target`, so a same-type SIBLING's mere + * existence never manufactures a false "ambiguous" (`resolveOwnerBinding` + * otherwise sees 2 equally-free candidates and gives up for BOTH). + * - `target !== agentType` (claiming a "-n" SIBLING slot): only legitimate + * once the EXACT-match name is already claimed by someone else — keep + * BOTH names, so `resolveOwnerBinding` sees 2 free candidates (denied, + * ambiguous) until the exact slot is taken, then exactly 1 (allowed). + * In every case, this agentId's own EXISTING binding (if any) and every + * name already claimed by anyone are also kept, so a mismatch against an + * established binding is still caught (never silently dropped from view). + */ +function narrowForTarget(taskFile: PrdTaskFile, agentType: string, target: string, agentId: string, bindings: Record): PrdTaskFile { + const allCandidates = candidateAgentNames(agentType, taskFile); + const keep = new Set([target]); + if (target !== agentType) keep.add(agentType); + const myBinding = bindings[agentId]; + if (myBinding !== undefined) keep.add(myBinding); + for (const name of Object.values(bindings)) keep.add(name); + const out: PrdTaskFile = {}; + for (const name of allCandidates) { + const entry = taskFile[name]; + if (keep.has(name) && entry) out[name] = entry; + } + return out; +} + +/** The task-PRD (if any) that declares `agent` as a literal key, across every task in the router. */ +function governingTaskFile(agent: string, taskFiles: Record): PrdTaskFile | null { + for (const tf of Object.values(taskFiles)) { + if (tf && Object.hasOwn(tf, agent)) return tf; + } + return null; +} + +/** + * Run the PreToolUse PRD gate. + * @returns The native outcome (allow bypassing `gate()`, or deny), or `null` + * to fall through to the ordinary pipeline (module off, or nothing in scope). + */ +export async function prdPreGate( + id: string, + payload: Record, + event: NormalizedEvent, + cwd: string, + trackFilePath: string, + now: number, +): Promise { + if (!isPrdEnabled(cwd, id)) return null; + // Canonicalized once: `isPrdScopedPath`/`classifyPrdPath` are pure + // string/path compares (lot A, by design, no fs) — on macOS, `cwd` (from a + // spawned process) resolves `/var` to `/private/var` while a caller's own + // absolute path may still carry the unresolved alias, so root and target + // must share ONE representation before any comparison (prd-canon.ts). + const root = canonicalRoot(prdProjectRoot(cwd)); + const homeSeg = harnessHomeSegment(id); + + if (event.tool === "Bash" && event.command) { + const redirectHit = shellOutputRedirects(event.command).some((r) => isPrdScopedPath(canonicalFilePath(r.target), root, homeSeg)); + // Beyond `>`/`>>` redirects: cp/mv/tee/sed -i/perl -i/install/dd of= also + // write a file without any redirect operator (prd-bash-targets.ts, local + // to this module — never merged into the shared bash-write-redirects.ts/ + // protectedPathGuard, see that file's own header for why). + const verbHit = extraBashWriteTargets(event.command).some((t) => isPrdScopedPath(canonicalFilePath(t), root, homeSeg)); + if (redirectHit || verbHit) return denyOutcome(id, event, trackFilePath, now, "PRD files must be written via Write/Edit/apply_patch, never Bash."); + } + + const files = prdCandidateFiles(event).map(canonicalFilePath); + if (files.length === 0) return null; + + const router = await readRouter(root, homeSeg); + if (router === null) { + if (!files.some((f) => isPrdScopedPath(f, root, homeSeg))) return null; + return denyOutcome(id, event, trackFilePath, now, "PRD router is malformed JSON — fix apex/prd.json or unset FUSE_PRD."); + } + + const inScope = files + .map((f) => ({ file: f, kind: classifyPrdPath(f, root, homeSeg, router) })) + .filter((c): c is { file: string; kind: PrdPathKind } => c.kind !== null && c.kind.kind !== "other"); + if (inScope.length === 0) return null; + + const identity = resolvePrdIdentity(id, event); + const track = await loadTrack(trackFilePath); + // Mutated in-loop (never the loaded track): a 2nd agent-report file in the + // SAME envelope must see the 1st file's pending bind, or a single agentId + // could claim two different agent names in one apply_patch batch (each + // looks free in isolation otherwise). + let bindings = { ...(track.prdOwners ?? {}) }; + const taskFiles = inScope.some((c) => c.kind.kind === "agentReport") ? await readAllTaskFiles(root, homeSeg, router) : {}; + + const binds: { agentId: string; name: string }[] = []; + for (const { file, kind } of inScope) { + let taskFile: PrdTaskFile | null = null; + if (kind.kind === "agentReport") { + const governing = governingTaskFile(kind.agent, taskFiles); + taskFile = governing && identity.agentType !== undefined && identity.agentId !== undefined + ? narrowForTarget(governing, identity.agentType, kind.agent, identity.agentId, bindings) + : governing; + } + const verdict: PrdOwnershipVerdict = evaluateWriteOwnership({ kind, identity, taskFile, bindings }); + if (verdict.allow === false) return denyOutcome(id, event, trackFilePath, now, `${file}: ${verdict.reason}`); + if (verdict.allow === true && verdict.bind) { + binds.push(verdict.bind); + bindings = { ...bindings, [verdict.bind.agentId]: verdict.bind.name }; + } + } + + // A mixed envelope (some candidate files never classified in-scope by + // classifyPrdPath, e.g. a genuinely unrelated file bundled into the SAME + // apply_patch) must never ride THIS allow past applyPatchGate/gate() for + // those other files — only a PURE-PRD envelope (every candidate file + // in-scope) may short-circuit. A denial above (ownership violation on an + // in-scope file) still fires regardless of mixing; only the ALLOW path is + // restricted here, so it never becomes a silent bypass for an unclassified + // file the PRD gate was never meant to authorize. + if (inScope.length !== files.length) return null; + + if (binds.length > 0) { + await withTrack(trackFilePath, (t) => binds.reduce((acc, b) => recordPrdOwner(acc, b.agentId, b.name), t)); + } + const mcpDir = projectLayout(cwd).cacheDir; + return allowOutcome(id, event, payload, mcpDir, cwd, { trackFile: trackFilePath, now }); +} diff --git a/src/runtime/prd/prd-stop-gate.ts b/src/runtime/prd/prd-stop-gate.ts new file mode 100644 index 0000000..fa88ddc --- /dev/null +++ b/src/runtime/prd/prd-stop-gate.ts @@ -0,0 +1,101 @@ +/** + * @module prd-stop-gate + * Block-once gate for the LEAD's `Stop` event when the router carries an + * unresolved PRD cross-check violation (design doc §2.5). SYNC, same + * one-shot idiom as {@link prdSubagentStopGate}, keyed `sessionId:Stop` + * instead of per-agent. Covers Codex too — its `Stop` already routes to + * `stopCore` under `scope === "core"`. + */ +import { dirname } from "node:path"; +import { harnessHomeSegment } from "../../policy/apex-target"; +import { + hasAnyViolations, isPrdEnabled, prdProjectRoot, readRouterSync, readTaskFileSync, + type PrdRouter, type PrdTaskFile, +} from "../../policy/prd"; +import { journalLogPath, readTrackSync } from "../../tracking/track-compact"; +import { trackJournalEnabled } from "../../tracking/store"; +import { appendEvent } from "../../tracking/track-journal"; +import { diffTrackEvents } from "../../tracking/track-diff"; +import { prdAlreadyBlocked, recordPrdStopBlocked, type SessionTrack } from "../../tracking/session-state"; +import { signTrack, writeLastNonce } from "../../tracking/integrity"; +import { withTrackLockSync } from "../../tracking/track-lock-sync"; +import { atomicWrite } from "../../util/json-io"; +import { blockResponse } from "../../adapters/claude"; +import { respond } from "../respond"; + +const REASON = "PRD cross-check found unresolved violation(s): a task/sub-task is marked validated without a matching done report. Run `harness prd status` for details."; + +/** Sync twin of `readAllTaskFiles` (lot A only exposes the async variant). */ +function readAllTaskFilesSync(root: string, homeSeg: string, router: PrdRouter): Record { + const out: Record = {}; + for (const [task, entry] of Object.entries(router)) out[task] = readTaskFileSync(root, homeSeg, entry.prd); + return out; +} + +/** + * Persist the one-shot block marker on the SAME side `trackJournalEnabled()` + * reads from. Twin of `prd-subagent-stop.ts`'s own `persistStopBlocked` + * (intentionally duplicated, not cross-imported — same SOLID file-size + * discipline as this module's other duplicated helpers, e.g. + * `readAllTaskFilesSync`). Bug this fixes: the old code always appended to + * the journal regardless of mode, but `readTrackSync(file, + * trackJournalEnabled())` never folds the log when `FUSE_TRACK_JOURNAL=0` + * (track-compact.ts) — so a legacy replay never saw the marker and + * re-blocked the lead's Stop on every call. + */ +function persistStopBlocked(trackFilePath: string, track: SessionTrack, key: string, now: number): void { + if (trackJournalEnabled()) { + const next = recordPrdStopBlocked(track, key, now); + for (const ev of diffTrackEvents(track, next, now)) appendEvent(journalLogPath(trackFilePath), ev.field, ev.op, ev.value, ev.ts); + return; + } + withTrackLockSync(dirname(trackFilePath), () => { + const fresh = readTrackSync(trackFilePath, false); // reload under lock: avoid clobbering a concurrent legacy RMW + const envelope = signTrack(recordPrdStopBlocked(fresh, key, now)); + atomicWrite(trackFilePath, JSON.stringify(envelope, null, 2)); + writeLastNonce(envelope.nonce); + }); +} + +/** + * Run the PRD lead-Stop block-once gate. + * @param payload - The raw Stop hook payload. + * @param cwd - Project root. + * @param id - Harness target id. + * @param trackFilePath - The session track file path. + * @param now - Event clock. + * @returns The native block stdout (once per session), else `""`. + */ +export function prdStopGate(payload: Record, cwd: string, id: string, trackFilePath: string, now: number): string { + if (!isPrdEnabled(cwd, id)) return ""; + if (id === "cursor") return ""; // `stop` is Cursor's terminal observation event, not gated today (design doc §5 table). + const root = prdProjectRoot(cwd); + const homeSeg = harnessHomeSegment(id); + const router = readRouterSync(root, homeSeg); + if (!router) return ""; + const track = readTrackSync(trackFilePath, trackJournalEnabled()); + // Two independent violation sources, per the design doc: (a) unresolved + // `prdViolations` already journaled by the PostToolUse cross-check + // (§2.2 — `hasAnyViolations` has no track access, so THIS is that half), + // (b) `hasAnyViolations` itself — a router entry marked `validated` whose + // task-PRD isn't actually fully validated. + const trackHasViolations = (track.prdViolations?.length ?? 0) > 0; + if (!trackHasViolations) { + const taskFiles = readAllTaskFilesSync(root, homeSeg, router); + if (!hasAnyViolations(router, taskFiles)) return ""; + } + + const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown"; + const key = `${sessionId}:Stop`; + if (prdAlreadyBlocked(track, key)) return ""; + persistStopBlocked(trackFilePath, track, key, now); + + // gemini-cli/cline: route through respond() for their own native block + // envelope (see prd-subagent-stop.ts's identical routing comment for the + // full rationale — claude-code/codex/hermes stay on blockResponse + // unchanged, respond() cannot reproduce their byte-identical shape here). + if (id === "kimi" || id === "gemini-cli" || id === "cline") { + return respond(id, { kind: "block", title: "PRD cross-check", reason: REASON }, "Stop"); + } + return blockResponse(REASON); +} diff --git a/src/runtime/prd/prd-subagent-context.ts b/src/runtime/prd/prd-subagent-context.ts new file mode 100644 index 0000000..d308cbb --- /dev/null +++ b/src/runtime/prd/prd-subagent-context.ts @@ -0,0 +1,69 @@ +/** + * @module prd-subagent-context + * SubagentStart injection (design doc §2.3) — the sub-agent's own PRD slice, + * never the whole task. Kept SYNCHRONOUS on purpose: `dispatchLifecycle` is + * called synchronously (no `await`) from `lifecycle-bridge.ts`/`handle.ts`, + * and making that chain async for one new injector would touch 3 unrelated + * files purely for signature plumbing — `prd-io.ts`'s sync twins + * (`readRouterSync`/`readTaskFileSync`) exist precisely so this call site + * needs zero signature changes anywhere else. + */ +import { contextResponse } from "../../adapters/claude"; +import { harnessHomeSegment } from "../../policy/apex-target"; +import { + agentSlices, isPrdEnabled, prdProjectRoot, readRouterSync, readTaskFileSync, renderAgentSliceMarkdown, + type PrdSubagentSlice, +} from "../../policy/prd"; +import { respond } from "../respond"; + +/** Compliance reminder appended after the rendered slice(s) — the 3 rules a sub-agent must keep in mind. */ +const RULES = [ + "1. Write ONLY the files listed in your slice above.", + "2. Report completion to YOUR OWN agent-report file above — never another agent's.", + "3. Do not mark a sub-task done until the work is actually finished; the coordinator validates from your report.", +].join("\n"); + +/** `agent_type`, same fallback `dispatch-aipilot.ts`'s `agentTypeOf` uses (duplicated here per SOLID file-size discipline, not cross-imported). */ +function agentTypeOf(payload: Record): string { + return String(payload.agent_type ?? payload.subagent_type ?? ""); +} + +/** + * Build the SubagentStart PRD context injection for this agent, or `""` when + * the module is off, the caller is unnamed, or nothing in the router + * concerns it. + * @param payload - The raw SubagentStart hook payload. + * @param cwd - Project root. + * @param id - Harness target id. + * @returns The native `additionalContext` hook stdout, or `""`. + */ +export function prdSubagentContext(payload: Record, cwd: string, id: string): string { + if (!isPrdEnabled(cwd, id)) return ""; + const agentType = agentTypeOf(payload); + if (!agentType) return ""; + const root = prdProjectRoot(cwd); + const homeSeg = harnessHomeSegment(id); + const router = readRouterSync(root, homeSeg); + if (!router) return ""; + + const slices: PrdSubagentSlice[] = []; + for (const [task, entry] of Object.entries(router)) { + const taskFile = readTaskFileSync(root, homeSeg, entry.prd); + if (!taskFile) continue; + slices.push(...agentSlices(taskFile, agentType, task)); + } + if (slices.length === 0) return ""; + + const text = `${renderAgentSliceMarkdown(slices)}\n\n### Rules\n${RULES}`; + // gemini-cli/cline don't understand Claude's hookSpecificOutput.additionalContext + // envelope: gemini-cli's native shape has no hookEventName field and cline's + // is `{contextModification}` entirely — route them through respond()'s + // already-correct "inform" branches. claude-code/codex/kimi/cursor/hermes + // stay on contextResponse unchanged: respond()'s "inform" kind re-wraps the + // text through formatPrompt (adds a "[NOTE] title" line), which would NOT + // reproduce today's byte-identical raw-markdown output. + if (id === "gemini-cli" || id === "cline") { + return respond(id, { kind: "inform", title: "PRD assignment", reason: text }, "SubagentStart"); + } + return contextResponse("SubagentStart", text); +} diff --git a/src/runtime/prd/prd-subagent-stop.ts b/src/runtime/prd/prd-subagent-stop.ts new file mode 100644 index 0000000..e174717 --- /dev/null +++ b/src/runtime/prd/prd-subagent-stop.ts @@ -0,0 +1,152 @@ +/** + * @module prd-subagent-stop + * Block-once gate for a finishing sub-agent with incomplete PRD sub-tasks + * (design doc §2.4). SYNC on purpose — the SubagentStop dispatch runs + * synchronously in a short-lived hook process; an async write could be + * dropped before exit (same reasoning as `track-lock-sync.ts`). + */ +import { dirname } from "node:path"; +import { harnessHomeSegment } from "../../policy/apex-target"; +import { + candidateAgentNames, incompleteSubTasks, isPrdEnabled, prdProjectRoot, + readAgentReportSync, readRouterSync, readTaskFileSync, type PrdTaskFile, +} from "../../policy/prd"; +import { journalLogPath, readTrackSync } from "../../tracking/track-compact"; +import { trackJournalEnabled } from "../../tracking/store"; +import { appendEvent } from "../../tracking/track-journal"; +import { diffTrackEvents } from "../../tracking/track-diff"; +import { prdAlreadyBlocked, recordPrdStopBlocked, type SessionTrack } from "../../tracking/session-state"; +import { signTrack, writeLastNonce } from "../../tracking/integrity"; +import { withTrackLockSync } from "../../tracking/track-lock-sync"; +import { atomicWrite } from "../../util/json-io"; +import { blockResponse } from "../../adapters/claude"; +import { respond } from "../respond"; + +/** `agent_type`, same fallback duplicated across the PRD runtime modules (SOLID file-size discipline). */ +function agentTypeOf(payload: Record): string { + return String(payload.agent_type ?? payload.subagent_type ?? ""); +} + +/** Renders the block reason for one finishing agent's incomplete sub-tasks. */ +function reasonFor(agent: string, task: string, subs: string[]): string { + return `PRD sub-task(s) not done for ${agent} on task "${task}": ${subs.join(", ")}. Finish the work (or ask the coordinator to reassign) before stopping.`; +} + +/** + * One-shot key for this finishing instance. Keyed by `agentId` when known — + * the STABLE identity of "this specific sub-agent call", regardless of which + * same-`agentType` candidate name it happens to match — so an unbound agent + * (no prior Write) whose incompleteness is first found under one sibling + * name doesn't get blocked a SECOND time on replay under the other sibling + * name (both unbound candidates are checked every call; only the key must + * stay identical). Falls back to per-name keying when `agentId` is absent + * (Kimi-shaped payloads structurally never carry it). + */ +function stopKey(sessionId: string, agentId: string | undefined, agent: string): string { + return agentId !== undefined ? `${sessionId}:SubagentStop:agent:${agentId}` : `${sessionId}:SubagentStop:${agent}`; +} + +/** + * Narrows the agentType-matching candidates to the ONE this specific + * `agent_id` is already bound to (per `prdOwners`), when known — otherwise + * every same-`agentType` sibling would be checked (and could block THIS + * instance for a SIBLING's incomplete work, e.g. `backend-expert` vs + * `backend-expert-2` sharing the same reported `agent_type`). Unbound (the + * agent hasn't written anything yet) falls back to checking every candidate, + * the only defensible default with no binding to disambiguate on. + */ +function resolveCandidates(agentType: string, taskFile: PrdTaskFile, agentId: string | undefined, bindings: Record): string[] { + const all = candidateAgentNames(agentType, taskFile); + const bound = agentId !== undefined ? bindings[agentId] : undefined; + return bound !== undefined && all.includes(bound) ? [bound] : all; +} + +/** + * Persist the one-shot block marker on the SAME side `trackJournalEnabled()` + * reads from — mirrors `harvestSubagentTrack`'s branching (`evidence-harvest- + * io.ts`), the codebase's only other sync lifecycle writer with this exact + * constraint. Bug this fixes: the old code always appended to the journal + * regardless of mode, but `readTrackSync(file, trackJournalEnabled())` never + * folds the log when `FUSE_TRACK_JOURNAL=0` (track-compact.ts) — so a legacy + * replay never saw the marker and re-blocked on every call. Journal mode + * (default): unchanged, append the diff. Legacy mode: locked read-modify- + * write straight into the snapshot (`signTrack` + atomic write + nonce, same + * body as `store.ts#saveTrack`'s legacy branch), so the very next + * `readTrackSync(file, false)` sees it. + */ +function persistStopBlocked(trackFilePath: string, track: SessionTrack, key: string, now: number): void { + if (trackJournalEnabled()) { + const next = recordPrdStopBlocked(track, key, now); + for (const ev of diffTrackEvents(track, next, now)) appendEvent(journalLogPath(trackFilePath), ev.field, ev.op, ev.value, ev.ts); + return; + } + withTrackLockSync(dirname(trackFilePath), () => { + const fresh = readTrackSync(trackFilePath, false); // reload under lock: avoid clobbering a concurrent legacy RMW + const envelope = signTrack(recordPrdStopBlocked(fresh, key, now)); + atomicWrite(trackFilePath, JSON.stringify(envelope, null, 2)); + writeLastNonce(envelope.nonce); + }); +} + +/** + * Run the PRD SubagentStop block-once gate. + * @param payload - The raw SubagentStop hook payload. + * @param cwd - Project root. + * @param id - Harness target id. + * @param trackFilePath - The session track file path. + * @param now - Event clock. + * @returns `null` when PRD had nothing to say (module off, unnamed agent, or + * every assigned sub-task is genuinely done) — the caller falls through to + * its normal SubagentStop handling (e.g. `trackAgentMemory`). A non-null + * string means PRD DID find an incomplete sub-task for this agent: the + * block stdout the first time, or `""` on every later replay of the SAME + * incompleteness (already-blocked-once) — either way the caller must return + * it AS-IS and skip its normal handling (a stale "agent completed" message + * would contradict the block that was just — or already — issued). + */ +export function prdSubagentStopGate(payload: Record, cwd: string, id: string, trackFilePath: string, now: number): string | null { + if (!isPrdEnabled(cwd, id)) return null; + // Cursor's sub-agent identity is structurally unlinkable at this event + // (design doc Risks §1) and Cursor never honors a block on SubagentStop + // anyway (adapters.md) — never even build a block payload for it. + if (id === "cursor") return null; + const agentType = agentTypeOf(payload); + if (!agentType) return null; + const agentId = typeof payload.agent_id === "string" ? payload.agent_id : undefined; + const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown"; + const root = prdProjectRoot(cwd); + const homeSeg = harnessHomeSegment(id); + const router = readRouterSync(root, homeSeg); + if (!router) return null; + const track = readTrackSync(trackFilePath, trackJournalEnabled()); + const bindings = track.prdOwners ?? {}; + + for (const [task, entry] of Object.entries(router)) { + const taskFile = readTaskFileSync(root, homeSeg, entry.prd); + if (!taskFile) continue; + for (const agent of resolveCandidates(agentType, taskFile, agentId, bindings)) { + const report = readAgentReportSync(root, homeSeg, agent); + const incomplete = incompleteSubTasks(taskFile, agent, task, report); + if (incomplete.length === 0) continue; + const key = stopKey(sessionId, agentId, agent); + if (prdAlreadyBlocked(track, key)) return ""; // already blocked once — silent, but still skip trackAgentMemory + persistStopBlocked(trackFilePath, track, key, now); + const reason = reasonFor(agent, task, incomplete); + // gemini-cli/cline have their OWN native block envelopes (respond() + // already knows them: `{decision:"deny"}` / `{cancel:true}`) — the old + // hardcoded `blockResponse` sent them the Claude `{decision:"block"}` + // shape, which neither honors. claude-code/codex/hermes stay on + // `blockResponse` unchanged: `respond()`'s claude-code/codex branch + // always calls `denyResponse` (a PreToolUse-only `permissionDecision`, + // ignored on Stop-family events — see `blockResponse`'s own doc + // comment), and hermes's `toHermesResponse` re-wraps the reason through + // `formatPrompt` (adds a "[BLOCKED] title" line) — neither reproduces + // today's byte-identical `{decision:"block",reason}`. + if (id === "kimi" || id === "gemini-cli" || id === "cline") { + return respond(id, { kind: "block", title: "PRD sub-task incomplete", reason }, "SubagentStop"); + } + return blockResponse(reason); + } + } + return null; +} diff --git a/src/tracking/session-state.ts b/src/tracking/session-state.ts index b2b5d89..e979725 100644 --- a/src/tracking/session-state.ts +++ b/src/tracking/session-state.ts @@ -25,7 +25,25 @@ export interface SessionTrack { brainstormRequired?: boolean; /** Verification receipts (tsc/test) at PostToolUse; absent/empty reads as unverified in the TaskCompleted gate (backward compat, fail-closed). See {@link Receipt}. */ receipts?: Receipt[]; + /** PRD (task/agent ownership coordination) — `agent_id` bound to its resolved agent-report name. Optional: absent on any track predating the PRD module (backward compat). */ + prdOwners?: Record; + /** PRD cross-check violations detected at PostToolUse (capped, append-only). */ + prdViolations?: PrdViolationRecord[]; + /** PRD SubagentStop/Stop one-shot block markers, keyed `"sessionId:event[:agent]"` -> the epoch-ms the block fired. */ + prdStopBlocked?: Record; } + +/** One PRD cross-check violation, timestamped for the journal/dedup. */ +export interface PrdViolationRecord { + ts: number; + task: string; + agent: string; + sub: string; + reason: string; +} + +/** Cap on {@link SessionTrack.prdViolations} (mirrors the `receipts` style bound — oldest evicted first). */ +export const PRD_VIOLATIONS_CAP = 50; /** A fresh, empty track. */ export function emptyTrack(): SessionTrack { return { authorizations: {}, refsRead: [], agents: [], trivialEdits: [] }; @@ -92,3 +110,31 @@ export function trivialCount(track: SessionTrack, windowMs: number, now: number) export function recordBrainstormRequired(track: SessionTrack, required: boolean): SessionTrack { return { ...track, brainstormRequired: required }; } + +/** Bind `agentId` to its resolved PRD agent-report `name` (merge; idempotent no-op if already bound to the same name). Immutable. */ +export function recordPrdOwner(track: SessionTrack, agentId: string, name: string): SessionTrack { + if (track.prdOwners?.[agentId] === name) return track; + return { ...track, prdOwners: { ...track.prdOwners, [agentId]: name } }; +} + +/** + * Append one PRD cross-check violation. Immutable, uncapped in the mutator + * itself — same append-only shape as {@link Receipt} — so the journal diff's + * prefix comparison (`tail`, track-diff.ts) never sees an eviction it would + * mistake for a bulk rewrite. The {@link PRD_VIOLATIONS_CAP} bound is applied + * only when the journal is folded/read (track-journal.ts's `foldEvents`), + * mirroring how `trivialEdits`'s sliding window is re-derived at fold time. + */ +export function recordPrdViolation(track: SessionTrack, v: PrdViolationRecord): SessionTrack { + return { ...track, prdViolations: [...(track.prdViolations ?? []), v] }; +} + +/** Record a PRD SubagentStop/Stop one-shot block marker (merge). Immutable. */ +export function recordPrdStopBlocked(track: SessionTrack, key: string, ts: number): SessionTrack { + return { ...track, prdStopBlocked: { ...track.prdStopBlocked, [key]: ts } }; +} + +/** True when the PRD one-shot block for `key` has already fired. */ +export function prdAlreadyBlocked(track: SessionTrack, key: string): boolean { + return track.prdStopBlocked?.[key] !== undefined; +} diff --git a/src/tracking/track-diff.ts b/src/tracking/track-diff.ts index 852b4e7..20b9f00 100644 --- a/src/tracking/track-diff.ts +++ b/src/tracking/track-diff.ts @@ -46,5 +46,8 @@ export function diffTrackEvents(prev: SessionTrack, next: SessionTrack, now: num for (const ts of added(prev.trivialEdits ?? [], next.trivialEdits ?? [])) out.push({ field: "trivialEdits", op: "add", value: ts, ts }); if (next.target && JSON.stringify(next.target) !== JSON.stringify(prev.target)) out.push({ field: "target", op: "set", value: next.target, ts: Date.parse(next.target.set_at) || now }); if (next.brainstormRequired !== prev.brainstormRequired) out.push({ field: "brainstormRequired", op: "set", value: next.brainstormRequired, ts: now }); + for (const [aid, name] of Object.entries(next.prdOwners ?? {})) if (prev.prdOwners?.[aid] !== name) out.push({ field: "prdOwners", op: "merge", value: [aid, name], ts: now }); + for (const v of tail(prev.prdViolations ?? [], next.prdViolations ?? [])) out.push({ field: "prdViolations", op: "append", value: v, ts: v.ts ?? now }); + for (const [key, ts] of Object.entries(next.prdStopBlocked ?? {})) if (prev.prdStopBlocked?.[key] !== ts) out.push({ field: "prdStopBlocked", op: "merge", value: [key, ts], ts }); return out; } diff --git a/src/tracking/track-journal.ts b/src/tracking/track-journal.ts index 42f439d..c2960fc 100644 --- a/src/tracking/track-journal.ts +++ b/src/tracking/track-journal.ts @@ -14,7 +14,7 @@ import { dirname } from "node:path"; import { randomBytes } from "node:crypto"; import { computeMac, loadOrCreateKey } from "./integrity"; import { withTrackLockSyncBlocking } from "./track-lock-sync"; -import { emptyTrack, type SessionTrack } from "./session-state"; +import { emptyTrack, PRD_VIOLATIONS_CAP, type PrdViolationRecord, type SessionTrack } from "./session-state"; import type { AuthEntry } from "../freshness/doc-helpers"; import type { SessionTarget } from "../policy/apex-authorization"; import type { Receipt } from "./receipts"; @@ -78,6 +78,9 @@ export function foldEvents(events: TrackEvent[], base: SessionTrack = emptyTrack const at: Record = { ...base.refsReadAt }; const receipts: Receipt[] = [...(base.receipts ?? [])]; const trivial = new Set(base.trivialEdits ?? []); + const prdOwners: Record = { ...base.prdOwners }; + const prdViolations: PrdViolationRecord[] = [...(base.prdViolations ?? [])]; + const prdStopBlocked: Record = { ...base.prdStopBlocked }; let targetTs = base.target ? Date.parse(base.target.set_at) || 0 : 0, brainstormTs = 0; // a base flag is unstamped: any event overrides it for (const ev of [...events].sort((a, b) => a.ts - b.ts)) { // stable: log order breaks ts ties if (ev.field === "refsRead") { const p = String(ev.value); if (!t.refsRead.includes(p)) t.refsRead.push(p); } @@ -88,9 +91,15 @@ export function foldEvents(events: TrackEvent[], base: SessionTrack = emptyTrack else if (ev.field === "trivialEdits") trivial.add(Number(ev.value)); else if (ev.field === "target" && ev.ts >= targetTs) { t.target = ev.value as SessionTarget; targetTs = ev.ts; } else if (ev.field === "brainstormRequired" && ev.ts >= brainstormTs) { t.brainstormRequired = Boolean(ev.value); brainstormTs = ev.ts; } + else if (ev.field === "prdOwners") { const [aid, name] = ev.value as [string, string]; prdOwners[aid] = name; } + else if (ev.field === "prdViolations") { prdViolations.push(ev.value as PrdViolationRecord); } + else if (ev.field === "prdStopBlocked") { const [key, ts] = ev.value as [string, number]; prdStopBlocked[key] = ts; } } if (Object.keys(at).length) t.refsReadAt = at; if (receipts.length) t.receipts = receipts; + if (Object.keys(prdOwners).length) t.prdOwners = prdOwners; + if (prdViolations.length) t.prdViolations = prdViolations.slice(-PRD_VIOLATIONS_CAP); + if (Object.keys(prdStopBlocked).length) t.prdStopBlocked = prdStopBlocked; const maxT = Math.max(0, ...trivial); t.trivialEdits = [...trivial].filter((x) => x > maxT - TRIVIAL_WINDOW_MS).sort((a, b) => a - b); return t; diff --git a/test/cli/prd-fixtures.ts b/test/cli/prd-fixtures.ts new file mode 100644 index 0000000..6c9969c --- /dev/null +++ b/test/cli/prd-fixtures.ts @@ -0,0 +1,67 @@ +/** + * Local test fixtures/capture helpers for `test/cli/prd.test.ts` (Lot C). + * Deliberately independent of `test/helpers/prd-env.ts` (Lot D) to avoid a + * cross-lot coupling edge during parallel development. + */ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** A fresh `mkdtemp`-scoped root dir (caller is responsible for cleanup). */ +export function tmpRoot(): string { + return mkdtempSync(join(tmpdir(), "fh-prd-cli-")); +} + +function writeJson(path: string, data: unknown): void { + mkdirSync(join(path, "..", "."), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2)); +} + +/** `/.claude/apex/**` fixture: router + 2-agent task-PRD (+ optional 2nd report). */ +export function buildFixture(root: string, secondReport = false): void { + const apex = join(root, ".claude", "apex"); + writeJson(join(apex, "prd.json"), { "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "assigned" } }); + writeJson(join(apex, "prd", "auth-refactor-prd.json"), { + "backend-expert": { files: ["src/auth/login.ts"], "sub-tasks": { "jwt-validation": { status: "assigned" } } }, + "backend-expert-2": { files: ["src/auth/session.ts"], "sub-tasks": { "session-store": { status: "assigned" } } }, + }); + writeJson(join(apex, "prd", "agents", "backend-expert-prd.json"), { + "auth-refactor": { "jwt-validation": { status: "done", modified: ["src/auth/login.ts"], unchanged: [] } }, + }); + if (secondReport) { + writeJson(join(apex, "prd", "agents", "backend-expert-2-prd.json"), { + "auth-refactor": { "session-store": { status: "done", modified: ["src/auth/session.ts"], unchanged: [] } }, + }); + } +} + +/** Write an extra router (e.g. under `.codex`), to trigger a `--id` ambiguity. */ +export function writeExtraRouter(root: string, homeSeg: string): void { + writeJson(join(root, homeSeg, "apex", "prd.json"), { + "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "assigned" }, + }); +} + +/** Result of a captured `run*` call. */ +export interface Captured { + code: number; + stdout: string; + stderr: string; +} + +/** Runs `run`, capturing everything it writes to stdout/stderr. */ +export async function capture(run: () => Promise): Promise { + const out: string[] = []; + const err: string[] = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = ((c: unknown) => { out.push(String(c)); return true; }) as unknown as typeof process.stdout.write; + process.stderr.write = ((c: unknown) => { err.push(String(c)); return true; }) as unknown as typeof process.stderr.write; + try { + const code = await run(); + return { code, stdout: out.join(""), stderr: err.join("") }; + } finally { + process.stdout.write = origOut; + process.stderr.write = origErr; + } +} diff --git a/test/cli/prd.test.ts b/test/cli/prd.test.ts new file mode 100644 index 0000000..e2436c5 --- /dev/null +++ b/test/cli/prd.test.ts @@ -0,0 +1,168 @@ +/** + * `harness prd` CLI tests. Fixtures mirror the README's `auth-refactor` + * worked example (see `test/cli/prd-fixtures.ts`). + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runPrdStatus } from "../../src/cli/prd/status"; +import { runPrdValidate } from "../../src/cli/prd/validate"; +import { runPrdCompact } from "../../src/cli/prd/compact"; +import { buildFixture, capture, tmpRoot, writeExtraRouter } from "./prd-fixtures"; + +const roots: string[] = []; + +afterEach(() => { + while (roots.length > 0) { + const dir = roots.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function root(): string { + const dir = tmpRoot(); + roots.push(dir); + return dir; +} + +describe("harness prd status", () => { + test("no flags -> exit 0, table with router/agents/sub-task counts", async () => { + const r0 = root(); + buildFixture(r0); + const r = await capture(() => runPrdStatus(["--root", r0], r0, {})); + expect(r.code).toBe(0); + expect(r.stdout).toContain("auth-refactor"); + expect(r.stdout).toContain("assigned"); + expect(r.stdout).toContain("2"); + expect(r.stdout).toContain("1/2"); + }); + + test("--json -> parsable JSON with router/taskFiles/reports", async () => { + const r0 = root(); + buildFixture(r0); + const r = await capture(() => runPrdStatus(["--root", r0, "--json"], r0, {})); + expect(r.code).toBe(0); + const parsed = JSON.parse(r.stdout) as { router: unknown; taskFiles: unknown; reports: unknown }; + expect(parsed.router).toBeDefined(); + expect(parsed.taskFiles).toBeDefined(); + expect(parsed.reports).toBeDefined(); + }); + + test("no router at a resolved --id -> exit 1 with message", async () => { + const r0 = root(); + const r = await capture(() => runPrdStatus(["--root", r0, "--id", "claude-code"], r0, {})); + expect(r.code).toBe(1); + expect(r.stderr.length).toBeGreaterThan(0); + }); + + test("no --id and no router anywhere -> exit 2 (unresolvable)", async () => { + const r0 = root(); + const r = await capture(() => runPrdStatus(["--root", r0], r0, {})); + expect(r.code).toBe(2); + }); + + test("ambiguous --id (two segments with a router) -> exit 2", async () => { + const r0 = root(); + buildFixture(r0); + writeExtraRouter(r0, ".codex"); + const r = await capture(() => runPrdStatus(["--root", r0], r0, {})); + expect(r.code).toBe(2); + }); +}); + +describe("harness prd validate", () => { + test("no FUSE_PRD -> exit 1", async () => { + const r0 = root(); + buildFixture(r0, true); + const r = await capture(() => runPrdValidate(["auth-refactor", "--root", r0], r0, {})); + expect(r.code).toBe(1); + expect(r.stderr).toContain("FUSE_PRD=1"); + }); + + test("FUSE_PRD=1, missing report for backend-expert-2 -> exit 1, violation listed, files unchanged", async () => { + const r0 = root(); + buildFixture(r0, false); + const taskPath = join(r0, ".claude", "apex", "prd", "auth-refactor-prd.json"); + const before = readFileSync(taskPath, "utf8"); + const r = await capture(() => runPrdValidate(["auth-refactor", "--root", r0], r0, { FUSE_PRD: "1" })); + expect(r.code).toBe(1); + expect(r.stderr).toContain("violation"); + expect(r.stderr).toContain("backend-expert-2"); + expect(readFileSync(taskPath, "utf8")).toBe(before); + }); + + test("FUSE_PRD=1, both reports done -> exit 0, sub-tasks validated, router validated", async () => { + const r0 = root(); + buildFixture(r0, true); + const r = await capture(() => runPrdValidate(["auth-refactor", "--root", r0], r0, { FUSE_PRD: "1" })); + expect(r.code).toBe(0); + const taskFile = JSON.parse(readFileSync(join(r0, ".claude", "apex", "prd", "auth-refactor-prd.json"), "utf8")); + expect(taskFile["backend-expert"]["sub-tasks"]["jwt-validation"].status).toBe("validated"); + expect(taskFile["backend-expert"]["sub-tasks"]["jwt-validation"]["validated-at"]).toBeDefined(); + expect(taskFile["backend-expert-2"]["sub-tasks"]["session-store"].status).toBe("validated"); + const router = JSON.parse(readFileSync(join(r0, ".claude", "apex", "prd.json"), "utf8")); + expect(router["auth-refactor"].status).toBe("validated"); + }); + + test("lock held -> exit 1", async () => { + const r0 = root(); + buildFixture(r0, true); + mkdirSync(join(r0, ".claude", "apex", "prd", ".lock"), { recursive: true }); + const r = await capture(() => runPrdValidate(["auth-refactor", "--root", r0], r0, { FUSE_PRD: "1" })); + expect(r.code).toBe(1); + expect(r.stderr).toContain("lock"); + }, 10_000); +}); + +describe("harness prd compact", () => { + test("no FUSE_PRD -> exit 1", async () => { + const r0 = root(); + buildFixture(r0, true); + const r = await capture(() => runPrdCompact(["auth-refactor", "--root", r0], r0, {})); + expect(r.code).toBe(1); + }); + + test("FUSE_PRD=1 before validation -> exit 1 refusal", async () => { + const r0 = root(); + buildFixture(r0, true); + const r = await capture(() => runPrdCompact(["auth-refactor", "--root", r0], r0, { FUSE_PRD: "1" })); + expect(r.code).toBe(1); + }); + + test("FUSE_PRD=1 after validation -> exit 0, entries compacted, router unchanged", async () => { + const r0 = root(); + buildFixture(r0, true); + const before = await capture(() => runPrdValidate(["auth-refactor", "--root", r0], r0, { FUSE_PRD: "1" })); + expect(before.code).toBe(0); + const routerBefore = readFileSync(join(r0, ".claude", "apex", "prd.json"), "utf8"); + const r = await capture(() => runPrdCompact(["auth-refactor", "--root", r0], r0, { FUSE_PRD: "1" })); + expect(r.code).toBe(0); + const taskFile = JSON.parse(readFileSync(join(r0, ".claude", "apex", "prd", "auth-refactor-prd.json"), "utf8")); + expect(taskFile["backend-expert"].status).toBe("validated"); + expect(taskFile["backend-expert"].files).toEqual(["src/auth/login.ts"]); + expect(readFileSync(join(r0, ".claude", "apex", "prd.json"), "utf8")).toBe(routerBefore); + }); +}); + +describe("harness prd via the binary", () => { + const bin = join(import.meta.dir, "..", "..", "src", "cli", "bin.ts"); + + test("status via `bun bin.ts prd status --root ` -> prints the table", () => { + const r0 = root(); + buildFixture(r0); + const out = execFileSync("bun", [bin, "prd", "status", "--root", r0], { encoding: "utf8" }); + expect(out).toContain("auth-refactor"); + }); + + test("/.env with FUSE_PRD=1 lets compact run without the var in the process env", () => { + const r0 = root(); + buildFixture(r0, true); + writeFileSync(join(r0, ".env"), "FUSE_PRD=1\n"); + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.FUSE_PRD; + execFileSync("bun", [bin, "prd", "validate", "auth-refactor", "--root", r0], { encoding: "utf8", env }); + const out = execFileSync("bun", [bin, "prd", "compact", "auth-refactor", "--root", r0], { encoding: "utf8", env }); + expect(out).toContain("compacted"); + }); +}); diff --git a/test/fixtures/prd/README.md b/test/fixtures/prd/README.md new file mode 100644 index 0000000..b49cd98 --- /dev/null +++ b/test/fixtures/prd/README.md @@ -0,0 +1,76 @@ +# PRD fixtures + +Two kinds of fixture, matching the two needs of the PRD module's test suite +(see `prd-design.md` §4/§5 for the full design this fixture set backs). + +## 1. Synthetic contract example (`router.json`, `prd/`) + +`router.json`, `prd/auth-refactor-prd.json`, `prd/agents/backend-expert-prd.json` +are a hand-authored, minimal instance of the PRD contract (`prd-design.md` §0) +— **exactly** the worked example the module's documentation (README.md §5) will +show end-to-end: task `auth-refactor`, two agents (`backend-expert`, +`backend-expert-2`), `backend-expert` already `done` on its `jwt-validation` +sub-task. No live capture involved; these are synthetic data mirroring the +on-disk shape `/apex/{prd.json, prd/-prd.json, +prd/agents/-prd.json}` one level down (the `/apex/` prefix is +added by the consumer — `test/helpers/prd-env.ts` or a scenario's own +`setup`/materialization step — not baked into these fixture paths). + +## 2. Authentic sanitized payloads (`authentic/{claude,cursor,kimi}/`) + +Real hook stdin payloads captured live against each harness on 2026-09-02 (see +`…/codex-plugins/b561ad52-…/scratchpad/probe/out/{claude,cursor,kimi}.jsonl`, +format `clieventjson`), reduced to the events the PRD ownership/ +lifecycle gates need real field shapes for: + +| File | Event | Why it matters for PRD | +|---|---|---| +| `claude/pretooluse-lead.json` | `PreToolUse` (Agent tool) | Lead session dispatching a sub-agent — **no** `agent_id`/`agent_type` on the event. Structural proof of `lead: true` (PRD design §1.1 `PrdIdentity`). | +| `claude/pretooluse-subagent-write.json` | `PreToolUse` (Write) | The sub-agent's OWN Write — `agent_id`+`agent_type` both present. The exact shape the ownership guard (`prd-ownership.ts`) resolves identity from. | +| `claude/subagentstart.json` | `SubagentStart` | `agent_id`+`agent_type` present — feeds the SubagentStart slice injection (§2.3). | +| `claude/subagentstop.json` | `SubagentStop` | `agent_id`+`agent_type` present, plus `agent_transcript_path`/`background_tasks` — feeds the block-once gate (§2.4). | +| `claude/stop.json` | `Stop` | Lead-scoped, no agent fields — feeds the lead block-once gate (§2.5). | +| `cursor/pretooluse-subagent-write.json` | `preToolUse` (Write) | The sub-agent's OWN Write, running under a `session_id`/`conversation_id` (`22222222-…0002`) that shares **zero** field with the lead's own id (`11111111-…0001`) or with `subagentStart`'s `subagent_id`/`parent_conversation_id`. **No** `agent_id`/`agent_type` field exists at all. This is the live evidence behind `prd-design.md` §6 Risk 1 (never attempt per-write identity correlation on Cursor — advisory only). | +| `cursor/subagentstart.json` | `subagentStart` | Carries `subagent_type` and runs under the **lead's** `conversation_id` — the later re-keyed Write (above) is what becomes unlinkable, not this event itself. | +| `cursor/subagentstop.json` | `subagentStop` | Back on the lead's `conversation_id`, response ignored by Cursor per `docs/adapters.md`. | +| `cursor/stop.json` | `stop` | Cursor's terminal observation event (not gated today per `docs/adapters.md`). | +| `kimi/pretooluse-bash.json` | `PreToolUse` (Bash) | No `agent_id`/`agent_type` field on any Kimi payload observed — structurally excluded per `adapters/kimi/index.ts`'s documented field set (`hook_event_name, session_id, cwd, client_type, tool_name, tool_input, tool_call_id`). | +| `kimi/stop.json` | `Stop` | Kimi's documented **blocking** event (unlike `SubagentStop`, which is observation-only per `docs/adapters.md`) — feeds `prd-design.md` §6 Risk 9. | + +Each file is `{ "provenance": {...}, "stdin": {...} }` — same two-key shape as +`test/fixtures/cursor/README.md`'s own authentic captures, `stdin` being the +exact (post-sanitization) bytes a hook would receive. + +### Sanitization (v1) + +Applied uniformly, mirroring `test/fixtures/cursor/README.md`'s own method: + +- Real absolute project/workspace paths → the sim harness's own placeholder + tokens, `$TMP` (project cwd) — substituted at scenario-run time by + `test/sim/load.ts`'s `substitute()` when a fixture is embedded in a scenario + `event`; left as the literal string `$TMP` otherwise. +- Real absolute *host* paths unrelated to the project cwd (Claude's own + `~/.claude/projects/...` transcript store, Cursor's own + `~/.cursor/projects/...` transcript store) → `/Users/user/...` — never the + real home directory. +- `user_email` — **removed** entirely (not replaced) on every Cursor payload. +- Real session/conversation/generation/tool-call/agent UUIDs → deterministic + fake ids, internally consistent within one harness's fixture set (the same + real id always maps to the same fake id across that harness's files — e.g. + Cursor's lead `conversation_id` is the same fake value in + `subagentstart.json`, `subagentstop.json`, and `stop.json`; the sub-agent's + own re-keyed `session_id`/`conversation_id` in + `pretooluse-subagent-write.json` is a DIFFERENT fake value, preserving the + live "no shared field" finding — see the table above). +- Free-text prompts / task descriptions / assistant messages → + `` placeholders preserving the original approximate + length. +- No real project name, username, or email appears anywhere in this + directory (verified by grepping this directory for the real username, the + real company domain, and any `mailto`-style local-part, plus every + `/Users/` occurrence other than the placeholder home `/Users/user` — zero + hits besides this sentence's own description of the check). + +Everything else (field names, types, presence/absence, key order, numeric +values, harness-specific quirks like Cursor's embedded-newline tool-call ids) +is **unchanged** from the real capture. diff --git a/test/fixtures/prd/authentic/claude/pretooluse-lead.json b/test/fixtures/prd/authentic/claude/pretooluse-lead.json new file mode 100644 index 0000000..e747da1 --- /dev/null +++ b/test/fixtures/prd/authentic/claude/pretooluse-lead.json @@ -0,0 +1,25 @@ +{ + "provenance": { + "source": "live capture — claude-code PreToolUse, lead session dispatching a sub-agent via the Agent tool (probe/out/claude.jsonl line 1)", + "cli_version": "claude-code (model claude-opus-5[1m], effort:high — no separate CLI version field in the payload)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "session_id": "00000000-1111-4000-8000-000000000001", + "transcript_path": "/Users/user/.claude/projects/fake-project/00000000-1111-4000-8000-000000000001.jsonl", + "cwd": "$TMP", + "scratchpad_dir": "$TMP/scratchpad", + "prompt_id": "00000000-2222-4000-8000-000000000001", + "permission_mode": "default", + "effort": { "level": "high" }, + "hook_event_name": "PreToolUse", + "tool_name": "Agent", + "tool_input": { + "description": "Create note.txt", + "prompt": "", + "subagent_type": "general-purpose" + }, + "tool_use_id": "toolu_fake0000000000000001" + } +} diff --git a/test/fixtures/prd/authentic/claude/pretooluse-subagent-write.json b/test/fixtures/prd/authentic/claude/pretooluse-subagent-write.json new file mode 100644 index 0000000..765cdd6 --- /dev/null +++ b/test/fixtures/prd/authentic/claude/pretooluse-subagent-write.json @@ -0,0 +1,23 @@ +{ + "provenance": { + "source": "live capture — claude-code PreToolUse, the sub-agent's OWN Write (agent_id+agent_type present) (probe/out/claude.jsonl line 5)", + "cli_version": "claude-code (model claude-opus-5[1m], effort:high — no separate CLI version field in the payload)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "session_id": "00000000-1111-4000-8000-000000000001", + "transcript_path": "/Users/user/.claude/projects/fake-project/00000000-1111-4000-8000-000000000001.jsonl", + "cwd": "$TMP", + "scratchpad_dir": "$TMP/scratchpad", + "prompt_id": "00000000-2222-4000-8000-000000000001", + "permission_mode": "default", + "agent_id": "agent0000000000001", + "agent_type": "general-purpose", + "effort": { "level": "high" }, + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { "file_path": "$TMP/note.txt", "content": "hello\n" }, + "tool_use_id": "toolu_fake0000000000000002" + } +} diff --git a/test/fixtures/prd/authentic/claude/stop.json b/test/fixtures/prd/authentic/claude/stop.json new file mode 100644 index 0000000..fb55aa6 --- /dev/null +++ b/test/fixtures/prd/authentic/claude/stop.json @@ -0,0 +1,24 @@ +{ + "provenance": { + "source": "live capture — claude-code Stop, lead session while its sub-agent is still running (probe/out/claude.jsonl line 4)", + "cli_version": "claude-code (no separate CLI version field in the payload)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "session_id": "00000000-1111-4000-8000-000000000001", + "transcript_path": "/Users/user/.claude/projects/fake-project/00000000-1111-4000-8000-000000000001.jsonl", + "cwd": "$TMP", + "scratchpad_dir": "$TMP/scratchpad", + "prompt_id": "00000000-2222-4000-8000-000000000001", + "permission_mode": "default", + "effort": { "level": "high" }, + "hook_event_name": "Stop", + "stop_hook_active": false, + "last_assistant_message": "", + "background_tasks": [ + { "id": "agent0000000000001", "type": "subagent", "status": "running", "description": "Create note.txt", "agent_type": "general-purpose" } + ], + "session_crons": [] + } +} diff --git a/test/fixtures/prd/authentic/claude/subagentstart.json b/test/fixtures/prd/authentic/claude/subagentstart.json new file mode 100644 index 0000000..b58406c --- /dev/null +++ b/test/fixtures/prd/authentic/claude/subagentstart.json @@ -0,0 +1,18 @@ +{ + "provenance": { + "source": "live capture — claude-code SubagentStart (probe/out/claude.jsonl line 2)", + "cli_version": "claude-code (no separate CLI version field in the payload)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "session_id": "00000000-1111-4000-8000-000000000001", + "transcript_path": "/Users/user/.claude/projects/fake-project/00000000-1111-4000-8000-000000000001.jsonl", + "cwd": "$TMP", + "scratchpad_dir": "$TMP/scratchpad", + "prompt_id": "00000000-2222-4000-8000-000000000001", + "agent_id": "agent0000000000001", + "agent_type": "general-purpose", + "hook_event_name": "SubagentStart" + } +} diff --git a/test/fixtures/prd/authentic/claude/subagentstop.json b/test/fixtures/prd/authentic/claude/subagentstop.json new file mode 100644 index 0000000..a7bb8e7 --- /dev/null +++ b/test/fixtures/prd/authentic/claude/subagentstop.json @@ -0,0 +1,27 @@ +{ + "provenance": { + "source": "live capture — claude-code SubagentStop (probe/out/claude.jsonl line 7)", + "cli_version": "claude-code (no separate CLI version field in the payload)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "session_id": "00000000-1111-4000-8000-000000000001", + "transcript_path": "/Users/user/.claude/projects/fake-project/00000000-1111-4000-8000-000000000001.jsonl", + "cwd": "$TMP", + "scratchpad_dir": "$TMP/scratchpad", + "prompt_id": "00000000-2222-4000-8000-000000000001", + "permission_mode": "default", + "agent_id": "agent0000000000001", + "agent_type": "general-purpose", + "effort": { "level": "high" }, + "hook_event_name": "SubagentStop", + "stop_hook_active": false, + "agent_transcript_path": "/Users/user/.claude/projects/fake-project/00000000-1111-4000-8000-000000000001/subagents/agent-agent0000000000001.jsonl", + "last_assistant_message": "", + "background_tasks": [ + { "id": "agent0000000000001", "type": "subagent", "status": "running", "description": "Create note.txt", "agent_type": "general-purpose" } + ], + "session_crons": [] + } +} diff --git a/test/fixtures/prd/authentic/cursor/pretooluse-subagent-write.json b/test/fixtures/prd/authentic/cursor/pretooluse-subagent-write.json new file mode 100644 index 0000000..262d5e5 --- /dev/null +++ b/test/fixtures/prd/authentic/cursor/pretooluse-subagent-write.json @@ -0,0 +1,21 @@ +{ + "provenance": { + "source": "live capture — Cursor preToolUse, the sub-agent's OWN Write. Runs under a session_id/conversation_id that shares ZERO field with the lead's or with subagentStart's own subagent_id/parent_conversation_id (probe/out/cursor.jsonl line 5) — the fixture that evidences PRD design §6 Risk 1 (no correlating field on Cursor).", + "cli_version": "Cursor 3.18.25 (payload cursor_version)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "conversation_id": "22222222-0000-4000-8000-000000000002", + "generation_id": "22222222-1111-4000-8000-000000000022", + "model": "cursor-grok-4.6-medium", + "tool_name": "Write", + "tool_input": { "file_path": "$TMP/note.txt", "content": "hello" }, + "tool_use_id": "call-fake0000000000000002\nfc_fake0000000000000002_1", + "session_id": "22222222-0000-4000-8000-000000000002", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.25", + "workspace_roots": ["$TMP"], + "transcript_path": null + } +} diff --git a/test/fixtures/prd/authentic/cursor/stop.json b/test/fixtures/prd/authentic/cursor/stop.json new file mode 100644 index 0000000..b07c689 --- /dev/null +++ b/test/fixtures/prd/authentic/cursor/stop.json @@ -0,0 +1,29 @@ +{ + "provenance": { + "source": "live capture — Cursor stop, the lead session's terminal observation event (probe/out/cursor.jsonl line 9)", + "cli_version": "Cursor 3.18.25 (payload cursor_version)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "conversation_id": "11111111-0000-4000-8000-000000000001", + "generation_id": "11111111-0000-4000-8000-000000000001", + "model": "cursor-grok-4.6-medium", + "model_id": "grok-4.6", + "model_params": [ + { "id": "effort", "value": "medium" }, + { "id": "fast", "value": "false" } + ], + "status": "completed", + "loop_count": 0, + "input_tokens": 73524, + "output_tokens": 429, + "cache_read_tokens": 42624, + "cache_write_tokens": 0, + "session_id": "11111111-0000-4000-8000-000000000001", + "hook_event_name": "stop", + "cursor_version": "3.18.25", + "workspace_roots": ["$TMP"], + "transcript_path": "/Users/user/.cursor/projects/fake-project/agent-transcripts/11111111-0000-4000-8000-000000000001/11111111-0000-4000-8000-000000000001.jsonl" + } +} diff --git a/test/fixtures/prd/authentic/cursor/subagentstart.json b/test/fixtures/prd/authentic/cursor/subagentstart.json new file mode 100644 index 0000000..4b06818 --- /dev/null +++ b/test/fixtures/prd/authentic/cursor/subagentstart.json @@ -0,0 +1,25 @@ +{ + "provenance": { + "source": "live capture — Cursor subagentStart, the LEAD's conversation_id (probe/out/cursor.jsonl line 2)", + "cli_version": "Cursor 3.18.25 (payload cursor_version)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "conversation_id": "11111111-0000-4000-8000-000000000001", + "generation_id": "11111111-0000-4000-8000-000000000001", + "model": "cursor-grok-4.6-medium", + "subagent_id": "call-fake0000000000000001\nfc_fake0000000000000001_0", + "subagent_type": "general-purpose", + "task": "", + "parent_conversation_id": "11111111-0000-4000-8000-000000000001", + "tool_call_id": "call-fake0000000000000001\nfc_fake0000000000000001_0", + "subagent_model": "cursor-grok-4.6-medium", + "is_parallel_worker": false, + "session_id": "11111111-0000-4000-8000-000000000001", + "hook_event_name": "subagentStart", + "cursor_version": "3.18.25", + "workspace_roots": ["$TMP"], + "transcript_path": null + } +} diff --git a/test/fixtures/prd/authentic/cursor/subagentstop.json b/test/fixtures/prd/authentic/cursor/subagentstop.json new file mode 100644 index 0000000..1081241 --- /dev/null +++ b/test/fixtures/prd/authentic/cursor/subagentstop.json @@ -0,0 +1,29 @@ +{ + "provenance": { + "source": "live capture — Cursor subagentStop, back on the LEAD's conversation_id (probe/out/cursor.jsonl line 8)", + "cli_version": "Cursor 3.18.25 (payload cursor_version)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "conversation_id": "11111111-0000-4000-8000-000000000001", + "generation_id": "11111111-0000-4000-8000-000000000001", + "model": "cursor-grok-4.6-medium", + "subagent_id": "call-fake0000000000000001\nfc_fake0000000000000001_0", + "subagent_type": "general-purpose", + "status": "completed", + "duration_ms": 17432, + "parent_conversation_id": "11111111-0000-4000-8000-000000000001", + "message_count": 0, + "tool_call_count": 0, + "loop_count": 0, + "task": "", + "description": "Create note.txt file", + "session_id": "11111111-0000-4000-8000-000000000001", + "hook_event_name": "subagentStop", + "cursor_version": "3.18.25", + "workspace_roots": ["$TMP"], + "transcript_path": "/Users/user/.cursor/projects/fake-project/agent-transcripts/11111111-0000-4000-8000-000000000001/11111111-0000-4000-8000-000000000001.jsonl", + "agent_transcript_path": null + } +} diff --git a/test/fixtures/prd/authentic/kimi/pretooluse-bash.json b/test/fixtures/prd/authentic/kimi/pretooluse-bash.json new file mode 100644 index 0000000..ff8bc1f --- /dev/null +++ b/test/fixtures/prd/authentic/kimi/pretooluse-bash.json @@ -0,0 +1,17 @@ +{ + "provenance": { + "source": "live capture — Kimi Code CLI PreToolUse, Bash (probe/out/kimi.jsonl line 9). No agent_id/agent_type field exists on any Kimi payload observed — structurally excluded per adapters/kimi/index.ts's documented field set.", + "cli_version": "kimi_code_cli (payload client_type — no separate version field)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "hook_event_name": "PreToolUse", + "session_id": "session_fake00000000000000000001", + "cwd": "$TMP", + "client_type": "kimi_code_cli", + "tool_name": "Bash", + "tool_input": { "command": "pwd && ls" }, + "tool_call_id": "tool_fake0000000000000001" + } +} diff --git a/test/fixtures/prd/authentic/kimi/stop.json b/test/fixtures/prd/authentic/kimi/stop.json new file mode 100644 index 0000000..fa65471 --- /dev/null +++ b/test/fixtures/prd/authentic/kimi/stop.json @@ -0,0 +1,15 @@ +{ + "provenance": { + "source": "live capture — Kimi Code CLI Stop (probe/out/kimi.jsonl line 27). Kimi's documented blocking-event set includes Stop (adapters.md), unlike SubagentStop which is observation-only.", + "cli_version": "kimi_code_cli (payload client_type — no separate version field)", + "captured": "2026-09-02", + "sanitization": "v1 — see test/fixtures/prd/README.md" + }, + "stdin": { + "hook_event_name": "Stop", + "session_id": "session_fake00000000000000000001", + "cwd": "$TMP", + "client_type": "kimi_code_cli", + "stop_hook_active": false + } +} diff --git a/test/fixtures/prd/prd/agents/backend-expert-prd.json b/test/fixtures/prd/prd/agents/backend-expert-prd.json new file mode 100644 index 0000000..579b137 --- /dev/null +++ b/test/fixtures/prd/prd/agents/backend-expert-prd.json @@ -0,0 +1,5 @@ +{ + "auth-refactor": { + "jwt-validation": { "status": "done", "modified": ["src/auth/login.ts"], "unchanged": [] } + } +} diff --git a/test/fixtures/prd/prd/auth-refactor-prd.json b/test/fixtures/prd/prd/auth-refactor-prd.json new file mode 100644 index 0000000..fd9f30d --- /dev/null +++ b/test/fixtures/prd/prd/auth-refactor-prd.json @@ -0,0 +1,10 @@ +{ + "backend-expert": { + "files": ["src/auth/login.ts"], + "sub-tasks": { "jwt-validation": { "status": "assigned" } } + }, + "backend-expert-2": { + "files": ["src/auth/session.ts"], + "sub-tasks": { "session-store": { "status": "assigned" } } + } +} diff --git a/test/fixtures/prd/router.json b/test/fixtures/prd/router.json new file mode 100644 index 0000000..ee84f08 --- /dev/null +++ b/test/fixtures/prd/router.json @@ -0,0 +1,3 @@ +{ + "auth-refactor": { "prd": "prd/auth-refactor-prd.json", "status": "assigned" } +} diff --git a/test/helpers/prd-env.ts b/test/helpers/prd-env.ts new file mode 100644 index 0000000..c288c4a --- /dev/null +++ b/test/helpers/prd-env.ts @@ -0,0 +1,68 @@ +/** + * @module test/helpers/prd-env + * Materializes the PRD contract's on-disk fixtures (test/fixtures/prd/) under a + * fresh `//apex/` tree, for tests that need a project with a + * real router + task PRD + one agent report already on disk (prd-design.md §0). + * Kept out of test/sim/* on purpose: this is a plain fs helper reused by BOTH + * the sim regression suite (test/sim/prd-regression.test.ts) and any future + * unit test that needs a populated PRD project without going through the + * scenario runner's own `setup` mechanism. + */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Absolute path to `test/fixtures/prd`, resolved from this file. */ +const FIXTURES_DIR: string = join(import.meta.dir, "..", "fixtures", "prd"); + +/** A populated tmp project root plus its cleanup callback. */ +export interface PrdEnv { + /** Project root (`CLAUDE_PROJECT_DIR`/cwd equivalent) — also used as `HOME`. */ + root: string; + /** Home-dir segment the fixtures were materialized under (e.g. ".claude"). */ + homeSeg: string; + /** Removes the whole tmp tree. Idempotent, safe to call more than once. */ + cleanup: () => void; +} + +/** + * Copies the three synthetic PRD fixtures (router, task PRD, one agent report — + * test/fixtures/prd/README.md §1) verbatim into `//apex/`. Byte- + * exact copy (no JSON re-serialization), so this is always the SAME content the + * documentation's worked example shows. + * + * The router fixture's SOURCE file is named `router.json` (test/fixtures/prd/ + * router.json — the fixture's own on-disk name), but the CONTRACT (prd-design.md + * §0) requires it live at `/apex/prd.json`, not `.../apex/router.json`. + * `isPrdEnabled()`'s activation check looks for `prd.json` specifically — the + * wrong destination name means the module is silently never activated by this + * helper. Do not "fix" this by renaming the fixture source file instead: that + * source name is the one test/fixtures/prd/README.md documents. + * @param root - Project root to materialize under. + * @param homeSeg - Home-dir segment (e.g. ".claude", ".codex", ".cursor"). + */ +export function materializePrdFixtures(root: string, homeSeg: string): void { + const apex = join(root, homeSeg, "apex"); + const copy = (srcRel: string, destRel: string): void => { + const dest = join(apex, destRel); + mkdirSync(join(dest, ".."), { recursive: true }); + writeFileSync(dest, readFileSync(join(FIXTURES_DIR, srcRel))); + }; + copy("router.json", "prd.json"); + copy(join("prd", "auth-refactor-prd.json"), join("prd", "auth-refactor-prd.json")); + copy(join("prd", "agents", "backend-expert-prd.json"), join("prd", "agents", "backend-expert-prd.json")); +} + +/** + * Creates an isolated tmp project already populated with the PRD fixtures, and + * returns it. `HOME` is set to the same tmp root by every caller that spawns + * the harness against `env.root` (mirrors `test/sim/exec.ts`'s own HOME=$TMP + * isolation contract — never the real `~/.fuse-harness`). + * @param homeSeg - Home-dir segment to materialize under; defaults to ".claude". + * @returns The populated {@link PrdEnv}. + */ +export function createPrdEnv(homeSeg: string = ".claude"): PrdEnv { + const root = mkdtempSync(join(tmpdir(), "fh-prd-env-")); + materializePrdFixtures(root, homeSeg); + return { root, homeSeg, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} diff --git a/test/policy/prd/prd-compact.test.ts b/test/policy/prd/prd-compact.test.ts new file mode 100644 index 0000000..0190a32 --- /dev/null +++ b/test/policy/prd/prd-compact.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { canPromoteRouterEntry, compactAgentEntry, compactTaskFile } from "../../../src/policy/prd/prd-compact"; +import type { PrdAgentEntryExpanded, PrdTaskFile } from "../../../src/policy/prd/interfaces/types"; + +const AT = "2026-01-01T00:00:00.000Z"; + +describe("compactAgentEntry", () => { + test("every sub-task validated -> collapses to compacted shape", () => { + const entry: PrdAgentEntryExpanded = { + files: ["a.ts"], + "sub-tasks": { jwt: { status: "validated", "validated-at": AT }, mfa: { status: "validated", "validated-at": AT } }, + }; + expect(compactAgentEntry(entry, AT)).toEqual({ status: "validated", files: ["a.ts"], "validated-at": AT }); + }); + test("one sub-task still assigned -> unchanged", () => { + const entry: PrdAgentEntryExpanded = { + files: ["a.ts"], + "sub-tasks": { jwt: { status: "validated", "validated-at": AT }, mfa: { status: "assigned" } }, + }; + expect(compactAgentEntry(entry, AT)).toEqual(entry); + }); + test("already compacted -> unchanged (idempotent)", () => { + const entry = { status: "validated" as const, files: ["a.ts"], "validated-at": AT }; + expect(compactAgentEntry(entry, AT)).toEqual(entry); + }); +}); + +describe("compactTaskFile", () => { + test("collapses every fully-validated agent, reports which ones", () => { + const taskFile: PrdTaskFile = { + a: { files: ["a.ts"], "sub-tasks": { x: { status: "validated", "validated-at": AT } } }, + b: { files: ["b.ts"], "sub-tasks": { y: { status: "assigned" } } }, + }; + const { file, compacted } = compactTaskFile(taskFile, AT); + expect(compacted).toEqual(["a"]); + expect(file.a).toEqual({ status: "validated", files: ["a.ts"], "validated-at": AT }); + expect(file.b).toEqual(taskFile.b); + }); + test("nothing to compact -> empty compacted list, unchanged file", () => { + const taskFile: PrdTaskFile = { a: { files: [], "sub-tasks": { x: { status: "assigned" } } } }; + const { file, compacted } = compactTaskFile(taskFile, AT); + expect(compacted).toEqual([]); + expect(file).toEqual(taskFile); + }); +}); + +describe("canPromoteRouterEntry", () => { + test("true when every agent is compacted+validated", () => { + const taskFile: PrdTaskFile = { + a: { status: "validated", files: [], "validated-at": AT }, + b: { status: "validated", files: [], "validated-at": AT }, + }; + expect(canPromoteRouterEntry(taskFile)).toBe(true); + }); + test("false when at least one agent is not compacted", () => { + const taskFile: PrdTaskFile = { + a: { status: "validated", files: [], "validated-at": AT }, + b: { files: [], "sub-tasks": { y: { status: "validated", "validated-at": AT } } }, + }; + expect(canPromoteRouterEntry(taskFile)).toBe(false); + }); +}); diff --git a/test/policy/prd/prd-context.test.ts b/test/policy/prd/prd-context.test.ts new file mode 100644 index 0000000..bd4e77f --- /dev/null +++ b/test/policy/prd/prd-context.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { agentSlice, agentSlices, joinContextResponses, renderAgentSliceMarkdown } from "../../../src/policy/prd/prd-context"; +import type { PrdSubagentSlice, PrdTaskFile } from "../../../src/policy/prd/interfaces/types"; + +describe("agentSlice", () => { + const taskFile: PrdTaskFile = { + "backend-expert": { files: ["src/auth/login.ts"], "sub-tasks": { "jwt-validation": { status: "assigned" } } }, + "backend-expert-2": { files: ["src/auth/session.ts"], "sub-tasks": { "session-store": { status: "assigned" } } }, + }; + + test("builds the slice for a single matching candidate", () => { + expect(agentSlice(taskFile, "backend-expert-2", "auth-refactor")).toEqual({ + task: "auth-refactor", agent: "backend-expert-2", subTasks: ["session-store"], files: ["src/auth/session.ts"], + }); + }); + test("null when the agent type matches nothing", () => { + expect(agentSlice(taskFile, "frontend-expert", "auth-refactor")).toBeNull(); + }); + test("null when the agent type is ambiguous (matches 2+ candidates)", () => { + const ambiguous: PrdTaskFile = { + "backend-expert": { files: [], "sub-tasks": {} }, + "backend-expert-2": { files: [], "sub-tasks": {} }, + }; + expect(agentSlice(ambiguous, "backend-expert", "t")).toBeNull(); + }); +}); + +describe("agentSlices (plural — surfaces every candidate on ambiguity)", () => { + const ambiguous: PrdTaskFile = { + "backend-expert": { files: ["src/auth/login.ts"], "sub-tasks": { "jwt-validation": { status: "assigned" } } }, + "backend-expert-2": { files: ["src/auth/session.ts"], "sub-tasks": { "session-store": { status: "assigned" } } }, + }; + + test("two candidates -> one slice per candidate", () => { + expect(agentSlices(ambiguous, "backend-expert", "auth-refactor")).toEqual([ + { task: "auth-refactor", agent: "backend-expert", subTasks: ["jwt-validation"], files: ["src/auth/login.ts"] }, + { task: "auth-refactor", agent: "backend-expert-2", subTasks: ["session-store"], files: ["src/auth/session.ts"] }, + ]); + }); + test("a single candidate -> the same one-slice array agentSlice implies", () => { + expect(agentSlices(ambiguous, "backend-expert-2", "auth-refactor")).toEqual([ + { task: "auth-refactor", agent: "backend-expert-2", subTasks: ["session-store"], files: ["src/auth/session.ts"] }, + ]); + }); + test("zero candidates -> empty array", () => { + expect(agentSlices(ambiguous, "frontend-expert", "auth-refactor")).toEqual([]); + }); +}); + +describe("renderAgentSliceMarkdown", () => { + const singleSliceBlock = [ + "## PRD assignment — task auth-refactor", + "Your files: src/auth/login.ts", + "Your sub-tasks: jwt-validation", + "Report to prd/agents/backend-expert-prd.json when done.", + ].join("\n"); + + test("renders a readable block: title, files, sub-tasks, report path", () => { + const slices: PrdSubagentSlice[] = [ + { task: "auth-refactor", agent: "backend-expert", subTasks: ["jwt-validation"], files: ["src/auth/login.ts"] }, + ]; + const text = renderAgentSliceMarkdown(slices); + expect(text).toContain("## PRD assignment — task auth-refactor"); + expect(text).toContain("src/auth/login.ts"); + expect(text).toContain("jwt-validation"); + expect(text).toContain("prd/agents/backend-expert-prd.json"); + }); + test("a single candidate renders byte-for-byte the same as before (no ambiguity header)", () => { + const slices: PrdSubagentSlice[] = [ + { task: "auth-refactor", agent: "backend-expert", subTasks: ["jwt-validation"], files: ["src/auth/login.ts"] }, + ]; + expect(renderAgentSliceMarkdown(slices)).toBe(singleSliceBlock); + }); + test("two slices sharing the same task (ambiguous) -> explicit header + both tranches", () => { + const slices: PrdSubagentSlice[] = [ + { task: "auth-refactor", agent: "backend-expert", subTasks: ["jwt-validation"], files: ["src/auth/login.ts"] }, + { task: "auth-refactor", agent: "backend-expert-2", subTasks: ["session-store"], files: ["src/auth/session.ts"] }, + ]; + const text = renderAgentSliceMarkdown(slices); + expect(text).toContain( + "Several assignments match your agent type. You are ONE of: backend-expert, backend-expert-2. " + + "The first report you write binds your name; write only that report.", + ); + expect(text).toContain("## PRD assignment — task auth-refactor"); + expect(text).toContain("prd/agents/backend-expert-prd.json"); + expect(text).toContain("prd/agents/backend-expert-2-prd.json"); + }); + test("two slices from two DIFFERENT tasks are not flagged as ambiguous (no header)", () => { + const slices: PrdSubagentSlice[] = [ + { task: "auth-refactor", agent: "backend-expert", subTasks: ["jwt-validation"], files: ["src/auth/login.ts"] }, + { task: "billing", agent: "backend-expert", subTasks: ["invoice"], files: ["src/billing.ts"] }, + ]; + expect(renderAgentSliceMarkdown(slices)).not.toContain("Several assignments match"); + }); + test("empty slices -> empty string", () => { + expect(renderAgentSliceMarkdown([])).toBe(""); + }); +}); + +describe("joinContextResponses", () => { + test("merges two additionalContext responses into one", () => { + const a = JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: "A" } }); + const b = JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: "B" } }); + const merged = JSON.parse(joinContextResponses(a, b)) as { hookSpecificOutput: { additionalContext: string } }; + expect(merged.hookSpecificOutput.additionalContext).toBe("A\n\nB"); + }); + test("empty parts produce an empty string", () => { + expect(joinContextResponses("", "")).toBe(""); + }); + + // RED (measured defect): cline's native `{contextModification}` shape (see + // runtime/respond.ts's "cline" branch) parses fine as JSON but has no + // `hookSpecificOutput` key, so the old contextTextOf's `?? ""` silently + // dropped it — a real, non-empty PRD slice reduced to "" and filtered out + // by `.filter(Boolean)`. Fails before the fix (merged === ""), passes after. + test("recognizes cline's native {contextModification} shape instead of silently dropping it", () => { + const clinePart = JSON.stringify({ contextModification: "PRD assignment for cline — session-store" }); + const merged = joinContextResponses(clinePart); + expect(merged).not.toBe(""); + const parsed = JSON.parse(merged) as { contextModification?: string }; + expect(parsed.contextModification).toContain("session-store"); + }); + + test("re-shapes the merge as {contextModification} when a cline-native part is combined with a Claude-shaped part, so cline's own consumer can still read it", () => { + const claudePart = JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: "cache entry" } }); + const clinePart = JSON.stringify({ contextModification: "PRD slice" }); + const merged = JSON.parse(joinContextResponses(claudePart, clinePart)) as { contextModification?: string; hookSpecificOutput?: unknown }; + expect(merged.hookSpecificOutput).toBeUndefined(); + expect(merged.contextModification).toBe("cache entry\n\nPRD slice"); + }); +}); diff --git a/test/policy/prd/prd-crosscheck.test.ts b/test/policy/prd/prd-crosscheck.test.ts new file mode 100644 index 0000000..2ea2e38 --- /dev/null +++ b/test/policy/prd/prd-crosscheck.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { + crossCheckRouter, crossCheckTask, hasAnyViolations, incompleteSubTasks, +} from "../../../src/policy/prd/prd-crosscheck"; +import type { PrdAgentReportFile, PrdRouter, PrdTaskFile } from "../../../src/policy/prd/interfaces/types"; + +const taskFile: PrdTaskFile = { + "backend-expert": { files: ["a.ts"], "sub-tasks": { jwt: { status: "validated", "validated-at": "t" } } }, + "backend-expert-2": { files: ["b.ts"], "sub-tasks": { session: { status: "validated", "validated-at": "t" } } }, +}; + +describe("crossCheckTask", () => { + test("validated sub-task without a matching done report -> violation", () => { + const reports: Record = { + "backend-expert": {}, + "backend-expert-2": { t: { session: { status: "done", modified: ["b.ts"], unchanged: [] } } }, + }; + const violations = crossCheckTask(taskFile, reports, "t"); + expect(violations).toEqual([ + { task: "t", agent: "backend-expert", sub: "jwt", reason: "validated without a matching done report" }, + ]); + }); + test("validated with a matching done report -> no violation", () => { + const reports: Record = { + "backend-expert": { t: { jwt: { status: "done", modified: ["a.ts"], unchanged: [] } } }, + "backend-expert-2": { t: { session: { status: "done", modified: ["b.ts"], unchanged: [] } } }, + }; + expect(crossCheckTask(taskFile, reports, "t")).toEqual([]); + }); + test("a compacted (already-validated) entry is ignored, not re-checked", () => { + const compactedTaskFile: PrdTaskFile = { + "backend-expert": { status: "validated", files: ["a.ts"], "validated-at": "t" }, + "backend-expert-2": { files: ["b.ts"], "sub-tasks": { session: { status: "assigned" } } }, + }; + expect(crossCheckTask(compactedTaskFile, {}, "t")).toEqual([]); + }); + test("an assigned (not yet validated) sub-task never produces a violation", () => { + const assignedTaskFile: PrdTaskFile = { + a: { files: [], "sub-tasks": { x: { status: "assigned" } } }, + b: { files: [], "sub-tasks": { y: { status: "assigned" } } }, + }; + expect(crossCheckTask(assignedTaskFile, {}, "t")).toEqual([]); + }); +}); + +describe("crossCheckRouter", () => { + test("router says validated but the task-PRD still has an assigned sub-task -> violation", () => { + const stillAssigned: PrdTaskFile = { + a: { files: [], "sub-tasks": { x: { status: "validated", "validated-at": "t" } } }, + b: { files: [], "sub-tasks": { y: { status: "assigned" } } }, + }; + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "validated" } }; + expect(crossCheckRouter(router, { t: stillAssigned }).length).toBe(1); + }); + test("router says validated and the task-PRD agrees -> no violation", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "validated" } }; + expect(crossCheckRouter(router, { t: taskFile })).toEqual([]); + }); + test("a null task file (unparseable) under a validated router entry is a violation", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "validated" } }; + expect(crossCheckRouter(router, { t: null }).length).toBe(1); + }); + test("a non-validated router entry is never cross-checked", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "assigned" } }; + expect(crossCheckRouter(router, { t: null })).toEqual([]); + }); +}); + +describe("incompleteSubTasks", () => { + test("sub-tasks assigned to the agent that are not done in its own report", () => { + const report: PrdAgentReportFile = { t: { jwt: { status: "done", modified: ["a.ts"], unchanged: [] } } }; + const twoSubs: PrdTaskFile = { + "backend-expert": { + files: [], "sub-tasks": { jwt: { status: "assigned" }, mfa: { status: "assigned" } }, + }, + other: { files: [], "sub-tasks": { x: { status: "assigned" } } }, + }; + expect(incompleteSubTasks(twoSubs, "backend-expert", "t", report)).toEqual(["mfa"]); + }); + test("null report -> every sub-task is incomplete", () => { + expect(incompleteSubTasks(taskFile, "backend-expert", "t", null)).toEqual(["jwt"]); + }); + test("compacted agent entry has nothing incomplete", () => { + const compactedTaskFile: PrdTaskFile = { + "backend-expert": { status: "validated", files: [], "validated-at": "t" }, + other: { files: [], "sub-tasks": {} }, + }; + expect(incompleteSubTasks(compactedTaskFile, "backend-expert", "t", null)).toEqual([]); + }); +}); + +describe("hasAnyViolations", () => { + test("true when crossCheckRouter finds anything", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "validated" } }; + expect(hasAnyViolations(router, { t: null })).toBe(true); + }); + test("false when clean", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "assigned" } }; + const assignedTaskFile: PrdTaskFile = { + a: { files: [], "sub-tasks": { x: { status: "assigned" } } }, + b: { files: [], "sub-tasks": { y: { status: "assigned" } } }, + }; + expect(hasAnyViolations(router, { t: assignedTaskFile })).toBe(false); + }); +}); diff --git a/test/policy/prd/prd-enabled.test.ts b/test/policy/prd/prd-enabled.test.ts new file mode 100644 index 0000000..ecc3bde --- /dev/null +++ b/test/policy/prd/prd-enabled.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isPrdEnabled, isPrdFlagSet, prdProjectRoot, routerExistsSync } from "../../../src/policy/prd/prd-enabled"; + +describe("isPrdFlagSet", () => { + test("exact \"1\" -> true", () => { + expect(isPrdFlagSet({ FUSE_PRD: "1" })).toBe(true); + }); + test("absent -> false", () => { + expect(isPrdFlagSet({})).toBe(false); + }); + test("\"true\" -> false", () => { + expect(isPrdFlagSet({ FUSE_PRD: "true" })).toBe(false); + }); + test("\"yes\" -> false", () => { + expect(isPrdFlagSet({ FUSE_PRD: "yes" })).toBe(false); + }); +}); + +describe("prdProjectRoot", () => { + test("CLAUDE_PROJECT_DIR wins when absolute", () => { + expect(prdProjectRoot("/cwd", { CLAUDE_PROJECT_DIR: "/claude-root" })).toBe("/claude-root"); + }); + test("CURSOR_PROJECT_DIR is the fallback when CLAUDE_PROJECT_DIR is absent", () => { + expect(prdProjectRoot("/cwd", { CURSOR_PROJECT_DIR: "/cursor-root" })).toBe("/cursor-root"); + }); + test("CLAUDE_PROJECT_DIR takes priority over CURSOR_PROJECT_DIR", () => { + expect(prdProjectRoot("/cwd", { CLAUDE_PROJECT_DIR: "/claude-root", CURSOR_PROJECT_DIR: "/cursor-root" })).toBe("/claude-root"); + }); + test("a non-absolute env value is ignored, falls through", () => { + expect(prdProjectRoot("/cwd", { CLAUDE_PROJECT_DIR: "relative/path" })).toBe("/cwd"); + }); + test("falls back to cwd when neither env var is set", () => { + expect(prdProjectRoot("/cwd", {})).toBe("/cwd"); + }); +}); + +describe("routerExistsSync", () => { + test("true once the router file exists on disk", () => { + const root = mkdtempSync(join(tmpdir(), "fh-prd-enabled-")); + try { + expect(routerExistsSync(root, ".claude")).toBe(false); + mkdirSync(join(root, ".claude", "apex"), { recursive: true }); + writeFileSync(join(root, ".claude", "apex", "prd.json"), "{}", "utf8"); + expect(routerExistsSync(root, ".claude")).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("isPrdEnabled — the single activation check", () => { + test("flag absent -> false, without needing any real root/id", () => { + expect(isPrdEnabled("/does/not/exist", "claude-code", {})).toBe(false); + }); + test("\"1\" without a router file -> false", () => { + const root = mkdtempSync(join(tmpdir(), "fh-prd-enabled-")); + try { + expect(isPrdEnabled(root, "claude-code", { FUSE_PRD: "1" })).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("\"1\" with a router file -> true", () => { + const root = mkdtempSync(join(tmpdir(), "fh-prd-enabled-")); + try { + mkdirSync(join(root, ".claude", "apex"), { recursive: true }); + writeFileSync(join(root, ".claude", "apex", "prd.json"), "{}", "utf8"); + expect(isPrdEnabled(root, "claude-code", { FUSE_PRD: "1" })).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("\"true\"/\"yes\" never activate, even with a router file present", () => { + const root = mkdtempSync(join(tmpdir(), "fh-prd-enabled-")); + try { + mkdirSync(join(root, ".claude", "apex"), { recursive: true }); + writeFileSync(join(root, ".claude", "apex", "prd.json"), "{}", "utf8"); + expect(isPrdEnabled(root, "claude-code", { FUSE_PRD: "true" })).toBe(false); + expect(isPrdEnabled(root, "claude-code", { FUSE_PRD: "yes" })).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/policy/prd/prd-io.test.ts b/test/policy/prd/prd-io.test.ts new file mode 100644 index 0000000..024327e --- /dev/null +++ b/test/policy/prd/prd-io.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readAgentReport, readAgentReportSync, readAllTaskFiles, readRouter, readRouterSync, + readTaskFile, readTaskFileSync, writeAgentReport, writeRouter, writeTaskFile, +} from "../../../src/policy/prd/prd-io"; +import { prdAgentReportPath, prdRouterPath, prdTaskPath } from "../../../src/policy/prd/prd-paths"; +import type { PrdAgentReportFile, PrdRouter, PrdTaskFile } from "../../../src/policy/prd/interfaces/types"; + +const homeSeg = ".claude"; + +function withTmpDir(fn: (root: string) => Promise | void): () => Promise { + return async () => { + const root = mkdtempSync(join(tmpdir(), "fh-prd-io-")); + try { + await fn(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; +} + +describe("router I/O", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "assigned" } }; + + test("writeRouter then readRouter round-trips", withTmpDir(async (root) => { + await writeRouter(root, homeSeg, router); + expect(await readRouter(root, homeSeg)).toEqual(router); + expect(readRouterSync(root, homeSeg)).toEqual(router); + })); + test("missing router file -> null (both variants)", withTmpDir(async (root) => { + expect(await readRouter(root, homeSeg)).toBeNull(); + expect(readRouterSync(root, homeSeg)).toBeNull(); + })); + test("corrupt router JSON content -> null (both variants)", withTmpDir(async (root) => { + const path = prdRouterPath(root, homeSeg); + mkdirSync(join(root, homeSeg, "apex"), { recursive: true }); + writeFileSync(path, "{not json", "utf8"); + expect(await readRouter(root, homeSeg)).toBeNull(); + expect(readRouterSync(root, homeSeg)).toBeNull(); + })); +}); + +describe("task-PRD I/O", () => { + const taskFile: PrdTaskFile = { + a: { files: ["a.ts"], "sub-tasks": { x: { status: "assigned" } } }, + b: { files: ["b.ts"], "sub-tasks": { y: { status: "assigned" } } }, + }; + const relPrd = "prd/t-prd.json"; + + test("writeTaskFile then readTaskFile round-trips", withTmpDir(async (root) => { + await writeTaskFile(root, homeSeg, relPrd, taskFile); + expect(await readTaskFile(root, homeSeg, relPrd)).toEqual(taskFile); + expect(readTaskFileSync(root, homeSeg, relPrd)).toEqual(taskFile); + })); + test("missing task file -> null", withTmpDir(async (root) => { + expect(await readTaskFile(root, homeSeg, relPrd)).toBeNull(); + expect(readTaskFileSync(root, homeSeg, relPrd)).toBeNull(); + })); + test("readAllTaskFiles reads every router-declared task, null for missing ones", withTmpDir(async (root) => { + await writeTaskFile(root, homeSeg, relPrd, taskFile); + const router: PrdRouter = { + t: { prd: relPrd, status: "assigned" }, + ghost: { prd: "prd/ghost-prd.json", status: "assigned" }, + }; + const all = await readAllTaskFiles(root, homeSeg, router); + expect(all.t).toEqual(taskFile); + expect(all.ghost).toBeNull(); + })); +}); + +describe("agent report I/O", () => { + const report: PrdAgentReportFile = { t: { x: { status: "done", modified: ["a.ts"], unchanged: [] } } }; + + test("writeAgentReport then readAgentReport round-trips", withTmpDir(async (root) => { + await writeAgentReport(root, homeSeg, "backend-expert", report); + expect(await readAgentReport(root, homeSeg, "backend-expert")).toEqual(report); + expect(readAgentReportSync(root, homeSeg, "backend-expert")).toEqual(report); + })); + test("missing agent report -> null", withTmpDir(async (root) => { + expect(await readAgentReport(root, homeSeg, "backend-expert")).toBeNull(); + expect(readAgentReportSync(root, homeSeg, "backend-expert")).toBeNull(); + })); + test("corrupt agent report JSON -> null", withTmpDir(async (root) => { + const path = prdAgentReportPath(root, homeSeg, "backend-expert"); + mkdirSync(join(root, homeSeg, "apex", "prd", "agents"), { recursive: true }); + writeFileSync(path, "not json at all", "utf8"); + expect(await readAgentReport(root, homeSeg, "backend-expert")).toBeNull(); + expect(readAgentReportSync(root, homeSeg, "backend-expert")).toBeNull(); + })); +}); diff --git a/test/policy/prd/prd-ownership.test.ts b/test/policy/prd/prd-ownership.test.ts new file mode 100644 index 0000000..f27ba2f --- /dev/null +++ b/test/policy/prd/prd-ownership.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { + candidateAgentNames, evaluateWriteOwnership, matchesAgentName, resolveOwnerBinding, +} from "../../../src/policy/prd/prd-ownership"; +import type { PrdIdentity, PrdTaskFile } from "../../../src/policy/prd/interfaces/types"; + +describe("matchesAgentName", () => { + test("exact match", () => { + expect(matchesAgentName("backend-expert", "backend-expert")).toBe(true); + }); + test("suffixed with n >= 2", () => { + expect(matchesAgentName("backend-expert-2", "backend-expert")).toBe(true); + }); + test("rejects -1 suffix", () => { + expect(matchesAgentName("backend-expert-1", "backend-expert")).toBe(false); + }); + test("rejects non-numeric suffix", () => { + expect(matchesAgentName("backend-expert-x", "backend-expert")).toBe(false); + }); + test("rejects a name that merely starts with the type", () => { + expect(matchesAgentName("backend-expertise", "backend-expert")).toBe(false); + }); +}); + +describe("candidateAgentNames", () => { + const taskFile: PrdTaskFile = { + "backend-expert": { files: [], "sub-tasks": {} }, + "backend-expert-2": { files: [], "sub-tasks": {} }, + "frontend-expert": { files: [], "sub-tasks": {} }, + }; + test("filters task-file agents by name match", () => { + expect(candidateAgentNames("backend-expert", taskFile).sort()).toEqual(["backend-expert", "backend-expert-2"]); + }); + test("empty when nothing matches", () => { + expect(candidateAgentNames("ghost-expert", taskFile)).toEqual([]); + }); +}); + +describe("resolveOwnerBinding", () => { + test("already bound to the same id", () => { + const result = resolveOwnerBinding(["a", "b"], "id-1", { "id-1": "a" }); + expect(result).toEqual({ name: "a", alreadyBound: true }); + }); + test("bound to a different id -> excluded, other candidate free", () => { + const result = resolveOwnerBinding(["a", "b"], "id-2", { "id-1": "a" }); + expect(result).toEqual({ name: "b", alreadyBound: false }); + }); + test("single free candidate binds", () => { + const result = resolveOwnerBinding(["a"], "id-1", {}); + expect(result).toEqual({ name: "a", alreadyBound: false }); + }); + test("two free candidates -> ambiguous, null", () => { + expect(resolveOwnerBinding(["a", "b"], "id-1", {})).toBeNull(); + }); + test("zero candidates -> null", () => { + expect(resolveOwnerBinding([], "id-1", {})).toBeNull(); + }); +}); + +describe("evaluateWriteOwnership", () => { + const taskFile: PrdTaskFile = { + "backend-expert": { files: ["a.ts"], "sub-tasks": { jwt: { status: "assigned" } } }, + "backend-expert-2": { files: ["b.ts"], "sub-tasks": { session: { status: "assigned" } } }, + }; + const lead: PrdIdentity = { lead: true }; + const sub = (agentType: string, agentId = "id-1"): PrdIdentity => ({ lead: false, agentType, agentId }); + const unknown: PrdIdentity = { lead: "unknown" }; + + for (const kind of [{ kind: "router" as const }, { kind: "task" as const, task: "t" }, { kind: "docs" as const, task: "t" }]) { + test(`${kind.kind}: lead allowed, sub denied, unknown advisory`, () => { + expect(evaluateWriteOwnership({ kind, identity: lead, taskFile: null, bindings: {} })).toEqual({ allow: true }); + const subVerdict = evaluateWriteOwnership({ kind, identity: sub("backend-expert"), taskFile: null, bindings: {} }); + expect(subVerdict.allow).toBe(false); + expect(evaluateWriteOwnership({ kind, identity: unknown, taskFile: null, bindings: {} })).toEqual({ allow: "advisory" }); + }); + } + + test("agentReport: lead denied", () => { + const verdict = evaluateWriteOwnership({ + kind: { kind: "agentReport", agent: "backend-expert" }, identity: lead, taskFile, bindings: {}, + }); + expect(verdict.allow).toBe(false); + }); + test("agentReport: sub allowed when its own name is the sole free candidate", () => { + const verdict = evaluateWriteOwnership({ + kind: { kind: "agentReport", agent: "backend-expert" }, + identity: sub("backend-expert"), taskFile, bindings: { "id-9": "backend-expert-2" }, + }); + expect(verdict).toEqual({ allow: true, bind: { agentId: "id-1", name: "backend-expert" } }); + }); + test("agentReport: sub denied when target belongs to a different agent name", () => { + const verdict = evaluateWriteOwnership({ + kind: { kind: "agentReport", agent: "backend-expert-2" }, + identity: sub("backend-expert"), taskFile, bindings: {}, + }); + expect(verdict.allow).toBe(false); + }); + test("agentReport: sub denied when already bound to a different agentId", () => { + const verdict = evaluateWriteOwnership({ + kind: { kind: "agentReport", agent: "backend-expert" }, + identity: sub("backend-expert", "id-2"), taskFile, bindings: { "id-1": "backend-expert" }, + }); + expect(verdict.allow).toBe(false); + }); + test("agentReport: agent_id present without agent_type -> deny (fail-closed)", () => { + const verdict = evaluateWriteOwnership({ + kind: { kind: "agentReport", agent: "backend-expert" }, + identity: { lead: false, agentId: "id-1" }, taskFile, bindings: {}, + }); + expect(verdict.allow).toBe(false); + }); + test("agentReport: unknown lead is always advisory", () => { + const verdict = evaluateWriteOwnership({ + kind: { kind: "agentReport", agent: "backend-expert" }, identity: unknown, taskFile, bindings: {}, + }); + expect(verdict).toEqual({ allow: "advisory" }); + }); + + test("other: denied for lead and sub, advisory for unknown", () => { + expect(evaluateWriteOwnership({ kind: { kind: "other" }, identity: lead, taskFile: null, bindings: {} }).allow).toBe(false); + expect(evaluateWriteOwnership({ kind: { kind: "other" }, identity: sub("backend-expert"), taskFile: null, bindings: {} }).allow).toBe(false); + expect(evaluateWriteOwnership({ kind: { kind: "other" }, identity: unknown, taskFile: null, bindings: {} })).toEqual({ allow: "advisory" }); + }); +}); diff --git a/test/policy/prd/prd-paths.test.ts b/test/policy/prd/prd-paths.test.ts new file mode 100644 index 0000000..b5c702e --- /dev/null +++ b/test/policy/prd/prd-paths.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { + classifyPrdPath, isPrdScopedPath, prdAgentReportPath, prdDir, prdDocsPath, + prdRouterPath, prdTaskPath, +} from "../../../src/policy/prd/prd-paths"; +import type { PrdRouter } from "../../../src/policy/prd/interfaces/types"; + +const root = "/proj"; +const homeSeg = ".claude"; + +describe("prd-paths builders", () => { + test("prdRouterPath", () => { + expect(prdRouterPath(root, homeSeg)).toBe("/proj/.claude/apex/prd.json"); + }); + test("prdDir", () => { + expect(prdDir(root, homeSeg)).toBe("/proj/.claude/apex/prd"); + }); + test("prdTaskPath resolves relPrd against apex dir", () => { + expect(prdTaskPath(root, homeSeg, "prd/auth-refactor-prd.json")).toBe( + "/proj/.claude/apex/prd/auth-refactor-prd.json", + ); + }); + test("prdAgentReportPath", () => { + expect(prdAgentReportPath(root, homeSeg, "backend-expert")).toBe( + "/proj/.claude/apex/prd/agents/backend-expert-prd.json", + ); + }); + test("prdDocsPath", () => { + expect(prdDocsPath(root, homeSeg, "auth-refactor")).toBe( + "/proj/.claude/apex/prd/docs/auth-refactor.md", + ); + }); +}); + +describe("isPrdScopedPath", () => { + test("router itself is in scope", () => { + expect(isPrdScopedPath("/proj/.claude/apex/prd.json", root, homeSeg)).toBe(true); + }); + test("relative path resolved against root is in scope", () => { + expect(isPrdScopedPath(".claude/apex/prd/agents/backend-expert-prd.json", root, homeSeg)).toBe(true); + }); + test("a file outside apex/prd is out of scope", () => { + expect(isPrdScopedPath("/proj/src/index.ts", root, homeSeg)).toBe(false); + }); + test("apex dir itself (not prd/) is out of scope", () => { + expect(isPrdScopedPath("/proj/.claude/apex/other.json", root, homeSeg)).toBe(false); + }); + test("a .. escape out of the prd dir is rejected", () => { + expect(isPrdScopedPath("/proj/.claude/apex/prd/../../../etc/passwd", root, homeSeg)).toBe(false); + }); +}); + +describe("classifyPrdPath", () => { + const router: PrdRouter = { + "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "assigned" }, + }; + + test("router path", () => { + expect(classifyPrdPath("/proj/.claude/apex/prd.json", root, homeSeg, router)).toEqual({ kind: "router" }); + }); + test("task path matched via router", () => { + expect(classifyPrdPath("/proj/.claude/apex/prd/auth-refactor-prd.json", root, homeSeg, router)).toEqual({ + kind: "task", + task: "auth-refactor", + }); + }); + test("agentReport path", () => { + expect( + classifyPrdPath("/proj/.claude/apex/prd/agents/backend-expert-prd.json", root, homeSeg, router), + ).toEqual({ kind: "agentReport", agent: "backend-expert" }); + }); + test("docs path", () => { + expect(classifyPrdPath("/proj/.claude/apex/prd/docs/auth-refactor.md", root, homeSeg, router)).toEqual({ + kind: "docs", + task: "auth-refactor", + }); + }); + test("a task path not matched by a null router falls back to other", () => { + expect(classifyPrdPath("/proj/.claude/apex/prd/auth-refactor-prd.json", root, homeSeg, null)).toEqual({ + kind: "other", + }); + }); + test("a stray file directly under prd/ not in the router is other", () => { + expect(classifyPrdPath("/proj/.claude/apex/prd/stray.json", root, homeSeg, router)).toEqual({ kind: "other" }); + }); + test("out-of-scope path returns null", () => { + expect(classifyPrdPath("/proj/src/index.ts", root, homeSeg, router)).toBeNull(); + }); +}); diff --git a/test/policy/prd/prd-schema.test.ts b/test/policy/prd/prd-schema.test.ts new file mode 100644 index 0000000..bac5356 --- /dev/null +++ b/test/policy/prd/prd-schema.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { + filesOf, isCompacted, parseAgentReportFile, parseRouter, parseTaskFile, subTasksOf, + validateTaskFileInvariant, withRouterStatus, withSubTaskValidated, +} from "../../../src/policy/prd/prd-schema"; +import type { + PrdAgentEntryCompacted, PrdAgentEntryExpanded, PrdRouter, PrdTaskFile, +} from "../../../src/policy/prd/interfaces/types"; + +describe("parseRouter", () => { + test("valid router", () => { + const raw = { "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "assigned" } }; + expect(parseRouter(raw)).toEqual(raw as PrdRouter); + }); + test("compacted (same shape, extra validated-at)", () => { + const raw = { + "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "validated", "validated-at": "2026-01-01T00:00:00.000Z" }, + }; + expect(parseRouter(raw)).toEqual(raw as PrdRouter); + }); + test("invalid JSON shape -> null", () => { + expect(parseRouter("not an object")).toBeNull(); + expect(parseRouter(null)).toBeNull(); + expect(parseRouter(42)).toBeNull(); + }); + test("missing required field -> null", () => { + expect(parseRouter({ "auth-refactor": { status: "assigned" } })).toBeNull(); + expect(parseRouter({ "auth-refactor": { prd: "x.json" } })).toBeNull(); + }); + test("bad status enum -> null", () => { + expect(parseRouter({ "auth-refactor": { prd: "x.json", status: "bogus" } })).toBeNull(); + }); +}); + +describe("parseTaskFile", () => { + test("valid expanded task file", () => { + const raw = { + "backend-expert": { files: ["a.ts"], "sub-tasks": { jwt: { status: "assigned" } } }, + }; + expect(parseTaskFile(raw)).toEqual(raw as PrdTaskFile); + }); + test("valid compacted task file", () => { + const raw = { + "backend-expert": { status: "validated", files: ["a.ts"], "validated-at": "2026-01-01T00:00:00.000Z" }, + }; + expect(parseTaskFile(raw)).toEqual(raw as PrdTaskFile); + }); + test("invalid JSON -> null", () => { + expect(parseTaskFile([])).toBeNull(); + expect(parseTaskFile(null)).toBeNull(); + }); + test("missing fields -> null", () => { + expect(parseTaskFile({ "backend-expert": { files: ["a.ts"] } })).toBeNull(); + expect(parseTaskFile({ "backend-expert": { "sub-tasks": {} } })).toBeNull(); + }); +}); + +describe("parseAgentReportFile", () => { + test("valid report", () => { + const raw = { "auth-refactor": { jwt: { status: "done", modified: ["a.ts"], unchanged: [] } } }; + expect(parseAgentReportFile(raw)).toEqual(raw as ReturnType); + }); + test("invalid JSON -> null", () => { + expect(parseAgentReportFile("nope")).toBeNull(); + }); + test("missing fields -> null", () => { + expect(parseAgentReportFile({ "auth-refactor": { jwt: { status: "done" } } })).toBeNull(); + }); +}); + +describe("isCompacted / subTasksOf / filesOf", () => { + const expanded: PrdAgentEntryExpanded = { files: ["a.ts"], "sub-tasks": { jwt: { status: "assigned" } } }; + const compacted: PrdAgentEntryCompacted = { status: "validated", files: ["a.ts"], "validated-at": "t" }; + + test("isCompacted distinguishes the two shapes", () => { + expect(isCompacted(expanded)).toBe(false); + expect(isCompacted(compacted)).toBe(true); + }); + test("subTasksOf returns {} for compacted, the map for expanded", () => { + expect(subTasksOf(expanded)).toEqual({ jwt: { status: "assigned" } }); + expect(subTasksOf(compacted)).toEqual({}); + }); + test("filesOf returns files for both shapes", () => { + expect(filesOf(expanded)).toEqual(["a.ts"]); + expect(filesOf(compacted)).toEqual(["a.ts"]); + }); +}); + +describe("validateTaskFileInvariant", () => { + test("errors when fewer than 2 agents", () => { + const oneAgent: PrdTaskFile = { a: { files: [], "sub-tasks": {} } }; + expect(validateTaskFileInvariant(oneAgent).length).toBeGreaterThan(0); + }); + test("no errors with 2+ agents", () => { + const twoAgents: PrdTaskFile = { + a: { files: [], "sub-tasks": {} }, + b: { files: [], "sub-tasks": {} }, + }; + expect(validateTaskFileInvariant(twoAgents)).toEqual([]); + }); +}); + +describe("withRouterStatus / withSubTaskValidated (immutable builders)", () => { + test("withRouterStatus updates status and validated-at without mutating input", () => { + const router: PrdRouter = { t: { prd: "prd/t-prd.json", status: "assigned" } }; + const next = withRouterStatus(router, "t", "validated", "2026-01-01T00:00:00.000Z"); + expect(next.t).toEqual({ prd: "prd/t-prd.json", status: "validated", "validated-at": "2026-01-01T00:00:00.000Z" }); + expect(router.t?.status).toBe("assigned"); + }); + test("withSubTaskValidated flips one sub-task without mutating input", () => { + const taskFile: PrdTaskFile = { a: { files: [], "sub-tasks": { jwt: { status: "assigned" } } } }; + const next = withSubTaskValidated(taskFile, "a", "jwt", "2026-01-01T00:00:00.000Z"); + const nextEntry = next.a as PrdAgentEntryExpanded; + expect(nextEntry["sub-tasks"].jwt).toEqual({ status: "validated", "validated-at": "2026-01-01T00:00:00.000Z" }); + const prevEntry = taskFile.a as PrdAgentEntryExpanded; + expect(prevEntry["sub-tasks"].jwt?.status).toBe("assigned"); + }); +}); diff --git a/test/runtime/prd/env.ts b/test/runtime/prd/env.ts new file mode 100644 index 0000000..1dfc4bf --- /dev/null +++ b/test/runtime/prd/env.ts @@ -0,0 +1,76 @@ +/** + * @module test/runtime/prd/env + * Shared temp-HOME/temp-cwd isolation for the Lot B runtime PRD tests — never + * the real `~/.fuse-harness` (CLAUDE.md hard stop). `createPrdEnv`'s `root` + * doubles as `HOME`, so every track file this module's functions read/write + * lands under `/.fuse-harness/state/...`, inside the disposable tmp + * tree `cleanup()` removes. + */ +import { existsSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultStateDir, trackFile } from "../../../src/runtime/paths"; +import { prdRouterPath, prdTaskPath } from "../../../src/policy/prd"; +import { createPrdEnv, type PrdEnv } from "../../helpers/prd-env"; + +/** + * Rewrites the fixture task-PRD so BOTH agents' sole sub-task is `validated` + * — `backend-expert`'s own report already says `done` for `jwt-validation` + * (no violation there), but `backend-expert-2` has no report at all, so this + * always yields exactly ONE genuine cross-check violation. Shared by the + * PostToolUse and lead-Stop gate tests (B2/B5), which both need a REAL + * violation on disk, not a hand-rolled one that could silently drift from + * what `crossCheckTask` actually checks. + */ +export function seedCrossCheckViolation(root: string, homeSeg: string): void { + writeFileSync(prdTaskPath(root, homeSeg, "prd/auth-refactor-prd.json"), JSON.stringify({ + "backend-expert": { files: ["src/auth/login.ts"], "sub-tasks": { "jwt-validation": { status: "validated", "validated-at": "t0" } } }, + "backend-expert-2": { files: ["src/auth/session.ts"], "sub-tasks": { "session-store": { status: "validated", "validated-at": "t0" } } }, + })); +} + +/** + * Workaround for a cross-lot fixture bug (reported to the coordinator, not + * fixed here — `test/helpers/prd-env.ts` is Lot D's exclusive file): + * `materializePrdFixtures` copies the router fixture to `/router.json`, + * but the actual contract (`prd-paths.ts#prdRouterPath`, design doc §0) reads + * it from `/prd.json`. Without this rename, `isPrdEnabled` never sees + * the router as present and every PRD test using this fixture set would be a + * false negative (module reads as permanently disabled). + */ +function fixupRouterFilename(root: string, homeSeg: string): void { + const wrongPath = join(root, homeSeg, "apex", "router.json"); + if (existsSync(wrongPath)) renameSync(wrongPath, prdRouterPath(root, homeSeg)); +} + +/** One isolated PRD test environment: populated project root, temp HOME, and its session track path. */ +export interface PrdTestEnv extends PrdEnv { + sessionId: string; + trackFilePath: string; + /** Restores `process.env.HOME` and removes the tmp tree. */ + restore: () => void; +} + +/** + * Materializes the PRD fixtures under a fresh tmp project, pins `HOME` to + * that SAME root for the duration (restored by `restore()`), and precomputes + * this env's session track path. + * @param homeSeg - Home-dir segment to materialize under (default `.claude`). + * @param sessionId - Session id for the track file (default `"s1"`). + */ +export function setupPrdEnv(homeSeg = ".claude", sessionId = "s1"): PrdTestEnv { + const env = createPrdEnv(homeSeg); + fixupRouterFilename(env.root, env.homeSeg); + const prevHome = process.env.HOME; + process.env.HOME = env.root; + const trackFilePath = trackFile(sessionId, defaultStateDir(env.root)); + return { + ...env, + sessionId, + trackFilePath, + restore: () => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + env.cleanup(); + }, + }; +} diff --git a/test/runtime/prd/prd-post-check.test.ts b/test/runtime/prd/prd-post-check.test.ts new file mode 100644 index 0000000..be506f7 --- /dev/null +++ b/test/runtime/prd/prd-post-check.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { prdPostCheck } from "../../../src/runtime/prd/prd-post-check"; +import { normalizeEvent } from "../../../src/runtime/normalize"; +import { loadTrack } from "../../../src/tracking/store"; +import { prdTaskPath } from "../../../src/policy/prd"; +import { seedCrossCheckViolation, setupPrdEnv } from "./env"; + +const NOW = 1_700_000_000_000; + +function taskWriteEvent(sessionId: string, absPath: string) { + return normalizeEvent("claude-code", { hook_event_name: "PostToolUse", tool_name: "Write", session_id: sessionId, tool_input: { file_path: absPath, content: "{}" } }); +} + +describe("B2 — PostToolUse cross-check", () => { + test("a validated sub-task without a matching done report is journaled once; a replay never duplicates it", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + seedCrossCheckViolation(env.root, env.homeSeg); + const target = prdTaskPath(env.root, env.homeSeg, "prd/auth-refactor-prd.json"); + const event = taskWriteEvent(env.sessionId, target); + + await prdPostCheck("claude-code", event, env.root, env.trackFilePath, NOW); + const first = await loadTrack(env.trackFilePath); + // backend-expert's report already has jwt-validation "done" (fixture) — no violation there; + // backend-expert-2 has no report at all — exactly one violation. + expect(first.prdViolations).toEqual([{ ts: NOW, task: "auth-refactor", agent: "backend-expert-2", sub: "session-store", reason: "validated without a matching done report" }]); + + await prdPostCheck("claude-code", event, env.root, env.trackFilePath, NOW + 1000); // replay + const second = await loadTrack(env.trackFilePath); + expect(second.prdViolations?.length).toBe(1); // deduped, not doubled + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("returns void — never stdout — and is inert when the flag is absent", async () => { + const env = setupPrdEnv(); + try { + delete process.env.FUSE_PRD; + seedCrossCheckViolation(env.root, env.homeSeg); + const target = prdTaskPath(env.root, env.homeSeg, "prd/auth-refactor-prd.json"); + const result = await prdPostCheck("claude-code", taskWriteEvent(env.sessionId, target), env.root, env.trackFilePath, NOW); + expect(result).toBeUndefined(); + const track = await loadTrack(env.trackFilePath); + expect(track.prdViolations ?? []).toEqual([]); // inert: no journal write at all + } finally { + env.restore(); + } + }); + + test("a write outside task/router paths (e.g. an agent's own report) never triggers the cross-check", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + seedCrossCheckViolation(env.root, env.homeSeg); + const agentReport = `${env.root}/${env.homeSeg}/apex/prd/agents/backend-expert-prd.json`; + await prdPostCheck("claude-code", taskWriteEvent(env.sessionId, agentReport), env.root, env.trackFilePath, NOW); + const track = await loadTrack(env.trackFilePath); + expect(track.prdViolations ?? []).toEqual([]); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); diff --git a/test/runtime/prd/prd-pre-gate-helpers.ts b/test/runtime/prd/prd-pre-gate-helpers.ts new file mode 100644 index 0000000..5eb891f --- /dev/null +++ b/test/runtime/prd/prd-pre-gate-helpers.ts @@ -0,0 +1,30 @@ +/** Shared helpers for the prd-pre-gate test files (split for the SOLID line cap). */ +import { normalizeEvent } from "../../../src/runtime/normalize"; +import type { NormalizedEvent } from "../../../src/runtime/normalize"; + +/** Extracts `permissionDecisionReason` from a rendered claude-code/codex deny stdout. */ +export function denyReason(stdout: string): string { + return (JSON.parse(stdout) as { hookSpecificOutput?: { permissionDecisionReason?: string } }).hookSpecificOutput?.permissionDecisionReason ?? ""; +} + +/** Builds a claude-code Write event targeting `absPath`, with optional identity fields. */ +export function writeEvent(sessionId: string, absPath: string, agentId?: string, agentType?: string): NormalizedEvent { + return normalizeEvent("claude-code", { + hook_event_name: "PreToolUse", tool_name: "Write", session_id: sessionId, + tool_input: { file_path: absPath, content: "{}" }, + ...(agentId ? { agent_id: agentId } : {}), ...(agentType ? { agent_type: agentType } : {}), + }); +} + +/** Builds a Bash PreToolUse event on `harness`, with optional identity fields. */ +export function bashEvent(harness: string, sessionId: string, command: string, agentId?: string, agentType?: string): NormalizedEvent { + return normalizeEvent(harness, { + hook_event_name: "PreToolUse", tool_name: "Bash", session_id: sessionId, tool_input: { command }, + ...(agentId ? { agent_id: agentId } : {}), ...(agentType ? { agent_type: agentType } : {}), + }); +} + +/** True when a rendered stdout is a hard `"deny"` permission decision. */ +export function isDenied(stdout: string): boolean { + return stdout.includes("\"deny\""); +} diff --git a/test/runtime/prd/prd-pre-gate-matrix.test.ts b/test/runtime/prd/prd-pre-gate-matrix.test.ts new file mode 100644 index 0000000..645cf70 --- /dev/null +++ b/test/runtime/prd/prd-pre-gate-matrix.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { writeFileSync } from "node:fs"; +import { prdPreGate } from "../../../src/runtime/prd/prd-pre-gate"; +import { normalizeEvent } from "../../../src/runtime/normalize"; +import { prdAgentReportPath, prdDocsPath, prdRouterPath, prdTaskPath } from "../../../src/policy/prd"; +import { loadTrack } from "../../../src/tracking/store"; +import { setupPrdEnv } from "./env"; +import { denyReason, writeEvent } from "./prd-pre-gate-helpers"; + +const NOW = 1_700_000_000_000; + +describe("B1 — coordinator-only paths, apply_patch envelope, Bash, Cursor, edge cases", () => { + test("lead writes router/task/docs: allowed WITH the flag; the SAME write is denied WITHOUT it (witness)", async () => { + const env = setupPrdEnv(); + try { + const targets = [prdRouterPath(env.root, env.homeSeg), prdTaskPath(env.root, env.homeSeg, "prd/auth-refactor-prd.json"), prdDocsPath(env.root, env.homeSeg, "auth-refactor")]; + for (const target of targets) { + delete process.env.FUSE_PRD; + expect(await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target), env.root, env.trackFilePath, NOW)).toBeNull(); + + process.env.FUSE_PRD = "1"; + const withFlag = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target), env.root, env.trackFilePath, NOW); + expect(withFlag).not.toBeNull(); + expect(withFlag!.stdout).not.toContain("\"deny\""); + } + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("lead writing an agent's own report file is denied", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert-2"); + const out = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target), env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(denyReason(out!.stdout)).toContain("coordinator"); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("apply_patch envelope with 3 files, one owned by a different agent, denies the WHOLE envelope naming that path", async () => { + const env = setupPrdEnv(".codex"); // codex's homeSeg — must match harnessHomeSegment("codex") + try { + process.env.FUSE_PRD = "1"; + const own = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const foreign = prdAgentReportPath(env.root, env.homeSeg, "backend-expert-2"); + const event = normalizeEvent("codex", { + hook_event_name: "PreToolUse", tool_name: "apply_patch", session_id: env.sessionId, agent_id: "a1", agent_type: "backend-expert", + tool_input: { command: `*** Begin Patch\n*** Update File: ${own}\n@@\n+x\n*** Update File: ${foreign}\n@@\n+y\n*** Add File: ${join(env.root, "src", "unrelated.ts")}\n+z\n*** End Patch\n` }, + }); + const out = await prdPreGate("codex", {}, event, env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(denyReason(out!.stdout)).toContain(foreign); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Bash write-redirect under prd/ is denied for everyone (even the lead); a plain `cat` is untouched", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const denyEvent = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Bash", session_id: env.sessionId, tool_input: { command: `echo x > ${target}` } }); + const out = await prdPreGate("claude-code", {}, denyEvent, env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(out!.stdout).toContain("Bash"); + + const catEvent = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Bash", session_id: env.sessionId, tool_input: { command: `cat ${target}` } }); + expect(await prdPreGate("claude-code", {}, catEvent, env.root, env.trackFilePath, NOW)).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Cursor sub-agent write, no identity fields on the event: advisory — allowed, never blocked", async () => { + const env = setupPrdEnv(".cursor"); // cursor's homeSeg — must match harnessHomeSegment("cursor") + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const event = normalizeEvent("cursor", { hook_event_name: "preToolUse", session_id: env.sessionId, tool_name: "Write", tool_input: { file_path: target, content: "{}" } }); + const out = await prdPreGate("cursor", {}, event, env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(out!.stdout).not.toContain("\"deny\""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("agent_id present without agent_type: denied (fail-closed, distinct from Cursor/Kimi's advisory)", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const event = writeEvent(env.sessionId, target, "a1"); // agent_id, no agent_type + const out = await prdPreGate("claude-code", {}, event, env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(denyReason(out!.stdout)).toContain("unidentifiable agent_type"); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("corrupted router denies only in-scope PRD writes; an unrelated file in the same session passes", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + writeFileSync(prdRouterPath(env.root, env.homeSeg), "{ not json"); + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const out = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target, "a1", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(denyReason(out!.stdout)).toContain("malformed JSON"); + + const elsewhere = join(env.root, "src", "elsewhere.ts"); + const passthrough = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, elsewhere), env.root, env.trackFilePath, NOW); + expect(passthrough).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("REGRESSION: apply_patch envelope mixing an allowed PRD write with a genuinely unrelated oversized file never short-circuits past the normal gate chain for that unrelated file", async () => { + const env = setupPrdEnv(".codex"); // codex's homeSeg — must match harnessHomeSegment("codex") + try { + process.env.FUSE_PRD = "1"; + const own = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + // An oversized unrelated file (well over the SOLID file-size default cap, + // 100 lines) bundled in the SAME apply_patch envelope as a legitimate PRD + // write. classifyPrdPath returns null for it (not under apex/prd/ at all) + // — it must never ride the PRD gate's allow bypass; the normal + // applyPatchGate/gate() SOLID check must still see it. + const oversized = join(env.root, "src", "unrelated-oversized.ts"); + const bigLines = Array.from({ length: 400 }, (_, i) => `+const line${i} = ${i};`).join("\n"); + const event = normalizeEvent("codex", { + hook_event_name: "PreToolUse", tool_name: "apply_patch", session_id: env.sessionId, agent_id: "a1", agent_type: "backend-expert", + tool_input: { command: `*** Begin Patch\n*** Update File: ${own}\n@@\n+x\n*** Add File: ${oversized}\n${bigLines}\n*** End Patch\n` }, + }); + const out = await prdPreGate("codex", {}, event, env.root, env.trackFilePath, NOW); + // Must fall through (null) rather than short-circuit-allow the whole + // envelope: the oversized unrelated file was never classified in-scope, + // so the PRD gate must never be the one to wave it through. + expect(out).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("REGRESSION: exact name already bound to a DIFFERENT agentId — a fresh agentId claiming the sole remaining sibling is ALLOWED and journaled; the SAME fresh agentId then trying the taken exact name is DENIED", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const exactTarget = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const siblingTarget = prdAgentReportPath(env.root, env.homeSeg, "backend-expert-2"); + + // a1 claims the exact name first (pre-existing binding this test depends on). + const first = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, exactTarget, "a1", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(first).not.toBeNull(); + expect(first!.stdout).not.toContain("\"deny\""); + + // a2 (fresh agentId, SAME agentType) claims the sole remaining free + // sibling -> allowed, and the bind is journaled. + const second = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, siblingTarget, "a2", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(second).not.toBeNull(); + expect(second!.stdout).not.toContain("\"deny\""); + const track = await loadTrack(env.trackFilePath); + expect(track.prdOwners).toEqual({ a1: "backend-expert", a2: "backend-expert-2" }); + + // a2 then reaching for the exact name (already bound to a1) is denied. + const third = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, exactTarget, "a2", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(third).not.toBeNull(); + expect(denyReason(third!.stdout)).toContain(exactTarget); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); diff --git a/test/runtime/prd/prd-pre-gate.test.ts b/test/runtime/prd/prd-pre-gate.test.ts new file mode 100644 index 0000000..58a4771 --- /dev/null +++ b/test/runtime/prd/prd-pre-gate.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { prdPreGate } from "../../../src/runtime/prd/prd-pre-gate"; +import { resolvePrdIdentity } from "../../../src/runtime/prd/prd-identity"; +import { normalizeEvent } from "../../../src/runtime/normalize"; +import { handleHook } from "../../../src/runtime/handle"; +import { loadTrack } from "../../../src/tracking/store"; +import { prdAgentReportPath, prdRouterPath } from "../../../src/policy/prd"; +import { setupPrdEnv } from "./env"; +import { bashEvent, denyReason, isDenied, writeEvent } from "./prd-pre-gate-helpers"; + +const NOW = 1_700_000_000_000; + +describe("B0 — inertia", () => { + test("flag absent, router present: prdPreGate is inert; handleHook falls through to protectedPathGuard's SAME deny", async () => { + const env = setupPrdEnv(); + try { + delete process.env.FUSE_PRD; + const filePath = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const event = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", session_id: env.sessionId, tool_input: { file_path: filePath, content: "{}" } }); + expect(await prdPreGate("claude-code", {}, event, env.root, env.trackFilePath, NOW)).toBeNull(); + const out = await handleHook("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", session_id: env.sessionId, tool_input: { file_path: filePath, content: "{}" } }, { now: NOW, cwd: env.root }); + expect(denyReason(out.stdout)).toContain("internal/generated enforcement state"); // protectedPathGuard's own message, untouched by PRD + expect(denyReason(out.stdout)).not.toContain("PRD"); + } finally { + env.restore(); + } + }); + + test("flag=1, router absent: same inert fallthrough", async () => { + const env = setupPrdEnv(); + rmSync(prdRouterPath(env.root, env.homeSeg)); + try { + process.env.FUSE_PRD = "1"; + const filePath = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const event = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", session_id: env.sessionId, tool_input: { file_path: filePath, content: "{}" } }); + expect(await prdPreGate("claude-code", {}, event, env.root, env.trackFilePath, NOW)).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +describe("B1 — identity resolution + first-write binding", () => { + test("real capture: normalizeEvent + resolvePrdIdentity populate agentId/agentType from the authentic Claude sub-agent Write", () => { + const fixture = JSON.parse(readFileSync(join(__dirname, "..", "..", "fixtures", "prd", "authentic", "claude", "pretooluse-subagent-write.json"), "utf8")) as { stdin: Record }; + const event = normalizeEvent("claude-code", fixture.stdin); + expect(event.agentId).toBe("agent0000000000001"); + expect(event.agentType).toBe("general-purpose"); + expect(resolvePrdIdentity("claude-code", event)).toEqual({ lead: false, agentId: "agent0000000000001", agentType: "general-purpose" }); + }); + + test("first write from a sub-agent binds its own report file; a 2nd distinct filename from the SAME agent is denied", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const out1 = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target, "a1", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(out1).not.toBeNull(); + expect(out1!.stdout).not.toContain("permissionDecision\":\"deny"); + const track = await loadTrack(env.trackFilePath); + expect(track.prdOwners).toEqual({ a1: "backend-expert" }); + + const other = prdAgentReportPath(env.root, env.homeSeg, "backend-expert-2"); + const out2 = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, other, "a1", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(out2).not.toBeNull(); + expect(denyReason(out2!.stdout)).toContain(other); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("a second agent_id trying an ALREADY-bound filename is denied", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target, "a1", "backend-expert"), env.root, env.trackFilePath, NOW); + const out2 = await prdPreGate("claude-code", {}, writeEvent(env.sessionId, target, "a2", "backend-expert"), env.root, env.trackFilePath, NOW); + expect(out2).not.toBeNull(); + expect(denyReason(out2!.stdout)).toContain(target); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +describe("B1c — Bash write-verb detection beyond redirects (cp/mv/tee/sed -i/install/dd of=)", () => { + test("RED->GREEN witness: `cp` (no redirect) to another agent's report on codex is denied", async () => { + const env = setupPrdEnv(".codex"); + try { + process.env.FUSE_PRD = "1"; + const foreign = prdAgentReportPath(env.root, env.homeSeg, "backend-expert-2"); + const event = bashEvent("codex", env.sessionId, `cp /etc/hosts ${foreign}`, "a1", "backend-expert"); + const out = await prdPreGate("codex", {}, event, env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(denyReason(out!.stdout)).toContain("never Bash"); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("positive witness per verb (options skipped) + chaining/substitution/quoting all deny the SAME in-scope target", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const t = prdAgentReportPath(env.root, env.homeSeg, "backend-expert-2"); + const commands = [ + `cp -r /etc ${t}`, `cp -a --force /etc/hosts ${t}`, `mv /etc/hosts ${t}`, `tee -a ${t}`, + `sed -i.bak -e s/x/y/ ${t}`, `install -m 644 /etc/hosts ${t}`, `dd if=/etc/hosts of=${t}`, + `echo hi && cp /etc/hosts ${t}`, `echo $(cp /etc/hosts ${t})`, `cp /etc/hosts '${t}'`, `cp /etc/hosts "${t}"`, + // Regression (sniper-found): bash still runs command substitution + // INSIDE double quotes (only single quotes suppress it) — the verb + // must still be found when `$(...)`/backticks are wrapped in `"..."`. + `echo "$(cp /etc/hosts ${t})"`, `echo "text \`cp /etc/hosts ${t}\` more"`, + ]; + for (const command of commands) { + const out = await prdPreGate("claude-code", {}, bashEvent("claude-code", env.sessionId, command), env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(out!.stdout).toContain("Bash"); + } + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Bash write-verb rule is UNCONDITIONAL (not the ownership advisory split): Cursor is denied too", async () => { + const env = setupPrdEnv(".cursor"); + try { + process.env.FUSE_PRD = "1"; + const target = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const event = normalizeEvent("cursor", { hook_event_name: "preToolUse", session_id: env.sessionId, tool_input: { command: `cp /etc/hosts ${target}` } }); + const out = await prdPreGate("cursor", {}, event, env.root, env.trackFilePath, NOW); + expect(out).not.toBeNull(); + expect(isDenied(out!.stdout)).toBe(true); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("non-interference (pinned to pre-extension null) + known false-positive traps never fire", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const inScope = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); // in-scope, but never a WRITE target below + const commands = [ + "cp src/a.ts src/b.ts", "mv build/x build/y", "echo x | tee /tmp/log", "sed -i.bak s/a/b/ package.json", + "echo hi && cp src/a.ts src/b.ts", "echo $(cp src/a.ts src/b.ts)", + `cp ${inScope} /tmp/other.json /out/of/scope/dir`, // multi-source + target dir; a SOURCE is in-scope, the real target isn't + `cat ${inScope} | tee`, // no positional arg: writes to stdout only + `sed s/x/y/ ${inScope}`, // no -i: prints to stdout, never edits in place + "install -m 644 /etc/hosts", // missing dest — "644" ends up last, but never resolves under apex/prd/ + ]; + for (const command of commands) { + const out = await prdPreGate("claude-code", {}, bashEvent("claude-code", env.sessionId, command), env.root, env.trackFilePath, NOW); + expect(out).toBeNull(); // manually A/B-verified identical to the pre-extension code — see PR report + } + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("REGRESSION (challenger): protected-path file in an apply_patch envelope — witness E (alone, denied) then case F (mixed with an OWNED report, never bypassed, still denied end-to-end)", async () => { + const env = setupPrdEnv(".codex"); // literal ".claude/apex/" fragment below is homeSeg-independent (protectedPathGuard is a hardcoded string match, not harnessHomeSegment-aware — the gap this witness proves) + try { + process.env.FUSE_PRD = "1"; + const protectedFile = join(env.root, ".claude", "apex", "tasks.json"); + const soloPayload = { + hook_event_name: "PreToolUse", session_id: env.sessionId, agent_id: "a1", agent_type: "backend-expert", tool_name: "apply_patch", + tool_input: { command: `*** Begin Patch\n*** Add File: ${protectedFile}\n+{}\n*** End Patch\n` }, + }; + const solo = await handleHook("codex", soloPayload, { now: NOW, cwd: env.root }); // witness E: alone, must be denied (else the probe is dead) + expect(denyReason(solo.stdout)).toContain("internal/generated enforcement state"); + + const own = prdAgentReportPath(env.root, env.homeSeg, "backend-expert"); + const mixedPayload = { + hook_event_name: "PreToolUse", session_id: env.sessionId, agent_id: "a1", agent_type: "backend-expert", tool_name: "apply_patch", + tool_input: { command: `*** Begin Patch\n*** Add File: ${own}\n+x\n*** Add File: ${protectedFile}\n+{}\n*** End Patch\n` }, + }; + const unit = await prdPreGate("codex", {}, normalizeEvent("codex", mixedPayload), env.root, env.trackFilePath, NOW); + expect(unit).toBeNull(); // case F: never short-circuits an allow that would carry the protected file with it + const full = await handleHook("codex", mixedPayload, { now: NOW, cwd: env.root }); + expect(denyReason(full.stdout)).toContain("internal/generated enforcement state"); // end-to-end: the normal chain still denies it + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); diff --git a/test/runtime/prd/prd-stop-gate.test.ts b/test/runtime/prd/prd-stop-gate.test.ts new file mode 100644 index 0000000..4d6de5c --- /dev/null +++ b/test/runtime/prd/prd-stop-gate.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, writeFileSync } from "node:fs"; +import { prdStopGate } from "../../../src/runtime/prd/prd-stop-gate"; +import { prdPostCheck } from "../../../src/runtime/prd/prd-post-check"; +import { normalizeEvent } from "../../../src/runtime/normalize"; +import { prdRouterPath, prdTaskPath } from "../../../src/policy/prd"; +import { withTrack } from "../../../src/tracking/store"; +import { recordPrdOwner } from "../../../src/tracking/session-state"; +import { journalLogPath, readTrackSync } from "../../../src/tracking/track-compact"; +import { seedCrossCheckViolation, setupPrdEnv } from "./env"; + +const NOW = 1_700_000_000_000; + +/** Populates `track.prdViolations` the SAME way production does: run the real PostToolUse cross-check over a genuinely seeded violation. */ +async function seedTrackViolation(id: string, root: string, homeSeg: string, sessionId: string, trackFilePath: string): Promise { + seedCrossCheckViolation(root, homeSeg); + const target = prdTaskPath(root, homeSeg, "prd/auth-refactor-prd.json"); + const event = normalizeEvent(id, { hook_event_name: "PostToolUse", tool_name: "Write", session_id: sessionId, tool_input: { file_path: target, content: "{}" } }); + await prdPostCheck(id, event, root, trackFilePath, NOW); +} + +describe("B5 — lead Stop block-once gate", () => { + test("track.prdViolations non-empty (PostToolUse already found one): blocks once, a replay lets it pass", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + await seedTrackViolation("claude-code", env.root, env.homeSeg, env.sessionId, env.trackFilePath); + const payload = { session_id: env.sessionId }; + const first = prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW); + expect(JSON.parse(first)).toMatchObject({ decision: "block" }); + + const replay = prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("router entry marked validated whose task-PRD isn't actually fully validated: also blocks (the OTHER half of the design's OR)", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + writeFileSync(prdRouterPath(env.root, env.homeSeg), JSON.stringify({ "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "validated" } })); + const out = prdStopGate({ session_id: env.sessionId }, env.root, "claude-code", env.trackFilePath, NOW); + expect(JSON.parse(out)).toMatchObject({ decision: "block" }); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Codex (scope core routes Stop -> stopCore) gets the same block shape", async () => { + const env = setupPrdEnv(".codex"); + try { + process.env.FUSE_PRD = "1"; + await seedTrackViolation("codex", env.root, env.homeSeg, env.sessionId, env.trackFilePath); + const out = prdStopGate({ session_id: env.sessionId }, env.root, "codex", env.trackFilePath, NOW); + expect(JSON.parse(out)).toMatchObject({ decision: "block" }); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Kimi gets its own Stop-blocking shape (Kimi's documented blocking event set includes Stop)", async () => { + const env = setupPrdEnv(".kimi-code"); + try { + process.env.FUSE_PRD = "1"; + await seedTrackViolation("kimi", env.root, env.homeSeg, env.sessionId, env.trackFilePath); + const out = prdStopGate({ session_id: env.sessionId }, env.root, "kimi", env.trackFilePath, NOW); + expect(JSON.parse(out)).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } }); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Cursor's `stop` is a terminal observation event, never gated", async () => { + const env = setupPrdEnv(".cursor"); + try { + process.env.FUSE_PRD = "1"; + await seedTrackViolation("cursor", env.root, env.homeSeg, env.sessionId, env.trackFilePath); + expect(prdStopGate({ session_id: env.sessionId }, env.root, "cursor", env.trackFilePath, NOW)).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("no violation: passes without a block", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; // fixture is un-mutated: sub-tasks are "assigned", never "validated" + expect(prdStopGate({ session_id: env.sessionId }, env.root, "claude-code", env.trackFilePath, NOW)).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("flag absent: never blocked even with a real violation on disk", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + await seedTrackViolation("claude-code", env.root, env.homeSeg, env.sessionId, env.trackFilePath); + delete process.env.FUSE_PRD; + expect(prdStopGate({ session_id: env.sessionId }, env.root, "claude-code", env.trackFilePath, NOW)).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +describe("B5 defect fix — block-once under FUSE_TRACK_JOURNAL=0 (legacy RMW)", () => { + test("with the journal kill-switch off, the SAME unresolved violation still blocks the lead's Stop ONCE then falls silent on replay (was: block on every call)", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + process.env.FUSE_TRACK_JOURNAL = "0"; + writeFileSync(prdRouterPath(env.root, env.homeSeg), JSON.stringify({ "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "validated" } })); + const payload = { session_id: env.sessionId }; + const first = prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW); + expect(JSON.parse(first)).toMatchObject({ decision: "block" }); + expect(prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 1000)).toBe(""); + expect(prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 2000)).toBe(""); + } finally { + delete process.env.FUSE_PRD; + delete process.env.FUSE_TRACK_JOURNAL; + env.restore(); + } + }); + + test("an OLD session (legacy snapshot on disk, its .log NEVER created) isn't crashed or double-blocked; pre-existing fields survive the marker write", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_TRACK_JOURNAL = "0"; + await withTrack(env.trackFilePath, (t) => recordPrdOwner(t, "a1", "backend-expert-2")); + expect(existsSync(journalLogPath(env.trackFilePath))).toBe(false); // genuinely no .log — the "old session" shape + + process.env.FUSE_PRD = "1"; + writeFileSync(prdRouterPath(env.root, env.homeSeg), JSON.stringify({ "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "validated" } })); + const payload = { session_id: env.sessionId }; + expect(JSON.parse(prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW))).toMatchObject({ decision: "block" }); + expect(existsSync(journalLogPath(env.trackFilePath))).toBe(false); // legacy branch writes the snapshot only + expect(prdStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 1000)).toBe(""); + expect(readTrackSync(env.trackFilePath, false).prdOwners).toMatchObject({ a1: "backend-expert-2" }); + } finally { + delete process.env.FUSE_PRD; + delete process.env.FUSE_TRACK_JOURNAL; + env.restore(); + } + }); +}); + +describe("B5 defect fix — native block shape for gemini-cli/cline (was: Claude-shaped {decision:\"block\"} neither honors)", () => { + test.each([ + ["gemini-cli", ".gemini", { decision: "deny" }], + ["cline", ".clinerules", { cancel: true }], + ] as const)("%s gets its native block shape, one-shot preserved", (id, homeSeg, shape) => { + const env = setupPrdEnv(homeSeg); + try { + process.env.FUSE_PRD = "1"; + writeFileSync(prdRouterPath(env.root, env.homeSeg), JSON.stringify({ "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "validated" } })); + const payload = { session_id: env.sessionId }; + const first = prdStopGate(payload, env.root, id, env.trackFilePath, NOW); + expect(JSON.parse(first)).toMatchObject(shape); + expect(prdStopGate(payload, env.root, id, env.trackFilePath, NOW + 1000)).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +describe("B5 non-regression — byte-identical output for the 5 protected targets (pinned, captured BEFORE the respond() routing fix)", () => { + const PINNED = "{\"decision\":\"block\",\"reason\":\"PRD cross-check found unresolved violation(s): a task/sub-task is marked validated without a matching done report. Run `harness prd status` for details.\"}"; + + test.each([ + ["claude-code", ".claude"], + ["codex", ".codex"], + ["hermes", ".hermes"], + ])("%s output stays byte-identical", (id, homeSeg) => { + const env = setupPrdEnv(homeSeg); + try { + process.env.FUSE_PRD = "1"; + writeFileSync(prdRouterPath(env.root, env.homeSeg), JSON.stringify({ "auth-refactor": { prd: "prd/auth-refactor-prd.json", status: "validated" } })); + const out = prdStopGate({ session_id: env.sessionId }, env.root, id, env.trackFilePath, NOW); + expect(out).toBe(PINNED); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); diff --git a/test/runtime/prd/prd-subagent-context.test.ts b/test/runtime/prd/prd-subagent-context.test.ts new file mode 100644 index 0000000..94bc6e9 --- /dev/null +++ b/test/runtime/prd/prd-subagent-context.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { prdSubagentContext } from "../../../src/runtime/prd/prd-subagent-context"; +import { additionalContextOf } from "../../../src/runtime/post-outcome"; +import { joinContextResponses } from "../../../src/policy/prd"; +import { setupPrdEnv } from "./env"; + +/** Exact pre-`agentSlices` output for the unambiguous case (captured live, single candidate) — pinned so the switch to `agentSlices` provably never touches this path. */ +const UNAMBIGUOUS_BYTES = "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"## PRD assignment — task auth-refactor\\nYour files: src/auth/session.ts\\nYour sub-tasks: session-store\\nReport to prd/agents/backend-expert-2-prd.json when done.\\n\\n### Rules\\n1. Write ONLY the files listed in your slice above.\\n2. Report completion to YOUR OWN agent-report file above — never another agent's.\\n3. Do not mark a sub-task done until the work is actually finished; the coordinator validates from your report.\"}}"; + +describe("B3 — SubagentStart PRD slice injection", () => { + test("agent_type=backend-expert-2 (unique candidate): output byte-identical to before the agentSlices switch", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentContext({ agent_type: "backend-expert-2" }, env.root, "claude-code"); + expect(out).toBe(UNAMBIGUOUS_BYTES); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("agent_type=backend-expert (ambiguous, 2 same-type siblings): the injected context now carries the disambiguation header AND both agents' full slices — no longer \"\"", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentContext({ agent_type: "backend-expert" }, env.root, "claude-code"); + const text = additionalContextOf(out); + expect(text).toContain("Several assignments match your agent type"); + expect(text).toContain("You are ONE of: backend-expert, backend-expert-2"); + // BOTH agents' own slices are present, not just one. + expect(text).toContain("prd/agents/backend-expert-prd.json"); + expect(text).toContain("prd/agents/backend-expert-2-prd.json"); + expect(text).toContain("src/auth/login.ts"); + expect(text).toContain("src/auth/session.ts"); + expect(text).toContain("jwt-validation"); + expect(text).toContain("session-store"); + expect(text.match(/^\d+\.\s/gm)?.length).toBe(3); // still exactly 3 numbered rules (once, not per-agent) + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("an unnamed agent gets \"\"", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + expect(prdSubagentContext({}, env.root, "claude-code")).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("flag absent: \"\" regardless of agent_type", () => { + const env = setupPrdEnv(); + try { + delete process.env.FUSE_PRD; + expect(prdSubagentContext({ agent_type: "backend-expert-2" }, env.root, "claude-code")).toBe(""); + } finally { + env.restore(); + } + }); +}); + +describe("B3 defect fix — native context shape for gemini-cli/cline (was: Claude-shaped hookSpecificOutput.additionalContext neither honors identically)", () => { + test("cline gets its native contextModification shape, not the Claude hookSpecificOutput envelope", () => { + const env = setupPrdEnv(".clinerules"); + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentContext({ agent_type: "backend-expert-2" }, env.root, "cline"); + const parsed = JSON.parse(out) as Record; + expect(parsed).toHaveProperty("contextModification"); + expect(parsed).not.toHaveProperty("hookSpecificOutput"); + expect(String(parsed.contextModification)).toContain("session-store"); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("gemini-cli gets a hookSpecificOutput.additionalContext with NO hookEventName field (its native shape)", () => { + const env = setupPrdEnv(".gemini"); + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentContext({ agent_type: "backend-expert-2" }, env.root, "gemini-cli"); + const parsed = JSON.parse(out) as { hookSpecificOutput?: Record }; + expect(parsed.hookSpecificOutput).toBeDefined(); + expect(parsed.hookSpecificOutput).not.toHaveProperty("hookEventName"); + expect(String(parsed.hookSpecificOutput?.additionalContext)).toContain("session-store"); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +describe("B3 non-regression — byte-identical output for the 5 protected targets (pinned, captured BEFORE the respond() routing fix)", () => { + test.each([ + ["claude-code", ".claude"], + ["codex", ".codex"], + ["hermes", ".hermes"], + ["cursor", ".cursor"], + ["kimi", ".kimi-code"], + ])("%s output stays byte-identical", (id, homeSeg) => { + const env = setupPrdEnv(homeSeg); + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentContext({ agent_type: "backend-expert-2" }, env.root, id); + expect(out).toBe(UNAMBIGUOUS_BYTES); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +// RED (measured defect, this exact composition is `dispatch.ts`'s SubagentStart +// branch: `joinContextResponses(subagentCacheContext(...), prdSubagentContext(...))`): +// `prdSubagentContext` builds cline's slice correctly (proven above), but before +// the `joinContextResponses` fix, merging it silently dropped to "" — cline never +// receives its PRD tranche. `cacheContext` is "" here (the common no-MCP-cache-yet +// case measured live: 0 bytes on real SubagentStart for cline). +describe("B3 defect — the merge step must not drop cline's slice (real dispatch.ts composition)", () => { + test("cline's slice survives joinContextResponses(cacheContext, prdSlice) — was silently dropped to \"\"", () => { + const env = setupPrdEnv(".clinerules"); + try { + process.env.FUSE_PRD = "1"; + const prdSlice = prdSubagentContext({ agent_type: "backend-expert-2" }, env.root, "cline"); + const merged = joinContextResponses("", prdSlice); + expect(merged).not.toBe(""); + const parsed = JSON.parse(merged) as { contextModification?: string }; + expect(parsed.contextModification).toContain("session-store"); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); diff --git a/test/runtime/prd/prd-subagent-stop.test.ts b/test/runtime/prd/prd-subagent-stop.test.ts new file mode 100644 index 0000000..97288c9 --- /dev/null +++ b/test/runtime/prd/prd-subagent-stop.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { prdSubagentStopGate } from "../../../src/runtime/prd/prd-subagent-stop"; +import { withTrack } from "../../../src/tracking/store"; +import { recordPrdOwner } from "../../../src/tracking/session-state"; +import { journalLogPath, readTrackSync } from "../../../src/tracking/track-compact"; +import { setupPrdEnv } from "./env"; + +const NOW = 1_700_000_000_000; + +describe("B4 — SubagentStop block-once gate", () => { + test("incomplete sub-task (backend-expert-2's session-store, no report at all) blocks once; a replay is \"\" (silent, but still non-null so the caller skips trackAgentMemory)", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + const payload = { session_id: env.sessionId, agent_type: "backend-expert-2" }; + const first = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW); + expect(first).not.toBeNull(); + expect(JSON.parse(first!)).toMatchObject({ decision: "block" }); + expect(first).toContain("session-store"); + + const replay = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); // non-null (caller must NOT fall through to trackAgentMemory), but silent + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("a fully-done agent (backend-expert, jwt-validation already done) is never blocked — null (genuinely done, caller falls through to trackAgentMemory normally), bound via agent_id so its SAME-type sibling's incomplete work is never blamed on it", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + // Without this bind, agent_type "backend-expert" alone would ambiguously + // match BOTH siblings (matchesAgentName's "-n" rule) and find the + // OTHER one's incomplete "session-store" — resolveCandidates narrows to + // the bound name specifically, the fix this test exists to prove. + await withTrack(env.trackFilePath, (t) => recordPrdOwner(t, "a1", "backend-expert")); + const out = prdSubagentStopGate({ session_id: env.sessionId, agent_id: "a1", agent_type: "backend-expert" }, env.root, "claude-code", env.trackFilePath, NOW); + expect(out).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("an unnamed agent is never blocked (null)", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + expect(prdSubagentStopGate({ session_id: env.sessionId }, env.root, "claude-code", env.trackFilePath, NOW)).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Cursor never gets a block, even with a genuinely incomplete sub-task (null)", () => { + const env = setupPrdEnv(".cursor"); // cursor's homeSeg — so the module is genuinely ENABLED, not skipped by accident + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentStopGate({ session_id: env.sessionId, subagent_type: "backend-expert-2" }, env.root, "cursor", env.trackFilePath, NOW); + expect(out).toBeNull(); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("Kimi gets the observation-only Kimi-shaped deny envelope (best-effort per adapters.md), still one-shot", () => { + const env = setupPrdEnv(".kimi-code"); // kimi's homeSeg — must match harnessHomeSegment("kimi") + try { + process.env.FUSE_PRD = "1"; + const first = prdSubagentStopGate({ session_id: env.sessionId, agent_type: "backend-expert-2" }, env.root, "kimi", env.trackFilePath, NOW); + expect(JSON.parse(first!)).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } }); + const replay = prdSubagentStopGate({ session_id: env.sessionId, agent_type: "backend-expert-2" }, env.root, "kimi", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("flag absent: never blocked (null)", () => { + const env = setupPrdEnv(); + try { + delete process.env.FUSE_PRD; + expect(prdSubagentStopGate({ session_id: env.sessionId, agent_type: "backend-expert-2" }, env.root, "claude-code", env.trackFilePath, NOW)).toBeNull(); + } finally { + env.restore(); + } + }); +}); + +describe("B4 defect fix — block-once under FUSE_TRACK_JOURNAL=0 (legacy RMW)", () => { + test("with the journal kill-switch off, the SAME incomplete sub-task still blocks ONCE then falls silent on replay (was: block on every call — the one-shot marker was journal-only, never seen by the legacy snapshot-only read)", () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_PRD = "1"; + process.env.FUSE_TRACK_JOURNAL = "0"; + const payload = { session_id: env.sessionId, agent_type: "backend-expert-2" }; + const first = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW); + expect(first).not.toBeNull(); + expect(JSON.parse(first!)).toMatchObject({ decision: "block" }); + + const replay = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); + + const thirdCall = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 2000); + expect(thirdCall).toBe(""); + } finally { + delete process.env.FUSE_PRD; + delete process.env.FUSE_TRACK_JOURNAL; + env.restore(); + } + }); + + test("an OLD session (legacy snapshot already on disk, its .log NEVER created) is not crashed nor double-blocked by the fix — the legacy branch never touches .log, and pre-existing track fields survive the marker write untouched", async () => { + const env = setupPrdEnv(); + try { + process.env.FUSE_TRACK_JOURNAL = "0"; + await withTrack(env.trackFilePath, (t) => recordPrdOwner(t, "a1", "backend-expert-2")); + expect(existsSync(journalLogPath(env.trackFilePath))).toBe(false); // genuinely no .log — the "old session" shape + + process.env.FUSE_PRD = "1"; + const payload = { session_id: env.sessionId, agent_type: "backend-expert-2" }; + const first = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW); + expect(JSON.parse(first!)).toMatchObject({ decision: "block" }); + expect(existsSync(journalLogPath(env.trackFilePath))).toBe(false); // legacy branch writes the snapshot only + + const replay = prdSubagentStopGate(payload, env.root, "claude-code", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); + + const after = readTrackSync(env.trackFilePath, false); + expect(after.prdOwners).toMatchObject({ a1: "backend-expert-2" }); // pre-existing field survived the RMW + } finally { + delete process.env.FUSE_PRD; + delete process.env.FUSE_TRACK_JOURNAL; + env.restore(); + } + }); +}); + +describe("B4 defect fix — native block shape for gemini-cli/cline (was: Claude-shaped {decision:\"block\"} neither honors)", () => { + test("gemini-cli gets its native deny shape ({decision:\"deny\",reason}), one-shot preserved", () => { + const env = setupPrdEnv(".gemini"); + try { + process.env.FUSE_PRD = "1"; + const payload = { session_id: env.sessionId, agent_type: "backend-expert-2" }; + const first = prdSubagentStopGate(payload, env.root, "gemini-cli", env.trackFilePath, NOW); + expect(JSON.parse(first!)).toMatchObject({ decision: "deny" }); + expect(first).toContain("session-store"); + const replay = prdSubagentStopGate(payload, env.root, "gemini-cli", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); + + test("cline gets its native cancel shape ({cancel:true,errorMessage}), one-shot preserved", () => { + const env = setupPrdEnv(".clinerules"); + try { + process.env.FUSE_PRD = "1"; + const payload = { session_id: env.sessionId, agent_type: "backend-expert-2" }; + const first = prdSubagentStopGate(payload, env.root, "cline", env.trackFilePath, NOW); + expect(JSON.parse(first!)).toMatchObject({ cancel: true }); + expect(first).toContain("session-store"); + const replay = prdSubagentStopGate(payload, env.root, "cline", env.trackFilePath, NOW + 1000); + expect(replay).toBe(""); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); + +describe("B4 non-regression — byte-identical output for the 5 protected targets (pinned, captured BEFORE the respond() routing fix)", () => { + const PINNED = "{\"decision\":\"block\",\"reason\":\"PRD sub-task(s) not done for backend-expert-2 on task \\\"auth-refactor\\\": session-store. Finish the work (or ask the coordinator to reassign) before stopping.\"}"; + + test.each([ + ["claude-code", ".claude"], + ["codex", ".codex"], + ["hermes", ".hermes"], + ])("%s output stays byte-identical", (id, homeSeg) => { + const env = setupPrdEnv(homeSeg); + try { + process.env.FUSE_PRD = "1"; + const out = prdSubagentStopGate({ session_id: env.sessionId, agent_type: "backend-expert-2" }, env.root, id, env.trackFilePath, NOW); + expect(out).toBe(PINNED); + } finally { + delete process.env.FUSE_PRD; + env.restore(); + } + }); +}); diff --git a/test/sim/prd-regression-helpers.ts b/test/sim/prd-regression-helpers.ts new file mode 100644 index 0000000..6a71934 --- /dev/null +++ b/test/sim/prd-regression-helpers.ts @@ -0,0 +1,113 @@ +/** + * @module test/sim/prd-regression-helpers + * Pure/spawn helpers backing `prd-regression.test.ts`, split out to keep that + * file under the repo's file-size ceiling. Owns: per-row scenario replay + * (fresh `mkdtemp` tmp+HOME per row, never shared), the sandbox-escape guard + * for `setup` files (byte-identical twin of run-scenario.ts's own + * `materializeSetup`, kept in lockstep — see its own doc comment), and the + * cross-row output normalizer that neutralizes the two known LEGITIMATE + * sources of per-row difference without hiding anything else: + * - the row's own fresh `$TMP`, embedded verbatim by some scenarios' stdout + * (e.g. `13-lessons-stop-session-scope`'s Stop reminder path); + * - the Codex CONFIRM display code (`CONFIRM <4-hex>`), a hash of + * `{..., cwd}` (`codex-confirm.ts`'s `codexAction`) — it changes with a + * fresh tmp even though the tmp string itself never appears literally, so + * substituting `$TMP` alone cannot neutralize it; scenarios + * `23/26/28/29-codex-*` need this second substitution too. + * Exit codes are never touched by either substitution — they carry no path + * or hash data, so a real exit-code regression still fails loudly. Neither + * substitution depends on, or pins, the literal text any policy guard emits + * (PRD guards included) — both only erase a KNOWN per-run identifier. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { loadScenario, substitute } from "./load"; +import { runHook, spawnEnv } from "./exec"; +import { materializePrdFixtures } from "../helpers/prd-env"; +import { harnessHomeSegment } from "../../src/policy/apex-target"; +import type { SetupFile } from "./types"; + +/** Absolute path to `test/sim/fixtures`, resolved from this file. */ +export const FIXTURES: string = join(import.meta.dir, "fixtures"); + +/** One step's outcome kept for comparison — stdout AND exit, never stdout alone. */ +export interface RowStep { + stdout: string; + exit: number; +} + +/** One full replay: the row's own tmp dir (needed to neutralize it) plus its ordered per-step results. */ +export interface ReplayRow { + tmp: string; + steps: RowStep[]; +} + +// Deliberate local twin of run-scenario.ts's own (non-exported) materializeSetup: +// that file is owned by a parallel lot in this same PR and out of this file's +// scope to modify just to add an export. Byte-identical body (including the +// sandbox-escape guard), kept in lockstep with the original. +/** + * Materialize each declared setup file under `tmp`. Each path is token- + * substituted and containment-checked: it MUST resolve inside `tmp`, so a + * typo'd scenario can never write outside the per-run sandbox (same contract + * as run-scenario.ts's own materializeSetup). + * @throws Error when a resolved path escapes `tmp`. + */ +export function materializeSetupFiles(setup: SetupFile[], tmp: string, vars: Record): void { + for (const raw of setup) { + const f = substitute(raw, vars); + const abs = resolve(f.path); + if (abs !== tmp && !abs.startsWith(tmp + sep)) throw new Error(`setup path escapes $TMP: ${f.path}`); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, f.content); + } +} + +/** + * Replays one scenario end-to-end in a BRAND-NEW `mkdtemp` tmp dir (also used + * as `HOME` — {@link import("./exec").spawnEnv}'s own isolation contract), + * under an env overlay applied on top of the scenario's own `env`, optionally + * pre-seeding the PRD fixtures. Cleans up its own tmp dir before returning. + * @returns The row's tmp dir plus its ordered per-step `{stdout, exit}`. + */ +export async function replayFresh(path: string, envOverlay: Record, seedPrd: boolean): Promise { + const tmp = mkdtempSync(join(tmpdir(), "fh-prdreg-")); + try { + const scenario = loadScenario(path); + const vars = { TMP: tmp, FIXTURES }; + const harness = scenario.harness ?? "claude-code"; + const env = spawnEnv(FIXTURES, tmp, substitute({ ...(scenario.env ?? {}), ...envOverlay }, vars)); + materializeSetupFiles(scenario.setup ?? [], tmp, vars); + if (seedPrd) materializePrdFixtures(tmp, harnessHomeSegment(harness)); + const steps: RowStep[] = []; + for (const rawStep of scenario.steps) { + const step = substitute(rawStep, vars); + if (step.delayMs && step.delayMs > 0) await Bun.sleep(step.delayMs); + const r = runHook(harness, step.scope, step.event, tmp, env); + steps.push({ stdout: r.stdout, exit: r.exit }); + } + return { tmp, steps }; + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +/** `CONFIRM <4-hex-chars>`, case-insensitive — same shape confirm-submit.ts parses (see its own CONFIRM_RE). */ +const CONFIRM_CODE_RE = /CONFIRM [0-9a-f]{4}\b/gi; + +/** + * Neutralize a replay row's own fresh `$TMP` and any Codex CONFIRM display + * code before it is compared to another row. Both are legitimate per-row + * differences (see the module doc comment) — never a signal of a real + * behavior change — so both are substituted with a stable token, and nothing + * else, before the byte-identical comparison in the test file. + * @param row - A {@link ReplayRow} returned by {@link replayFresh}. + * @returns The row's steps with `$TMP`/CONFIRM-code differences erased; `exit` untouched. + */ +export function normalizeRow(row: ReplayRow): RowStep[] { + return row.steps.map((s) => ({ + stdout: s.stdout.split(row.tmp).join("$TMP").replace(CONFIRM_CODE_RE, "CONFIRM $HASH"), + exit: s.exit, + })); +} diff --git a/test/sim/prd-regression.test.ts b/test/sim/prd-regression.test.ts new file mode 100644 index 0000000..f04c037 --- /dev/null +++ b/test/sim/prd-regression.test.ts @@ -0,0 +1,135 @@ +/** + * @module test/sim/prd-regression + * Non-regression matrix (prd-design.md §4): every EXISTING scenario under + * `test/sim/scenarios/*.json` (excluding the new PRD-specific corpus, see + * {@link discoverBaselineScenarios}) is replayed three times per scenario — + * `{}` (flag absent), `{FUSE_PRD:"1"}` with no router on disk, and + * `{FUSE_PRD:"1"}` WITH the PRD fixtures materialized under + * `$TMP//apex/` — asserting `{stdout, exit}` are identical across + * all three AFTER {@link normalizeRow} erases each row's own fresh `$TMP` + * and any Codex CONFIRM display code (the only two legitimate per-row + * differences — see `prd-regression-helpers.ts`'s doc comment). The PRD + * module (`src/runtime/prd/**`, `src/policy/prd/**`) is live: this suite is + * the load-bearing "inert unless FUSE_PRD=1 AND a router is on disk" proof, + * backed by a positive witness (below) showing activation genuinely changes + * behavior — a suite that never activates its own target proves nothing. + * Each of the 3 rows gets its OWN fresh `mkdtemp` tmp dir AND `HOME` (never + * shared, never reused): sharing one tmp/HOME across rows was tried first and + * reverted — it caused an intermittent cross-row flake under full-suite load + * (measured: scenario 13 failed once in an 81s full-suite run, passed 2/2 in + * isolation), most likely a wall-clock-adjacent dedup/throttle window + * (`inject-dedup`, lessons throttle) not fully reset by a directory wipe. + */ +import { describe, expect, test } from "bun:test"; +import { Glob } from "bun"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { substitute } from "./load"; +import { runHook, spawnEnv } from "./exec"; +import { materializePrdFixtures } from "../helpers/prd-env"; +import { harnessHomeSegment } from "../../src/policy/apex-target"; +import { FIXTURES, materializeSetupFiles, normalizeRow, replayFresh, type RowStep } from "./prd-regression-helpers"; +import type { SetupFile } from "./types"; + +const SCENARIOS_DIR = join(import.meta.dir, "scenarios"); + +/** + * Every baseline scenario, excluding the new PRD-specific corpus + * (30-prd-ownership-deny-claude, 30b-prd-ownership-deny-codex, + * 30c-prd-ownership-advisory-cursor, 31-prd-subagentstop-block-once-claude). + * Matched on the literal `-prd-` infix, NOT a numeric "30"/"31" prefix: two + * UNRELATED pre-existing scenarios already occupy those numbers + * (30-codex-config-toml-protected.json, 31-codex-array-command-bypass- + * permissions-allow.json) — a prefix-based filter would silently drop them + * from this non-regression matrix too. + */ +function discoverBaselineScenarios(): string[] { + return [...new Glob("*.json").scanSync({ cwd: SCENARIOS_DIR, absolute: true })] + .filter((p) => !/-prd-/.test(basename(p))) + .sort(); +} + +const baseline = discoverBaselineScenarios(); + +test("baseline corpus is non-empty (guards against a silently-empty regression run)", () => { + expect(baseline.length).toBeGreaterThan(0); +}); + +test("materializeSetupFiles rejects a setup path escaping $TMP", () => { + const tmp = mkdtempSync(join(tmpdir(), "fh-prdreg-guard-")); + try { + const escapee: SetupFile = { path: join(tmpdir(), "fh-prdreg-guard-escape.json"), content: "{}" }; + expect(() => materializeSetupFiles([escapee], tmp, {})).toThrow(/setup path escapes \$TMP/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// Generous per-scenario budget: 3 full replays of a scenario that may itself +// contain a burst-dedup delayMs sleep (scenarios 03/16, ~2.1s each) — mirrors +// simulator.test.ts's own SCENARIO_TIMEOUT_MS rationale, tripled for 3 rows. +const ROW_TIMEOUT_MS = 90_000; + +describe("PRD flag/router non-regression ({stdout, exit} identical off/on-no-router/on-with-router, normalized)", () => { + for (const path of baseline) { + const name = basename(path, ".json"); + test(name, async () => { + const off: RowStep[] = normalizeRow(await replayFresh(path, {}, false)); + const onNoRouter: RowStep[] = normalizeRow(await replayFresh(path, { FUSE_PRD: "1" }, false)); + const onWithRouter: RowStep[] = normalizeRow(await replayFresh(path, { FUSE_PRD: "1" }, true)); + expect(onNoRouter).toEqual(off); + expect(onWithRouter).toEqual(off); + }, ROW_TIMEOUT_MS); + } +}); + +/** + * Positive witness (mandatory companion to the invariance matrix above): a + * suite that only ever proves "nothing changes" while FUSE_PRD is never + * actually exercised proves nothing about the module's ON path. This runs + * ONE PreToolUse event — a sub-agent (`backend-expert`) writing ANOTHER + * agent's own PRD report file, the exact shape `prd-pre-gate.ts` resolves + * ownership from (test/fixtures/prd/README.md §1's worked example) — under + * the same off/on-no-router/on-with-router rows, and asserts ONLY that the + * activated row's output genuinely DIFFERS from the inert rows. It never + * pins any literal text a PRD guard emits (title, reason wording, prompt + * shape): those guards are actively being revised in parallel lots of this + * same PR, so asserting their exact string here would make this witness + * fail on a wording change that is not a regression. Output equality (not + * inequality) still needs `$TMP` erased first (see {@link normalizeRow}'s + * doc comment) — this event's own `tool_input.file_path` embeds the row's + * tmp dir verbatim, so an un-normalized off/onNoRouter compare would flake. + */ +test("FUSE_PRD=1 + router changes PreToolUse output (positive witness — activation is not a no-op)", async () => { + const event = { + hook_event_name: "PreToolUse", + session_id: "sc-witness", + agent_id: "agent-backend-expert-0001", + agent_type: "backend-expert", + tool_name: "Write", + tool_input: { + file_path: "$TMP/.claude/apex/prd/agents/backend-expert-2-prd.json", + content: "{}", + }, + }; + const runOnce = async (envOverlay: Record, seedPrd: boolean): Promise => { + const tmp = mkdtempSync(join(tmpdir(), "fh-prdreg-witness-")); + try { + const vars = { TMP: tmp, FIXTURES }; + const env = spawnEnv(FIXTURES, tmp, substitute(envOverlay, vars)); + if (seedPrd) materializePrdFixtures(tmp, harnessHomeSegment("claude-code")); + const stdout = runHook("claude-code", "core", substitute(event, vars), tmp, env).stdout; + return stdout.split(tmp).join("$TMP"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }; + const off = await runOnce({}, false); + const onNoRouter = await runOnce({ FUSE_PRD: "1" }, false); + const onWithRouter = await runOnce({ FUSE_PRD: "1" }, true); + // Flag alone (router absent) must stay inert — isPrdEnabled requires both. + expect(onNoRouter).toBe(off); + // Router present: the load-bearing proof that activation genuinely changes something. + expect(onWithRouter).not.toBe(off); +}, ROW_TIMEOUT_MS); diff --git a/test/sim/scenarios/30-prd-ownership-deny-claude.json b/test/sim/scenarios/30-prd-ownership-deny-claude.json new file mode 100644 index 0000000..7c9b133 --- /dev/null +++ b/test/sim/scenarios/30-prd-ownership-deny-claude.json @@ -0,0 +1,28 @@ +{ + "name": "prd-ownership-deny-claude", + "harness": "claude-code", + "doc": "TARGET behavior once Lots A/B ship (prd-design.md §1.4/§2.1/§2.8, not implemented yet — Lot D only). A sub-agent identified as backend-expert (agent_type on the event) tries to Write another agent's own PRD report file, prd/agents/backend-expert-2-prd.json. Final expected verdict: PreToolUse deny with prompt.title 'PRD ownership' (renders '[BLOCKED] PRD ownership', §2.8's Prompt). RED TODAY BY DESIGN: verified live 2026-09-02 against `bun src/cli/bin.ts hook claude-code core` with this exact setup — a deny IS emitted today, but for the WRONG reason: `.claude/apex/` is already in protected-path.ts's PROTECTED_FRAGMENTS (prd-design.md §2.0/§6 Risk 3), so protectedPathGuard fires first with '[BLOCKED] Protected path', never reaching any PRD-ownership logic (which does not exist yet). This scenario intentionally asserts the FINAL string, not today's, so it stays red until prdPreGate (§2.1) ships and bypasses protectedPathGuard for an authorized PRD write.", + "env": { "FUSE_PRD": "1" }, + "setup": [ + { "path": "$TMP/.claude/apex/prd.json", "content": "{\"auth-refactor\":{\"prd\":\"prd/auth-refactor-prd.json\",\"status\":\"assigned\"}}" }, + { "path": "$TMP/.claude/apex/prd/auth-refactor-prd.json", "content": "{\"backend-expert\":{\"files\":[\"src/auth/login.ts\"],\"sub-tasks\":{\"jwt-validation\":{\"status\":\"assigned\"}}},\"backend-expert-2\":{\"files\":[\"src/auth/session.ts\"],\"sub-tasks\":{\"session-store\":{\"status\":\"assigned\"}}}}" } + ], + "steps": [ + { + "scope": "core", + "doc": "backend-expert (agent_id+agent_type on the event, per the live Claude sub-agent shape in test/fixtures/prd/authentic/claude/pretooluse-subagent-write.json) attempts to Write backend-expert-2's own report file. Not its file to write (prd-design.md §1.4 table: agentReport / lead:false / name mismatch -> deny).", + "event": { + "hook_event_name": "PreToolUse", + "session_id": "sc30", + "agent_id": "agent-backend-expert-0001", + "agent_type": "backend-expert", + "tool_name": "Write", + "tool_input": { + "file_path": "$TMP/.claude/apex/prd/agents/backend-expert-2-prd.json", + "content": "{\"auth-refactor\":{\"session-store\":{\"status\":\"done\",\"modified\":[\"src/auth/session.ts\"],\"unchanged\":[]}}}" + } + }, + "expect": { "exit": 0, "stdout": { "contains": "PRD ownership" } } + } + ] +} diff --git a/test/sim/scenarios/30b-prd-ownership-deny-codex.json b/test/sim/scenarios/30b-prd-ownership-deny-codex.json new file mode 100644 index 0000000..c2ff217 --- /dev/null +++ b/test/sim/scenarios/30b-prd-ownership-deny-codex.json @@ -0,0 +1,27 @@ +{ + "name": "prd-ownership-deny-codex", + "harness": "codex", + "doc": "Same target behavior as 30-prd-ownership-deny-claude, spawned as `hook codex core` — proves the future PRD ownership guard is harness-shared, not claude-only (prd-design.md §2.1 runs in the core-scope dispatch, same as Claude). Codex's deny envelope is the SAME denyResponse() shape as Claude (adapters/codex/index.ts re-exports it — hookSpecificOutput.permissionDecision), so the assertion form matches scenario 19's codex-shares-guards-deny precedent. RED TODAY BY DESIGN, verified live 2026-09-02: unlike Claude, `.claude/apex/` in protected-path.ts's PROTECTED_FRAGMENTS is a HARDCODED literal, not homeSeg-aware — Codex's own home segment is `.codex` (harnessHomeSegment, apex-target.ts), which that fragment list never mentions. So TODAY a Codex write under `.codex/apex/prd/agents/...` is NOT blocked at all (genuine allow, permission decision absent from stdout) — the true 'no deny emitted' case the PRD design assumed universally, but which only actually holds for codex/cursor/kimi, not claude-code (see 30's own doc note).", + "env": { "FUSE_PRD": "1" }, + "setup": [ + { "path": "$TMP/.codex/apex/prd.json", "content": "{\"auth-refactor\":{\"prd\":\"prd/auth-refactor-prd.json\",\"status\":\"assigned\"}}" }, + { "path": "$TMP/.codex/apex/prd/auth-refactor-prd.json", "content": "{\"backend-expert\":{\"files\":[\"src/auth/login.ts\"],\"sub-tasks\":{\"jwt-validation\":{\"status\":\"assigned\"}}},\"backend-expert-2\":{\"files\":[\"src/auth/session.ts\"],\"sub-tasks\":{\"session-store\":{\"status\":\"assigned\"}}}}" } + ], + "steps": [ + { + "scope": "core", + "doc": "Codex's edit primitive is apply_patch (normalize.ts fans it into event.files); an Add File targeting another agent's report file, from a payload carrying backend-expert's agent_id+agent_type.", + "event": { + "hook_event_name": "PreToolUse", + "session_id": "sc30b", + "agent_id": "agent-backend-expert-0001", + "agent_type": "backend-expert", + "tool_name": "apply_patch", + "tool_input": { + "command": "*** Begin Patch\n*** Add File: .codex/apex/prd/agents/backend-expert-2-prd.json\n+{\"auth-refactor\":{\"session-store\":{\"status\":\"done\",\"modified\":[\"src/auth/session.ts\"],\"unchanged\":[]}}}\n*** End Patch" + } + }, + "expect": { "exit": 0, "stdout": { "jsonPath": "hookSpecificOutput.permissionDecision", "equals": "deny" } } + } + ] +} diff --git a/test/sim/scenarios/30c-prd-ownership-advisory-cursor.json b/test/sim/scenarios/30c-prd-ownership-advisory-cursor.json new file mode 100644 index 0000000..e4e89df --- /dev/null +++ b/test/sim/scenarios/30c-prd-ownership-advisory-cursor.json @@ -0,0 +1,29 @@ +{ + "name": "prd-ownership-advisory-cursor", + "harness": "cursor", + "doc": "Same attempted write, on Cursor, with NO agent_id/agent_type on the event at all (matches the live capture in test/fixtures/prd/authentic/cursor/pretooluse-subagent-write.json — Cursor never sends per-write agent identity; prd-design.md §6 Risk 1: the sub-agent's actual Write runs under a session_id/conversation_id unrelated to the lead's or to subagentStart's own fields, so no per-write correlation is possible). Expected FINAL behavior: `lead: \"unknown\"` -> `advisory` -> the ownership guard NEVER blocks on Cursor (prd-design.md §1.4 table, §6 Risk 1 recommendation) -> allow. This is the one PRD scenario in this batch that is documented to stay GREEN both today and after Lots A/B ship: verified live 2026-09-02 against `bun src/cli/bin.ts hook cursor core` with Cursor's real home segment (`.cursor`, harnessHomeSegment) — protected-path.ts's PROTECTED_FRAGMENTS only lists the literal `.claude/apex/`, so a `.cursor/apex/...` write is already unenforced today, coincidentally matching the advisory-allow target (for a completely different reason: no PRD logic exists yet, vs. the deliberate 'never block Cursor' policy Lot A/B will encode). Kept in the corpus (not skipped) as a locked non-regression: this must STAY allow once Lots A/B land, not flip to deny.", + "env": { "FUSE_PRD": "1" }, + "setup": [ + { "path": "$TMP/.cursor/apex/prd.json", "content": "{\"auth-refactor\":{\"prd\":\"prd/auth-refactor-prd.json\",\"status\":\"assigned\"}}" }, + { "path": "$TMP/.cursor/apex/prd/auth-refactor-prd.json", "content": "{\"backend-expert\":{\"files\":[\"src/auth/login.ts\"],\"sub-tasks\":{\"jwt-validation\":{\"status\":\"assigned\"}}},\"backend-expert-2\":{\"files\":[\"src/auth/session.ts\"],\"sub-tasks\":{\"session-store\":{\"status\":\"assigned\"}}}}" } + ], + "steps": [ + { + "scope": "core", + "doc": "No agent_id/agent_type/subagent_id field anywhere on the event — the structural shape that forces lead:\"unknown\" per prd-design.md §1.1 PrdIdentity.", + "event": { + "hook_event_name": "preToolUse", + "session_id": "sc30c", + "conversation_id": "sc30c", + "tool_name": "Write", + "tool_input": { + "file_path": "$TMP/.cursor/apex/prd/agents/backend-expert-2-prd.json", + "content": "{\"auth-refactor\":{\"session-store\":{\"status\":\"done\",\"modified\":[\"src/auth/session.ts\"],\"unchanged\":[]}}}" + }, + "cursor_version": "3.18.25", + "workspace_roots": ["$TMP"] + }, + "expect": { "exit": 0, "stdout": { "jsonPath": "permission", "equals": "allow" } } + } + ] +} diff --git a/test/sim/scenarios/31-prd-subagentstop-block-once-claude.json b/test/sim/scenarios/31-prd-subagentstop-block-once-claude.json new file mode 100644 index 0000000..1613e5d --- /dev/null +++ b/test/sim/scenarios/31-prd-subagentstop-block-once-claude.json @@ -0,0 +1,36 @@ +{ + "name": "prd-subagentstop-block-once-claude", + "harness": "claude-code", + "doc": "TARGET behavior once Lots A/B ship (prd-design.md §2.4, not implemented yet — Lot D only). backend-expert's SubagentStop fires while its own sub-task (jwt-validation) is still 'assigned' in the task PRD and NOT reported 'done' in its own agent report (no prd/agents/backend-expert-prd.json on disk at all here) -> incompleteSubTasks finds a violation -> block-once: first SubagentStop for this session+agent -> blockResponse (Claude/Codex shape {\"decision\":\"block\",\"reason\":...}, adapters/claude/index.ts:40); an IDENTICAL replay of the same SubagentStop for the same session -> already-blocked-once -> empty stdout (let it stop). RED TODAY BY DESIGN: verified live 2026-09-02 against `bun src/cli/bin.ts hook claude-code core` with this exact setup (FUSE_PRD=1, router+task-PRD present, no agent report) — the CURRENT SubagentStop dispatch (harvestSubagentTrack + trackAgentMemory, lifecycle/dispatch.ts's existing SubagentStop case) has no PRD awareness at all and returns `{\"message\":\"Agent backend-expert completed (no code changes)\"}` on BOTH calls (no `decision` key ever, so step 1 fails; not empty either, so step 2 fails too) — a real response is emitted today, just not the PRD gate's.", + "env": { "FUSE_PRD": "1" }, + "setup": [ + { "path": "$TMP/.claude/apex/prd.json", "content": "{\"auth-refactor\":{\"prd\":\"prd/auth-refactor-prd.json\",\"status\":\"assigned\"}}" }, + { "path": "$TMP/.claude/apex/prd/auth-refactor-prd.json", "content": "{\"backend-expert\":{\"files\":[\"src/auth/login.ts\"],\"sub-tasks\":{\"jwt-validation\":{\"status\":\"assigned\"}}},\"backend-expert-2\":{\"files\":[\"src/auth/session.ts\"],\"sub-tasks\":{\"session-store\":{\"status\":\"assigned\"}}}}" } + ], + "steps": [ + { + "scope": "core", + "doc": "First SubagentStop for backend-expert, jwt-validation still incomplete -> expected block.", + "event": { + "hook_event_name": "SubagentStop", + "session_id": "sc31", + "agent_id": "agent-backend-expert-0001", + "agent_type": "backend-expert", + "stop_hook_active": false + }, + "expect": { "exit": 0, "stdout": { "jsonPath": "decision", "equals": "block" } } + }, + { + "scope": "core", + "doc": "Identical replay, same session_id+agent -> already blocked once -> pass through silently (empty stdout), never a permanent stop-loop.", + "event": { + "hook_event_name": "SubagentStop", + "session_id": "sc31", + "agent_id": "agent-backend-expert-0001", + "agent_type": "backend-expert", + "stop_hook_active": false + }, + "expect": { "exit": 0, "stdout": { "empty": true } } + } + ] +} diff --git a/test/tracking-prd.test.ts b/test/tracking-prd.test.ts new file mode 100644 index 0000000..d6dec86 --- /dev/null +++ b/test/tracking-prd.test.ts @@ -0,0 +1,115 @@ +import { test, expect } from "bun:test"; +import { join } from "node:path"; +import { loadTrack, withTrack } from "../src/tracking/store"; +import { appendEvent, foldEvents } from "../src/tracking/track-journal"; +import { readEvents } from "../src/tracking/track-compact"; +import { diffTrackEvents } from "../src/tracking/track-diff"; +import { + emptyTrack, prdAlreadyBlocked, PRD_VIOLATIONS_CAP, + recordPrdOwner, recordPrdStopBlocked, recordPrdViolation, + type PrdViolationRecord, type SessionTrack, +} from "../src/tracking/session-state"; +import { dir, withEnv } from "./helpers/track-env"; + +const BASE = 1_700_000_000_000; +const violation = (n: number): PrdViolationRecord => ({ ts: BASE + n, task: "t", agent: "a", sub: `s${n}`, reason: "no done" }); + +test("recordPrdOwner: merge, idempotent no-op on the same binding", () => { + const t1 = recordPrdOwner(emptyTrack(), "call-1", "backend-expert"); + expect(t1.prdOwners).toEqual({ "call-1": "backend-expert" }); + const t2 = recordPrdOwner(t1, "call-1", "backend-expert"); + expect(t2).toBe(t1); // same reference: true no-op + const t3 = recordPrdOwner(t1, "call-2", "backend-expert-2"); + expect(t3.prdOwners).toEqual({ "call-1": "backend-expert", "call-2": "backend-expert-2" }); +}); + +test("recordPrdViolation: append-only, uncapped in the mutator itself", () => { + let t = emptyTrack(); + for (let i = 0; i < 3; i++) t = recordPrdViolation(t, violation(i)); + expect(t.prdViolations).toHaveLength(3); + expect(t.prdViolations?.[0]?.sub).toBe("s0"); +}); + +test("recordPrdStopBlocked + prdAlreadyBlocked: merge, one-shot lookup", () => { + const t1 = recordPrdStopBlocked(emptyTrack(), "sid:SubagentStop:backend-expert", BASE); + expect(prdAlreadyBlocked(t1, "sid:SubagentStop:backend-expert")).toBe(true); + expect(prdAlreadyBlocked(t1, "sid:SubagentStop:other")).toBe(false); + expect(prdAlreadyBlocked(emptyTrack(), "sid:SubagentStop:backend-expert")).toBe(false); +}); + +test("foldEvents: prdOwners merges, prdViolations appends (capped), prdStopBlocked merges", () => { + const log = join(dir(), "track-prd-fold.log"); + appendEvent(log, "prdOwners", "merge", ["call-1", "backend-expert"], BASE); + appendEvent(log, "prdOwners", "merge", ["call-2", "backend-expert-2"], BASE + 1); + appendEvent(log, "prdViolations", "append", violation(1), BASE + 2); + appendEvent(log, "prdViolations", "append", violation(2), BASE + 3); + appendEvent(log, "prdStopBlocked", "merge", ["sid:Stop", BASE + 4], BASE + 4); + const t = foldEvents(readEvents(log)); + expect(t.prdOwners).toEqual({ "call-1": "backend-expert", "call-2": "backend-expert-2" }); + expect(t.prdViolations).toHaveLength(2); + expect(t.prdStopBlocked).toEqual({ "sid:Stop": BASE + 4 }); +}); + +test("foldEvents: prdViolations is capped at PRD_VIOLATIONS_CAP, oldest evicted first", () => { + const log = join(dir(), "track-prd-cap.log"); + const n = PRD_VIOLATIONS_CAP + 5; + for (let i = 0; i < n; i++) appendEvent(log, "prdViolations", "append", violation(i), BASE + i); + const t = foldEvents(readEvents(log)); + expect(t.prdViolations).toHaveLength(PRD_VIOLATIONS_CAP); + expect(t.prdViolations?.[0]?.sub).toBe(`s5`); // first 5 evicted + expect(t.prdViolations?.at(-1)?.sub).toBe(`s${n - 1}`); +}); + +test("legacy track file predating the PRD fields folds without error (backward compat)", () => { + const base: SessionTrack = emptyTrack(); // no prdOwners/prdViolations/prdStopBlocked keys at all + const log = join(dir(), "track-prd-legacy.log"); + appendEvent(log, "refsRead", "add", "a.md", BASE); + const t = foldEvents(readEvents(log), base); + expect(t.prdOwners).toBeUndefined(); + expect(t.prdViolations).toBeUndefined(); + expect(t.prdStopBlocked).toBeUndefined(); + expect(t.refsRead).toEqual(["a.md"]); +}); + +test("diffTrackEvents: prdOwners/prdStopBlocked emit only changed keys (merge), prdViolations emits only the tail (append)", () => { + const prev = recordPrdOwner(emptyTrack(), "call-1", "backend-expert"); + const next = recordPrdOwner(prev, "call-2", "backend-expert-2"); + const events = diffTrackEvents(prev, next, BASE); + expect(events).toEqual([{ field: "prdOwners", op: "merge", value: ["call-2", "backend-expert-2"], ts: BASE }]); + + const prevV = recordPrdViolation(emptyTrack(), violation(1)); + const nextV = recordPrdViolation(prevV, violation(2)); + const ve = diffTrackEvents(prevV, nextV, BASE); + expect(ve).toEqual([{ field: "prdViolations", op: "append", value: violation(2), ts: violation(2).ts }]); + + const prevB = recordPrdStopBlocked(emptyTrack(), "sid:Stop", BASE); + const nextB = recordPrdStopBlocked(prevB, "sid:Stop:agent", BASE + 1); + const be = diffTrackEvents(prevB, nextB, BASE + 1); + expect(be).toEqual([{ field: "prdStopBlocked", op: "merge", value: ["sid:Stop:agent", BASE + 1], ts: BASE + 1 }]); +}); + +test("diffTrackEvents: crossing PRD_VIOLATIONS_CAP does NOT re-emit already-journaled violations as duplicate events (regression: eviction must not look like a bulk rewrite)", () => { + // Simulate the exact runtime shape: `prev` is what loadTrack() would hand + // back — already capped at PRD_VIOLATIONS_CAP by foldEvents — and `next` is + // one more append past the cap. + let prev = emptyTrack(); + for (let i = 0; i < PRD_VIOLATIONS_CAP; i++) prev = recordPrdViolation(prev, violation(i)); + prev = { ...prev, prdViolations: prev.prdViolations!.slice(-PRD_VIOLATIONS_CAP) }; + const next = recordPrdViolation(prev, violation(PRD_VIOLATIONS_CAP)); + const events = diffTrackEvents(prev, next, BASE + 999); + expect(events).toHaveLength(1); // NOT 50 — only the genuinely new violation + expect(events[0]).toEqual({ field: "prdViolations", op: "append", value: violation(PRD_VIOLATIONS_CAP), ts: violation(PRD_VIOLATIONS_CAP).ts }); +}); + +test("withTrack round-trip through the real journal store: prdOwners/prdViolations/prdStopBlocked persist and re-load", async () => { + await withEnv(undefined, async () => { + const file = join(dir(), "track.json"); + await withTrack(file, (t) => recordPrdOwner(t, "call-1", "backend-expert")); + await withTrack(file, (t) => recordPrdViolation(t, violation(1))); + await withTrack(file, (t) => recordPrdStopBlocked(t, "sid:Stop", BASE)); + const loaded = await loadTrack(file); + expect(loaded.prdOwners).toEqual({ "call-1": "backend-expert" }); + expect(loaded.prdViolations).toHaveLength(1); + expect(loaded.prdStopBlocked).toEqual({ "sid:Stop": BASE }); + }); +});