@tech-leads-club/harness-toolkit 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/capabilities/catalog.json +2 -2
  3. package/dist/compact-before.mjs +84 -84
  4. package/dist/doctor.mjs +89 -89
  5. package/dist/init-project.mjs +87 -87
  6. package/dist/install-runtime.mjs +86 -86
  7. package/dist/lessons-cli.mjs +83 -83
  8. package/dist/obs-cli.mjs +86 -86
  9. package/dist/price-lookup.mjs +1 -1
  10. package/dist/prompt-submit.mjs +84 -84
  11. package/dist/refresh-model-prices.mjs +88 -88
  12. package/dist/response-after.mjs +84 -84
  13. package/dist/run.mjs +84 -84
  14. package/dist/session-end.mjs +89 -89
  15. package/dist/session-start.mjs +91 -91
  16. package/dist/shim.mjs +85 -85
  17. package/dist/ship-gate.mjs +85 -85
  18. package/dist/stop.mjs +92 -92
  19. package/dist/subagent-start.mjs +84 -84
  20. package/dist/subagent-stop.mjs +85 -85
  21. package/dist/support.mjs +87 -87
  22. package/dist/tlc-cli.mjs +104 -104
  23. package/dist/tool-after.mjs +80 -80
  24. package/dist/tool-before.mjs +88 -88
  25. package/dist/tool-failure.mjs +84 -84
  26. package/dist/uninstall-runtime.mjs +4 -4
  27. package/docs/concepts.md +13 -7
  28. package/docs/diagnose.md +2 -2
  29. package/docs/log.md +6 -0
  30. package/package.json +1 -1
  31. package/src/core/core.facade.ts +20 -3
  32. package/src/core/gate/gate.artifact.ts +43 -7
  33. package/src/core/observability/observability.service.ts +1 -1
  34. package/src/core/presence/presence.service.ts +68 -0
  35. package/src/core/rules/rules.trigger.ts +89 -5
  36. package/src/core/turn/turn.activity.ts +14 -4
  37. package/src/entrypoints/run.ts +39 -2
  38. package/src/entrypoints/session-start.ts +1 -0
  39. package/src/entrypoints/stop.ts +14 -2
  40. package/src/entrypoints/support.ts +8 -1
  41. package/src/entrypoints/tool-before.ts +10 -2
  42. package/src/platform/paths.ts +4 -0
  43. package/src/providers/cursor/cursor.inbound.ts +22 -1
  44. package/tools/obs-cli.ts +12 -2
