@tech-leads-club/harness-toolkit 0.11.2 → 0.12.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
- import{createRequire as F$}from"node:module";var l=F$(import.meta.url);import{existsSync as B,lstatSync as h8,readFileSync as S,readlinkSync as p8,realpathSync as K$,rmSync as c8,statSync as d8,unlinkSync as l8,writeFileSync as i8}from"node:fs";import{homedir as n8}from"node:os";import{basename as a8,dirname as M$,isAbsolute as O$,join as G,relative as s8,resolve as h}from"node:path";import{homedir as j}from"node:os";import{delimiter as MZ,dirname as OZ,join as D,resolve as CZ}from"node:path";function i(){return D(j(),".tlc","harness")}function C($=process.env){return $.TLC_HOME??i()}function T(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:D(j(),".claude")}function A(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:D(j(),".cursor")}function b($=process.env){return $.TLC_BIN_DIR?.trim()||D(j(),".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 j$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let q of J.extensions)Z.set(q.toLowerCase(),Q)}return Z}var D$=j$(s);var T$=D$.size;var y=6;var x$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var N5=new Set([...x$,"nc","ncat","socat","telnet","lwp-request"]);var S$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",j5=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${S$}))(?=[:)\\s,'"\`]|$)`,"g");var R="1970-01-01T00:00:00.000Z";function M($){return{...$,scope:"gate-execution",source:"core",tier:"core",status:"active",confidence:1,hitCount:1,pinned:!1,refs:[],sessionKeys:[],injectedCount:0,gradeableCount:0,helpedCount:0,neutralCount:0,firstSeenAt:R,lastSeenAt:R,lastAccessedAt:R,updatedAt:R}}var h9=[M({id:"core:gate:lint",failedGate:"lint",category:"verification",triggerTokens:["lint","biome","eslint","ruff","format"],instruction:"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",avoid:"Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",prefer:"Apply the smallest fix that clears each finding, then let the stop hook re-check.",preRetryCheck:"Confirm the lint command targets only the intended changed files and still fails for the same codes.",priority:90}),M({id:"core:gate:test",failedGate:"test",category:"verification",triggerTokens:["test","vitest","jest","pytest","failing"],instruction:"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",avoid:"Do not delete failing tests, mark them skipped, or weaken assertions to force green.",prefer:"Reproduce the failure, fix root cause, re-run the same test target.",preRetryCheck:"Identify the failing test name/file from the gate output before editing.",priority:90}),M({id:"core:gate:comments",failedGate:"comments",category:"verification",triggerTokens:["junk comment","TODO","FIXME","banner"],instruction:"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",avoid:"Do not keep TODO markers or section banners 'for clarity'.",prefer:"Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",preRetryCheck:"Scan the listed file:line hits and remove each one.",priority:80}),M({id:"core:gate:ship",failedGate:"ship",category:"ship-evidence",triggerTokens:["ship","evidence","90-verdict","PASS"],instruction:"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",avoid:"Do not claim shipped based on unit tests alone when runtime paths changed.",prefer:"Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",preRetryCheck:"Confirm evidenceDir and a recent PASS verdict exist for this change.",priority:95}),M({id:"core:gate:empty-diff",failedGate:"empty-diff",category:"ship-evidence",triggerTokens:["empty","diff","no changes","shipped"],instruction:"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",avoid:"Do not restate 'done' without a real diff or an explicit zero-change justification.",prefer:"Make the missing change, or clearly document why no files should change.",preRetryCheck:"Inspect git status / changed files before the next stop.",priority:92}),M({id:"core:gate:stagnation",failedGate:"stagnation",category:"stagnation",triggerTokens:["stagnation","identical","fingerprint","same fail"],instruction:"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",avoid:"Do not retry the exact same patch, command, or suppression.",prefer:"Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",preRetryCheck:"Diff your last edit against the gate output; ensure the next action is different.",priority:100})];var i$={structure:"#3d3a4a",accent:"#a78bfa",success:"#6ee7b7",warning:"#d4a574",error:"#f87171",info:"#93c5fd",textMain:"#f5f5f7",textMuted:"#9ca3af",textDim:"#6b7280"},V={check:"✔",cross:"✖",warning:"⚠",arrow:"→",arrowRight:"▸",dot:"•",bar:"│",rule:"══",dash:"──"};function n$($){let Z=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec($.trim());if(!Z)return"255;255;255";return[Z[1],Z[2],Z[3]].map((J)=>Number.parseInt(J,16)).join(";")}var q$=String.fromCharCode(27),a$=`${q$}[0m`;function s$($=process.env,Z=process.argv,J=process.stdout.isTTY===!0){if("NO_COLOR"in $)return!1;if(Z.includes("--no-color"))return!1;return J}var r$={ok:"success",warn:"warning",fail:"error",info:"info"},o$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},m=16;function E($=s$()){let Z=(Q,q)=>$?`${q$}[${Q}m${q}${a$}`:q,J=(Q,q)=>Z(`38;2;${n$(i$[Q])}`,q);return{enabled:$,paint:J,bold:(Q)=>Z("1",Q),dim:(Q)=>J("textDim",Q),heading:(Q)=>J("accent",`${V.rule} ${Q} ${V.rule}`),footer:(Q)=>J("textDim",`${V.dash} ${Q} ${V.dash}`),kv:(Q,q,X=m)=>` ${J("textMuted",`${Q}:`.padEnd(X))} ${q}`,status:(Q,q)=>`${J(r$[Q],o$[Q])} ${q}`}}var O=E(!1);var t$=2;function e$($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function $8($,Z){if($==="")return[""];let J=[],Q="";for(let q of $.split(/\s+/).filter((X)=>X.length>0)){if(Q===""){Q=q;continue}if(Q.length+1+q.length<=Z){Q=`${Q} ${q}`;continue}J.push(Q),Q=q}if(Q!=="")J.push(Q);return J.length===0?[""]:J}function F($,Z,J=e$()){let Q=[Z.heading($.title.toUpperCase())];if($.summary&&$.summary.length>0)Q.push(` ${$.summary.join(Z.dim(` ${V.bar} `))}`);let q=Math.max(m,...$.sections.flatMap((X)=>(X.rows??[]).map((z)=>z.label.length+1)));for(let X of $.sections){if(Q.push(""),X.title)Q.push(Z.paint("accent",X.title));for(let z of X.rows??[]){let W=z.level?Z.status(z.level,z.value):z.value;Q.push(Z.kv(z.label,W,q))}for(let z of X.lines??[]){if(z===""){Q.push("");continue}let W=X.wrap?$8(z,J-t$):[z];for(let U of W)Q.push(` ${U}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
1
+ import{createRequire as F$}from"node:module";var l=F$(import.meta.url);import{existsSync as B,lstatSync as h8,readFileSync as S,readlinkSync as p8,realpathSync as K$,rmSync as c8,statSync as d8,unlinkSync as l8,writeFileSync as i8}from"node:fs";import{homedir as n8}from"node:os";import{basename as a8,dirname as M$,isAbsolute as O$,join as G,relative as s8,resolve as h}from"node:path";import{homedir as L}from"node:os";import{delimiter as MZ,dirname as OZ,join as D,resolve as CZ}from"node:path";function i(){return D(L(),".tlc","harness")}function C($=process.env){return $.TLC_HOME??i()}function T(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:D(L(),".claude")}function A(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:D(L(),".cursor")}function b($=process.env){return $.TLC_BIN_DIR?.trim()||D(L(),".local","bin")}function n(){return["tlc","tlc.cmd"]}var s=[{id:"typescript",extensions:[".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".cjs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"python",extensions:[".py",".pyi",".pyw"],line:["#"],block:[['"""','"""'],["'''","'''"]],middle:[]},{id:"ruby",extensions:[".rb",".rake",".gemspec"],line:["#"],block:[["=begin","=end"]],middle:[]},{id:"shell",extensions:[".sh",".bash",".zsh",".ksh",".fish"],line:["#"],block:[],middle:[]},{id:"go",extensions:[".go"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"rust",extensions:[".rs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"java",extensions:[".java"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"kotlin",extensions:[".kt",".kts"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"swift",extensions:[".swift"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"c",extensions:[".c",".h"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"cpp",extensions:[".cc",".cpp",".cxx",".hpp",".hh",".hxx"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"csharp",extensions:[".cs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"php",extensions:[".php"],line:["//","#"],block:[["/*","*/"]],middle:["*"]},{id:"scala",extensions:[".scala",".sc"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"dart",extensions:[".dart"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"elixir",extensions:[".ex",".exs"],line:["#"],block:[],middle:[]},{id:"erlang",extensions:[".erl",".hrl"],line:["%"],block:[],middle:[]},{id:"haskell",extensions:[".hs"],line:["--"],block:[["{-","-}"]],middle:[]},{id:"lua",extensions:[".lua"],line:["--"],block:[["--[[","]]"]],middle:[]},{id:"sql",extensions:[".sql"],line:["--"],block:[["/*","*/"]],middle:[]},{id:"css",extensions:[".css",".scss",".sass",".less"],line:["//"],block:[["/*","*/"]],middle:[]},{id:"yaml",extensions:[".yaml",".yml"],line:["#"],block:[],middle:[]},{id:"toml",extensions:[".toml"],line:["#"],block:[],middle:[]},{id:"ini",extensions:[".ini",".cfg",".conf",".properties"],line:[";","#"],block:[],middle:[]},{id:"dockerfile",extensions:[".dockerfile","dockerfile"],line:["#"],block:[],middle:[]},{id:"makefile",extensions:[".mk","makefile"],line:["#"],block:[],middle:[]},{id:"terraform",extensions:[".tf",".tfvars"],line:["#","//"],block:[["/*","*/"]],middle:["*"]},{id:"powershell",extensions:[".ps1",".psm1",".psd1"],line:["#"],block:[["<#","#>"]],middle:[]},{id:"perl",extensions:[".pl",".pm"],line:["#"],block:[],middle:[]},{id:"r",extensions:[".r"],line:["#"],block:[],middle:[]},{id:"julia",extensions:[".jl"],line:["#"],block:[["#=","=#"]],middle:[]},{id:"vue",extensions:[".vue",".svelte"],line:["//"],block:[["/*","*/"],["<!--","-->"]],middle:["*"]},{id:"html",extensions:[".html",".htm",".xml",".xhtml"],line:[],block:[["<!--","-->"]],middle:[]},{id:"graphql",extensions:[".graphql",".gql"],line:["#"],block:[],middle:[]},{id:"protobuf",extensions:[".proto"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"zig",extensions:[".zig"],line:["//"],block:[],middle:[]},{id:"clojure",extensions:[".clj",".cljs",".cljc",".edn"],line:[";"],block:[],middle:[]},{id:"ocaml",extensions:[".ml",".mli"],line:[],block:[["(*","*)"]],middle:["*"]},{id:"fsharp",extensions:[".fs",".fsi",".fsx"],line:["//"],block:[["(*","*)"]],middle:["*"]},{id:"vim",extensions:[".vim"],line:['"'],block:[],middle:[]},{id:"tex",extensions:[".tex",".sty",".cls"],line:["%"],block:[],middle:[]}];function L$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let q of J.extensions)Z.set(q.toLowerCase(),Q)}return Z}var D$=L$(s);var T$=D$.size;var y=6;var x$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var N5=new Set([...x$,"nc","ncat","socat","telnet","lwp-request"]);var S$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",L5=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${S$}))(?=[:)\\s,'"\`]|$)`,"g");var R="1970-01-01T00:00:00.000Z";function M($){return{...$,scope:"gate-execution",source:"core",tier:"core",status:"active",confidence:1,hitCount:1,pinned:!1,refs:[],sessionKeys:[],injectedCount:0,gradeableCount:0,helpedCount:0,neutralCount:0,firstSeenAt:R,lastSeenAt:R,lastAccessedAt:R,updatedAt:R}}var h9=[M({id:"core:gate:lint",failedGate:"lint",category:"verification",triggerTokens:["lint","biome","eslint","ruff","format"],instruction:"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",avoid:"Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",prefer:"Apply the smallest fix that clears each finding, then let the stop hook re-check.",preRetryCheck:"Confirm the lint command targets only the intended changed files and still fails for the same codes.",priority:90}),M({id:"core:gate:test",failedGate:"test",category:"verification",triggerTokens:["test","vitest","jest","pytest","failing"],instruction:"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",avoid:"Do not delete failing tests, mark them skipped, or weaken assertions to force green.",prefer:"Reproduce the failure, fix root cause, re-run the same test target.",preRetryCheck:"Identify the failing test name/file from the gate output before editing.",priority:90}),M({id:"core:gate:comments",failedGate:"comments",category:"verification",triggerTokens:["junk comment","TODO","FIXME","banner"],instruction:"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",avoid:"Do not keep TODO markers or section banners 'for clarity'.",prefer:"Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",preRetryCheck:"Scan the listed file:line hits and remove each one.",priority:80}),M({id:"core:gate:ship",failedGate:"ship",category:"ship-evidence",triggerTokens:["ship","evidence","90-verdict","PASS"],instruction:"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",avoid:"Do not claim shipped based on unit tests alone when runtime paths changed.",prefer:"Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",preRetryCheck:"Confirm evidenceDir and a recent PASS verdict exist for this change.",priority:95}),M({id:"core:gate:empty-diff",failedGate:"empty-diff",category:"ship-evidence",triggerTokens:["empty","diff","no changes","shipped"],instruction:"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",avoid:"Do not restate 'done' without a real diff or an explicit zero-change justification.",prefer:"Make the missing change, or clearly document why no files should change.",preRetryCheck:"Inspect git status / changed files before the next stop.",priority:92}),M({id:"core:gate:stagnation",failedGate:"stagnation",category:"stagnation",triggerTokens:["stagnation","identical","fingerprint","same fail"],instruction:"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",avoid:"Do not retry the exact same patch, command, or suppression.",prefer:"Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",preRetryCheck:"Diff your last edit against the gate output; ensure the next action is different.",priority:100})];var i$={structure:"#3d3a4a",accent:"#a78bfa",success:"#6ee7b7",warning:"#d4a574",error:"#f87171",info:"#93c5fd",textMain:"#f5f5f7",textMuted:"#9ca3af",textDim:"#6b7280"},V={check:"✔",cross:"✖",warning:"⚠",arrow:"→",arrowRight:"▸",dot:"•",bar:"│",rule:"══",dash:"──"};function n$($){let Z=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec($.trim());if(!Z)return"255;255;255";return[Z[1],Z[2],Z[3]].map((J)=>Number.parseInt(J,16)).join(";")}var q$=String.fromCharCode(27),a$=`${q$}[0m`;function s$($=process.env,Z=process.argv,J=process.stdout.isTTY===!0){if("NO_COLOR"in $)return!1;if(Z.includes("--no-color"))return!1;return J}var r$={ok:"success",warn:"warning",fail:"error",info:"info"},o$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},m=16;function E($=s$()){let Z=(Q,q)=>$?`${q$}[${Q}m${q}${a$}`:q,J=(Q,q)=>Z(`38;2;${n$(i$[Q])}`,q);return{enabled:$,paint:J,bold:(Q)=>Z("1",Q),dim:(Q)=>J("textDim",Q),heading:(Q)=>J("accent",`${V.rule} ${Q} ${V.rule}`),footer:(Q)=>J("textDim",`${V.dash} ${Q} ${V.dash}`),kv:(Q,q,X=m)=>` ${J("textMuted",`${Q}:`.padEnd(X))} ${q}`,status:(Q,q)=>`${J(r$[Q],o$[Q])} ${q}`}}var O=E(!1);var t$=2;function e$($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function $8($,Z){if($==="")return[""];let J=[],Q="";for(let q of $.split(/\s+/).filter((X)=>X.length>0)){if(Q===""){Q=q;continue}if(Q.length+1+q.length<=Z){Q=`${Q} ${q}`;continue}J.push(Q),Q=q}if(Q!=="")J.push(Q);return J.length===0?[""]:J}function F($,Z,J=e$()){let Q=[Z.heading($.title.toUpperCase())];if($.summary&&$.summary.length>0)Q.push(` ${$.summary.join(Z.dim(` ${V.bar} `))}`);let q=Math.max(m,...$.sections.flatMap((X)=>(X.rows??[]).map((z)=>z.label.length+1)));for(let X of $.sections){if(Q.push(""),X.title)Q.push(Z.paint("accent",X.title));for(let z of X.rows??[]){let W=z.level?Z.status(z.level,z.value):z.value;Q.push(Z.kv(z.label,W,q))}for(let z of X.lines??[]){if(z===""){Q.push("");continue}let W=X.wrap?$8(z,J-t$):[z];for(let U of W)Q.push(` ${U}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
2
2
  `)}var H={enabled:!0,signalPath:"obs.jsonl",debugPath:"debug.jsonl",debugEnabled:!1,includePayloads:!1,maxAttrChars:500,sessionCostAlertUsd:5,retentionDays:14,maxSignalEvents:50000,globalSpool:!1};var Q8=["No harness decision in this window.","Whatever you just saw was the model, not a rail — the harness allowed everything it was asked about."].join(`
3
- `);var J8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:y},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},secrets:{redactOutput:!0},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...J8}},mcpPrime:[],bootstrapExtra:[]};var N="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as f8,mkdirSync as Eq,readFileSync as y8,realpathSync as kq,writeFileSync as v8}from"node:fs";function z$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function m8($){return z$($)}var g8="tlc-exec.mjs";function u8($){return JSON.stringify($??null).includes(g8)}function g($){if($===null||$.trim()==="")return{ok:!0,settingsText:"",changed:!1};let Z;try{Z=JSON.parse($)}catch(z){return{ok:!1,error:z instanceof Error?z.message:String(z),block:""}}if(!z$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=m8(Z.hooks)?Z.hooks:{},Q={},q=!1;for(let[z,W]of Object.entries(J)){let U=W.filter((_)=>!u8(_));if(U.length!==W.length)q=!0;if(U.length>0)Q[z]=U}let X={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")X[z]=W;else if(Object.keys(Q).length>0)X.hooks=Q;return{ok:!0,settingsText:JSON.stringify(X,null,2),changed:q}}function W$($){if(!f8($))return{ok:!0,settingsText:"",changed:!1};let Z=g(y8($,"utf8"));if(Z.ok&&Z.changed)v8($,Z.settingsText,"utf8");return Z}function u($,Z="tlc-exec.mjs"){if($===null||$.trim()==="")return{kind:"absent"};let J;try{J=JSON.parse($)}catch{return{kind:"unparsed"}}if(J===null||typeof J!=="object"||Array.isArray(J))return{kind:"unparsed"};let Q=J,q=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},X={},z=0,W=0;for(let[U,_]of Object.entries(q)){let d=Array.isArray(_)?_:[],L=d.filter((I$)=>!JSON.stringify(I$??null).includes(Z));if(z+=d.length-L.length,L.length>0)X[U]=L,W+=L.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:X},null,2)}
3
+ `);var J8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:y},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},secrets:{redactOutput:!0},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...J8}},mcpPrime:[],bootstrapExtra:[]};var N="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as f8,mkdirSync as Eq,readFileSync as y8,realpathSync as kq,writeFileSync as v8}from"node:fs";function z$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function m8($){return z$($)}var g8="tlc-exec.mjs";function u8($){return JSON.stringify($??null).includes(g8)}function g($){if($===null||$.trim()==="")return{ok:!0,settingsText:"",changed:!1};let Z;try{Z=JSON.parse($)}catch(z){return{ok:!1,error:z instanceof Error?z.message:String(z),block:""}}if(!z$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=m8(Z.hooks)?Z.hooks:{},Q={},q=!1;for(let[z,W]of Object.entries(J)){let U=W.filter((_)=>!u8(_));if(U.length!==W.length)q=!0;if(U.length>0)Q[z]=U}let X={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")X[z]=W;else if(Object.keys(Q).length>0)X.hooks=Q;return{ok:!0,settingsText:JSON.stringify(X,null,2),changed:q}}function W$($){if(!f8($))return{ok:!0,settingsText:"",changed:!1};let Z=g(y8($,"utf8"));if(Z.ok&&Z.changed)v8($,Z.settingsText,"utf8");return Z}function u($,Z="tlc-exec.mjs"){if($===null||$.trim()==="")return{kind:"absent"};let J;try{J=JSON.parse($)}catch{return{kind:"unparsed"}}if(J===null||typeof J!=="object"||Array.isArray(J))return{kind:"unparsed"};let Q=J,q=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},X={},z=0,W=0;for(let[U,_]of Object.entries(q)){let d=Array.isArray(_)?_:[],j=d.filter((I$)=>!JSON.stringify(I$??null).includes(Z));if(z+=d.length-j.length,j.length>0)X[U]=j,W+=j.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:X},null,2)}
4
4
  `}}import{join as Y$,relative as vq,resolve as mq,sep as gq}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],nq=[Y$("tools","dev"),Y$("tools","__test__")];function r8($=process.env){let Z=n8(),J=b($);return{home:C($),binLinks:n().map((Q)=>G(J,Q)),claudeSettings:G(T(),"settings.json"),cursorHooks:G(A(),"hooks.json"),skillLinks:[G(T(),"skills","harness-init"),G(A(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function p($){try{return h8($).isSymbolicLink()}catch{return!1}}function c($){let Z=h($),J=[];for(;;)try{return G(K$(Z),...J)}catch{let Q=M$(Z);if(Q===Z)return h($);J.unshift(a8(Z)),Z=Q}}function x($){try{return K$($)}catch{try{let Z=p8($);return c(O$(Z)?Z:h(M$($),Z))}catch{return c($)}}}function o8($,Z,J={relative:s8,isAbsolute:O$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function B$($,Z){return o8(x($),c(Z))}var t8="tlc-exec.mjs";function e8($){try{return d8($).size<4096&&S($,"utf8").includes(t8)}catch{return!1}}function U$($,Z,J,Q,q){if(!B(Z)&&!p(Z))return;if(!p(Z)){if(e8(Z)){$.push({action:"remove",target:Z,detail:`${Q}, installed as a copy`});return}$.push({action:"keep",target:Z,detail:`${Q} is a real file the installer did not write`});return}if(q==="target"&&!B$(Z,J)){$.push({action:"keep",target:Z,detail:`points at ${x(Z)} — not ours`});return}let X=q==="location"&&!B$(Z,J);$.push({action:"unlink",target:Z,detail:X?`${Q}, stale — points at ${x(Z)}`:Q})}function $Z($,Z){if(!B(Z))return;let J=g(S(Z,"utf8"));if(!J.ok){$.push({action:"keep",target:Z,detail:`left untouched — it does not parse as JSON: ${J.error}`});return}if(!J.changed)return;$.push({action:"unmerge",target:Z,detail:"drop the harness hook groups, keep every other key and every foreign hook"})}function ZZ($,Z){let J=B(Z)?S(Z,"utf8"):null,Q=u(J);switch(Q.kind){case"absent":return;case"unparsed":$.push({action:"keep",target:Z,detail:"left untouched — it does not parse as JSON"});return;case"empty":if(Q.removed>0)$.push({action:"remove",target:Z,detail:`${Q.removed} entries, all ours`});return;default:$.push({action:"unmerge",target:Z,detail:`drop ${Q.removed} harness entries, keep the rest`})}}function QZ($,Z,J){if(p(Z))return $.push({action:"unlink",target:Z,detail:`a link to ${x(Z)} — the checkout it points at is left exactly as it is`}),!0;if(!B(Z))return!1;for(let X of V$){let z=G(Z,X);if(B(z))$.push({action:"remove",target:z,detail:"runtime payload"})}let q=G(Z,k);if(B(q))$.push({action:"remove",target:q,detail:"install marker"});for(let X of G$){let z=G(Z,X);if(!B(z))continue;$.push(J?{action:"remove",target:z,detail:"--purge"}:{action:"keep",target:z,detail:"yours — add --purge to remove it"})}return!1}function JZ($,Z){if(B(G(Z,k)))$.push({action:"manual",target:`npm uninstall -g ${N}`,detail:"the global package is reported, never removed for you"});$.push({action:"manual",target:"rm -rf .tlc/ in each repository",detail:"per-project config and state — this command does not search your disk for them"})}function qZ($,Z={}){let J=Z.purge===!0,Q=[];$Z(Q,$.claudeSettings),ZZ(Q,$.cursorHooks);for(let X of $.skillLinks)U$(Q,X,$.home,"skill link","location");for(let X of $.binLinks)U$(Q,X,$.home,"the tlc launcher on PATH","target");let q=QZ(Q,$.home,J);return JZ(Q,$.home),{items:Q,purge:J,homeIsLink:q}}function C$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function XZ($,Z){let J=[],Q=[];for(let q of C$($))try{if(q.action==="unmerge"&&q.target===Z.claudeSettings)W$(q.target);else if(q.action==="unmerge"){let X=u(S(q.target,"utf8"));if(X.kind==="rewritten")i8(q.target,X.text,"utf8")}else if(q.action==="unlink")l8(q.target);else c8(q.target,{recursive:!0,force:!0});J.push(q)}catch(X){Q.push({item:q,reason:X instanceof Error?X.message:String(X)})}return{applied:J,failed:Q}}var zZ={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function WZ($,Z){let J=C$($),Q=Z!==null;if(J.length===0)return{title:"harness uninstall",summary:["nothing wired"],sections:[{rows:[{label:"state",value:"no harness artefact found — nothing to undo",level:"ok"}]},{title:"STILL YOURS TO DO",rows:H$($)}],footer:`already clean · reinstall with npm i -g ${N}, then \`tlc harness install\``};let q=J.map((W)=>({label:W.action,value:`${W.target} — ${W.detail}`,level:zZ[W.action]})),X=$.items.filter((W)=>W.action==="keep"),z=[{title:Q?"REMOVED":"WOULD REMOVE",rows:q},...X.length>0?[{title:"KEPT",rows:X.map((W)=>({label:"keep",value:`${W.target} — ${W.detail}`,level:"ok"}))}]:[],{title:"STILL YOURS TO DO",rows:H$($)},...Z!==null&&Z.failed.length>0?[{title:"FAILED",rows:Z.failed.map((W)=>({label:"error",value:`${W.item.target} — ${W.reason}`,level:"fail"}))}]:[]];return{title:"harness uninstall",summary:[Q?`${Z.applied.length} applied`:`${J.length} pending`,$.purge?"purge: state included":"purge: state kept",$.homeIsLink?"runtime: linked checkout, unlinked only":"runtime: owned by the installer"],sections:z,footer:Q?`reinstall any time with npm i -g ${N}, then \`tlc harness install\``:"nothing was changed · re-run with --yes to apply this plan"}}function H$($){return $.items.filter((Z)=>Z.action==="manual").map((Z)=>({label:"run",value:`${Z.target} — ${Z.detail}`,level:"info"}))}function YZ($,Z,J=O){return F(WZ($,Z),J)}function VZ($=process.argv.slice(2)){let Z=r8(),J=qZ(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?XZ(J,Z):null;return console.log(YZ(J,Q,E())),Q!==null&&Q.failed.length>0?1:0}if(l.main==l.module)process.exitCode=VZ();export{r8 as uninstallTargets,WZ as uninstallScreen,YZ as uninstallReportText,qZ as planUninstall,C$ as pendingItems,VZ as main,o8 as isInsideRoot,c as canonicalise,XZ as applyUninstall};
package/docs/concepts.md CHANGED
@@ -329,7 +329,7 @@ rather than reporting nothing, because an inert mechanism and a working one othe
329
329
 
330
330
  ```markdown
331
331
  ---
332
- on: pr-open # pr-open | commit | push | stop | tool(<name>) | command(<pattern>)
332
+ on: pr-open # pr-open | commit | push | pr-merge | stop | tool(<name>) | command(<pattern>)
333
333
  require:
334
334
  - subagent(the-jury) since HEAD # subagent | command | gate | file, since HEAD or since session
335
335
  otherwise: deny # deny | ask | follow-up | warn
@@ -406,9 +406,22 @@ Posture reaches `ask` and nothing else: it interrupts under `paired` and hardens
406
406
  `focus`. `deny`, `follow-up` and `warn` are verification and are identical at all three
407
407
  ([/decisions/ad-025.md](/decisions/ad-025.md)).
408
408
 
409
- A pattern trigger is policy rather than containment. A script written to disk and executed later, a command name
410
- built at runtime, `gh api` instead of `gh pr create`, or a pull request opened in a browser all escape it — the
411
- rule covers the agent's shell path ([/decisions/ad-100.md](/decisions/ad-100.md)).
409
+ A pattern trigger is policy rather than containment. `pr-open` and `push` recognize their `gh api` REST
410
+ equivalent (`POST` to a path ending in `pulls`; `POST`/`PATCH` to a path touching `git/refs`) alongside the CLI
411
+ shape, and `pr-merge` recognizes `gh pr merge` and `PUT` to a path ending in `merge` under `pulls`
412
+ ([/decisions/ad-128.md](/decisions/ad-128.md)) — but a script written to disk and executed later, a command
413
+ name built at runtime, a GraphQL mutation, `curl` direct to the API, a `gh api` call that flips a pull request
414
+ to ready without going through `gh pr ready`, or a pull request opened, approved, or merged in a browser all
415
+ still escape every trigger this project has. The rule covers the agent's shell path
416
+ ([/decisions/ad-100.md](/decisions/ad-100.md)); it was never meant to cover more than that.
417
+
418
+ **The durable, non-bypassable boundary is GitHub's own branch protection, not this mechanism.** A required
419
+ status check (posted by whatever proof this project's rule already demands — the-jury's review, a gate
420
+ passing) combined with "do not allow bypassing the above settings" turned on and no automation account holding
421
+ admin closes the remainder, because it is enforced server-side at merge time regardless of which client
422
+ attempted it. A `pr-open`/`push`/`pr-merge` rule here is a fast local nudge in front of that boundary — worth
423
+ having, because it catches a cooperative agent's mistake before a round trip to GitHub, but not a substitute
424
+ for configuring the boundary that actually holds against one that doesn't cooperate.
412
425
 
413
426
  `pr-open` does not fire on opening a **draft** pull request — only on a non-draft create and on converting a
414
427
  draft to ready. This matters when a `require:` proof itself depends on the pull request already existing (a
package/docs/log.md CHANGED
@@ -13,6 +13,10 @@ Generated from `docs/decisions/` — do not edit by hand. Run `node tools/render
13
13
  A reserved file of the [OKF v0.1](/decisions/ad-013.md) bundle: entries grouped under ISO 8601 headings,
14
14
  newest first. For what landed in which npm release, see `CHANGELOG.md` at the repository root.
15
15
 
16
+ ## 2026-09-09
17
+
18
+ - **AD-128** — pr-open and push recognize their gh api equivalent, and pr-merge exists ([/decisions/ad-128.md](/decisions/ad-128.md))
19
+
16
20
  ## 2026-09-08
17
21
 
18
22
  - **AD-127** — A rule's shell triggers match the act at any word position, not only at word 0 ([/decisions/ad-127.md](/decisions/ad-127.md))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -21,7 +21,7 @@ import {
21
21
 
22
22
  export type RuleSource = { name: string; tier: RuleTier; text: string };
23
23
 
24
- const BARE_TRIGGERS = new Set(["pr-open", "commit", "push", "stop"]);
24
+ const BARE_TRIGGERS = new Set(["pr-open", "commit", "push", "pr-merge", "stop"]);
25
25
 
26
26
  /** why: `tool(Write)` and `command(gh pr create)` carry an argument; the other four do not. */
27
27
  function parseTrigger(raw: string): RuleTrigger | string {
@@ -33,7 +33,7 @@ function parseTrigger(raw: string): RuleTrigger | string {
33
33
  const verb = call?.[1];
34
34
  const argument = call?.[2]?.trim();
35
35
  if (verb === undefined || argument === undefined || argument === "") {
36
- return `unknown trigger "${bare}" — use one of pr-open, commit, push, stop, tool(<name>), command(<pattern>)`;
36
+ return `unknown trigger "${bare}" — use one of pr-open, commit, push, pr-merge, stop, tool(<name>), command(<pattern>)`;
37
37
  }
38
38
  return verb === "tool" ? { kind: "tool", name: argument } : { kind: "command", pattern: argument };
39
39
  }
@@ -38,13 +38,14 @@ type ShellShape = {
38
38
  readonly excludeIfAny?: readonly string[];
39
39
  };
40
40
 
41
- const SHELL_SHAPES: Record<"pr-open" | "commit" | "push", readonly ShellShape[]> = {
41
+ const SHELL_SHAPES: Record<"pr-open" | "commit" | "push" | "pr-merge", readonly ShellShape[]> = {
42
42
  "pr-open": [
43
43
  { prefix: ["gh", "pr", "create"], excludeIfAny: ["--draft", "-d"] },
44
44
  { prefix: ["gh", "pr", "ready"] },
45
45
  ],
46
46
  commit: [{ prefix: ["git", "commit"] }],
47
47
  push: [{ prefix: ["git", "push"] }],
48
+ "pr-merge": [{ prefix: ["gh", "pr", "merge"] }],
48
49
  };
49
50
 
50
51
  /**
@@ -88,11 +89,113 @@ function tokenMatches(word: string | undefined, token: string): boolean {
88
89
  * that reason before this existed ([/decisions/ad-127.md](/decisions/ad-127.md)). Trailing words past the
89
90
  * phrase don't matter either: `gh pr create --fill --base main` is the same act as `gh pr create`.
90
91
  */
91
- function containsPhrase(words: readonly string[], tokens: readonly string[]): boolean {
92
+ function findPhraseIndex(words: readonly string[], tokens: readonly string[]): number {
92
93
  if (tokens.length === 0) {
94
+ return -1;
95
+ }
96
+ return words.findIndex((_, start) =>
97
+ tokens.every((token, index) => tokenMatches(words[start + index], token)),
98
+ );
99
+ }
100
+
101
+ function containsPhrase(words: readonly string[], tokens: readonly string[]): boolean {
102
+ return findPhraseIndex(words, tokens) !== -1;
103
+ }
104
+
105
+ /**
106
+ * why a second matcher, not one more `ShellShape`: `gh pr create` and `gh api repos/{o}/{r}/pulls` are the same
107
+ * real-world act described in two different grammars — CLI subcommand words vs. a REST path and an HTTP method.
108
+ * A pattern trigger was already documented as policy, not containment, precisely because of this escape
109
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)); this narrows the one instance of it this project has actually
110
+ * seen exploited, without pretending to close the class ([/decisions/ad-128.md](/decisions/ad-128.md)).
111
+ */
112
+ type ApiShape = {
113
+ readonly lastSegment?: string;
114
+ readonly containsSegment?: string;
115
+ readonly adjacentPair?: readonly [string, string];
116
+ readonly methods: readonly string[];
117
+ };
118
+
119
+ const API_VALUE_FLAGS = new Set([
120
+ "-X",
121
+ "--method",
122
+ "-f",
123
+ "--raw-field",
124
+ "-F",
125
+ "--field",
126
+ "--input",
127
+ "-p",
128
+ "--preview",
129
+ ]);
130
+ const API_BODY_FLAGS = new Set(["-f", "--raw-field", "-F", "--field", "--input"]);
131
+
132
+ /** why strip scheme+host and query: `gh api` accepts a bare path, a leading-slash path, or a full URL alike. */
133
+ function apiPathSegments(path: string): string[] {
134
+ const withoutQuery = (path.split("?")[0] ?? "").replace(/^https?:\/\/[^/]+/, "");
135
+ return withoutQuery.split("/").filter((segment) => segment.length > 0);
136
+ }
137
+
138
+ /**
139
+ * why a hand-rolled scan and not `.find`: a value-taking flag's value (`-X POST`) must not be mistaken for the
140
+ * endpoint, and `gh api` accepts both `<endpoint> [flags]` and `[flags] <endpoint>` — its own `--help` shows
141
+ * both orders.
142
+ */
143
+ function apiPathArgument(words: readonly string[]): string | undefined {
144
+ const at = findPhraseIndex(words, ["gh", "api"]);
145
+ if (at === -1) {
146
+ return undefined;
147
+ }
148
+ const rest = words.slice(at + 2);
149
+ for (let index = 0; index < rest.length; index += 1) {
150
+ const word = rest[index];
151
+ if (word === undefined) {
152
+ continue;
153
+ }
154
+ if (API_VALUE_FLAGS.has(word)) {
155
+ index += 1;
156
+ continue;
157
+ }
158
+ if (word.startsWith("-")) {
159
+ continue;
160
+ }
161
+ return word;
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ /**
167
+ * why POST when a body flag is present with no explicit method: confirmed against `gh api --help`'s own text,
168
+ * not assumed — "The default HTTP request method is GET normally and POST if any parameters were added."
169
+ */
170
+ function inferredApiMethod(words: readonly string[]): string {
171
+ const methodAt = words.findIndex((word) => word === "-X" || word === "--method");
172
+ const explicit = methodAt === -1 ? undefined : words[methodAt + 1];
173
+ if (explicit !== undefined) {
174
+ return explicit.toUpperCase();
175
+ }
176
+ return words.some((word) => API_BODY_FLAGS.has(word)) ? "POST" : "GET";
177
+ }
178
+
179
+ function matchesApiShape(words: readonly string[], shape: ApiShape): boolean {
180
+ const path = apiPathArgument(words);
181
+ if (path === undefined) {
182
+ return false;
183
+ }
184
+ const segments = apiPathSegments(path);
185
+ const last = segments[segments.length - 1];
186
+ if (shape.lastSegment !== undefined && last !== shape.lastSegment) {
187
+ return false;
188
+ }
189
+ if (shape.containsSegment !== undefined && !segments.includes(shape.containsSegment)) {
93
190
  return false;
94
191
  }
95
- return words.some((_, start) => tokens.every((token, index) => tokenMatches(words[start + index], token)));
192
+ if (shape.adjacentPair !== undefined) {
193
+ const [a, b] = shape.adjacentPair;
194
+ if (!segments.some((segment, index) => segment === a && segments[index + 1] === b)) {
195
+ return false;
196
+ }
197
+ }
198
+ return shape.methods.includes(inferredApiMethod(words));
96
199
  }
97
200
 
98
201
  /**
@@ -110,6 +213,29 @@ function matchesShape(words: readonly string[], shape: ShellShape): boolean {
110
213
  return !shape.excludeIfAny?.some((flag) => words.includes(flag));
111
214
  }
112
215
 
216
+ /**
217
+ * why partial and not total: `commit` has no REST-endpoint equivalent an agent's normal workflow ever produces
218
+ * — creating a commit through GitHub's git-database API is not a shape this project has observed in practice,
219
+ * unlike the `pr-open`/`push`/`pr-merge` cases this record exists to close
220
+ * ([/decisions/ad-128.md](/decisions/ad-128.md)).
221
+ */
222
+ const API_SHAPES: Partial<Record<"pr-open" | "commit" | "push" | "pr-merge", readonly ApiShape[]>> = {
223
+ "pr-open": [{ lastSegment: "pulls", methods: ["POST"] }],
224
+ push: [{ adjacentPair: ["git", "refs"], methods: ["POST", "PATCH"] }],
225
+ "pr-merge": [{ lastSegment: "merge", containsSegment: "pulls", methods: ["PUT"] }],
226
+ };
227
+
228
+ function matchesAnyShape(
229
+ words: readonly string[],
230
+ kind: "pr-open" | "commit" | "push" | "pr-merge",
231
+ ): boolean {
232
+ if (SHELL_SHAPES[kind].some((shape) => matchesShape(words, shape))) {
233
+ return true;
234
+ }
235
+ const apiShapes = API_SHAPES[kind] ?? [];
236
+ return apiShapes.some((shape) => matchesApiShape(words, shape));
237
+ }
238
+
113
239
  export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): boolean {
114
240
  switch (trigger.kind) {
115
241
  case "stop":
@@ -118,12 +244,12 @@ export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): b
118
244
  return context.toolName === trigger.name;
119
245
  case "pr-open":
120
246
  case "commit":
121
- case "push": {
247
+ case "push":
248
+ case "pr-merge": {
122
249
  if (context.command === undefined) {
123
250
  return false;
124
251
  }
125
- const shapes = SHELL_SHAPES[trigger.kind];
126
- return subCommands(context.command).some((words) => shapes.some((shape) => matchesShape(words, shape)));
252
+ return subCommands(context.command).some((words) => matchesAnyShape(words, trigger.kind));
127
253
  }
128
254
  default: {
129
255
  if (context.command === undefined) {
@@ -25,6 +25,7 @@ export type RuleTrigger =
25
25
  | { kind: "pr-open" }
26
26
  | { kind: "commit" }
27
27
  | { kind: "push" }
28
+ | { kind: "pr-merge" }
28
29
  | { kind: "stop" }
29
30
  | { kind: "tool"; name: string }
30
31
  | { kind: "command"; pattern: string };
@@ -15,7 +15,10 @@ import {
15
15
  shaScopeRoot,
16
16
  } from "./support.ts";
17
17
 
18
- const FULL_BATTERY_KINDS = ["push", "pr-open"] as const;
18
+ // why: pr-merge is the moment code actually lands on the shared branch. Usually no local diff exists at merge
19
+ // time, so the battery abstains — the merged commits already paid it at push/pr-open time
20
+ // ([/decisions/ad-128.md](/decisions/ad-128.md)).
21
+ const FULL_BATTERY_KINDS = ["push", "pr-open", "pr-merge"] as const;
19
22
 
20
23
  function isShipCommand(event: HarnessEvent): boolean {
21
24
  if (event.command === undefined) {