@webpieces/ai-hook-rules 0.4.636 → 0.4.638

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.
Files changed (94) hide show
  1. package/README.md +11 -4
  2. package/package.json +2 -2
  3. package/src/adapters/hook-core.js +4 -3
  4. package/src/adapters/hook-core.js.map +1 -1
  5. package/src/bin/setup.js +72 -6
  6. package/src/bin/setup.js.map +1 -1
  7. package/src/core/custom-rule-adapter.js +3 -1
  8. package/src/core/custom-rule-adapter.js.map +1 -1
  9. package/src/core/decision-log.d.ts +38 -5
  10. package/src/core/decision-log.js +45 -6
  11. package/src/core/decision-log.js.map +1 -1
  12. package/src/core/l1-doc.js +6 -3
  13. package/src/core/l1-doc.js.map +1 -1
  14. package/src/core/l2-doc.d.ts +7 -0
  15. package/src/core/l2-doc.js +274 -0
  16. package/src/core/l2-doc.js.map +1 -0
  17. package/src/core/l2-rows.d.ts +97 -0
  18. package/src/core/l2-rows.js +229 -0
  19. package/src/core/l2-rows.js.map +1 -0
  20. package/src/core/load-rules.d.ts +8 -2
  21. package/src/core/load-rules.js +51 -31
  22. package/src/core/load-rules.js.map +1 -1
  23. package/src/core/main-sync-refresh.d.ts +8 -1
  24. package/src/core/main-sync-refresh.js +9 -2
  25. package/src/core/main-sync-refresh.js.map +1 -1
  26. package/src/core/main-sync-timeout.d.ts +32 -0
  27. package/src/core/main-sync-timeout.js +93 -0
  28. package/src/core/main-sync-timeout.js.map +1 -0
  29. package/src/core/rules/branch-creation-guard.js +1 -1
  30. package/src/core/rules/branch-creation-guard.js.map +1 -1
  31. package/src/core/rules/catch-error-pattern.js +1 -1
  32. package/src/core/rules/catch-error-pattern.js.map +1 -1
  33. package/src/core/rules/feature-branch-guard.d.ts +3 -3
  34. package/src/core/rules/feature-branch-guard.js +17 -4
  35. package/src/core/rules/feature-branch-guard.js.map +1 -1
  36. package/src/core/rules/index.d.ts +15 -1
  37. package/src/core/rules/index.js +18 -10
  38. package/src/core/rules/index.js.map +1 -1
  39. package/src/core/rules/match-rule.js +4 -1
  40. package/src/core/rules/match-rule.js.map +1 -1
  41. package/src/core/rules/max-file-lines.js +1 -1
  42. package/src/core/rules/max-file-lines.js.map +1 -1
  43. package/src/core/rules/merge-in-progress-guard.d.ts +9 -3
  44. package/src/core/rules/merge-in-progress-guard.js +9 -4
  45. package/src/core/rules/merge-in-progress-guard.js.map +1 -1
  46. package/src/core/rules/merged-branch-bash-guard.d.ts +3 -3
  47. package/src/core/rules/merged-branch-bash-guard.js +17 -6
  48. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  49. package/src/core/rules/no-any-unknown.js +1 -1
  50. package/src/core/rules/no-any-unknown.js.map +1 -1
  51. package/src/core/rules/no-custom-css.js +1 -1
  52. package/src/core/rules/no-custom-css.js.map +1 -1
  53. package/src/core/rules/no-destructure.js +1 -1
  54. package/src/core/rules/no-destructure.js.map +1 -1
  55. package/src/core/rules/no-implicit-any.js +1 -1
  56. package/src/core/rules/no-implicit-any.js.map +1 -1
  57. package/src/core/rules/no-js-files.js +1 -1
  58. package/src/core/rules/no-js-files.js.map +1 -1
  59. package/src/core/rules/no-process-exit-outside-main.js +1 -1
  60. package/src/core/rules/no-process-exit-outside-main.js.map +1 -1
  61. package/src/core/rules/no-symbol-di-tokens.js +1 -1
  62. package/src/core/rules/no-symbol-di-tokens.js.map +1 -1
  63. package/src/core/rules/no-unmanaged-exceptions.js +1 -1
  64. package/src/core/rules/no-unmanaged-exceptions.js.map +1 -1
  65. package/src/core/rules/pr-creation-or-push-guard.d.ts +10 -3
  66. package/src/core/rules/pr-creation-or-push-guard.js +10 -4
  67. package/src/core/rules/pr-creation-or-push-guard.js.map +1 -1
  68. package/src/core/rules/pr-merge-guard.d.ts +3 -3
  69. package/src/core/rules/pr-merge-guard.js +2 -1
  70. package/src/core/rules/pr-merge-guard.js.map +1 -1
  71. package/src/core/rules/read-stale-guard.d.ts +3 -3
  72. package/src/core/rules/read-stale-guard.js +15 -6
  73. package/src/core/rules/read-stale-guard.js.map +1 -1
  74. package/src/core/rules/redirect-how-to-merge-main.d.ts +3 -3
  75. package/src/core/rules/redirect-how-to-merge-main.js +1 -1
  76. package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
  77. package/src/core/rules/require-return-type.js +1 -1
  78. package/src/core/rules/require-return-type.js.map +1 -1
  79. package/src/core/rules/stale-main-bash-guard.d.ts +3 -3
  80. package/src/core/rules/stale-main-bash-guard.js +5 -4
  81. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  82. package/src/core/rules/throw-cause-required.js +1 -1
  83. package/src/core/rules/throw-cause-required.js.map +1 -1
  84. package/src/core/rules/validate-ts-in-src.js +1 -1
  85. package/src/core/rules/validate-ts-in-src.js.map +1 -1
  86. package/src/core/rules/whole-repo-build-guard.js +6 -2
  87. package/src/core/rules/whole-repo-build-guard.js.map +1 -1
  88. package/src/core/runner.js +41 -22
  89. package/src/core/runner.js.map +1 -1
  90. package/src/core/sync-main.d.ts +9 -0
  91. package/src/core/sync-main.js +17 -1
  92. package/src/core/sync-main.js.map +1 -1
  93. package/src/core/types.d.ts +11 -0
  94. package/src/core/types.js.map +1 -1
@@ -18,7 +18,9 @@ class CustomRuleAdapter extends rules_config_1.AbstractRule {
18
18
  impl;
19
19
  rawConfig;
20
20
  constructor(impl, rawConfig) {
21
- super(rawConfig, impl.name);
21
+ // A custom rule from `rulesDir` is keyed in webpieces.config.json by its own `name`, so the
22
+ // two identities coincide. Stated twice rather than defaulted — see AbstractRule.
23
+ super(rawConfig, impl.name, impl.name);
22
24
  this.impl = impl;
23
25
  this.scope = impl.scope;
24
26
  this.files = impl.files;
@@ -1 +1 @@
1
- {"version":3,"file":"custom-rule-adapter.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/custom-rule-adapter.ts"],"names":[],"mappings":";;;AAAA,0DAAoF;AAQpF;;;;;;GAMG;AACH,MAAa,iBAAkB,SAAQ,2BAA4B;IACtD,KAAK,CAAY;IACjB,KAAK,CAAoB;IACzB,WAAW,CAAS;IACpB,OAAO,CAAU;IACjB,cAAc,CAAc;IACpB,IAAI,CAAY;IAChB,SAAS,CAAc;IAExC,YAAY,IAAe,EAAE,SAAsB;QAC/C,KAAK,CAAC,SAA2B,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9C,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACxB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACxB,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;QACpC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;QAC1C,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC/B,CAAC;IAEO,YAAY;QAChB,MAAM,GAAG,GAAgB,EAAE,CAAC;QAC5B,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;QACxF,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAC5C,kEAAkE;YAClE,IAAI,GAAG,KAAK,MAAM;gBAAE,SAAS;YAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED,KAAK,CAAC,GAA4C;QAC9C,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QAClC,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC;CACJ;AAnCD,8CAmCC","sourcesContent":["import { AbstractRule, BaseRuleConfig, RuleOptions } from '@webpieces/rules-config';\n\nimport type {\n PlainRule, Rule, RuleScope,\n EditContext, FileContext, BashContext, Violation,\n} from './types';\nimport type { FixHint } from './fix-hint';\n\n/**\n * Wraps a custom rule loaded from a `rulesDir` (a plain object) so it satisfies the same\n * runtime `Rule` contract as the built-in rule classes: it gains `shouldRun()` (driven by the\n * rule's config entry in webpieces.config.json) from AbstractRule, and it seeds `ctx.options`\n * with the merged option bag before delegating to the plain rule's `check()` — preserving the\n * exact behavior custom rules had under the old options-based runner.\n */\nexport class CustomRuleAdapter extends AbstractRule<BaseRuleConfig> implements Rule {\n readonly scope: RuleScope;\n readonly files: readonly string[];\n readonly description: string;\n readonly fixHint: FixHint;\n readonly defaultOptions: RuleOptions;\n private readonly impl: PlainRule;\n private readonly rawConfig: RuleOptions;\n\n constructor(impl: PlainRule, rawConfig: RuleOptions) {\n super(rawConfig as BaseRuleConfig, impl.name);\n this.impl = impl;\n this.scope = impl.scope;\n this.files = impl.files;\n this.description = impl.description;\n this.fixHint = impl.fixHint;\n this.defaultOptions = impl.defaultOptions;\n this.rawConfig = rawConfig;\n }\n\n private buildOptions(): RuleOptions {\n const out: RuleOptions = {};\n for (const key of Object.keys(this.defaultOptions)) out[key] = this.defaultOptions[key];\n for (const key of Object.keys(this.rawConfig)) {\n // 'mode' is the framework-level on/off switch, not a rule option.\n if (key === 'mode') continue;\n out[key] = this.rawConfig[key];\n }\n return out;\n }\n\n check(ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n ctx.options = this.buildOptions();\n return this.impl.check(ctx);\n }\n}\n"]}
1
+ {"version":3,"file":"custom-rule-adapter.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/custom-rule-adapter.ts"],"names":[],"mappings":";;;AAAA,0DAAoF;AAQpF;;;;;;GAMG;AACH,MAAa,iBAAkB,SAAQ,2BAA4B;IACtD,KAAK,CAAY;IACjB,KAAK,CAAoB;IACzB,WAAW,CAAS;IACpB,OAAO,CAAU;IACjB,cAAc,CAAc;IACpB,IAAI,CAAY;IAChB,SAAS,CAAc;IAExC,YAAY,IAAe,EAAE,SAAsB;QAC/C,4FAA4F;QAC5F,kFAAkF;QAClF,KAAK,CAAC,SAA2B,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACxB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACxB,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;QACpC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;QAC1C,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC/B,CAAC;IAEO,YAAY;QAChB,MAAM,GAAG,GAAgB,EAAE,CAAC;QAC5B,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;QACxF,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAC5C,kEAAkE;YAClE,IAAI,GAAG,KAAK,MAAM;gBAAE,SAAS;YAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED,KAAK,CAAC,GAA4C;QAC9C,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QAClC,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC;CACJ;AArCD,8CAqCC","sourcesContent":["import { AbstractRule, BaseRuleConfig, RuleOptions } from '@webpieces/rules-config';\n\nimport type {\n PlainRule, Rule, RuleScope,\n EditContext, FileContext, BashContext, Violation,\n} from './types';\nimport type { FixHint } from './fix-hint';\n\n/**\n * Wraps a custom rule loaded from a `rulesDir` (a plain object) so it satisfies the same\n * runtime `Rule` contract as the built-in rule classes: it gains `shouldRun()` (driven by the\n * rule's config entry in webpieces.config.json) from AbstractRule, and it seeds `ctx.options`\n * with the merged option bag before delegating to the plain rule's `check()` — preserving the\n * exact behavior custom rules had under the old options-based runner.\n */\nexport class CustomRuleAdapter extends AbstractRule<BaseRuleConfig> implements Rule {\n readonly scope: RuleScope;\n readonly files: readonly string[];\n readonly description: string;\n readonly fixHint: FixHint;\n readonly defaultOptions: RuleOptions;\n private readonly impl: PlainRule;\n private readonly rawConfig: RuleOptions;\n\n constructor(impl: PlainRule, rawConfig: RuleOptions) {\n // A custom rule from `rulesDir` is keyed in webpieces.config.json by its own `name`, so the\n // two identities coincide. Stated twice rather than defaulted — see AbstractRule.\n super(rawConfig as BaseRuleConfig, impl.name, impl.name);\n this.impl = impl;\n this.scope = impl.scope;\n this.files = impl.files;\n this.description = impl.description;\n this.fixHint = impl.fixHint;\n this.defaultOptions = impl.defaultOptions;\n this.rawConfig = rawConfig;\n }\n\n private buildOptions(): RuleOptions {\n const out: RuleOptions = {};\n for (const key of Object.keys(this.defaultOptions)) out[key] = this.defaultOptions[key];\n for (const key of Object.keys(this.rawConfig)) {\n // 'mode' is the framework-level on/off switch, not a rule option.\n if (key === 'mode') continue;\n out[key] = this.rawConfig[key];\n }\n return out;\n }\n\n check(ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n ctx.options = this.buildOptions();\n return this.impl.check(ctx);\n }\n}\n"]}
@@ -26,10 +26,10 @@ export type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_C
26
26
  /**
27
27
  * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.
28
28
  *
29
- * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`) — the
29
+ * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `L2_ROWS[i].num`) — the
30
30
  * same number the generated doc prints, because the doc is rendered from that same array. So a log
31
31
  * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use
32
- * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).
32
+ * cases becomes a lookup rather than an investigation.
33
33
  */
