@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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- 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
|
+
}
|