@tech-leads-club/harness-toolkit 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compact-before.mjs +77 -77
- package/dist/doctor.mjs +83 -83
- package/dist/init-project.mjs +80 -80
- package/dist/install-runtime.mjs +83 -83
- package/dist/lessons-cli.mjs +78 -78
- package/dist/obs-cli.mjs +79 -79
- package/dist/prompt-submit.mjs +78 -78
- package/dist/refresh-model-prices.mjs +77 -77
- package/dist/response-after.mjs +77 -77
- package/dist/run.mjs +77 -77
- package/dist/session-end.mjs +82 -82
- package/dist/session-start.mjs +84 -84
- package/dist/shim.mjs +80 -80
- package/dist/ship-gate.mjs +79 -79
- package/dist/stop.mjs +82 -82
- package/dist/subagent-start.mjs +77 -77
- package/dist/subagent-stop.mjs +78 -78
- package/dist/support.mjs +82 -82
- package/dist/tlc-cli.mjs +101 -101
- package/dist/tool-after.mjs +77 -77
- package/dist/tool-before.mjs +83 -83
- package/dist/tool-failure.mjs +77 -77
- package/dist/uninstall-runtime.mjs +2 -2
- package/docs/concepts.md +9 -5
- package/docs/log.md +2 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +4 -1
- package/src/core/observability/observability.service.ts +1 -1
- package/src/core/rules/rules.trigger.ts +89 -5
- package/src/core/turn/turn.activity.ts +14 -4
- package/src/entrypoints/run.ts +39 -2
- package/src/entrypoints/tool-before.ts +10 -2
- package/src/providers/cursor/cursor.inbound.ts +22 -1
- package/tools/obs-cli.ts +12 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
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
2
|
`)}var H={enabled:!0,signalPath:"obs.jsonl",debugPath:"debug.jsonl",debugEnabled:!1,includePayloads:!1,maxAttrChars:500,sessionCostAlertUsd:5,retentionDays:14,maxSignalEvents:50000,globalSpool:!1};var Q8=["No harness decision in this window.","Whatever you just saw was the model, not a rail — the harness allowed everything it was asked about."].join(`
|
|
3
|
-
`);var J8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:y},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},secrets:{redactOutput:!0},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...J8}},mcpPrime:[],bootstrapExtra:[]};var
|
|
4
|
-
`}}import{join as Y$,relative as
|
|
3
|
+
`);var J8={enabled:!1,maxInjectSession:5,maxInjectRetry:8,maxCharsSession:900,maxCharsRetry:1400,promoteHitCount:2,decayLambda:0.02,projectBoost:1.5,syncRulesFile:"auto",gardenOnSessionEnd:!0},X$={version:1,mode:"solo",codePaths:["src","apps","libs","packages"],grind:{enabled:!1,maxLoops:5,lintCommand:null,testCommand:null,appendFiles:"auto"},shipGate:{enabled:!1,runtimePathPrefixes:["src","apps","libs","packages","deploy","scripts"],runtimePathExcludes:[".tlc/","**/node_modules/","**/.git/"],evidenceDir:null,evidenceMaxAgeHours:48,emptyDiffAntiShip:!1,claimWindowMinutes:10},subagents:{enforceAllowlist:!1,requireModel:!1,allowedModels:[],blockedPatterns:["-fast(?:$|[^a-z0-9])","/fast(?:$|[^a-z0-9])"],minEffort:null,blockParentFast:!1,blockMode:"deny",readOnlyTypes:["explore"]},docs:{command:null,severity:"warn"},observe:{enabled:!1,rails:[]},comments:{enabled:!1,onViolation:"followup",mode:"declared"},supplyChain:{enabled:!1},duplication:{enabled:!1,minRun:y},obs:{globalSpool:!1,includePayloads:H.includePayloads,maxAttrChars:H.maxAttrChars,sessionCostAlertUsd:H.sessionCostAlertUsd,retentionDays:H.retentionDays},untrustedContent:{mode:"frame",enabled:!1,extraTools:[],extraCommandPatterns:[]},planGate:{enabled:!1,windowMinutes:120},rules:{enabled:!1},shell:{catastrophicAsk:!0,stallDetection:!1,stallRepeatThreshold:3},secrets:{redactOutput:!0},intelligence:{gapFeedback:!0,failureClassification:!0,progressiveHandoff:!0,progressiveContext:!0,autopilot:!0,idleTurnGate:!1,budgetContinue:!1,budgetContinueAfterLoops:3,lessons:{...J8}},mcpPrime:[],bootstrapExtra:[]};var _="@tech-leads-club/harness-toolkit",k="installed-from-npm";import{existsSync as y8,mkdirSync as hX,readFileSync as v8,realpathSync as pX,writeFileSync as m8}from"node:fs";function z$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function g8($){return z$($)}var u8="tlc-exec.mjs";function h8($){return JSON.stringify($??null).includes(u8)}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=g8(Z.hooks)?Z.hooks:{},Q={},q=!1;for(let[z,W]of Object.entries(J)){let U=W.filter((N)=>!h8(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(!y8($))return{ok:!0,settingsText:"",changed:!1};let Z=g(v8($,"utf8"));if(Z.ok&&Z.changed)m8($,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,N]of Object.entries(q)){let d=Array.isArray(N)?N:[],j=d.filter((F$)=>!JSON.stringify(F$??null).includes(Z));if(z+=d.length-j.length,j.length>0)X[U]=j,W+=j.length}if(W===0)return{kind:"empty",removed:z};return{kind:"rewritten",removed:z,text:`${JSON.stringify({...Q,hooks:X},null,2)}
|
|
4
|
+
`}}import{join as Y$,relative as sX,resolve as rX,sep as oX}from"node:path";var V$=["bin","capabilities","dist","docs","skills","src","tools","config.example.json","package.json"],G$=["config.json","state","flags","rules"],X4=[Y$("tools","dev"),Y$("tools","__test__")];function o8($=process.env){let Z=a8(),J=b($);return{home:O($),binLinks:n().map((Q)=>G(J,Q)),claudeSettings:G(T(),"settings.json"),cursorHooks:G(A(),"hooks.json"),skillLinks:[G(T(),"skills","harness-init"),G(A(),"skills","harness-init"),G(Z,".tlc","skills","harness-init")]}}function p($){try{return p8($).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(s8(Z)),Z=Q}}function x($){try{return K$($)}catch{try{let Z=c8($);return c(C$(Z)?Z:h(M$($),Z))}catch{return c($)}}}function t8($,Z,J={relative:r8,isAbsolute:C$}){if($===Z)return!0;let Q=J.relative(Z,$);return Q!==""&&!Q.startsWith("..")&&!J.isAbsolute(Q)}function B$($,Z){return t8(x($),c(Z))}var e8="tlc-exec.mjs";function $Z($){try{return l8($).size<4096&&S($,"utf8").includes(e8)}catch{return!1}}function U$($,Z,J,Q,q){if(!B(Z)&&!p(Z))return;if(!p(Z)){if($Z(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 ZZ($,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 QZ($,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 JZ($,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 qZ($,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 XZ($,Z={}){let J=Z.purge===!0,Q=[];ZZ(Q,$.claudeSettings),QZ(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=JZ(Q,$.home,J);return qZ(Q,$.home),{items:Q,purge:J,homeIsLink:q}}function O$($){return $.items.filter((Z)=>Z.action==="unmerge"||Z.action==="unlink"||Z.action==="remove")}function zZ($,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=u(S(q.target,"utf8"));if(X.kind==="rewritten")n8(q.target,X.text,"utf8")}else if(q.action==="unlink")i8(q.target);else d8(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 WZ={unmerge:"warn",unlink:"warn",remove:"warn",keep:"ok",manual:"info"};function YZ($,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:WZ[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 VZ($,Z,J=C){return I(YZ($,Z),J)}function GZ($=process.argv.slice(2)){let Z=o8(),J=XZ(Z,{purge:$.includes("--purge")}),Q=$.includes("--yes")?zZ(J,Z):null;return console.log(VZ(J,Q,E())),Q!==null&&Q.failed.length>0?1:0}if(l.main==l.module)process.exitCode=GZ();export{o8 as uninstallTargets,YZ as uninstallScreen,VZ as uninstallReportText,XZ as planUninstall,O$ as pendingItems,GZ as main,t8 as isInsideRoot,c as canonicalise,zZ as applyUninstall};
|
package/docs/concepts.md
CHANGED
|
@@ -411,11 +411,15 @@ equivalent (`POST` to a path ending in `pulls`; `POST`/`PATCH` to a path touchin
|
|
|
411
411
|
shape, and `pr-merge` recognizes `gh pr merge` and `PUT` to a path ending in `merge` under `pulls`
|
|
412
412
|
([/decisions/ad-128.md](/decisions/ad-128.md)) — scoped to the local checkout's own remote, so a `gh api` push
|
|
413
413
|
to an unrelated repository (a template repo, a sibling project) is never gated by this project's own local
|
|
414
|
-
state ([/decisions/ad-130.md](/decisions/ad-130.md))
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
414
|
+
state ([/decisions/ad-130.md](/decisions/ad-130.md)). `pr-open` also recognizes the GitHub MCP server's
|
|
415
|
+
`create_pull_request` tool call, on both `mcp__<server>__create_pull_request` (Claude Code, any server name)
|
|
416
|
+
and the bare or host-prefixed form Cursor sends, with the same repository scoping
|
|
417
|
+
([/decisions/ad-135.md](/decisions/ad-135.md)) — but that is the one evidenced MCP tool name, not MCP as a
|
|
418
|
+
class: a different MCP server or tool name performing the same act is not covered. A script written to disk
|
|
419
|
+
and executed later, a command name built at runtime, a GraphQL mutation, `curl` direct to the API, a `gh api`
|
|
420
|
+
call that flips a pull request to ready without going through `gh pr ready`, or a pull request opened,
|
|
421
|
+
approved, or merged in a browser all still escape every trigger this project has
|
|
422
|
+
([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
419
423
|
|
|
420
424
|
**The durable, non-bypassable boundary is GitHub's own branch protection, not this mechanism.** A required
|
|
421
425
|
status check (posted by whatever proof this project's rule already demands — the-jury's review, a gate
|
package/docs/log.md
CHANGED
|
@@ -20,6 +20,8 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
|
|
|
20
20
|
- **AD-132** — Git-root discovery replaces an exact-match guard, in the six functions and their consumers ([/decisions/ad-132.md](/decisions/ad-132.md))
|
|
21
21
|
- **AD-133** — runCommand stops truncating; the one caller that needs a bound already had one ([/decisions/ad-133.md](/decisions/ad-133.md))
|
|
22
22
|
- **AD-134** — runCommand moves to process.ts; listTrackedFiles never reaches for it again ([/decisions/ad-134.md](/decisions/ad-134.md))
|
|
23
|
+
- **AD-135** — pr-open recognizes its MCP surface ([/decisions/ad-135.md](/decisions/ad-135.md))
|
|
24
|
+
- **AD-136** — hook.enter makes a hook invocation observable before any decision is made ([/decisions/ad-136.md](/decisions/ad-136.md))
|
|
23
25
|
|
|
24
26
|
## 2026-09-09
|
|
25
27
|
|
package/package.json
CHANGED
package/src/core/core.facade.ts
CHANGED
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
} from "./observability/observability.report.ts";
|
|
100
100
|
import {
|
|
101
101
|
DEFAULT_OBS,
|
|
102
|
+
deriveTraceId,
|
|
102
103
|
recordAudit,
|
|
103
104
|
recordFromEvent,
|
|
104
105
|
recordObs,
|
|
@@ -179,7 +180,7 @@ import {
|
|
|
179
180
|
readRuleSources,
|
|
180
181
|
recordObservation,
|
|
181
182
|
} from "./rules/rules.store.ts";
|
|
182
|
-
import { firingRules, mentionsGhApi, triggerMatches } from "./rules/rules.trigger.ts";
|
|
183
|
+
import { firingRules, mentionsGhApi, mentionsMcpAct, triggerMatches } from "./rules/rules.trigger.ts";
|
|
183
184
|
import { scanForSecrets } from "./secret-scan/secret-scan.service.ts";
|
|
184
185
|
import { placeholderFor } from "./secret-scan/secret-scan.store.ts";
|
|
185
186
|
import { evaluateShellCommand } from "./shell-policy/shell-policy.service.ts";
|
|
@@ -380,6 +381,7 @@ export const coreFacade = {
|
|
|
380
381
|
observability: {
|
|
381
382
|
DEFAULT_OBS,
|
|
382
383
|
resolveObsLevel,
|
|
384
|
+
deriveTraceId,
|
|
383
385
|
recordObs,
|
|
384
386
|
recordFromEvent,
|
|
385
387
|
recordAudit,
|
|
@@ -507,6 +509,7 @@ export const coreFacade = {
|
|
|
507
509
|
firing: firingRules,
|
|
508
510
|
triggerMatches,
|
|
509
511
|
mentionsGhApi,
|
|
512
|
+
mentionsMcpAct,
|
|
510
513
|
observations: readObservations,
|
|
511
514
|
record: recordObservation,
|
|
512
515
|
observationFrom,
|
|
@@ -28,7 +28,7 @@ function shortId(): string {
|
|
|
28
28
|
return randomUUID().replace(/-/g, "").slice(0, 16);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function deriveTraceId(sessionKey?: string): string {
|
|
31
|
+
export function deriveTraceId(sessionKey?: string): string {
|
|
32
32
|
const seed = sessionKey || randomUUID();
|
|
33
33
|
return createHash("sha256").update(seed).digest("hex").slice(0, 32);
|
|
34
34
|
}
|
|
@@ -27,6 +27,12 @@ export type TriggerContext = {
|
|
|
27
27
|
* ([/decisions/ad-130.md](/decisions/ad-130.md)).
|
|
28
28
|
*/
|
|
29
29
|
repoRemote?: { owner: string; repo: string } | null;
|
|
30
|
+
/**
|
|
31
|
+
* why optional, and read only by the MCP shape: a shell-triggered event never carries this, and the vast
|
|
32
|
+
* majority of MCP events are not pr-open-shaped either — populating it for every event would be work spent
|
|
33
|
+
* on a field almost nothing reads ([/decisions/ad-135.md](/decisions/ad-135.md)).
|
|
34
|
+
*/
|
|
35
|
+
toolInput?: Record<string, unknown>;
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -256,6 +262,81 @@ function matchesAnyShape(
|
|
|
256
262
|
return apiShapes.some((shape) => matchesApiShape(words, shape, repoRemote));
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
/**
|
|
266
|
+
* why a structural strip, not a literal prefix list: an MCP server's name is operator-configured
|
|
267
|
+
* (`mcp__github__create_pull_request` vs. `mcp__gh__create_pull_request` name the same tool through two
|
|
268
|
+
* differently-configured servers), so a fixed string would miss every server name this project has not seen
|
|
269
|
+
* yet — the same "why a basename fallback" reasoning `tokenMatches` already applies to a script's own path
|
|
270
|
+
* ([/decisions/ad-121.md](/decisions/ad-121.md)).
|
|
271
|
+
*/
|
|
272
|
+
export function normalizeMcpToolName(toolName: string): string {
|
|
273
|
+
const withoutHostPrefix = toolName.startsWith("MCP:") ? toolName.slice(4) : toolName;
|
|
274
|
+
if (!withoutHostPrefix.startsWith("mcp__")) {
|
|
275
|
+
return withoutHostPrefix;
|
|
276
|
+
}
|
|
277
|
+
const afterMcp = withoutHostPrefix.slice("mcp__".length);
|
|
278
|
+
const lastSeparator = afterMcp.lastIndexOf("__");
|
|
279
|
+
return lastSeparator === -1 ? afterMcp : afterMcp.slice(lastSeparator + 2);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* why a third grammar, not a widened `ApiShape`: an MCP tool call has no path or HTTP method to infer a verb
|
|
284
|
+
* from — the tool name already names the act, the way a CLI subcommand does. Reusing `ApiShape`'s fields
|
|
285
|
+
* would leave `lastSegment`/`methods` meaningless for this grammar ([/decisions/ad-135.md](/decisions/ad-135.md)).
|
|
286
|
+
*/
|
|
287
|
+
type McpShape = {
|
|
288
|
+
readonly toolName: string;
|
|
289
|
+
readonly ownerField: string;
|
|
290
|
+
readonly repoField: string;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const MCP_SHAPES: Partial<Record<"pr-open" | "commit" | "push" | "pr-merge", readonly McpShape[]>> = {
|
|
294
|
+
"pr-open": [{ toolName: "create_pull_request", ownerField: "owner", repoField: "repo" }],
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function matchesMcpShape(
|
|
298
|
+
context: TriggerContext,
|
|
299
|
+
shape: McpShape,
|
|
300
|
+
repoRemote?: { owner: string; repo: string } | null,
|
|
301
|
+
): boolean {
|
|
302
|
+
if (context.toolName === undefined || normalizeMcpToolName(context.toolName) !== shape.toolName) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
const owner = context.toolInput?.[shape.ownerField];
|
|
306
|
+
const repo = context.toolInput?.[shape.repoField];
|
|
307
|
+
if (typeof owner !== "string" || typeof repo !== "string") {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (repoRemote === undefined) {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (repoRemote === null) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
return owner === repoRemote.owner && repo === repoRemote.repo;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function matchesAnyMcpShape(
|
|
320
|
+
kind: "pr-open" | "commit" | "push" | "pr-merge",
|
|
321
|
+
context: TriggerContext,
|
|
322
|
+
): boolean {
|
|
323
|
+
const mcpShapes = MCP_SHAPES[kind] ?? [];
|
|
324
|
+
return mcpShapes.some((shape) => matchesMcpShape(context, shape, context.repoRemote));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* why exported, mirroring `mentionsGhApi`: the caller deciding whether `repoRemote` is worth a process spawn
|
|
329
|
+
* needs to know whether the MCP-shaped path could possibly be in play, without paying for `localRepoRemote`
|
|
330
|
+
* on every unrelated tool call ([/decisions/ad-130.md](/decisions/ad-130.md), [/decisions/ad-135.md](/decisions/ad-135.md)).
|
|
331
|
+
*/
|
|
332
|
+
export function mentionsMcpAct(toolName: string | undefined): boolean {
|
|
333
|
+
if (toolName === undefined) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const normalized = normalizeMcpToolName(toolName);
|
|
337
|
+
return Object.values(MCP_SHAPES).some((shapes) => shapes.some((shape) => shape.toolName === normalized));
|
|
338
|
+
}
|
|
339
|
+
|
|
259
340
|
export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): boolean {
|
|
260
341
|
switch (trigger.kind) {
|
|
261
342
|
case "stop":
|
|
@@ -266,12 +347,15 @@ export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): b
|
|
|
266
347
|
case "commit":
|
|
267
348
|
case "push":
|
|
268
349
|
case "pr-merge": {
|
|
269
|
-
|
|
270
|
-
|
|
350
|
+
const byCommand =
|
|
351
|
+
context.command !== undefined &&
|
|
352
|
+
subCommands(context.command).some((words) =>
|
|
353
|
+
matchesAnyShape(words, trigger.kind, context.repoRemote),
|
|
354
|
+
);
|
|
355
|
+
if (byCommand) {
|
|
356
|
+
return true;
|
|
271
357
|
}
|
|
272
|
-
return
|
|
273
|
-
matchesAnyShape(words, trigger.kind, context.repoRemote),
|
|
274
|
-
);
|
|
358
|
+
return matchesAnyMcpShape(trigger.kind, context);
|
|
275
359
|
}
|
|
276
360
|
default: {
|
|
277
361
|
if (context.command === undefined) {
|
|
@@ -60,11 +60,21 @@ export function activitySince(events: readonly ObsEvent[], sessionKey: string):
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
function isTurnRelevant(event: ObsEvent): boolean {
|
|
64
|
+
return event.kind === TURN_START || TOOL_KINDS.has(event.kind);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// hazard: truncating to `limit` before filtering let a noise kind (`hook.enter`, one record per gate-relevant
|
|
68
|
+
// hook call) push `prompt.submit` out of the window this function reads — the same order-of-operations defect
|
|
69
|
+
// `tools/obs-cli.ts`'s `liveEvents` had. Read wide, filter to what this function actually counts, then cut to
|
|
70
|
+
// `limit` ([/decisions/ad-136.md](/decisions/ad-136.md)).
|
|
71
|
+
export function readTurnActivity(root: string, sessionKey: string, limit = 500, readWide = 20): TurnActivity {
|
|
72
|
+
const events = ACTIVITY_PLANES.flatMap((plane) =>
|
|
73
|
+
readSignalEvents(root, plane, Math.max(limit * readWide, limit))
|
|
74
|
+
.filter(isTurnRelevant)
|
|
75
|
+
.slice(-limit),
|
|
67
76
|
);
|
|
77
|
+
return activitySince(events, sessionKey);
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
export type IdleTurnInput = {
|
package/src/entrypoints/run.ts
CHANGED
|
@@ -62,19 +62,55 @@ function errorMessage(error: unknown): string {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// why: ObsKind is a closed union with no adapter-boundary member — these fire before a provider/session is known, so they bypass core's typed observability rather than widening that union from outside core.
|
|
65
|
-
function recordAdapterEvent(
|
|
65
|
+
function recordAdapterEvent(
|
|
66
|
+
root: string,
|
|
67
|
+
kind: string,
|
|
68
|
+
attrs: Record<string, unknown>,
|
|
69
|
+
provider = "unknown",
|
|
70
|
+
top: Record<string, unknown> = {},
|
|
71
|
+
): void {
|
|
66
72
|
try {
|
|
67
73
|
appendRecord(join(projectStateDir(root), "obs.jsonl"), {
|
|
68
74
|
schema: "harness.observability.v1",
|
|
69
|
-
provider
|
|
75
|
+
provider,
|
|
70
76
|
kind,
|
|
71
77
|
level: "signal",
|
|
72
78
|
ts: new Date().toISOString(),
|
|
79
|
+
...top,
|
|
73
80
|
attrs,
|
|
74
81
|
});
|
|
75
82
|
} catch {}
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
// hazard: unscoped, one record per hook invocation of any kind flooded the signal plane and pushed
|
|
86
|
+
// `prompt.submit`/`policy.deny` out of two readers' fixed-count tails ([/decisions/ad-136.md](/decisions/ad-136.md)).
|
|
87
|
+
function isGateRelevantHookEvent(event: HarnessEvent): boolean {
|
|
88
|
+
return event.event === "shell.before" || event.event === "mcp.before";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* AD-136 — a hook invocation that never reaches the point where any existing record gets written (the handler
|
|
93
|
+
* killed mid-flight, a host-side timeout) previously left nothing in `obs.jsonl` at all — indistinguishable
|
|
94
|
+
* from a hook that never started. This is the earliest point after an event is known: before `loadPolicy`,
|
|
95
|
+
* before `handler` runs, before anything that could throw or take time. A future incident reads as "entered,
|
|
96
|
+
* no paired completion" instead of being reconstructed from raw provider traces after the fact.
|
|
97
|
+
*/
|
|
98
|
+
function recordHookEnter(event: HarnessEvent): void {
|
|
99
|
+
if (!isGateRelevantHookEvent(event)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
recordAdapterEvent(
|
|
103
|
+
event.projectDir,
|
|
104
|
+
"hook.enter",
|
|
105
|
+
{ event: event.event, toolName: event.toolName ?? "none", sessionKey: event.sessionKey },
|
|
106
|
+
event.provider,
|
|
107
|
+
{
|
|
108
|
+
trace_id: coreFacade.observability.deriveTraceId(event.sessionKey),
|
|
109
|
+
session_id: event.sessionKey,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
/**
|
|
79
115
|
* hazard: `policy.deny` fed `rollup.denials` and the report's "Policy denials" line, and had no producer — so a
|
|
80
116
|
* harness whose whole purpose is refusing things reported zero refusals
|
|
@@ -155,6 +191,7 @@ export async function runHandler(handler: Handler, io: RunIo = {}): Promise<RunO
|
|
|
155
191
|
}
|
|
156
192
|
|
|
157
193
|
const capabilities = provider.capabilities();
|
|
194
|
+
recordHookEnter(event);
|
|
158
195
|
|
|
159
196
|
try {
|
|
160
197
|
const policy = coreFacade.policy.loadPolicy(event.projectDir);
|
|
@@ -94,7 +94,12 @@ function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, de
|
|
|
94
94
|
*/
|
|
95
95
|
async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
|
|
96
96
|
const config = ctx.policy.rules;
|
|
97
|
-
const trigger = {
|
|
97
|
+
const trigger = {
|
|
98
|
+
event: event.event,
|
|
99
|
+
toolName: event.toolName,
|
|
100
|
+
command: event.command,
|
|
101
|
+
toolInput: event.toolInput,
|
|
102
|
+
};
|
|
98
103
|
const shaRoot = shaScopeRoot(event);
|
|
99
104
|
const dryRun = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
|
|
100
105
|
sha: null,
|
|
@@ -110,7 +115,10 @@ async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<
|
|
|
110
115
|
// ([/decisions/ad-130.md](/decisions/ad-130.md)) — a CLI shape or an unrelated trigger never reads it.
|
|
111
116
|
const [sha, repoRemote] = await Promise.all([
|
|
112
117
|
currentGitSha(shaRoot),
|
|
113
|
-
event.command && coreFacade.rules.mentionsGhApi(event.command)
|
|
118
|
+
(event.command && coreFacade.rules.mentionsGhApi(event.command)) ||
|
|
119
|
+
coreFacade.rules.mentionsMcpAct(event.toolName)
|
|
120
|
+
? localRepoRemote(shaRoot)
|
|
121
|
+
: undefined,
|
|
114
122
|
]);
|
|
115
123
|
const verdict = coreFacade.rules.decideAction(
|
|
116
124
|
event.projectDir,
|
|
@@ -36,6 +36,27 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
36
36
|
: undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* hazard: `beforeMCPExecution`'s own `tool_input` is documented as a JSON *string* ("JSON params string that
|
|
41
|
+
* will be passed to the tool"), not an object like `preToolUse`'s — `asRecord` alone left it `undefined` for
|
|
42
|
+
* every MCP call on this host, silently skipping any rule that reads `toolInput` fields
|
|
43
|
+
* ([/decisions/ad-135.md](/decisions/ad-135.md)).
|
|
44
|
+
*/
|
|
45
|
+
function mcpToolInput(value: unknown): Record<string, unknown> | undefined {
|
|
46
|
+
const direct = asRecord(value);
|
|
47
|
+
if (direct) {
|
|
48
|
+
return direct;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return asRecord(JSON.parse(value));
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function asStatus(value: unknown): "completed" | "aborted" | "error" | undefined {
|
|
40
61
|
return value === "completed" || value === "aborted" || value === "error" ? value : undefined;
|
|
41
62
|
}
|
|
@@ -164,7 +185,7 @@ export function cursorToEvent(raw: Record<string, unknown>): HarnessEvent | null
|
|
|
164
185
|
if (toolName) {
|
|
165
186
|
event.toolName = toolName;
|
|
166
187
|
}
|
|
167
|
-
const toolInput =
|
|
188
|
+
const toolInput = mcpToolInput(raw.tool_input);
|
|
168
189
|
if (toolInput) {
|
|
169
190
|
event.toolInput = toolInput;
|
|
170
191
|
}
|
package/tools/obs-cli.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "nod
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { coreFacade } from "../src/core/index.ts";
|
|
4
4
|
import { readSignalEvents } from "../src/core/observability/observability.store.ts";
|
|
5
|
-
import { DEFAULT_OBS, type ObsEvent } from "../src/core/observability/observability.types.ts";
|
|
5
|
+
import { DEFAULT_OBS, LIVE_ALLOWLIST, type ObsEvent } from "../src/core/observability/observability.types.ts";
|
|
6
6
|
import { loadPolicy } from "../src/core/policy/policy.loader.ts";
|
|
7
7
|
import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
|
|
8
8
|
import { projectStateDir } from "../src/platform/paths.ts";
|
|
@@ -31,6 +31,16 @@ export function limitFrom(raw: string | undefined, fallback: number): number {
|
|
|
31
31
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// hazard: filtering a tail already truncated to `limit` leaves a noise kind (`hook.enter`, one record per
|
|
35
|
+
// gate-relevant hook call) able to push every allowlisted event out of the window the operator actually reads
|
|
36
|
+
// — the window has to be read wide, filtered, then cut to `limit`, never the other order
|
|
37
|
+
// ([/decisions/ad-136.md](/decisions/ad-136.md)).
|
|
38
|
+
export function liveEvents(root: string, limit: number, readWide = 20): readonly ObsEvent[] {
|
|
39
|
+
const wide = readSignalEvents(root, DEFAULT_OBS.signalPath, Math.max(limit * readWide, limit));
|
|
40
|
+
const allowed = wide.filter((event) => LIVE_ALLOWLIST.has(event.kind));
|
|
41
|
+
return allowed.slice(-limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
/**
|
|
35
45
|
* hazard: this sorted by filename. A session id is a UUID, so alphabetical order has no relation to time — on
|
|
36
46
|
* this machine it picked a session from thirteen days earlier whose every counter was zero, which reads exactly
|
|
@@ -67,7 +77,7 @@ function main(argv: string[]): void {
|
|
|
67
77
|
const arg = rest[1];
|
|
68
78
|
|
|
69
79
|
if (cmd === "live") {
|
|
70
|
-
const events =
|
|
80
|
+
const events = liveEvents(root, limitFrom(arg, 40));
|
|
71
81
|
if (json) {
|
|
72
82
|
emitJson(liveJson(events));
|
|
73
83
|
} else {
|