@codedology/code-forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The author loop's SHAPE (plan §3.7, O18) — types and pure loop-continuation logic only. The
3
+ * actual `code-forge author` session runner (spawning a fresh L3 closed-book session, writing the
4
+ * draft to `plans_dir`, printing questions to the human, collecting answers, re-invoking with
5
+ * `--draft`/`--answers`) is B9's `src/session/author.mjs` (C3: "no `s2.mjs` here" — likewise no
6
+ * session-spawning code here). What belongs to the DECISION layer is the deterministic question
7
+ * of "is this loop done", which B9's runner calls after every round instead of re-deriving the
8
+ * same rule inline.
9
+ *
10
+ * §3.7, verbatim: "stop when `questions[]` is empty or contains only non-blocking items the human
11
+ * chose to skip." Read literally (fix round 1, review finding "spec deviation"): a BLOCKING
12
+ * question in the latest round keeps the loop going, full stop — there is no "skip" path for a
13
+ * blocking item, and `skippedIds` never clears one. Only a NON-blocking item can be cleared, and
14
+ * only by being in `skippedIds` (the human explicitly chose to skip it — an item the human simply
15
+ * hasn't looked at yet does NOT count). A blocking question stops being "in the latest round" only
16
+ * because a LATER `author` call, given the human's real answer, omitted it — that happens upstream
17
+ * of this module, in B9's loop.
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} AuthorQuestion
22
+ * @property {string} id
23
+ * @property {string} question
24
+ * @property {string[]} [options]
25
+ * @property {string} [why]
26
+ * @property {boolean} blocking
27
+ */
28
+
29
+ /**
30
+ * Validates the one shape this module actually depends on — FAILS CLOSED (review finding "fails
31
+ * open"): `questions[]` is parsed LLM JSON, so a missing/typo'd `blocking` field must be a loud
32
+ * `TypeError`, never a silent "treat as non-blocking" that could let the loop stop while a real
33
+ * blocking question sits unanswered.
34
+ * @param {any} q
35
+ * @returns {asserts q is AuthorQuestion}
36
+ */
37
+ function assertQuestionShape(q) {
38
+ if (q === null || typeof q !== 'object') {
39
+ throw new TypeError('author-loop: each question must be an object');
40
+ }
41
+ if (typeof q.id !== 'string' || q.id.length === 0) {
42
+ throw new TypeError('author-loop: question.id must be a non-empty string');
43
+ }
44
+ if (typeof q.blocking !== 'boolean') {
45
+ throw new TypeError(`author-loop: question ${JSON.stringify(q.id)}.blocking must be a boolean, got ${typeof q.blocking}`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param {any} questions - untrusted (parsed `author` JSON output); validated per-item, not just
51
+ * `Array.isArray` — see {@link assertQuestionShape}.
52
+ * @returns {AuthorQuestion[]}
53
+ */
54
+ export function blockingQuestions(questions) {
55
+ if (!Array.isArray(questions)) {
56
+ throw new TypeError('blockingQuestions: questions must be an array');
57
+ }
58
+ questions.forEach(assertQuestionShape);
59
+ return questions.filter((q) => q.blocking === true);
60
+ }
61
+
62
+ /**
63
+ * @param {any} questions - the LATEST round's `questions[]` from `author`'s output.
64
+ * @param {Iterable<string>} [skippedIds] - NON-blocking ids the human explicitly chose to skip
65
+ * this round. Never clears a blocking id (§3.7 — see the module header).
66
+ * @returns {boolean} `true` when the loop should stop.
67
+ */
68
+ export function isAuthorLoopDone(questions, skippedIds = []) {
69
+ if (!Array.isArray(questions)) {
70
+ throw new TypeError('isAuthorLoopDone: questions must be an array');
71
+ }
72
+ if (questions.length === 0) {
73
+ return true;
74
+ }
75
+ questions.forEach(assertQuestionShape);
76
+ const skipped = skippedIds instanceof Set ? skippedIds : new Set(skippedIds);
77
+ return questions.every((q) => !q.blocking && skipped.has(q.id));
78
+ }
79
+
80
+ /**
81
+ * The blocking questions still standing in the way of stopping — under §3.7 that is EVERY
82
+ * blocking question in `questions[]` (a blocking item is never "resolved" by a skip set; see the
83
+ * module header). Kept as its own export, distinct from {@link blockingQuestions}, for the
84
+ * call-site clarity of "what must the human still answer before this loop can finish".
85
+ * @param {any} questions
86
+ * @returns {AuthorQuestion[]}
87
+ */
88
+ export function remainingBlockingQuestions(questions) {
89
+ return blockingQuestions(questions);
90
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Escalation precedence — deterministic (plan §3.6, O10, R1; v1.3 adds rule 2b `review_stall`
3
+ * and rule 6 `review_cap`, §4.11). "Evaluated in this order after every attempt; the first rule
4
+ * that fires wins":
5
+ *
6
+ * 1. Attempt counter — attempts at the current level == `retries_per_level` (2) and not green
7
+ * ⇒ +1 level, whatever S1 said (`trigger: 'retries'`).
8
+ * 2b. Review stall (v1.3) — a re-check round whose open `fix_now` set did NOT strictly shrink
9
+ * (`reviewRoundStalled` below) ⇒ +1 level IMMEDIATELY (`trigger: 'review_stall'`), checked
10
+ * BEFORE rule 2's own round-exhaustion counter — a block need not exhaust
11
+ * `review_rounds_per_level` to escalate on a stalled round.
12
+ * 2. Review rounds — `review_rounds_per_level` (2) exhausted with an open `fix_now` finding
13
+ * ⇒ +1 level (`trigger: 'review_rounds'`).
14
+ * 3. Security floor (DISPATCH time only) — `security_sensitive == true` ⇒ first-attempt level
15
+ * = `min(max(lane, L1) + 1, L2)` (`trigger: 'security'`). This is the one rule that fires
16
+ * before the first attempt, not after one — `dispatchLevel()` below is its own entry point,
17
+ * never folded into `escalationAfterAttempt()`.
18
+ * 4. S2 ruling — S1 `next = escalate` is never acted on directly; it goes to S2 as a check
19
+ * (`trigger: 's2_ruling'`, `action: 's2_check'`). S1 `next = stop` routes straight to the
20
+ * human (`action: 'stop'`).
21
+ * 5. Never skip a level; `stop_at: L3` is fixed. L3 is never a RUNNING level (R1: "the L3 rung
22
+ * is a patch, not bulk coding"; the block continues at L2): the running ceiling is
23
+ * `MAX_RUNNING_LEVEL` (L2), or `stop_at` when lower. When rule 1, 2b or 2 fires AND the block
24
+ * is already at the running ceiling, there IS no "+1 level" to go to. R1's L3 rung takes
25
+ * over instead: `escalation.l3_mode: 'patch'` (default) routes to a fresh L3
26
+ * session that returns a PATCH, never a whole feature (`action: 'l3_rung', mode: 'patch'`).
27
+ * `l3_mode: 'code'` keeps D4 literal — the block keeps coding at L3 ONCE more
28
+ * (`action: 'escalate', level: 'L3'`), no patch machinery. Either way, a SECOND ceiling hit
29
+ * on the same block (`l3RungAlreadyUsed`) always `stop`s (fix round 1: `l3_mode: 'code'`
30
+ * used to have no end state at all — the caller resets its attempt counter, so rules 1/2
31
+ * would fire again every `retriesPerLevel` failures forever, never honouring `stop_at`).
32
+ * A `stop_at` below L3 (schema-rejected; defensive) has no rung: at its ceiling ⇒ `stop`
33
+ * (`reason: 'stop_at'`).
34
+ * 6. Review cap (v1.3) — `review.max_rounds_per_file` (default 4) reached on a file with an
35
+ * open `fix_now` finding ⇒ the L3 patch rung for THAT FILE, once (`trigger: 'review_cap'`,
36
+ * `action: 'l3_rung', mode: 'patch'` — always `'patch'`, regardless of `l3Mode`: a round-cap
37
+ * breach is a fix-scale problem, never a whole re-code). It shares the SAME
38
+ * `l3RungAlreadyUsed` flag as rule 5 — the plan's L3 rung is one per block, not one per
39
+ * trigger — so a second breach from EITHER rule 5 or rule 6 always `stop`s
40
+ * (`reason: 'review_cap'`). Checked last: it only matters when rules 1/2b/2/4 did not
41
+ * already return an action this attempt.
42
+ *
43
+ * Rules 1, 2b, 2, 4 and 6 are the six *triggered* rules of the precedence table (each carries its
44
+ * own `trigger` name); rule 5 is the ceiling guard folded into `finalizeEscalate` below, not a
45
+ * seventh trigger.
46
+ */
47
+
48
+ /** Level order, low to high. `escalation.stop_at` is schema-fixed to `'L3'` (B1's `checkStopAtNotL3`). */
49
+ export const LEVEL_ORDER = Object.freeze(['L0', 'L1', 'L2', 'L3']);
50
+
51
+ /**
52
+ * @param {string} level
53
+ * @returns {number}
54
+ * @throws {TypeError} for anything not one of `LEVEL_ORDER`.
55
+ */
56
+ export function levelIndex(level) {
57
+ const i = LEVEL_ORDER.indexOf(level);
58
+ if (i === -1) {
59
+ throw new TypeError(`levelIndex: unknown level ${JSON.stringify(level)} (expected one of ${LEVEL_ORDER.join(', ')})`);
60
+ }
61
+ return i;
62
+ }
63
+
64
+ /**
65
+ * @param {string} current
66
+ * @param {string} [stopAt]
67
+ * @returns {string} `current`'s successor, capped at `stopAt` — never skips a level, never
68
+ * exceeds the ceiling (rule 5's "never skip a level" half; the "at the ceiling" half lives in
69
+ * `escalationAfterAttempt`'s `finalizeEscalate`).
70
+ */
71
+ export function nextLevelCapped(current, stopAt = 'L3') {
72
+ const capIndex = levelIndex(stopAt);
73
+ return LEVEL_ORDER[Math.min(levelIndex(current) + 1, capIndex)];
74
+ }
75
+
76
+ /**
77
+ * Rule 3's arithmetic: `min(max(lane, L1) + 1, L2)`.
78
+ * @param {string} lane - `'L0'|'L1'|'L2'` (§O10: "L3 is not a lane").
79
+ * @returns {string}
80
+ */
81
+ export function securityFloorLevel(lane) {
82
+ const idx = Math.min(Math.max(levelIndex(lane), levelIndex('L1')) + 1, levelIndex('L2'));
83
+ return LEVEL_ORDER[idx];
84
+ }
85
+
86
+ /**
87
+ * Rule 3, at dispatch time only — call this once per block, before the first attempt.
88
+ * @param {{lane: string, securitySensitive: boolean}} ctx
89
+ * @returns {{level: string, trigger: 'security'|null}}
90
+ */
91
+ export function dispatchLevel({ lane, securitySensitive }) {
92
+ if (securitySensitive) {
93
+ return { level: securityFloorLevel(lane), trigger: 'security' };
94
+ }
95
+ return { level: lane, trigger: null };
96
+ }
97
+
98
+ /**
99
+ * Rule 4's floor: `next = 'escalate'` (and `'stop'`) must never be acted on directly by the
100
+ * orchestrator — both always route through this module's rule 4, never straight to "continue as
101
+ * S1 said" even when `thresholds.decide()` reported `decided: true` for the `next` question.
102
+ * @param {string} next
103
+ * @returns {boolean}
104
+ */
105
+ export function mustRouteNextThroughEscalation(next) {
106
+ return next === 'escalate' || next === 'stop';
107
+ }
108
+
109
+ /**
110
+ * Rule 2b's shrink test (v1.3, §4.11): a re-check round's open `fix_now` count must be STRICTLY
111
+ * less than the previous round's. Equal or larger ⇒ the round stalled.
112
+ * @param {number} openBefore - open `fix_now` count entering this round.
113
+ * @param {number} openAfter - open `fix_now` count after this round's fixes + recheck.
114
+ * @returns {boolean} true when the round did NOT strictly shrink (a stall).
115
+ */
116
+ export function reviewRoundStalled(openBefore, openAfter) {
117
+ return openAfter >= openBefore;
118
+ }
119
+
120
+ /**
121
+ * The highest level a block CODES at (R1, D4, §0.7: "the L3 rung is a patch, not bulk coding";
122
+ * §3.6: after the patch "the block continues at L2"). L3 is never a running level — it is only
123
+ * the one-shot rung that `finalizeEscalate` returns once the block is at this ceiling.
124
+ */
125
+ export const MAX_RUNNING_LEVEL = 'L2';
126
+
127
+ /**
128
+ * @param {string} stopAt - `escalation.stop_at`.
129
+ * @returns {string} the running ceiling: `MAX_RUNNING_LEVEL`, lowered to `stopAt` when that is
130
+ * lower (defensive — B1's schema fixes `stop_at` to `'L3'`).
131
+ */
132
+ export function runningCeiling(stopAt = 'L3') {
133
+ return LEVEL_ORDER[Math.min(levelIndex(MAX_RUNNING_LEVEL), levelIndex(stopAt))];
134
+ }
135
+
136
+ /**
137
+ * Rules 1, 2b and 2 share this: below the running ceiling ⇒ +1 level (never to L3); at the
138
+ * running ceiling ⇒ rule 5, the block's one L3 rung (R1), then `stop`.
139
+ * @param {'retries'|'review_rounds'|'review_stall'} trigger
140
+ * @param {string} currentLevel
141
+ * @param {string} stopAt
142
+ * @param {'patch'|'code'} l3Mode
143
+ * @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS block, in
144
+ * EITHER mode and from either rule 5 or rule 6 (one rung per block, not per trigger). A second
145
+ * hit always stops.
146
+ * @returns {EscalationResult}
147
+ */
148
+ function finalizeEscalate(trigger, currentLevel, stopAt, l3Mode, l3RungAlreadyUsed) {
149
+ const ceiling = runningCeiling(stopAt);
150
+ if (levelIndex(currentLevel) < levelIndex(ceiling)) {
151
+ return { trigger, action: 'escalate', level: nextLevelCapped(currentLevel, ceiling) };
152
+ }
153
+ // At (or, after an `l3_mode: code` turn, above) the running ceiling: rule 5.
154
+ if (stopAt !== 'L3') {
155
+ // `stop_at` below L3 forbids any L3 session, so there is no rung to take (§3.6 fixes
156
+ // `stop_at: L3`; this only guards a hand-built ctx).
157
+ return { trigger, action: 'stop', reason: 'stop_at' };
158
+ }
159
+ if (l3RungAlreadyUsed) {
160
+ return { trigger, action: 'stop', reason: l3Mode === 'code' ? 'l3_code_exhausted' : 'l3_patch_exhausted' };
161
+ }
162
+ if (l3Mode === 'code') {
163
+ // D4 literal: ONE coding turn at L3 (the caller resets its attempt counter); the next
164
+ // ceiling hit falls into the `l3RungAlreadyUsed` branch above and stops.
165
+ return { trigger, action: 'escalate', level: 'L3' };
166
+ }
167
+ return { trigger, action: 'l3_rung', mode: 'patch' };
168
+ }
169
+
170
+ /**
171
+ * @typedef {object} EscalationCtx
172
+ * @property {number} [attemptsAtLevel]
173
+ * @property {number} [retriesPerLevel] - `escalation.retries_per_level`, default 2.
174
+ * @property {boolean} [gateGreen] - whether the block's gate is green on this attempt.
175
+ * @property {number} [reviewRoundsAtLevel]
176
+ * @property {number} [reviewRoundsPerLevel] - `escalation.review_rounds_per_level`, default 2.
177
+ * @property {boolean} [openFixNowFinding] - an unresolved `fix_now` finding remains open.
178
+ * @property {boolean} [reviewStall] - rule 2b (v1.3, §4.11): this round's open `fix_now` set did
179
+ * NOT strictly shrink (see `reviewRoundStalled`). Checked, and fires, before rule 2's own
180
+ * `reviewRoundsAtLevel` counter is exhausted.
181
+ * @property {'complete'|'retry'|'escalate'|'stop'|undefined} [s1Next] - S1's `next` answer.
182
+ * @property {string} [currentLevel] - REQUIRED at runtime (checked below, throws when absent);
183
+ * optional in this typedef only so a deliberately-invalid `{}` type-checks in a test that
184
+ * proves the runtime check fires.
185
+ * @property {string} [stopAt] - `escalation.stop_at`, default `'L3'`.
186
+ * @property {'patch'|'code'} [l3Mode] - `escalation.l3_mode`, default `'patch'` (R1).
187
+ * @property {boolean} [l3RungAlreadyUsed] - the L3 rung (ceiling, rule 5, OR the file's review
188
+ * cap, rule 6) was already used once before on THIS block, from either source. A second hit
189
+ * always `stop`s — the plan's L3 rung is one per block, not one per trigger.
190
+ * @property {number} [roundsAtFile] - rule 6 (v1.3, §4.11): total review rounds run on this file
191
+ * so far, counted across every level — unlike `reviewRoundsAtLevel`, this is never reset by a
192
+ * level escalation.
193
+ * @property {number} [maxRoundsPerFile] - `review.max_rounds_per_file`, default 4 (schema
194
+ * minimum 2, maximum 6; B1.1).
195
+ *
196
+ * @typedef {object} EscalationResult
197
+ * @property {'retries'|'review_stall'|'review_rounds'|'s2_ruling'|'review_cap'|null} trigger
198
+ * @property {'escalate'|'s2_check'|'stop'|'l3_rung'|'none'} action
199
+ * @property {string} [level]
200
+ * @property {string} [mode]
201
+ * @property {string} [reason]
202
+ */
203
+
204
+ /**
205
+ * Rules 1, 2b, 2, 4 and 6 (rule 3 is `dispatchLevel`; rule 5 is the ceiling guard inside
206
+ * `finalizeEscalate`), evaluated in the documented order — the first rule that fires wins.
207
+ * @param {EscalationCtx} ctx
208
+ * @returns {EscalationResult}
209
+ */
210
+ export function escalationAfterAttempt(ctx) {
211
+ const {
212
+ attemptsAtLevel = 0,
213
+ retriesPerLevel = 2,
214
+ gateGreen = false,
215
+ reviewRoundsAtLevel = 0,
216
+ reviewRoundsPerLevel = 2,
217
+ openFixNowFinding = false,
218
+ reviewStall = false,
219
+ s1Next,
220
+ currentLevel,
221
+ stopAt = 'L3',
222
+ l3Mode = 'patch',
223
+ l3RungAlreadyUsed = false,
224
+ roundsAtFile = 0,
225
+ maxRoundsPerFile = 4,
226
+ } = ctx;
227
+
228
+ if (typeof currentLevel !== 'string') {
229
+ throw new TypeError('escalationAfterAttempt: ctx.currentLevel is required');
230
+ }
231
+
232
+ // Rule 1: attempt counter.
233
+ if (attemptsAtLevel >= retriesPerLevel && !gateGreen) {
234
+ return finalizeEscalate('retries', currentLevel, stopAt, l3Mode, l3RungAlreadyUsed);
235
+ }
236
+ // Rule 2b: review stall — fires immediately, ahead of rule 2's own round-exhaustion counter.
237
+ if (reviewStall && openFixNowFinding) {
238
+ return finalizeEscalate('review_stall', currentLevel, stopAt, l3Mode, l3RungAlreadyUsed);
239
+ }
240
+ // Rule 2: review rounds.
241
+ if (reviewRoundsAtLevel >= reviewRoundsPerLevel && openFixNowFinding) {
242
+ return finalizeEscalate('review_rounds', currentLevel, stopAt, l3Mode, l3RungAlreadyUsed);
243
+ }
244
+ // Rule 4: S2 ruling.
245
+ if (s1Next === 'escalate') {
246
+ return { trigger: 's2_ruling', action: 's2_check' };
247
+ }
248
+ if (s1Next === 'stop') {
249
+ return { trigger: 's2_ruling', action: 'stop' };
250
+ }
251
+ // Rule 6: review cap — the file's absolute round count, independent of the escalation level.
252
+ // Always `patch` (never `l3Mode: 'code'`): a round-cap breach is a fix-scale problem. Shares
253
+ // `l3RungAlreadyUsed` with rule 5's ceiling guard (one L3 rung per block, not per trigger).
254
+ if (roundsAtFile >= maxRoundsPerFile && openFixNowFinding) {
255
+ if (l3RungAlreadyUsed) {
256
+ return { trigger: 'review_cap', action: 'stop', reason: 'review_cap' };
257
+ }
258
+ return { trigger: 'review_cap', action: 'l3_rung', mode: 'patch' };
259
+ }
260
+ // No rule fired this attempt.
261
+ return { trigger: null, action: 'none' };
262
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Fallback when Jev is unavailable (plan §3.5, O9/O27) — `system1.fallback`, default `'rules'`.
3
+ * Layer 1 of that fallback: deterministic rules for `lane`, `risk` and `security_sensitive` (ALL
4
+ * THREE are always resolvable this way — "a rule that fires is final", so these three question
5
+ * ids never sit in the residue), and for `next` only "when the evidence is unambiguous". The
6
+ * plan's residue set — the questions rules cannot cover: `defect`, `resolved`, an AMBIGUOUS
7
+ * `next`, and `scope` — goes to S2 instead, tagged `source: 's2-fallback'` so §3.4's
8
+ * `> 6 non-fallback S2 calls per block ⇒ s2-heavy` counter never counts it.
9
+ *
10
+ * **Rule inputs are pre-computed facts, not raw diffs.** This module does no path-globbing, no
11
+ * git diffing, no gate-output parsing — that belongs to the caller (B5's gate/scope machinery,
12
+ * B9's evidence normalizer), which already has to do it anyway for the deterministic gate itself.
13
+ * Keeping these functions pure over a small fact object keeps them exhaustively unit-testable and
14
+ * decoupled from any one block's I/O.
15
+ */
16
+
17
+ /** Keywords the plan names for the fallback rules (§3.5): "Money, Policy, Auth, Webhook, migration". */
18
+ export const FALLBACK_KEYWORDS = Object.freeze(['Money', 'Policy', 'Auth', 'Webhook', 'migration']);
19
+
20
+ /** Diff-size floor above which a change is at least L1 (§3.5). */
21
+ export const LANE_SIZE_FILES_FLOOR = 4;
22
+ export const LANE_SIZE_LINES_FLOOR = 200;
23
+
24
+ /** `> 6 non-fallback S2 calls per block ⇒ s2-heavy` (§3.4). */
25
+ export const S2_HEAVY_THRESHOLD = 6;
26
+
27
+ /**
28
+ * @typedef {object} FallbackFacts
29
+ * @property {boolean} [pathFloorHit] - a changed path matches `proof.tiers.high.paths` (§3.5:
30
+ * "Path floors ... ⇒ risk 3, security true").
31
+ * @property {number} [filesChanged]
32
+ * @property {number} [linesAdded]
33
+ * @property {boolean} [touchesMigration]
34
+ * @property {boolean} [touchesPolicyOrMiddleware]
35
+ * @property {string[]} [keywordsFound] - any of `FALLBACK_KEYWORDS` found in the task text/diff/paths.
36
+ */
37
+
38
+ /**
39
+ * §3.5, verbatim: "diff size (> 200 added lines or > 4 files ⇒ **≥ L1**)". The "≥" only means
40
+ * something if the baseline BELOW that floor is lower than L1 — so a change that trips neither
41
+ * the migration/policy/path floor NOR the size floor is `'L0'` (trivial), and the size floor's
42
+ * whole job is to bump an otherwise-untagged-but-sizeable diff up to `'L1'` (fix round 1: the old
43
+ * version returned `'L1'` on BOTH the size branch and the fall-through default, making the size
44
+ * floor dead code and `'L0'` unreachable despite the JSDoc return type listing it).
45
+ * @param {FallbackFacts} facts
46
+ * @returns {'L0'|'L1'|'L2'} never `null` — lane is always rule-covered in fallback mode.
47
+ */
48
+ export function fallbackLane(facts = {}) {
49
+ if (facts.touchesMigration || facts.touchesPolicyOrMiddleware || facts.pathFloorHit) {
50
+ return 'L2';
51
+ }
52
+ if ((facts.filesChanged ?? 0) > LANE_SIZE_FILES_FLOOR || (facts.linesAdded ?? 0) > LANE_SIZE_LINES_FLOOR) {
53
+ return 'L1';
54
+ }
55
+ return 'L0';
56
+ }
57
+
58
+ /**
59
+ * @param {FallbackFacts} facts
60
+ * @returns {0|1|2|3} never `null` — risk is always rule-covered in fallback mode.
61
+ */
62
+ export function fallbackRisk(facts = {}) {
63
+ if (facts.pathFloorHit) return 3;
64
+ if (facts.touchesMigration || facts.touchesPolicyOrMiddleware) return 2;
65
+ if ((facts.filesChanged ?? 0) > LANE_SIZE_FILES_FLOOR || (facts.linesAdded ?? 0) > LANE_SIZE_LINES_FLOOR) return 1;
66
+ return 0;
67
+ }
68
+
69
+ /** Keywords whose presence, by themselves, mean the change touches a security-sensitive domain. */
70
+ const SECURITY_KEYWORDS = Object.freeze(['Auth', 'Webhook', 'Money']);
71
+
72
+ /**
73
+ * @param {FallbackFacts} facts
74
+ * @returns {boolean} never `null` — security_sensitive is always rule-covered in fallback mode.
75
+ */
76
+ export function fallbackSecuritySensitive(facts = {}) {
77
+ if (facts.pathFloorHit) return true;
78
+ return (facts.keywordsFound ?? []).some((k) => SECURITY_KEYWORDS.includes(k));
79
+ }
80
+
81
+ /**
82
+ * Resolve the three ALWAYS-covered dispatch questions by rules alone — every entry is
83
+ * `source: 'rules'`, so a caller counting S2 calls over the result gets exactly 0.
84
+ * @param {FallbackFacts} facts
85
+ * @returns {{lane: {value: string, source: 'rules'}, risk: {value: number, source: 'rules'}, security_sensitive: {value: boolean, source: 'rules'}}}
86
+ */
87
+ export function resolveDispatchQuestionsByRules(facts = {}) {
88
+ return {
89
+ lane: { value: fallbackLane(facts), source: 'rules' },
90
+ risk: { value: fallbackRisk(facts), source: 'rules' },
91
+ security_sensitive: { value: fallbackSecuritySensitive(facts), source: 'rules' },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * @typedef {object} NextEvidence
97
+ * @property {boolean} allGreen - every gate (tests/lint/types/format) passed.
98
+ * @property {number} attempt
99
+ * @property {boolean} [spreading] - failures grew across attempts, or span more than one file/area.
100
+ * @property {boolean} [newTestsAdded]
101
+ * @property {string} [noNewTestsReason]
102
+ */
103
+
104
+ /**
105
+ * `next`, "when the evidence is unambiguous" (§3.5) — the one rule-covered question that CAN be
106
+ * residue: an ambiguous case (spreading failures, or a later attempt with no clear single cause)
107
+ * returns `null`, and the caller must route it to S2 instead (`source: 's2-fallback'`).
108
+ * @param {NextEvidence} evidence
109
+ * @returns {'complete'|'retry'|null}
110
+ */
111
+ export function fallbackNext(evidence) {
112
+ if (evidence.allGreen && (evidence.newTestsAdded || Boolean(evidence.noNewTestsReason))) {
113
+ return 'complete';
114
+ }
115
+ if (!evidence.allGreen && !evidence.spreading && (evidence.attempt ?? 1) <= 1) {
116
+ return 'retry';
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * @param {NextEvidence} evidence
123
+ * @returns {{value: 'complete'|'retry', source: 'rules'} | {value: null, source: 's2-fallback'}}
124
+ */
125
+ export function resolveNextByRules(evidence) {
126
+ const value = fallbackNext(evidence);
127
+ return value === null ? { value: null, source: 's2-fallback' } : { value, source: 'rules' };
128
+ }
129
+
130
+ /**
131
+ * `defect`, `resolved` and `scope` have no rule at all — they are ALWAYS residue in fallback mode.
132
+ * @param {string} _id - one of `'defect'|'resolved'|'scope'`; accepted as a plain `string` (not
133
+ * narrowed further) for call-site clarity/documentation only — this function ignores it.
134
+ * @returns {{value: null, source: 's2-fallback'}}
135
+ */
136
+ export function alwaysResidue(_id) {
137
+ return { value: null, source: 's2-fallback' };
138
+ }
139
+
140
+ /**
141
+ * How many of `entries` actually require an S2 call — anything whose `source` isn't `'rules'`
142
+ * (a floor/keyword/size rule fired and answered outright) needs S2, whether that S2 call is a
143
+ * fallback residue (`'s2-fallback'`) or, later, an ordinary band check (`'s2'`). This is the
144
+ * literal "S2 call count" the plan's acceptance clause names: a batch resolved entirely by rules
145
+ * (the floor path) counts 0; a batch with one residue entry (e.g. an ambiguous `next`) counts 1.
146
+ * @param {{source: string}[]} entries
147
+ * @returns {number}
148
+ */
149
+ export function countS2Calls(entries) {
150
+ return entries.filter((entry) => entry.source !== 'rules').length;
151
+ }
152
+
153
+ /**
154
+ * The `s2-heavy` counter (§3.4) counts only NON-fallback S2 calls — a `source: 's2-fallback'`
155
+ * call is exempt (§3.5): Jev being down for the whole run must not itself trip a "this block is
156
+ * asking S2 too much" flag.
157
+ * @param {string} source
158
+ * @returns {boolean}
159
+ */
160
+ export function countsTowardS2Heavy(source) {
161
+ return source === 's2' || source === 's2-check' || source === 's2-decide';
162
+ }
163
+
164
+ /**
165
+ * @param {{source: string}[]} s2Calls - every S2 invocation recorded for one block, in order.
166
+ * @param {number} [threshold] - default `S2_HEAVY_THRESHOLD` (6).
167
+ * @returns {{count: number, heavy: boolean}} `count` is the NON-fallback tally only.
168
+ */
169
+ export function evaluateS2Heavy(s2Calls, threshold = S2_HEAVY_THRESHOLD) {
170
+ const count = s2Calls.filter((call) => countsTowardS2Heavy(call.source)).length;
171
+ return { count, heavy: count > threshold };
172
+ }