@codedology/code-forge 0.8.0 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * 1. **Schema validation** (Ajv, draft 2020-12, `additionalProperties: false` almost everywhere)
6
6
  * catches shape errors: unknown keys, wrong types, missing required fields.
7
- * 2. **Domain rules** (`DOMAIN_RULES` below, 16 of them as of B32) catch things no JSON Schema can express:
7
+ * 2. **Domain rules** (`DOMAIN_RULES` below, 20 of them as of B93) catch things no JSON Schema can express:
8
8
  * cross-field comparisons (two levels resolving to the same tuple), a value that needs a
9
9
  * second config value to be reachable (`second_levels.L2.provider` vs. the effective L2
10
10
  * provider) and rules that reach outside the config object entirely (is a CLI on PATH, is a
@@ -36,6 +36,10 @@
36
36
  * 14 caps-coders-exceeds-cap WARN (v1.3/R10/B1.1: caps.coders > 2)
37
37
  * 15 effort-not-valid-for-provider ERROR (B29: a level's / fallback's effort vs. its provider)
38
38
  * 16 closed-book-on-openai WARN (B32: Codex has no no-tools mode)
39
+ * 17 proof-runners-invalid ERROR (B87: a proof.runners entry: adapter, match, argv tokens, env, timeout_s)
40
+ * 18 review-routes-invalid ERROR (B90: a review.routes rule: level, duplicate level+min_lines, provider vs. the second reviewer)
41
+ * 19 sandbox-dir-refused ERROR (B93: a coder.sandbox.add_dirs entry opens /, the home dir, a secret dir or the run temp tree)
42
+ * 20 sandbox-dirs-unused WARN (B93: coder.sandbox.add_dirs set under a profile other than toolchain)
39
43
  *
40
44
  * **B1.2 (v1.3, Q16 cut) removed rule:** `proof-tool-absent-for-high-tier` (formerly #13) — the
41
45
  * key it warned about (`proof.tiers.high.tool`) left the schema in the same amendment, along with
@@ -66,6 +70,11 @@ import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
66
70
  import { checkEffort, displayEffort } from '../engines/efforts.mjs';
67
71
  import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
68
72
  import { isKnownId, resolveLevel } from './known-ids.mjs';
73
+ // B90: the routing module's own level list and provider resolution, so rules 16 and 18 check a
74
+ // route with exactly the provider the worker spawns it on (they cannot drift)
75
+ import { ROUTE_LEVELS, ruleProvider } from '../review/route.mjs';
76
+ import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES, sandboxDirRefusals, SANDBOX_DIR_REFUSED } from '../engines/sandbox-dirs.mjs';
77
+ import { tmpBase } from '../util/tmp.mjs';
69
78
  import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
70
79
 
71
80
  /**
@@ -781,6 +790,21 @@ function checkClosedBookOnOpenai(cfg) {
781
790
  if (cfg?.review?.multimodel === true && isNoClosedBookProvider(effectiveSecondL2Provider(cfg))) {
782
791
  where.push('review.second_levels.L2 (else review.second_provider)');
783
792
  }
793
+ // B90: a review.routes rule replaces an L2/L3 level for a big file — the same closed-book limit
794
+ routeRulesOf(cfg).forEach((rule, i) => {
795
+ if (!isPlainObject(rule)) return;
796
+ if (isNoClosedBookProvider(ruleProvider(cfg, rule))) where.push(`review.routes[${i}]`);
797
+ if (Array.isArray(rule.fallback)) {
798
+ rule.fallback.forEach((fb, j) => {
799
+ if (isNoClosedBookProvider(fb?.provider)) where.push(`review.routes[${i}].fallback[${j}]`);
800
+ });
801
+ }
802
+ // like the second reviewer's own check above: only with multimodel on, and with the same
803
+ // effective second provider (the rule's own, else second_levels.L2, else second_provider)
804
+ if (cfg?.review?.multimodel === true && isPlainObject(rule.second) && isNoClosedBookProvider(rule.second.provider ?? effectiveSecondL2Provider(cfg))) {
805
+ where.push(`review.routes[${i}].second`);
806
+ }
807
+ });
784
808
  // B32 opt-in: the same places, but the text says what the opt-in costs instead of "refused".
785
809
  const text = openBookCodexAllowed(cfg) ? OPEN_BOOK_CODEX_WARNING : CODEX_CLOSED_BOOK_REFUSAL;
786
810
  return where.map((key) => ({
@@ -790,11 +814,298 @@ function checkClosedBookOnOpenai(cfg) {
790
814
  }));
791
815
  }
792
816
 
817
+ // ── 17. proof-runners-invalid (ERROR, B87) ──────────────────────────────────
818
+
819
+ /**
820
+ * B87: the argv tokens each `proof.runners` adapter expands. A token is a whole argv item, never
821
+ * the command. `proof/runners.mjs` expands exactly these (proof may import config; config may not
822
+ * import proof).
823
+ */
824
+ export const RUNNER_ADAPTER_TOKENS = Object.freeze({
825
+ junit: Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}']),
826
+ swift: Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}']),
827
+ // B102: the values come from the entry's `xcode` object; `{only_testing}` names the test and
828
+ // `{result_bundle}` is the fresh result bundle path code-forge reads
829
+ xcodebuild: Object.freeze(['{workspace}', '{project}', '{scheme}', '{destination}', '{only_testing}', '{result_bundle}']),
830
+ });
831
+ /** B87: the tokens that name the test; a `junit` or `swift` runner needs at least one (a proof must name its test). */
832
+ export const RUNNER_TEST_TOKENS = Object.freeze(['{test_file}', '{test_id}', '{test_filter}']);
833
+ /** B87: every token any adapter knows. */
834
+ const ALL_RUNNER_TOKENS = Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}', '{workspace}', '{project}', '{scheme}', '{destination}', '{only_testing}', '{result_bundle}']);
835
+ /** B102: the keys of an `xcodebuild` entry's `xcode` object, and the ones it must have. */
836
+ export const XCODE_KEYS = Object.freeze(['workspace', 'project', 'scheme', 'destination', 'test_target']);
837
+ const XCODE_REQUIRED = Object.freeze(['scheme', 'destination', 'test_target']);
838
+ /** B102: the tokens an `xcodebuild` argv must have, and why. */
839
+ const XCODE_REQUIRED_TOKENS = Object.freeze([
840
+ ['{scheme}', 'the xcodebuild adapter fills it from xcode.scheme'],
841
+ ['{destination}', 'the xcodebuild adapter fills it from xcode.destination, the one the precheck checks'],
842
+ ['{only_testing}', 'a proof must name its test'],
843
+ ['{result_bundle}', 'the xcodebuild adapter reads the result bundle code-forge places there'],
844
+ ]);
845
+ /** B87: an item that is a whole `{word}` is read as a token. */
846
+ const TOKEN_SHAPE = /^\{[a-z_]+\}$/;
847
+ /** B87: the bounds of `proof.runners[].timeout_s`, in seconds. */
848
+ const RUNNER_TIMEOUT_MIN_S = 60;
849
+ const RUNNER_TIMEOUT_MAX_S = 7200;
850
+ /** B87: the keys a runner entry may have (the schema's `additionalProperties: false`, as a rule). */
851
+ const RUNNER_KEYS = Object.freeze(['match', 'adapter', 'argv', 'env', 'timeout_s', 'xcode']);
852
+ /** B87: every token some adapter lists; with a bad adapter, an item outside it is still refused. */
853
+ const ANY_ADAPTER_TOKENS = Object.freeze([...new Set(Object.values(RUNNER_ADAPTER_TOKENS).flat())]);
854
+ /** B102: the report items and the test tokens of every adapter; a bad adapter's argv needs one of each. */
855
+ const ANY_REPORT_TOKENS = Object.freeze(['{report}', '{result_bundle}']);
856
+ const ANY_TEST_TOKENS = Object.freeze([...RUNNER_TEST_TOKENS, '{only_testing}']);
857
+
858
+ /**
859
+ * Rule 17's messages for one runner entry. A message names the key path and the rule; it may name
860
+ * an env variable NAME or an unknown entry key (key paths), never a value (argv items, env values) — only the adapter's
861
+ * own token words, which are code-forge's. The argv checks run even when the adapter is bad, so
862
+ * one pass shows every error. `proof/runners.mjs` checks the snapshot's entries with it again
863
+ * before it runs one.
864
+ * @param {unknown} entry @param {number} i @returns {string[]}
865
+ */
866
+ export function runnerMessages(entry, i) {
867
+ const at = `proof.runners[${i}]`;
868
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return [`${at} must be an object with match, adapter and argv`];
869
+ const runner = /** @type {Record<string, unknown>} */ (entry);
870
+ const messages = [];
871
+ const adapter = typeof runner.adapter === 'string' && Object.hasOwn(RUNNER_ADAPTER_TOKENS, runner.adapter) ? runner.adapter : null;
872
+ if (adapter === null) messages.push(`${at}.adapter must be one of: ${Object.keys(RUNNER_ADAPTER_TOKENS).join(', ')}`);
873
+ for (const key of Object.keys(runner)) {
874
+ if (!RUNNER_KEYS.includes(key)) messages.push(`${at}: ${JSON.stringify(key)} is not a runner key (${RUNNER_KEYS.join(', ')})`);
875
+ }
876
+ const match = runner.match;
877
+ if (!Array.isArray(match) || match.length === 0 || !match.every((glob) => typeof glob === 'string' && glob.length > 0)) {
878
+ messages.push(`${at}.match must be a non-empty list of globs`);
879
+ }
880
+ if (!isArgvArray(runner.argv)) {
881
+ messages.push(`${at}.argv must be a non-empty argv array of strings`);
882
+ } else {
883
+ const argv = /** @type {string[]} */ (runner.argv);
884
+ if (TOKEN_SHAPE.test(argv[0])) messages.push(`${at}.argv[0]: a token cannot be the command`);
885
+ // with a bad adapter, a token-shaped item is checked against every adapter's tokens instead;
886
+ // every other argv check still runs
887
+ const known = adapter === null ? null : RUNNER_ADAPTER_TOKENS[/** @type {keyof typeof RUNNER_ADAPTER_TOKENS} */ (adapter)];
888
+ argv.forEach((item, j) => {
889
+ if (item.includes('\0')) {
890
+ messages.push(`${at}.argv[${j}] contains a NUL character`);
891
+ } else if (j > 0 && TOKEN_SHAPE.test(item) && known !== null && !known.includes(item)) {
892
+ messages.push(`${at}.argv[${j}] is not a token of the ${adapter} adapter (${known.join(', ')})`);
893
+ } else if (j > 0 && TOKEN_SHAPE.test(item) && known === null && !ANY_ADAPTER_TOKENS.includes(item)) {
894
+ messages.push(`${at}.argv[${j}] is not a token of any adapter (${ANY_ADAPTER_TOKENS.join(', ')})`);
895
+ } else if (!TOKEN_SHAPE.test(item) && ALL_RUNNER_TOKENS.some((token) => item.includes(token))) {
896
+ messages.push(`${at}.argv[${j}]: a token must be a whole argv item (give it as its own item)`);
897
+ }
898
+ });
899
+ // the required tokens depend on the adapter; with a bad adapter, every adapter's report items
900
+ // and test tokens count, so an argv that names no report or no test is still refused
901
+ const items = argv.slice(1);
902
+ if (adapter === 'junit' || adapter === 'swift') {
903
+ if (!items.includes('{report}')) messages.push(`${at}.argv needs a {report} item (the ${adapter} adapter reads the report code-forge places there)`);
904
+ if (!items.some((item) => RUNNER_TEST_TOKENS.includes(item))) messages.push(`${at}.argv needs a test token (${RUNNER_TEST_TOKENS.join(', ')}): a proof must name its test`);
905
+ } else if (adapter === 'xcodebuild') {
906
+ for (const [token, why] of XCODE_REQUIRED_TOKENS) if (!items.includes(token)) messages.push(`${at}.argv needs a ${token} item (${why})`);
907
+ } else {
908
+ if (!items.some((item) => ANY_REPORT_TOKENS.includes(item))) messages.push(`${at}.argv needs a report item (${ANY_REPORT_TOKENS.join(', ')}): every adapter reads the report code-forge places there`);
909
+ if (!items.some((item) => ANY_TEST_TOKENS.includes(item))) messages.push(`${at}.argv needs a test token (${ANY_TEST_TOKENS.join(', ')}): a proof must name its test`);
910
+ }
911
+ }
912
+ messages.push(...xcodeMessages(runner, adapter, at));
913
+ if (runner.env !== undefined) {
914
+ if (runner.env === null || typeof runner.env !== 'object' || Array.isArray(runner.env)) {
915
+ messages.push(`${at}.env must be a map of variable names to string values`);
916
+ } else {
917
+ for (const [name, envValue] of Object.entries(runner.env)) {
918
+ if (!ENV_NAME.test(name)) messages.push(`${at}.env: ${JSON.stringify(name)} is not a variable name (letters, digits and _, not starting with a digit)`);
919
+ else if (typeof envValue !== 'string') messages.push(`${at}.env.${name} must be a string`);
920
+ else if (envValue.includes('\0')) messages.push(`${at}.env.${name} contains a NUL character`);
921
+ }
922
+ }
923
+ }
924
+ if (runner.timeout_s !== undefined) {
925
+ const t = runner.timeout_s;
926
+ if (typeof t !== 'number' || !Number.isInteger(t) || t < RUNNER_TIMEOUT_MIN_S || t > RUNNER_TIMEOUT_MAX_S) {
927
+ messages.push(`${at}.timeout_s must be a whole number of seconds from ${RUNNER_TIMEOUT_MIN_S} to ${RUNNER_TIMEOUT_MAX_S}`);
928
+ }
929
+ }
930
+ return messages;
931
+ }
932
+
933
+ /** @param {unknown} value @returns {boolean} */
934
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
935
+
936
+ /**
937
+ * B102: rule 17's messages for an entry's `xcode` object. An `xcodebuild` entry needs one; any other
938
+ * adapter refuses one. Its values are argv items of `xcodebuild`, so each is a non-empty string with
939
+ * no NUL that does not start with `-` (it would be read as an option). Messages name key paths only.
940
+ * @param {Record<string, unknown>} runner @param {string | null} adapter @param {string} at
941
+ * @returns {string[]}
942
+ */
943
+ function xcodeMessages(runner, adapter, at) {
944
+ const xcode = runner.xcode;
945
+ if (xcode === undefined) return adapter === 'xcodebuild' ? [`${at}.xcode is required for the xcodebuild adapter (${XCODE_REQUIRED.join(', ')})`] : [];
946
+ if (adapter !== null && adapter !== 'xcodebuild') return [`${at}.xcode is only for the xcodebuild adapter`];
947
+ if (!isPlainObject(xcode)) return [`${at}.xcode must be an object with ${XCODE_REQUIRED.join(', ')}`];
948
+ const values = /** @type {Record<string, unknown>} */ (xcode);
949
+ const messages = [];
950
+ for (const key of Object.keys(values)) {
951
+ if (!XCODE_KEYS.includes(key)) messages.push(`${at}.xcode: ${JSON.stringify(key)} is not an xcode key (${XCODE_KEYS.join(', ')})`);
952
+ }
953
+ for (const key of XCODE_KEYS) {
954
+ const value = values[key];
955
+ if (value === undefined) {
956
+ if (XCODE_REQUIRED.includes(key)) messages.push(`${at}.xcode.${key} is required`);
957
+ } else if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) {
958
+ messages.push(`${at}.xcode.${key} must be a non-empty string that does not start with "-"`);
959
+ } else if (value.includes('\0')) {
960
+ messages.push(`${at}.xcode.${key} contains a NUL character`);
961
+ } else if (key === 'test_target' && value.includes('/')) {
962
+ messages.push(`${at}.xcode.test_target must be one target name, without "/"`);
963
+ }
964
+ }
965
+ if (values.workspace !== undefined && values.project !== undefined) messages.push(`${at}.xcode: give workspace or project, not both`);
966
+ // a {workspace} / {project} item and its xcode key come together: one without the other is a
967
+ // value xcodebuild would never see, or an argv item with no value
968
+ const argv = isArgvArray(runner.argv) ? /** @type {string[]} */ (runner.argv).slice(1) : null;
969
+ if (argv !== null) {
970
+ for (const key of ['workspace', 'project']) {
971
+ const token = `{${key}}`;
972
+ if (argv.includes(token) && values[key] === undefined) messages.push(`${at}.argv has ${token} but ${at}.xcode.${key} is not set`);
973
+ if (!argv.includes(token) && values[key] !== undefined) messages.push(`${at}.xcode.${key} is set but ${at}.argv has no ${token} item`);
974
+ }
975
+ }
976
+ return messages;
977
+ }
978
+
979
+ function checkProofRunnersInvalid(cfg) {
980
+ const runners = cfg?.proof?.runners;
981
+ if (runners === undefined) return [];
982
+ const messages = !Array.isArray(runners)
983
+ ? ['proof.runners must be a list of runner entries']
984
+ : runners.length === 0
985
+ ? ['proof.runners must list at least one runner (leave the key out for none)']
986
+ : runners.flatMap((entry, i) => runnerMessages(entry, i));
987
+ return messages.map((message) => ({ rule: 'proof-runners-invalid', severity: /** @type {const} */ ('error'), message }));
988
+ }
989
+
990
+ // ── 18. review-routes-invalid (ERROR, B90) ──────────────────────────────────
991
+
992
+ /** @param {Record<string, any>} cfg @returns {any[]} the `review.routes` list, `[]` when it is not one. */
993
+ function routeRulesOf(cfg) {
994
+ const routes = cfg?.review?.routes;
995
+ return Array.isArray(routes) ? routes : [];
996
+ }
997
+
998
+ /**
999
+ * B90: what the schema cannot say about `review.routes` — a level other than L2 or L3, two rules
1000
+ * for the same level and `min_lines` (which one wins would be order-dependent), and, with
1001
+ * `review.multimodel` on, an L2 rule whose provider (its own, else the top-level one) equals its
1002
+ * own `second.provider`, or the second reviewer's effective L2 provider — the consensus slot B a
1003
+ * routed ticket really runs with — (the consensus needs two providers on a big file too).
1004
+ * Messages name key paths only, never a value.
1005
+ * @param {Record<string, any>} cfg
1006
+ */
1007
+ function checkReviewRoutesInvalid(cfg) {
1008
+ const routes = cfg?.review?.routes;
1009
+ if (routes === undefined || routes === null) return [];
1010
+ /** @type {string[]} */
1011
+ const messages = [];
1012
+ if (!Array.isArray(routes)) {
1013
+ messages.push('review.routes must be a list of rules');
1014
+ } else {
1015
+ /** @type {Map<string, number>} */
1016
+ const seen = new Map();
1017
+ const second = effectiveSecondL2Provider(cfg);
1018
+ routes.forEach((rule, i) => {
1019
+ const at = `review.routes[${i}]`;
1020
+ if (!isPlainObject(rule)) {
1021
+ messages.push(`${at} must be a rule object`);
1022
+ return;
1023
+ }
1024
+ const level = rule.level ?? 'L2';
1025
+ if (!ROUTE_LEVELS.includes(level)) {
1026
+ messages.push(`${at}.level must be L2 or L3`);
1027
+ return;
1028
+ }
1029
+ if (Number.isInteger(rule.min_lines)) {
1030
+ const k = `${level}\0${rule.min_lines}`;
1031
+ const first = seen.get(k);
1032
+ if (first === undefined) seen.set(k, i);
1033
+ else messages.push(`${at} has the same level and min_lines as review.routes[${first}]`);
1034
+ }
1035
+ if (cfg?.review?.multimodel === true && level === 'L2') {
1036
+ // the provider the worker spawns this rule on: its own, else the top-level one (the rule
1037
+ // REPLACES levels.L2, so levels.L2.provider is never inherited)
1038
+ const first = ruleProvider(cfg, rule);
1039
+ const own = isPlainObject(rule.second) ? rule.second.provider : undefined;
1040
+ if (own !== undefined && first === own) {
1041
+ // the rule's own second reviewer
1042
+ messages.push(`${at}.provider (else provider) equals ${at}.second.provider — multimodel needs two different providers`);
1043
+ } else if (second !== undefined && first === second) {
1044
+ // the consensus slot B a routed ticket runs with (it keeps review.second_levels.L2)
1045
+ messages.push(`${at}.provider (else provider) equals the second reviewer's effective L2 provider (review.second_levels.L2.provider, else review.second_provider) — multimodel needs two different providers`);
1046
+ }
1047
+ }
1048
+ });
1049
+ }
1050
+ return messages.map((message) => ({ rule: 'review-routes-invalid', severity: /** @type {const} */ ('error'), message }));
1051
+ }
1052
+
1053
+ // ── 19. sandbox-dir-refused (ERROR, B93) ────────────────────────────────────
1054
+ /**
1055
+ * A `coder.sandbox.add_dirs` entry that is not absolute / `~/`, or that is `/`, the home dir, or
1056
+ * is, holds or sits inside a secret dir (`~/.code-forge`, `~/.codex`, `~/.ssh`, …) or the run
1057
+ * temp base (every run temp root and session CODEX_HOME live under it). One issue per refused
1058
+ * entry; the message names the key path, never the value. The builder checks again at spawn.
1059
+ * @param {Record<string, any>} cfg
1060
+ * @returns {ValidationIssue[]}
1061
+ */
1062
+ function checkSandboxDirRefused(cfg) {
1063
+ const sandbox = isPlainObject(cfg.coder) ? cfg.coder.sandbox : undefined;
1064
+ // a non-list add_dirs, or a non-string entry, is the schema layer's issue (fix round 1)
1065
+ if (!isPlainObject(sandbox) || !Array.isArray(sandbox.add_dirs)) return [];
1066
+ const dirs = /** @type {unknown[]} */ (sandbox.add_dirs);
1067
+ /** @type {string[]} */
1068
+ const extra = [];
1069
+ try {
1070
+ extra.push(tmpBase(isPlainObject(cfg.tmp) ? cfg.tmp.root ?? undefined : undefined));
1071
+ } catch {
1072
+ extra.push(tmpBase()); // a bad tmp.root is the schema's issue; the default base still counts
1073
+ }
1074
+ const nonStrings = new Set(dirs.flatMap((entry, i) => (typeof entry === 'string' ? [] : [`coder.sandbox.add_dirs[${i}]`])));
1075
+ return sandboxDirRefusals(dirs, { extra })
1076
+ .filter((r) => !nonStrings.has(r.keyPath))
1077
+ .map((r) => ({
1078
+ rule: SANDBOX_DIR_REFUSED,
1079
+ severity: /** @type {const} */ ('error'),
1080
+ message: `${r.keyPath} is refused — ${r.why}`,
1081
+ }));
1082
+ }
1083
+
1084
+ // ── 20. sandbox-dirs-unused (WARN, B93 fix round 1) ─────────────────────────
1085
+ /**
1086
+ * `coder.sandbox.add_dirs` is set (a non-empty list) but the profile is not `toolchain`: only
1087
+ * `toolchain` adds `--add-dir`, so the entries change nothing (rule 19 still refuses a bad one).
1088
+ * The message names the key path and the profile, never a dir.
1089
+ * @param {Record<string, any>} cfg
1090
+ * @returns {ValidationIssue[]}
1091
+ */
1092
+ function checkSandboxDirsUnused(cfg) {
1093
+ const sandbox = isPlainObject(cfg.coder) ? cfg.coder.sandbox : undefined;
1094
+ if (!isPlainObject(sandbox) || !Array.isArray(sandbox.add_dirs) || sandbox.add_dirs.length === 0) return [];
1095
+ const profile = sandbox.profile ?? DEFAULT_SANDBOX_PROFILE;
1096
+ if (profile === 'toolchain' || !SANDBOX_PROFILES.includes(profile)) return [];
1097
+ return [{
1098
+ rule: 'sandbox-dirs-unused',
1099
+ severity: /** @type {const} */ ('warning'),
1100
+ message: `coder.sandbox.add_dirs is set but coder.sandbox.profile is ${profile}, which does not use it — only toolchain adds --add-dir`,
1101
+ }];
1102
+ }
1103
+
793
1104
  /**
794
- * The 16 domain rules paired with the exact `rule` id string each one emits — the single source
1105
+ * The 20 domain rules paired with the exact `rule` id string each one emits — the single source
795
1106
  * of truth both `DOMAIN_RULES` (below, what `validateConfig` runs) and the exported
796
1107
  * `DOMAIN_RULE_IDS` are built from. `'schema'` (the ajv layer's own issues) is deliberately not
797
- * one of these 16 (B29 added rule 15, B32 rule 16). (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
1108
+ * one of these 20 (B29 added rule 15, B32 rule 16, B87 rule 17, B90 rule 18, B93 rules 19 and 20). (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
798
1109
  * @type {ReadonlyArray<{fn: (cfg: Record<string, any>, opts?: object) => ValidationIssue[], id: string}>}
799
1110
  */