34
34
  export declare class MatrixRef {
35
35
  readonly layer: string;
@@ -37,8 +37,12 @@ export declare class MatrixRef {
37
37
  constructor(layer: string, row: string);
38
38
  }
39
39
  /**
40
- * The layer tokens. `row` is `'-'` only for a layer with no row array YET (L2 is the un-converted one)
41
- * but the LAYER is always named, so `grep layer=L2` works today and the row fills in when L2 converts.
40
+ * The layer tokens. EVERY layer now cites a row: L0 through its two decision-matrix rows, L1 through
41
+ * L1_ROWS, and L2 through L2_ROWS (see `matrixL2Row`). This used to say `'-'` was for "a layer with no
42
+ * row array YET (L2 is the un-converted one)" — L2 is converted, and a comment describing a state the
43
+ * code left behind is exactly the kind of doc a reader trusts and should not.
44
+ *
45
+ * `'-'` survives for ONE case, and it is a real one: an L2 reason that no row claims. See matrixL2Row.
42
46
  *
43
47
  * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the
44
48
  * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own
@@ -54,7 +58,36 @@ export declare class MatrixRef {
54
58
  */
55
59
  export declare const MATRIX_L0_ALLOW: MatrixRef;
56
60
  export declare const MATRIX_L0_BLOCK: MatrixRef;
57
- export declare const MATRIX_L2: MatrixRef;
61
+ /**
62
+ * The L2 reference for one decision, with the row DERIVED FROM THE REASON.
63
+ *
64
+ * There is no `MATRIX_L2` constant any more, and its absence is the point: a single shared instance
65
+ * meant every L2 line in the repo carried `row=-`, which reads as "L2 has no rows" rather than "this
66
+ * decision was not classified". Deleting it makes every construction site name a reason, and the reason
67
+ * is the only thing a call site has that identifies which row it is an instance of.
68
+ *
69
+ * `l2RowForReason` returns null for a reason no row claims, and that renders as `'-'` — visible in the
70
+ * log, not silently absorbed into a default row. l2-matrix.spec.ts reads the four guard sources and
71
+ * fails the build if any reason literal in them is unmapped, so `-` should never appear in practice;
72
+ * when it does, it is a genuine hole in the table and the log says so.
73
+ */
74
+ export declare function matrixL2Row(reason: string): MatrixRef;
75
+ /**
76
+ * The L2 stream, with NO row — for the two kinds of line that genuinely are not an instance of a row.
77
+ *
78
+ * 1. The runner's AGGREGATE bash lines ("no bash-guard block" / "bash-guard block"). They summarise
79
+ * the whole guard set's answer for one command, not one row's verdict; the per-guard lines that
80
+ * DO cite rows are written alongside them by the guards themselves.
81
+ * 2. `whole-repo-build-guard`, which is not a branch-state policy at all — it is the experimental
82
+ * home-config guard, has no webpieces.config.json entry, and shares this stream only because the
83
+ * stream is "bash decisions", not "L2 rows".
84
+ *
85
+ * A NAMED constant rather than an inline `new MatrixRef('L2', '-')`, so `grep MATRIX_L2_UNROWED` lists
86
+ * every uncited line and the list stays short and arguable. It is deliberately NOT called `MATRIX_L2`:
87
+ * the old name was used by everything and made "L2 has no rows" indistinguishable from "this line is
88
+ * not a row".
89
+ */
90
+ export declare const MATRIX_L2_UNROWED: MatrixRef;
58
91
  export declare class GuardDecision {
59
92
  rule: string;
60
93
  tool: string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.invocationLog = exports.InvocationLog = exports.GuardInvocation = exports.GuardDecision = exports.MATRIX_L2 = exports.MATRIX_L0_BLOCK = exports.MATRIX_L0_ALLOW = exports.MatrixRef = void 0;
3
+ exports.invocationLog = exports.InvocationLog = exports.GuardInvocation = exports.GuardDecision = exports.MATRIX_L2_UNROWED = exports.MATRIX_L0_BLOCK = exports.MATRIX_L0_ALLOW = exports.MatrixRef = void 0;
4
+ exports.matrixL2Row = matrixL2Row;
4
5
  exports.logGuardDecision = logGuardDecision;
5
6
  exports.logL1Decision = logL1Decision;
6
7
  exports.branchForLog = branchForLog;
@@ -13,6 +14,7 @@ const log_streams_1 = require("./log-streams");
13
14
  const l0_fault_codes_1 = require("./l0-fault-codes");
14
15
  const to_error_1 = require("./to-error");
15
16
  const log_stream_1 = require("./log-stream");
17
+ const l2_rows_1 = require("./l2-rows");
16
18
  // The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is
17
19
  // the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This
18
20
  // one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES
@@ -24,10 +26,10 @@ const MAX_TARGET_LEN = 160;
24
26
  /**
25
27
  * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.
26
28
  *
27
- * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`) — the
29
+ * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `L2_ROWS[i].num`) — the
28
30
  * same number the generated doc prints, because the doc is rendered from that same array. So a log
29
31
  * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use
30
- * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).
32
+ * cases becomes a lookup rather than an investigation.
31
33
  */
32
34
  class MatrixRef {
33
35
  layer;
@@ -39,8 +41,12 @@ class MatrixRef {
39
41
  }
40
42
  exports.MatrixRef = MatrixRef;
41
43
  /**
42
- * The layer tokens. `row` is `'-'` only for a layer with no row array YET (L2 is the un-converted one)
43
- * but the LAYER is always named, so `grep layer=L2` works today and the row fills in when L2 converts.
44
+ * The layer tokens. EVERY layer now cites a row: L0 through its two decision-matrix rows, L1 through
45
+ * L1_ROWS, and L2 through L2_ROWS (see `matrixL2Row`). This used to say `'-'` was for "a layer with no
46
+ * row array YET (L2 is the un-converted one)" — L2 is converted, and a comment describing a state the
47
+ * code left behind is exactly the kind of doc a reader trusts and should not.
48
+ *
49
+ * `'-'` survives for ONE case, and it is a real one: an L2 reason that no row claims. See matrixL2Row.
44
50
  *
45
51
  * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the
46
52
  * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own
@@ -56,7 +62,40 @@ exports.MatrixRef = MatrixRef;
56
62
  */
57
63
  exports.MATRIX_L0_ALLOW = new MatrixRef('L0', l0_fault_codes_1.L0_ROW_ALLOWLISTED);
58
64
  exports.MATRIX_L0_BLOCK = new MatrixRef('L0', l0_fault_codes_1.L0_ROW_BLOCKED);
59
- exports.MATRIX_L2 = new MatrixRef('L2', '-');
65
+ /**
66
+ * The L2 reference for one decision, with the row DERIVED FROM THE REASON.
67
+ *
68
+ * There is no `MATRIX_L2` constant any more, and its absence is the point: a single shared instance
69
+ * meant every L2 line in the repo carried `row=-`, which reads as "L2 has no rows" rather than "this
70
+ * decision was not classified". Deleting it makes every construction site name a reason, and the reason
71
+ * is the only thing a call site has that identifies which row it is an instance of.
72
+ *
73
+ * `l2RowForReason` returns null for a reason no row claims, and that renders as `'-'` — visible in the
74
+ * log, not silently absorbed into a default row. l2-matrix.spec.ts reads the four guard sources and
75
+ * fails the build if any reason literal in them is unmapped, so `-` should never appear in practice;
76
+ * when it does, it is a genuine hole in the table and the log says so.
77
+ */
78
+ // webpieces-disable no-function-outside-class -- a named constructor for MatrixRef beside it, in this module of module-scope writers
79
+ function matrixL2Row(reason) {
80
+ const row = (0, l2_rows_1.l2RowForReason)(reason);
81
+ return new MatrixRef('L2', row === null ? '-' : String(row));
82
+ }
83
+ /**
84
+ * The L2 stream, with NO row — for the two kinds of line that genuinely are not an instance of a row.
85
+ *
86
+ * 1. The runner's AGGREGATE bash lines ("no bash-guard block" / "bash-guard block"). They summarise
87
+ * the whole guard set's answer for one command, not one row's verdict; the per-guard lines that
88
+ * DO cite rows are written alongside them by the guards themselves.
89
+ * 2. `whole-repo-build-guard`, which is not a branch-state policy at all — it is the experimental
90
+ * home-config guard, has no webpieces.config.json entry, and shares this stream only because the
91
+ * stream is "bash decisions", not "L2 rows".
92
+ *
93
+ * A NAMED constant rather than an inline `new MatrixRef('L2', '-')`, so `grep MATRIX_L2_UNROWED` lists
94
+ * every uncited line and the list stays short and arguable. It is deliberately NOT called `MATRIX_L2`:
95
+ * the old name was used by everything and made "L2 has no rows" indistinguishable from "this line is
96
+ * not a row".
97
+ */
98
+ exports.MATRIX_L2_UNROWED = new MatrixRef('L2', '-');
60
99
  // Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).
61
100
  // `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard
62
101
  // decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted
@@ -1 +1 @@
1
- {"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, readMainSyncStatus, MainSyncStatus, RepoRootFinder, claudeEnv } from '@webpieces/rules-config';\nimport { L1_LOCATION_STREAM, L2_DECISIONS_STREAM, CALLS_STREAM } from './log-streams';\n\nimport { L0_FAULT_NONE, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED } from './l0-fault-codes';\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\n\n// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is\n// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This\n// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES\n// the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is\n// traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —\n// the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\n/**\n * THE ACTION CODEBOOK, as a type. These are the five actions GUARD_MATRIX.md numbers 1-5, and they\n * are the vocabulary EVERY layer reports in — so one grep spans L-1, L0, L1 and L2.\n *\n * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:\n *\n * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW\n * is NOT saying the call ran: the layer below it, or the OTHER parallel hook, may\n * still deny. This is L1's `ACT_DOWN`.\n * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's\n * `ACT_EXEMPT`.\n * ALLOW_FAIL_OPEN state could not be established, so nothing was judged. Keeping this distinct\n * from ALLOW is the entire point of the type: a fail-open allow and a real allow\n * that look identical make it impossible to tell whether the guards are protecting\n * anything or quietly abstaining. It used to be a `' (fail-open)'` SUBSTRING on the\n * reason field, which is exactly why the abstentions were never countable.\n * BLOCK_AI_CURE blocked, and the printed cure is a command the AI can run itself.\n * BLOCK_HUMAN blocked, and it needs a human decision — or a delegation (spawn a subagent) that\n * the blocked agent cannot perform for itself.\n *\n * Hard cut, per CLAUDE.md: `'BLOCK'` is GONE rather than aliased, so every construction site fails to\n * compile and has to say which kind of block it is. Before, that question had exactly one wrong\n * answer available — silence.\n */\nexport type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_CURE' | 'BLOCK_HUMAN';\n\n/**\n * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.\n *\n * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`) — the\n * same number the generated doc prints, because the doc is rendered from that same array. So a log\n * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use\n * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).\n */\nexport class MatrixRef {\n constructor(readonly layer: string, readonly row: string) {}\n}\n\n/**\n * The layer tokens. `row` is `'-'` only for a layer with no row array YET (L2 is the un-converted one)\n * — but the LAYER is always named, so `grep layer=L2` works today and the row fills in when L2 converts.\n *\n * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the\n * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own\n * docblock argues against for `'BLOCK'`, where silence was the one wrong answer available.\n *\n * L0 NOW CITES ITS ROW TOO. It used to be `'-'` on the grounds that \"L0's faults are a table of letters\n * rather than numbered rows\" — but L0 has BOTH: the letter says WHICH fault, and the numbered decision\n * matrix (`renderGuardMatrixDoc`) says which of its three rows was taken. There is one ALLOW row and one\n * BLOCK row, so the pair `row=` + `fault=` pins the decision exactly, and the deny an agent reads now\n * carries the identical pair. That is the join: one grep spans the deny, the log line and the doc.\n * Two constants, not one `MATRIX_L0`, because the two call sites are a cure-bypass ALLOW and an L0\n * BLOCK — one token covering both is what made the row unciteable in the first place.\n */\nexport const MATRIX_L0_ALLOW = new MatrixRef('L0', L0_ROW_ALLOWLISTED);\nexport const MATRIX_L0_BLOCK = new MatrixRef('L0', L0_ROW_BLOCKED);\nexport const MATRIX_L2 = new MatrixRef('L2', '-');\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\n// `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard\n// decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted\n// (bash guards, on-main, config-bypass).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n cache: string;\n /**\n * The L0 fault this decision IS, in the codebook's letter (core/l0-fault-codes.ts), or `-` for an\n * ordinary rule decision. The `sh` shim has always stamped `fault=` on its own stream; the three\n * JS-side faults (S/C/Y) reached this one with no label at all, so `grep fault=S` found nothing\n * even while an S storm was blocking every call.\n */\n fault: string;\n /** Which layer + row decided this. See MatrixRef — it is what joins a log line to the doc. */\n matrix: MatrixRef;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string, cache: string = '-', fault: string, matrix: MatrixRef) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n this.cache = cache;\n this.fault = fault;\n this.matrix = matrix;\n }\n}\n\n/**\n * Append one tab-separated line per L2 decision to `.webpieces/logs/L2-decisions/<writer>.log`, where\n * <writer> is LogStream's `<sessionId>-<agentId|coordinator>-<hook>` key (a caller that never\n * identified renders as `unknown-coordinator-hook` — there is no un-keyed name).\n * `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or a\n * RepoRootFinder-resolved root at the pre-load config-bypass site — never a raw cwd, so a bypass\n * logged from a subdir never scatters a stray `.webpieces`). Swallows all errors — logging must never\n * block or fail a hook.\n */\n// webpieces-disable no-function-outside-class -- the module-scope writer this log has always been, beside branchForLog/oneLine/rotateLogFile; it must stay callable from a tree too broken to build a DI container\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n appendDecision(root, L2_DECISIONS_STREAM, decision);\n}\n\n/**\n * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.\n *\n * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name\n * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —\n * the exempt row and the three hand-down rows — wrote nothing at all. So \"L1 had no objection\" was\n * unobservable, and \"show me every L1 decision\" had no answer: L1 existed in the trail only as the\n * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.\n *\n * A SIBLING rather than a `base` parameter on logGuardDecision, deliberately: that signature is what\n * the process-wide `logStream` singleton exists to keep unchanged (see LogStream's docblock), and\n * `INVOCATION_LOG_FILE` already establishes the pattern of a second stream owning its own name in\n * this same module.\n */\n// webpieces-disable no-function-outside-class -- sibling of logGuardDecision, same module-scope writer shape and same reason\nexport function logL1Decision(root: string, decision: GuardDecision): void {\n appendDecision(root, L1_LOCATION_STREAM, decision);\n}\n\n// The one appender both streams share. `streamDir` is the LAYER; the writer key inside it is\n// logStream's session/agent/hook, which is what keeps one writer per file.\n// webpieces-disable no-function-outside-class -- the shared body of the two module-scope writers above\nfunction appendDecision(root: string, streamDir: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: a guard decision belongs to the tree it judged. WHO made the call is answered\n // by the filename, which logStream names with session/agent/hook.\n const logsDir = dotWebpieces.logsFile(root, streamDir);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n oneLine(decision.cache),\n // WHICH ROW of WHICH table decided this. The directory already carries the layer, but a\n // line quoted out of its file must still say what judged it — and `row=` is the join key\n // to the generated doc, which is the point of the whole exercise.\n `layer=${decision.matrix.layer}`,\n `row=${decision.matrix.row}`,\n // The tree this decision was actually made against, and what Claude Code told the hook the\n // project was. Appended (never reordered) for the same reason as on the invocation line —\n // see ClaudeEnv: when these two disagree, that disagreement is the bug.\n `root=${root}`,\n `projectDir=${claudeEnv.projectDirForLog()}`,\n // git's name for that tree — `primary`, else the worktree name. Same literal and same\n // derivation as the L0 shim log's `tree=` (shim-audit-log.ts), so one grep spans both\n // streams: L0 carries tree without projectDir, L1 now carries both.\n `tree=${dotWebpieces.worktreeName(root) || 'primary'}`,\n // APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault\n // this was, or `-`.\n `fault=${decision.fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n/**\n * What the guard SAW on one invocation, captured up front and held until the outcome is known.\n * Data-only (per CLAUDE.md: classes for data, explicit construction).\n */\nexport class GuardInvocation {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n public readonly root: string,\n public readonly timestamp: string,\n public readonly tool: string,\n public readonly target: string,\n public readonly branch: string,\n public readonly sync: string,\n public readonly projectDir: string,\n ) {}\n}\n\n/**\n * The per-INVOCATION stream — `.webpieces/logs/calls/<writer>.log` (see LogStream for the writer\n * key), one line for EVERY guards-hook\n * call (allow or block, bash or file), unlike `L2-decisions/` which records only the calls a\n * rule actually judged. It captures the tool, the command/file, the live git branch, the async-written\n * main-sync-status.json snapshot (branch / merged / fork-point / conflict), and — since this class\n * replaced a bare log-and-forget function — HOW THE CALL ENDED.\n *\n * WHY IT IS TWO CALLS. The line used to be written the moment the hook started, so it could not carry\n * a verdict: the decision had not been made yet. Answering \"what happened to this call?\" therefore\n * meant joining this file against the L2 decision stream BY TIMESTAMP, which is exactly the kind of\n * reconstruction a log exists to make unnecessary. So {@link begin} now only CAPTURES (including the\n * git/cache reads, which must still happen while the hook is running), and {@link finish} — called\n * from the hook's single terminal boundary, emitAllow/emitDeny — writes the whole line once the\n * outcome is known. The two streams stay distinct in purpose: this one is \"every call and how it\n * ended\", the decision log remains \"every judgement and why\".\n *\n * Every error is swallowed: logging must never block or fail a hook.\n */\nexport class InvocationLog {\n private pending: GuardInvocation | null = null;\n\n /**\n * Capture the context of one invocation. `cwd` is the AI's working dir; the repo root that owns\n * `.webpieces` is resolved from it. Writes NOTHING — {@link finish} does that.\n */\n begin(cwd: string, tool: string, target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const branch = branchForLog(root);\n // The cache is branch-keyed, so the entry to log is the one for the branch we are standing\n // on. 'unknown' (branchForLog's failure value) simply misses and logs 'sync=none'.\n const sync = summarizeSyncStatus(readMainSyncStatus(root, branch));\n this.pending = new GuardInvocation(root, new Date().toISOString(), tool, oneLine(target), branch, sync, claudeEnv.projectDirForLog());\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Write the captured line, now stamped with the outcome. A no-op when nothing was captured (the\n * 'rules' hook, or a terminal boundary reached before begin()), and it clears the pending entry so\n * a second emit cannot double-log.\n *\n * `rule` is the rule that blocked, or '-' when there is none; `fault` is the L0 fault code when this\n * call ended on one (S/C/Y — the JS-side faults), else '-'. FIELD ORDER IS APPEND-ONLY: the five\n * original fields keep their positions (cleanup automation mines this file), and `guards=` /\n * `rule=` / … / `fault=` are added at the end.\n */\n finish(verdict: Verdict, rule: string, fault: string = L0_FAULT_NONE): void {\n const invocation = this.pending;\n this.pending = null;\n if (invocation === null) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const logsDir = dotWebpieces.logsFile(invocation.root, CALLS_STREAM);\n fs.mkdirSync(logsDir, { recursive: true });\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${invocation.timestamp}]`,\n invocation.tool,\n invocation.target,\n `branch=${invocation.branch}`,\n invocation.sync,\n // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all\n // its PreToolUse hooks IN PARALLEL, so another hook process may deny a call this one\n // had no objection to, and neither can see the other's answer. Measured under the\n // RETIRED three-hook form: `cd <repo>/packages && ls` was DENIED by L-1 and recorded\n // here three times as `verdict=ALLOW`. The old field name promised an outcome it\n // structurally cannot know, so the name stayed even though L-1 is gone: the two\n // surviving hooks still run in parallel and still cannot see each other.\n `guards=${verdict}`,\n `rule=${oneLine(rule) || '-'}`,\n // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on\n // every line, because the diagnostic value is entirely in comparing them — see\n // ClaudeEnv for the open question this field exists to settle empirically.\n `root=${invocation.root}`,\n `projectDir=${invocation.projectDir}`,\n // See logGuardDecision: the short tree label, so `tree=primary` with a matching\n // projectDir reads as healthy at a glance and `tree=<worktree>` beside a projectDir\n // pointing at the primary is the straddle, without diffing two absolute paths.\n `tree=${dotWebpieces.worktreeName(invocation.root) || 'primary'}`,\n // WHICH L0 fault ended this call, in the same letters and the same field name the L0 sh\n // shim uses (the `L0-shim/` stream) — so ONE grep spans the whole trail.\n `fault=${fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Process-wide instance: one hook process handles exactly one tool call, so a single pending entry is\n// the whole state there is. Module-scope (rather than DI) because the terminal boundary that flushes\n// it — emitAllow/emitDeny — is itself module-scope protocol code with no container in reach.\nexport const invocationLog = new InvocationLog();\n\n// One-field summary of main-sync-status.json for the invocation log: the branch the cache is FOR,\n// whether it is already merged (and its PR), fork-point presence, and conflict state — the signals a\n// cleanup step keys off. 'sync=none' when the cache has not been written yet (first call of a session).\nfunction summarizeSyncStatus(status: MainSyncStatus | null): string {\n if (status === null) return 'sync=none';\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `sync=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\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 error;\n }\n}\n"]}
1
+ {"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, readMainSyncStatus, MainSyncStatus, RepoRootFinder, claudeEnv } from '@webpieces/rules-config';\nimport { L1_LOCATION_STREAM, L2_DECISIONS_STREAM, CALLS_STREAM } from './log-streams';\n\nimport { L0_FAULT_NONE, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED } from './l0-fault-codes';\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\nimport { l2RowForReason } from './l2-rows';\n\n// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is\n// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This\n// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES\n// the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is\n// traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —\n// the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\n/**\n * THE ACTION CODEBOOK, as a type. These are the five actions GUARD_MATRIX.md numbers 1-5, and they\n * are the vocabulary EVERY layer reports in — so one grep spans L-1, L0, L1 and L2.\n *\n * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:\n *\n * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW\n * is NOT saying the call ran: the layer below it, or the OTHER parallel hook, may\n * still deny. This is L1's `ACT_DOWN`.\n * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's\n * `ACT_EXEMPT`.\n * ALLOW_FAIL_OPEN state could not be established, so nothing was judged. Keeping this distinct\n * from ALLOW is the entire point of the type: a fail-open allow and a real allow\n * that look identical make it impossible to tell whether the guards are protecting\n * anything or quietly abstaining. It used to be a `' (fail-open)'` SUBSTRING on the\n * reason field, which is exactly why the abstentions were never countable.\n * BLOCK_AI_CURE blocked, and the printed cure is a command the AI can run itself.\n * BLOCK_HUMAN blocked, and it needs a human decision — or a delegation (spawn a subagent) that\n * the blocked agent cannot perform for itself.\n *\n * Hard cut, per CLAUDE.md: `'BLOCK'` is GONE rather than aliased, so every construction site fails to\n * compile and has to say which kind of block it is. Before, that question had exactly one wrong\n * answer available — silence.\n */\nexport type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_CURE' | 'BLOCK_HUMAN';\n\n/**\n * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.\n *\n * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `L2_ROWS[i].num`) — the\n * same number the generated doc prints, because the doc is rendered from that same array. So a log\n * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use\n * cases becomes a lookup rather than an investigation.\n */\nexport class MatrixRef {\n constructor(readonly layer: string, readonly row: string) {}\n}\n\n/**\n * The layer tokens. EVERY layer now cites a row: L0 through its two decision-matrix rows, L1 through\n * L1_ROWS, and L2 through L2_ROWS (see `matrixL2Row`). This used to say `'-'` was for \"a layer with no\n * row array YET (L2 is the un-converted one)\" — L2 is converted, and a comment describing a state the\n * code left behind is exactly the kind of doc a reader trusts and should not.\n *\n * `'-'` survives for ONE case, and it is a real one: an L2 reason that no row claims. See matrixL2Row.\n *\n * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the\n * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own\n * docblock argues against for `'BLOCK'`, where silence was the one wrong answer available.\n *\n * L0 NOW CITES ITS ROW TOO. It used to be `'-'` on the grounds that \"L0's faults are a table of letters\n * rather than numbered rows\" — but L0 has BOTH: the letter says WHICH fault, and the numbered decision\n * matrix (`renderGuardMatrixDoc`) says which of its three rows was taken. There is one ALLOW row and one\n * BLOCK row, so the pair `row=` + `fault=` pins the decision exactly, and the deny an agent reads now\n * carries the identical pair. That is the join: one grep spans the deny, the log line and the doc.\n * Two constants, not one `MATRIX_L0`, because the two call sites are a cure-bypass ALLOW and an L0\n * BLOCK — one token covering both is what made the row unciteable in the first place.\n */\nexport const MATRIX_L0_ALLOW = new MatrixRef('L0', L0_ROW_ALLOWLISTED);\nexport const MATRIX_L0_BLOCK = new MatrixRef('L0', L0_ROW_BLOCKED);\n\n/**\n * The L2 reference for one decision, with the row DERIVED FROM THE REASON.\n *\n * There is no `MATRIX_L2` constant any more, and its absence is the point: a single shared instance\n * meant every L2 line in the repo carried `row=-`, which reads as \"L2 has no rows\" rather than \"this\n * decision was not classified\". Deleting it makes every construction site name a reason, and the reason\n * is the only thing a call site has that identifies which row it is an instance of.\n *\n * `l2RowForReason` returns null for a reason no row claims, and that renders as `'-'` — visible in the\n * log, not silently absorbed into a default row. l2-matrix.spec.ts reads the four guard sources and\n * fails the build if any reason literal in them is unmapped, so `-` should never appear in practice;\n * when it does, it is a genuine hole in the table and the log says so.\n */\n// webpieces-disable no-function-outside-class -- a named constructor for MatrixRef beside it, in this module of module-scope writers\nexport function matrixL2Row(reason: string): MatrixRef {\n const row = l2RowForReason(reason);\n return new MatrixRef('L2', row === null ? '-' : String(row));\n}\n\n/**\n * The L2 stream, with NO row — for the two kinds of line that genuinely are not an instance of a row.\n *\n * 1. The runner's AGGREGATE bash lines (\"no bash-guard block\" / \"bash-guard block\"). They summarise\n * the whole guard set's answer for one command, not one row's verdict; the per-guard lines that\n * DO cite rows are written alongside them by the guards themselves.\n * 2. `whole-repo-build-guard`, which is not a branch-state policy at all — it is the experimental\n * home-config guard, has no webpieces.config.json entry, and shares this stream only because the\n * stream is \"bash decisions\", not \"L2 rows\".\n *\n * A NAMED constant rather than an inline `new MatrixRef('L2', '-')`, so `grep MATRIX_L2_UNROWED` lists\n * every uncited line and the list stays short and arguable. It is deliberately NOT called `MATRIX_L2`:\n * the old name was used by everything and made \"L2 has no rows\" indistinguishable from \"this line is\n * not a row\".\n */\nexport const MATRIX_L2_UNROWED = new MatrixRef('L2', '-');\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\n// `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard\n// decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted\n// (bash guards, on-main, config-bypass).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n cache: string;\n /**\n * The L0 fault this decision IS, in the codebook's letter (core/l0-fault-codes.ts), or `-` for an\n * ordinary rule decision. The `sh` shim has always stamped `fault=` on its own stream; the three\n * JS-side faults (S/C/Y) reached this one with no label at all, so `grep fault=S` found nothing\n * even while an S storm was blocking every call.\n */\n fault: string;\n /** Which layer + row decided this. See MatrixRef — it is what joins a log line to the doc. */\n matrix: MatrixRef;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string, cache: string = '-', fault: string, matrix: MatrixRef) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n this.cache = cache;\n this.fault = fault;\n this.matrix = matrix;\n }\n}\n\n/**\n * Append one tab-separated line per L2 decision to `.webpieces/logs/L2-decisions/<writer>.log`, where\n * <writer> is LogStream's `<sessionId>-<agentId|coordinator>-<hook>` key (a caller that never\n * identified renders as `unknown-coordinator-hook` — there is no un-keyed name).\n * `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or a\n * RepoRootFinder-resolved root at the pre-load config-bypass site — never a raw cwd, so a bypass\n * logged from a subdir never scatters a stray `.webpieces`). Swallows all errors — logging must never\n * block or fail a hook.\n */\n// webpieces-disable no-function-outside-class -- the module-scope writer this log has always been, beside branchForLog/oneLine/rotateLogFile; it must stay callable from a tree too broken to build a DI container\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n appendDecision(root, L2_DECISIONS_STREAM, decision);\n}\n\n/**\n * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.\n *\n * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name\n * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —\n * the exempt row and the three hand-down rows — wrote nothing at all. So \"L1 had no objection\" was\n * unobservable, and \"show me every L1 decision\" had no answer: L1 existed in the trail only as the\n * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.\n *\n * A SIBLING rather than a `base` parameter on logGuardDecision, deliberately: that signature is what\n * the process-wide `logStream` singleton exists to keep unchanged (see LogStream's docblock), and\n * `INVOCATION_LOG_FILE` already establishes the pattern of a second stream owning its own name in\n * this same module.\n */\n// webpieces-disable no-function-outside-class -- sibling of logGuardDecision, same module-scope writer shape and same reason\nexport function logL1Decision(root: string, decision: GuardDecision): void {\n appendDecision(root, L1_LOCATION_STREAM, decision);\n}\n\n// The one appender both streams share. `streamDir` is the LAYER; the writer key inside it is\n// logStream's session/agent/hook, which is what keeps one writer per file.\n// webpieces-disable no-function-outside-class -- the shared body of the two module-scope writers above\nfunction appendDecision(root: string, streamDir: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: a guard decision belongs to the tree it judged. WHO made the call is answered\n // by the filename, which logStream names with session/agent/hook.\n const logsDir = dotWebpieces.logsFile(root, streamDir);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n oneLine(decision.cache),\n // WHICH ROW of WHICH table decided this. The directory already carries the layer, but a\n // line quoted out of its file must still say what judged it — and `row=` is the join key\n // to the generated doc, which is the point of the whole exercise.\n `layer=${decision.matrix.layer}`,\n `row=${decision.matrix.row}`,\n // The tree this decision was actually made against, and what Claude Code told the hook the\n // project was. Appended (never reordered) for the same reason as on the invocation line —\n // see ClaudeEnv: when these two disagree, that disagreement is the bug.\n `root=${root}`,\n `projectDir=${claudeEnv.projectDirForLog()}`,\n // git's name for that tree — `primary`, else the worktree name. Same literal and same\n // derivation as the L0 shim log's `tree=` (shim-audit-log.ts), so one grep spans both\n // streams: L0 carries tree without projectDir, L1 now carries both.\n `tree=${dotWebpieces.worktreeName(root) || 'primary'}`,\n // APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault\n // this was, or `-`.\n `fault=${decision.fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n/**\n * What the guard SAW on one invocation, captured up front and held until the outcome is known.\n * Data-only (per CLAUDE.md: classes for data, explicit construction).\n */\nexport class GuardInvocation {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n public readonly root: string,\n public readonly timestamp: string,\n public readonly tool: string,\n public readonly target: string,\n public readonly branch: string,\n public readonly sync: string,\n public readonly projectDir: string,\n ) {}\n}\n\n/**\n * The per-INVOCATION stream — `.webpieces/logs/calls/<writer>.log` (see LogStream for the writer\n * key), one line for EVERY guards-hook\n * call (allow or block, bash or file), unlike `L2-decisions/` which records only the calls a\n * rule actually judged. It captures the tool, the command/file, the live git branch, the async-written\n * main-sync-status.json snapshot (branch / merged / fork-point / conflict), and — since this class\n * replaced a bare log-and-forget function — HOW THE CALL ENDED.\n *\n * WHY IT IS TWO CALLS. The line used to be written the moment the hook started, so it could not carry\n * a verdict: the decision had not been made yet. Answering \"what happened to this call?\" therefore\n * meant joining this file against the L2 decision stream BY TIMESTAMP, which is exactly the kind of\n * reconstruction a log exists to make unnecessary. So {@link begin} now only CAPTURES (including the\n * git/cache reads, which must still happen while the hook is running), and {@link finish} — called\n * from the hook's single terminal boundary, emitAllow/emitDeny — writes the whole line once the\n * outcome is known. The two streams stay distinct in purpose: this one is \"every call and how it\n * ended\", the decision log remains \"every judgement and why\".\n *\n * Every error is swallowed: logging must never block or fail a hook.\n */\nexport class InvocationLog {\n private pending: GuardInvocation | null = null;\n\n /**\n * Capture the context of one invocation. `cwd` is the AI's working dir; the repo root that owns\n * `.webpieces` is resolved from it. Writes NOTHING — {@link finish} does that.\n */\n begin(cwd: string, tool: string, target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const branch = branchForLog(root);\n // The cache is branch-keyed, so the entry to log is the one for the branch we are standing\n // on. 'unknown' (branchForLog's failure value) simply misses and logs 'sync=none'.\n const sync = summarizeSyncStatus(readMainSyncStatus(root, branch));\n this.pending = new GuardInvocation(root, new Date().toISOString(), tool, oneLine(target), branch, sync, claudeEnv.projectDirForLog());\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Write the captured line, now stamped with the outcome. A no-op when nothing was captured (the\n * 'rules' hook, or a terminal boundary reached before begin()), and it clears the pending entry so\n * a second emit cannot double-log.\n *\n * `rule` is the rule that blocked, or '-' when there is none; `fault` is the L0 fault code when this\n * call ended on one (S/C/Y — the JS-side faults), else '-'. FIELD ORDER IS APPEND-ONLY: the five\n * original fields keep their positions (cleanup automation mines this file), and `guards=` /\n * `rule=` / … / `fault=` are added at the end.\n */\n finish(verdict: Verdict, rule: string, fault: string = L0_FAULT_NONE): void {\n const invocation = this.pending;\n this.pending = null;\n if (invocation === null) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const logsDir = dotWebpieces.logsFile(invocation.root, CALLS_STREAM);\n fs.mkdirSync(logsDir, { recursive: true });\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${invocation.timestamp}]`,\n invocation.tool,\n invocation.target,\n `branch=${invocation.branch}`,\n invocation.sync,\n // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all\n // its PreToolUse hooks IN PARALLEL, so another hook process may deny a call this one\n // had no objection to, and neither can see the other's answer. Measured under the\n // RETIRED three-hook form: `cd <repo>/packages && ls` was DENIED by L-1 and recorded\n // here three times as `verdict=ALLOW`. The old field name promised an outcome it\n // structurally cannot know, so the name stayed even though L-1 is gone: the two\n // surviving hooks still run in parallel and still cannot see each other.\n `guards=${verdict}`,\n `rule=${oneLine(rule) || '-'}`,\n // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on\n // every line, because the diagnostic value is entirely in comparing them — see\n // ClaudeEnv for the open question this field exists to settle empirically.\n `root=${invocation.root}`,\n `projectDir=${invocation.projectDir}`,\n // See logGuardDecision: the short tree label, so `tree=primary` with a matching\n // projectDir reads as healthy at a glance and `tree=<worktree>` beside a projectDir\n // pointing at the primary is the straddle, without diffing two absolute paths.\n `tree=${dotWebpieces.worktreeName(invocation.root) || 'primary'}`,\n // WHICH L0 fault ended this call, in the same letters and the same field name the L0 sh\n // shim uses (the `L0-shim/` stream) — so ONE grep spans the whole trail.\n `fault=${fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Process-wide instance: one hook process handles exactly one tool call, so a single pending entry is\n// the whole state there is. Module-scope (rather than DI) because the terminal boundary that flushes\n// it — emitAllow/emitDeny — is itself module-scope protocol code with no container in reach.\nexport const invocationLog = new InvocationLog();\n\n// One-field summary of main-sync-status.json for the invocation log: the branch the cache is FOR,\n// whether it is already merged (and its PR), fork-point presence, and conflict state — the signals a\n// cleanup step keys off. 'sync=none' when the cache has not been written yet (first call of a session).\nfunction summarizeSyncStatus(status: MainSyncStatus | null): string {\n if (status === null) return 'sync=none';\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `sync=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\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 error;\n }\n}\n"]}
@@ -54,9 +54,12 @@ function renderHead() {
54
54
  '',
55
55
  '**Goal: is this call ours to judge, is this tree governed by the release it asks for, and is git being run from the root?**',
56
56
  '',
57
- '**Config key: `location-guard` (proposed).** Force-to-root and trinary-version-skew both have **no',
58
- 'config key today** and cannot be disabled; `excludePaths` is a top-level block, not a `hookGuards`',
59
- 'entry.',
57
+ '**Config key: none, and none is proposed.** Force-to-root and trinary-version-skew have **no config',
58
+ 'key** and cannot be disabled; `excludePaths` is a top-level block, not a `hookGuards` entry. A',
59
+ '`location-guard` key was once proposed here — it never existed, and `hookGuards` has just gone from',
60
+ 'nine keys to three, so adding a tenth-turned-fourth for a layer nobody has asked to switch off would',
61
+ 'run against the whole point. L0 has no key for the stronger version of the same reason: a layer that',
62
+ 'decides whether the tooling can be trusted cannot be configured by the file it has not validated yet.',
60
63
  '',
61
64
  '',
62
65
  '**Code:** `packages/tooling/ai-hook-rules/src/core/effective-tree.ts` (`EffectiveTreeResolver`,',
@@ -1 +1 @@
1
- {"version":3,"file":"l1-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l1-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L1Row, L1UseCase, L1_ROWS, L1_PRESTAGE_ROW, allL1UseCases } from './l1-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L1-location.md, rendered from L1_ROWS.\n//\n// Same arrangement as l0-matrix.renderGuardMatrixDoc(): one join('\\n') of literal markdown lines with\n// the ROW DATA interpolated from the array the guard consults. Everything that is not row data — every\n// prose section — is a literal line here, because that is the half a generator cannot own.\n//\n// A unit test (l1-matrix.spec.ts) locks guards/L1-location.md byte-identical to renderL1Doc(), and\n// `pnpm guards:generate` rewrites the file. So the table in the doc IS the array, not a description of\n// it. This module, like l1-rows.ts, has no runtime imports outside this pair so the generator can load\n// it without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n/** A dimension cell: the wildcard renders bare, every value renders as code. */\n// webpieces-disable no-function-outside-class -- pure cell formatter for renderL1Doc below, in this render module\nfunction cell(value: string): string {\n return value === '-' ? '-' : `\\`${value}\\``;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL1Doc below, in this render module\nfunction tableRow(row: L1Row): string {\n const dims = [row.k, row.a, row.r, row.g, row.p].map(cell).join(' | ');\n // Row 6 has no `why` — an EMPTY cell is `| |`, not `| |`. Two spaces would render the same in a\n // browser and fail the byte-lock, which is the whole point of locking bytes rather than markdown.\n const why = row.why === '' ? ' ' : ` ${row.why} `;\n return `| ${row.num} | ${dims} | ${row.action.label} |${why}|`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL1Doc below, in this render module\nfunction useCaseRow(useCase: L1UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L1-location.md.\n *\n * Split into three consecutive halves purely to stay inside the method-line budget — the join order is\n * what makes them one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L1_ROWS, beside the array it reads\nexport function renderL1Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderUseCases(),\n ...renderTail(),\n ].join('\\n');\n}\n\n\n// The three questions L1 answers, the preamble and the filter — all prose, none of it row data.\n// webpieces-disable no-function-outside-class -- first section of renderL1Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L1 — location',\n '',\n '**Goal: is this call ours to judge, is this tree governed by the release it asks for, and is git being run from the root?**',\n '',\n '**Config key: `location-guard` (proposed).** Force-to-root and trinary-version-skew both have **no',\n 'config key today** and cannot be disabled; `excludePaths` is a top-level block, not a `hookGuards`',\n 'entry.',\n '',\n '',\n '**Code:** `packages/tooling/ai-hook-rules/src/core/effective-tree.ts` (`EffectiveTreeResolver`,',\n '`TreeKind`) · `packages/tooling/ai-hook-rules/src/core/runner.ts` (`l1LocationBlock`,',\n '`filterByExcludedPaths`, the `foreign` check) · `.../force-to-root.ts` (`ForceToRootGuard`) ·',\n '`packages/tooling/ai-hook-rules/src/core/missing-directory.ts` (`MissingDirectoryGuard`) ·',\n '`packages/tooling/ai-hook-rules/src/core/version-sync.ts` (`VersionSyncGuard`,',\n '`WebpiecesVersions`).',\n '',\n 'L1 answers four questions, and they are genuinely separate:',\n '',\n '1. **Do we govern this at all?** — the escape hatches, for other repos and non-governed paths.',\n ' Answered by asking GIT (`--git-common-dir`), never by path math: see the legend under **K**.',\n '2. **Does the directory still EXIST?** — row 7. A worktree reaped out from under a live shell leaves',\n ' a cwd that names nothing, and that state needs its own name and its own message, because the',\n ' remedy for \"you are in a subdirectory\" is a `cd` back into the very directory that is gone.',\n '3. **Is this tree governed by a release it did not ask for?** — row 8. A worktree MAY have its own',\n ' `node_modules` (nx, vitest and the eslint plugin all execute there and load from it), and when it',\n ' has none the shim\\'s upward walk runs the main tree\\'s binary. Either way the rule is the same and',\n ' holds whichever registration form — absolute or relative — is live in the consumer: the two trees',\n ' must PIN the same `@webpieces`, or the worktree is linted, validated and built by a release its',\n ' own manifest does not ask for. Asked of the PATH acted on, never of who is asking —',\n ' agent identity was measured untrustworthy for tree detection (a worktree-isolated agent whose tree',\n ' is auto-reaped at a turn boundary silently resumes on the primary clone).',\n '4. **Is the agent stranded away from the root?** — force-to-root, git/gh only. Agents forget where',\n ' they are constantly, and `cd` gives them two different ways to be wrong: a `cd` that stays INSIDE',\n ' the workspace PERSISTS to later calls (so the shell can be parked in a subdirectory left by an',\n ' unrelated command turns earlier), while a `cd` that LEAVES it is reset by the harness, which says',\n ' so — `Shell cwd was reset to <root>`. Neither can be assumed, which is why every remedy names the',\n ' root explicitly instead of telling the agent to `cd` first.',\n '',\n '## Preamble — resolve the target first (Bash only)',\n '',\n '`EffectiveTreeResolver.resolve()` computes `effectiveCwd`: the directory the command actually runs in,',\n 'which is the shell\\'s cwd unless the command leads with `cd <dir> &&`. **K is classified from',\n '`effectiveCwd`, not from the shell\\'s cwd** — so \"a foreign repo that `cd`s into ours\" is not a cell,',\n 'it is simply `pw` after resolution.',\n '',\n 'That holds because K is resolved by ASKING GIT about `effectiveCwd`, not by testing whether the path',\n 'is lexically under the governed root. It has to be said that way round: the resolver used to do the',\n 'path test, and a linked worktree under `.claude/worktrees/**` therefore resolved `f` — every bash',\n 'guard exempt in the one sandbox agents are told to work in. A sentence in this file asserted the',\n 'opposite as fact for several releases, which is how it went unnoticed.',\n '',\n 'Only a LEADING run of `cd`/`pushd` counts. A *trailing* `… && cd <exempt-tree>` must never',\n 'retroactively pull a command out of scope — that would smuggle a root-level `git push` past the',\n 'guards. Quoting is handled by `ShellSegmentScan`, so `echo \"cd sub && git push\"` is one opaque',\n 'segment and its quoted `cd` is never picked up.',\n '',\n ...renderFilterSection(),\n ];\n}\n\n// `excludePaths` — a FILTER over the rule list, not a dimension of the table. Its own function because\n// renderHead is at the 70-line method cap, and because this section is one self-contained argument.\n// webpieces-disable no-function-outside-class -- prose section of renderL1Doc's string, beside it in this module\nfunction renderFilterSection(): string[] {\n return [\n '## Filter — not a dimension (all tools)',\n '',\n '`filterByExcludedPaths` drops every rule excluded for this path: the **target path** for',\n 'Read/Write/Edit, `effectiveCwd` for Bash. An empty rule list means allow. This is a filter, not a row:',\n '\"exempt\" is what emerges when the list empties.',\n '',\n '`excludePaths` is **ONE glob list** (canonical: `\"excludePaths\": [\"repositories/**\"]`). The',\n '`{ rules: [...], guards: [...] }` object is **retired and rejected**, with the union it must become',\n 'named in the error. `wp-install-ai-hooks` migrates it in place.',\n '',\n 'This used to be a tolerated fallback, justified here by \"rejecting it would block every Bash/Edit',\n 'including the edit that would fix it.\" **That was never true**, and the fallback it licensed is why',\n 'consumer configs — this repo\\'s own included — sat on the dead shape for releases. A Write/Edit whose',\n 'target is `webpieces.config.json` is an unconditional **PASS** (see the L0 table above), and',\n '`pnpm install` has an installer bypass, so an invalid config can always be repaired from inside the',\n 'block. Config rejection is self-recoverable by construction; see `retired-config-keys.ts` for the',\n 'policy and the reasoning.',\n '',\n ];\n}\n\n// The legend, the table itself (ROW DATA), and the note on the two structural blocks.\n// webpieces-disable no-function-outside-class -- second section of renderL1Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Legend',\n '',\n '| col | dimension | values |',\n '|---|---|---|',\n '| **K** | tree kind of the resolved target, from git\\'s own dirs | `f` foreign repo (a DIFFERENT `--git-common-dir`) · `m` the directory does not exist · `o` outside any repo · `w` a LINKED worktree of ours (`--git-dir` ≠ `--git-common-dir`), wherever it sits on disk · `pw` ours (primary **or** worktree) |',\n '| **V** | do the `@webpieces` versions agree between this worktree and the MAIN tree | `n` skewed · `y` in sync |',\n '| **R** | command is provably read-only inspection | `n` · `y` |',\n '| **G** | command invokes git/gh | `n` · `y` |',\n '| **P** | position of the resolved target | `root` · `sub` |',\n '',\n 'All of them are **Bash only**. Read/Write/Edit resolve their own target (`input.filePath`) and have no',\n 'dimensions — the filter is all that applies to them. **The Read tool is never blocked by L1.**',\n '',\n 'A linked worktree is deliberately **not** foreign: it is the same project, so the guards run against',\n 'THAT tree\\'s branch and cache. Every rule-scoped guard treats `p` and `w` alike, hence `pw`; row 8 below',\n 'is the ONE place they separate, and it turns on **V** — the versions, read off the tree itself.',\n '',\n 'PLACEMENT IS NOT IDENTITY. A worktree checked out INSIDE the repo — `<repo>/.claude/worktrees/agent-XXXX`,',\n 'which is where Claude Code puts every agent worktree — is `w` exactly like a sibling `../feature-dir` one.',\n 'K comes from git\\'s own dirs (`--git-common-dir` is identical for every checkout of one repo,',\n '`--git-dir` differs only in a linked worktree), never from whether the path sits under the governed root.',\n 'It used to short-circuit on that path test, so an in-repo worktree read as `f` — every bash guard exempt,',\n 'and row 8 unreachable, for the only layout the harness actually produces. A nested clone under',\n '`repositories/**` still reads `f`, because its shared git dir is its own.',\n '',\n '`V` comes from reading manifests off disk — the MAIN tree\\'s `pnpm-workspace.yaml` catalog pin, its',\n 'installed `node_modules` version, this worktree\\'s pin, and this worktree\\'s own installed version when',\n 'it has one (which it does the moment anyone runs `pnpm add` there). Three always, a fourth when',\n 'present. Anything unreadable is NO OPINION, never skew: a guard that cannot measure must not block.',\n 'It is deliberately NOT read from `agent_id`/`agent_type` — the dimension this replaced was, and a',\n 'worktree-isolated agent was measured resuming on the primary clone after its tree was reaped, so who',\n 'is asking cannot be trusted to say which tree is being acted on.',\n '',\n '`R` is `ReadOnlyInspectionScan` — the same paranoid \"provably inert\" test the unloadable-config escape',\n 'hatch uses (allowlisted viewers/searchers only, no redirects, no `sed -i`).',\n '',\n '## Table',\n '',\n '| # | K | V | R | G | P | act | why |',\n '|---|---|---|---|---|---|---|---|',\n // Row 0 is the PRE-STAGE (`misplacedCdBlock`). It decides from command TEXT before a tree is\n // resolved, so it cannot be classified over the five dimensions rows 1-6 share — but it IS an\n // L1 block, and an L1 block the table did not describe is exactly the drift this table exists\n // to prevent. It is numbered 0, not 7, because it does not sit in the first-match scan; and it\n // is PRINTED because `row=0` in the L1 log has to join to something.\n `| ${L1_PRESTAGE_ROW} | – | – | – | – | – | 4 block | a \\`cd\\` that is not leading + literal, judged before any tree is resolved |`,\n ...L1_ROWS.map(tableRow),\n '',\n 'Rows 3, 5 and 7 are the structural blocks, and they run as ONE step (`l1LocationBlock` in `runner.ts`)',\n 'so they can never be reordered by accident — row 7 (the directory is gone) first, then row 8, then',\n 'force-to-root. Row 7 is printed LAST above only because row numbers are stable across releases and',\n 'renumbering 1-6 would invalidate every `row=` in the logs; `m` matches no other row, so its position',\n 'in the scan is immaterial. All three sit after the L0',\n 'allowlist, after the `f` check, and after the `excludePaths` filter and the config-sync check. So a',\n 'cure (`cd <worktree> && pnpm install`) still reaches any tree: that is L0\\'s invariant, and row 8 does',\n 'not weaken it.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own numbering) and the two notes that follow it.\n// webpieces-disable no-function-outside-class -- third section of renderL1Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L1 use cases',\n '',\n 'Same row shape as L0: the **Fix** is literal or it is not a fix. `<root>` is the absolute workspace',\n 'root — the messages name it explicitly rather than telling you to `cd` first, for the reason in the',\n 'section head (neither the shell\\'s cwd nor a `cd`\\'s persistence can be assumed).',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL1UseCases().map(useCaseRow),\n '',\n 'Row 8 is the one that changed. It used to be ALLOWED, because the predicate was',\n '`shellAtRoot || cdsToRoot` — two variables OR\\'d, so the same destination got opposite verdicts',\n 'depending on where the shell happened to start. It is now one variable, `effectiveCwd === root`.',\n '',\n 'Row 12 is the incident that produced table row 8, and it is a VERSION SKEW incident — which is why',\n 'the row that replaced it measures versions rather than agent identity. The coordinator ran',\n '`git worktree add`, `cd`\\'d in,',\n 'and worked there. An L0 version-drift fault then fired against the PRIMARY (pin `0.4.545` vs',\n '`node_modules` `0.4.526`) and prescribed `pnpm install` — which ran in the WORKTREE, internally',\n 'consistent at `0.4.526`/`0.4.526`, so it succeeded, changed nothing in the measured tree, and the guard',\n 're-denied. Five identical installs later the agent had invented a theory about the harness stripping',\n 'its `cd` and handed the problem to the human. Note what row 15 says: the fix is NOT to deny that',\n 'install. It is to make the split state unreachable, so the wrong-tree install is never plausible.',\n '',\n ];\n}\n\n// The known gap and the code anchors — prose, and the one section that must never be summarised away:\n// three code comments point at it.\n// webpieces-disable no-function-outside-class -- last section of renderL1Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — `o` is not exempt yet',\n '',\n 'Row 2 hands `\\'outside\\'` down to L2 rather than exempting it. `\\'outside\\'` is produced at',\n '`effective-tree.ts` (git has no answer for the directory) carrying `governedRoot`, and **no code branches on it**, so a',\n 'command in no git repo is judged against the governed repo\\'s branch and staleness state. That is a',\n 'wrong verdict, and `exempt` is the right action.',\n '',\n '**It must not ship alone.** Jurisdiction comes from the shell cwd, not from what the command touches,',\n 'so exempting `o` opens a bypass an agent reaches by typing `cd /tmp &&`:',\n '',\n '| command | today | with `o → exempt` alone |',\n '|---|---|---|',\n '| `cd /tmp && ls` | judged against the repo | exempt — **correct** |',\n '| `cd /tmp && git -C $REPO commit` | L2 guards fire | exempt — **every L2 guard bypassed** |',\n '| `cd /tmp && rm -rf $REPO/packages/http/src` | judged | exempt — **unguarded** |',\n '',\n 'The two cases only separate once jurisdiction is judged on **what the command touches** (explicit',\n '`git -C` / `--work-tree`, then path arguments, then the `cd`, then the shell cwd), with the fail-safe',\n 'rule that **any** resolved target inside `governedRoot` means `pw`. Ship the two together, or neither.',\n '',\n 'Tracked in `backlog/bug-bash-guards-judge-the-shell-cwd-not-the-paths-the-command-touches.md` and',\n '`backlog/bug-outside-tree-kind-is-never-consumed-so-a-non-git-dir-is-judged-against-the-governed-repo.md`.',\n 'That resolver has three consumers — L1\\'s K, L2\\'s scope dimension, and `excludePaths` on the Bash path',\n '— which is why the backlog says **fix once**.',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| resolver, K | `ai-hook-rules/src/core/effective-tree.ts` | `EffectiveTreeResolver`, `TreeKind` |',\n '| the two structural blocks, in order | `ai-hook-rules/src/core/runner.ts` | `l1LocationBlock` |',\n '| trinary-version-skew (row 8), V, R | `ai-hook-rules/src/core/version-sync.ts` | `VersionSyncGuard`, `WebpiecesVersions` |',\n '| force-to-root (row 5) | `ai-hook-rules/src/core/force-to-root.ts` | `ForceToRootGuard` |',\n '| the directory is gone (row 7) | `ai-hook-rules/src/core/missing-directory.ts` | `MissingDirectoryGuard` |',\n '| the filter | `ai-hook-rules/src/core/runner.ts` | `filterByExcludedPaths` |',\n '| `excludePaths` shape | `rules-config/src/exclude-hook-paths.ts`, `validate-config.ts`, `retired-config-keys.ts` | `ExcludePaths`, `validateExcludePaths` |',\n '',\n ];\n}\n"]}
1
+ {"version":3,"file":"l1-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l1-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L1Row, L1UseCase, L1_ROWS, L1_PRESTAGE_ROW, allL1UseCases } from './l1-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L1-location.md, rendered from L1_ROWS.\n//\n// Same arrangement as l0-matrix.renderGuardMatrixDoc(): one join('\\n') of literal markdown lines with\n// the ROW DATA interpolated from the array the guard consults. Everything that is not row data — every\n// prose section — is a literal line here, because that is the half a generator cannot own.\n//\n// A unit test (l1-matrix.spec.ts) locks guards/L1-location.md byte-identical to renderL1Doc(), and\n// `pnpm guards:generate` rewrites the file. So the table in the doc IS the array, not a description of\n// it. This module, like l1-rows.ts, has no runtime imports outside this pair so the generator can load\n// it without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n/** A dimension cell: the wildcard renders bare, every value renders as code. */\n// webpieces-disable no-function-outside-class -- pure cell formatter for renderL1Doc below, in this render module\nfunction cell(value: string): string {\n return value === '-' ? '-' : `\\`${value}\\``;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL1Doc below, in this render module\nfunction tableRow(row: L1Row): string {\n const dims = [row.k, row.a, row.r, row.g, row.p].map(cell).join(' | ');\n // Row 6 has no `why` — an EMPTY cell is `| |`, not `| |`. Two spaces would render the same in a\n // browser and fail the byte-lock, which is the whole point of locking bytes rather than markdown.\n const why = row.why === '' ? ' ' : ` ${row.why} `;\n return `| ${row.num} | ${dims} | ${row.action.label} |${why}|`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL1Doc below, in this render module\nfunction useCaseRow(useCase: L1UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L1-location.md.\n *\n * Split into three consecutive halves purely to stay inside the method-line budget — the join order is\n * what makes them one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L1_ROWS, beside the array it reads\nexport function renderL1Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderUseCases(),\n ...renderTail(),\n ].join('\\n');\n}\n\n\n// The three questions L1 answers, the preamble and the filter — all prose, none of it row data.\n// webpieces-disable no-function-outside-class -- first section of renderL1Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L1 — location',\n '',\n '**Goal: is this call ours to judge, is this tree governed by the release it asks for, and is git being run from the root?**',\n '',\n '**Config key: none, and none is proposed.** Force-to-root and trinary-version-skew have **no config',\n 'key** and cannot be disabled; `excludePaths` is a top-level block, not a `hookGuards` entry. A',\n '`location-guard` key was once proposed here — it never existed, and `hookGuards` has just gone from',\n 'nine keys to three, so adding a tenth-turned-fourth for a layer nobody has asked to switch off would',\n 'run against the whole point. L0 has no key for the stronger version of the same reason: a layer that',\n 'decides whether the tooling can be trusted cannot be configured by the file it has not validated yet.',\n '',\n '',\n '**Code:** `packages/tooling/ai-hook-rules/src/core/effective-tree.ts` (`EffectiveTreeResolver`,',\n '`TreeKind`) · `packages/tooling/ai-hook-rules/src/core/runner.ts` (`l1LocationBlock`,',\n '`filterByExcludedPaths`, the `foreign` check) · `.../force-to-root.ts` (`ForceToRootGuard`) ·',\n '`packages/tooling/ai-hook-rules/src/core/missing-directory.ts` (`MissingDirectoryGuard`) ·',\n '`packages/tooling/ai-hook-rules/src/core/version-sync.ts` (`VersionSyncGuard`,',\n '`WebpiecesVersions`).',\n '',\n 'L1 answers four questions, and they are genuinely separate:',\n '',\n '1. **Do we govern this at all?** — the escape hatches, for other repos and non-governed paths.',\n ' Answered by asking GIT (`--git-common-dir`), never by path math: see the legend under **K**.',\n '2. **Does the directory still EXIST?** — row 7. A worktree reaped out from under a live shell leaves',\n ' a cwd that names nothing, and that state needs its own name and its own message, because the',\n ' remedy for \"you are in a subdirectory\" is a `cd` back into the very directory that is gone.',\n '3. **Is this tree governed by a release it did not ask for?** — row 8. A worktree MAY have its own',\n ' `node_modules` (nx, vitest and the eslint plugin all execute there and load from it), and when it',\n ' has none the shim\\'s upward walk runs the main tree\\'s binary. Either way the rule is the same and',\n ' holds whichever registration form — absolute or relative — is live in the consumer: the two trees',\n ' must PIN the same `@webpieces`, or the worktree is linted, validated and built by a release its',\n ' own manifest does not ask for. Asked of the PATH acted on, never of who is asking —',\n ' agent identity was measured untrustworthy for tree detection (a worktree-isolated agent whose tree',\n ' is auto-reaped at a turn boundary silently resumes on the primary clone).',\n '4. **Is the agent stranded away from the root?** — force-to-root, git/gh only. Agents forget where',\n ' they are constantly, and `cd` gives them two different ways to be wrong: a `cd` that stays INSIDE',\n ' the workspace PERSISTS to later calls (so the shell can be parked in a subdirectory left by an',\n ' unrelated command turns earlier), while a `cd` that LEAVES it is reset by the harness, which says',\n ' so — `Shell cwd was reset to <root>`. Neither can be assumed, which is why every remedy names the',\n ' root explicitly instead of telling the agent to `cd` first.',\n '',\n '## Preamble — resolve the target first (Bash only)',\n '',\n '`EffectiveTreeResolver.resolve()` computes `effectiveCwd`: the directory the command actually runs in,',\n 'which is the shell\\'s cwd unless the command leads with `cd <dir> &&`. **K is classified from',\n '`effectiveCwd`, not from the shell\\'s cwd** — so \"a foreign repo that `cd`s into ours\" is not a cell,',\n 'it is simply `pw` after resolution.',\n '',\n 'That holds because K is resolved by ASKING GIT about `effectiveCwd`, not by testing whether the path',\n 'is lexically under the governed root. It has to be said that way round: the resolver used to do the',\n 'path test, and a linked worktree under `.claude/worktrees/**` therefore resolved `f` — every bash',\n 'guard exempt in the one sandbox agents are told to work in. A sentence in this file asserted the',\n 'opposite as fact for several releases, which is how it went unnoticed.',\n '',\n 'Only a LEADING run of `cd`/`pushd` counts. A *trailing* `… && cd <exempt-tree>` must never',\n 'retroactively pull a command out of scope — that would smuggle a root-level `git push` past the',\n 'guards. Quoting is handled by `ShellSegmentScan`, so `echo \"cd sub && git push\"` is one opaque',\n 'segment and its quoted `cd` is never picked up.',\n '',\n ...renderFilterSection(),\n ];\n}\n\n// `excludePaths` — a FILTER over the rule list, not a dimension of the table. Its own function because\n// renderHead is at the 70-line method cap, and because this section is one self-contained argument.\n// webpieces-disable no-function-outside-class -- prose section of renderL1Doc's string, beside it in this module\nfunction renderFilterSection(): string[] {\n return [\n '## Filter — not a dimension (all tools)',\n '',\n '`filterByExcludedPaths` drops every rule excluded for this path: the **target path** for',\n 'Read/Write/Edit, `effectiveCwd` for Bash. An empty rule list means allow. This is a filter, not a row:',\n '\"exempt\" is what emerges when the list empties.',\n '',\n '`excludePaths` is **ONE glob list** (canonical: `\"excludePaths\": [\"repositories/**\"]`). The',\n '`{ rules: [...], guards: [...] }` object is **retired and rejected**, with the union it must become',\n 'named in the error. `wp-install-ai-hooks` migrates it in place.',\n '',\n 'This used to be a tolerated fallback, justified here by \"rejecting it would block every Bash/Edit',\n 'including the edit that would fix it.\" **That was never true**, and the fallback it licensed is why',\n 'consumer configs — this repo\\'s own included — sat on the dead shape for releases. A Write/Edit whose',\n 'target is `webpieces.config.json` is an unconditional **PASS** (see the L0 table above), and',\n '`pnpm install` has an installer bypass, so an invalid config can always be repaired from inside the',\n 'block. Config rejection is self-recoverable by construction; see `retired-config-keys.ts` for the',\n 'policy and the reasoning.',\n '',\n ];\n}\n\n// The legend, the table itself (ROW DATA), and the note on the two structural blocks.\n// webpieces-disable no-function-outside-class -- second section of renderL1Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Legend',\n '',\n '| col | dimension | values |',\n '|---|---|---|',\n '| **K** | tree kind of the resolved target, from git\\'s own dirs | `f` foreign repo (a DIFFERENT `--git-common-dir`) · `m` the directory does not exist · `o` outside any repo · `w` a LINKED worktree of ours (`--git-dir` ≠ `--git-common-dir`), wherever it sits on disk · `pw` ours (primary **or** worktree) |',\n '| **V** | do the `@webpieces` versions agree between this worktree and the MAIN tree | `n` skewed · `y` in sync |',\n '| **R** | command is provably read-only inspection | `n` · `y` |',\n '| **G** | command invokes git/gh | `n` · `y` |',\n '| **P** | position of the resolved target | `root` · `sub` |',\n '',\n 'All of them are **Bash only**. Read/Write/Edit resolve their own target (`input.filePath`) and have no',\n 'dimensions — the filter is all that applies to them. **The Read tool is never blocked by L1.**',\n '',\n 'A linked worktree is deliberately **not** foreign: it is the same project, so the guards run against',\n 'THAT tree\\'s branch and cache. Every rule-scoped guard treats `p` and `w` alike, hence `pw`; row 8 below',\n 'is the ONE place they separate, and it turns on **V** — the versions, read off the tree itself.',\n '',\n 'PLACEMENT IS NOT IDENTITY. A worktree checked out INSIDE the repo — `<repo>/.claude/worktrees/agent-XXXX`,',\n 'which is where Claude Code puts every agent worktree — is `w` exactly like a sibling `../feature-dir` one.',\n 'K comes from git\\'s own dirs (`--git-common-dir` is identical for every checkout of one repo,',\n '`--git-dir` differs only in a linked worktree), never from whether the path sits under the governed root.',\n 'It used to short-circuit on that path test, so an in-repo worktree read as `f` — every bash guard exempt,',\n 'and row 8 unreachable, for the only layout the harness actually produces. A nested clone under',\n '`repositories/**` still reads `f`, because its shared git dir is its own.',\n '',\n '`V` comes from reading manifests off disk — the MAIN tree\\'s `pnpm-workspace.yaml` catalog pin, its',\n 'installed `node_modules` version, this worktree\\'s pin, and this worktree\\'s own installed version when',\n 'it has one (which it does the moment anyone runs `pnpm add` there). Three always, a fourth when',\n 'present. Anything unreadable is NO OPINION, never skew: a guard that cannot measure must not block.',\n 'It is deliberately NOT read from `agent_id`/`agent_type` — the dimension this replaced was, and a',\n 'worktree-isolated agent was measured resuming on the primary clone after its tree was reaped, so who',\n 'is asking cannot be trusted to say which tree is being acted on.',\n '',\n '`R` is `ReadOnlyInspectionScan` — the same paranoid \"provably inert\" test the unloadable-config escape',\n 'hatch uses (allowlisted viewers/searchers only, no redirects, no `sed -i`).',\n '',\n '## Table',\n '',\n '| # | K | V | R | G | P | act | why |',\n '|---|---|---|---|---|---|---|---|',\n // Row 0 is the PRE-STAGE (`misplacedCdBlock`). It decides from command TEXT before a tree is\n // resolved, so it cannot be classified over the five dimensions rows 1-6 share — but it IS an\n // L1 block, and an L1 block the table did not describe is exactly the drift this table exists\n // to prevent. It is numbered 0, not 7, because it does not sit in the first-match scan; and it\n // is PRINTED because `row=0` in the L1 log has to join to something.\n `| ${L1_PRESTAGE_ROW} | – | – | – | – | – | 4 block | a \\`cd\\` that is not leading + literal, judged before any tree is resolved |`,\n ...L1_ROWS.map(tableRow),\n '',\n 'Rows 3, 5 and 7 are the structural blocks, and they run as ONE step (`l1LocationBlock` in `runner.ts`)',\n 'so they can never be reordered by accident — row 7 (the directory is gone) first, then row 8, then',\n 'force-to-root. Row 7 is printed LAST above only because row numbers are stable across releases and',\n 'renumbering 1-6 would invalidate every `row=` in the logs; `m` matches no other row, so its position',\n 'in the scan is immaterial. All three sit after the L0',\n 'allowlist, after the `f` check, and after the `excludePaths` filter and the config-sync check. So a',\n 'cure (`cd <worktree> && pnpm install`) still reaches any tree: that is L0\\'s invariant, and row 8 does',\n 'not weaken it.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own numbering) and the two notes that follow it.\n// webpieces-disable no-function-outside-class -- third section of renderL1Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L1 use cases',\n '',\n 'Same row shape as L0: the **Fix** is literal or it is not a fix. `<root>` is the absolute workspace',\n 'root — the messages name it explicitly rather than telling you to `cd` first, for the reason in the',\n 'section head (neither the shell\\'s cwd nor a `cd`\\'s persistence can be assumed).',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL1UseCases().map(useCaseRow),\n '',\n 'Row 8 is the one that changed. It used to be ALLOWED, because the predicate was',\n '`shellAtRoot || cdsToRoot` — two variables OR\\'d, so the same destination got opposite verdicts',\n 'depending on where the shell happened to start. It is now one variable, `effectiveCwd === root`.',\n '',\n 'Row 12 is the incident that produced table row 8, and it is a VERSION SKEW incident — which is why',\n 'the row that replaced it measures versions rather than agent identity. The coordinator ran',\n '`git worktree add`, `cd`\\'d in,',\n 'and worked there. An L0 version-drift fault then fired against the PRIMARY (pin `0.4.545` vs',\n '`node_modules` `0.4.526`) and prescribed `pnpm install` — which ran in the WORKTREE, internally',\n 'consistent at `0.4.526`/`0.4.526`, so it succeeded, changed nothing in the measured tree, and the guard',\n 're-denied. Five identical installs later the agent had invented a theory about the harness stripping',\n 'its `cd` and handed the problem to the human. Note what row 15 says: the fix is NOT to deny that',\n 'install. It is to make the split state unreachable, so the wrong-tree install is never plausible.',\n '',\n ];\n}\n\n// The known gap and the code anchors — prose, and the one section that must never be summarised away:\n// three code comments point at it.\n// webpieces-disable no-function-outside-class -- last section of renderL1Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — `o` is not exempt yet',\n '',\n 'Row 2 hands `\\'outside\\'` down to L2 rather than exempting it. `\\'outside\\'` is produced at',\n '`effective-tree.ts` (git has no answer for the directory) carrying `governedRoot`, and **no code branches on it**, so a',\n 'command in no git repo is judged against the governed repo\\'s branch and staleness state. That is a',\n 'wrong verdict, and `exempt` is the right action.',\n '',\n '**It must not ship alone.** Jurisdiction comes from the shell cwd, not from what the command touches,',\n 'so exempting `o` opens a bypass an agent reaches by typing `cd /tmp &&`:',\n '',\n '| command | today | with `o → exempt` alone |',\n '|---|---|---|',\n '| `cd /tmp && ls` | judged against the repo | exempt — **correct** |',\n '| `cd /tmp && git -C $REPO commit` | L2 guards fire | exempt — **every L2 guard bypassed** |',\n '| `cd /tmp && rm -rf $REPO/packages/http/src` | judged | exempt — **unguarded** |',\n '',\n 'The two cases only separate once jurisdiction is judged on **what the command touches** (explicit',\n '`git -C` / `--work-tree`, then path arguments, then the `cd`, then the shell cwd), with the fail-safe',\n 'rule that **any** resolved target inside `governedRoot` means `pw`. Ship the two together, or neither.',\n '',\n 'Tracked in `backlog/bug-bash-guards-judge-the-shell-cwd-not-the-paths-the-command-touches.md` and',\n '`backlog/bug-outside-tree-kind-is-never-consumed-so-a-non-git-dir-is-judged-against-the-governed-repo.md`.',\n 'That resolver has three consumers — L1\\'s K, L2\\'s scope dimension, and `excludePaths` on the Bash path',\n '— which is why the backlog says **fix once**.',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| resolver, K | `ai-hook-rules/src/core/effective-tree.ts` | `EffectiveTreeResolver`, `TreeKind` |',\n '| the two structural blocks, in order | `ai-hook-rules/src/core/runner.ts` | `l1LocationBlock` |',\n '| trinary-version-skew (row 8), V, R | `ai-hook-rules/src/core/version-sync.ts` | `VersionSyncGuard`, `WebpiecesVersions` |',\n '| force-to-root (row 5) | `ai-hook-rules/src/core/force-to-root.ts` | `ForceToRootGuard` |',\n '| the directory is gone (row 7) | `ai-hook-rules/src/core/missing-directory.ts` | `MissingDirectoryGuard` |',\n '| the filter | `ai-hook-rules/src/core/runner.ts` | `filterByExcludedPaths` |',\n '| `excludePaths` shape | `rules-config/src/exclude-hook-paths.ts`, `validate-config.ts`, `retired-config-keys.ts` | `ExcludePaths`, `validateExcludePaths` |',\n '',\n ];\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Render guards/L2-branch-state.md.
3
+ *
4
+ * Split into sections purely to stay inside the method-line budget — the join order is what makes them
5
+ * one file, so keep them adjacent and keep the byte-lock test as the arbiter.
6
+ */
7
+ export declare function renderL2Doc(): string;