@@ -1,4 +1,4 @@
1
- import{createRequire as I$}from"node:module";var l=I$(import.meta.url);import{existsSync as B,lstatSync as p8,readFileSync as S,readlinkSync as c8,realpathSync as K$,rmSync as d8,statSync as l8,unlinkSync as i8,writeFileSync as n8}from"node:fs";import{homedir as a8}from"node:os";import{basename as s8,dirname as M$,isAbsolute as C$,join as G,relative as r8,resolve as h}from"node:path";import{homedir as L}from"node:os";import{delimiter as CZ,dirname as OZ,join as D,resolve as FZ}from"node:path";function i(){return D(L(),".tlc","harness")}function O($=process.env){return $.TLC_HOME??i()}function T(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:D(L(),".claude")}function A(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:D(L(),".cursor")}function b($=process.env){return $.TLC_BIN_DIR?.trim()||D(L(),".local","bin")}function n(){return["tlc","tlc.cmd"]}var s=[{id:"typescript",extensions:[".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".cjs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"python",extensions:[".py",".pyi",".pyw"],line:["#"],block:[['"""','"""'],["'''","'''"]],middle:[]},{id:"ruby",extensions:[".rb",".rake",".gemspec"],line:["#"],block:[["=begin","=end"]],middle:[]},{id:"shell",extensions:[".sh",".bash",".zsh",".ksh",".fish"],line:["#"],block:[],middle:[]},{id:"go",extensions:[".go"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"rust",extensions:[".rs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"java",extensions:[".java"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"kotlin",extensions:[".kt",".kts"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"swift",extensions:[".swift"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"c",extensions:[".c",".h"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"cpp",extensions:[".cc",".cpp",".cxx",".hpp",".hh",".hxx"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"csharp",extensions:[".cs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"php",extensions:[".php"],line:["//","#"],block:[["/*","*/"]],middle:["*"]},{id:"scala",extensions:[".scala",".sc"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"dart",extensions:[".dart"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"elixir",extensions:[".ex",".exs"],line:["#"],block:[],middle:[]},{id:"erlang",extensions:[".erl",".hrl"],line:["%"],block:[],middle:[]},{id:"haskell",extensions:[".hs"],line:["--"],block:[["{-","-}"]],middle:[]},{id:"lua",extensions:[".lua"],line:["--"],block:[["--[[","]]"]],middle:[]},{id:"sql",extensions:[".sql"],line:["--"],block:[["/*","*/"]],middle:[]},{id:"css",extensions:[".css",".scss",".sass",".less"],line:["//"],block:[["/*","*/"]],middle:[]},{id:"yaml",extensions:[".yaml",".yml"],line:["#"],block:[],middle:[]},{id:"toml",extensions:[".toml"],line:["#"],block:[],middle:[]},{id:"ini",extensions:[".ini",".cfg",".conf",".properties"],line:[";","#"],block:[],middle:[]},{id:"dockerfile",extensions:[".dockerfile","dockerfile"],line:["#"],block:[],middle:[]},{id:"makefile",extensions:[".mk","makefile"],line:["#"],block:[],middle:[]},{id:"terraform",extensions:[".tf",".tfvars"],line:["#","//"],block:[["/*","*/"]],middle:["*"]},{id:"powershell",extensions:[".ps1",".psm1",".psd1"],line:["#"],block:[["<#","#>"]],middle:[]},{id:"perl",extensions:[".pl",".pm"],line:["#"],block:[],middle:[]},{id:"r",extensions:[".r"],line:["#"],block:[],middle:[]},{id:"julia",extensions:[".jl"],line:["#"],block:[["#=","=#"]],middle:[]},{id:"vue",extensions:[".vue",".svelte"],line:["//"],block:[["/*","*/"],["<!--","-->"]],middle:["*"]},{id:"html",extensions:[".html",".htm",".xml",".xhtml"],line:[],block:[["<!--","-->"]],middle:[]},{id:"graphql",extensions:[".graphql",".gql"],line:["#"],block:[],middle:[]},{id:"protobuf",extensions:[".proto"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"zig",extensions:[".zig"],line:["//"],block:[],middle:[]},{id:"clojure",extensions:[".clj",".cljs",".cljc",".edn"],line:[";"],block:[],middle:[]},{id:"ocaml",extensions:[".ml",".mli"],line:[],block:[["(*","*)"]],middle:["*"]},{id:"fsharp",extensions:[".fs",".fsi",".fsx"],line:["//"],block:[["(*","*)"]],middle:["*"]},{id:"vim",extensions:[".vim"],line:['"'],block:[],middle:[]},{id:"tex",extensions:[".tex",".sty",".cls"],line:["%"],block:[],middle:[]}];function L$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let q of J.extensions)Z.set(q.toLowerCase(),Q)}return Z}var D$=L$(s);var T$=D$.size;var y=6;var x$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var L5=new Set([...x$,"nc","ncat","socat","telnet","lwp-request"]);var S$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",A5=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${S$}))(?=[:)\\s,'"\`]|$)`,"g");var R="1970-01-01T00:00:00.000Z";function M($){return{...$,scope:"gate-execution",source:"core",tier:"core",status:"active",confidence:1,hitCount:1,pinned:!1,refs:[],sessionKeys:[],injectedCount:0,gradeableCount:0,helpedCount:0,neutralCount:0,firstSeenAt:R,lastSeenAt:R,lastAccessedAt:R,updatedAt:R}}var i9=[M({id:"core:gate:lint",failedGate:"lint",category:"verification",triggerTokens:["lint","biome","eslint","ruff","format"],instruction:"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",avoid:"Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",prefer:"Apply the smallest fix that clears each finding, then let the stop hook re-check.",preRetryCheck:"Confirm the lint command targets only the intended changed files and still fails for the same codes.",priority:90}),M({id:"core:gate:test",failedGate:"test",category:"verification",triggerTokens:["test","vitest","jest","pytest","failing"],instruction:"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",avoid:"Do not delete failing tests, mark them skipped, or weaken assertions to force green.",prefer:"Reproduce the failure, fix root cause, re-run the same test target.",preRetryCheck:"Identify the failing test name/file from the gate output before editing.",priority:90}),M({id:"core:gate:comments",failedGate:"comments",category:"verification",triggerTokens:["junk comment","TODO","FIXME","banner"],instruction:"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",avoid:"Do not keep TODO markers or section banners 'for clarity'.",prefer:"Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",preRetryCheck:"Scan the listed file:line hits and remove each one.",priority:80}),M({id:"core:gate:ship",failedGate:"ship",category:"ship-evidence",triggerTokens:["ship","evidence","90-verdict","PASS"],instruction:"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",avoid:"Do not claim shipped based on unit tests alone when runtime paths changed.",prefer:"Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",preRetryCheck:"Confirm evidenceDir and a recent PASS verdict exist for this change.",priority:95}),M({id:"core:gate:empty-diff",failedGate:"empty-diff",category:"ship-evidence",triggerTokens:["empty","diff","no changes","shipped"],instruction:"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",avoid:"Do not restate 'done' without a real diff or an explicit zero-change justification.",prefer:"Make the missing change, or clearly document why no files should change.",preRetryCheck:"Inspect git status / changed files before the next stop.",priority:92}),M({id:"core:gate:stagnation",failedGate:"stagnation",category:"stagnation",triggerTokens:["stagnation","identical","fingerprint","same fail"],instruction:"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",avoid:"Do not retry the exact same patch, command, or suppression.",prefer:"Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",preRetryCheck:"Diff your last edit against the gate output; ensure the next action is different.",priority:100})];var i$={structure:"#3d3a4a",accent:"#a78bfa",success:"#6ee7b7",warning:"#d4a574",error:"#f87171",info:"#93c5fd",textMain:"#f5f5f7",textMuted:"#9ca3af",textDim:"#6b7280"},V={check:"✔",cross:"✖",warning:"⚠",arrow:"→",arrowRight:"▸",dot:"•",bar:"│",rule:"══",dash:"──"};function n$($){let Z=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec($.trim());if(!Z)return"255;255;255";return[Z[1],Z[2],Z[3]].map((J)=>Number.parseInt(J,16)).join(";")}var q$=String.fromCharCode(27),a$=`${q$}[0m`;function s$($=process.env,Z=process.argv,J=process.stdout.isTTY===!0){if("NO_COLOR"in $)return!1;if(Z.includes("--no-color"))return!1;return J}var r$={ok:"success",warn:"warning",fail:"error",info:"info"},o$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},m=16;function E($=s$()){let Z=(Q,q)=>$?`${q$}[${Q}m${q}${a$}`:q,J=(Q,q)=>Z(`38;2;${n$(i$[Q])}`,q);return{enabled:$,paint:J,bold:(Q)=>Z("1",Q),dim:(Q)=>J("textDim",Q),heading:(Q)=>J("accent",`${V.rule} ${Q} ${V.rule}`),footer:(Q)=>J("textDim",`${V.dash} ${Q} ${V.dash}`),kv:(Q,q,X=m)=>` ${J("textMuted",`${Q}:`.padEnd(X))} ${q}`,status:(Q,q)=>`${J(r$[Q],o$[Q])} ${q}`}}var C=E(!1);var t$=2;function e$($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function $8($,Z){if($==="")return[""];let J=[],Q="";for(let q of $.split(/\s+/).filter((X)=>X.length>0)){if(Q===""){Q=q;continue}if(Q.length+1+q.length<=Z){Q=`${Q} ${q}`;continue}J.push(Q),Q=q}if(Q!=="")J.push(Q);return J.length===0?[""]:J}function I($,Z,J=e$()){let Q=[Z.heading($.title.toUpperCase())];if($.summary&&$.summary.length>0)Q.push(` ${$.summary.join(Z.dim(` ${V.bar} `))}`);let q=Math.max(m,...$.sections.flatMap((X)=>(X.rows??[]).map((z)=>z.label.length+1)));for(let X of $.sections){if(Q.push(""),X.title)Q.push(Z.paint("accent",X.title));for(let z of X.rows??[]){let W=z.level?Z.status(z.level,z.value):z.value;Q.push(Z.kv(z.label,W,q))}for(let z of X.lines??[]){if(z===""){Q.push("");continue}let W=X.wrap?$8(z,J-t$):[z];for(let U of W)Q.push(` ${U}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
2
- `)}var H={enabled:!0,signalPath:"obs.jsonl",debugPath:"debug.jsonl",debugEnabled:!1,includePayloads:!1,maxAttrChars:500,sessionCostAlertUsd:5,retentionDays:14,maxSignalEvents:50000,globalSpool:!1};var Q8=["No harness decision in this window.","Whatever you just saw was the model, not a rail — the harness allowed everything it was asked about."].join(`
3
- `);var J8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:y},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},secrets:{redactOutput:!0},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...J8}},mcpPrime:[],bootstrapExtra:[]};var N="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as y8,mkdirSync as gX,readFileSync as v8,realpathSync as uX,writeFileSync as m8}from"node:fs";function z$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function g8($){return z$($)}var u8="tlc-exec.mjs";function h8($){return JSON.stringify($??null).includes(u8)}function g($){if($===null||$.trim()==="")return{ok:!0,settingsText:"",changed:!1};let Z;try{Z=JSON.parse($)}catch(z){return{ok:!1,error:z instanceof Error?z.message:String(z),block:""}}if(!z$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=g8(Z.hooks)?Z.hooks:{},Q={},q=!1;for(let[z,W]of Object.entries(J)){let U=W.filter((_)=>!h8(_));if(U.length!==W.length)q=!0;if(U.length>0)Q[z]=U}let X={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")X[z]=W;else if(Object.keys(Q).length>0)X.hooks=Q;return{ok:!0,settingsText:JSON.stringify(X,null,2),changed:q}}function W$($){if(!y8($))return{ok:!0,settingsText:"",changed:!1};let Z=g(v8($,"utf8"));if(Z.ok&&Z.changed)m8($,Z.settingsText,"utf8");return Z}function u($,Z="tlc-exec.mjs"){if($===null||$.trim()==="")return{kind:"absent"};let J;try{J=JSON.parse($)}catch{return{kind:"unparsed"}}if(J===null||typeof J!=="object"||Array.isArray(J))return{kind:"unparsed"};let Q=J,q=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},X={},z=0,W=0;for(let[U,_]of Object.entries(q)){let d=Array.isArray(_)?_:[],j=d.filter((F$)=>!JSON.stringify(F$??null).includes(Z));if(z+=d.length-j.length,j.length>0)X[U]=j,W+=j.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:X},null,2)}
4
- `}}import{join as Y$,relative as nX,resolve as aX,sep as sX}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],J4=[Y$("tools","dev"),Y$("tools","__test__")];function o8($=process.env){let Z=a8(),J=b($);return{home:O($),binLinks:n().map((Q)=>G(J,Q)),claudeSettings:G(T(),"settings.json"),cursorHooks:G(A(),"hooks.json"),skillLinks:[G(T(),"skills","harness-init"),G(A(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function p($){try{return p8($).isSymbolicLink()}catch{return!1}}function c($){let Z=h($),J=[];for(;;)try{return G(K$(Z),...J)}catch{let Q=M$(Z);if(Q===Z)return h($);J.unshift(s8(Z)),Z=Q}}function x($){try{return K$($)}catch{try{let Z=c8($);return c(C$(Z)?Z:h(M$($),Z))}catch{return c($)}}}function t8($,Z,J={relative:r8,isAbsolute:C$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function B$($,Z){return t8(x($),c(Z))}var e8="tlc-exec.mjs";function $Z($){try{return l8($).size<4096&&S($,"utf8").includes(e8)}catch{return!1}}function U$($,Z,J,Q,q){if(!B(Z)&&!p(Z))return;if(!p(Z)){if($Z(Z)){$.push({action:"remove",target:Z,detail:`${Q}, installed as a copy`});return}$.push({action:"keep",target:Z,detail:`${Q} is a real file the installer did not write`});return}if(q==="target"&&!B$(Z,J)){$.push({action:"keep",target:Z,detail:`points at ${x(Z)} — not ours`});return}let X=q==="location"&&!B$(Z,J);$.push({action:"unlink",target:Z,detail:X?`${Q}, stale — points at ${x(Z)}`:Q})}function ZZ($,Z){if(!B(Z))return;let J=g(S(Z,"utf8"));if(!J.ok){$.push({action:"keep",target:Z,detail:`left untouched — it does not parse as JSON: ${J.error}`});return}if(!J.changed)return;$.push({action:"unmerge",target:Z,detail:"drop the harness hook groups, keep every other key and every foreign hook"})}function QZ($,Z){let J=B(Z)?S(Z,"utf8"):null,Q=u(J);switch(Q.kind){case"absent":return;case"unparsed":$.push({action:"keep",target:Z,detail:"left untouched — it does not parse as JSON"});return;case"empty":if(Q.removed>0)$.push({action:"remove",target:Z,detail:`${Q.removed} entries, all ours`});return;default:$.push({action:"unmerge",target:Z,detail:`drop ${Q.removed} harness entries, keep the rest`})}}function JZ($,Z,J){if(p(Z))return $.push({action:"unlink",target:Z,detail:`a link to ${x(Z)} — the checkout it points at is left exactly as it is`}),!0;if(!B(Z))return!1;for(let X of V$){let z=G(Z,X);if(B(z))$.push({action:"remove",target:z,detail:"runtime payload"})}let q=G(Z,k);if(B(q))$.push({action:"remove",target:q,detail:"install marker"});for(let X of G$){let z=G(Z,X);if(!B(z))continue;$.push(J?{action:"remove",target:z,detail:"--purge"}:{action:"keep",target:z,detail:"yours — add --purge to remove it"})}return!1}function qZ($,Z){if(B(G(Z,k)))$.push({action:"manual",target:`npm uninstall -g ${N}`,detail:"the global package is reported, never removed for you"});$.push({action:"manual",target:"rm -rf .tlc/ in each repository",detail:"per-project config and state — this command does not search your disk for them"})}function XZ($,Z={}){let J=Z.purge===!0,Q=[];ZZ(Q,$.claudeSettings),QZ(Q,$.cursorHooks);for(let X of $.skillLinks)U$(Q,X,$.home,"skill link","location");for(let X of $.binLinks)U$(Q,X,$.home,"the tlc launcher on PATH","target");let q=JZ(Q,$.home,J);return qZ(Q,$.home),{items:Q,purge:J,homeIsLink:q}}function O$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function zZ($,Z){let J=[],Q=[];for(let q of O$($))try{if(q.action==="unmerge"&&q.target===Z.claudeSettings)W$(q.target);else if(q.action==="unmerge"){let X=u(S(q.target,"utf8"));if(X.kind==="rewritten")n8(q.target,X.text,"utf8")}else if(q.action==="unlink")i8(q.target);else d8(q.target,{recursive:!0,force:!0});J.push(q)}catch(X){Q.push({item:q,reason:X instanceof Error?X.message:String(X)})}return{applied:J,failed:Q}}var WZ={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function YZ($,Z){let J=O$($),Q=Z!==null;if(J.length===0)return{title:"harness uninstall",summary:["nothing wired"],sections:[{rows:[{label:"state",value:"no harness artefact found — nothing to undo",level:"ok"}]},{title:"STILL YOURS TO DO",rows:H$($)}],footer:`already clean · reinstall with npm i -g ${N}, then \`tlc harness install\``};let q=J.map((W)=>({label:W.action,value:`${W.target} — ${W.detail}`,level:WZ[W.action]})),X=$.items.filter((W)=>W.action==="keep"),z=[{title:Q?"REMOVED":"WOULD REMOVE",rows:q},...X.length>0?[{title:"KEPT",rows:X.map((W)=>({label:"keep",value:`${W.target} — ${W.detail}`,level:"ok"}))}]:[],{title:"STILL YOURS TO DO",rows:H$($)},...Z!==null&&Z.failed.length>0?[{title:"FAILED",rows:Z.failed.map((W)=>({label:"error",value:`${W.item.target} — ${W.reason}`,level:"fail"}))}]:[]];return{title:"harness uninstall",summary:[Q?`${Z.applied.length} applied`:`${J.length} pending`,$.purge?"purge: state included":"purge: state kept",$.homeIsLink?"runtime: linked checkout, unlinked only":"runtime: owned by the installer"],sections:z,footer:Q?`reinstall any time with npm i -g ${N}, then \`tlc harness install\``:"nothing was changed · re-run with --yes to apply this plan"}}function H$($){return $.items.filter((Z)=>Z.action==="manual").map((Z)=>({label:"run",value:`${Z.target} — ${Z.detail}`,level:"info"}))}function VZ($,Z,J=C){return I(YZ($,Z),J)}function GZ($=process.argv.slice(2)){let Z=o8(),J=XZ(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?zZ(J,Z):null;return console.log(VZ(J,Q,E())),Q!==null&&Q.failed.length>0?1:0}if(l.main==l.module)process.exitCode=GZ();export{o8 as uninstallTargets,YZ as uninstallScreen,VZ as uninstallReportText,XZ as planUninstall,O$ as pendingItems,GZ as main,t8 as isInsideRoot,c as canonicalise,zZ as applyUninstall};
1
+ import{createRequire as I$}from"node:module";var i=I$(import.meta.url);import{existsSync as B,lstatSync as c8,readFileSync as S,readlinkSync as d8,realpathSync as K$,rmSync as l8,statSync as i8,unlinkSync as n8,writeFileSync as a8}from"node:fs";import{homedir as s8}from"node:os";import{basename as r8,dirname as M$,isAbsolute as C$,join as G,relative as o8,resolve as p}from"node:path";import{homedir as L}from"node:os";import{delimiter as OZ,dirname as FZ,join as D,resolve as IZ}from"node:path";function n(){return D(L(),".tlc","harness")}function F($=process.env){return $.TLC_HOME??n()}function T(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:D(L(),".claude")}function A(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:D(L(),".cursor")}function b($=process.env){return $.TLC_BIN_DIR?.trim()||D(L(),".local","bin")}function a(){return["tlc","tlc.cmd"]}var s=[{id:"typescript",extensions:[".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".cjs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"python",extensions:[".py",".pyi",".pyw"],line:["#"],block:[['"""','"""'],["'''","'''"]],middle:[]},{id:"ruby",extensions:[".rb",".rake",".gemspec"],line:["#"],block:[["=begin","=end"]],middle:[]},{id:"shell",extensions:[".sh",".bash",".zsh",".ksh",".fish"],line:["#"],block:[],middle:[]},{id:"go",extensions:[".go"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"rust",extensions:[".rs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"java",extensions:[".java"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"kotlin",extensions:[".kt",".kts"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"swift",extensions:[".swift"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"c",extensions:[".c",".h"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"cpp",extensions:[".cc",".cpp",".cxx",".hpp",".hh",".hxx"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"csharp",extensions:[".cs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"php",extensions:[".php"],line:["//","#"],block:[["/*","*/"]],middle:["*"]},{id:"scala",extensions:[".scala",".sc"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"dart",extensions:[".dart"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"elixir",extensions:[".ex",".exs"],line:["#"],block:[],middle:[]},{id:"erlang",extensions:[".erl",".hrl"],line:["%"],block:[],middle:[]},{id:"haskell",extensions:[".hs"],line:["--"],block:[["{-","-}"]],middle:[]},{id:"lua",extensions:[".lua"],line:["--"],block:[["--[[","]]"]],middle:[]},{id:"sql",extensions:[".sql"],line:["--"],block:[["/*","*/"]],middle:[]},{id:"css",extensions:[".css",".scss",".sass",".less"],line:["//"],block:[["/*","*/"]],middle:[]},{id:"yaml",extensions:[".yaml",".yml"],line:["#"],block:[],middle:[]},{id:"toml",extensions:[".toml"],line:["#"],block:[],middle:[]},{id:"ini",extensions:[".ini",".cfg",".conf",".properties"],line:[";","#"],block:[],middle:[]},{id:"dockerfile",extensions:[".dockerfile","dockerfile"],line:["#"],block:[],middle:[]},{id:"makefile",extensions:[".mk","makefile"],line:["#"],block:[],middle:[]},{id:"terraform",extensions:[".tf",".tfvars"],line:["#","//"],block:[["/*","*/"]],middle:["*"]},{id:"powershell",extensions:[".ps1",".psm1",".psd1"],line:["#"],block:[["<#","#>"]],middle:[]},{id:"perl",extensions:[".pl",".pm"],line:["#"],block:[],middle:[]},{id:"r",extensions:[".r"],line:["#"],block:[],middle:[]},{id:"julia",extensions:[".jl"],line:["#"],block:[["#=","=#"]],middle:[]},{id:"vue",extensions:[".vue",".svelte"],line:["//"],block:[["/*","*/"],["<!--","-->"]],middle:["*"]},{id:"html",extensions:[".html",".htm",".xml",".xhtml"],line:[],block:[["<!--","-->"]],middle:[]},{id:"graphql",extensions:[".graphql",".gql"],line:["#"],block:[],middle:[]},{id:"protobuf",extensions:[".proto"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"zig",extensions:[".zig"],line:["//"],block:[],middle:[]},{id:"clojure",extensions:[".clj",".cljs",".cljc",".edn"],line:[";"],block:[],middle:[]},{id:"ocaml",extensions:[".ml",".mli"],line:[],block:[["(*","*)"]],middle:["*"]},{id:"fsharp",extensions:[".fs",".fsi",".fsx"],line:["//"],block:[["(*","*)"]],middle:["*"]},{id:"vim",extensions:[".vim"],line:['"'],block:[],middle:[]},{id:"tex",extensions:[".tex",".sty",".cls"],line:["%"],block:[],middle:[]}];function L$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let q of J.extensions)Z.set(q.toLowerCase(),Q)}return Z}var D$=L$(s);var T$=D$.size;var v=6;var S$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var L5=new Set([...S$,"nc","ncat","socat","telnet","lwp-request"]);var b$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",A5=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${b$}))(?=[:)\\s,'"\`]|$)`,"g");var R="1970-01-01T00:00:00.000Z";function C($){return{...$,scope:"gate-execution",source:"core",tier:"core",status:"active",confidence:1,hitCount:1,pinned:!1,refs:[],sessionKeys:[],injectedCount:0,gradeableCount:0,helpedCount:0,neutralCount:0,firstSeenAt:R,lastSeenAt:R,lastAccessedAt:R,updatedAt:R}}var r9=[C({id:"core:gate:lint",failedGate:"lint",category:"verification",triggerTokens:["lint","biome","eslint","ruff","format"],instruction:"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",avoid:"Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",prefer:"Apply the smallest fix that clears each finding, then let the stop hook re-check.",preRetryCheck:"Confirm the lint command targets only the intended changed files and still fails for the same codes.",priority:90}),C({id:"core:gate:test",failedGate:"test",category:"verification",triggerTokens:["test","vitest","jest","pytest","failing"],instruction:"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",avoid:"Do not delete failing tests, mark them skipped, or weaken assertions to force green.",prefer:"Reproduce the failure, fix root cause, re-run the same test target.",preRetryCheck:"Identify the failing test name/file from the gate output before editing.",priority:90}),C({id:"core:gate:comments",failedGate:"comments",category:"verification",triggerTokens:["junk comment","TODO","FIXME","banner"],instruction:"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",avoid:"Do not keep TODO markers or section banners 'for clarity'.",prefer:"Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",preRetryCheck:"Scan the listed file:line hits and remove each one.",priority:80}),C({id:"core:gate:ship",failedGate:"ship",category:"ship-evidence",triggerTokens:["ship","evidence","90-verdict","PASS"],instruction:"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",avoid:"Do not claim shipped based on unit tests alone when runtime paths changed.",prefer:"Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",preRetryCheck:"Confirm evidenceDir and a recent PASS verdict exist for this change.",priority:95}),C({id:"core:gate:empty-diff",failedGate:"empty-diff",category:"ship-evidence",triggerTokens:["empty","diff","no changes","shipped"],instruction:"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",avoid:"Do not restate 'done' without a real diff or an explicit zero-change justification.",prefer:"Make the missing change, or clearly document why no files should change.",preRetryCheck:"Inspect git status / changed files before the next stop.",priority:92}),C({id:"core:gate:stagnation",failedGate:"stagnation",category:"stagnation",triggerTokens:["stagnation","identical","fingerprint","same fail"],instruction:"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",avoid:"Do not retry the exact same patch, command, or suppression.",prefer:"Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",preRetryCheck:"Diff your last edit against the gate output; ensure the next action is different.",priority:100})];var n$={structure:"#3d3a4a",accent:"#a78bfa",success:"#6ee7b7",warning:"#d4a574",error:"#f87171",info:"#93c5fd",textMain:"#f5f5f7",textMuted:"#9ca3af",textDim:"#6b7280"},V={check:"✔",cross:"✖",warning:"⚠",arrow:"→",arrowRight:"▸",dot:"•",bar:"│",rule:"══",dash:"──"};function a$($){let Z=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec($.trim());if(!Z)return"255;255;255";return[Z[1],Z[2],Z[3]].map((J)=>Number.parseInt(J,16)).join(";")}var q$=String.fromCharCode(27),s$=`${q$}[0m`;function r$($=process.env,Z=process.argv,J=process.stdout.isTTY===!0){if("NO_COLOR"in $)return!1;if(Z.includes("--no-color"))return!1;return J}var o$={ok:"success",warn:"warning",fail:"error",info:"info"},t$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},g=16;function E($=r$()){let Z=(Q,q)=>$?`${q$}[${Q}m${q}${s$}`:q,J=(Q,q)=>Z(`38;2;${a$(n$[Q])}`,q);return{enabled:$,paint:J,bold:(Q)=>Z("1",Q),dim:(Q)=>J("textDim",Q),heading:(Q)=>J("accent",`${V.rule} ${Q} ${V.rule}`),footer:(Q)=>J("textDim",`${V.dash} ${Q} ${V.dash}`),kv:(Q,q,X=g)=>` ${J("textMuted",`${Q}:`.padEnd(X))} ${q}`,status:(Q,q)=>`${J(o$[Q],t$[Q])} ${q}`}}var O=E(!1);var e$=2;function $8($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function Z8($,Z){if($==="")return[""];let J=[],Q="";for(let q of $.split(/\s+/).filter((X)=>X.length>0)){if(Q===""){Q=q;continue}if(Q.length+1+q.length<=Z){Q=`${Q} ${q}`;continue}J.push(Q),Q=q}if(Q!=="")J.push(Q);return J.length===0?[""]:J}function I($,Z,J=$8()){let Q=[Z.heading($.title.toUpperCase())];if($.summary&&$.summary.length>0)Q.push(` ${$.summary.join(Z.dim(` ${V.bar} `))}`);let q=Math.max(g,...$.sections.flatMap((X)=>(X.rows??[]).map((z)=>z.label.length+1)));for(let X of $.sections){if(Q.push(""),X.title)Q.push(Z.paint("accent",X.title));for(let z of X.rows??[]){let W=z.level?Z.status(z.level,z.value):z.value;Q.push(Z.kv(z.label,W,q))}for(let z of X.lines??[]){if(z===""){Q.push("");continue}let W=X.wrap?Z8(z,J-e$):[z];for(let U of W)Q.push(` ${U}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
2
+ `)}var H={enabled:!0,signalPath:"obs.jsonl",debugPath:"debug.jsonl",debugEnabled:!1,includePayloads:!1,maxAttrChars:500,sessionCostAlertUsd:5,retentionDays:14,maxSignalEvents:50000,globalSpool:!1};var J8=["No harness decision in this window.","Whatever you just saw was the model, not a rail — the harness allowed everything it was asked about."].join(`
3
+ `);var q8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:v},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},secrets:{redactOutput:!0},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...q8}},mcpPrime:[],bootstrapExtra:[]};var _="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as v8,mkdirSync as nX,readFileSync as m8,realpathSync as aX,writeFileSync as g8}from"node:fs";function z$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function u8($){return z$($)}var h8="tlc-exec.mjs";function p8($){return JSON.stringify($??null).includes(h8)}function u($){if($===null||$.trim()==="")return{ok:!0,settingsText:"",changed:!1};let Z;try{Z=JSON.parse($)}catch(z){return{ok:!1,error:z instanceof Error?z.message:String(z),block:""}}if(!z$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=u8(Z.hooks)?Z.hooks:{},Q={},q=!1;for(let[z,W]of Object.entries(J)){let U=W.filter((N)=>!p8(N));if(U.length!==W.length)q=!0;if(U.length>0)Q[z]=U}let X={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")X[z]=W;else if(Object.keys(Q).length>0)X.hooks=Q;return{ok:!0,settingsText:JSON.stringify(X,null,2),changed:q}}function W$($){if(!v8($))return{ok:!0,settingsText:"",changed:!1};let Z=u(m8($,"utf8"));if(Z.ok&&Z.changed)g8($,Z.settingsText,"utf8");return Z}function h($,Z="tlc-exec.mjs"){if($===null||$.trim()==="")return{kind:"absent"};let J;try{J=JSON.parse($)}catch{return{kind:"unparsed"}}if(J===null||typeof J!=="object"||Array.isArray(J))return{kind:"unparsed"};let Q=J,q=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},X={},z=0,W=0;for(let[U,N]of Object.entries(q)){let l=Array.isArray(N)?N:[],j=l.filter((F$)=>!JSON.stringify(F$??null).includes(Z));if(z+=l.length-j.length,j.length>0)X[U]=j,W+=j.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:X},null,2)}
4
+ `}}import{join as Y$,relative as Z4,resolve as Q4,sep as J4}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],B4=[Y$("tools","dev"),Y$("tools","__test__")];function t8($=process.env){let Z=s8(),J=b($);return{home:F($),binLinks:a().map((Q)=>G(J,Q)),claudeSettings:G(T(),"settings.json"),cursorHooks:G(A(),"hooks.json"),skillLinks:[G(T(),"skills","harness-init"),G(A(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function c($){try{return c8($).isSymbolicLink()}catch{return!1}}function d($){let Z=p($),J=[];for(;;)try{return G(K$(Z),...J)}catch{let Q=M$(Z);if(Q===Z)return p($);J.unshift(r8(Z)),Z=Q}}function x($){try{return K$($)}catch{try{let Z=d8($);return d(C$(Z)?Z:p(M$($),Z))}catch{return d($)}}}function e8($,Z,J={relative:o8,isAbsolute:C$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function B$($,Z){return e8(x($),d(Z))}var $Z="tlc-exec.mjs";function ZZ($){try{return i8($).size<4096&&S($,"utf8").includes($Z)}catch{return!1}}function U$($,Z,J,Q,q){if(!B(Z)&&!c(Z))return;if(!c(Z)){if(ZZ(Z)){$.push({action:"remove",target:Z,detail:`${Q}, installed as a copy`});return}$.push({action:"keep",target:Z,detail:`${Q} is a real file the installer did not write`});return}if(q==="target"&&!B$(Z,J)){$.push({action:"keep",target:Z,detail:`points at ${x(Z)} — not ours`});return}let X=q==="location"&&!B$(Z,J);$.push({action:"unlink",target:Z,detail:X?`${Q}, stale — points at ${x(Z)}`:Q})}function QZ($,Z){if(!B(Z))return;let J=u(S(Z,"utf8"));if(!J.ok){$.push({action:"keep",target:Z,detail:`left untouched — it does not parse as JSON: ${J.error}`});return}if(!J.changed)return;$.push({action:"unmerge",target:Z,detail:"drop the harness hook groups, keep every other key and every foreign hook"})}function JZ($,Z){let J=B(Z)?S(Z,"utf8"):null,Q=h(J);switch(Q.kind){case"absent":return;case"unparsed":$.push({action:"keep",target:Z,detail:"left untouched — it does not parse as JSON"});return;case"empty":if(Q.removed>0)$.push({action:"remove",target:Z,detail:`${Q.removed} entries, all ours`});return;default:$.push({action:"unmerge",target:Z,detail:`drop ${Q.removed} harness entries, keep the rest`})}}function qZ($,Z,J){if(c(Z))return $.push({action:"unlink",target:Z,detail:`a link to ${x(Z)} — the checkout it points at is left exactly as it is`}),!0;if(!B(Z))return!1;for(let X of V$){let z=G(Z,X);if(B(z))$.push({action:"remove",target:z,detail:"runtime payload"})}let q=G(Z,k);if(B(q))$.push({action:"remove",target:q,detail:"install marker"});for(let X of G$){let z=G(Z,X);if(!B(z))continue;$.push(J?{action:"remove",target:z,detail:"--purge"}:{action:"keep",target:z,detail:"yours — add --purge to remove it"})}return!1}function XZ($,Z){if(B(G(Z,k)))$.push({action:"manual",target:`npm uninstall -g ${_}`,detail:"the global package is reported, never removed for you"});$.push({action:"manual",target:"rm -rf .tlc/ in each repository",detail:"per-project config and state — this command does not search your disk for them"})}function zZ($,Z={}){let J=Z.purge===!0,Q=[];QZ(Q,$.claudeSettings),JZ(Q,$.cursorHooks);for(let X of $.skillLinks)U$(Q,X,$.home,"skill link","location");for(let X of $.binLinks)U$(Q,X,$.home,"the tlc launcher on PATH","target");let q=qZ(Q,$.home,J);return XZ(Q,$.home),{items:Q,purge:J,homeIsLink:q}}function O$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function WZ($,Z){let J=[],Q=[];for(let q of O$($))try{if(q.action==="unmerge"&&q.target===Z.claudeSettings)W$(q.target);else if(q.action==="unmerge"){let X=h(S(q.target,"utf8"));if(X.kind==="rewritten")a8(q.target,X.text,"utf8")}else if(q.action==="unlink")n8(q.target);else l8(q.target,{recursive:!0,force:!0});J.push(q)}catch(X){Q.push({item:q,reason:X instanceof Error?X.message:String(X)})}return{applied:J,failed:Q}}var YZ={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function VZ($,Z){let J=O$($),Q=Z!==null;if(J.length===0)return{title:"harness uninstall",summary:["nothing wired"],sections:[{rows:[{label:"state",value:"no harness artefact found — nothing to undo",level:"ok"}]},{title:"STILL YOURS TO DO",rows:H$($)}],footer:`already clean · reinstall with npm i -g ${_}, then \`tlc harness install\``};let q=J.map((W)=>({label:W.action,value:`${W.target} — ${W.detail}`,level:YZ[W.action]})),X=$.items.filter((W)=>W.action==="keep"),z=[{title:Q?"REMOVED":"WOULD REMOVE",rows:q},...X.length>0?[{title:"KEPT",rows:X.map((W)=>({label:"keep",value:`${W.target} — ${W.detail}`,level:"ok"}))}]:[],{title:"STILL YOURS TO DO",rows:H$($)},...Z!==null&&Z.failed.length>0?[{title:"FAILED",rows:Z.failed.map((W)=>({label:"error",value:`${W.item.target} — ${W.reason}`,level:"fail"}))}]:[]];return{title:"harness uninstall",summary:[Q?`${Z.applied.length} applied`:`${J.length} pending`,$.purge?"purge: state included":"purge: state kept",$.homeIsLink?"runtime: linked checkout, unlinked only":"runtime: owned by the installer"],sections:z,footer:Q?`reinstall any time with npm i -g ${_}, then \`tlc harness install\``:"nothing was changed · re-run with --yes to apply this plan"}}function H$($){return $.items.filter((Z)=>Z.action==="manual").map((Z)=>({label:"run",value:`${Z.target} — ${Z.detail}`,level:"info"}))}function GZ($,Z,J=O){return I(VZ($,Z),J)}function BZ($=process.argv.slice(2)){let Z=t8(),J=zZ(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?WZ(J,Z):null;return console.log(GZ(J,Q,E())),Q!==null&&Q.failed.length>0?1:0}if(i.main==i.module)process.exitCode=BZ();export{t8 as uninstallTargets,VZ as uninstallScreen,GZ as uninstallReportText,zZ as planUninstall,O$ as pendingItems,BZ as main,e8 as isInsideRoot,d as canonicalise,WZ as applyUninstall};
package/docs/concepts.md CHANGED
@@ -116,8 +116,10 @@ A lock is reclaimed when it is older than 30 minutes, when it cannot be read, or
116
116
  — the last one only on the host that wrote it, since a pid means nothing on another machine
117
117
  ([/decisions/ad-024.md](/decisions/ad-024.md)).
118
118
 
119
- Each lint/test invocation writes `.tlc/harness/state/last-gate.json` (`harness.gate.v1`) with exit code,
120
- command, files, `outputTail`, and `findings`. Follow-up gaps and stagnation fingerprints use that artifact.
119
+ Each lint/test invocation writes `.tlc/harness/state/gate-sessions/<session>.json` (`harness.gate.v1`) with
120
+ exit code, command, files, `outputTail`, and `findings` — one file per session, so a concurrent neighbour's
121
+ own gate outcome is never read as this session's own
122
+ ([/decisions/ad-137.md](/decisions/ad-137.md)). Follow-up gaps and stagnation fingerprints use that artifact.
121
123
  Optional: the child may write findings to the path in `HARNESS_GATE_REPORT` (JSON
122
124
  `{ "findings": [{ "summary": "..." }] }`).
123
125
 
@@ -411,11 +413,15 @@ equivalent (`POST` to a path ending in `pulls`; `POST`/`PATCH` to a path touchin
411
413
  shape, and `pr-merge` recognizes `gh pr merge` and `PUT` to a path ending in `merge` under `pulls`
412
414
  ([/decisions/ad-128.md](/decisions/ad-128.md)) — scoped to the local checkout's own remote, so a `gh api` push
413
415
  to an unrelated repository (a template repo, a sibling project) is never gated by this project's own local
414
- state ([/decisions/ad-130.md](/decisions/ad-130.md)) — but a script written to disk and executed later, a
415
- command name built at runtime, a GraphQL mutation, `curl` direct to the API, a `gh api` call that flips a pull
416
- request to ready without going through `gh pr ready`, or a pull request opened, approved, or merged in a
417
- browser all still escape every trigger this project has. The rule covers the agent's shell path
418
- ([/decisions/ad-100.md](/decisions/ad-100.md)); it was never meant to cover more than that.
416
+ state ([/decisions/ad-130.md](/decisions/ad-130.md)). `pr-open` also recognizes the GitHub MCP server's
417
+ `create_pull_request` tool call, on both `mcp__<server>__create_pull_request` (Claude Code, any server name)
418
+ and the bare or host-prefixed form Cursor sends, with the same repository scoping
419
+ ([/decisions/ad-135.md](/decisions/ad-135.md)) — but that is the one evidenced MCP tool name, not MCP as a
420
+ class: a different MCP server or tool name performing the same act is not covered. A script written to disk
421
+ and executed later, a command name built at runtime, a GraphQL mutation, `curl` direct to the API, a `gh api`
422
+ call that flips a pull request to ready without going through `gh pr ready`, or a pull request opened,
423
+ approved, or merged in a browser all still escape every trigger this project has
424
+ ([/decisions/ad-100.md](/decisions/ad-100.md)).
419
425
 
420
426
  **The durable, non-bypassable boundary is GitHub's own branch protection, not this mechanism.** A required
421
427
  status check (posted by whatever proof this project's rule already demands — the-jury's review, a gate
package/docs/diagnose.md CHANGED
@@ -237,8 +237,8 @@ warns.
237
237
  2. Gates must not be PAUSED.
238
238
  3. Project `.tlc/harness/config.json` needs `grind.lintCommand` / `grind.testCommand` if you expect those
239
239
  gates.
240
- 4. On failure, inspect `.tlc/harness/state/last-gate.json` (`findings`, `exitCode`, `outputTail`) before
241
- trusting chat follow-up text.
240
+ 4. On failure, inspect `.tlc/harness/state/gate-sessions/<session>.json` (`findings`, `exitCode`,
241
+ `outputTail`) before trusting chat follow-up text.
242
242
  5. Concurrent agents: wait for `.tlc/harness/state/grind.lock` or stop the other grind.
243
243
  6. Stop status must be `completed` (aborted/error skips).
244
244
 
package/docs/log.md CHANGED
@@ -13,6 +13,10 @@ Generated from `docs/decisions/` — do not edit by hand. Run `node tools/render
13
13
  A reserved file of the [OKF v0.1](/decisions/ad-013.md) bundle: entries grouped under ISO 8601 headings,
14
14
  newest first. For what landed in which npm release, see `CHANGELOG.md` at the repository root.
15
15
 
16
+ ## 2026-09-11
17
+
18
+ - **AD-137** — Gate artifacts are scoped per session, the same fix AD-122 already proved for handoff ([/decisions/ad-137.md](/decisions/ad-137.md))
19
+
16
20
  ## 2026-09-10
17
21
 
18
22
  - **AD-130** — gh api pr-open/push/pr-merge detection is scoped to the local checkout's own remote ([/decisions/ad-130.md](/decisions/ad-130.md))
@@ -20,6 +24,8 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
20
24
  - **AD-132** — Git-root discovery replaces an exact-match guard, in the six functions and their consumers ([/decisions/ad-132.md](/decisions/ad-132.md))
21
25
  - **AD-133** — runCommand stops truncating; the one caller that needs a bound already had one ([/decisions/ad-133.md](/decisions/ad-133.md))
22
26
  - **AD-134** — runCommand moves to process.ts; listTrackedFiles never reaches for it again ([/decisions/ad-134.md](/decisions/ad-134.md))
27
+ - **AD-135** — pr-open recognizes its MCP surface ([/decisions/ad-135.md](/decisions/ad-135.md))
28
+ - **AD-136** — hook.enter makes a hook invocation observable before any decision is made ([/decisions/ad-136.md](/decisions/ad-136.md))
23
29
 
24
30
  ## 2026-09-09
25
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -39,7 +39,12 @@ import {
39
39
  scanProject,
40
40
  } from "./duplication/duplication.service.ts";
41
41
  import { evaluateFloor } from "./floor/floor.service.ts";
42
- import { computeGateFingerprint, readLastGate, writeLastGate } from "./gate/gate.artifact.ts";
42
+ import {
43
+ computeGateFingerprint,
44
+ pruneGateSessions,
45
+ readLastGate,
46
+ writeLastGate,
47
+ } from "./gate/gate.artifact.ts";
43
48
  import {
44
49
  appendFilesVerdict,
45
50
  isCommandResolutionFailure,
@@ -99,6 +104,7 @@ import {
99
104
  } from "./observability/observability.report.ts";
100
105
  import {
101
106
  DEFAULT_OBS,
107
+ deriveTraceId,
102
108
  recordAudit,
103
109
  recordFromEvent,
104
110
  recordObs,
@@ -143,7 +149,14 @@ import { isOperatorMode, OPERATOR_MODES } from "./policy/policy.posture.ts";
143
149
  import { activeRails } from "./policy/policy.rails.ts";
144
150
  import { pruneShadowed, shadowedKeys, typeMismatches, unknownKeys } from "./policy/policy.shadow.ts";
145
151
  import { forProvider } from "./policy/policy.types.ts";
146
- import { checkCollision, heartbeat, register, release, sweepStale } from "./presence/presence.service.ts";
152
+ import {
153
+ checkCollision,
154
+ filesClaimedByOtherLiveSessions,
155
+ heartbeat,
156
+ register,
157
+ release,
158
+ sweepStale,
159
+ } from "./presence/presence.service.ts";
147
160
  import { freshness, freshnessMessage, mayReplace, shouldRefetch } from "./pricing/pricing.freshness.ts";
148
161
  import {
149
162
  allDecisionFiles,
@@ -179,7 +192,7 @@ import {
179
192
  readRuleSources,
180
193
  recordObservation,
181
194
  } from "./rules/rules.store.ts";
182
- import { firingRules, mentionsGhApi, triggerMatches } from "./rules/rules.trigger.ts";
195
+ import { firingRules, mentionsGhApi, mentionsMcpAct, triggerMatches } from "./rules/rules.trigger.ts";
183
196
  import { scanForSecrets } from "./secret-scan/secret-scan.service.ts";
184
197
  import { placeholderFor } from "./secret-scan/secret-scan.store.ts";
185
198
  import { evaluateShellCommand } from "./shell-policy/shell-policy.service.ts";
@@ -289,6 +302,7 @@ export const coreFacade = {
289
302
  GateLockTimeoutError,
290
303
  writeLastGate,
291
304
  readLastGate,
305
+ pruneGateSessions,
292
306
  computeGateFingerprint,
293
307
  computeInputsHash,
294
308
  isCacheHit,
@@ -380,6 +394,7 @@ export const coreFacade = {
380
394
  observability: {
381
395
  DEFAULT_OBS,
382
396
  resolveObsLevel,
397
+ deriveTraceId,
383
398
  recordObs,
384
399
  recordFromEvent,
385
400
  recordAudit,
@@ -507,6 +522,7 @@ export const coreFacade = {
507
522
  firing: firingRules,
508
523
  triggerMatches,
509
524
  mentionsGhApi,
525
+ mentionsMcpAct,
510
526
  observations: readObservations,
511
527
  record: recordObservation,
512
528
  observationFrom,
@@ -524,6 +540,7 @@ export const coreFacade = {
524
540
  checkCollision,
525
541
  sweepStale,
526
542
  release,
543
+ filesClaimedByOtherLiveSessions,
527
544
  },
528
545
  floor: {
529
546
  evaluateFloor,
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { setProjectScopedEnv } from "../../platform/env-scope.ts";
5
- import { projectStateDir } from "../../platform/paths.ts";
5
+ import { gateSessionsDir, projectStateDir } from "../../platform/paths.ts";
6
6
  import { NO_OUTPUT_CAPTURED } from "../../platform/process.ts";
7
+ import { sanitizeSegment } from "../../platform/sanitize.ts";
7
8
  import { findingsFromLines } from "./gate.findings.ts";
8
9
  import { GATE_SCHEMA, type GateFinding, type LastGateArtifact } from "./gate.types.ts";
9
10
 
@@ -13,8 +14,8 @@ export const FINDINGS_MAX = 8;
13
14
  const FAIL_HINT =
14
15
  /(?:\bFAIL(?:ED)?\b|\bERROR\b|Error:|error\[|AssertionError|\bpanic:|✗|×|✕|✖|failures?\s*[:=]\s*[1-9])/i;
15
16
 
16
- export function lastGatePath(root: string): string {
17
- return join(projectStateDir(root), "last-gate.json");
17
+ export function lastGatePath(root: string, sessionKey: string): string {
18
+ return join(gateSessionsDir(root), `${sanitizeSegment(sessionKey)}.json`);
18
19
  }
19
20
 
20
21
  export function gateReportPath(root: string): string {
@@ -101,6 +102,7 @@ export function extractFindingsFromOutput(
101
102
 
102
103
  export function writeLastGate(args: {
103
104
  root: string;
105
+ sessionKey: string;
104
106
  gate: string;
105
107
  exitCode: number;
106
108
  command: string[];
@@ -137,14 +139,48 @@ export function writeLastGate(args: {
137
139
  scopedEnv: setProjectScopedEnv(),
138
140
  ...(args.inputsHash ? { inputsHash: args.inputsHash } : {}),
139
141
  };
140
- const path = lastGatePath(args.root);
142
+ const path = lastGatePath(args.root, args.sessionKey);
141
143
  mkdirSync(dirname(path), { recursive: true });
142
144
  writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
143
145
  return artifact;
144
146
  }
145
147
 
146
- export function readLastGate(root: string): LastGateArtifact | null {
147
- return readJson<LastGateArtifact>(lastGatePath(root));
148
+ export function readLastGate(root: string, sessionKey: string): LastGateArtifact | null {
149
+ return readJson<LastGateArtifact>(lastGatePath(root, sessionKey));
150
+ }
151
+
152
+ const GATE_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
153
+
154
+ export function pruneGateSessions(root: string, options: { now?: number; maxAgeMs?: number } = {}): number {
155
+ const dir = gateSessionsDir(root);
156
+ if (!existsSync(dir)) {
157
+ return 0;
158
+ }
159
+ const now = options.now ?? Date.now();
160
+ const maxAgeMs = options.maxAgeMs ?? GATE_SESSION_MAX_AGE_MS;
161
+ let pruned = 0;
162
+ for (const name of readdirSync(dir)) {
163
+ if (!name.endsWith(".json")) {
164
+ continue;
165
+ }
166
+ const path = join(dir, name);
167
+ const artifact = readJson<LastGateArtifact>(path);
168
+ // why: an unreadable or unparseable ts (a torn read racing a concurrent writer, or a shape from
169
+ // before this field existed) means "unknown", not "old" — the sibling handoff pruner
170
+ // ([/decisions/ad-122.md](/decisions/ad-122.md)) already treats doubt as a reason to keep a file, not
171
+ // delete it, and a false delete here costs a neighbour session's cached gate result.
172
+ const age = artifact ? now - Date.parse(artifact.ts) : Number.NaN;
173
+ if (Number.isNaN(age) || age < maxAgeMs) {
174
+ continue;
175
+ }
176
+ // why: a file already gone between `readdirSync` and this `unlinkSync` needs no retry — the same
177
+ // silent swallow `clearGateReport` already uses for its own single-file unlink, above.
178
+ try {
179
+ unlinkSync(path);
180
+ pruned += 1;
181
+ } catch {}
182
+ }
183
+ return pruned;
148
184
  }
149
185
 
150
186
  export function computeGateFingerprint(artifact: LastGateArtifact): string {
@@ -28,7 +28,7 @@ function shortId(): string {
28
28
  return randomUUID().replace(/-/g, "").slice(0, 16);
29
29
  }
30
30
 
31
- function deriveTraceId(sessionKey?: string): string {
31
+ export function deriveTraceId(sessionKey?: string): string {
32
32
  const seed = sessionKey || randomUUID();
33
33
  return createHash("sha256").update(seed).digest("hex").slice(0, 32);
34
34
  }
@@ -1,4 +1,8 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
1
3
  import type { Decision } from "../../contracts/decision.ts";
4
+ import { gitRootOf } from "../../platform/git.ts";
5
+ import { normalizeSeparators } from "../../platform/sanitize.ts";
2
6
  import {
3
7
  deletePresenceRecord,
4
8
  listPresenceRecords,
@@ -162,4 +166,68 @@ export function isSessionLive(
162
166
  return record !== null && !isStale(record, now.getTime(), CONVERSATION_STALE_MS);
163
167
  }
164
168
 
169
+ // why: `gitRootOf` resolves symlinks and Windows short (8.3) names, but a claim's own path is not guaranteed
170
+ // to be — reproduced on both macOS's `/tmp` → `/private/tmp` and Windows's `RUNNER~1` → `runneradmin`.
171
+ // `realpathSync` alone does not expand 8.3 names (a pure-JS, per-segment resolver); `.native` asks the OS.
172
+ function realpathExistingPrefix(path: string): string {
173
+ let current = path;
174
+ let tail = "";
175
+ for (;;) {
176
+ try {
177
+ const real = realpathSync.native(current);
178
+ return tail ? join(real, tail) : real;
179
+ } catch {
180
+ const parent = dirname(current);
181
+ if (parent === current) {
182
+ return path;
183
+ }
184
+ tail = tail ? join(basename(current), tail) : basename(current);
185
+ current = parent;
186
+ }
187
+ }
188
+ }
189
+
190
+ function relativeClaim(resolvedGitRoot: string, file: string): string {
191
+ return normalizeSeparators(
192
+ isAbsolute(file) ? relative(resolvedGitRoot, realpathExistingPrefix(file)) : file,
193
+ );
194
+ }
195
+
196
+ /**
197
+ * hazard: two concurrent sessions in the same checkout both read `git diff` against the same shared working
198
+ * tree — a file only one of them ever touched still shows up in the *other's* own diff, because git has no
199
+ * concept of which agent wrote an uncommitted change. A file is excluded only when a *live* neighbour's own
200
+ * claim names it and this session's own claims do not — an unclaimed file (a shell script, a generated
201
+ * output) or one both sessions touched stays in scope, so the existing single-session behaviour this exists
202
+ * to leave alone is never narrowed by a guess ([/decisions/ad-137.md](/decisions/ad-137.md)).
203
+ */
204
+ export async function filesClaimedByOtherLiveSessions(
205
+ root: string,
206
+ gitRoot: string,
207
+ provider: string,
208
+ sessionKey: string,
209
+ now: Date = new Date(),
210
+ ): Promise<Set<string>> {
211
+ const resolvedGitRoot = realpathExistingPrefix((await gitRootOf(gitRoot)) ?? gitRoot);
212
+ const mine = sessionIdFromSessionKey(provider, sessionKey);
213
+ const own = readPresenceRecord(root, provider, mine);
214
+ const ownFiles = new Set((own?.recent_files ?? []).map((file) => relativeClaim(resolvedGitRoot, file)));
215
+ const claimed = new Set<string>();
216
+ for (const record of listPresenceRecords(root)) {
217
+ if (record.provider === provider && record.session === mine) {
218
+ continue;
219
+ }
220
+ if (isStale(record, now.getTime(), CONVERSATION_STALE_MS)) {
221
+ continue;
222
+ }
223
+ for (const rawFile of record.recent_files) {
224
+ const file = relativeClaim(resolvedGitRoot, rawFile);
225
+ if (!ownFiles.has(file)) {
226
+ claimed.add(file);
227
+ }
228
+ }
229
+ }
230
+ return claimed;
231
+ }
232
+
165
233
  export { listPresenceRecords, presenceSessionKey, readPresenceRecord };
@@ -27,6 +27,12 @@ export type TriggerContext = {
27
27
  * ([/decisions/ad-130.md](/decisions/ad-130.md)).
28
28
  */
29
29
  repoRemote?: { owner: string; repo: string } | null;
30
+ /**
31
+ * why optional, and read only by the MCP shape: a shell-triggered event never carries this, and the vast
32
+ * majority of MCP events are not pr-open-shaped either — populating it for every event would be work spent
33
+ * on a field almost nothing reads ([/decisions/ad-135.md](/decisions/ad-135.md)).
34
+ */
35
+ toolInput?: Record<string, unknown>;
30
36
  };
31
37
 
32
38
  /**
@@ -256,6 +262,81 @@ function matchesAnyShape(
256
262
  return apiShapes.some((shape) => matchesApiShape(words, shape, repoRemote));
257
263
  }
258
264
 
265
+ /**
266
+ * why a structural strip, not a literal prefix list: an MCP server's name is operator-configured
267
+ * (`mcp__github__create_pull_request` vs. `mcp__gh__create_pull_request` name the same tool through two
268
+ * differently-configured servers), so a fixed string would miss every server name this project has not seen
269
+ * yet — the same "why a basename fallback" reasoning `tokenMatches` already applies to a script's own path
270
+ * ([/decisions/ad-121.md](/decisions/ad-121.md)).
271
+ */
272
+ export function normalizeMcpToolName(toolName: string): string {
273
+ const withoutHostPrefix = toolName.startsWith("MCP:") ? toolName.slice(4) : toolName;
274
+ if (!withoutHostPrefix.startsWith("mcp__")) {
275
+ return withoutHostPrefix;
276
+ }
277
+ const afterMcp = withoutHostPrefix.slice("mcp__".length);
278
+ const lastSeparator = afterMcp.lastIndexOf("__");
279
+ return lastSeparator === -1 ? afterMcp : afterMcp.slice(lastSeparator + 2);
280
+ }
281
+
282
+ /**
283
+ * why a third grammar, not a widened `ApiShape`: an MCP tool call has no path or HTTP method to infer a verb
284
+ * from — the tool name already names the act, the way a CLI subcommand does. Reusing `ApiShape`'s fields
285
+ * would leave `lastSegment`/`methods` meaningless for this grammar ([/decisions/ad-135.md](/decisions/ad-135.md)).
286
+ */
287
+ type McpShape = {
288
+ readonly toolName: string;
289
+ readonly ownerField: string;
290
+ readonly repoField: string;
291
+ };
292
+
293
+ const MCP_SHAPES: Partial<Record<"pr-open" | "commit" | "push" | "pr-merge", readonly McpShape[]>> = {
294
+ "pr-open": [{ toolName: "create_pull_request", ownerField: "owner", repoField: "repo" }],
295
+ };
296
+
297
+ function matchesMcpShape(
298
+ context: TriggerContext,
299
+ shape: McpShape,
300
+ repoRemote?: { owner: string; repo: string } | null,
301
+ ): boolean {
302
+ if (context.toolName === undefined || normalizeMcpToolName(context.toolName) !== shape.toolName) {
303
+ return false;
304
+ }
305
+ const owner = context.toolInput?.[shape.ownerField];
306
+ const repo = context.toolInput?.[shape.repoField];
307
+ if (typeof owner !== "string" || typeof repo !== "string") {
308
+ return true;
309
+ }
310
+ if (repoRemote === undefined) {
311
+ return true;
312
+ }
313
+ if (repoRemote === null) {
314
+ return false;
315
+ }
316
+ return owner === repoRemote.owner && repo === repoRemote.repo;
317
+ }
318
+
319
+ function matchesAnyMcpShape(
320
+ kind: "pr-open" | "commit" | "push" | "pr-merge",
321
+ context: TriggerContext,
322
+ ): boolean {
323
+ const mcpShapes = MCP_SHAPES[kind] ?? [];
324
+ return mcpShapes.some((shape) => matchesMcpShape(context, shape, context.repoRemote));
325
+ }
326
+
327
+ /**
328
+ * why exported, mirroring `mentionsGhApi`: the caller deciding whether `repoRemote` is worth a process spawn
329
+ * needs to know whether the MCP-shaped path could possibly be in play, without paying for `localRepoRemote`
330
+ * on every unrelated tool call ([/decisions/ad-130.md](/decisions/ad-130.md), [/decisions/ad-135.md](/decisions/ad-135.md)).
331
+ */
332
+ export function mentionsMcpAct(toolName: string | undefined): boolean {
333
+ if (toolName === undefined) {
334
+ return false;
335
+ }
336
+ const normalized = normalizeMcpToolName(toolName);
337
+ return Object.values(MCP_SHAPES).some((shapes) => shapes.some((shape) => shape.toolName === normalized));
338
+ }
339
+
259
340
  export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): boolean {
260
341
  switch (trigger.kind) {
261
342
  case "stop":
@@ -266,12 +347,15 @@ export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): b
266
347
  case "commit":
267
348
  case "push":
268
349
  case "pr-merge": {
269
- if (context.command === undefined) {
270
- return false;
350
+ const byCommand =
351
+ context.command !== undefined &&
352
+ subCommands(context.command).some((words) =>
353
+ matchesAnyShape(words, trigger.kind, context.repoRemote),
354
+ );
355
+ if (byCommand) {
356
+ return true;
271
357
  }
272
- return subCommands(context.command).some((words) =>
273
- matchesAnyShape(words, trigger.kind, context.repoRemote),
274
- );
358
+ return matchesAnyMcpShape(trigger.kind, context);
275
359
  }
276
360
  default: {
277
361
  if (context.command === undefined) {
@@ -60,11 +60,21 @@ export function activitySince(events: readonly ObsEvent[], sessionKey: string):
60
60
  };
61
61
  }
62
62
 
63
- export function readTurnActivity(root: string, sessionKey: string, limit = 500): TurnActivity {
64
- return activitySince(
65
- ACTIVITY_PLANES.flatMap((plane) => readSignalEvents(root, plane, limit)),
66
- sessionKey,
63
+ function isTurnRelevant(event: ObsEvent): boolean {
64
+ return event.kind === TURN_START || TOOL_KINDS.has(event.kind);
65
+ }
66
+
67
+ // hazard: truncating to `limit` before filtering let a noise kind (`hook.enter`, one record per gate-relevant
68
+ // hook call) push `prompt.submit` out of the window this function reads — the same order-of-operations defect
69
+ // `tools/obs-cli.ts`'s `liveEvents` had. Read wide, filter to what this function actually counts, then cut to
70
+ // `limit` ([/decisions/ad-136.md](/decisions/ad-136.md)).
71
+ export function readTurnActivity(root: string, sessionKey: string, limit = 500, readWide = 20): TurnActivity {
72
+ const events = ACTIVITY_PLANES.flatMap((plane) =>
73
+ readSignalEvents(root, plane, Math.max(limit * readWide, limit))
74
+ .filter(isTurnRelevant)
75
+ .slice(-limit),
67
76
  );
77
+ return activitySince(events, sessionKey);
68
78
  }
69
79
 
70
80
  export type IdleTurnInput = {