@tech-leads-club/harness-toolkit 0.15.0 → 0.15.2
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 +88 -86
- package/dist/doctor.mjs +91 -89
- package/dist/init-project.mjs +91 -89
- package/dist/install-runtime.mjs +88 -86
- package/dist/lessons-cli.mjs +84 -82
- package/dist/obs-cli.mjs +88 -86
- package/dist/price-lookup.mjs +1 -1
- package/dist/prompt-submit.mjs +88 -86
- package/dist/refresh-model-prices.mjs +90 -88
- package/dist/response-after.mjs +88 -86
- package/dist/run.mjs +88 -86
- package/dist/session-end.mjs +93 -91
- package/dist/session-start.mjs +95 -93
- package/dist/shim.mjs +87 -85
- package/dist/ship-gate.mjs +89 -87
- package/dist/stop.mjs +94 -92
- package/dist/subagent-start.mjs +88 -86
- package/dist/subagent-stop.mjs +89 -87
- package/dist/support.mjs +89 -87
- package/dist/tlc-cli.mjs +107 -105
- package/dist/tool-after.mjs +84 -82
- package/dist/tool-before.mjs +92 -90
- package/dist/tool-failure.mjs +88 -86
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/concepts.md +4 -2
- package/docs/diagnose.md +2 -2
- package/docs/log.md +5 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +16 -2
- package/src/core/gate/gate.artifact.ts +43 -7
- package/src/core/presence/presence.service.ts +68 -0
- package/src/core/rules/rules.decide.ts +11 -1
- package/src/core/rules/rules.trigger.ts +11 -0
- package/src/entrypoints/session-start.ts +1 -0
- package/src/entrypoints/stop.ts +14 -2
- package/src/entrypoints/support.ts +8 -1
- package/src/platform/paths.ts +4 -0
|
@@ -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 sX,readFileSync as m8,realpathSync as rX,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 J4,resolve as q4,sep as X4}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],H4=[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
|
|
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,11 @@ 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
|
+
- **AD-138** — a pr-open rule's own denial names its draft escape hatch ([/decisions/ad-138.md](/decisions/ad-138.md))
|
|
20
|
+
|
|
16
21
|
## 2026-09-10
|
|
17
22
|
|
|
18
23
|
- **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))
|
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,
|
|
@@ -144,7 +149,14 @@ import { isOperatorMode, OPERATOR_MODES } from "./policy/policy.posture.ts";
|
|
|
144
149
|
import { activeRails } from "./policy/policy.rails.ts";
|
|
145
150
|
import { pruneShadowed, shadowedKeys, typeMismatches, unknownKeys } from "./policy/policy.shadow.ts";
|
|
146
151
|
import { forProvider } from "./policy/policy.types.ts";
|
|
147
|
-
import {
|
|
152
|
+
import {
|
|
153
|
+
checkCollision,
|
|
154
|
+
filesClaimedByOtherLiveSessions,
|
|
155
|
+
heartbeat,
|
|
156
|
+
register,
|
|
157
|
+
release,
|
|
158
|
+
sweepStale,
|
|
159
|
+
} from "./presence/presence.service.ts";
|
|
148
160
|
import { freshness, freshnessMessage, mayReplace, shouldRefetch } from "./pricing/pricing.freshness.ts";
|
|
149
161
|
import {
|
|
150
162
|
allDecisionFiles,
|
|
@@ -290,6 +302,7 @@ export const coreFacade = {
|
|
|
290
302
|
GateLockTimeoutError,
|
|
291
303
|
writeLastGate,
|
|
292
304
|
readLastGate,
|
|
305
|
+
pruneGateSessions,
|
|
293
306
|
computeGateFingerprint,
|
|
294
307
|
computeInputsHash,
|
|
295
308
|
isCacheHit,
|
|
@@ -527,6 +540,7 @@ export const coreFacade = {
|
|
|
527
540
|
checkCollision,
|
|
528
541
|
sweepStale,
|
|
529
542
|
release,
|
|
543
|
+
filesClaimedByOtherLiveSessions,
|
|
530
544
|
},
|
|
531
545
|
floor: {
|
|
532
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 {
|
|
@@ -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 };
|
|
@@ -9,6 +9,7 @@ import type { Decision } from "../../contracts/decision.ts";
|
|
|
9
9
|
import { WHY_POINTER } from "../diagnostics/diagnostics.message.ts";
|
|
10
10
|
import type { OperatorMode } from "../policy/policy.types.ts";
|
|
11
11
|
import { missingProofs, type Observation, type ProofContext, proofLabel } from "./rules.proof.ts";
|
|
12
|
+
import { TRIGGER_ESCAPE_HATCH_HINT } from "./rules.trigger.ts";
|
|
12
13
|
import type { Rule, RuleVerdict } from "./rules.types.ts";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -28,6 +29,13 @@ export function effectiveVerdict(declared: RuleVerdict, mode: OperatorMode): Rul
|
|
|
28
29
|
* genuine miss, and nothing in the message said which directory was checked
|
|
29
30
|
* ([/decisions/ad-120.md](/decisions/ad-120.md)). The body stays verbatim beneath it either way.
|
|
30
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* why the escape-hatch hint sits here, generated, rather than in the operator's own body: an operator who
|
|
34
|
+
* writes a `pr-open` rule and never thinks to mention the draft escape hatch (`SHELL_SHAPES`, `rules.trigger.ts`)
|
|
35
|
+
* leaves every agent that hits the denial with no way to discover it except a human explaining it by hand,
|
|
36
|
+
* confirmed live — generated once, here, it reaches every operator's rule without asking any of them to write
|
|
37
|
+
* it ([/decisions/ad-138.md](/decisions/ad-138.md)).
|
|
38
|
+
*/
|
|
31
39
|
export function ruleMessage(
|
|
32
40
|
rule: Rule,
|
|
33
41
|
missing: readonly ReturnType<typeof proofLabel>[],
|
|
@@ -35,7 +43,9 @@ export function ruleMessage(
|
|
|
35
43
|
sha: string | null,
|
|
36
44
|
): string {
|
|
37
45
|
const head = `rule ${rule.name} (${rule.tier}): missing ${missing.join(", ")} — checked ${shaRoot} at ${sha ?? "no HEAD"}\n${WHY_POINTER}`;
|
|
38
|
-
|
|
46
|
+
const hint = TRIGGER_ESCAPE_HATCH_HINT[rule.on.kind];
|
|
47
|
+
const withHint = hint === undefined ? head : `${head}\n\n${hint}`;
|
|
48
|
+
return rule.body.trim() === "" ? withHint : `${withHint}\n\n${rule.body.trim()}`;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
export type RuleOutcome = {
|
|
@@ -61,6 +61,17 @@ const SHELL_SHAPES: Record<"pr-open" | "commit" | "push" | "pr-merge", readonly
|
|
|
61
61
|
"pr-merge": [{ prefix: ["gh", "pr", "merge"] }],
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* why colocated with `SHELL_SHAPES`, not left to `rules.decide.ts`: this is the human-readable half of the
|
|
66
|
+
* `excludeIfAny` fact above it. Only `pr-open` carries one today — a future trigger gaining its own
|
|
67
|
+
* `excludeIfAny` shape has this constant sitting right beside the reminder that its own message may need one
|
|
68
|
+
* too ([/decisions/ad-138.md](/decisions/ad-138.md)).
|
|
69
|
+
*/
|
|
70
|
+
export const TRIGGER_ESCAPE_HATCH_HINT: Partial<Record<RuleTrigger["kind"], string>> = {
|
|
71
|
+
"pr-open":
|
|
72
|
+
"A draft PR (`gh pr create --draft` or `-d`) is not gated by any pr-open rule — open it as a draft, satisfy this rule's proof, then `gh pr ready`, which is itself gated the normal way.",
|
|
73
|
+
};
|
|
74
|
+
|
|
64
75
|
/**
|
|
65
76
|
* invariant: `tokenizeShell` already declines to emit segments from a heredoc body, so a body is never mistaken
|
|
66
77
|
* for a command and a command after one is still seen. Measured both ways on
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -205,7 +205,11 @@ export async function runLockedGate(args: {
|
|
|
205
205
|
}): Promise<GateRun> {
|
|
206
206
|
const command = [...args.command, ...args.argvFiles];
|
|
207
207
|
const inputs = coreFacade.gate.computeInputsHash(args.root, args.recordFiles, command);
|
|
208
|
-
const cached = coreFacade.gate.cachedVerdict(
|
|
208
|
+
const cached = coreFacade.gate.cachedVerdict(
|
|
209
|
+
coreFacade.gate.readLastGate(args.root, args.sessionKey),
|
|
210
|
+
args.gate,
|
|
211
|
+
inputs,
|
|
212
|
+
);
|
|
209
213
|
|
|
210
214
|
let artifact: LastGateArtifact;
|
|
211
215
|
if (cached !== null) {
|
|
@@ -220,6 +224,7 @@ export async function runLockedGate(args: {
|
|
|
220
224
|
const result = await runCommand(args.root, args.command, args.argvFiles);
|
|
221
225
|
return coreFacade.gate.writeLastGate({
|
|
222
226
|
root: args.root,
|
|
227
|
+
sessionKey: args.sessionKey,
|
|
223
228
|
gate: args.gate,
|
|
224
229
|
exitCode: result.exitCode,
|
|
225
230
|
command,
|
|
@@ -491,7 +496,14 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
491
496
|
* says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
492
497
|
*/
|
|
493
498
|
const deferred: string[] = [];
|
|
494
|
-
const
|
|
499
|
+
const rawChangedFiles = await listChangedRepoFiles(shaRoot, turnBase);
|
|
500
|
+
const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
|
|
501
|
+
root,
|
|
502
|
+
shaRoot,
|
|
503
|
+
provider,
|
|
504
|
+
sessionKey,
|
|
505
|
+
);
|
|
506
|
+
const changedFiles = rawChangedFiles.filter((file) => !otherSessionFiles.has(file));
|
|
495
507
|
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
496
508
|
const testTargets = filterTestTargets(changedFiles);
|
|
497
509
|
// why: the comment rail scopes by the syntax catalog (40+ languages), not `codeTargets`'s nine-extension
|
|
@@ -108,7 +108,14 @@ export async function computeTurnScope(
|
|
|
108
108
|
const seal = coreFacade.handoff.handoffInjectable(root, sessionKey);
|
|
109
109
|
const handoff = seal.ok ? coreFacade.handoff.readHandoff(root, provider, sessionKey) : undefined;
|
|
110
110
|
const turnBase = handoff?.turn_base_sha ?? "HEAD";
|
|
111
|
-
const
|
|
111
|
+
const rawChangedFiles = await listChangedRepoFiles(gitRoot, turnBase);
|
|
112
|
+
const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
|
|
113
|
+
root,
|
|
114
|
+
gitRoot,
|
|
115
|
+
provider,
|
|
116
|
+
sessionKey,
|
|
117
|
+
);
|
|
118
|
+
const changedFiles = rawChangedFiles.filter((file) => !otherSessionFiles.has(file));
|
|
112
119
|
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
113
120
|
const testTargets = filterTestTargets(changedFiles);
|
|
114
121
|
const commentScope = changedFiles.filter((file) =>
|
package/src/platform/paths.ts
CHANGED
|
@@ -132,6 +132,10 @@ export function handoffSessionsDir(root: string): string {
|
|
|
132
132
|
return join(projectStateDir(root), "handoff-sessions");
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
export function gateSessionsDir(root: string): string {
|
|
136
|
+
return join(projectStateDir(root), "gate-sessions");
|
|
137
|
+
}
|
|
138
|
+
|
|
135
139
|
// why: inside the state directory on purpose — the baseline that proves the policy was not switched off
|
|
136
140
|
// inherits the same protection as the policy itself.
|
|
137
141
|
export function policyBaselineDir(root: string): string {
|