@webpieces/ai-hook-rules 0.4.601 → 0.4.603
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/package.json +2 -2
- package/src/adapters/hook-core.js +1 -1
- package/src/adapters/hook-core.js.map +1 -1
- package/src/core/log-stream.d.ts +30 -1
- package/src/core/log-stream.js +41 -5
- package/src/core/log-stream.js.map +1 -1
- package/src/core/main-sync-refresh.d.ts +18 -0
- package/src/core/main-sync-refresh.js +23 -1
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/rejection-log.js +124 -44
- package/src/core/rejection-log.js.map +1 -1
- package/src/core/sync-main.d.ts +19 -1
- package/src/core/sync-main.js +24 -1
- package/src/core/sync-main.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.603",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.603"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -249,7 +249,7 @@ async function runMain(mode) {
|
|
|
249
249
|
toolName = payload.tool_name;
|
|
250
250
|
// BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why
|
|
251
251
|
// all three of session/agent/hook are needed to keep concurrent writers off one file.
|
|
252
|
-
log_stream_1.logStream.identify(payload.session_id ?? '', payload.agent_id ?? '', mode);
|
|
252
|
+
log_stream_1.logStream.identify(new log_stream_1.StreamIdentity(payload.session_id ?? '', payload.agent_id ?? '', mode));
|
|
253
253
|
// Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over
|
|
254
254
|
// process.cwd(); they match today, but the payload is the authoritative signal and stays
|
|
255
255
|
// correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":";;AAgPA,8DAKC;AAyDD,0BAgEC;;AA9WD,mDAA6B;AAE7B,2CAAuD;AACvD,uEAA6D;AAC7D,yDAAuE;AACvE,uDAAoG;AACpG,iEAAmE;AACnE,qDAAsD;AACtD,0DAAyD;AACzD,yCAAqI;AACrI,+CAA2C;AAC3C,iEAA6D;AAC7D,sCAA2G;AAC3G,gEAA+D;AAC/D,iDAA4E;AAC5E,mDAA+C;AAC/C,2DAA6D;AAW7D,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;AAEnE,qGAAqG;AACrG,gGAAgG;AAChG,oGAAoG;AACpG,kGAAkG;AAClG,mDAAmD;AACnD,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AA6B1C,SAAS,SAAS;IACd,OAAO,IAAI,OAAO,CAAC,CAAC,OAAgC,EAAE,EAAE;QACpD,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAClC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,GAAG,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAChE,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QAC7C,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7C,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,CAAC,EAAE,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;AACP,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC1B,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAsB,CAAC;IAChD,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,IAAI,qBAAa,CAAC,gDAAgD,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC/G,CAAC;AACL,CAAC;AAED,SAAS,iBAAiB,CAAC,QAAgB;IACvC,IAAI,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAoB,CAAC;IAClE,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,kBAAkB,CAAC,QAAkB,EAAE,SAA8B;IAC1E,MAAM,QAAQ,GAAG,SAAS,CAAC,SAAS,CAAC;IACrC,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAE3B,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QACvB,OAAO,IAAI,2BAAmB,CAAC,QAAQ,EAAE;YACrC,IAAI,sBAAc,CAAC,EAAE,EAAE,SAAS,CAAC,OAAO,IAAI,EAAE,CAAC;SAClD,CAAC,CAAC;IACP,CAAC;IACD,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACtB,OAAO,IAAI,2BAAmB,CAAC,QAAQ,EAAE;YACrC,IAAI,sBAAc,CAAC,SAAS,CAAC,UAAU,IAAI,EAAE,EAAE,SAAS,CAAC,UAAU,IAAI,EAAE,CAAC;SAC7E,CAAC,CAAC;IACP,CAAC;IACD,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;QAC3B,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAClE,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,CAAsB,EAAE,EAAE,CAAC,IAAI,sBAAc,CAAC,CAAC,CAAC,UAAU,IAAI,EAAE,EAAE,CAAC,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,CAAC;QAC9G,OAAO,IAAI,2BAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,kGAAkG;AAClG,qFAAqF;AACrF,0JAA0J;AAC1J,SAAS,eAAe,CAAC,OAA0B;IAC/C,OAAO,IAAI,oCAAa,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC;AAC/E,CAAC;AAED,kGAAkG;AAClG,kGAAkG;AAClG,sDAAsD;AACtD,0JAA0J;AAC1J,SAAS,YAAY,CAAC,MAAc,EAAE,QAAgB;IAClD,MAAM,KAAK,GAAG,IAAA,gCAAgB,EAAC,MAAM,CAAC,CAAC;IACvC,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AACzD,CAAC;AAED,SAAS,UAAU,CAAC,OAA0B,EAAE,GAAW,EAAE,IAAc;IACvE,MAAM,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC;IAC3C,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IACvD,MAAM,KAAK,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,IAAA,gBAAO,EAAC,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,IAAI,CAAC,MAAM,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAC7B,kGAAkG;IAClG,mGAAmG;IACnG,mGAAmG;IACnG,MAAM,IAAI,GAAG,IAAI,6BAAc,EAAE,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IACvD,IAAA,+BAAgB,EAAC,IAAI,EAAE,IAAI,4BAAa,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,IAAI,EAAE,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAC9I,kGAAkG;IAClG,gGAAgG;IAChG,IAAA,+BAAQ,EAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAC7F,CAAC;AAED;;;;;;;GAOG;AACH,0JAA0J;AAC1J,SAAS,UAAU,CAAC,QAAgB,EAAE,GAAW,EAAE,IAAc;IAC7D,IAAI,QAAQ,KAAK,EAAE;QAAE,OAAO;IAC5B,IAAI,MAAM,GAAyB,IAAI,CAAC;IACxC,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,GAAG,IAAA,gBAAO,EAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IAC1C,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,OAAO,CAAC,kCAAkC;IAC9C,CAAC;IACD,IAAI,CAAC,MAAM;QAAE,OAAO;IACpB,IAAA,4BAAY,EAAC,MAAM,EAAE,IAAI,2BAAmB,CAAC,QAAQ,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;IACzE,IAAA,+BAAQ,EAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAC7F,CAAC;AAED,SAAS,cAAc,CAAC,OAA0B,EAAE,GAAW,EAAE,IAAc;IAC3E,MAAM,QAAQ,GAAG,iBAAiB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACtD,IAAI,CAAC,QAAQ,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAE/B,MAAM,KAAK,GAAG,kBAAkB,CAAC,QAAQ,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/D,IAAI,CAAC,KAAK,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAE5B,+FAA+F;IAC/F,gGAAgG;IAChG,gGAAgG;IAChG,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,6BAAe,EAAE,CAAC;QACpD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;YACnB,wFAAwF;YACxF,sFAAsF;YACtF,oCAAoC;YACpC,MAAM,IAAI,GAAG,IAAI,6BAAc,EAAE,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;YACvD,IAAA,+BAAgB,EACZ,IAAI,EACJ,IAAI,4BAAa,CAAC,sBAAsB,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,EAAE,OAAO,EAAE,8CAA8C,CAAC,CACnJ,CAAC;YACF,yFAAyF;YACzF,sFAAsF;YACtF,qEAAqE;YACrE,IAAA,0CAAsB,EAAC,IAAI,CAAC,CAAC;QACjC,CAAC;QACD,IAAA,gCAAS,GAAE,CAAC;IAChB,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,YAAG,EAAC,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,MAAM,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAE7B,IAAA,4BAAY,EAAC,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;IAC3C,kGAAkG;IAClG,8EAA8E;IAC9E,IAAA,+BAAQ,EAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAC/F,CAAC;AAmCD,2JAA2J;AAC3J,SAAgB,yBAAyB,CAAC,QAAgB,EAAE,OAAe,EAAE,QAAgB;IACzF,MAAM,OAAO,GAAG,IAAA,gBAAS,EAAC,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IACvD,IAAI,OAAO,KAAK,MAAM;QAAE,OAAO,MAAM,CAAC;IACtC,IAAI,OAAO,KAAK,OAAO;QAAE,OAAO,YAAY,CAAC;IAC7C,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,uGAAuG;AACvG,4CAA4C;AAC5C,qGAAqG;AACrG,uGAAuG;AACvG,mGAAmG;AACnG,sGAAsG;AACtG,mGAAmG;AACnG,6FAA6F;AAC7F,6GAA6G;AAC7G,oGAAoG;AACpG,kFAAkF;AAClF,0JAA0J;AAC1J,SAAS,oBAAoB,CAAC,OAA0B,EAAE,GAAW,EAAE,IAAc;IACjF,oGAAoG;IACpG,gGAAgG;IAChG,qGAAqG;IACrG,wGAAwG;IACxG,6BAA6B;IAC7B,MAAM,QAAQ,GAAG,IAAA,wBAAiB,GAAE,CAAC;IACrC,IAAI,IAAI,KAAK,OAAO;QAAE,OAAO;IAC7B,yFAAyF;IACzF,8FAA8F;IAC9F,6FAA6F;IAC7F,8FAA8F;IAC9F,MAAM,OAAO,GAAG,IAAA,uCAAmB,EAAC,QAAQ,CAAC,CAAC;IAC9C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IACjC,MAAM,QAAQ,GAAG,yBAAyB,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC;IACpI,IAAI,QAAQ,KAAK,MAAM;QAAE,OAAO;IAChC,IAAI,QAAQ,KAAK,YAAY;QAAE,IAAA,gCAAS,GAAE,CAAC;IAC3C,kGAAkG;IAClG,6FAA6F;IAC7F,MAAM,IAAI,GAAG,IAAI,6BAAc,EAAE,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IACvD,MAAM,OAAO,GAAG,IAAA,+BAAmB,EAAC,IAAI,CAAC,CAAC;IAC1C,kGAAkG;IAClG,kGAAkG;IAClG,mGAAmG;IACnG,mGAAmG;IACnG,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC;IAChF,IAAA,+BAAgB,EACZ,IAAI,EACJ,IAAI,4BAAa,CAAC,sBAAsB,EAAE,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,EAAE,OAAO,EAAE,2CAA2C,EAAE,GAAG,EAAE,oCAAmB,CAAC,CAC3K,CAAC;IACF,+FAA+F;IAC/F,kGAAkG;IAClG,2DAA2D;IAC3D,IAAA,+BAAQ,EAAC,IAAA,0BAAmB,EAAC,IAAA,gCAAyB,GAAE,EAAE,QAAQ,IAAI,EAAE,EAAE,OAAO,CAAC,GAAG,IAAA,8BAAkB,EAAC,OAAO,CAAC,EAAE,OAAO,CAAC,SAAS,EAAE,sBAAsB,EAAE,oCAAmB,CAAC,CAAC;AACtL,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,OAAO,CAAC,IAAc;IACxC,kGAAkG;IAClG,gGAAgG;IAChG,+FAA+F;IAC/F,IAAI,QAAQ,GAAG,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,OAAO,EAAE,CAAC;YAAC,IAAA,gCAAS,GAAE,CAAC;QAAC,CAAC;QAC9B,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC;QAE7B,4FAA4F;QAC5F,sFAAsF;QACtF,sBAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC;QAE3E,sFAAsF;QACtF,yFAAyF;QACzF,uFAAuF;QACvF,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;QAEzC,+FAA+F;QAC/F,wGAAwG;QACxG,oBAAoB,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;QAEzC,4FAA4F;QAC5F,+FAA+F;QAC/F,2EAA2E;QAC3E,yEAAyE;QACzE,IAAI,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;YACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC;YACpD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;gBACnB,4BAAa,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;gBACtD,qFAAqF;gBACrF,iFAAiF;gBACjF,IAAA,0CAAsB,EAAC,GAAG,CAAC,CAAC;YAChC,CAAC;YACD,UAAU,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;YAChC,IAAA,gCAAS,GAAE,CAAC;QAChB,CAAC;QAED,wFAAwF;QACxF,8FAA8F;QAC9F,+FAA+F;QAC/F,0EAA0E;QAC1E,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;YACnB,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC;YACxH,4BAAa,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;QACxD,CAAC;QAED,IAAI,OAAO,CAAC,SAAS,KAAK,MAAM,EAAE,CAAC;YAC/B,qEAAqE;YACrE,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;gBAAC,IAAA,gCAAS,GAAE,CAAC;YAAC,CAAC;YACtC,UAAU,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;YAC/B,OAAO;QACX,CAAC;QAED,+EAA+E;QAC/E,8EAA8E;QAC9E,cAAc,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IACvC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IAClC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,oLAAoL;AACpL,SAAS,YAAY,CAAC,KAAY,EAAE,QAAgB;IAChD,IAAI,KAAK,YAAY,qBAAa,EAAE,CAAC;QACjC,IAAA,+BAAQ,EAAC,KAAK,CAAC,SAAS,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;IACtD,CAAC;IACD,IAAI,KAAK,YAAY,qBAAa,EAAE,CAAC;QACjC,IAAA,+BAAQ,EAAC,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC;IAC7D,CAAC;IACD,IAAA,+BAAQ,EAAC,0DAA0D,KAAK,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;AAChH,CAAC","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { AgentIdentity } from '../core/coordinator-worktree';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder } from '@webpieces/rules-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { governingShimRoot, isAllowed, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream } from '../core/log-stream';\nimport { L0_FAULT_SHIM_STALE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\n// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log\n// (guard-invocations.log). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a\n// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it\n// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;\n// widen (Grep/Glob/NotebookRead) later if desired.\nconst READ_ONLY_TOOLS = new Set(['Read']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n // Claude Code sends the session's current working directory (follows a persisted `cd`). Used to\n // scope guards to the git repo the AI is actually in — see runner git-repo-boundary governance.\n cwd?: string;\n // Present ONLY when the hook fires inside a SUBAGENT. Absent = the coordinator (the main agent\n // loop) — there is no positive coordinator field to read. See AgentIdentity.\n session_id?: string;\n agent_id?: string;\n agent_type?: string;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\n// The caller behind this payload. Both fields are sent only inside a subagent, so BOTH absent (or\n// empty) is the coordinator — the one distinction CoordinatorWorktreeGuard turns on.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction agentIdentityOf(payload: ClaudeCodePayload): AgentIdentity {\n return new AgentIdentity(payload.agent_id ?? '', payload.agent_type ?? '');\n}\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const agent = agentIdentityOf(payload);\n const result = runBash(command, cwd, mode, agent);\n if (!result) { emitAllow(); }\n // Persist the block + WHY. File-tool denies go to hook-rejection.log via logRejection, but a Bash\n // deny had no audit trail — record it in guard-sync-decisions.log so \"blocked and why\" is complete\n // for Bash too. `.webpieces` lives at the repo root, resolved from cwd. Best-effort; never blocks.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('bash-guard', 'Bash', command ?? '', branchForLog(root), 'BLOCK', result.report, '-', result.fault));\n // Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny\n // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.\n emitDeny(result.report, 'Bash', blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(result.report, 'Read', blockingRule(result.report, 'read-guard'), result.fault);\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { emitAllow(); }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { emitAllow(); }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(root), 'ALLOW', 'config-bypass (feature-branch-guard skipped)'),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root);\n }\n emitAllow();\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { emitAllow(); }\n\n logRejection(toolKind, input, result, cwd);\n // File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See claude-code-response.ts.\n emitDeny(result.report, toolKind, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the three managed things moved — the shim, the L-1 hook, or the settings.json\n // registration. Nothing validated the registration before, so a settings file left on the old\n // two-absolute-hook form silently reverted a repo to per-PRIMARY governance and disabled L-1\n // entirely: the one component whose whole job is failing closed, switched off with no signal.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's ai-hook-shim.log.\n emitDeny(shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted) + guardMatrixPointer(docPath), payload.tool_name, 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a\n * PreToolUse `permissionDecision:\"deny\"` JSON on stdout (exit 0) — see claude-code-response.ts. Fails\n * CLOSED on any unexpected crash (emits a deny) so a broken hook never silently lets an edit through,\n * and the reason now surfaces in the Claude Code UI instead of being hidden on a stderr+exit-2 block.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // Captured from the payload as soon as it parses so the fail-closed catch below can tell denyJson\n // which tool it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Empty (before parse / malformed input) → treated as non-Bash.\n let toolName = '';\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { emitAllow(); }\n toolName = payload.tool_name;\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(payload.session_id ?? '', payload.agent_id ?? '', mode);\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, cwd, mode);\n\n // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule\n // ever sees a Read, and the only way this path can deny is a stale `main`.\n // The audit trail still records every file the AI opened (see setup.ts).\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n const readPath = payload.tool_input.file_path ?? '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, payload.tool_name, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd);\n }\n handleRead(readPath, cwd, mode);\n emitAllow();\n }\n\n // Per-invocation guard log (guard-invocations.log): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');\n invocationLog.begin(cwd, payload.tool_name, target);\n }\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n denyForCrash(error, toolName);\n }\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin) both carry an\n * AI-readable message; anything else is an unexpected bug. All three DENY and surface their reason,\n * because a hook that crashed established nothing and must never be read as an allow.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, toolName: string): never {\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName, 'rule-crash');\n }\n if (error instanceof InformAiError) {\n emitDeny(error.message, toolName, 'bad-config-or-stdin');\n }\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName, 'hook-crash');\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { AgentIdentity } from '../core/coordinator-worktree';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder } from '@webpieces/rules-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { governingShimRoot, isAllowed, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { L0_FAULT_SHIM_STALE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\n// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log\n// (guard-invocations.log). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a\n// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it\n// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;\n// widen (Grep/Glob/NotebookRead) later if desired.\nconst READ_ONLY_TOOLS = new Set(['Read']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n // Claude Code sends the session's current working directory (follows a persisted `cd`). Used to\n // scope guards to the git repo the AI is actually in — see runner git-repo-boundary governance.\n cwd?: string;\n // Present ONLY when the hook fires inside a SUBAGENT. Absent = the coordinator (the main agent\n // loop) — there is no positive coordinator field to read. See AgentIdentity.\n session_id?: string;\n agent_id?: string;\n agent_type?: string;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\n// The caller behind this payload. Both fields are sent only inside a subagent, so BOTH absent (or\n// empty) is the coordinator — the one distinction CoordinatorWorktreeGuard turns on.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction agentIdentityOf(payload: ClaudeCodePayload): AgentIdentity {\n return new AgentIdentity(payload.agent_id ?? '', payload.agent_type ?? '');\n}\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const agent = agentIdentityOf(payload);\n const result = runBash(command, cwd, mode, agent);\n if (!result) { emitAllow(); }\n // Persist the block + WHY. File-tool denies go to hook-rejection.log via logRejection, but a Bash\n // deny had no audit trail — record it in guard-sync-decisions.log so \"blocked and why\" is complete\n // for Bash too. `.webpieces` lives at the repo root, resolved from cwd. Best-effort; never blocks.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('bash-guard', 'Bash', command ?? '', branchForLog(root), 'BLOCK', result.report, '-', result.fault));\n // Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny\n // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.\n emitDeny(result.report, 'Bash', blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(result.report, 'Read', blockingRule(result.report, 'read-guard'), result.fault);\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { emitAllow(); }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { emitAllow(); }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(root), 'ALLOW', 'config-bypass (feature-branch-guard skipped)'),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root);\n }\n emitAllow();\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { emitAllow(); }\n\n logRejection(toolKind, input, result, cwd);\n // File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See claude-code-response.ts.\n emitDeny(result.report, toolKind, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the three managed things moved — the shim, the L-1 hook, or the settings.json\n // registration. Nothing validated the registration before, so a settings file left on the old\n // two-absolute-hook form silently reverted a repo to per-PRIMARY governance and disabled L-1\n // entirely: the one component whose whole job is failing closed, switched off with no signal.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's ai-hook-shim.log.\n emitDeny(shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted) + guardMatrixPointer(docPath), payload.tool_name, 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a\n * PreToolUse `permissionDecision:\"deny\"` JSON on stdout (exit 0) — see claude-code-response.ts. Fails\n * CLOSED on any unexpected crash (emits a deny) so a broken hook never silently lets an edit through,\n * and the reason now surfaces in the Claude Code UI instead of being hidden on a stderr+exit-2 block.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // Captured from the payload as soon as it parses so the fail-closed catch below can tell denyJson\n // which tool it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Empty (before parse / malformed input) → treated as non-Bash.\n let toolName = '';\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { emitAllow(); }\n toolName = payload.tool_name;\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(payload.session_id ?? '', payload.agent_id ?? '', mode));\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, cwd, mode);\n\n // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule\n // ever sees a Read, and the only way this path can deny is a stale `main`.\n // The audit trail still records every file the AI opened (see setup.ts).\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n const readPath = payload.tool_input.file_path ?? '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, payload.tool_name, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd);\n }\n handleRead(readPath, cwd, mode);\n emitAllow();\n }\n\n // Per-invocation guard log (guard-invocations.log): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');\n invocationLog.begin(cwd, payload.tool_name, target);\n }\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n denyForCrash(error, toolName);\n }\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin) both carry an\n * AI-readable message; anything else is an unexpected bug. All three DENY and surface their reason,\n * because a hook that crashed established nothing and must never be read as an allow.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, toolName: string): never {\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName, 'rule-crash');\n }\n if (error instanceof InformAiError) {\n emitDeny(error.message, toolName, 'bad-config-or-stdin');\n }\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName, 'hook-crash');\n}\n"]}
|
package/src/core/log-stream.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO is writing — the three fields that make a log filename unique, carried as one value.
|
|
3
|
+
*
|
|
4
|
+
* Data-only (per CLAUDE.md: classes for data, explicit construction). It exists as a class rather
|
|
5
|
+
* than three loose parameters because the identity has to CROSS A PROCESS BOUNDARY: the detached
|
|
6
|
+
* main-sync refresher is a separate node process, and the spawner hands it these three fields on
|
|
7
|
+
* argv so parent and child write ONE stream. A single named carrier is what makes "read it back out
|
|
8
|
+
* of the parent, put it on argv, set it in the child" a three-line round trip instead of three
|
|
9
|
+
* parallel string parameters that can be reordered at either end.
|
|
10
|
+
*/
|
|
11
|
+
export declare class StreamIdentity {
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
readonly agentId: string;
|
|
14
|
+
readonly hook: string;
|
|
15
|
+
constructor(sessionId: string, agentId: string, hook: string);
|
|
16
|
+
}
|
|
1
17
|
/**
|
|
2
18
|
* WHICH LOG FILE does this hook invocation append to?
|
|
3
19
|
*
|
|
@@ -55,7 +71,20 @@ export declare class LogStream {
|
|
|
55
71
|
* coordinator — that absence IS the signal, see AgentIdentity — and renders as `coordinator`.
|
|
56
72
|
* An empty `sessionId` renders as `unknown`: visible, never merged into another stream.
|
|
57
73
|
*/
|
|
58
|
-
identify(
|
|
74
|
+
identify(identity: StreamIdentity): void;
|
|
75
|
+
/**
|
|
76
|
+
* This process's identity, readable so it can be HANDED TO A CHILD PROCESS.
|
|
77
|
+
*
|
|
78
|
+
* The detached main-sync refresher (main-sync-refresh.ts → sync-main.ts) is a separate node
|
|
79
|
+
* process with a fresh, unidentified `logStream`. Before this existed the parent logged
|
|
80
|
+
* SPAWN_ATTEMPT to its own prefixed stream while the child logged START/FINISH/ERROR to the
|
|
81
|
+
* shared `unknown-coordinator-hook-guard-async-work.log` — so ONE refresh cycle was split across
|
|
82
|
+
* two files, every agent's child appended to that one shared file (the PIPE_BUF tearing this
|
|
83
|
+
* class exists to remove, still live on that stream), and the documented
|
|
84
|
+
* "SPAWN_ATTEMPT with no START means the child never launched" check read as a false failure on
|
|
85
|
+
* every single cycle. The spawner now reads this and puts it on the child's argv.
|
|
86
|
+
*/
|
|
87
|
+
identity(): StreamIdentity;
|
|
59
88
|
/**
|
|
60
89
|
* This caller's name for `base` — `<sessionId>-<agentId|coordinator>-<hook>-<base>`, ALWAYS.
|
|
61
90
|
*
|
package/src/core/log-stream.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.logStream = exports.LogStream = void 0;
|
|
3
|
+
exports.logStream = exports.LogStream = exports.StreamIdentity = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* WHO is writing — the three fields that make a log filename unique, carried as one value.
|
|
6
|
+
*
|
|
7
|
+
* Data-only (per CLAUDE.md: classes for data, explicit construction). It exists as a class rather
|
|
8
|
+
* than three loose parameters because the identity has to CROSS A PROCESS BOUNDARY: the detached
|
|
9
|
+
* main-sync refresher is a separate node process, and the spawner hands it these three fields on
|
|
10
|
+
* argv so parent and child write ONE stream. A single named carrier is what makes "read it back out
|
|
11
|
+
* of the parent, put it on argv, set it in the child" a three-line round trip instead of three
|
|
12
|
+
* parallel string parameters that can be reordered at either end.
|
|
13
|
+
*/
|
|
14
|
+
class StreamIdentity {
|
|
15
|
+
sessionId;
|
|
16
|
+
agentId;
|
|
17
|
+
hook;
|
|
18
|
+
constructor(sessionId, agentId, hook) {
|
|
19
|
+
this.sessionId = sessionId;
|
|
20
|
+
this.agentId = agentId;
|
|
21
|
+
this.hook = hook;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.StreamIdentity = StreamIdentity;
|
|
4
25
|
/**
|
|
5
26
|
* WHICH LOG FILE does this hook invocation append to?
|
|
6
27
|
*
|
|
@@ -63,10 +84,25 @@ class LogStream {
|
|
|
63
84
|
* coordinator — that absence IS the signal, see AgentIdentity — and renders as `coordinator`.
|
|
64
85
|
* An empty `sessionId` renders as `unknown`: visible, never merged into another stream.
|
|
65
86
|
*/
|
|
66
|
-
identify(
|
|
67
|
-
this.sessionId = sessionId;
|
|
68
|
-
this.agentId = agentId;
|
|
69
|
-
this.hook = hook;
|
|
87
|
+
identify(identity) {
|
|
88
|
+
this.sessionId = identity.sessionId;
|
|
89
|
+
this.agentId = identity.agentId;
|
|
90
|
+
this.hook = identity.hook;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* This process's identity, readable so it can be HANDED TO A CHILD PROCESS.
|
|
94
|
+
*
|
|
95
|
+
* The detached main-sync refresher (main-sync-refresh.ts → sync-main.ts) is a separate node
|
|
96
|
+
* process with a fresh, unidentified `logStream`. Before this existed the parent logged
|
|
97
|
+
* SPAWN_ATTEMPT to its own prefixed stream while the child logged START/FINISH/ERROR to the
|
|
98
|
+
* shared `unknown-coordinator-hook-guard-async-work.log` — so ONE refresh cycle was split across
|
|
99
|
+
* two files, every agent's child appended to that one shared file (the PIPE_BUF tearing this
|
|
100
|
+
* class exists to remove, still live on that stream), and the documented
|
|
101
|
+
* "SPAWN_ATTEMPT with no START means the child never launched" check read as a false failure on
|
|
102
|
+
* every single cycle. The spawner now reads this and puts it on the child's argv.
|
|
103
|
+
*/
|
|
104
|
+
identity() {
|
|
105
|
+
return new StreamIdentity(this.sessionId, this.agentId, this.hook);
|
|
70
106
|
}
|
|
71
107
|
/**
|
|
72
108
|
* This caller's name for `base` — `<sessionId>-<agentId|coordinator>-<hook>-<base>`, ALWAYS.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"log-stream.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/log-stream.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"log-stream.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/log-stream.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;GASG;AACH,MAAa,cAAc;IACd,SAAS,CAAS;IAClB,OAAO,CAAS;IAChB,IAAI,CAAS;IAEtB,YAAY,SAAiB,EAAE,OAAe,EAAE,IAAY;QACxD,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ;AAVD,wCAUC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAa,SAAS;IAClB,iGAAiG;IACjG,+FAA+F;IAC/F,iGAAiG;IACjG,gGAAgG;IAChG,oCAAoC;IAC5B,SAAS,GAAG,SAAS,CAAC;IACtB,OAAO,GAAG,EAAE,CAAC;IACb,IAAI,GAAG,MAAM,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,QAAwB;QAC7B,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC,SAAS,CAAC;QACpC,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC;QAChC,IAAI,CAAC,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC;IAC9B,CAAC;IAED;;;;;;;;;;;OAWG;IACH,QAAQ;QACJ,OAAO,IAAI,cAAc,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IACvE,CAAC;IAED;;;;;OAKG;IACH,QAAQ,CAAC,IAAY;QACjB,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1E,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;IAC/E,CAAC;CACJ;AA/CD,8BA+CC;AAED;;;;;GAKG;AACH,gIAAgI;AAChI,SAAS,OAAO,CAAC,GAAW;IACxB,MAAM,OAAO,GAAG,GAAG;SACd,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAG,iDAAiD;SACpF,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,CAAY,2DAA2D;SAC9F,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAe,4BAA4B;SAC/D,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClB,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AAChD,CAAC;AAED;;;;GAIG;AACU,QAAA,SAAS,GAAG,IAAI,SAAS,EAAE,CAAC","sourcesContent":["/**\n * WHO is writing — the three fields that make a log filename unique, carried as one value.\n *\n * Data-only (per CLAUDE.md: classes for data, explicit construction). It exists as a class rather\n * than three loose parameters because the identity has to CROSS A PROCESS BOUNDARY: the detached\n * main-sync refresher is a separate node process, and the spawner hands it these three fields on\n * argv so parent and child write ONE stream. A single named carrier is what makes \"read it back out\n * of the parent, put it on argv, set it in the child\" a three-line round trip instead of three\n * parallel string parameters that can be reordered at either end.\n */\nexport class StreamIdentity {\n readonly sessionId: string;\n readonly agentId: string;\n readonly hook: string;\n\n constructor(sessionId: string, agentId: string, hook: string) {\n this.sessionId = sessionId;\n this.agentId = agentId;\n this.hook = hook;\n }\n}\n\n/**\n * WHICH LOG FILE does this hook invocation append to?\n *\n * ─── The bug this exists to fix ────────────────────────────────────────────────────────────────────\n * Log paths used to be keyed by the git WORKTREE alone (`<local>/logs/…`). Three separate things share\n * a worktree, so three separate things shared one file:\n *\n * 1. **Parallel hooks.** The hooks reference says \"when multiple PreToolUse hooks match a tool call,\n * ALL matching hooks run in parallel\". `wp-ai-rules-hook` and `wp-ai-guards-hook` both match\n * Write/Edit/MultiEdit, so on every file edit TWO PROCESSES append to the same file at the same\n * time. L-1's `guarantee-root.sh` makes it three.\n * 2. **Subagents.** A subagent without worktree isolation shares the coordinator's tree.\n * 3. **Whole sessions.** Four Claude Code windows on one clone are four coordinators, and `agent_id`\n * is absent for every one of them — so agent identity alone cannot tell them apart.\n *\n * `O_APPEND` is indivisible only under `PIPE_BUF`, which is **512 bytes on macOS**. Measured\n * 2026-08-06 across three repos: `guard-invocations.log` 208/3306 lines (6.3%) exceed it, max 608 B;\n * `guard-sync-decisions.log` 209/4097 (5.1%), max 625 B. So this tears TODAY, and the corrupted line\n * is exactly the long one — the `recover=` line a human needs most.\n *\n * ─── The key: three dimensions, one FLAT filename ──────────────────────────────────────────────────\n * <local>/logs/<sessionId>-<agentId | \"coordinator\">-<hook>-<file>.log\n *\n * sessionId separates concurrent Claude Code windows (`session_id`, on every hook payload)\n * agentId separates subagents within one window (`agent_id`, subagent-only — absent = coordinator)\n * hook separates the PARALLEL hooks ('guards' | 'rules' | 'guarantee-root')\n *\n * One writer per FILE, by construction, so appends cannot interleave and nothing needs a lock.\n *\n * DELIBERATELY FLAT, not `sessions/<id>/<agent>/<hook>/<file>`. A nested tree makes the common\n * question — \"show me everything that happened, in time order\" — into a directory walk, when it should\n * be one glob: `ls logs/` shows every stream at once, `logs/<sid>-*` is one window, `*-<agent>-*` is one\n * subagent, `*-guards-*` is one hook. Rotation is unchanged because `.1.log` is still a suffix.\n *\n * `transcript_path` is also unique per session, but it is a filesystem PATH — long, and full of\n * separators that would have to be flattened anyway — and `session_id` is its stable identifier, so\n * session_id is the better key.\n *\n * The tree is still visible — every line already carries `root=` / `projectDir=` / `tree=` columns —\n * so nothing is lost by the filename not encoding it.\n *\n * ─── There is no un-split path ─────────────────────────────────────────────────────────────────────\n * Every name is prefixed, always. A caller that never identifies renders as\n * `unknown-coordinator-hook-<base>` — a distinct, greppable stream, NOT the shared file. Keeping a\n * bare-name fallback would have meant two reachable spellings of one filename, with the tearing one\n * reached by doing nothing; that is the widening-as-absence this whole class exists to remove, so it\n * is not offered.\n */\nexport class LogStream {\n // ALWAYS a real identity. There is no \"unset\" state and no bare-name branch, so there is exactly\n // ONE spelling of a log filename and a writer cannot reach the shared, tearing stream by doing\n // nothing. A caller with no Claude Code payload (the openclaw adapter, library consumers, specs)\n // gets UNIDENTIFIED below — which still prefixes, with `unknown`, so it is a distinct greppable\n // stream rather than a merge point.\n private sessionId = 'unknown';\n private agentId = '';\n private hook = 'hook';\n\n /**\n * Called once per invocation by the adapter that parsed the payload. `agentId` is empty for the\n * coordinator — that absence IS the signal, see AgentIdentity — and renders as `coordinator`.\n * An empty `sessionId` renders as `unknown`: visible, never merged into another stream.\n */\n identify(identity: StreamIdentity): void {\n this.sessionId = identity.sessionId;\n this.agentId = identity.agentId;\n this.hook = identity.hook;\n }\n\n /**\n * This process's identity, readable so it can be HANDED TO A CHILD PROCESS.\n *\n * The detached main-sync refresher (main-sync-refresh.ts → sync-main.ts) is a separate node\n * process with a fresh, unidentified `logStream`. Before this existed the parent logged\n * SPAWN_ATTEMPT to its own prefixed stream while the child logged START/FINISH/ERROR to the\n * shared `unknown-coordinator-hook-guard-async-work.log` — so ONE refresh cycle was split across\n * two files, every agent's child appended to that one shared file (the PIPE_BUF tearing this\n * class exists to remove, still live on that stream), and the documented\n * \"SPAWN_ATTEMPT with no START means the child never launched\" check read as a false failure on\n * every single cycle. The spawner now reads this and puts it on the child's argv.\n */\n identity(): StreamIdentity {\n return new StreamIdentity(this.sessionId, this.agentId, this.hook);\n }\n\n /**\n * This caller's name for `base` — `<sessionId>-<agentId|coordinator>-<hook>-<base>`, ALWAYS.\n *\n * Takes the WHOLE filename (`guard-invocations.log`, and separately `guard-invocations.1.log`) so\n * the rotation sibling gets the identical prefix and rotation keeps working untouched.\n */\n fileName(base: string): string {\n const agent = segment(this.agentId === '' ? 'coordinator' : this.agentId);\n return `${segment(this.sessionId)}-${agent}-${segment(this.hook)}-${base}`;\n }\n}\n\n/**\n * One path segment, sanitised. `session_id` and `agent_id` arrive from a JSON payload, so they are\n * UNTRUSTED INPUT being used to build a filesystem path: `../../../etc` must become a harmless name and\n * never escape the logs directory. Everything outside `[A-Za-z0-9._-]` collapses to `_`, a leading dot\n * is neutralised so nothing becomes a hidden file or `..`, and the result is capped and never empty.\n */\n// webpieces-disable no-function-outside-class -- pure string sanitiser, the module's own leaf helper beside the class it serves\nfunction segment(raw: string): string {\n const cleaned = raw\n .replace(/[^A-Za-z0-9._-]/g, '_') // kills every separator, so nothing can traverse\n .replace(/\\.{2,}/g, '_') // and no run of dots survives, so no segment reads as `..`\n .replace(/^\\.+/, '_') // nor becomes a hidden file\n .slice(0, 64);\n return cleaned === '' ? 'unknown' : cleaned;\n}\n\n/**\n * Process-wide instance. The hook adapters identify it once at the top of the invocation and every\n * writer downstream reads it, which is what keeps `logGuardDecision()` / `logRejection()` signatures\n * unchanged — the alternative was threading three more parameters through every call site.\n */\nexport const logStream = new LogStream();\n"]}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { StreamIdentity } from './log-stream';
|
|
1
2
|
export declare function resetMainSyncRefreshLatchForTest(): void;
|
|
3
|
+
/**
|
|
4
|
+
* The detached child's argv, spawner side. Positions here are offset by two from the child's
|
|
5
|
+
* `process.argv` (node + script path), so this array's 3/4/5 are the child's 4/5/6 — which is what
|
|
6
|
+
* `sync-main.ts:spawnerIdentity` reads. The two are pinned against each other by ONE test, because a
|
|
7
|
+
* silent disagreement about these positions IS the bug being fixed: the child previously received no
|
|
8
|
+
* identity at all and wrote every line to the shared `unknown-coordinator-hook-` stream.
|
|
9
|
+
*/
|
|
10
|
+
export declare function refresherArgv(refresher: string, workspaceRoot: string, hangTimeoutMinutes: number, identity: StreamIdentity): string[];
|
|
2
11
|
/**
|
|
3
12
|
* Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,
|
|
4
13
|
* not a bin). The child outlives this hook process (`detached` + `unref`), does the slow
|
|
@@ -10,5 +19,14 @@ export declare function resetMainSyncRefreshLatchForTest(): void;
|
|
|
10
19
|
* stdout/stderr are redirected to a sibling file (not
|
|
11
20
|
* /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows
|
|
12
21
|
* SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
|
|
22
|
+
*
|
|
23
|
+
* THE IDENTITY GOES ON ARGV, and that is what makes the sentence above true. The child is a separate
|
|
24
|
+
* node process whose `logStream` starts unidentified, so it used to write every line to the shared
|
|
25
|
+
* `unknown-coordinator-hook-guard-async-work.log` while this parent wrote SPAWN_ATTEMPT to its own
|
|
26
|
+
* prefixed stream. One cycle, two files — the SPAWN_ATTEMPT-without-START check then read as a
|
|
27
|
+
* failure on every cycle even when the child ran perfectly — and every agent's child appending to
|
|
28
|
+
* that one shared path is exactly the multi-writer tearing LogStream exists to remove. Passing
|
|
29
|
+
* session/agent/hook through (positional argv, alongside the root and timeout the child already
|
|
30
|
+
* takes) puts the whole cycle back in ONE file.
|
|
13
31
|
*/
|
|
14
32
|
export declare function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes?: number): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resetMainSyncRefreshLatchForTest = resetMainSyncRefreshLatchForTest;
|
|
4
|
+
exports.refresherArgv = refresherArgv;
|
|
4
5
|
exports.triggerMainSyncRefresh = triggerMainSyncRefresh;
|
|
5
6
|
const tslib_1 = require("tslib");
|
|
6
7
|
const child_process_1 = require("child_process");
|
|
@@ -9,6 +10,7 @@ const path = tslib_1.__importStar(require("path"));
|
|
|
9
10
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
10
11
|
const to_error_1 = require("./to-error");
|
|
11
12
|
const main_sync_log_1 = require("./main-sync-log");
|
|
13
|
+
const log_stream_1 = require("./log-stream");
|
|
12
14
|
// Per-process latch for the spawn below. A hook process handles exactly one tool call, so this makes
|
|
13
15
|
// the refresher at-most-once per tool call. Exported reset is test-only.
|
|
14
16
|
let alreadyTriggered = false;
|
|
@@ -16,6 +18,17 @@ let alreadyTriggered = false;
|
|
|
16
18
|
function resetMainSyncRefreshLatchForTest() {
|
|
17
19
|
alreadyTriggered = false;
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The detached child's argv, spawner side. Positions here are offset by two from the child's
|
|
23
|
+
* `process.argv` (node + script path), so this array's 3/4/5 are the child's 4/5/6 — which is what
|
|
24
|
+
* `sync-main.ts:spawnerIdentity` reads. The two are pinned against each other by ONE test, because a
|
|
25
|
+
* silent disagreement about these positions IS the bug being fixed: the child previously received no
|
|
26
|
+
* identity at all and wrote every line to the shared `unknown-coordinator-hook-` stream.
|
|
27
|
+
*/
|
|
28
|
+
// webpieces-disable no-function-outside-class -- argv builder for this module's spawn, matching its function shape
|
|
29
|
+
function refresherArgv(refresher, workspaceRoot, hangTimeoutMinutes, identity) {
|
|
30
|
+
return [refresher, workspaceRoot, String(hangTimeoutMinutes), identity.sessionId, identity.agentId, identity.hook];
|
|
31
|
+
}
|
|
19
32
|
/**
|
|
20
33
|
* Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,
|
|
21
34
|
* not a bin). The child outlives this hook process (`detached` + `unref`), does the slow
|
|
@@ -27,6 +40,15 @@ function resetMainSyncRefreshLatchForTest() {
|
|
|
27
40
|
* stdout/stderr are redirected to a sibling file (not
|
|
28
41
|
* /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows
|
|
29
42
|
* SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
|
|
43
|
+
*
|
|
44
|
+
* THE IDENTITY GOES ON ARGV, and that is what makes the sentence above true. The child is a separate
|
|
45
|
+
* node process whose `logStream` starts unidentified, so it used to write every line to the shared
|
|
46
|
+
* `unknown-coordinator-hook-guard-async-work.log` while this parent wrote SPAWN_ATTEMPT to its own
|
|
47
|
+
* prefixed stream. One cycle, two files — the SPAWN_ATTEMPT-without-START check then read as a
|
|
48
|
+
* failure on every cycle even when the child ran perfectly — and every agent's child appending to
|
|
49
|
+
* that one shared path is exactly the multi-writer tearing LogStream exists to remove. Passing
|
|
50
|
+
* session/agent/hook through (positional argv, alongside the root and timeout the child already
|
|
51
|
+
* takes) puts the whole cycle back in ONE file.
|
|
30
52
|
*/
|
|
31
53
|
function triggerMainSyncRefresh(workspaceRoot, hangTimeoutMinutes = rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES) {
|
|
32
54
|
// ONE refresher per hook process. Several call sites fire this on a single tool call — the Read
|
|
@@ -45,7 +67,7 @@ function triggerMainSyncRefresh(workspaceRoot, hangTimeoutMinutes = rules_config
|
|
|
45
67
|
// Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash
|
|
46
68
|
// before the child's own logging — e.g. a module-load failure — is still captured.
|
|
47
69
|
const errFd = fs.openSync((0, main_sync_log_1.syncStderrLogPath)(workspaceRoot), 'a');
|
|
48
|
-
const child = (0, child_process_1.spawn)(process.execPath,
|
|
70
|
+
const child = (0, child_process_1.spawn)(process.execPath, refresherArgv(refresher, workspaceRoot, hangTimeoutMinutes, log_stream_1.logStream.identity()), {
|
|
49
71
|
detached: true,
|
|
50
72
|
stdio: ['ignore', errFd, errFd],
|
|
51
73
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;AAeA,4EAEC;AAUD,sCAEC;AAuBD,wDAmCC;;AAvFD,iDAAsC;AACtC,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAuE;AAEvE,yCAAqC;AACrC,mDAAgF;AAChF,6CAAyD;AAEzD,qGAAqG;AACrG,yEAAyE;AACzE,IAAI,gBAAgB,GAAG,KAAK,CAAC;AAE7B,8GAA8G;AAC9G,SAAgB,gCAAgC;IAC5C,gBAAgB,GAAG,KAAK,CAAC;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,mHAAmH;AACnH,SAAgB,aAAa,CAAC,SAAiB,EAAE,aAAqB,EAAE,kBAA0B,EAAE,QAAwB;IACxH,OAAO,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;AACvH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,qBAA6B,2CAA4B;IACnH,gGAAgG;IAChG,mGAAmG;IACnG,mGAAmG;IACnG,2FAA2F;IAC3F,wFAAwF;IACxF,IAAI,gBAAgB;QAAE,OAAO;IAC7B,gBAAgB,GAAG,IAAI,CAAC;IAExB,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,2FAA2F;QAC3F,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,eAAe,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,SAAS,EAAE,CAAC,CAAC,CAAC;QAE3G,6FAA6F;QAC7F,mFAAmF;QACnF,MAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAA,iCAAiB,EAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,SAAS,EAAE,aAAa,EAAE,kBAAkB,EAAE,sBAAS,CAAC,QAAQ,EAAE,CAAC,EAAE;YACrH,QAAQ,EAAE,IAAI;YACd,KAAK,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;SAClC,CAAC,CAAC;QACH,8FAA8F;QAC9F,wFAAwF;QACxF,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAU,EAAQ,EAAE;YACrC,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,iBAAiB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACjH,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,mFAAmF;QACnF,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACxB,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,0EAA0E;IAC9E,CAAC;AACL,CAAC","sourcesContent":["import { spawn } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent, syncStderrLogPath } from './main-sync-log';\nimport { logStream, StreamIdentity } from './log-stream';\n\n// Per-process latch for the spawn below. A hook process handles exactly one tool call, so this makes\n// the refresher at-most-once per tool call. Exported reset is test-only.\nlet alreadyTriggered = false;\n\n// webpieces-disable no-function-outside-class -- test-only latch reset, matching this module's function shape\nexport function resetMainSyncRefreshLatchForTest(): void {\n alreadyTriggered = false;\n}\n\n/**\n * The detached child's argv, spawner side. Positions here are offset by two from the child's\n * `process.argv` (node + script path), so this array's 3/4/5 are the child's 4/5/6 — which is what\n * `sync-main.ts:spawnerIdentity` reads. The two are pinned against each other by ONE test, because a\n * silent disagreement about these positions IS the bug being fixed: the child previously received no\n * identity at all and wrote every line to the shared `unknown-coordinator-hook-` stream.\n */\n// webpieces-disable no-function-outside-class -- argv builder for this module's spawn, matching its function shape\nexport function refresherArgv(refresher: string, workspaceRoot: string, hangTimeoutMinutes: number, identity: StreamIdentity): string[] {\n return [refresher, workspaceRoot, String(hangTimeoutMinutes), identity.sessionId, identity.agentId, identity.hook];\n}\n\n/**\n * Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,\n * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow\n * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on\n * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.\n *\n * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to\n * `.webpieces/logs/<stream>guard-async-work.log` (LogStream prefixes every name). The child's\n * stdout/stderr are redirected to a sibling file (not\n * /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows\n * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.\n *\n * THE IDENTITY GOES ON ARGV, and that is what makes the sentence above true. The child is a separate\n * node process whose `logStream` starts unidentified, so it used to write every line to the shared\n * `unknown-coordinator-hook-guard-async-work.log` while this parent wrote SPAWN_ATTEMPT to its own\n * prefixed stream. One cycle, two files — the SPAWN_ATTEMPT-without-START check then read as a\n * failure on every cycle even when the child ran perfectly — and every agent's child appending to\n * that one shared path is exactly the multi-writer tearing LogStream exists to remove. Passing\n * session/agent/hook through (positional argv, alongside the root and timeout the child already\n * takes) puts the whole cycle back in ONE file.\n */\nexport function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes: number = DEFAULT_HANG_TIMEOUT_MINUTES): void {\n // ONE refresher per hook process. Several call sites fire this on a single tool call — the Read\n // fast path in hook-core AND read-stale-guard's own check(), for one — which is why the log showed\n // two SPAWN_ATTEMPTs ~20ms apart from the same pid on every cycle. The loser only ever reached the\n // lock and exited, so the second child was pure waste (and one more `git fetch` racing the\n // agent's). The child's lock still guards against refreshers from OTHER hook processes.\n if (alreadyTriggered) return;\n alreadyTriggered = true;\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const refresher = path.join(__dirname, 'sync-main.js');\n // SPAWN_ATTEMPT first — this also creates .webpieces/logs so the stderr fd below can open.\n logSyncEvent(workspaceRoot, new SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));\n\n // Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash\n // before the child's own logging — e.g. a module-load failure — is still captured.\n const errFd = fs.openSync(syncStderrLogPath(workspaceRoot), 'a');\n const child = spawn(process.execPath, refresherArgv(refresher, workspaceRoot, hangTimeoutMinutes, logStream.identity()), {\n detached: true,\n stdio: ['ignore', errFd, errFd],\n });\n // spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit\n // before this handler runs, but on POSIX a successful exec has already happened by now.\n child.once('error', (err: Error): void => {\n logSyncEvent(workspaceRoot, new SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));\n });\n child.unref();\n // The child has its own dup'd copy of the fd after spawn; close the parent's copy.\n fs.closeSync(errFd);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Spawning the background refresh must never block or fail the tool call.\n }\n}\n"]}
|
|
@@ -7,11 +7,34 @@ const fs = tslib_1.__importStar(require("fs"));
|
|
|
7
7
|
const path = tslib_1.__importStar(require("path"));
|
|
8
8
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
9
9
|
const log_stream_1 = require("./log-stream");
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
const to_error_1 = require("./to-error");
|
|
11
|
+
/**
|
|
12
|
+
* The rejection index and its detail files, BOTH under `logs/`, both named by the same stream:
|
|
13
|
+
*
|
|
14
|
+
* logs/<sid>-<agent>-<hook>-hook-rejection.log ← the index (one line per block)
|
|
15
|
+
* logs/<sid>-<agent>-<hook>-hook-rejection/ ← its detail dir, SAME base name
|
|
16
|
+
* writeInfo-<epochMs>.md
|
|
17
|
+
*
|
|
18
|
+
* The detail files used to be `hooks/<YYYY-MM-DD>/writeInfo-<epochMs>.md` — a directory keyed only by
|
|
19
|
+
* the DATE, shared by every writer in the tree, written with a raw `writeFileSync`. Two agents blocked
|
|
20
|
+
* in the same millisecond produced the same path and one silently overwrote the other's evidence. The
|
|
21
|
+
* comment that licensed it claimed a per-worktree log has exactly one writer; LogStream's class
|
|
22
|
+
* comment is the measurement that disproves it (parallel PreToolUse hooks, non-isolated subagents and
|
|
23
|
+
* multiple Claude Code windows all share a tree). Naming the directory after the LOG THAT POINTS AT IT
|
|
24
|
+
* gives it the same one-owner-by-construction property the log itself has, and makes the pointer
|
|
25
|
+
* readable: the index line, the directory and the file are one name.
|
|
26
|
+
*
|
|
27
|
+
* RETENTION is therefore filename-based, not directory-date-based: there is no date level left, so
|
|
28
|
+
* `DetailPruner` parses `<epochMs>` out of each `writeInfo-*.md` (no `stat` call) and removes any
|
|
29
|
+
* stream directory left empty. Every delete is `force: true` and failures are swallowed, so concurrent
|
|
30
|
+
* agents racing the same week-old files is harmless.
|
|
31
|
+
*/
|
|
32
|
+
const LOG_BASE = 'hook-rejection';
|
|
33
|
+
const LOG_FILE = `${LOG_BASE}.log`;
|
|
34
|
+
const LOG_FILE_PREV = `${LOG_BASE}.1.log`;
|
|
35
|
+
const DETAIL_PREFIX = 'writeInfo-';
|
|
36
|
+
const DETAIL_SUFFIX = '.md';
|
|
37
|
+
const DETAIL_RE = /^writeInfo-(\d+)\.md$/;
|
|
15
38
|
const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded
|
|
16
39
|
const MAX_AGE_DAYS = 7;
|
|
17
40
|
const RULE_NAME_RE = /^\[([^\]]+)\] \(/gm;
|
|
@@ -24,23 +47,20 @@ function logRejection(toolKind, input, result, cwd) {
|
|
|
24
47
|
const now = new Date();
|
|
25
48
|
const timestamp = now.toISOString();
|
|
26
49
|
const epochMs = String(now.getTime());
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
// writer, so its appends and its daily detail files cannot collide with another agent's.
|
|
30
|
-
const hooksDir = rules_config_1.dotWebpieces.localFile(root, rules_config_1.HOOKS_STATE_DIR);
|
|
50
|
+
// LOCAL scope — a rejection is this worktree's event. WHICH writer's event is answered by the
|
|
51
|
+
// stream prefix, on the index AND on its detail directory, so neither can collide.
|
|
31
52
|
const logsDir = rules_config_1.dotWebpieces.logs(root);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
fs.mkdirSync(
|
|
53
|
+
const detailDirName = log_stream_1.logStream.fileName(LOG_BASE);
|
|
54
|
+
const detailDir = path.join(logsDir, detailDirName);
|
|
55
|
+
fs.mkdirSync(detailDir, { recursive: true });
|
|
35
56
|
const relativePath = computeRelativePath(input.filePath, root);
|
|
36
57
|
const ruleNames = extractRuleNames(result.report);
|
|
37
|
-
const detailFileName =
|
|
38
|
-
// Relative to
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const detailRelPath = `${rules_config_1.HOOKS_STATE_DIR}/${dateStr}/${detailFileName}`;
|
|
58
|
+
const detailFileName = `${DETAIL_PREFIX}${epochMs}${DETAIL_SUFFIX}`;
|
|
59
|
+
// Relative to `logs/` — the index's own directory — so the pointer resolves from exactly where
|
|
60
|
+
// the reader found the line, and reads as the log's own name plus a file.
|
|
61
|
+
const detailRelPath = `${detailDirName}/${detailFileName}`;
|
|
42
62
|
const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);
|
|
43
|
-
fs.writeFileSync(path.join(
|
|
63
|
+
fs.writeFileSync(path.join(detailDir, detailFileName), detail);
|
|
44
64
|
const logPath = path.join(logsDir, log_stream_1.logStream.fileName(LOG_FILE));
|
|
45
65
|
rotateLogFile(logPath, path.join(logsDir, log_stream_1.logStream.fileName(LOG_FILE_PREV)));
|
|
46
66
|
// APPEND-ONLY, and `fault=` is spelled exactly as the L0 sh log spells it. A fault-S storm
|
|
@@ -49,7 +69,7 @@ function logRejection(toolKind, input, result, cwd) {
|
|
|
49
69
|
// the filename, which logStream prefixes with session/agent/hook — not by a column here.)
|
|
50
70
|
const logLine = `[${timestamp}]\t${toolKind}\t${relativePath}\t[${ruleNames.join(',')}]\t${detailRelPath}\tfault=${result.fault}\n`;
|
|
51
71
|
fs.appendFileSync(logPath, logLine);
|
|
52
|
-
|
|
72
|
+
detailPruner.prune(logsDir, MAX_AGE_DAYS);
|
|
53
73
|
}
|
|
54
74
|
catch (err) {
|
|
55
75
|
//const error = toError(err);
|
|
@@ -139,34 +159,94 @@ function rotateLogFile(logPath, prevPath) {
|
|
|
139
159
|
void err;
|
|
140
160
|
}
|
|
141
161
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
162
|
+
/**
|
|
163
|
+
* The 7-day sweep over rejection DETAIL files.
|
|
164
|
+
*
|
|
165
|
+
* A class rather than four module-scope helpers because the four belong together: `prune` is the
|
|
166
|
+
* policy and the other three are the swallow-everything filesystem calls it is built from, none of
|
|
167
|
+
* which means anything on its own.
|
|
168
|
+
*
|
|
169
|
+
* The age comes out of the FILENAME, not out of `stat` — the epoch millis are already in the name, so
|
|
170
|
+
* a sweep costs one `readdir` per stream directory and no syscall per file. (The old scheme could
|
|
171
|
+
* delete a whole `hooks/<YYYY-MM-DD>/` in one recursive `rm` because the date WAS the directory; the
|
|
172
|
+
* directory is now the WRITER, which never expires, so expiry moved down one level.)
|
|
173
|
+
*
|
|
174
|
+
* IDEMPOTENT AND RACE-TOLERANT ON PURPOSE. Several agents sweep the same directory concurrently, so
|
|
175
|
+
* two processes will regularly try to unlink the same week-old file and to rmdir the same emptied
|
|
176
|
+
* directory. `force: true` makes an already-gone target a no-op, an rmdir of a still-populated (or
|
|
177
|
+
* already-removed) directory fails harmlessly, and every error is swallowed: the loser of the race
|
|
178
|
+
* does nothing and reports nothing, which is the correct outcome — the file is gone either way.
|
|
179
|
+
*/
|
|
180
|
+
class DetailPruner {
|
|
181
|
+
prune(logsDir, maxAgeDays) {
|
|
182
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
183
|
+
for (const dirName of this.readdirOrEmpty(logsDir)) {
|
|
184
|
+
const dir = path.join(logsDir, dirName);
|
|
185
|
+
if (!this.isDirectory(dir))
|
|
186
|
+
continue;
|
|
187
|
+
const names = this.readdirOrEmpty(dir);
|
|
188
|
+
let removed = 0;
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const match = DETAIL_RE.exec(name);
|
|
191
|
+
if (match === null)
|
|
192
|
+
continue;
|
|
193
|
+
if (Number(match[1]) >= cutoff)
|
|
194
|
+
continue;
|
|
195
|
+
this.removeFile(path.join(dir, name));
|
|
196
|
+
removed += 1;
|
|
197
|
+
}
|
|
198
|
+
// Only a directory we just emptied is a candidate, and the removal is a NON-recursive rmdir
|
|
199
|
+
// on purpose: it fails harmlessly if a concurrent writer dropped a fresh detail in between,
|
|
200
|
+
// so the sweep can never take a file it did not decide was expired.
|
|
201
|
+
if (removed > 0 && removed === names.length)
|
|
202
|
+
this.removeEmptyDir(dir);
|
|
203
|
+
}
|
|
148
204
|
}
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
205
|
+
readdirOrEmpty(dir) {
|
|
206
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
207
|
+
try {
|
|
208
|
+
return fs.readdirSync(dir);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const error = (0, to_error_1.toError)(err);
|
|
212
|
+
void error;
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
153
215
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
216
|
+
isDirectory(target) {
|
|
217
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
218
|
+
try {
|
|
219
|
+
return fs.statSync(target).isDirectory();
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
const error = (0, to_error_1.toError)(err);
|
|
223
|
+
void error;
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// `force: true` is what makes the concurrent sweep harmless: a file another agent already deleted
|
|
228
|
+
// is a no-op rather than an ENOENT.
|
|
229
|
+
removeFile(target) {
|
|
230
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
231
|
+
try {
|
|
232
|
+
fs.rmSync(target, { force: true });
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
const error = (0, to_error_1.toError)(err);
|
|
236
|
+
void error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// NON-recursive by design — see prune(). This must refuse a directory that is not empty.
|
|
240
|
+
removeEmptyDir(target) {
|
|
241
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
242
|
+
try {
|
|
243
|
+
fs.rmdirSync(target);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
const error = (0, to_error_1.toError)(err);
|
|
247
|
+
void error;
|
|
169
248
|
}
|
|
170
249
|
}
|
|
171
250
|
}
|
|
251
|
+
const detailPruner = new DetailPruner();
|
|
172
252
|
//# sourceMappingURL=rejection-log.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rejection-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/rejection-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, RepoRootFinder, HOOKS_STATE_DIR } from '@webpieces/rules-config';\n\nimport type { ToolKind, NormalizedToolInput, BlockedResult } from './types';\nimport { logStream } from './log-stream';\n\n// The rejection log SPLITS across the two state dirs on purpose: the `.log` index goes to `logs/`\n// with every other webpieces log, while the dated `hooks/<YYYY-MM-DD>/writeInfo-*.md` DETAIL files\n// are not logs and stay in `hooks/` (see LOGS_STATE_DIR).\nconst LOG_FILE = 'hook-rejection.log';\nconst LOG_FILE_PREV = 'hook-rejection.1.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded\nconst MAX_AGE_DAYS = 7;\n\nconst RULE_NAME_RE = /^\\[([^\\]]+)\\] \\(/gm;\n\nexport function logRejection(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n result: BlockedResult,\n cwd: string,\n): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // `.webpieces/` lives at the repo root, NOT the AI's cwd — resolve it so a hook fired while\n // the AI is in a subdirectory never scatters a stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const now = new Date();\n const timestamp = now.toISOString();\n const epochMs = String(now.getTime());\n const dateStr = timestamp.slice(0, 10);\n\n // LOCAL scope — a rejection is this worktree's event, and a per-worktree log has exactly one\n // writer, so its appends and its daily detail files cannot collide with another agent's.\n const hooksDir = dotWebpieces.localFile(root, HOOKS_STATE_DIR);\n const logsDir = dotWebpieces.logs(root);\n const dayDir = path.join(hooksDir, dateStr);\n fs.mkdirSync(dayDir, { recursive: true });\n fs.mkdirSync(logsDir, { recursive: true });\n\n const relativePath = computeRelativePath(input.filePath, root);\n const ruleNames = extractRuleNames(result.report);\n const detailFileName = `writeInfo-${epochMs}.md`;\n // Relative to the STATE DIR, not to the log file's own directory: the index now lives in\n // `logs/` while the detail lives in `hooks/<date>/`, so a bare `<date>/<file>` would no\n // longer resolve from where the reader found the line.\n const detailRelPath = `${HOOKS_STATE_DIR}/${dateStr}/${detailFileName}`;\n\n const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);\n fs.writeFileSync(path.join(dayDir, detailFileName), detail);\n\n const logPath = path.join(logsDir, logStream.fileName(LOG_FILE));\n rotateLogFile(logPath, path.join(logsDir, logStream.fileName(LOG_FILE_PREV)));\n\n // APPEND-ONLY, and `fault=` is spelled exactly as the L0 sh log spells it. A fault-S storm\n // previously landed here as a couple of lines attributed to whatever rule the report happened\n // to cite, with nothing anywhere identifying L0 as the cause. (WHO made the call is answered by\n // the filename, which logStream prefixes with session/agent/hook — not by a column here.)\n const logLine = `[${timestamp}]\\t${toolKind}\\t${relativePath}\\t[${ruleNames.join(',')}]\\t${detailRelPath}\\tfault=${result.fault}\\n`;\n fs.appendFileSync(logPath, logLine);\n\n rotateOldDays(hooksDir, MAX_AGE_DAYS);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n}\n\nfunction computeRelativePath(filePath: string, cwd: string): string {\n if (filePath.startsWith(cwd)) {\n const rel = filePath.slice(cwd.length);\n if (rel.startsWith('/')) return rel.slice(1);\n return rel;\n }\n return filePath;\n}\n\n/**\n * The rule names a block report cites — every `[<rule-name>] (` header it opens with. Exported because\n * two audit streams need the same answer from the same regex: this file's rejection index, and the\n * `rule=` field guard-invocations.log now carries (see InvocationLog.finish). Two scrapers would be\n * two answers to one question.\n */\n// webpieces-disable no-function-outside-class -- pure regex scraper beside this module's other module-scope helpers; exported so the invocation log and the rejection index scrape rule names with the SAME code.\nexport function extractRuleNames(report: string): string[] {\n const names: string[] = [];\n let match = RULE_NAME_RE.exec(report);\n while (match !== null) {\n names.push(match[1]);\n match = RULE_NAME_RE.exec(report);\n }\n RULE_NAME_RE.lastIndex = 0;\n return names;\n}\n\nfunction buildDetailContent(\n timestamp: string,\n toolKind: ToolKind,\n relativePath: string,\n ruleNames: string[],\n report: string,\n input: NormalizedToolInput,\n): string {\n const lines: string[] = [];\n lines.push('# Hook Rejection Detail');\n lines.push('');\n lines.push(`- **Timestamp:** ${timestamp}`);\n lines.push(`- **Tool:** ${toolKind}`);\n lines.push(`- **File:** ${relativePath}`);\n lines.push(`- **Rules violated:** ${ruleNames.join(', ')}`);\n lines.push('');\n lines.push('## Report');\n lines.push('');\n lines.push('```');\n lines.push(report.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('## Content Being Written');\n lines.push('');\n\n if (toolKind === 'Write') {\n const content = input.edits.length > 0 ? input.edits[0].newString : '';\n lines.push('```typescript');\n lines.push(content.trimEnd());\n lines.push('```');\n } else {\n for (let i = 0; i < input.edits.length; i += 1) {\n const edit = input.edits[i];\n lines.push(`### Edit ${String(i + 1)} of ${String(input.edits.length)}`);\n lines.push('');\n lines.push('**old_string:**');\n lines.push('```typescript');\n lines.push(edit.oldString.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('**new_string:**');\n lines.push('```typescript');\n lines.push(edit.newString.trimEnd());\n lines.push('```');\n lines.push('');\n }\n }\n\n return lines.join('\\n') + '\\n';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n}\n\nfunction rotateOldDays(hooksDir: string, maxAgeDays: number): void {\n const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(hooksDir);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n return;\n }\n\n for (const entry of entries) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(entry)) continue;\n const dirDate = new Date(entry + 'T00:00:00Z');\n if (isNaN(dirDate.getTime())) continue;\n if (dirDate.getTime() < cutoff) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(path.join(hooksDir, entry), { recursive: true, force: true });\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"rejection-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/rejection-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, RepoRootFinder } from '@webpieces/rules-config';\n\nimport type { ToolKind, NormalizedToolInput, BlockedResult } from './types';\nimport { logStream } from './log-stream';\nimport { toError } from './to-error';\n\n/**\n * The rejection index and its detail files, BOTH under `logs/`, both named by the same stream:\n *\n * logs/<sid>-<agent>-<hook>-hook-rejection.log ← the index (one line per block)\n * logs/<sid>-<agent>-<hook>-hook-rejection/ ← its detail dir, SAME base name\n * writeInfo-<epochMs>.md\n *\n * The detail files used to be `hooks/<YYYY-MM-DD>/writeInfo-<epochMs>.md` — a directory keyed only by\n * the DATE, shared by every writer in the tree, written with a raw `writeFileSync`. Two agents blocked\n * in the same millisecond produced the same path and one silently overwrote the other's evidence. The\n * comment that licensed it claimed a per-worktree log has exactly one writer; LogStream's class\n * comment is the measurement that disproves it (parallel PreToolUse hooks, non-isolated subagents and\n * multiple Claude Code windows all share a tree). Naming the directory after the LOG THAT POINTS AT IT\n * gives it the same one-owner-by-construction property the log itself has, and makes the pointer\n * readable: the index line, the directory and the file are one name.\n *\n * RETENTION is therefore filename-based, not directory-date-based: there is no date level left, so\n * `DetailPruner` parses `<epochMs>` out of each `writeInfo-*.md` (no `stat` call) and removes any\n * stream directory left empty. Every delete is `force: true` and failures are swallowed, so concurrent\n * agents racing the same week-old files is harmless.\n */\nconst LOG_BASE = 'hook-rejection';\nconst LOG_FILE = `${LOG_BASE}.log`;\nconst LOG_FILE_PREV = `${LOG_BASE}.1.log`;\nconst DETAIL_PREFIX = 'writeInfo-';\nconst DETAIL_SUFFIX = '.md';\nconst DETAIL_RE = /^writeInfo-(\\d+)\\.md$/;\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded\nconst MAX_AGE_DAYS = 7;\n\nconst RULE_NAME_RE = /^\\[([^\\]]+)\\] \\(/gm;\n\nexport function logRejection(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n result: BlockedResult,\n cwd: string,\n): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // `.webpieces/` lives at the repo root, NOT the AI's cwd — resolve it so a hook fired while\n // the AI is in a subdirectory never scatters a stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const now = new Date();\n const timestamp = now.toISOString();\n const epochMs = String(now.getTime());\n\n // LOCAL scope — a rejection is this worktree's event. WHICH writer's event is answered by the\n // stream prefix, on the index AND on its detail directory, so neither can collide.\n const logsDir = dotWebpieces.logs(root);\n const detailDirName = logStream.fileName(LOG_BASE);\n const detailDir = path.join(logsDir, detailDirName);\n fs.mkdirSync(detailDir, { recursive: true });\n\n const relativePath = computeRelativePath(input.filePath, root);\n const ruleNames = extractRuleNames(result.report);\n const detailFileName = `${DETAIL_PREFIX}${epochMs}${DETAIL_SUFFIX}`;\n // Relative to `logs/` — the index's own directory — so the pointer resolves from exactly where\n // the reader found the line, and reads as the log's own name plus a file.\n const detailRelPath = `${detailDirName}/${detailFileName}`;\n\n const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);\n fs.writeFileSync(path.join(detailDir, detailFileName), detail);\n\n const logPath = path.join(logsDir, logStream.fileName(LOG_FILE));\n rotateLogFile(logPath, path.join(logsDir, logStream.fileName(LOG_FILE_PREV)));\n\n // APPEND-ONLY, and `fault=` is spelled exactly as the L0 sh log spells it. A fault-S storm\n // previously landed here as a couple of lines attributed to whatever rule the report happened\n // to cite, with nothing anywhere identifying L0 as the cause. (WHO made the call is answered by\n // the filename, which logStream prefixes with session/agent/hook — not by a column here.)\n const logLine = `[${timestamp}]\\t${toolKind}\\t${relativePath}\\t[${ruleNames.join(',')}]\\t${detailRelPath}\\tfault=${result.fault}\\n`;\n fs.appendFileSync(logPath, logLine);\n\n detailPruner.prune(logsDir, MAX_AGE_DAYS);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n}\n\nfunction computeRelativePath(filePath: string, cwd: string): string {\n if (filePath.startsWith(cwd)) {\n const rel = filePath.slice(cwd.length);\n if (rel.startsWith('/')) return rel.slice(1);\n return rel;\n }\n return filePath;\n}\n\n/**\n * The rule names a block report cites — every `[<rule-name>] (` header it opens with. Exported because\n * two audit streams need the same answer from the same regex: this file's rejection index, and the\n * `rule=` field guard-invocations.log now carries (see InvocationLog.finish). Two scrapers would be\n * two answers to one question.\n */\n// webpieces-disable no-function-outside-class -- pure regex scraper beside this module's other module-scope helpers; exported so the invocation log and the rejection index scrape rule names with the SAME code.\nexport function extractRuleNames(report: string): string[] {\n const names: string[] = [];\n let match = RULE_NAME_RE.exec(report);\n while (match !== null) {\n names.push(match[1]);\n match = RULE_NAME_RE.exec(report);\n }\n RULE_NAME_RE.lastIndex = 0;\n return names;\n}\n\nfunction buildDetailContent(\n timestamp: string,\n toolKind: ToolKind,\n relativePath: string,\n ruleNames: string[],\n report: string,\n input: NormalizedToolInput,\n): string {\n const lines: string[] = [];\n lines.push('# Hook Rejection Detail');\n lines.push('');\n lines.push(`- **Timestamp:** ${timestamp}`);\n lines.push(`- **Tool:** ${toolKind}`);\n lines.push(`- **File:** ${relativePath}`);\n lines.push(`- **Rules violated:** ${ruleNames.join(', ')}`);\n lines.push('');\n lines.push('## Report');\n lines.push('');\n lines.push('```');\n lines.push(report.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('## Content Being Written');\n lines.push('');\n\n if (toolKind === 'Write') {\n const content = input.edits.length > 0 ? input.edits[0].newString : '';\n lines.push('```typescript');\n lines.push(content.trimEnd());\n lines.push('```');\n } else {\n for (let i = 0; i < input.edits.length; i += 1) {\n const edit = input.edits[i];\n lines.push(`### Edit ${String(i + 1)} of ${String(input.edits.length)}`);\n lines.push('');\n lines.push('**old_string:**');\n lines.push('```typescript');\n lines.push(edit.oldString.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('**new_string:**');\n lines.push('```typescript');\n lines.push(edit.newString.trimEnd());\n lines.push('```');\n lines.push('');\n }\n }\n\n return lines.join('\\n') + '\\n';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n}\n\n/**\n * The 7-day sweep over rejection DETAIL files.\n *\n * A class rather than four module-scope helpers because the four belong together: `prune` is the\n * policy and the other three are the swallow-everything filesystem calls it is built from, none of\n * which means anything on its own.\n *\n * The age comes out of the FILENAME, not out of `stat` — the epoch millis are already in the name, so\n * a sweep costs one `readdir` per stream directory and no syscall per file. (The old scheme could\n * delete a whole `hooks/<YYYY-MM-DD>/` in one recursive `rm` because the date WAS the directory; the\n * directory is now the WRITER, which never expires, so expiry moved down one level.)\n *\n * IDEMPOTENT AND RACE-TOLERANT ON PURPOSE. Several agents sweep the same directory concurrently, so\n * two processes will regularly try to unlink the same week-old file and to rmdir the same emptied\n * directory. `force: true` makes an already-gone target a no-op, an rmdir of a still-populated (or\n * already-removed) directory fails harmlessly, and every error is swallowed: the loser of the race\n * does nothing and reports nothing, which is the correct outcome — the file is gone either way.\n */\nclass DetailPruner {\n prune(logsDir: string, maxAgeDays: number): void {\n const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;\n for (const dirName of this.readdirOrEmpty(logsDir)) {\n const dir = path.join(logsDir, dirName);\n if (!this.isDirectory(dir)) continue;\n const names = this.readdirOrEmpty(dir);\n let removed = 0;\n for (const name of names) {\n const match = DETAIL_RE.exec(name);\n if (match === null) continue;\n if (Number(match[1]) >= cutoff) continue;\n this.removeFile(path.join(dir, name));\n removed += 1;\n }\n // Only a directory we just emptied is a candidate, and the removal is a NON-recursive rmdir\n // on purpose: it fails harmlessly if a concurrent writer dropped a fresh detail in between,\n // so the sweep can never take a file it did not decide was expired.\n if (removed > 0 && removed === names.length) this.removeEmptyDir(dir);\n }\n }\n\n private readdirOrEmpty(dir: string): string[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.readdirSync(dir);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return [];\n }\n }\n\n private isDirectory(target: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.statSync(target).isDirectory();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // `force: true` is what makes the concurrent sweep harmless: a file another agent already deleted\n // is a no-op rather than an ENOENT.\n private removeFile(target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(target, { force: true });\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // NON-recursive by design — see prune(). This must refuse a directory that is not empty.\n private removeEmptyDir(target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmdirSync(target);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\nconst detailPruner = new DetailPruner();\n"]}
|
package/src/core/sync-main.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StreamIdentity } from './log-stream';
|
|
1
2
|
/**
|
|
2
3
|
* The detached, fire-and-forget refresher spawned (by file path, not a bin) from
|
|
3
4
|
* main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +
|
|
@@ -9,9 +10,26 @@
|
|
|
9
10
|
* it and is alive, we exit immediately (don't pile up `git fetch`es). If the holder is finished, dead,
|
|
10
11
|
* or older than hangTimeoutMinutes, the lock is reclaimed and we proceed.
|
|
11
12
|
*
|
|
12
|
-
* argv: [, , repoRoot, hangTimeoutMinutes]
|
|
13
|
+
* argv: [, , repoRoot, hangTimeoutMinutes, sessionId, agentId, hook]
|
|
14
|
+
*
|
|
15
|
+
* The last three are the SPAWNER's LogStream identity, and adopting them here is the whole reason
|
|
16
|
+
* they are passed. This process has its own fresh module-level `logStream`; left unidentified it
|
|
17
|
+
* named every line `unknown-coordinator-hook-guard-async-work.log` — one shared file that every
|
|
18
|
+
* agent's refresher child appended to concurrently (the PIPE_BUF tearing LogStream exists to remove),
|
|
19
|
+
* and which held this run's START/FINISH while the parent's SPAWN_ATTEMPT sat in a different file
|
|
20
|
+
* entirely. Adopting the parent's identity puts one refresh cycle back in one stream. Absent argv
|
|
21
|
+
* (a hand-run of this file) leaves the default, which still prefixes.
|
|
13
22
|
*/
|
|
14
23
|
export declare function main(): void;
|
|
24
|
+
/**
|
|
25
|
+
* The spawner's LogStream identity as it arrives on this child's argv — positions 4/5/6, which is
|
|
26
|
+
* where `triggerMainSyncRefresh` puts them (its own array is offset by two: node + script path).
|
|
27
|
+
*
|
|
28
|
+
* Exported so ONE test can pin BOTH ends of the contract at once. The bug this closes is precisely a
|
|
29
|
+
* disagreement between two files about argv positions, and a test that only checks the reader would
|
|
30
|
+
* have stayed green through it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function spawnerIdentity(argv: string[]): StreamIdentity;
|
|
15
33
|
/**
|
|
16
34
|
* The refresh itself, argv-free so it can be driven by a test. `main()` is only the argv parse — a
|
|
17
35
|
* test importing another module's `main` is what no-process-exit-outside-main exists to prevent.
|
package/src/core/sync-main.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.main = main;
|
|
4
|
+
exports.spawnerIdentity = spawnerIdentity;
|
|
4
5
|
exports.refreshMainSync = refreshMainSync;
|
|
5
6
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
6
7
|
const to_error_1 = require("./to-error");
|
|
7
8
|
const main_sync_log_1 = require("./main-sync-log");
|
|
9
|
+
const log_stream_1 = require("./log-stream");
|
|
8
10
|
/**
|
|
9
11
|
* The detached, fire-and-forget refresher spawned (by file path, not a bin) from
|
|
10
12
|
* main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +
|
|
@@ -16,11 +18,32 @@ const main_sync_log_1 = require("./main-sync-log");
|
|
|
16
18
|
* it and is alive, we exit immediately (don't pile up `git fetch`es). If the holder is finished, dead,
|
|
17
19
|
* or older than hangTimeoutMinutes, the lock is reclaimed and we proceed.
|
|
18
20
|
*
|
|
19
|
-
* argv: [, , repoRoot, hangTimeoutMinutes]
|
|
21
|
+
* argv: [, , repoRoot, hangTimeoutMinutes, sessionId, agentId, hook]
|
|
22
|
+
*
|
|
23
|
+
* The last three are the SPAWNER's LogStream identity, and adopting them here is the whole reason
|
|
24
|
+
* they are passed. This process has its own fresh module-level `logStream`; left unidentified it
|
|
25
|
+
* named every line `unknown-coordinator-hook-guard-async-work.log` — one shared file that every
|
|
26
|
+
* agent's refresher child appended to concurrently (the PIPE_BUF tearing LogStream exists to remove),
|
|
27
|
+
* and which held this run's START/FINISH while the parent's SPAWN_ATTEMPT sat in a different file
|
|
28
|
+
* entirely. Adopting the parent's identity puts one refresh cycle back in one stream. Absent argv
|
|
29
|
+
* (a hand-run of this file) leaves the default, which still prefixes.
|
|
20
30
|
*/
|
|
21
31
|
function main() {
|
|
32
|
+
log_stream_1.logStream.identify(spawnerIdentity(process.argv));
|
|
22
33
|
refreshMainSync(process.argv[2] ?? process.cwd(), Number(process.argv[3]) || rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES, process.argv.slice(2).join(' '));
|
|
23
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The spawner's LogStream identity as it arrives on this child's argv — positions 4/5/6, which is
|
|
37
|
+
* where `triggerMainSyncRefresh` puts them (its own array is offset by two: node + script path).
|
|
38
|
+
*
|
|
39
|
+
* Exported so ONE test can pin BOTH ends of the contract at once. The bug this closes is precisely a
|
|
40
|
+
* disagreement between two files about argv positions, and a test that only checks the reader would
|
|
41
|
+
* have stayed green through it.
|
|
42
|
+
*/
|
|
43
|
+
// webpieces-disable no-function-outside-class -- argv parse for this detached main(), matching the file's existing shape
|
|
44
|
+
function spawnerIdentity(argv) {
|
|
45
|
+
return new log_stream_1.StreamIdentity(argv[4] ?? '', argv[5] ?? '', argv[6] ?? 'hook');
|
|
46
|
+
}
|
|
24
47
|
/**
|
|
25
48
|
* The refresh itself, argv-free so it can be driven by a test. `main()` is only the argv parse — a
|
|
26
49
|
* test importing another module's `main` is what no-process-exit-outside-main exists to prevent.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n BranchReaper,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n MainSyncStatusFile,\n MergedBranchesCache,\n MergedBranchesService,\n ReapResult,\n loadAndValidate,\n computeAllMainSyncStatuses,\n writeMainSyncStatusFile,\n writeMainSyncLock,\n tryAcquireMainSyncLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch + the holder's pid, taken with an ATOMIC exclusive create. If another refresher already holds\n * it and is alive, we exit immediately (don't pile up `git fetch`es). If the holder is finished, dead,\n * or older than hangTimeoutMinutes, the lock is reclaimed and we proceed.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n refreshMainSync(\n process.argv[2] ?? process.cwd(),\n Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES,\n process.argv.slice(2).join(' '),\n );\n}\n\n/**\n * The refresh itself, argv-free so it can be driven by a test. `main()` is only the argv parse — a\n * test importing another module's `main` is what no-process-exit-outside-main exists to prevent.\n */\n// webpieces-disable no-function-outside-class -- module-level entry point of this detached refresher, matching the file's existing shape\nexport function refreshMainSync(repoRoot: string, hangTimeoutMinutes: number, argvDetail: string = ''): void {\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If guard-async-work.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${argvDetail}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // ONE atomic acquire — not a check followed by a write. The old check-then-write pair had a\n // gap in which two detached children could both pass the check and then both run `git fetch`.\n const lock = tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes);\n if (!lock) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // EVERY worktree's branch, not just ours. The cache is shared by the whole repo but used\n // to describe only the branch of whichever tree won this lock, so every other worktree's\n // guards read a cross-branch cache and abstained. One fetch + one `gh` call now arms all\n // of them; the map is rebuilt from the live worktree set, so dead branches drop out.\n const file = computeAllMainSyncStatuses(repoRoot);\n writeMainSyncStatusFile(repoRoot, file);\n\n // Second slow signal, same lock, same detached run: which local branches are dead. One bulk\n // `gh pr list --state merged` call. The branch-creation-guard reads the result to enforce its\n // cap without ever touching the network itself. Deliberately allowed to go stale.\n const mergedBranches = new MergedBranchesService();\n const cache = mergedBranches.computeMergedBranches(repoRoot);\n mergedBranches.writeMergedBranches(repoRoot, cache);\n\n // Third step, same detached run: actually DELETE the dead branches. Reporting them was\n // never enough — the reap was only ever a `git branch -D` string in a fix hint, which an\n // agent reads as destructive and stalls on, so nothing was ever cleaned. Here nobody has\n // to be asked. Reuses the verdicts we JUST computed (no second `gh` call).\n const reaped = autoReap(repoRoot, cache);\n\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n const names = Object.keys(file.branches);\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, names.join(','),\n `branches=${String(names.length)} ${names.map(summarize(file)).join(' ')} deletableBranches=${String(cache.deletable.length)} reaped=${String(reaped)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\n/**\n * One `branch(merged/forkPoint/conflict)` token per recorded branch, for the FINISH log line. The log\n * is the only evidence that a given worktree's branch was actually armed by a refresh, so it has to\n * name every branch written, not just the tree the refresher happened to run in.\n */\n// webpieces-disable no-function-outside-class -- module-level log formatter of this detached main(), matching the file's existing shape\nfunction summarize(file: MainSyncStatusFile): (branch: string) => string {\n return (branch: string): string => {\n const status = file.branches[branch];\n if (status === undefined) return `${branch}(?)`;\n return `${branch}(merged=${String(status.branchAlreadyMerged)}:${status.mergedPr} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)})`;\n };\n}\n\n/**\n * Delete the branches the verdicts just declared dead. Returns how many actually went.\n *\n * WHY it is safe to do this unattended: every candidate is provably dead (merged PR / squash backup\n * of a merged branch / zero commits of its own), `main` and any worktree-held branch are excluded\n * upstream, and each delete is logged with the branch's pre-delete SHA plus the exact command that\n * restores it. WHY it is safe to do it HERE: this refresher already recomputed those verdicts on\n * this very run, so it is acting on evidence seconds old, not on the deliberately-stale cache file.\n *\n * Swallows everything. We are detached and fire-and-forget: cleanup failing must never damage the\n * main-sync status this process exists to produce — but every failure is logged, because a silent\n * background deletion is exactly what nobody should have to trust.\n */\n// webpieces-disable no-function-outside-class -- module-level helper of this detached main(), matching the file's existing shape\nfunction autoReap(repoRoot: string, cache: MergedBranchesCache): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const config = loadAndValidate(repoRoot).rulesConfig['branch-creation-guard'];\n // Strictly opt-IN: only an explicit `true` reaps. `autoReapMergedBranches` is schema-required,\n // so every validated config states an answer — which means \"absent\" here is not a consumer\n // who wants the default, it is a config that never passed validation. Deleting branches on\n // that basis would be deleting on a preference nobody expressed.\n if (config?.mode === 'OFF' || config?.autoReapMergedBranches !== true) return 0;\n\n const result: ReapResult = new BranchReaper().reap(repoRoot, 'auto-reap', cache);\n for (const failure of result.failed) {\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'ERROR', process.pid, failure.branch, `reap failed: ${failure.error}`));\n }\n return result.reaped.length;\n } catch (err: unknown) {\n const error = toError(err);\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `autoReap: ${error.message}`));\n return 0;\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}
|
|
1
|
+
{"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n BranchReaper,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n MainSyncStatusFile,\n MergedBranchesCache,\n MergedBranchesService,\n ReapResult,\n loadAndValidate,\n computeAllMainSyncStatuses,\n writeMainSyncStatusFile,\n writeMainSyncLock,\n tryAcquireMainSyncLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\nimport { logStream, StreamIdentity } from './log-stream';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch + the holder's pid, taken with an ATOMIC exclusive create. If another refresher already holds\n * it and is alive, we exit immediately (don't pile up `git fetch`es). If the holder is finished, dead,\n * or older than hangTimeoutMinutes, the lock is reclaimed and we proceed.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes, sessionId, agentId, hook]\n *\n * The last three are the SPAWNER's LogStream identity, and adopting them here is the whole reason\n * they are passed. This process has its own fresh module-level `logStream`; left unidentified it\n * named every line `unknown-coordinator-hook-guard-async-work.log` — one shared file that every\n * agent's refresher child appended to concurrently (the PIPE_BUF tearing LogStream exists to remove),\n * and which held this run's START/FINISH while the parent's SPAWN_ATTEMPT sat in a different file\n * entirely. Adopting the parent's identity puts one refresh cycle back in one stream. Absent argv\n * (a hand-run of this file) leaves the default, which still prefixes.\n */\nexport function main(): void {\n logStream.identify(spawnerIdentity(process.argv));\n refreshMainSync(\n process.argv[2] ?? process.cwd(),\n Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES,\n process.argv.slice(2).join(' '),\n );\n}\n\n/**\n * The spawner's LogStream identity as it arrives on this child's argv — positions 4/5/6, which is\n * where `triggerMainSyncRefresh` puts them (its own array is offset by two: node + script path).\n *\n * Exported so ONE test can pin BOTH ends of the contract at once. The bug this closes is precisely a\n * disagreement between two files about argv positions, and a test that only checks the reader would\n * have stayed green through it.\n */\n// webpieces-disable no-function-outside-class -- argv parse for this detached main(), matching the file's existing shape\nexport function spawnerIdentity(argv: string[]): StreamIdentity {\n return new StreamIdentity(argv[4] ?? '', argv[5] ?? '', argv[6] ?? 'hook');\n}\n\n/**\n * The refresh itself, argv-free so it can be driven by a test. `main()` is only the argv parse — a\n * test importing another module's `main` is what no-process-exit-outside-main exists to prevent.\n */\n// webpieces-disable no-function-outside-class -- module-level entry point of this detached refresher, matching the file's existing shape\nexport function refreshMainSync(repoRoot: string, hangTimeoutMinutes: number, argvDetail: string = ''): void {\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If guard-async-work.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${argvDetail}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // ONE atomic acquire — not a check followed by a write. The old check-then-write pair had a\n // gap in which two detached children could both pass the check and then both run `git fetch`.\n const lock = tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes);\n if (!lock) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // EVERY worktree's branch, not just ours. The cache is shared by the whole repo but used\n // to describe only the branch of whichever tree won this lock, so every other worktree's\n // guards read a cross-branch cache and abstained. One fetch + one `gh` call now arms all\n // of them; the map is rebuilt from the live worktree set, so dead branches drop out.\n const file = computeAllMainSyncStatuses(repoRoot);\n writeMainSyncStatusFile(repoRoot, file);\n\n // Second slow signal, same lock, same detached run: which local branches are dead. One bulk\n // `gh pr list --state merged` call. The branch-creation-guard reads the result to enforce its\n // cap without ever touching the network itself. Deliberately allowed to go stale.\n const mergedBranches = new MergedBranchesService();\n const cache = mergedBranches.computeMergedBranches(repoRoot);\n mergedBranches.writeMergedBranches(repoRoot, cache);\n\n // Third step, same detached run: actually DELETE the dead branches. Reporting them was\n // never enough — the reap was only ever a `git branch -D` string in a fix hint, which an\n // agent reads as destructive and stalls on, so nothing was ever cleaned. Here nobody has\n // to be asked. Reuses the verdicts we JUST computed (no second `gh` call).\n const reaped = autoReap(repoRoot, cache);\n\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n const names = Object.keys(file.branches);\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, names.join(','),\n `branches=${String(names.length)} ${names.map(summarize(file)).join(' ')} deletableBranches=${String(cache.deletable.length)} reaped=${String(reaped)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\n/**\n * One `branch(merged/forkPoint/conflict)` token per recorded branch, for the FINISH log line. The log\n * is the only evidence that a given worktree's branch was actually armed by a refresh, so it has to\n * name every branch written, not just the tree the refresher happened to run in.\n */\n// webpieces-disable no-function-outside-class -- module-level log formatter of this detached main(), matching the file's existing shape\nfunction summarize(file: MainSyncStatusFile): (branch: string) => string {\n return (branch: string): string => {\n const status = file.branches[branch];\n if (status === undefined) return `${branch}(?)`;\n return `${branch}(merged=${String(status.branchAlreadyMerged)}:${status.mergedPr} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)})`;\n };\n}\n\n/**\n * Delete the branches the verdicts just declared dead. Returns how many actually went.\n *\n * WHY it is safe to do this unattended: every candidate is provably dead (merged PR / squash backup\n * of a merged branch / zero commits of its own), `main` and any worktree-held branch are excluded\n * upstream, and each delete is logged with the branch's pre-delete SHA plus the exact command that\n * restores it. WHY it is safe to do it HERE: this refresher already recomputed those verdicts on\n * this very run, so it is acting on evidence seconds old, not on the deliberately-stale cache file.\n *\n * Swallows everything. We are detached and fire-and-forget: cleanup failing must never damage the\n * main-sync status this process exists to produce — but every failure is logged, because a silent\n * background deletion is exactly what nobody should have to trust.\n */\n// webpieces-disable no-function-outside-class -- module-level helper of this detached main(), matching the file's existing shape\nfunction autoReap(repoRoot: string, cache: MergedBranchesCache): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const config = loadAndValidate(repoRoot).rulesConfig['branch-creation-guard'];\n // Strictly opt-IN: only an explicit `true` reaps. `autoReapMergedBranches` is schema-required,\n // so every validated config states an answer — which means \"absent\" here is not a consumer\n // who wants the default, it is a config that never passed validation. Deleting branches on\n // that basis would be deleting on a preference nobody expressed.\n if (config?.mode === 'OFF' || config?.autoReapMergedBranches !== true) return 0;\n\n const result: ReapResult = new BranchReaper().reap(repoRoot, 'auto-reap', cache);\n for (const failure of result.failed) {\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'ERROR', process.pid, failure.branch, `reap failed: ${failure.error}`));\n }\n return result.reaped.length;\n } catch (err: unknown) {\n const error = toError(err);\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `autoReap: ${error.message}`));\n return 0;\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}
|