@tech-leads-club/harness-toolkit 0.10.4 → 0.10.6
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/bin/tlc-cli.ts +13 -11
- package/dist/compact-before.mjs +79 -79
- package/dist/doctor.mjs +80 -80
- package/dist/init-project.mjs +82 -82
- package/dist/install-runtime.mjs +79 -79
- package/dist/lessons-cli.mjs +79 -79
- package/dist/obs-cli.mjs +84 -84
- package/dist/price-lookup.mjs +1 -1
- package/dist/prompt-submit.mjs +79 -79
- package/dist/refresh-model-prices.mjs +79 -79
- package/dist/response-after.mjs +79 -79
- package/dist/run.mjs +79 -79
- package/dist/session-end.mjs +84 -84
- package/dist/session-start.mjs +86 -86
- package/dist/shim.mjs +78 -78
- package/dist/ship-gate.mjs +79 -79
- package/dist/stop.mjs +85 -85
- package/dist/subagent-start.mjs +79 -79
- package/dist/subagent-stop.mjs +80 -80
- package/dist/support.mjs +83 -83
- package/dist/tlc-cli.mjs +97 -97
- package/dist/tool-after.mjs +79 -79
- package/dist/tool-before.mjs +81 -81
- package/dist/tool-failure.mjs +79 -79
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/concepts.md +1 -1
- package/docs/log.md +5 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +8 -0
- package/src/core/gate/gate.lock.ts +8 -32
- package/src/core/handoff/handoff.service.ts +110 -27
- package/src/core/handoff/handoff.session-store.ts +200 -0
- package/src/core/handoff/handoff.store.ts +5 -20
- package/src/core/handoff/handoff.types.ts +42 -9
- package/src/core/presence/presence.service.ts +59 -13
- package/src/core/rules/rules.trigger.ts +19 -1
- package/src/entrypoints/prompt-submit.ts +1 -1
- package/src/entrypoints/response-after.ts +4 -4
- package/src/entrypoints/session-end.ts +1 -1
- package/src/entrypoints/session-start.ts +5 -4
- package/src/entrypoints/ship-gate.ts +2 -2
- package/src/entrypoints/stop.ts +18 -18
- package/src/entrypoints/subagent-stop.ts +4 -3
- package/src/entrypoints/support.ts +5 -3
- package/src/entrypoints/tool-after.ts +1 -1
- package/src/entrypoints/tool-before.ts +1 -1
- package/src/platform/paths.ts +4 -0
- package/src/platform/process.ts +36 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{createRequire as M$}from"node:module";var l=M$(import.meta.url);import{existsSync as B,lstatSync as u8,readFileSync as S,readlinkSync as g8,realpathSync as G$,rmSync as h8,statSync as p8,unlinkSync as c8,writeFileSync as d8}from"node:fs";import{homedir as l8}from"node:os";import{basename as i8,dirname as B$,isAbsolute as H$,join as G,relative as n8,resolve as h}from"node:path";import{homedir as L}from"node:os";import{delimiter as K2,dirname as U2,join as j,resolve as M2}from"node:path";function i(){return j(L(),".tlc","harness")}function F($=process.env){return $.TLC_HOME??i()}function T(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:j(L(),".claude")}function P(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:j(L(),".cursor")}function f($=process.env){return $.TLC_BIN_DIR?.trim()||j(L(),".local","bin")}function n(){return["tlc","tlc.cmd"]}var s=[{id:"typescript",extensions:[".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".cjs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"python",extensions:[".py",".pyi",".pyw"],line:["#"],block:[['"""','"""'],["'''","'''"]],middle:[]},{id:"ruby",extensions:[".rb",".rake",".gemspec"],line:["#"],block:[["=begin","=end"]],middle:[]},{id:"shell",extensions:[".sh",".bash",".zsh",".ksh",".fish"],line:["#"],block:[],middle:[]},{id:"go",extensions:[".go"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"rust",extensions:[".rs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"java",extensions:[".java"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"kotlin",extensions:[".kt",".kts"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"swift",extensions:[".swift"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"c",extensions:[".c",".h"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"cpp",extensions:[".cc",".cpp",".cxx",".hpp",".hh",".hxx"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"csharp",extensions:[".cs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"php",extensions:[".php"],line:["//","#"],block:[["/*","*/"]],middle:["*"]},{id:"scala",extensions:[".scala",".sc"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"dart",extensions:[".dart"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"elixir",extensions:[".ex",".exs"],line:["#"],block:[],middle:[]},{id:"erlang",extensions:[".erl",".hrl"],line:["%"],block:[],middle:[]},{id:"haskell",extensions:[".hs"],line:["--"],block:[["{-","-}"]],middle:[]},{id:"lua",extensions:[".lua"],line:["--"],block:[["--[[","]]"]],middle:[]},{id:"sql",extensions:[".sql"],line:["--"],block:[["/*","*/"]],middle:[]},{id:"css",extensions:[".css",".scss",".sass",".less"],line:["//"],block:[["/*","*/"]],middle:[]},{id:"yaml",extensions:[".yaml",".yml"],line:["#"],block:[],middle:[]},{id:"toml",extensions:[".toml"],line:["#"],block:[],middle:[]},{id:"ini",extensions:[".ini",".cfg",".conf",".properties"],line:[";","#"],block:[],middle:[]},{id:"dockerfile",extensions:[".dockerfile","dockerfile"],line:["#"],block:[],middle:[]},{id:"makefile",extensions:[".mk","makefile"],line:["#"],block:[],middle:[]},{id:"terraform",extensions:[".tf",".tfvars"],line:["#","//"],block:[["/*","*/"]],middle:["*"]},{id:"powershell",extensions:[".ps1",".psm1",".psd1"],line:["#"],block:[["<#","#>"]],middle:[]},{id:"perl",extensions:[".pl",".pm"],line:["#"],block:[],middle:[]},{id:"r",extensions:[".r"],line:["#"],block:[],middle:[]},{id:"julia",extensions:[".jl"],line:["#"],block:[["#=","=#"]],middle:[]},{id:"vue",extensions:[".vue",".svelte"],line:["//"],block:[["/*","*/"],["<!--","-->"]],middle:["*"]},{id:"html",extensions:[".html",".htm",".xml",".xhtml"],line:[],block:[["<!--","-->"]],middle:[]},{id:"graphql",extensions:[".graphql",".gql"],line:["#"],block:[],middle:[]},{id:"protobuf",extensions:[".proto"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"zig",extensions:[".zig"],line:["//"],block:[],middle:[]},{id:"clojure",extensions:[".clj",".cljs",".cljc",".edn"],line:[";"],block:[],middle:[]},{id:"ocaml",extensions:[".ml",".mli"],line:[],block:[["(*","*)"]],middle:["*"]},{id:"fsharp",extensions:[".fs",".fsi",".fsx"],line:["//"],block:[["(*","*)"]],middle:["*"]},{id:"vim",extensions:[".vim"],line:['"'],block:[],middle:[]},{id:"tex",extensions:[".tex",".sty",".cls"],line:["%"],block:[],middle:[]}];function C$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let q of J.extensions)Z.set(q.toLowerCase(),Q)}return Z}var _$=C$(s);var N$=_$.size;var y=6;var A$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var FZ=new Set([...A$,"nc","ncat","socat","telnet","lwp-request"]);var R$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",_Z=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${R$}))(?=[:)\\s,'"\`]|$)`,"g");var A="1970-01-01T00:00:00.000Z";function M($){return{...$,scope:"gate-execution",source:"core",tier:"core",status:"active",confidence:1,hitCount:1,pinned:!1,refs:[],sessionKeys:[],injectedCount:0,gradeableCount:0,helpedCount:0,neutralCount:0,firstSeenAt:A,lastSeenAt:A,lastAccessedAt:A,updatedAt:A}}var W5=[M({id:"core:gate:lint",failedGate:"lint",category:"verification",triggerTokens:["lint","biome","eslint","ruff","format"],instruction:"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",avoid:"Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",prefer:"Apply the smallest fix that clears each finding, then let the stop hook re-check.",preRetryCheck:"Confirm the lint command targets only the intended changed files and still fails for the same codes.",priority:90}),M({id:"core:gate:test",failedGate:"test",category:"verification",triggerTokens:["test","vitest","jest","pytest","failing"],instruction:"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",avoid:"Do not delete failing tests, mark them skipped, or weaken assertions to force green.",prefer:"Reproduce the failure, fix root cause, re-run the same test target.",preRetryCheck:"Identify the failing test name/file from the gate output before editing.",priority:90}),M({id:"core:gate:comments",failedGate:"comments",category:"verification",triggerTokens:["junk comment","TODO","FIXME","banner"],instruction:"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",avoid:"Do not keep TODO markers or section banners 'for clarity'.",prefer:"Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",preRetryCheck:"Scan the listed file:line hits and remove each one.",priority:80}),M({id:"core:gate:ship",failedGate:"ship",category:"ship-evidence",triggerTokens:["ship","evidence","90-verdict","PASS"],instruction:"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",avoid:"Do not claim shipped based on unit tests alone when runtime paths changed.",prefer:"Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",preRetryCheck:"Confirm evidenceDir and a recent PASS verdict exist for this change.",priority:95}),M({id:"core:gate:empty-diff",failedGate:"empty-diff",category:"ship-evidence",triggerTokens:["empty","diff","no changes","shipped"],instruction:"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",avoid:"Do not restate 'done' without a real diff or an explicit zero-change justification.",prefer:"Make the missing change, or clearly document why no files should change.",preRetryCheck:"Inspect git status / changed files before the next stop.",priority:92}),M({id:"core:gate:stagnation",failedGate:"stagnation",category:"stagnation",triggerTokens:["stagnation","identical","fingerprint","same fail"],instruction:"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",avoid:"Do not retry the exact same patch, command, or suppression.",prefer:"Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",preRetryCheck:"Diff your last edit against the gate output; ensure the next action is different.",priority:100})];var d$={structure:"#3d3a4a",accent:"#a78bfa",success:"#6ee7b7",warning:"#d4a574",error:"#f87171",info:"#93c5fd",textMain:"#f5f5f7",textMuted:"#9ca3af",textDim:"#6b7280"},V={check:"✔",cross:"✖",warning:"⚠",arrow:"→",arrowRight:"▸",dot:"•",bar:"│",rule:"══",dash:"──"};function l$($){let Z=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec($.trim());if(!Z)return"255;255;255";return[Z[1],Z[2],Z[3]].map((J)=>Number.parseInt(J,16)).join(";")}var $$=String.fromCharCode(27),i$=`${$$}[0m`;function n$($=process.env,Z=process.argv,J=process.stdout.isTTY===!0){if("NO_COLOR"in $)return!1;if(Z.includes("--no-color"))return!1;return J}var a$={ok:"success",warn:"warning",fail:"error",info:"info"},s$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},m=16;function R($=n$()){let Z=(Q,q)=>$?`${$$}[${Q}m${q}${i$}`:q,J=(Q,q)=>Z(`38;2;${l$(d$[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(a$[Q],s$[Q])} ${q}`}}var O=R(!1);var r$=2;function o$($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function t$($,Z){if($==="")return[""];let J=[],Q="";for(let 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=o$()){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?t$(z,J-r$):[z];for(let H of W)Q.push(` ${H}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
|
|
2
|
-
`)}var
|
|
3
|
-
`);var
|
|
4
|
-
`}}import{join as
|
|
1
|
+
import{createRequire as F$}from"node:module";var l=F$(import.meta.url);import{existsSync as B,lstatSync as h8,readFileSync as S,readlinkSync as p8,realpathSync as K$,rmSync as c8,statSync as d8,unlinkSync as l8,writeFileSync as i8}from"node:fs";import{homedir as n8}from"node:os";import{basename as a8,dirname as M$,isAbsolute as O$,join as G,relative as s8,resolve as h}from"node:path";import{homedir as j}from"node:os";import{delimiter as MZ,dirname as OZ,join as T,resolve as CZ}from"node:path";function i(){return T(j(),".tlc","harness")}function C($=process.env){return $.TLC_HOME??i()}function D(){let $=process.env.CLAUDE_CONFIG_DIR?.trim();return $&&$.length>0?$:T(j(),".claude")}function P(){let $=process.env.CURSOR_CONFIG_DIR?.trim();return $&&$.length>0?$:T(j(),".cursor")}function b($=process.env){return $.TLC_BIN_DIR?.trim()||T(j(),".local","bin")}function n(){return["tlc","tlc.cmd"]}var s=[{id:"typescript",extensions:[".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".cjs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"python",extensions:[".py",".pyi",".pyw"],line:["#"],block:[['"""','"""'],["'''","'''"]],middle:[]},{id:"ruby",extensions:[".rb",".rake",".gemspec"],line:["#"],block:[["=begin","=end"]],middle:[]},{id:"shell",extensions:[".sh",".bash",".zsh",".ksh",".fish"],line:["#"],block:[],middle:[]},{id:"go",extensions:[".go"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"rust",extensions:[".rs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"java",extensions:[".java"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"kotlin",extensions:[".kt",".kts"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"swift",extensions:[".swift"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"c",extensions:[".c",".h"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"cpp",extensions:[".cc",".cpp",".cxx",".hpp",".hh",".hxx"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"csharp",extensions:[".cs"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"php",extensions:[".php"],line:["//","#"],block:[["/*","*/"]],middle:["*"]},{id:"scala",extensions:[".scala",".sc"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"dart",extensions:[".dart"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"elixir",extensions:[".ex",".exs"],line:["#"],block:[],middle:[]},{id:"erlang",extensions:[".erl",".hrl"],line:["%"],block:[],middle:[]},{id:"haskell",extensions:[".hs"],line:["--"],block:[["{-","-}"]],middle:[]},{id:"lua",extensions:[".lua"],line:["--"],block:[["--[[","]]"]],middle:[]},{id:"sql",extensions:[".sql"],line:["--"],block:[["/*","*/"]],middle:[]},{id:"css",extensions:[".css",".scss",".sass",".less"],line:["//"],block:[["/*","*/"]],middle:[]},{id:"yaml",extensions:[".yaml",".yml"],line:["#"],block:[],middle:[]},{id:"toml",extensions:[".toml"],line:["#"],block:[],middle:[]},{id:"ini",extensions:[".ini",".cfg",".conf",".properties"],line:[";","#"],block:[],middle:[]},{id:"dockerfile",extensions:[".dockerfile","dockerfile"],line:["#"],block:[],middle:[]},{id:"makefile",extensions:[".mk","makefile"],line:["#"],block:[],middle:[]},{id:"terraform",extensions:[".tf",".tfvars"],line:["#","//"],block:[["/*","*/"]],middle:["*"]},{id:"powershell",extensions:[".ps1",".psm1",".psd1"],line:["#"],block:[["<#","#>"]],middle:[]},{id:"perl",extensions:[".pl",".pm"],line:["#"],block:[],middle:[]},{id:"r",extensions:[".r"],line:["#"],block:[],middle:[]},{id:"julia",extensions:[".jl"],line:["#"],block:[["#=","=#"]],middle:[]},{id:"vue",extensions:[".vue",".svelte"],line:["//"],block:[["/*","*/"],["<!--","-->"]],middle:["*"]},{id:"html",extensions:[".html",".htm",".xml",".xhtml"],line:[],block:[["<!--","-->"]],middle:[]},{id:"graphql",extensions:[".graphql",".gql"],line:["#"],block:[],middle:[]},{id:"protobuf",extensions:[".proto"],line:["//"],block:[["/*","*/"]],middle:["*"]},{id:"zig",extensions:[".zig"],line:["//"],block:[],middle:[]},{id:"clojure",extensions:[".clj",".cljs",".cljc",".edn"],line:[";"],block:[],middle:[]},{id:"ocaml",extensions:[".ml",".mli"],line:[],block:[["(*","*)"]],middle:["*"]},{id:"fsharp",extensions:[".fs",".fsi",".fsx"],line:["//"],block:[["(*","*)"]],middle:["*"]},{id:"vim",extensions:[".vim"],line:['"'],block:[],middle:[]},{id:"tex",extensions:[".tex",".sty",".cls"],line:["%"],block:[],middle:[]}];function j$($){let Z=new Map;for(let J of $){let Q={line:J.line,block:J.block,middle:J.middle};for(let q of J.extensions)Z.set(q.toLowerCase(),Q)}return Z}var T$=j$(s);var D$=T$.size;var y=6;var x$=new Set(["aria2c","curl","fetch","http","httpie","https","wget"]);var F5=new Set([...x$,"nc","ncat","socat","telnet","lwp-request"]);var S$="ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql",L5=new RegExp(`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${S$}))(?=[:)\\s,'"\`]|$)`,"g");var R="1970-01-01T00:00:00.000Z";function M($){return{...$,scope:"gate-execution",source:"core",tier:"core",status:"active",confidence:1,hitCount:1,pinned:!1,refs:[],sessionKeys:[],injectedCount:0,gradeableCount:0,helpedCount:0,neutralCount:0,firstSeenAt:R,lastSeenAt:R,lastAccessedAt:R,updatedAt:R}}var u9=[M({id:"core:gate:lint",failedGate:"lint",category:"verification",triggerTokens:["lint","biome","eslint","ruff","format"],instruction:"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",avoid:"Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",prefer:"Apply the smallest fix that clears each finding, then let the stop hook re-check.",preRetryCheck:"Confirm the lint command targets only the intended changed files and still fails for the same codes.",priority:90}),M({id:"core:gate:test",failedGate:"test",category:"verification",triggerTokens:["test","vitest","jest","pytest","failing"],instruction:"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",avoid:"Do not delete failing tests, mark them skipped, or weaken assertions to force green.",prefer:"Reproduce the failure, fix root cause, re-run the same test target.",preRetryCheck:"Identify the failing test name/file from the gate output before editing.",priority:90}),M({id:"core:gate:comments",failedGate:"comments",category:"verification",triggerTokens:["junk comment","TODO","FIXME","banner"],instruction:"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",avoid:"Do not keep TODO markers or section banners 'for clarity'.",prefer:"Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",preRetryCheck:"Scan the listed file:line hits and remove each one.",priority:80}),M({id:"core:gate:ship",failedGate:"ship",category:"ship-evidence",triggerTokens:["ship","evidence","90-verdict","PASS"],instruction:"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",avoid:"Do not claim shipped based on unit tests alone when runtime paths changed.",prefer:"Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",preRetryCheck:"Confirm evidenceDir and a recent PASS verdict exist for this change.",priority:95}),M({id:"core:gate:empty-diff",failedGate:"empty-diff",category:"ship-evidence",triggerTokens:["empty","diff","no changes","shipped"],instruction:"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",avoid:"Do not restate 'done' without a real diff or an explicit zero-change justification.",prefer:"Make the missing change, or clearly document why no files should change.",preRetryCheck:"Inspect git status / changed files before the next stop.",priority:92}),M({id:"core:gate:stagnation",failedGate:"stagnation",category:"stagnation",triggerTokens:["stagnation","identical","fingerprint","same fail"],instruction:"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",avoid:"Do not retry the exact same patch, command, or suppression.",prefer:"Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",preRetryCheck:"Diff your last edit against the gate output; ensure the next action is different.",priority:100})];var i$={structure:"#3d3a4a",accent:"#a78bfa",success:"#6ee7b7",warning:"#d4a574",error:"#f87171",info:"#93c5fd",textMain:"#f5f5f7",textMuted:"#9ca3af",textDim:"#6b7280"},V={check:"✔",cross:"✖",warning:"⚠",arrow:"→",arrowRight:"▸",dot:"•",bar:"│",rule:"══",dash:"──"};function n$($){let Z=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec($.trim());if(!Z)return"255;255;255";return[Z[1],Z[2],Z[3]].map((J)=>Number.parseInt(J,16)).join(";")}var q$=String.fromCharCode(27),a$=`${q$}[0m`;function s$($=process.env,Z=process.argv,J=process.stdout.isTTY===!0){if("NO_COLOR"in $)return!1;if(Z.includes("--no-color"))return!1;return J}var r$={ok:"success",warn:"warning",fail:"error",info:"info"},o$={ok:V.check,warn:V.warning,fail:V.cross,info:V.arrowRight},m=16;function E($=s$()){let Z=(Q,q)=>$?`${q$}[${Q}m${q}${a$}`:q,J=(Q,q)=>Z(`38;2;${n$(i$[Q])}`,q);return{enabled:$,paint:J,bold:(Q)=>Z("1",Q),dim:(Q)=>J("textDim",Q),heading:(Q)=>J("accent",`${V.rule} ${Q} ${V.rule}`),footer:(Q)=>J("textDim",`${V.dash} ${Q} ${V.dash}`),kv:(Q,q,X=m)=>` ${J("textMuted",`${Q}:`.padEnd(X))} ${q}`,status:(Q,q)=>`${J(r$[Q],o$[Q])} ${q}`}}var O=E(!1);var t$=2;function e$($=process.stdout.columns){return Math.min(110,Math.max(60,$??100))}function $8($,Z){if($==="")return[""];let J=[],Q="";for(let q of $.split(/\s+/).filter((X)=>X.length>0)){if(Q===""){Q=q;continue}if(Q.length+1+q.length<=Z){Q=`${Q} ${q}`;continue}J.push(Q),Q=q}if(Q!=="")J.push(Q);return J.length===0?[""]:J}function F($,Z,J=e$()){let Q=[Z.heading($.title.toUpperCase())];if($.summary&&$.summary.length>0)Q.push(` ${$.summary.join(Z.dim(` ${V.bar} `))}`);let q=Math.max(m,...$.sections.flatMap((X)=>(X.rows??[]).map((z)=>z.label.length+1)));for(let X of $.sections){if(Q.push(""),X.title)Q.push(Z.paint("accent",X.title));for(let z of X.rows??[]){let W=z.level?Z.status(z.level,z.value):z.value;Q.push(Z.kv(z.label,W,q))}for(let z of X.lines??[]){if(z===""){Q.push("");continue}let W=X.wrap?$8(z,J-t$):[z];for(let U of W)Q.push(` ${U}`)}}if($.footer)Q.push("",Z.footer($.footer));return Q.join(`
|
|
2
|
+
`)}var H={enabled:!0,signalPath:"obs.jsonl",debugPath:"debug.jsonl",debugEnabled:!1,includePayloads:!1,maxAttrChars:500,sessionCostAlertUsd:5,retentionDays:14,maxSignalEvents:50000,globalSpool:!1};var Q8=["No harness decision in this window.","Whatever you just saw was the model, not a rail — the harness allowed everything it was asked about."].join(`
|
|
3
|
+
`);var J8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:y},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...J8}},mcpPrime:[],bootstrapExtra:[]};var N="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as f8,mkdirSync as _q,readFileSync as y8,realpathSync as Lq,writeFileSync as v8}from"node:fs";function z$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function m8($){return z$($)}var g8="tlc-exec.mjs";function u8($){return JSON.stringify($??null).includes(g8)}function g($){if($===null||$.trim()==="")return{ok:!0,settingsText:"",changed:!1};let Z;try{Z=JSON.parse($)}catch(z){return{ok:!1,error:z instanceof Error?z.message:String(z),block:""}}if(!z$(Z))return{ok:!1,error:"settings.json root is not a JSON object",block:""};let J=m8(Z.hooks)?Z.hooks:{},Q={},q=!1;for(let[z,W]of Object.entries(J)){let U=W.filter((_)=>!u8(_));if(U.length!==W.length)q=!0;if(U.length>0)Q[z]=U}let X={};for(let[z,W]of Object.entries(Z))if(z!=="hooks")X[z]=W;else if(Object.keys(Q).length>0)X.hooks=Q;return{ok:!0,settingsText:JSON.stringify(X,null,2),changed:q}}function W$($){if(!f8($))return{ok:!0,settingsText:"",changed:!1};let Z=g(y8($,"utf8"));if(Z.ok&&Z.changed)v8($,Z.settingsText,"utf8");return Z}function u($,Z="tlc-exec.mjs"){if($===null||$.trim()==="")return{kind:"absent"};let J;try{J=JSON.parse($)}catch{return{kind:"unparsed"}}if(J===null||typeof J!=="object"||Array.isArray(J))return{kind:"unparsed"};let Q=J,q=Q.hooks!==null&&typeof Q.hooks==="object"&&!Array.isArray(Q.hooks)?Q.hooks:{},X={},z=0,W=0;for(let[U,_]of Object.entries(q)){let d=Array.isArray(_)?_:[],L=d.filter((I$)=>!JSON.stringify(I$??null).includes(Z));if(z+=d.length-L.length,L.length>0)X[U]=L,W+=L.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:X},null,2)}
|
|
4
|
+
`}}import{join as Y$,relative as Eq,resolve as kq,sep as xq}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],gq=[Y$("tools","dev"),Y$("tools","__test__")];function r8($=process.env){let Z=n8(),J=b($);return{home:C($),binLinks:n().map((Q)=>G(J,Q)),claudeSettings:G(D(),"settings.json"),cursorHooks:G(P(),"hooks.json"),skillLinks:[G(D(),"skills","harness-init"),G(P(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function p($){try{return h8($).isSymbolicLink()}catch{return!1}}function c($){let Z=h($),J=[];for(;;)try{return G(K$(Z),...J)}catch{let Q=M$(Z);if(Q===Z)return h($);J.unshift(a8(Z)),Z=Q}}function x($){try{return K$($)}catch{try{let Z=p8($);return c(O$(Z)?Z:h(M$($),Z))}catch{return c($)}}}function o8($,Z,J={relative:s8,isAbsolute:O$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function B$($,Z){return o8(x($),c(Z))}var t8="tlc-exec.mjs";function e8($){try{return d8($).size<4096&&S($,"utf8").includes(t8)}catch{return!1}}function U$($,Z,J,Q,q){if(!B(Z)&&!p(Z))return;if(!p(Z)){if(e8(Z)){$.push({action:"remove",target:Z,detail:`${Q}, installed as a copy`});return}$.push({action:"keep",target:Z,detail:`${Q} is a real file the installer did not write`});return}if(q==="target"&&!B$(Z,J)){$.push({action:"keep",target:Z,detail:`points at ${x(Z)} — not ours`});return}let X=q==="location"&&!B$(Z,J);$.push({action:"unlink",target:Z,detail:X?`${Q}, stale — points at ${x(Z)}`:Q})}function $Z($,Z){if(!B(Z))return;let J=g(S(Z,"utf8"));if(!J.ok){$.push({action:"keep",target:Z,detail:`left untouched — it does not parse as JSON: ${J.error}`});return}if(!J.changed)return;$.push({action:"unmerge",target:Z,detail:"drop the harness hook groups, keep every other key and every foreign hook"})}function ZZ($,Z){let J=B(Z)?S(Z,"utf8"):null,Q=u(J);switch(Q.kind){case"absent":return;case"unparsed":$.push({action:"keep",target:Z,detail:"left untouched — it does not parse as JSON"});return;case"empty":if(Q.removed>0)$.push({action:"remove",target:Z,detail:`${Q.removed} entries, all ours`});return;default:$.push({action:"unmerge",target:Z,detail:`drop ${Q.removed} harness entries, keep the rest`})}}function QZ($,Z,J){if(p(Z))return $.push({action:"unlink",target:Z,detail:`a link to ${x(Z)} — the checkout it points at is left exactly as it is`}),!0;if(!B(Z))return!1;for(let X of V$){let z=G(Z,X);if(B(z))$.push({action:"remove",target:z,detail:"runtime payload"})}let q=G(Z,k);if(B(q))$.push({action:"remove",target:q,detail:"install marker"});for(let X of G$){let z=G(Z,X);if(!B(z))continue;$.push(J?{action:"remove",target:z,detail:"--purge"}:{action:"keep",target:z,detail:"yours — add --purge to remove it"})}return!1}function JZ($,Z){if(B(G(Z,k)))$.push({action:"manual",target:`npm uninstall -g ${N}`,detail:"the global package is reported, never removed for you"});$.push({action:"manual",target:"rm -rf .tlc/ in each repository",detail:"per-project config and state — this command does not search your disk for them"})}function qZ($,Z={}){let J=Z.purge===!0,Q=[];$Z(Q,$.claudeSettings),ZZ(Q,$.cursorHooks);for(let X of $.skillLinks)U$(Q,X,$.home,"skill link","location");for(let X of $.binLinks)U$(Q,X,$.home,"the tlc launcher on PATH","target");let q=QZ(Q,$.home,J);return JZ(Q,$.home),{items:Q,purge:J,homeIsLink:q}}function C$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function XZ($,Z){let J=[],Q=[];for(let q of C$($))try{if(q.action==="unmerge"&&q.target===Z.claudeSettings)W$(q.target);else if(q.action==="unmerge"){let X=u(S(q.target,"utf8"));if(X.kind==="rewritten")i8(q.target,X.text,"utf8")}else if(q.action==="unlink")l8(q.target);else c8(q.target,{recursive:!0,force:!0});J.push(q)}catch(X){Q.push({item:q,reason:X instanceof Error?X.message:String(X)})}return{applied:J,failed:Q}}var zZ={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function WZ($,Z){let J=C$($),Q=Z!==null;if(J.length===0)return{title:"harness uninstall",summary:["nothing wired"],sections:[{rows:[{label:"state",value:"no harness artefact found — nothing to undo",level:"ok"}]},{title:"STILL YOURS TO DO",rows:H$($)}],footer:`already clean · reinstall with npm i -g ${N}, then \`tlc harness install\``};let q=J.map((W)=>({label:W.action,value:`${W.target} — ${W.detail}`,level:zZ[W.action]})),X=$.items.filter((W)=>W.action==="keep"),z=[{title:Q?"REMOVED":"WOULD REMOVE",rows:q},...X.length>0?[{title:"KEPT",rows:X.map((W)=>({label:"keep",value:`${W.target} — ${W.detail}`,level:"ok"}))}]:[],{title:"STILL YOURS TO DO",rows:H$($)},...Z!==null&&Z.failed.length>0?[{title:"FAILED",rows:Z.failed.map((W)=>({label:"error",value:`${W.item.target} — ${W.reason}`,level:"fail"}))}]:[]];return{title:"harness uninstall",summary:[Q?`${Z.applied.length} applied`:`${J.length} pending`,$.purge?"purge: state included":"purge: state kept",$.homeIsLink?"runtime: linked checkout, unlinked only":"runtime: owned by the installer"],sections:z,footer:Q?`reinstall any time with npm i -g ${N}, then \`tlc harness install\``:"nothing was changed · re-run with --yes to apply this plan"}}function H$($){return $.items.filter((Z)=>Z.action==="manual").map((Z)=>({label:"run",value:`${Z.target} — ${Z.detail}`,level:"info"}))}function YZ($,Z,J=O){return F(WZ($,Z),J)}function VZ($=process.argv.slice(2)){let Z=r8(),J=qZ(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?XZ(J,Z):null;return console.log(YZ(J,Q,E())),Q!==null&&Q.failed.length>0?1:0}if(l.main==l.module)process.exitCode=VZ();export{r8 as uninstallTargets,WZ as uninstallScreen,YZ as uninstallReportText,qZ as planUninstall,C$ as pendingItems,VZ as main,o8 as isInsideRoot,c as canonicalise,XZ as applyUninstall};
|
package/docs/concepts.md
CHANGED
|
@@ -353,7 +353,7 @@ most common way a new rule reads as protection and is not:
|
|
|
353
353
|
| proof | what it names | how it matches |
|
|
354
354
|
|---|---|---|
|
|
355
355
|
| `subagent(<type>)` | the *declared type* of a spawn that finished, not the name the spawning agent gave it | exact string |
|
|
356
|
-
| `command(<pattern>)` | a shell command that ran and did not fail | the pattern's words, in order, as a contiguous run inside the command — `command(gh pr create)` matches `gh pr create --fill`, not `gh api pulls
|
|
356
|
+
| `command(<pattern>)` | a shell command that ran and did not fail | the pattern's words, in order, as a contiguous run inside the command — `command(gh pr create)` matches `gh pr create --fill`, not `gh api pulls`. A word with no `/` of its own also matches a path ending in it: `command(build.sh)` matches `./scripts/build.sh` and `/opt/tools/build.sh` alike, never a different file that merely ends the same way |
|
|
357
357
|
| `gate(<name>)` | one of this harness's own gates *passing* — never just running | exact string; the only names a gate ever reports are `lint`, `test`, `docs` |
|
|
358
358
|
| `file(<pattern>)` | a file edited by a write tool | exact path, **or** `*<suffix>` (a leading `*`, matched against the path's end), **or** `<dir>/` (a trailing `/`, matched as a prefix) — three shapes, not a glob engine |
|
|
359
359
|
|
package/docs/log.md
CHANGED
|
@@ -13,9 +13,14 @@ 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-01
|
|
17
|
+
|
|
18
|
+
- **AD-122** — Handoff continuity is scoped per session, and a live neighbour is never a source ([/decisions/ad-122.md](/decisions/ad-122.md))
|
|
19
|
+
|
|
16
20
|
## 2026-08-30
|
|
17
21
|
|
|
18
22
|
- **AD-120** — A rule denial names the directory and sha it actually checked ([/decisions/ad-120.md](/decisions/ad-120.md))
|
|
23
|
+
- **AD-121** — command(<pattern>) matches a bare word against a path ending in it, not only an exact word ([/decisions/ad-121.md](/decisions/ad-121.md))
|
|
19
24
|
|
|
20
25
|
## 2026-08-29
|
|
21
26
|
|
package/package.json
CHANGED
package/src/core/core.facade.ts
CHANGED
|
@@ -52,10 +52,14 @@ import { gapsFromArtifact } from "./gate/gate.service.ts";
|
|
|
52
52
|
import {
|
|
53
53
|
clearStuckSignals,
|
|
54
54
|
handoffInjectable,
|
|
55
|
+
handoffSessionPath,
|
|
56
|
+
listHandoffSessionFiles,
|
|
55
57
|
patchHandoff,
|
|
58
|
+
pruneDeadHandoffSessions,
|
|
56
59
|
readForeignSlices,
|
|
57
60
|
readHandoff,
|
|
58
61
|
readHandoffFile,
|
|
62
|
+
readLatestSlice,
|
|
59
63
|
} from "./handoff/handoff.service.ts";
|
|
60
64
|
import { authoredLessonId, buildAuthoredLesson } from "./lesson/lesson.authored.ts";
|
|
61
65
|
import type { LessonVerdict } from "./lesson/lesson.credit.ts";
|
|
@@ -306,11 +310,15 @@ export const coreFacade = {
|
|
|
306
310
|
},
|
|
307
311
|
handoff: {
|
|
308
312
|
handoffInjectable,
|
|
313
|
+
handoffSessionPath,
|
|
309
314
|
patchHandoff,
|
|
310
315
|
readHandoff,
|
|
311
316
|
readHandoffFile,
|
|
312
317
|
readForeignSlices,
|
|
318
|
+
readLatestSlice,
|
|
313
319
|
clearStuckSignals,
|
|
320
|
+
listHandoffSessionFiles,
|
|
321
|
+
pruneDeadHandoffSessions,
|
|
314
322
|
},
|
|
315
323
|
shim: {
|
|
316
324
|
coversHandler,
|
|
@@ -12,6 +12,7 @@ import { hostname } from "node:os";
|
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { nextDelay } from "../../platform/backoff.ts";
|
|
14
14
|
import { projectStateDir } from "../../platform/paths.ts";
|
|
15
|
+
import { isProcessAlive, type ProcessProbe } from "../../platform/process.ts";
|
|
15
16
|
import type { LockBody } from "./gate.types.ts";
|
|
16
17
|
|
|
17
18
|
export const GATE_LOCK_WAIT_MS = 120_000;
|
|
@@ -87,42 +88,17 @@ export function isLockUnreadable(path: string, args: { now: number; graceMs: num
|
|
|
87
88
|
/**
|
|
88
89
|
* Whether the recorded process is provably gone.
|
|
89
90
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* why: `process.kill(pid, 0)` sends no signal, it only asks whether the process exists. `EPERM` means it
|
|
95
|
-
* exists and belongs to another user, which is alive for our purposes. Anything unexpected is treated as
|
|
96
|
-
* alive, because the age rule is the safe fallback and reclaiming a live holder is the expensive mistake.
|
|
91
|
+
* why: `isProcessAlive` answers the OS-level liveness question generically
|
|
92
|
+
* ([/decisions/ad-122.md](/decisions/ad-122.md)); this file's own contribution is only "does the body even
|
|
93
|
+
* carry a usable pid/host to ask about" — a lock with no usable body has nothing to prove gone.
|
|
97
94
|
*/
|
|
98
|
-
|
|
99
|
-
export type ProcessProbe = (pid: number) => void;
|
|
100
|
-
|
|
101
|
-
const probeProcess: ProcessProbe = (pid) => {
|
|
102
|
-
process.kill(pid, 0);
|
|
103
|
-
};
|
|
95
|
+
export type { ProcessProbe };
|
|
104
96
|
|
|
105
|
-
export function isLockOwnerGone(
|
|
106
|
-
body
|
|
107
|
-
thisHost: string = hostname(),
|
|
108
|
-
probe: ProcessProbe = probeProcess,
|
|
109
|
-
): boolean {
|
|
110
|
-
if (!isUsableLockBody(body)) {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
const { host, pid } = body;
|
|
114
|
-
if (typeof host !== "string" || host !== thisHost) {
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
probe(pid);
|
|
97
|
+
export function isLockOwnerGone(body: unknown, thisHost: string = hostname(), probe?: ProcessProbe): boolean {
|
|
98
|
+
if (!isUsableLockBody(body) || body.host === undefined) {
|
|
122
99
|
return false;
|
|
123
|
-
} catch (error) {
|
|
124
|
-
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
125
100
|
}
|
|
101
|
+
return !isProcessAlive(body.pid, body.host, thisHost, probe);
|
|
126
102
|
}
|
|
127
103
|
|
|
128
104
|
export function isLockReclaimable(
|
|
@@ -1,28 +1,97 @@
|
|
|
1
1
|
import { divergedMessage, shouldInject, verifySeal } from "../integrity/state-seal.ts";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
findDeadPredecessor,
|
|
4
|
+
handoffSessionPath,
|
|
5
|
+
latestSessionForProvider,
|
|
6
|
+
listHandoffSessionFiles,
|
|
7
|
+
patchHandoffSession,
|
|
8
|
+
pruneDeadHandoffSessions,
|
|
9
|
+
readHandoffSessionFile,
|
|
10
|
+
} from "./handoff.session-store.ts";
|
|
11
|
+
import { handoffPath, patchHandoffShared, readHandoffFile } from "./handoff.store.ts";
|
|
12
|
+
import type {
|
|
13
|
+
ForeignSlice,
|
|
14
|
+
HandoffProviderSlice,
|
|
15
|
+
HandoffSessionFile,
|
|
16
|
+
HandoffShared,
|
|
17
|
+
} from "./handoff.types.ts";
|
|
4
18
|
|
|
5
19
|
export type ResolvedHandoff = HandoffShared & HandoffProviderSlice;
|
|
6
20
|
|
|
7
21
|
/**
|
|
8
22
|
* Whether the handoff is safe to read aloud to the model.
|
|
9
23
|
*
|
|
10
|
-
* why:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
24
|
+
* why: the shared facts and this session's own continuity are two separately-sealed writes now
|
|
25
|
+
* ([/decisions/ad-122.md](/decisions/ad-122.md)) — either one changing outside a harness write withholds the
|
|
26
|
+
* whole handoff from this turn, the same as before the split.
|
|
13
27
|
*/
|
|
14
|
-
export function handoffInjectable(root: string): { ok: boolean; note: string | null } {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
export function handoffInjectable(root: string, sessionKey: string): { ok: boolean; note: string | null } {
|
|
29
|
+
const sharedVerdict = verifySeal(handoffPath(root));
|
|
30
|
+
if (!shouldInject(sharedVerdict)) {
|
|
31
|
+
return { ok: false, note: divergedMessage(handoffPath(root), "The handoff") };
|
|
32
|
+
}
|
|
33
|
+
const sessionPath = handoffSessionPath(root, sessionKey);
|
|
34
|
+
const sessionVerdict = verifySeal(sessionPath);
|
|
35
|
+
return shouldInject(sessionVerdict)
|
|
18
36
|
? { ok: true, note: null }
|
|
19
|
-
: { ok: false, note: divergedMessage(
|
|
37
|
+
: { ok: false, note: divergedMessage(sessionPath, "The handoff") };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* why: a file this turn merges in belongs to a *different* session, so nothing at the call site already
|
|
42
|
+
* checked its seal the way `handoffInjectable` checks the caller's own — an untampered predecessor is the one
|
|
43
|
+
* this function is allowed to promise ([/decisions/ad-078.md](/decisions/ad-078.md), read alongside AD-122).
|
|
44
|
+
*/
|
|
45
|
+
function sealedSlice(path: string, file: HandoffSessionFile | null): HandoffSessionFile | null {
|
|
46
|
+
return file && shouldInject(verifySeal(path)) ? file : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* This session's own resolved continuity: shared facts plus this session's own slice
|
|
51
|
+
* ([/decisions/ad-122.md](/decisions/ad-122.md)). A dead predecessor's fields are copied into this session's
|
|
52
|
+
* own file once, at its first write (`patchHandoffSession`'s own materialization) — once that file exists, it
|
|
53
|
+
* is the sole source, so a field this session later clears never resurfaces from the predecessor. Before this
|
|
54
|
+
* session has written anything at all, the same not-live, seal-verified predecessor is read directly instead,
|
|
55
|
+
* for a read that happens ahead of any write.
|
|
56
|
+
*/
|
|
57
|
+
export function readHandoff(root: string, provider: string, sessionKey: string): ResolvedHandoff {
|
|
58
|
+
const shared = readHandoffFile(root).shared;
|
|
59
|
+
const own = readHandoffSessionFile(root, sessionKey);
|
|
60
|
+
if (own) {
|
|
61
|
+
return { ...shared, ...own.slice };
|
|
62
|
+
}
|
|
63
|
+
const predecessor = findDeadPredecessor(root, provider, sessionKey);
|
|
64
|
+
const verified =
|
|
65
|
+
predecessor && sealedSlice(handoffSessionPath(root, predecessor.owner.session_key), predecessor);
|
|
66
|
+
return { ...shared, ...verified?.slice };
|
|
20
67
|
}
|
|
21
68
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
69
|
+
/** The operator's diagnostic view of one provider: whichever session most recently wrote it, live or not —
|
|
70
|
+
* reading here is not the injection surface AD-078 protects, so it is not liveness-gated. */
|
|
71
|
+
export function readLatestSlice(root: string, provider: string): ResolvedHandoff {
|
|
72
|
+
const shared = readHandoffFile(root).shared;
|
|
73
|
+
const latest = latestSessionForProvider(root, provider);
|
|
74
|
+
return { ...shared, ...latest?.slice };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type HandoffPatch = {
|
|
78
|
+
shared?: Partial<HandoffShared>;
|
|
79
|
+
slice?: Partial<HandoffProviderSlice>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export async function patchHandoff(
|
|
83
|
+
root: string,
|
|
84
|
+
provider: string,
|
|
85
|
+
sessionKey: string,
|
|
86
|
+
patch: HandoffPatch,
|
|
87
|
+
): Promise<ResolvedHandoff> {
|
|
88
|
+
if (patch.shared) {
|
|
89
|
+
await patchHandoffShared(root, patch.shared);
|
|
90
|
+
}
|
|
91
|
+
if (patch.slice) {
|
|
92
|
+
await patchHandoffSession(root, provider, sessionKey, patch.slice);
|
|
93
|
+
}
|
|
94
|
+
return readHandoff(root, provider, sessionKey);
|
|
26
95
|
}
|
|
27
96
|
|
|
28
97
|
const CLEARED_SLICE: Partial<HandoffProviderSlice> = {
|
|
@@ -39,35 +108,49 @@ function hasStuckSignal(slice: HandoffProviderSlice): boolean {
|
|
|
39
108
|
}
|
|
40
109
|
|
|
41
110
|
/**
|
|
42
|
-
* why:
|
|
43
|
-
*
|
|
111
|
+
* why: the operator's escape hatch runs from their own terminal, never from inside an agent session
|
|
112
|
+
* (`policy-surface-write` refuses it there) — by the time it runs, "which sessions are still live" is not the
|
|
113
|
+
* question; a stuck signal left behind by any of them, dead or not, is what it clears.
|
|
44
114
|
*/
|
|
45
115
|
export async function clearStuckSignals(root: string): Promise<string[]> {
|
|
46
|
-
const file = readHandoffFile(root);
|
|
47
116
|
const cleared: string[] = [];
|
|
48
|
-
for (const
|
|
49
|
-
if (!hasStuckSignal(slice)) {
|
|
117
|
+
for (const file of listHandoffSessionFiles(root)) {
|
|
118
|
+
if (!hasStuckSignal(file.slice)) {
|
|
50
119
|
continue;
|
|
51
120
|
}
|
|
52
|
-
await
|
|
53
|
-
cleared.push(provider);
|
|
121
|
+
await patchHandoffSession(root, file.owner.provider, file.owner.session_key, CLEARED_SLICE);
|
|
122
|
+
cleared.push(`${file.owner.provider}:${file.owner.session_key}`);
|
|
54
123
|
}
|
|
55
124
|
return cleared;
|
|
56
125
|
}
|
|
57
126
|
|
|
58
127
|
export function readForeignSlices(root: string, provider: string): ForeignSlice[] {
|
|
59
|
-
const
|
|
128
|
+
const byProvider = new Map<string, HandoffSessionFile>();
|
|
129
|
+
for (const file of listHandoffSessionFiles(root)) {
|
|
130
|
+
if (file.owner.provider === provider) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const current = byProvider.get(file.owner.provider);
|
|
134
|
+
if (!current || file.owner.updated_at > current.owner.updated_at) {
|
|
135
|
+
byProvider.set(file.owner.provider, file);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
60
138
|
const foreign: ForeignSlice[] = [];
|
|
61
|
-
for (const [name,
|
|
62
|
-
|
|
139
|
+
for (const [name, latest] of byProvider) {
|
|
140
|
+
const verified = sealedSlice(handoffSessionPath(root, latest.owner.session_key), latest);
|
|
141
|
+
if (!verified) {
|
|
63
142
|
continue;
|
|
64
143
|
}
|
|
65
|
-
if (slice.next_action === undefined && slice.blockers === undefined) {
|
|
144
|
+
if (verified.slice.next_action === undefined && verified.slice.blockers === undefined) {
|
|
66
145
|
continue;
|
|
67
146
|
}
|
|
68
|
-
foreign.push({
|
|
147
|
+
foreign.push({
|
|
148
|
+
provider: name,
|
|
149
|
+
next_action: verified.slice.next_action,
|
|
150
|
+
blockers: verified.slice.blockers,
|
|
151
|
+
});
|
|
69
152
|
}
|
|
70
153
|
return foreign;
|
|
71
154
|
}
|
|
72
155
|
|
|
73
|
-
export {
|
|
156
|
+
export { handoffSessionPath, listHandoffSessionFiles, pruneDeadHandoffSessions, readHandoffFile };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { updateJsonAtomic } from "../../platform/fs-atomic.ts";
|
|
4
|
+
import { handoffSessionsDir } from "../../platform/paths.ts";
|
|
5
|
+
import { sanitizeSegment } from "../../platform/sanitize.ts";
|
|
6
|
+
import { seal, sealPath, shouldInject, verifySeal } from "../integrity/state-seal.ts";
|
|
7
|
+
import { isSessionLive } from "../presence/presence.service.ts";
|
|
8
|
+
import {
|
|
9
|
+
HANDOFF_SESSION_SCHEMA,
|
|
10
|
+
type HandoffProviderSlice,
|
|
11
|
+
type HandoffSessionFile,
|
|
12
|
+
isHandoffSessionFile,
|
|
13
|
+
} from "./handoff.types.ts";
|
|
14
|
+
|
|
15
|
+
/** why keyed by session alone: `sessionKey` is already unique per provider (every inbound mapper prefixes it
|
|
16
|
+
* with the provider's own name), so a second key would only restate what the name already carries. */
|
|
17
|
+
export function handoffSessionPath(root: string, sessionKey: string): string {
|
|
18
|
+
return join(handoffSessionsDir(root), `${sanitizeSegment(sessionKey)}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function handoffSessionLockPath(root: string, sessionKey: string): string {
|
|
22
|
+
return `${handoffSessionPath(root, sessionKey)}.lock`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readHandoffSessionFile(root: string, sessionKey: string): HandoffSessionFile | null {
|
|
26
|
+
const path = handoffSessionPath(root, sessionKey);
|
|
27
|
+
if (!existsSync(path)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
return isHandoffSessionFile(parsed) ? parsed : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EMPTY_SLICE: HandoffProviderSlice = { updated_at: "" };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* why: clearing a field is omitting its key (JSON drops `undefined`), indistinguishable from never written —
|
|
42
|
+
* re-merging a predecessor on every read cannot tell those apart, so a clear kept resurfacing. Copying it in
|
|
43
|
+
* once, at this session's own first write, makes this session's file the sole source from then on
|
|
44
|
+
* ([/decisions/ad-122.md](/decisions/ad-122.md)).
|
|
45
|
+
*/
|
|
46
|
+
function inheritedBase(
|
|
47
|
+
root: string,
|
|
48
|
+
provider: string,
|
|
49
|
+
sessionKey: string,
|
|
50
|
+
isLive: LivenessCheck,
|
|
51
|
+
): HandoffProviderSlice {
|
|
52
|
+
const predecessor = findDeadPredecessor(root, provider, sessionKey, { isLive });
|
|
53
|
+
if (!predecessor) {
|
|
54
|
+
return EMPTY_SLICE;
|
|
55
|
+
}
|
|
56
|
+
const path = handoffSessionPath(root, predecessor.owner.session_key);
|
|
57
|
+
return shouldInject(verifySeal(path)) ? predecessor.slice : EMPTY_SLICE;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** why: the owner is restamped on every write because this session wrote just now, which is the fact worth
|
|
61
|
+
* keeping — liveness itself is decided elsewhere, against presence's own heartbeat
|
|
62
|
+
* ([/decisions/ad-122.md](/decisions/ad-122.md)). */
|
|
63
|
+
export function patchHandoffSession(
|
|
64
|
+
root: string,
|
|
65
|
+
provider: string,
|
|
66
|
+
sessionKey: string,
|
|
67
|
+
patch: Partial<HandoffProviderSlice>,
|
|
68
|
+
options: PredecessorOptions = {},
|
|
69
|
+
): Promise<HandoffSessionFile> {
|
|
70
|
+
const isLive = options.isLive ?? defaultLiveness(root);
|
|
71
|
+
const path = handoffSessionPath(root, sessionKey);
|
|
72
|
+
return updateJsonAtomic<HandoffSessionFile>(
|
|
73
|
+
path,
|
|
74
|
+
(current) => {
|
|
75
|
+
const base =
|
|
76
|
+
current && isHandoffSessionFile(current)
|
|
77
|
+
? current.slice
|
|
78
|
+
: inheritedBase(root, provider, sessionKey, isLive);
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
return {
|
|
81
|
+
schema: HANDOFF_SESSION_SCHEMA,
|
|
82
|
+
owner: { session_key: sessionKey, provider, updated_at: now },
|
|
83
|
+
slice: { ...base, ...patch, updated_at: now },
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
{ lockPath: handoffSessionLockPath(root, sessionKey), afterWrite: seal },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readAllSessionFiles(root: string): Array<{ path: string; file: HandoffSessionFile }> {
|
|
91
|
+
const dir = handoffSessionsDir(root);
|
|
92
|
+
if (!existsSync(dir)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const out: Array<{ path: string; file: HandoffSessionFile }> = [];
|
|
96
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
97
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const path = join(dir, entry.name);
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
103
|
+
if (isHandoffSessionFile(parsed)) {
|
|
104
|
+
out.push({ path, file: parsed });
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every known session's file, across every provider — the operator's unfiltered diagnostic view. */
|
|
112
|
+
export function listHandoffSessionFiles(root: string): HandoffSessionFile[] {
|
|
113
|
+
return readAllSessionFiles(root).map((entry) => entry.file);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mostRecent(files: readonly HandoffSessionFile[]): HandoffSessionFile | null {
|
|
117
|
+
if (files.length === 0) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return files.reduce((latest, file) => (file.owner.updated_at > latest.owner.updated_at ? file : latest));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type LivenessCheck = (provider: string, sessionKey: string) => boolean;
|
|
124
|
+
|
|
125
|
+
function defaultLiveness(root: string): LivenessCheck {
|
|
126
|
+
return (provider, sessionKey) => isSessionLive(root, provider, sessionKey);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type PredecessorOptions = {
|
|
130
|
+
isLive?: LivenessCheck;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* why: the most-recently-updated *other* session of this provider whose conversation is confirmed not live is
|
|
135
|
+
* a legitimate handoff. A live neighbour, however recent, is never a candidate — that is the one property this
|
|
136
|
+
* whole file exists to guarantee ([/decisions/ad-122.md](/decisions/ad-122.md)).
|
|
137
|
+
*/
|
|
138
|
+
export function findDeadPredecessor(
|
|
139
|
+
root: string,
|
|
140
|
+
provider: string,
|
|
141
|
+
sessionKey: string,
|
|
142
|
+
options: PredecessorOptions = {},
|
|
143
|
+
): HandoffSessionFile | null {
|
|
144
|
+
const isLive = options.isLive ?? defaultLiveness(root);
|
|
145
|
+
const candidates = readAllSessionFiles(root)
|
|
146
|
+
.map((entry) => entry.file)
|
|
147
|
+
.filter((file) => file.owner.provider === provider && file.owner.session_key !== sessionKey)
|
|
148
|
+
.filter((file) => !isLive(file.owner.provider, file.owner.session_key));
|
|
149
|
+
return mostRecent(candidates);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The most-recently-updated session file for a *given* provider, regardless of liveness — used both for the
|
|
153
|
+
* requesting provider's own foreign-slice lookups and for the operator's diagnostic view
|
|
154
|
+
* ([/decisions/ad-039.md](/decisions/ad-039.md) precedent: informational, not continuity). */
|
|
155
|
+
export function latestSessionForProvider(root: string, provider: string): HandoffSessionFile | null {
|
|
156
|
+
const candidates = readAllSessionFiles(root)
|
|
157
|
+
.map((entry) => entry.file)
|
|
158
|
+
.filter((file) => file.owner.provider === provider);
|
|
159
|
+
return mostRecent(candidates);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type PruneOptions = {
|
|
163
|
+
now?: number;
|
|
164
|
+
staleMs?: number;
|
|
165
|
+
isLive?: LivenessCheck;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const HANDOFF_SESSION_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* why: a not-live session's file is exactly what the *next* session inherits from, so deleting it the moment
|
|
172
|
+
* its conversation goes quiet would remove the handoff before anyone reads it — and never deleting it would
|
|
173
|
+
* grow the directory forever. Seven days is long enough for a same-week restart to still inherit, and bounded
|
|
174
|
+
* enough that it does not accumulate without limit. The seal sidecar is removed alongside the session file.
|
|
175
|
+
*/
|
|
176
|
+
export function pruneDeadHandoffSessions(root: string, options: PruneOptions = {}): number {
|
|
177
|
+
const now = options.now ?? Date.now();
|
|
178
|
+
const staleMs = options.staleMs ?? HANDOFF_SESSION_STALE_MS;
|
|
179
|
+
const isLive = options.isLive ?? defaultLiveness(root);
|
|
180
|
+
let pruned = 0;
|
|
181
|
+
for (const entry of readAllSessionFiles(root)) {
|
|
182
|
+
const age = now - Date.parse(entry.file.owner.updated_at);
|
|
183
|
+
if (Number.isNaN(age) || age < staleMs) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (isLive(entry.file.owner.provider, entry.file.owner.session_key)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
unlinkSync(entry.path);
|
|
191
|
+
pruned += 1;
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
unlinkSync(sealPath(entry.path));
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
return pruned;
|
|
200
|
+
}
|