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