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

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.
@@ -1,4 +1,4 @@
1
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
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};
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 _="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as y8,mkdirSync as hX,readFileSync as v8,realpathSync as pX,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((N)=>!h8(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(!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,N]of Object.entries(q)){let d=Array.isArray(N)?N:[],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 sX,resolve as rX,sep as oX}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],X4=[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 ${_}`,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 ${_}, 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 ${_}, 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};
package/docs/concepts.md CHANGED
@@ -411,11 +411,15 @@ equivalent (`POST` to a path ending in `pulls`; `POST`/`PATCH` to a path touchin
411
411
  shape, and `pr-merge` recognizes `gh pr merge` and `PUT` to a path ending in `merge` under `pulls`
412
412
  ([/decisions/ad-128.md](/decisions/ad-128.md)) — scoped to the local checkout's own remote, so a `gh api` push
413
413
  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.
414
+ state ([/decisions/ad-130.md](/decisions/ad-130.md)). `pr-open` also recognizes the GitHub MCP server's
415
+ `create_pull_request` tool call, on both `mcp__<server>__create_pull_request` (Claude Code, any server name)
416
+ and the bare or host-prefixed form Cursor sends, with the same repository scoping
417
+ ([/decisions/ad-135.md](/decisions/ad-135.md)) — but that is the one evidenced MCP tool name, not MCP as a
418
+ class: a different MCP server or tool name performing the same act is not covered. A script written to disk
419
+ and executed later, a command name built at runtime, a GraphQL mutation, `curl` direct to the API, a `gh api`
420
+ call that flips a pull request to ready without going through `gh pr ready`, or a pull request opened,
421
+ approved, or merged in a browser all still escape every trigger this project has
422
+ ([/decisions/ad-100.md](/decisions/ad-100.md)).
419
423
 
420
424
  **The durable, non-bypassable boundary is GitHub's own branch protection, not this mechanism.** A required
421
425
  status check (posted by whatever proof this project's rule already demands — the-jury's review, a gate
package/docs/log.md CHANGED
@@ -20,6 +20,8 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
20
20
  - **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
21
  - **AD-133** — runCommand stops truncating; the one caller that needs a bound already had one ([/decisions/ad-133.md](/decisions/ad-133.md))
22
22
  - **AD-134** — runCommand moves to process.ts; listTrackedFiles never reaches for it again ([/decisions/ad-134.md](/decisions/ad-134.md))
23
+ - **AD-135** — pr-open recognizes its MCP surface ([/decisions/ad-135.md](/decisions/ad-135.md))
24
+ - **AD-136** — hook.enter makes a hook invocation observable before any decision is made ([/decisions/ad-136.md](/decisions/ad-136.md))
23
25
 
24
26
  ## 2026-09-09
25
27
 
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.0",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -99,6 +99,7 @@ import {
99
99
  } from "./observability/observability.report.ts";
100
100
  import {
101
101
  DEFAULT_OBS,
102
+ deriveTraceId,
102
103
  recordAudit,
103
104
  recordFromEvent,
104
105
  recordObs,
@@ -179,7 +180,7 @@ import {
179
180
  readRuleSources,
180
181
  recordObservation,
181
182
  } from "./rules/rules.store.ts";
182
- import { firingRules, mentionsGhApi, triggerMatches } from "./rules/rules.trigger.ts";
183
+ import { firingRules, mentionsGhApi, mentionsMcpAct, triggerMatches } from "./rules/rules.trigger.ts";
183
184
  import { scanForSecrets } from "./secret-scan/secret-scan.service.ts";
184
185
  import { placeholderFor } from "./secret-scan/secret-scan.store.ts";
185
186
  import { evaluateShellCommand } from "./shell-policy/shell-policy.service.ts";
@@ -380,6 +381,7 @@ export const coreFacade = {
380
381
  observability: {
381
382
  DEFAULT_OBS,
382
383
  resolveObsLevel,
384
+ deriveTraceId,
383
385
  recordObs,
384
386
  recordFromEvent,
385
387
  recordAudit,
@@ -507,6 +509,7 @@ export const coreFacade = {
507
509
  firing: firingRules,
508
510
  triggerMatches,
509
511
  mentionsGhApi,
512
+ mentionsMcpAct,
510
513
  observations: readObservations,
511
514
  record: recordObservation,
512
515
  observationFrom,
@@ -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
  }
@@ -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 = {
@@ -62,19 +62,55 @@ function errorMessage(error: unknown): string {
62
62
  }
63
63
 
64
64
  // why: ObsKind is a closed union with no adapter-boundary member — these fire before a provider/session is known, so they bypass core's typed observability rather than widening that union from outside core.
65
- function recordAdapterEvent(root: string, kind: string, attrs: Record<string, unknown>): void {
65
+ function recordAdapterEvent(
66
+ root: string,
67
+ kind: string,
68
+ attrs: Record<string, unknown>,
69
+ provider = "unknown",
70
+ top: Record<string, unknown> = {},
71
+ ): void {
66
72
  try {
67
73
  appendRecord(join(projectStateDir(root), "obs.jsonl"), {
68
74
  schema: "harness.observability.v1",
69
- provider: "unknown",
75
+ provider,
70
76
  kind,
71
77
  level: "signal",
72
78
  ts: new Date().toISOString(),
79
+ ...top,
73
80
  attrs,
74
81
  });
75
82
  } catch {}
76
83
  }
77
84
 
85
+ // hazard: unscoped, one record per hook invocation of any kind flooded the signal plane and pushed
86
+ // `prompt.submit`/`policy.deny` out of two readers' fixed-count tails ([/decisions/ad-136.md](/decisions/ad-136.md)).
87
+ function isGateRelevantHookEvent(event: HarnessEvent): boolean {
88
+ return event.event === "shell.before" || event.event === "mcp.before";
89
+ }
90
+
91
+ /**
92
+ * AD-136 — a hook invocation that never reaches the point where any existing record gets written (the handler
93
+ * killed mid-flight, a host-side timeout) previously left nothing in `obs.jsonl` at all — indistinguishable
94
+ * from a hook that never started. This is the earliest point after an event is known: before `loadPolicy`,
95
+ * before `handler` runs, before anything that could throw or take time. A future incident reads as "entered,
96
+ * no paired completion" instead of being reconstructed from raw provider traces after the fact.
97
+ */
98
+ function recordHookEnter(event: HarnessEvent): void {
99
+ if (!isGateRelevantHookEvent(event)) {
100
+ return;
101
+ }
102
+ recordAdapterEvent(
103
+ event.projectDir,
104
+ "hook.enter",
105
+ { event: event.event, toolName: event.toolName ?? "none", sessionKey: event.sessionKey },
106
+ event.provider,
107
+ {
108
+ trace_id: coreFacade.observability.deriveTraceId(event.sessionKey),
109
+ session_id: event.sessionKey,
110
+ },
111
+ );
112
+ }
113
+
78
114
  /**
79
115
  * hazard: `policy.deny` fed `rollup.denials` and the report's "Policy denials" line, and had no producer — so a
80
116
  * harness whose whole purpose is refusing things reported zero refusals
@@ -155,6 +191,7 @@ export async function runHandler(handler: Handler, io: RunIo = {}): Promise<RunO
155
191
  }
156
192
 
157
193
  const capabilities = provider.capabilities();
194
+ recordHookEnter(event);
158
195
 
159
196
  try {
160
197
  const policy = coreFacade.policy.loadPolicy(event.projectDir);
@@ -94,7 +94,12 @@ function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, de
94
94
  */
95
95
  async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
96
96
  const config = ctx.policy.rules;
97
- const trigger = { event: event.event, toolName: event.toolName, command: event.command };
97
+ const trigger = {
98
+ event: event.event,
99
+ toolName: event.toolName,
100
+ command: event.command,
101
+ toolInput: event.toolInput,
102
+ };
98
103
  const shaRoot = shaScopeRoot(event);
99
104
  const dryRun = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
100
105
  sha: null,
@@ -110,7 +115,10 @@ async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<
110
115
  // ([/decisions/ad-130.md](/decisions/ad-130.md)) — a CLI shape or an unrelated trigger never reads it.
111
116
  const [sha, repoRemote] = await Promise.all([
112
117
  currentGitSha(shaRoot),
113
- event.command && coreFacade.rules.mentionsGhApi(event.command) ? localRepoRemote(shaRoot) : undefined,
118
+ (event.command && coreFacade.rules.mentionsGhApi(event.command)) ||
119
+ coreFacade.rules.mentionsMcpAct(event.toolName)
120
+ ? localRepoRemote(shaRoot)
121
+ : undefined,
114
122
  ]);
115
123
  const verdict = coreFacade.rules.decideAction(
116
124
  event.projectDir,
@@ -36,6 +36,27 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
36
36
  : undefined;
37
37
  }
38
38
 
39
+ /**
40
+ * hazard: `beforeMCPExecution`'s own `tool_input` is documented as a JSON *string* ("JSON params string that
41
+ * will be passed to the tool"), not an object like `preToolUse`'s — `asRecord` alone left it `undefined` for
42
+ * every MCP call on this host, silently skipping any rule that reads `toolInput` fields
43
+ * ([/decisions/ad-135.md](/decisions/ad-135.md)).
44
+ */
45
+ function mcpToolInput(value: unknown): Record<string, unknown> | undefined {
46
+ const direct = asRecord(value);
47
+ if (direct) {
48
+ return direct;
49
+ }
50
+ if (typeof value !== "string") {
51
+ return undefined;
52
+ }
53
+ try {
54
+ return asRecord(JSON.parse(value));
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
39
60
  function asStatus(value: unknown): "completed" | "aborted" | "error" | undefined {
40
61
  return value === "completed" || value === "aborted" || value === "error" ? value : undefined;
41
62
  }
@@ -164,7 +185,7 @@ export function cursorToEvent(raw: Record<string, unknown>): HarnessEvent | null
164
185
  if (toolName) {
165
186
  event.toolName = toolName;
166
187
  }
167
- const toolInput = asRecord(raw.tool_input);
188
+ const toolInput = mcpToolInput(raw.tool_input);
168
189
  if (toolInput) {
169
190
  event.toolInput = toolInput;
170
191
  }
package/tools/obs-cli.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "nod
2
2
  import { join } from "node:path";
3
3
  import { coreFacade } from "../src/core/index.ts";
4
4
  import { readSignalEvents } from "../src/core/observability/observability.store.ts";
5
- import { DEFAULT_OBS, type ObsEvent } from "../src/core/observability/observability.types.ts";
5
+ import { DEFAULT_OBS, LIVE_ALLOWLIST, type ObsEvent } from "../src/core/observability/observability.types.ts";
6
6
  import { loadPolicy } from "../src/core/policy/policy.loader.ts";
7
7
  import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
8
8
  import { projectStateDir } from "../src/platform/paths.ts";
@@ -31,6 +31,16 @@ export function limitFrom(raw: string | undefined, fallback: number): number {
31
31
  return Number.isFinite(parsed) ? parsed : fallback;
32
32
  }
33
33
 
34
+ // hazard: filtering a tail already truncated to `limit` leaves a noise kind (`hook.enter`, one record per
35
+ // gate-relevant hook call) able to push every allowlisted event out of the window the operator actually reads
36
+ // — the window has to be read wide, filtered, then cut to `limit`, never the other order
37
+ // ([/decisions/ad-136.md](/decisions/ad-136.md)).
38
+ export function liveEvents(root: string, limit: number, readWide = 20): readonly ObsEvent[] {
39
+ const wide = readSignalEvents(root, DEFAULT_OBS.signalPath, Math.max(limit * readWide, limit));
40
+ const allowed = wide.filter((event) => LIVE_ALLOWLIST.has(event.kind));
41
+ return allowed.slice(-limit);
42
+ }
43
+
34
44
  /**
35
45
  * hazard: this sorted by filename. A session id is a UUID, so alphabetical order has no relation to time — on
36
46
  * this machine it picked a session from thirteen days earlier whose every counter was zero, which reads exactly
@@ -67,7 +77,7 @@ function main(argv: string[]): void {
67
77
  const arg = rest[1];
68
78
 
69
79
  if (cmd === "live") {
70
- const events = readSignalEvents(root, DEFAULT_OBS.signalPath, limitFrom(arg, 40));
80
+ const events = liveEvents(root, limitFrom(arg, 40));
71
81
  if (json) {
72
82
  emitJson(liveJson(events));
73
83
  } else {