@tech-leads-club/harness-toolkit 0.8.0 → 0.10.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 M$}from"node:module";var l=M$(import.meta.url);import{existsSync as B,lstatSync as u8,readFileSync as S,readlinkSync as g8,realpathSync as G$,rmSync as h8,statSync as p8,unlinkSync as c8,writeFileSync as d8}from"node:fs";import{homedir as l8}from"node:os";import{basename as i8,dirname as B$,isAbsolute as H$,join as G,relative as n8,resolve as h}from"node:path";import{homedir as L}from"node:os";import{delimiter as K2,dirname as U2,join as j,resolve as M2}from"node:path";function i(){return j(L(),".tlc","harness")}function F($=process.env){return $.TLC_HOME??i()}function P(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:j(L(),".claude")}function T(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:j(L(),".cursor")}function f($=process.env){return $.TLC_BIN_DIR?.trim()||j(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 C$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let X of J.extensions)Z.set(X.toLowerCase(),Q)}return Z}var _$=C$(s);var N$=_$.size;var y=6;var A$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var FZ=new Set([...A$,"nc","ncat","socat","telnet","lwp-request"]);var R$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",_Z=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${R$}))(?=[:)\\s,'"\`]|$)`,"g");var A="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:A,lastSeenAt:A,lastAccessedAt:A,updatedAt:A}}var W5=[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 d$={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 l$($){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 $$=String.fromCharCode(27),i$=`${$$}[0m`;function n$($=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 a$={ok:"success",warn:"warning",fail:"error",info:"info"},s$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},m=16;function R($=n$()){let Z=(Q,X)=>$?`${$$}[${Q}m${X}${i$}`:X,J=(Q,X)=>Z(`38;2;${l$(d$[Q])}`,X);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,X,q=m)=>` ${J("textMuted",`${Q}:`.padEnd(q))} ${X}`,status:(Q,X)=>`${J(a$[Q],s$[Q])} ${X}`}}var O=R(!1);var r$=2;function o$($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function t$($,Z){if($==="")return[""];let J=[],Q="";for(let X of $.split(/\s+/).filter((q)=>q.length>0)){if(Q===""){Q=X;continue}if(Q.length+1+X.length<=Z){Q=`${Q} ${X}`;continue}J.push(Q),Q=X}if(Q!=="")J.push(Q);return J.length===0?[""]:J}function I($,Z,J=o$()){let Q=[Z.heading($.title.toUpperCase())];if($.summary&&$.summary.length>0)Q.push(` ${$.summary.join(Z.dim(` ${V.bar} `))}`);let X=Math.max(m,...$.sections.flatMap((q)=>(q.rows??[]).map((z)=>z.label.length+1)));for(let q of $.sections){if(Q.push(""),q.title)Q.push(Z.paint("accent",q.title));for(let z of q.rows??[]){let W=z.level?Z.status(z.level,z.value):z.value;Q.push(Z.kv(z.label,W,X))}for(let z of q.lines??[]){if(z===""){Q.push("");continue}let W=q.wrap?t$(z,J-r$):[z];for(let H of W)Q.push(` ${H}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
2
2
  `)}var K={enabled:!0,signalPath:"obs.jsonl",debugPath:"debug.jsonl",debugEnabled:!1,includePayloads:!1,maxAttrChars:500,sessionCostAlertUsd:5,retentionDays:14,maxSignalEvents:50000,globalSpool:!1};var $8=["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 Z8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},Z$={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:K.includePayloads,maxAttrChars:K.maxAttrChars,sessionCostAlertUsd:K.sessionCostAlertUsd,retentionDays:K.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...Z8}},mcpPrime:[],bootstrapExtra:[]};var C="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as f8,mkdirSync as rX,readFileSync as b8,realpathSync as oX,writeFileSync as w8}from"node:fs";function Q$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function y8($){return Q$($)}var v8="tlc-exec.mjs";function m8($){return JSON.stringify($??null).includes(v8)}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(!Q$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=y8(Z.hooks)?Z.hooks:{},Q={},X=!1;for(let[z,W]of Object.entries(J)){let H=W.filter((_)=>!m8(_));if(H.length!==W.length)X=!0;if(H.length>0)Q[z]=H}let q={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")q[z]=W;else if(Object.keys(Q).length>0)q.hooks=Q;return{ok:!0,settingsText:JSON.stringify(q,null,2),changed:X}}function J$($){if(!f8($))return{ok:!0,settingsText:"",changed:!1};let Z=u(b8($,"utf8"));if(Z.ok&&Z.changed)w8($,Z.settingsText,"utf8");return Z}function g($,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,X=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},q={},z=0,W=0;for(let[H,_]of Object.entries(X)){let d=Array.isArray(_)?_:[],N=d.filter((U$)=>!JSON.stringify(U$??null).includes(Z));if(z+=d.length-N.length,N.length>0)q[H]=N,W+=N.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:q},null,2)}
4
- `}}import{join as X$,relative as X4,resolve as q4,sep as z4}from"node:path";var q$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],z$=["config.json","state","flags","rules"],U4=[X$("tools","dev"),X$("tools","__test__")];function a8($=process.env){let Z=l8(),J=f($);return{home:F($),binLinks:n().map((Q)=>G(J,Q)),claudeSettings:G(P(),"settings.json"),cursorHooks:G(T(),"hooks.json"),skillLinks:[G(P(),"skills","harness-init"),G(T(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function p($){try{return u8($).isSymbolicLink()}catch{return!1}}function c($){let Z=h($),J=[];for(;;)try{return G(G$(Z),...J)}catch{let Q=B$(Z);if(Q===Z)return h($);J.unshift(i8(Z)),Z=Q}}function x($){try{return G$($)}catch{try{let Z=g8($);return c(H$(Z)?Z:h(B$($),Z))}catch{return c($)}}}function s8($,Z,J={relative:n8,isAbsolute:H$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function W$($,Z){return s8(x($),c(Z))}var r8="tlc-exec.mjs";function o8($){try{return p8($).size<4096&&S($,"utf8").includes(r8)}catch{return!1}}function Y$($,Z,J,Q,X){if(!B(Z)&&!p(Z))return;if(!p(Z)){if(o8(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(X==="target"&&!W$(Z,J)){$.push({action:"keep",target:Z,detail:`points at ${x(Z)} — not ours`});return}let q=X==="location"&&!W$(Z,J);$.push({action:"unlink",target:Z,detail:q?`${Q}, stale — points at ${x(Z)}`:Q})}function t8($,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 e8($,Z){let J=B(Z)?S(Z,"utf8"):null,Q=g(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 $2($,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 q of q$){let z=G(Z,q);if(B(z))$.push({action:"remove",target:z,detail:"runtime payload"})}let X=G(Z,k);if(B(X))$.push({action:"remove",target:X,detail:"install marker"});for(let q of z$){let z=G(Z,q);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 Z2($,Z){if(B(G(Z,k)))$.push({action:"manual",target:`npm uninstall -g ${C}`,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 Q2($,Z={}){let J=Z.purge===!0,Q=[];t8(Q,$.claudeSettings),e8(Q,$.cursorHooks);for(let q of $.skillLinks)Y$(Q,q,$.home,"skill link","location");for(let q of $.binLinks)Y$(Q,q,$.home,"the tlc launcher on PATH","target");let X=$2(Q,$.home,J);return Z2(Q,$.home),{items:Q,purge:J,homeIsLink:X}}function K$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function J2($,Z){let J=[],Q=[];for(let X of K$($))try{if(X.action==="unmerge"&&X.target===Z.claudeSettings)J$(X.target);else if(X.action==="unmerge"){let q=g(S(X.target,"utf8"));if(q.kind==="rewritten")d8(X.target,q.text,"utf8")}else if(X.action==="unlink")c8(X.target);else h8(X.target,{recursive:!0,force:!0});J.push(X)}catch(q){Q.push({item:X,reason:q instanceof Error?q.message:String(q)})}return{applied:J,failed:Q}}var X2={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function q2($,Z){let J=K$($),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:V$($)}],footer:`already clean · reinstall with npm i -g ${C}, then \`tlc harness install\``};let X=J.map((W)=>({label:W.action,value:`${W.target} — ${W.detail}`,level:X2[W.action]})),q=$.items.filter((W)=>W.action==="keep"),z=[{title:Q?"REMOVED":"WOULD REMOVE",rows:X},...q.length>0?[{title:"KEPT",rows:q.map((W)=>({label:"keep",value:`${W.target} — ${W.detail}`,level:"ok"}))}]:[],{title:"STILL YOURS TO DO",rows:V$($)},...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 ${C}, then \`tlc harness install\``:"nothing was changed · re-run with --yes to apply this plan"}}function V$($){return $.items.filter((Z)=>Z.action==="manual").map((Z)=>({label:"run",value:`${Z.target} — ${Z.detail}`,level:"info"}))}function z2($,Z,J=O){return I(q2($,Z),J)}function W2($=process.argv.slice(2)){let Z=a8(),J=Q2(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?J2(J,Z):null;return console.log(z2(J,Q,R())),Q!==null&&Q.failed.length>0?1:0}if(l.main==l.module)process.exitCode=W2();export{a8 as uninstallTargets,q2 as uninstallScreen,z2 as uninstallReportText,Q2 as planUninstall,K$ as pendingItems,W2 as main,s8 as isInsideRoot,c as canonicalise,J2 as applyUninstall};
3
+ `);var Z8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},Z$={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:K.includePayloads,maxAttrChars:K.maxAttrChars,sessionCostAlertUsd:K.sessionCostAlertUsd,retentionDays:K.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...Z8}},mcpPrime:[],bootstrapExtra:[]};var C="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as f8,mkdirSync as oX,readFileSync as b8,realpathSync as tX,writeFileSync as w8}from"node:fs";function Q$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function y8($){return Q$($)}var v8="tlc-exec.mjs";function m8($){return JSON.stringify($??null).includes(v8)}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(!Q$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=y8(Z.hooks)?Z.hooks:{},Q={},X=!1;for(let[z,W]of Object.entries(J)){let H=W.filter((_)=>!m8(_));if(H.length!==W.length)X=!0;if(H.length>0)Q[z]=H}let q={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")q[z]=W;else if(Object.keys(Q).length>0)q.hooks=Q;return{ok:!0,settingsText:JSON.stringify(q,null,2),changed:X}}function J$($){if(!f8($))return{ok:!0,settingsText:"",changed:!1};let Z=u(b8($,"utf8"));if(Z.ok&&Z.changed)w8($,Z.settingsText,"utf8");return Z}function g($,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,X=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},q={},z=0,W=0;for(let[H,_]of Object.entries(X)){let d=Array.isArray(_)?_:[],N=d.filter((U$)=>!JSON.stringify(U$??null).includes(Z));if(z+=d.length-N.length,N.length>0)q[H]=N,W+=N.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:q},null,2)}
4
+ `}}import{join as X$,relative as q4,resolve as z4,sep as W4}from"node:path";var q$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],z$=["config.json","state","flags","rules"],M4=[X$("tools","dev"),X$("tools","__test__")];function a8($=process.env){let Z=l8(),J=f($);return{home:F($),binLinks:n().map((Q)=>G(J,Q)),claudeSettings:G(P(),"settings.json"),cursorHooks:G(T(),"hooks.json"),skillLinks:[G(P(),"skills","harness-init"),G(T(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function p($){try{return u8($).isSymbolicLink()}catch{return!1}}function c($){let Z=h($),J=[];for(;;)try{return G(G$(Z),...J)}catch{let Q=B$(Z);if(Q===Z)return h($);J.unshift(i8(Z)),Z=Q}}function x($){try{return G$($)}catch{try{let Z=g8($);return c(H$(Z)?Z:h(B$($),Z))}catch{return c($)}}}function s8($,Z,J={relative:n8,isAbsolute:H$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function W$($,Z){return s8(x($),c(Z))}var r8="tlc-exec.mjs";function o8($){try{return p8($).size<4096&&S($,"utf8").includes(r8)}catch{return!1}}function Y$($,Z,J,Q,X){if(!B(Z)&&!p(Z))return;if(!p(Z)){if(o8(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(X==="target"&&!W$(Z,J)){$.push({action:"keep",target:Z,detail:`points at ${x(Z)} — not ours`});return}let q=X==="location"&&!W$(Z,J);$.push({action:"unlink",target:Z,detail:q?`${Q}, stale — points at ${x(Z)}`:Q})}function t8($,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 e8($,Z){let J=B(Z)?S(Z,"utf8"):null,Q=g(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 $2($,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 q of q$){let z=G(Z,q);if(B(z))$.push({action:"remove",target:z,detail:"runtime payload"})}let X=G(Z,k);if(B(X))$.push({action:"remove",target:X,detail:"install marker"});for(let q of z$){let z=G(Z,q);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 Z2($,Z){if(B(G(Z,k)))$.push({action:"manual",target:`npm uninstall -g ${C}`,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 Q2($,Z={}){let J=Z.purge===!0,Q=[];t8(Q,$.claudeSettings),e8(Q,$.cursorHooks);for(let q of $.skillLinks)Y$(Q,q,$.home,"skill link","location");for(let q of $.binLinks)Y$(Q,q,$.home,"the tlc launcher on PATH","target");let X=$2(Q,$.home,J);return Z2(Q,$.home),{items:Q,purge:J,homeIsLink:X}}function K$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function J2($,Z){let J=[],Q=[];for(let X of K$($))try{if(X.action==="unmerge"&&X.target===Z.claudeSettings)J$(X.target);else if(X.action==="unmerge"){let q=g(S(X.target,"utf8"));if(q.kind==="rewritten")d8(X.target,q.text,"utf8")}else if(X.action==="unlink")c8(X.target);else h8(X.target,{recursive:!0,force:!0});J.push(X)}catch(q){Q.push({item:X,reason:q instanceof Error?q.message:String(q)})}return{applied:J,failed:Q}}var X2={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function q2($,Z){let J=K$($),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:V$($)}],footer:`already clean · reinstall with npm i -g ${C}, then \`tlc harness install\``};let X=J.map((W)=>({label:W.action,value:`${W.target} — ${W.detail}`,level:X2[W.action]})),q=$.items.filter((W)=>W.action==="keep"),z=[{title:Q?"REMOVED":"WOULD REMOVE",rows:X},...q.length>0?[{title:"KEPT",rows:q.map((W)=>({label:"keep",value:`${W.target} — ${W.detail}`,level:"ok"}))}]:[],{title:"STILL YOURS TO DO",rows:V$($)},...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 ${C}, then \`tlc harness install\``:"nothing was changed · re-run with --yes to apply this plan"}}function V$($){return $.items.filter((Z)=>Z.action==="manual").map((Z)=>({label:"run",value:`${Z.target} — ${Z.detail}`,level:"info"}))}function z2($,Z,J=O){return I(q2($,Z),J)}function W2($=process.argv.slice(2)){let Z=a8(),J=Q2(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?J2(J,Z):null;return console.log(z2(J,Q,R())),Q!==null&&Q.failed.length>0?1:0}if(l.main==l.module)process.exitCode=W2();export{a8 as uninstallTargets,q2 as uninstallScreen,z2 as uninstallReportText,Q2 as planUninstall,K$ as pendingItems,W2 as main,s8 as isInsideRoot,c as canonicalise,J2 as applyUninstall};
package/docs/log.md CHANGED
@@ -13,6 +13,12 @@ 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-08-29
17
+
18
+ - **AD-114** — Rule proof `since HEAD` resolves sha from the event's own working directory, not the project root ([/decisions/ad-114.md](/decisions/ad-114.md))
19
+ - **AD-115** — A comment violation refuses commit, push and gh pr create, not only the stop ([/decisions/ad-115.md](/decisions/ad-115.md))
20
+ - **AD-116** — push and gh pr create run the stop-time battery before shipping, identically on every provider ([/decisions/ad-116.md](/decisions/ad-116.md))
21
+
16
22
  ## 2026-08-26
17
23
 
18
24
  - **AD-106** — A build step's own exit code cannot be the publish guarantee when it is also a recovery path ([/decisions/ad-106.md](/decisions/ad-106.md))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -52,6 +52,13 @@ export type HarnessEvent = {
52
52
  event: HarnessEventKind;
53
53
  sessionKey: string;
54
54
  projectDir: string;
55
+ /**
56
+ * The host's own report of the actual working directory for this event — the worktree root after
57
+ * the agent enters one, or the directory after a `cd` ([/decisions/ad-114.md](/decisions/ad-114.md)).
58
+ * Absent when the host does not report a per-event working directory. Distinct from `projectDir`,
59
+ * which anchors state/config and deliberately does not move into a worktree.
60
+ */
61
+ cwd?: string;
55
62
  // hazard: the `spawn*` pair describes the child of a spawn; the unprefixed fields describe the
56
63
  // running agent. Conflating them clobbers sticky parent state.
57
64
  model?: string;
@@ -174,7 +174,7 @@ import {
174
174
  readRuleSources,
175
175
  recordObservation,
176
176
  } from "./rules/rules.store.ts";
177
- import { firingRules } from "./rules/rules.trigger.ts";
177
+ import { firingRules, triggerMatches } from "./rules/rules.trigger.ts";
178
178
  import { evaluateShellCommand } from "./shell-policy/shell-policy.service.ts";
179
179
  import { clearShellStall } from "./shell-policy/shell-policy.stall.ts";
180
180
  import { coversHandler, decideShim } from "./shim/shim.precedence.ts";
@@ -489,6 +489,7 @@ export const coreFacade = {
489
489
  read: readRuleSources,
490
490
  build: buildRuleSet,
491
491
  firing: firingRules,
492
+ triggerMatches,
492
493
  observations: readObservations,
493
494
  record: recordObservation,
494
495
  observationFrom,
@@ -0,0 +1,174 @@
1
+ // why: `stop` was the only place these checks could refuse a turn, so `push`/`gh pr create` mid-turn
2
+ // always shipped first and learned about the violation afterward. Same checks, before ship, identical
3
+ // on Claude and Cursor — every input read here is host-neutral ([/decisions/ad-116.md](/decisions/ad-116.md)).
4
+ import { readFileSync } from "node:fs";
5
+ import type { Decision, HarnessEvent } from "../contracts/index.ts";
6
+ import { coreFacade } from "../core/index.ts";
7
+ import { listAddedLines, listTrackedFiles } from "../platform/git.ts";
8
+ import type { HandlerContext } from "./run.ts";
9
+ import { runLockedGate } from "./stop.ts";
10
+ import {
11
+ computeTurnScope,
12
+ currentGitSha,
13
+ pendingCommentViolations,
14
+ sessionIdFromKey,
15
+ shaScopeRoot,
16
+ } from "./support.ts";
17
+
18
+ const FULL_BATTERY_KINDS = ["push", "pr-open"] as const;
19
+
20
+ function isShipCommand(event: HarnessEvent): boolean {
21
+ if (event.command === undefined) {
22
+ return false;
23
+ }
24
+ const context = { event: event.event, command: event.command };
25
+ return FULL_BATTERY_KINDS.some((kind) => coreFacade.rules.triggerMatches({ kind }, context));
26
+ }
27
+
28
+ function gateFailureMessage(gate: string, command: readonly string[], outputTail: string): string {
29
+ return [
30
+ `BLOCKED: ${gate} failed before shipping — a review or a deploy would have seen this next.`,
31
+ `TRIED: ${command.join(" ")}`,
32
+ `NEED: fix the ${gate} findings, then retry the commit/push/PR.`,
33
+ "",
34
+ outputTail,
35
+ ].join("\n");
36
+ }
37
+
38
+ /**
39
+ * why after comments and before the stop-rules check: cheapest first. A comment violation costs a
40
+ * regex scan; a gate command is a real process; the stop-rules check is a directory read plus, only
41
+ * once something fired, one `git rev-parse`.
42
+ */
43
+ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
44
+ if (!isShipCommand(event)) {
45
+ return { kind: "abstain" };
46
+ }
47
+ const { policy } = ctx;
48
+ const root = event.projectDir;
49
+ const provider = event.provider;
50
+ const sessionKey = event.sessionKey;
51
+ const session = sessionIdFromKey(event);
52
+ const scope = await computeTurnScope(root, provider, policy);
53
+ const shaRoot = shaScopeRoot(event);
54
+
55
+ const commentHits = await pendingCommentViolations(root, provider, policy);
56
+ if (commentHits.length > 0) {
57
+ return {
58
+ kind: "deny",
59
+ reason: coreFacade.commentPolicy.commentViolationMessage(commentHits, policy.comments.mode),
60
+ rule: "ship-gate-comments",
61
+ };
62
+ }
63
+
64
+ if (policy.grind.enabled && policy.grind.lintCommand && scope.codeTargets.length > 0) {
65
+ const run = await runLockedGate({
66
+ root,
67
+ shaRoot,
68
+ provider,
69
+ session,
70
+ pendingCredit: scope.pendingCredit,
71
+ sessionKey,
72
+ policy,
73
+ gate: "lint",
74
+ command: policy.grind.lintCommand,
75
+ argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.lintCommand, policy.grind.appendFiles)
76
+ ? scope.codeTargets
77
+ : [],
78
+ recordFiles: scope.codeTargets,
79
+ });
80
+ if (run.kind === "ran" && !run.artifact.passed) {
81
+ return {
82
+ kind: "deny",
83
+ reason: gateFailureMessage("lint", run.artifact.command, run.artifact.outputTail),
84
+ rule: "ship-gate-lint",
85
+ };
86
+ }
87
+ }
88
+
89
+ if (
90
+ policy.grind.enabled &&
91
+ policy.grind.testCommand &&
92
+ (scope.testTargets.length > 0 || scope.codeTargets.length > 0)
93
+ ) {
94
+ const recordFiles = scope.testTargets.length > 0 ? scope.testTargets : scope.codeTargets;
95
+ const run = await runLockedGate({
96
+ root,
97
+ shaRoot,
98
+ provider,
99
+ session,
100
+ pendingCredit: scope.pendingCredit,
101
+ sessionKey,
102
+ policy,
103
+ gate: "test",
104
+ command: policy.grind.testCommand,
105
+ argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.testCommand, policy.grind.appendFiles)
106
+ ? recordFiles
107
+ : [],
108
+ recordFiles,
109
+ });
110
+ if (run.kind === "ran" && !run.artifact.passed) {
111
+ return {
112
+ kind: "deny",
113
+ reason: gateFailureMessage("test", run.artifact.command, run.artifact.outputTail),
114
+ rule: "ship-gate-test",
115
+ };
116
+ }
117
+ }
118
+
119
+ if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
120
+ const run = await runLockedGate({
121
+ root,
122
+ shaRoot,
123
+ provider,
124
+ session,
125
+ pendingCredit: scope.pendingCredit,
126
+ sessionKey,
127
+ policy,
128
+ gate: "docs",
129
+ command: policy.docs.command,
130
+ argvFiles: [],
131
+ recordFiles: scope.changedFiles,
132
+ });
133
+ if (run.kind === "ran" && !run.artifact.passed) {
134
+ return {
135
+ kind: "deny",
136
+ reason: gateFailureMessage("docs", run.artifact.command, run.artifact.outputTail),
137
+ rule: "ship-gate-docs",
138
+ };
139
+ }
140
+ }
141
+
142
+ if (policy.duplication.enabled && scope.codeTargets.length > 0) {
143
+ const added = await listAddedLines(root, scope.codeTargets, scope.turnBase);
144
+ const tracked = await listTrackedFiles(root);
145
+ const scan = coreFacade.duplication.scanProject(
146
+ tracked,
147
+ (relativePath) => {
148
+ try {
149
+ return readFileSync(`${root}/${relativePath}`, "utf8");
150
+ } catch {
151
+ return null;
152
+ }
153
+ },
154
+ policy.duplication.minRun,
155
+ );
156
+ const hits = coreFacade.duplication.findDuplications(added, scan.index, policy.duplication.minRun);
157
+ if (hits.length > 0) {
158
+ return {
159
+ kind: "deny",
160
+ reason: coreFacade.duplication.duplicationMessage(hits),
161
+ rule: "ship-gate-duplication",
162
+ };
163
+ }
164
+ }
165
+
166
+ const sha = await currentGitSha(shaRoot);
167
+ const stopRules = coreFacade.rules.decideStop(root, policy.rules, { sessionKey, mode: policy.mode, sha });
168
+ const worst = coreFacade.rules.strictest(stopRules.outcomes);
169
+ if (worst && worst.verdict !== "warn") {
170
+ return { kind: "deny", reason: worst.message, rule: `rule:${worst.rule.name}` };
171
+ }
172
+
173
+ return { kind: "abstain" };
174
+ }
@@ -13,7 +13,13 @@ import {
13
13
  import { flagsDir } from "../platform/paths.ts";
14
14
  import type { Handler, HandlerContext } from "./run.ts";
15
15
  import { main } from "./run.ts";
16
- import { currentGitSha, formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
16
+ import {
17
+ currentGitSha,
18
+ formatLessonsBlock,
19
+ obsConfigFor,
20
+ sessionIdFromKey,
21
+ shaScopeRoot,
22
+ } from "./support.ts";
17
23
 
18
24
  const STAGNATION_FOLLOWUP = [
19
25
  "BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
@@ -31,6 +37,7 @@ const STAGNATION_FOLLOWUP = [
31
37
  */
32
38
  async function observeGateForRules(args: {
33
39
  root: string;
40
+ shaRoot: string;
34
41
  sessionKey: string;
35
42
  policy: Policy;
36
43
  gate: string;
@@ -40,7 +47,7 @@ async function observeGateForRules(args: {
40
47
  return;
41
48
  }
42
49
  coreFacade.rules.observeGate(args.root, args.policy.rules, args.gate, {
43
- sha: await currentGitSha(args.root),
50
+ sha: await currentGitSha(args.shaRoot),
44
51
  sessionKey: args.sessionKey,
45
52
  at: new Date().toISOString(),
46
53
  });
@@ -123,7 +130,7 @@ function recordGateDeferred(args: {
123
130
  * would have let `undefined` mean "fine" at three call sites that reach straight for `artifact.passed`
124
131
  * ([/decisions/ad-073.md](/decisions/ad-073.md)).
125
132
  */
126
- type GateRun =
133
+ export type GateRun =
127
134
  | { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
128
135
  | { kind: "deferred"; holder: string };
129
136
 
@@ -177,8 +184,14 @@ async function creditPendingLessons(args: {
177
184
  });
178
185
  }
179
186
 
180
- async function runLockedGate(args: {
187
+ /**
188
+ * why exported: this is the one place a `commit`/`push`/`pr-open` ship-time gate
189
+ * ([/decisions/ad-116.md](/decisions/ad-116.md)) can ask the identical question `stop` asks — same lock, same
190
+ * content-hash cache, same observability. A second implementation is the drift AD-071 already named once.
191
+ */
192
+ export async function runLockedGate(args: {
181
193
  root: string;
194
+ shaRoot: string;
182
195
  provider: string;
183
196
  session: string;
184
197
  gate: "lint" | "test" | "docs";
@@ -429,6 +442,7 @@ async function failGate(args: {
429
442
  */
430
443
  async function decideStopRules(
431
444
  root: string,
445
+ shaRoot: string,
432
446
  policy: Policy,
433
447
  sessionKey: string,
434
448
  ): Promise<ReturnType<typeof coreFacade.rules.decideStop>> {
@@ -439,13 +453,14 @@ async function decideStopRules(
439
453
  }
440
454
  return coreFacade.rules.decideStop(root, policy.rules, {
441
455
  ...context,
442
- sha: await currentGitSha(root),
456
+ sha: await currentGitSha(shaRoot),
443
457
  });
444
458
  }
445
459
 
446
460
  export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext): Promise<Decision> => {
447
461
  const { policy, capabilities } = ctx;
448
462
  const root = event.projectDir;
463
+ const shaRoot = shaScopeRoot(event);
449
464
  const provider = event.provider;
450
465
  const sessionKey = event.sessionKey;
451
466
  const session = sessionIdFromKey(event);
@@ -562,6 +577,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
562
577
  if (policy.grind.enabled && policy.grind.lintCommand && codeTargets.length > 0) {
563
578
  const run = await runLockedGate({
564
579
  root,
580
+ shaRoot,
565
581
  provider,
566
582
  session,
567
583
  pendingCredit,
@@ -599,6 +615,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
599
615
  const recordFiles = testTargets.length > 0 ? testTargets : codeTargets;
600
616
  const run = await runLockedGate({
601
617
  root,
618
+ shaRoot,
602
619
  provider,
603
620
  session,
604
621
  pendingCredit,
@@ -757,6 +774,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
757
774
  if (policy.docs.command && policy.docs.command.length > 0) {
758
775
  const run = await runLockedGate({
759
776
  root,
777
+ shaRoot,
760
778
  provider,
761
779
  session,
762
780
  pendingCredit,
@@ -900,7 +918,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
900
918
  * invariant: `warn` returns `context`, which does not block. Everything else refuses the stop, so a rule the
901
919
  * operator wrote cannot be ended past.
902
920
  */
903
- const stopRules = await decideStopRules(root, policy, sessionKey);
921
+ const stopRules = await decideStopRules(root, shaRoot, policy, sessionKey);
904
922
  if (stopRules.decision.kind !== "abstain") {
905
923
  if (stopRules.decision.kind !== "context") {
906
924
  const worst = coreFacade.rules.strictest(stopRules.outcomes);
@@ -1,7 +1,15 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { EffortLevel, HarnessEvent } from "../contracts/index.ts";
4
- import { coreFacade, type HarnessLesson, type ObservabilityConfig, type Policy } from "../core/index.ts";
4
+ import {
5
+ type CommentFinding,
6
+ coreFacade,
7
+ type HarnessLesson,
8
+ type ObservabilityConfig,
9
+ type PendingLessonCredit,
10
+ type Policy,
11
+ } from "../core/index.ts";
12
+ import { filterCodeTargets, filterTestTargets, listChangedRepoFiles } from "../platform/git.ts";
5
13
  import { runProcess } from "../platform/process.ts";
6
14
  import { type ProviderPort, renderClaudeLessonsView, renderCursorLessonsView } from "../providers/index.ts";
7
15
 
@@ -58,6 +66,85 @@ export async function currentGitBranch(root: string): Promise<string | null> {
58
66
  return branch.length > 0 ? branch : null;
59
67
  }
60
68
 
69
+ /**
70
+ * why: `event.projectDir` prefers `CLAUDE_PROJECT_DIR`, which the host deliberately keeps pointed at the
71
+ * session's original root — including inside a git worktree, where it would otherwise return the wrong
72
+ * HEAD for a `since HEAD` rule proof. `event.cwd` is the field the host actually moves when the agent is
73
+ * working in a worktree or after a `cd`. Only the rules engine's proof sha needs this; every other use of
74
+ * `event.projectDir` (state dir, policy config, presence) is deliberately left alone
75
+ * ([/decisions/ad-114.md](/decisions/ad-114.md)).
76
+ */
77
+ export function shaScopeRoot(event: HarnessEvent): string {
78
+ return event.cwd ?? event.projectDir;
79
+ }
80
+
81
+ export type TurnScope = {
82
+ turnBase: string;
83
+ changedFiles: string[];
84
+ codeTargets: string[];
85
+ testTargets: string[];
86
+ commentTargets: string[];
87
+ pendingCredit: PendingLessonCredit | undefined;
88
+ };
89
+
90
+ /**
91
+ * "What did this turn add" — the one answer every gate that scopes by turn shares, whether it runs
92
+ * at `stop` or, since AD-116, before `commit`/`push`/`pr-open` ships it. A second, differently-scoped
93
+ * answer to the same question is the drift AD-071 already named once ([/decisions/ad-116.md](/decisions/ad-116.md)).
94
+ *
95
+ * invariant: `turn_base_sha` falls back to `HEAD` when the handoff is absent or its seal diverged —
96
+ * identical to `stop.ts`'s own fallback.
97
+ */
98
+ export async function computeTurnScope(
99
+ root: string,
100
+ provider: string,
101
+ policy: Pick<Policy, "codePaths">,
102
+ ): Promise<TurnScope> {
103
+ const seal = coreFacade.handoff.handoffInjectable(root);
104
+ const handoff = seal.ok ? coreFacade.handoff.readHandoff(root, provider) : undefined;
105
+ const turnBase = handoff?.turn_base_sha ?? "HEAD";
106
+ const changedFiles = await listChangedRepoFiles(root, turnBase);
107
+ const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
108
+ const testTargets = filterTestTargets(changedFiles);
109
+ const commentScope = changedFiles.filter((file) =>
110
+ coreFacade.policy.isUnderCodePaths(file, policy.codePaths),
111
+ );
112
+ const commentTargets = coreFacade.commentPolicy.filterCommentTargets(commentScope);
113
+ return {
114
+ turnBase,
115
+ changedFiles,
116
+ codeTargets,
117
+ testTargets,
118
+ commentTargets,
119
+ pendingCredit: handoff?.pending_lesson_credit,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * The same unresolved-comment check `stop.ts` already runs at the end of the turn, exposed so an
125
+ * action-time rail can ask the identical question before a `commit`/`push`/`pr-open` ships it
126
+ * ([/decisions/ad-115.md](/decisions/ad-115.md)).
127
+ */
128
+ export async function pendingCommentViolations(
129
+ root: string,
130
+ provider: string,
131
+ policy: Pick<Policy, "comments" | "codePaths">,
132
+ ): Promise<CommentFinding[]> {
133
+ if (!policy.comments.enabled || policy.comments.onViolation !== "followup") {
134
+ return [];
135
+ }
136
+ const scope = await computeTurnScope(root, provider, policy);
137
+ if (scope.commentTargets.length === 0) {
138
+ return [];
139
+ }
140
+ return coreFacade.commentPolicy.scanAddedComments(
141
+ root,
142
+ scope.commentTargets,
143
+ policy.comments.mode,
144
+ scope.turnBase,
145
+ );
146
+ }
147
+
61
148
  export async function currentGitSha(root: string): Promise<string | null> {
62
149
  if (!existsSync(join(root, ".git"))) {
63
150
  return null;
@@ -205,7 +292,7 @@ export async function observeForRules(
205
292
  if (!coreFacade.rules.wants(event.projectDir, config, event)) {
206
293
  return;
207
294
  }
208
- const sha = await currentGitSha(event.projectDir);
295
+ const sha = await currentGitSha(shaScopeRoot(event));
209
296
  coreFacade.rules.observe(event.projectDir, config, event, {
210
297
  sha,
211
298
  sessionKey: event.sessionKey,
@@ -2,7 +2,40 @@ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
2
  import { coreFacade } from "../core/index.ts";
3
3
  import type { Handler, HandlerContext } from "./run.ts";
4
4
  import { main } from "./run.ts";
5
- import { currentGitSha, obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
5
+ import { shipGateVerdict } from "./ship-gate.ts";
6
+ import {
7
+ currentGitSha,
8
+ obsConfigFor,
9
+ pendingCommentViolations,
10
+ readModelFromToolInput,
11
+ shaScopeRoot,
12
+ subagentSpawnInput,
13
+ } from "./support.ts";
14
+
15
+ /**
16
+ * why `commit` gets only the cheap check and `push`/`pr-open` get the full battery
17
+ * (`shipGateVerdict`): measured in this repo, lint+test run ~9.4s cold — paying that on every local
18
+ * commit inside one turn is the edit-time cost AD-111 already declined, moved to a higher frequency.
19
+ * A bare commit is local and reversible; nothing shares it yet ([/decisions/ad-116.md](/decisions/ad-116.md)).
20
+ */
21
+ async function commentGateBeforeCommit(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
22
+ if (event.command === undefined) {
23
+ return { kind: "abstain" };
24
+ }
25
+ const context = { event: event.event, command: event.command };
26
+ if (!coreFacade.rules.triggerMatches({ kind: "commit" }, context)) {
27
+ return { kind: "abstain" };
28
+ }
29
+ const hits = await pendingCommentViolations(event.projectDir, event.provider, ctx.policy);
30
+ if (hits.length === 0) {
31
+ return { kind: "abstain" };
32
+ }
33
+ return {
34
+ kind: "deny",
35
+ reason: coreFacade.commentPolicy.commentViolationMessage(hits, ctx.policy.comments.mode),
36
+ rule: "comment-policy-before-ship",
37
+ };
38
+ }
6
39
 
7
40
  const READONLY_BLOCKED_TOOLS = new Set(["Write", "Delete", "Shell"]);
8
41
 
@@ -61,7 +94,7 @@ async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<
61
94
  }
62
95
  // why twice: the first pass answers whether any rule fired at all, which costs no git. Only then is the sha
63
96
  // worth a process, and the second pass is the one whose verdict counts.
64
- const sha = await currentGitSha(event.projectDir);
97
+ const sha = await currentGitSha(shaScopeRoot(event));
65
98
  const verdict = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
66
99
  sha,
67
100
  sessionKey: event.sessionKey,
@@ -200,6 +233,28 @@ export const toolBeforeHandler: Handler = async (
200
233
  return rulesVerdict;
201
234
  }
202
235
 
236
+ /**
237
+ * why after the operator rules and before the shell fallback: a built-in capability check, not
238
+ * operator-authored, so it is not a `.tlc/harness/rules/` rule — but it reads policy, so it is
239
+ * not floor-tier either ([/decisions/ad-115.md](/decisions/ad-115.md)).
240
+ */
241
+ const commitGuard = await commentGateBeforeCommit(event, ctx);
242
+ if (commitGuard.kind !== "abstain") {
243
+ recordShellDecisionIfShell(event, ctx, commitGuard);
244
+ return commitGuard;
245
+ }
246
+
247
+ /**
248
+ * why the full battery only for push/pr-open: that is the moment something leaves the machine —
249
+ * a shared branch, a PR, a deploy. A bare commit does not, so it keeps the cheap check above
250
+ * ([/decisions/ad-116.md](/decisions/ad-116.md)).
251
+ */
252
+ const shipGuard = await shipGateVerdict(event, ctx);
253
+ if (shipGuard.kind !== "abstain") {
254
+ recordShellDecisionIfShell(event, ctx, shipGuard);
255
+ return shipGuard;
256
+ }
257
+
203
258
  switch (event.event) {
204
259
  case "shell.before":
205
260
  return handleShellBefore(event, ctx);
@@ -119,6 +119,14 @@ export function claudeToEvent(raw: Record<string, unknown>): HarnessEvent | null
119
119
  event.permissionMode = permissionMode;
120
120
  }
121
121
 
122
+ // why: `cwd` is a common field on every Claude hook payload, and the host docs confirm it tracks the
123
+ // agent into a worktree or after a `cd` — unlike `projectDir`, which stays at the session's original
124
+ // root ([/decisions/ad-114.md](/decisions/ad-114.md)).
125
+ const cwd = asString(raw.cwd);
126
+ if (cwd) {
127
+ event.cwd = cwd;
128
+ }
129
+
122
130
  const isSpawnEvent = eventKind === "subagent.start" || eventKind === "subagent.stop";
123
131
  const model = isSpawnEvent ? undefined : asString(raw.model);
124
132
  if (model) {
@@ -142,6 +142,16 @@ export function cursorToEvent(raw: Record<string, unknown>): HarnessEvent | null
142
142
  if (output !== undefined) {
143
143
  event.toolOutput = output;
144
144
  }
145
+ // why: confirmed against cursor.com/docs/hooks — `cwd` exists only on `beforeShellExecution`
146
+ // (this event's `shell.before` half), not on `afterShellExecution` or any other event this
147
+ // adapter maps. Never guessed onto an event kind the host does not report it for
148
+ // ([/decisions/ad-114.md](/decisions/ad-114.md)).
149
+ if (eventKind === "shell.before") {
150
+ const cwd = asString(raw.cwd);
151
+ if (cwd !== undefined) {
152
+ event.cwd = cwd;
153
+ }
154
+ }
145
155
  break;
146
156
  }
147
157
  case "mcp.before":