800
1111
  const RULE_TABLE = Object.freeze([
@@ -814,12 +1125,16 @@ const RULE_TABLE = Object.freeze([
814
1125
  { fn: checkCapsCodersExceedsCap, id: 'caps-coders-exceeds-cap' },
815
1126
  { fn: checkEffortNotValidForProvider, id: 'effort-not-valid-for-provider' },
816
1127
  { fn: checkClosedBookOnOpenai, id: 'closed-book-on-openai' },
1128
+ { fn: checkProofRunnersInvalid, id: 'proof-runners-invalid' },
1129
+ { fn: checkReviewRoutesInvalid, id: 'review-routes-invalid' },
1130
+ { fn: checkSandboxDirRefused, id: 'sandbox-dir-refused' },
1131
+ { fn: checkSandboxDirsUnused, id: 'sandbox-dirs-unused' },
817
1132
  ]);
818
1133
 
819
- /** The 16 domain rules, in the order documented above. */
1134
+ /** The 20 domain rules, in the order documented above. */
820
1135
  const DOMAIN_RULES = Object.freeze(RULE_TABLE.map((entry) => entry.fn));
821
1136
 
822
- /** The 16 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
1137
+ /** The 20 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
823
1138
  export const DOMAIN_RULE_IDS = Object.freeze(RULE_TABLE.map((entry) => entry.id));
824
1139
 
825
1140
  /**
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `code-forge doctor` (plan §2.3; block B13b): every row, in print order. `--quick` = the three
3
3
  * rows the skill's preflight needs (config, harness links, Jev key resolution); the full run adds
4
- * the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, the provider CLI probes (presence,
5
- * version, flags, one call per role builder, isolation, path deny, Codex rules), the worker
6
- * start/stop, the signer key and the `signer: same-user boundary only` line.
4
+ * the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, `review state` (B69), the provider
5
+ * CLI probes (presence, version, flags, one call per role builder, isolation, path deny, Codex
6
+ * rules), the worker start/stop, the signer key and the `signer: same-user boundary only` line.
7
7
  */
8
8
 
9
9
  import { randomBytes } from 'node:crypto';
@@ -13,8 +13,8 @@ import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
- import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkTmp, checkTools } from './local.mjs';
17
- import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
16
+ import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
17
+ import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
20
20
 
@@ -68,7 +68,9 @@ export async function runDoctor(opts = {}, deps = {}) {
68
68
  rows.push(...(await checkTools(toolOpts)));
69
69
  rows.push(...(await checkLedger()));
70
70
  rows.push(...checkTmp(cfg));
71
+ rows.push(...(await checkReviewState(cfg)));
71
72
  rows.push(checkReviewConcurrency(cfg));
73
+ rows.push(...checkCoderSandbox(cfg)); // B93: no rows without coder.sandbox
72
74
  if (!cfg) {
73
75
  rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
74
76
  rows.push(SIGNER_ROW);
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Doctor rows that need no provider CLI (plan §2.3; block B13b): config, harness links, Jev key
3
3
  * resolution (the three `--quick` rows), `code-forge` on PATH, Solo, gate commands, the live Jev
4
- * call, ledger writability and the `tmp` row. Every check returns rows; none throws.
4
+ * call, ledger writability, the `tmp` row and (B69) the `review state` row. Every check returns
5
+ * rows; none throws.
5
6
  */
6
7
 
7
8
  import { constants, readdirSync } from 'node:fs';
@@ -15,12 +16,18 @@ import { commandOnPath } from '../install/detect.mjs';
15
16
  import { installsPath, readInstalls, targetResolves } from '../install/link.mjs';
16
17
  import { missingTools } from '../install/tools.mjs';
17
18
  import { OP_MESSAGES } from '../keys/onepassword.mjs';
18
- import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
19
+ import { cacheHoursFrom, createDefaultKeyStore, isTime, resolveKey } from '../keys/store.mjs';
19
20
  import { ledgerDir } from '../ledger/paths.mjs';
21
+ import { readAllRows } from '../ledger/write.mjs';
20
22
  import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
23
+ import { snapshotFor } from '../state/config-snapshot.mjs';
24
+ import { assertRunId, runsDir } from '../state/paths.mjs';
25
+ import { readRun } from '../state/run.mjs';
26
+ import { loadKey } from '../state/signer.mjs';
21
27
  import { listEntries } from '../util/reaper.mjs';
22
- import { ownerState, tmpBase } from '../util/tmp.mjs';
28
+ import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
23
29
  import { parallelTickets } from '../worker/loop.mjs';
30
+ import { missingStateFiles } from '../worker/review-state.mjs';
24
31
  import { row } from './rows.mjs';
25
32
 
26
33
  /** @typedef {import('./rows.mjs').Row} Row */
@@ -65,16 +72,28 @@ export async function checkLinks(home) {
65
72
 
66
73
  /**
67
74
  * Resolve the Jev key through B2's chain (env → keychain/file → 1Password). The value never
68
- * leaves this function except to the caller that makes the live call.
75
+ * leaves this function except to the caller that makes the live call. A refresh from 1Password is
76
+ * cached for `keys.cache_hours` (B84); a config that failed validation arrives as null ⇒ 8 h.
77
+ * B85: under 1 hour left the row is WARN with a fixed re-link text (never the reference); when
78
+ * `keys.cache_hours` is set the OK text ends `(cache <n> h)`.
69
79
  * @param {Record<string, any> | null} cfg
70
- * @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any}} deps
80
+ * @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any, now?: number}} deps
71
81
  * @returns {Promise<{rows: Row[], key: string | null}>}
72
82
  */
73
83
  export async function checkKeys(cfg, deps) {
74
84
  try {
75
85
  const store = deps.store ?? (await createDefaultKeyStore(deps.env));
76
86
  const ref = cfg?.system1?.key ?? cfg?.keys?.jev;
77
- const res = await resolveKey('jev', { store, env: deps.env, ref: typeof ref === 'string' ? ref : undefined, ...(deps.opRead ? { opRead: deps.opRead } : {}) });
87
+ const cache = cacheHoursFrom(cfg);
88
+ const now = deps.now ?? Date.now();
89
+ const res = await resolveKey('jev', {
90
+ store,
91
+ env: deps.env,
92
+ ref: typeof ref === 'string' ? ref : undefined,
93
+ cacheHours: cache.hours,
94
+ now,
95
+ ...(deps.opRead ? { opRead: deps.opRead } : {}),
96
+ });
78
97
  if (res.value === null) {
79
98
  // only fixed texts are shown: a 1Password failure by its classified message (B25), any other
80
99
  // reason as a fixed "key store error" — never a reference, a path or tool output
@@ -85,8 +104,17 @@ export async function checkKeys(cfg, deps) {
85
104
  const why = reasons.length > 0 ? ` (${reasons.join('; ')})` : '';
86
105
  return { rows: [row('keys', 'WARN', 'keys', `jev key not resolved${why}; System 1 falls back to rules`)], key: null };
87
106
  }
88
- const exp = res.exp === null ? 'no expiry' : `expires ${new Date(res.exp).toISOString()}`;
89
- return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}`)], key: res.value };
107
+ if (isTime(res.exp) && res.exp - now < 3_600_000) {
108
+ // whole minutes left, never 0 and never 60 (as keys list)
109
+ const minutes = Math.max(1, Math.floor((res.exp - now) / 60_000));
110
+ return {
111
+ rows: [row('keys', 'WARN', 'keys', `jev key from ${res.source} expires in ${minutes} min; re-link with code-forge keys set jev --op <ref from keys.jev>`)],
112
+ key: res.value,
113
+ };
114
+ }
115
+ const exp = res.exp === null ? 'no expiry' : isTime(res.exp) ? `expires ${new Date(res.exp).toISOString()}` : 'expiry unknown';
116
+ const hours = cache.source === 'config' ? ` (cache ${cache.hours} h)` : '';
117
+ return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}${hours}`)], key: res.value };
90
118
  } catch (err) {
91
119
  return { rows: [row('keys', 'FAIL', 'keys', `jev key resolution failed (${err?.code ?? err?.name ?? 'error'})`)], key: null };
92
120
  }
@@ -192,6 +220,86 @@ export function checkTmp(cfg) {
192
220
  return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
193
221
  }
194
222
 
223
+ /** B69: the most missing state files the `review state` row names. */
224
+ export const LISTED_MAX = 10;
225
+
226
+ /**
227
+ * B69: one WARN row per ACTIVE run (its record's `status` is `active`, as `run end` and the worker
228
+ * decide it) whose ledger has `review.state` anchors whose latest state file is missing — neither
229
+ * at `<code-forge home>/runs/<run>/review-state/` nor at the old temp-root path. The row counts the
230
+ * files the ledger can rebuild (the latest anchor carries `state_body`) and the lost ones (no body:
231
+ * the next ticket is refused `review-state-lost`; the owner needs `code-forge block reopen`), and
232
+ * names the first {@link LISTED_MAX} blocks and files (then `… and K more`). No row when nothing
233
+ * is missing. When the old temp location cannot be named (`tmp.root` not absolute), nothing is
234
+ * counted and the row says so; when an anchor of the run does not verify, the row says the review
235
+ * state was not checked. Each project's ledger is read once. Never throws: a run whose record, key
236
+ * or ledger cannot be read is skipped. No file content is read or printed.
237
+ * @param {Record<string, any> | null} cfg
238
+ * @returns {Promise<Row[]>}
239
+ */
240
+ export async function checkReviewState(cfg) {
241
+ let names = [];
242
+ try {
243
+ names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
244
+ } catch {
245
+ return [];
246
+ }
247
+ /** @type {Row[]} */
248
+ const rows = [];
249
+ /** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
250
+ const ledgers = new Map();
251
+ for (const name of names.sort()) {
252
+ const runId = name.slice(0, -'.json'.length);
253
+ try {
254
+ assertRunId(runId);
255
+ const record = await readRun(runId);
256
+ if (record?.status !== 'active' || typeof record.project !== 'string') continue;
257
+ const key = await loadKey(runId);
258
+ let ledger = ledgers.get(record.project);
259
+ if (ledger === undefined) {
260
+ ledger = await readAllRows(record.project);
261
+ ledgers.set(record.project, ledger);
262
+ }
263
+ const atNew = missingStateFiles({ runId, key, rows: ledger });
264
+ if (atNew === null) {
265
+ rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: review.state anchors do not verify; review state not checked`));
266
+ continue;
267
+ }
268
+ let root;
269
+ try {
270
+ root = snapshotFor(record, '')?.config?.tmp?.root;
271
+ } catch {
272
+ root = undefined;
273
+ }
274
+ if (typeof root !== 'string') root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
275
+ let runRootDir;
276
+ try {
277
+ runRootDir = runRootPath(runId, root === undefined ? {} : { root });
278
+ } catch {
279
+ runRootDir = undefined;
280
+ }
281
+ if (runRootDir === undefined) {
282
+ // the old place cannot be named: nothing is counted as missing, the row says why
283
+ if (atNew.length > 0) {
284
+ rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: the old temp location could not be checked (tmp.root is not an absolute path); review state files are not counted`));
285
+ }
286
+ continue;
287
+ }
288
+ const missing = missingStateFiles({ runId, key, rows: ledger, runRootDir });
289
+ if (missing === null || missing.length === 0) continue;
290
+ const rebuildable = missing.filter((m) => m.rebuildable).length;
291
+ const lost = missing.length - rebuildable;
292
+ const shown = missing.slice(0, LISTED_MAX).map((m) => `${m.block} ${m.file} (${m.rebuildable ? 'rebuildable' : 'lost'})`).join(', ');
293
+ const more = missing.length > LISTED_MAX ? ` … and ${missing.length - LISTED_MAX} more` : '';
294
+ rows.push(row('review-state', 'WARN', 'review state',
295
+ `run ${runId}: ${missing.length} state file(s) missing — ${rebuildable} rebuildable from the ledger, ${lost} lost (needs code-forge block reopen): ${shown}${more}`));
296
+ } catch {
297
+ // an unreadable record, key or ledger: not this row's business
298
+ }
299
+ }
300
+ return rows;
301
+ }
302
+
195
303
  /**
196
304
  * B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
197
305
  * session slots, from the effective config (defaults when a key is unset). Never throws.
@@ -32,6 +32,7 @@ import { mergeForbidden, renderForCodex } from '../util/forbidden.mjs';
32
32
  import { buildArgv } from '../engines/builders/index.mjs';
33
33
  import { probeHelpText } from '../engines/probe.mjs';
34
34
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
35
+ import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES } from '../engines/sandbox-dirs.mjs';
35
36
  import { commandOnPath } from '../install/detect.mjs';
36
37
  import { classifyUnavailable, readAnswer, spawnSession } from '../session/spawn.mjs';
37
38
  import { S2_SCHEMA } from '../session/s2.mjs';
@@ -98,6 +99,43 @@ export function configuredProviders(cfg) {
98
99
  return [...out];
99
100
  }
100
101
 
102
+ /** B93: the coders `coder.sandbox` never changes, by provider, in the order doctor names them. */
103
+ const SANDBOX_NO_EFFECT = Object.freeze([
104
+ ['anthropic', 'Claude (anthropic)'],
105
+ ['xai', 'Grok (xai)'],
106
+ ]);
107
+
108
+ /**
109
+ * B93: the `coder.sandbox` rows. None without the key, for a null config, or for a value the
110
+ * schema refuses (a non-mapping, an unknown profile: config validation reports those). With it:
111
+ * - one INFO row `coder-sandbox` naming the profile (and, for `toolchain`, how many extra dirs);
112
+ * - one INFO row `coder-sandbox-no-effect` only when a Claude (anthropic) or Grok (xai) coder is
113
+ * configured (any level or fallback): the key has no effect on it, its argv does not change;
114
+ * - one WARN row `coder-sandbox-full` when the profile is `full` (`-s danger-full-access`).
115
+ * Rows name the profile and a count, never a directory.
116
+ * @param {Record<string, any> | null | undefined} cfg
117
+ * @returns {import('./rows.mjs').Row[]}
118
+ */
119
+ export function checkCoderSandbox(cfg) {
120
+ const sandbox = cfg?.coder?.sandbox;
121
+ if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return [];
122
+ const profile = sandbox.profile ?? DEFAULT_SANDBOX_PROFILE;
123
+ if (!SANDBOX_PROFILES.includes(profile)) return [];
124
+ const dirs = Array.isArray(sandbox.add_dirs) ? sandbox.add_dirs.length : 0;
125
+ const extra = profile === 'toolchain' ? `, ${dirs} extra writable ${dirs === 1 ? 'dir' : 'dirs'}` : '';
126
+ const rows = [row('coder-sandbox', 'INFO', 'coder sandbox', `profile ${profile}${extra}; applies to the Codex (openai) coder only`)];
127
+ const providers = configuredProviders(cfg);
128
+ const others = SANDBOX_NO_EFFECT.filter(([id]) => providers.includes(id)).map(([, name]) => name);
129
+ if (others.length > 0) {
130
+ const who = others.length === 1 ? `the ${others[0]} coder` : `the ${others.join(' and ')} coders`;
131
+ rows.push(row('coder-sandbox-no-effect', 'INFO', 'coder sandbox', `coder.sandbox has no effect on ${who}: the argv does not change`));
132
+ }
133
+ if (profile === 'full') {
134
+ rows.push(row('coder-sandbox-full', 'WARN', 'coder sandbox', 'profile full runs the Codex coder with -s danger-full-access: it can write anywhere you can; only the forbidden list, the transcript grep and the gates limit it'));
135
+ }
136
+ return rows;
137
+ }
138
+
101
139
  /** @param {Record<string, any>} cfg @returns {Record<string, any>} the config with every level's fallback removed. */
102
140
  function withoutFallback(cfg) {
103
141
  /** @type {Record<string, any>} */