@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.
- package/dist/compact-before.mjs +79 -79
- package/dist/doctor.mjs +80 -80
- package/dist/init-project.mjs +77 -77
- package/dist/install-runtime.mjs +74 -74
- package/dist/lessons-cli.mjs +74 -74
- package/dist/obs-cli.mjs +74 -74
- package/dist/prompt-submit.mjs +79 -79
- package/dist/refresh-model-prices.mjs +74 -74
- package/dist/response-after.mjs +79 -79
- package/dist/run.mjs +79 -79
- package/dist/session-end.mjs +84 -84
- package/dist/session-start.mjs +83 -83
- package/dist/shim.mjs +73 -73
- package/dist/ship-gate.mjs +143 -0
- package/dist/stop.mjs +84 -84
- package/dist/subagent-start.mjs +79 -79
- package/dist/subagent-stop.mjs +80 -80
- package/dist/support.mjs +83 -83
- package/dist/tlc-cli.mjs +81 -81
- package/dist/tool-after.mjs +79 -79
- package/dist/tool-before.mjs +83 -81
- package/dist/tool-failure.mjs +79 -79
- package/dist/uninstall-runtime.mjs +2 -2
- package/docs/log.md +6 -0
- package/package.json +1 -1
- package/src/contracts/harness-event.ts +7 -0
- package/src/core/core.facade.ts +2 -1
- package/src/entrypoints/ship-gate.ts +174 -0
- package/src/entrypoints/stop.ts +24 -6
- package/src/entrypoints/support.ts +89 -2
- package/src/entrypoints/tool-before.ts +57 -2
- package/src/providers/claude/claude.inbound.ts +8 -0
- package/src/providers/cursor/cursor.inbound.ts +10 -0
|
@@ -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
|
|
4
|
-
`}}import{join as X$,relative as
|
|
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
|
@@ -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;
|
package/src/core/core.facade.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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":
|