@starci/hfs 4.1.0 → 4.2.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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.2.0 - 2026-10-09
4
+
5
+ - Changed: the lint runner (`lint/run.mjs`) and the bundled runtime copies follow the runtime of 1.0.0-alpha.9. Version 4.1.0 on the registry carries the copies of an earlier runtime.
6
+
3
7
  ## 4.1.0 - 2026-10-08
4
8
 
5
9
  - Breaking (runtime alpha.4 CLI unification): remove the `hfs` bin and expose `main(argv, io)` for the sole `starci app ...` grammar; the app flags `--repo` and `--root` are replaced by the global `--cwd` flag.
package/lint/run.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { createRequire } from 'node:module';
21
- import { spawnSync } from 'node:child_process';
21
+ import { spawn } from 'node:child_process';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
  import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
24
24
  import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
@@ -66,8 +66,22 @@ export function parseLintArgs(argv) {
66
66
  export const BOUND_ENV = 'STARCI_LINT_BOUND';
67
67
  export const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
68
68
 
69
- /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. */
70
- function runLinter({ cwd, pkg, bin, args, bound = null }) {
69
+ /** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
70
+ function runNode(args, options) {
71
+ return new Promise((resolve) => {
72
+ const child = spawn(process.execPath, args, { ...options, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
73
+ const out = [];
74
+ const err = [];
75
+ let failure;
76
+ child.stdout.on('data', (chunk) => out.push(chunk));
77
+ child.stderr.on('data', (chunk) => err.push(chunk));
78
+ child.on('error', (error) => { failure = error; });
79
+ child.on('close', (status, signal) => resolve({ status, signal, error: failure, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }));
80
+ });
81
+ }
82
+
83
+ /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
84
+ async function runLinter({ cwd, pkg, bin, args, bound = null }) {
71
85
  let entry;
72
86
  try {
73
87
  const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
@@ -76,7 +90,7 @@ function runLinter({ cwd, pkg, bin, args, bound = null }) {
76
90
  } catch {
77
91
  return { error: `${pkg} is not installed for ${cwd}` };
78
92
  }
79
- const run = spawnSync(process.execPath, [...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
93
+ const run = await runNode([...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
80
94
  // ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
81
95
  // without a spawn error or signal are measurable, and exit 1 must carry findings.
82
96
  let results;
@@ -165,6 +179,29 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
165
179
  return { results: valid, errors };
166
180
  }
167
181
 
182
+ /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
183
+ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe }) {
184
+ const launches = [];
185
+ for (const side of app ? SIDES : []) {
186
+ if (workspace !== null && side !== STYLE_SIDE) { launches.push({ side, skipped: `outside --workspace ${workspace}` }); continue; }
187
+ const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
188
+ if (changed !== null && !sources.length) { launches.push({ side, skipped: 'no changed source file' }); continue; }
189
+ const cwd = path.join(repoRoot, side);
190
+ let expected;
191
+ try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
192
+ catch (error) { launches.push({ side, scopeError: `eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}` }); continue; }
193
+ launches.push({ side, cwd, expected, run: runLinter({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot }) });
194
+ }
195
+ return launches;
196
+ }
197
+
198
+ /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
199
+ function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
200
+ const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
201
+ if (changed !== null && !styles.length) return null;
202
+ return runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
203
+ }
204
+
168
205
  /**
169
206
  * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
170
207
  * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
@@ -191,22 +228,24 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
191
228
  /** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
192
229
  const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
193
230
 
231
+ // The linters and the app check are independent: every child starts first, the app check runs while they work, and the results are read in a fixed order.
232
+ const eslintLaunches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe });
233
+ const styleLaunch = app ? launchStylelint({ repoRoot, opts, changed, existing, onFe }) : null;
234
+ const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value }), (error) => ({ error })) : null;
235
+
194
236
  // 1. ESLint, once per side, from the side folder with that side's config.
195
237
  engines.eslint = { sides: {} };
196
- for (const side of app ? SIDES : []) {
197
- if (workspace !== null && side !== STYLE_SIDE) { engines.eslint.sides[side] = { files: 0, skipped: `outside --workspace ${workspace}` }; continue; }
198
- const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
199
- if (changed !== null && !sources.length) { engines.eslint.sides[side] = { files: 0, skipped: 'no changed source file' }; continue; }
200
- const cwd = path.join(repoRoot, side);
201
- let expected;
202
- try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
203
- catch (error) { errors.push(`eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}`); engines.eslint.sides[side] = { files: 0 }; continue; }
204
- const linted = runLinter({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot });
238
+ const eslintRuns = await Promise.all(eslintLaunches.map((launch) => launch.run ?? null));
239
+ eslintLaunches.forEach((launch, at) => {
240
+ const { side } = launch;
241
+ if (launch.skipped) { engines.eslint.sides[side] = { files: 0, skipped: launch.skipped }; return; }
242
+ if (launch.scopeError) { errors.push(launch.scopeError); engines.eslint.sides[side] = { files: 0 }; return; }
243
+ const linted = eslintRuns[at];
205
244
  if (linted.error) errors.push(linted.error);
206
245
  if (linted.results) {
207
- const checked = checkedEslintResults({ results: linted.results, expected, cwd, scope: onFe, changed: changed !== null });
208
- errors.push(...checked.errors);
209
- linted.results = checked.results;
246
+ const checkedResults = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, scope: onFe, changed: changed !== null });
247
+ errors.push(...checkedResults.errors);
248
+ linted.results = checkedResults.results;
210
249
  // The side canon names side-relative paths in its messages; the report names every path from the app root.
211
250
  const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
212
251
  for (const result of linted.results) for (const message of result.messages ?? []) message.message = appRelative(message.message);
@@ -214,13 +253,12 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
214
253
  findings.push(...eslintFindings(linted.results, repoRoot));
215
254
  }
216
255
  engines.eslint.sides[side] = { files: linted.results?.length ?? 0 };
217
- }
256
+ });
218
257
 
219
258
  // 3. stylelint over the fe side's stylesheets.
220
259
  if (app) {
221
- const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
222
- if (changed === null || styles.length) {
223
- const linted = runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
260
+ if (styleLaunch) {
261
+ const linted = await styleLaunch;
224
262
  if (linted.error) errors.push(linted.error);
225
263
  if (linted.results) {
226
264
  const appRelative = appRelativeMessages(STYLE_SIDE, path.join(repoRoot, STYLE_SIDE));
@@ -234,8 +272,10 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
234
272
 
235
273
  // 2. The app check: root and sides.
236
274
  let checked = { findings: [] };
237
- if (app) {
238
- try { checked = await hfsCheck(repoRoot); } catch (error) { errors.push(`starci app check could not run: ${String(error?.message ?? error)}`); }
275
+ if (checkRun) {
276
+ const settled = await checkRun;
277
+ if (settled.error) errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
278
+ else checked = settled.value;
239
279
  }
240
280
  const repoErrors = checked.findings.filter((finding) => finding.level === 'error');
241
281
  // A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -185,3 +185,10 @@ sonar: {organization: null}
185
185
  # minFreeRamPct free-RAM percentage below which no new heavy op starts
186
186
  # set them only in the gitignored config.yaml copy, e.g.
187
187
  # resources: {minFreeDiskGb: 5, minFreeDiskPct: 1, minFreeRamPct: 10}
188
+ # release — where the full test suite of a release is judged (scripts/guards/release-suite-mode.mjs; docs/releasing.md "Where the suite runs"). One key:
189
+ # suite: local the shipped default: `starci release cut` runs the root suite and the Linux parity container before it pushes, and the pre-push gate asks for their green rows
190
+ # suite: ci the owner's recorded choice (2026-10-09): no local full suite and no Linux container; the GitHub ci workflow runs the suite (with coverage, Codecov, SonarCloud) AFTER the push.
191
+ # The cut still runs npm run check, npm run test:packages, the specs affected by the release, the live Orca smokes and the example apps; the record lists the root suite as
192
+ # delegated, never green. A release can reach GitHub and its tag before the suite has run; a red CI is fixed forward with the next pre-release (starci release ci-status).
193
+ # "none" is refused: the suite does not vanish, it moves to CI. Set it only in the gitignored config.yaml copy: release: {suite: ci}
194
+ release: {suite: local}
@@ -6,6 +6,7 @@ import {parseYaml} from './yaml.mjs';
6
6
  import {isPlainObject as plain} from './plain-object.mjs';
7
7
  import {invalid,validateRoots,ROOT_KEYS} from './invalid-config.mjs';
8
8
  import {validateSonar} from './sonar-config.mjs';
9
+ import {validateRelease,RELEASE_KEYS} from './release-config.mjs';
9
10
  import {validateOrca,ORCA_KEYS} from './orca-config.mjs';
10
11
  import {validateResources,RESOURCE_KEYS} from './resources-config.mjs';
11
12
  import {ENV_NAME,secretEnv,connectorSecret} from './secrets.mjs';
@@ -242,7 +243,7 @@ export function uatSettings(config=loadConfig()){
242
243
  const GRANT=/^([a-z0-9][a-z0-9.-]*)=(\d+)@([a-z]+(?:\+[a-z]+)*)$/;
243
244
  /** One grant string `<pool>=<slots>@<role>+<role>` as {pool, slots, roles}, or null when it is not that shape. */
244
245
  /** The closed set of top-level config.yaml blocks: the validator accepts these and refuses every other key. */
245
- export const CONFIG_BLOCKS=Object.freeze(['language','model','effort','models','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','debugLoop','orca','roots','resources','sonar','launchTrust','retention']);
246
+ export const CONFIG_BLOCKS=Object.freeze(['language','model','effort','models','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','debugLoop','orca','roots','resources','sonar','release','launchTrust','retention']);
246
247
  export function parseAllocationGrant(text){
247
248
  const m=typeof text==='string'?GRANT.exec(text.trim()):null;
248
249
  return m?{pool:m[1],slots:Number(m[2]),roles:m[3].split('+')}:null;
@@ -323,7 +324,7 @@ const validateEarlyConfigBlocks=(config)=>{
323
324
  if(config?.launchTrust!==undefined){launchTrustSettings(config);} if(config?.retention!==undefined){workflowPurgeSettings(config);}
324
325
  if(config?.connectors!==undefined){validateConnectors(config.connectors);} if(config?.asks!==undefined){validateAsks(config.asks);}
325
326
  if(config?.uat!==undefined){validateUat(config.uat);} if(config?.debugLoop!==undefined){validateDebugLoop(config.debugLoop);}
326
- if(config?.orca!==undefined){validateOrca(config.orca);} if(config?.roots!==undefined){validateRoots(config.roots);} if(config?.resources!==undefined){validateResources(config.resources);} if(config?.sonar!==undefined){validateSonar(config.sonar);}
327
+ if(config?.orca!==undefined){validateOrca(config.orca);} if(config?.roots!==undefined){validateRoots(config.roots);} if(config?.resources!==undefined){validateResources(config.resources);} if(config?.sonar!==undefined){validateSonar(config.sonar);} if(config?.release!==undefined){validateRelease(config.release);}
327
328
  };
328
329
  const validateConfigBlocks=(config,knownProviders,runtimes,profile)=>{
329
330
  // specs (owner 2026-09-28): {harness?, unit?, e2e?} booleans - each family a boolean; absent = its default (SPEC_DEFAULTS: harness off, unit on, e2e off; specsSettings).
@@ -416,7 +417,7 @@ export const CONFIG_KEY_TREE=Object.freeze({
416
417
  supervisor:Object.freeze(['mode','pollIntervalMs','repos','stallMinutes','kernel','workers','landGate','frozenMinutes']),
417
418
  delegation:Object.freeze(['asks','until','excludes','note']),asks:Object.freeze(['autoAcceptRecommended','excludes']),uat:Object.freeze(['maxConcurrent']),
418
419
  specs:SPEC_FAMILIES,reconciler:Object.freeze(['enabled','profile','controllers']),debugLoop:Object.freeze(['interval','worktreeLimit']),
419
- orca:ORCA_KEYS,roots:ROOT_KEYS,resources:RESOURCE_KEYS,
420
+ orca:ORCA_KEYS,roots:ROOT_KEYS,resources:RESOURCE_KEYS,release:RELEASE_KEYS,
420
421
  });
421
422
  export const SPEC_DEFAULTS=Object.freeze({harness:false,unit:true,e2e:false});
422
423
  export function specsSettings(config){const specs=plain(config?.specs)?config.specs:{};return Object.fromEntries(SPEC_FAMILIES.map(key=>[key,typeof specs[key]==='boolean'?specs[key]:SPEC_DEFAULTS[key]]));}
@@ -0,0 +1,28 @@
1
+ import {isPlainObject as plain} from './plain-object.mjs';
2
+ import {invalid} from './invalid-config.mjs';
3
+
4
+ /** The keys of the release block. */
5
+ export const RELEASE_KEYS=Object.freeze(['suite']);
6
+ /** Where the full test suite of a release is judged: `local` (the release cut runs it, the shipped default) or `ci` (the GitHub workflow runs it after the push; the cut runs only the affected specs of the release and the rows CI cannot run). */
7
+ export const SUITE_MODES=Object.freeze(['local','ci']);
8
+ export const RELEASE_DEFAULTS=Object.freeze({suite:'local'});
9
+
10
+ /**
11
+ * config.yaml `release` - {suite?: local | ci}: who judges the full suite of a release (docs/releasing.md "Where the suite runs"). `local` is the shipped
12
+ * default: `starci release cut` runs the root suite and the Linux parity container before it pushes. `ci` is the owner's recorded choice (2026-10-09) that
13
+ * the full suite runs on GitHub only: the cut runs no root suite and no Linux container, and the CI workflow's verdict is read afterwards (`starci release ci-status`).
14
+ * `none` is refused: the suite does not vanish, it moves to CI, and the value says so.
15
+ */
16
+ export function validateRelease(release){
17
+ if(release===null)return;
18
+ const bad=invalid('release');
19
+ if(!plain(release))bad(` must be {suite?: ${SUITE_MODES.join(' | ')}} or null.`);
20
+ for(const key of Object.keys(release))if(!RELEASE_KEYS.includes(key))bad(` has unknown key ${key} (allowed: ${RELEASE_KEYS.join(', ')}).`);
21
+ const value=release.suite;
22
+ if(value===undefined||value===null)return;
23
+ if(value==='none')bad('.suite: "none" is not a mode: write "ci" (no local full suite, CI judges) or "local".');
24
+ if(!SUITE_MODES.includes(value))bad(`.suite must be ${SUITE_MODES.join(' or ')}, or null.`);
25
+ }
26
+
27
+ /** The suite mode a validated owner config names; an absent block or key is the shipped default. */
28
+ export const releaseSuiteMode=config=>config?.release?.suite??RELEASE_DEFAULTS.suite;
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind and the IconButton disclosure props. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.1.0
32
+ version: 3.2.0
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.1.0
39
+ version: 8.2.0
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.1.0
79
+ version: 1.2.0
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.1.0
86
+ version: 4.2.0
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -2090,3 +2090,66 @@ rules:
2090
2090
  - {kind: eslint-fe, id: no-unused-import}
2091
2091
  - {kind: eslint-fe, id: prefer-export-from}
2092
2092
  - {kind: eslint-fe, id: no-unused-prop-types}
2093
+ - id: R236
2094
+ code: "RT_GATE_LOOSENING"
2095
+ law: "A change that loosens a gate or check is owner-class: a check removed from a gate file's list, a floor lowered or a ceiling raised there, an allowlist entry added, a spec deleted or weakened or skipped together with product code (modules/kernel/gate-loosening.yaml). It lands, and passes the check, only when the tree it is judged against already holds the owner's approval of exactly that loosening, an owner-rulings entry gate-loosening-<fingerprint>."
2096
+ scope: runtime
2097
+ kinds: [check]
2098
+ gates: [land, runtime]
2099
+ failureCodes: ["RT_GATE_LOOSENING"]
2100
+ enforcers:
2101
+ - {kind: runtime, id: gate-loosening, at: scripts/checks/check-gate-loosening.mjs}
2102
+ - id: R237
2103
+ code: "RT_REVISION_SCOPE"
2104
+ law: "What a change of the runtime tree asks of each role is declared once, in modules/kernel/revision-scope.yaml: every tracked path matches a row, the engine-loaded files are derived from the import graph of the engine's entries and never hand-listed, the files each seat's launch prompt is built from are the files its generator reads, and a wording-only declaration holds only for an edit that keeps the file's structure."
2105
+ scope: runtime
2106
+ kinds: [check]
2107
+ gates: [land, runtime]
2108
+ failureCodes: ["RT_REVISION_SCOPE"]
2109
+ enforcers:
2110
+ - {kind: runtime, id: revision-scope, at: scripts/checks/check-revision-scope.mjs}
2111
+ - id: R238
2112
+ code: "RT_EVENT_SPILL_BYPASS"
2113
+ law: "A ledger event payload that grows with its input (a file list, a manifest, a digest, a report, a critique, a menu) goes to the blob store through the one path of appendEvent: engine/db/event-compact.mjs eventPayloadRecord keeps a bounded inline view of at most 16 KiB and the whole payload behind events.payload_sha, and readers resolve it through engine/db/event-payload.mjs. No runtime source file other than engine/db/ledger.mjs and engine/db/event-compact.mjs passes a payloadSha to a write, catches the payload-too-large refusal to spill by hand, or inserts into the events table directly. Specs, tests, node_modules, the generated runtime copies and packages are out of scope."
2114
+ scope: runtime
2115
+ kinds: [check]
2116
+ gates: [land, runtime]
2117
+ failureCodes: ["RT_EVENT_SPILL_BYPASS"]
2118
+ enforcers:
2119
+ - {kind: runtime, id: event-spill, at: scripts/checks/check-event-spill.mjs}
2120
+ - id: R239
2121
+ code: "RT_STORAGE_UNDECLARED"
2122
+ law: "Content lives as a file in the content-addressed blob store under the runtime state dir (<runtime root>/.runtime/artifacts, engine/db/blob.mjs) and a database row holds only the reference (the sha) plus the small scalars it is queried by (owner ruling 2026-10-09). Always content, whatever its size: a report, a critique or verdict, a prompt or dispatch contract, a read manifest, a list of files, a log or command output, a menu or digest snapshot, an image or render, a diff. What may stay inline: ids, states, timestamps, counts, short codes and one-line reasons up to 256 bytes; any other free text up to 1024 bytes; a payload of any size is class spill (appendEvent and supEvent keep the whole payload behind the sha and a small inline form). Every text column of the product ledger and of machine.sqlite is declared in modules/schemas/storage-convention.yaml with its class (scalar, bounded, reference, spill, migrate), so a new table or column cannot appear without one; a column that holds content inline today is class migrate with its measured size, and the migration to a reference is a proposed runtime step, idempotent and journalled, never a hand rewrite of a live store."
2123
+ scope: runtime
2124
+ kinds: [check]
2125
+ gates: [land, runtime]
2126
+ failureCodes: ["RT_STORAGE_UNDECLARED"]
2127
+ enforcers:
2128
+ - {kind: runtime, id: storage-convention, at: scripts/checks/check-storage-convention.mjs}
2129
+ - id: R240
2130
+ code: "RT_ROOT_ADHOC"
2131
+ law: "Which directory a component reads or writes is decided in one module, scripts/lib/roots.mjs: the runtime tree, its state directory, the temp root, the invocation directory, the Work directory name, the order in which two trees that can hold the same record are tried (the tree the work happens in first, then the product's main checkout: workDirHolding, workRecordPath) and the order a tool is looked up (the tree the work happens in, then the project, the runtime's own install last: toolSearchDirs). A runtime source file does not resolve a root by itself: it neither spells process.cwd() nor the Work directory name as a literal outside that module more often than the tree the check landed on held it: a file keeps the count it had and only loses it as it is converted, and a new file starts at none. Four times in two days a component read or wrote the wrong tree because it resolved the directory itself."
2132
+ scope: runtime
2133
+ kinds: [check]
2134
+ gates: [land, runtime]
2135
+ failureCodes: ["RT_ROOT_ADHOC"]
2136
+ enforcers:
2137
+ - {kind: runtime, id: root-adhoc, at: scripts/checks/check-root-adhoc.mjs}
2138
+ - id: R241
2139
+ code: "RT_HELD_UNSURFACED"
2140
+ law: "A failure code the catalogue gives to the Supervisor or the owner as a runtime fault is a state that role must be able to learn of; a stop that nobody is told of stands for hours until someone reads source. Each such entry of modules/kernel/failure-codes.yaml names the house mechanism that reaches its owner (surfacedBy: decision-item, sla-clock, incident, digest, notifier, caller or open) with surfacedAs (the item kind, clock code, incident kind, digest problem line or urgent class) and surfacedAt (the file that does it), and the check proves the mechanism exists and is wired in that file: the kind is a Decision Item kind and the file opens items naming it, the code is an SLA code with an owner and the file sets its clock, the problem line is a departure of the operating standard, a caller is a verb the owning role ran itself and never a reconciler file (a loop is no owner). An entry nothing reaches is surfacedBy open and names an open edge-case registry entry with the exact reason; it is never exempted silently."
2141
+ scope: runtime
2142
+ kinds: [check]
2143
+ gates: [land, runtime]
2144
+ failureCodes: ["RT_HELD_UNSURFACED"]
2145
+ enforcers:
2146
+ - {kind: runtime, id: held-surfacing, at: scripts/checks/check-held-surfacing.mjs}
2147
+ - id: R242
2148
+ code: "RT_OP_JUDGE_UNDECLARED"
2149
+ law: "Every op declares who judges its product (principle P2: the maker never judges its own work). The contract of each op (modules/ops/ops/<op>.yaml) carries the required field `judge`, a list of at least one entry, each with a one-line why: machine (independent measures the runtime owns or re-runs at settle, named from modules/kernel/op-judges.yaml, and the contract proofs they rely on; a test file the same attempt wrote is never one by itself), critic (the independent Critic and its rubric, declared exactly for the op kinds that modules/kernel/critic.yaml coverage marks covered), owner (only where modules/kernel/op-judges.yaml ownerGates holds an owner gate) and next-leg (a registered reviewing leg whose contract is to review this product). Nobody cannot be declared. The registry and the contracts are checked against the runtime's own tables (op-gate and sonar-gate enforcedOps, opProofs, proof media, the Critic coverage), so the declarations and what the runtime does cannot drift apart. Surfaced in the full status text of a leg and in the op prompt."
2150
+ scope: runtime
2151
+ kinds: [check]
2152
+ gates: [land, runtime]
2153
+ failureCodes: ["RT_OP_JUDGE_UNDECLARED"]
2154
+ enforcers:
2155
+ - {kind: runtime, id: op-judge, at: scripts/checks/check-op-judge.mjs}
@@ -154,6 +154,58 @@ removed:
154
154
  use: 'skills/starci/references/host-startup.md'
155
155
  since: 1.0.0-alpha.6
156
156
  match: '(?<![\w-])host-maintenance\b'
157
+ - id: config-drawloop-critic
158
+ kind: config-key
159
+ name: allocation.drawLoop.critic
160
+ use: 'the Critic is picked from its tier (modules/models/tiers.yaml seats.critic, modules/kernel/critic.yaml) with provider independence as a hard filter; its wall bound is allocation.drawLoop.criticTimeoutMs'
161
+ since: 1.0.0-alpha.9
162
+ match: 'allocation\.drawLoop\.critic(?![A-Za-z])'
163
+ - id: config-drawloop-criticwhendrawer
164
+ kind: config-key
165
+ name: allocation.drawLoop.criticWhenDrawer
166
+ use: 'the Critic tier holds a member of every provider and the picker removes the provider of the maker; there is no per-drawer table'
167
+ since: 1.0.0-alpha.9
168
+ match: 'criticWhenDrawer'
169
+ - id: registry-critic-coverage-define-architecture
170
+ kind: vocabulary
171
+ name: critic-coverage-define-architecture
172
+ use: 'the edge-case registry entry is critic-coverage-decision-legs'
173
+ since: 1.0.0-alpha.9
174
+ - id: supervisor-grammar-release
175
+ kind: vocabulary
176
+ name: grammarRelease
177
+ use: 'the owner publishes @starci/grammar through starci release publish; the Supervisor changes no runtime code'
178
+ since: 1.0.0-alpha.9
179
+ - id: supervise-yaml-worker-land-blocks
180
+ kind: vocabulary
181
+ name: supervise.yaml workers
182
+ use: 'the Supervisor spawns no fix workers and lands nothing: it answers its menu (starci supervisor status) and records runtime defects for Debug'
183
+ since: 1.0.0-alpha.9
184
+ match: 'supervise\.yaml\s+(?:workers|landGate)\b|supervise\.yaml\s+kernelSeat/workers'
185
+ - id: supervisor-fixes-through-lanes
186
+ kind: vocabulary
187
+ name: the Supervisor fixes through lanes
188
+ use: 'the Supervisor records a runtime defect (starci supervisor actions record --item runtime-defect:<cause>) and Debug changes .claude'
189
+ since: 1.0.0-alpha.9
190
+ match: 'Supervisor[^\n.]{0,60}\bfix(?:es)?\s+through\s+(?:its\s+own\s+)?(?:staged\s+)?lanes?\b'
191
+ - id: kernel-ranked-actions
192
+ kind: vocabulary
193
+ name: ranked actions
194
+ use: "the Kernel's menu: the Decide section of starci kernel status, answered with starci kernel decide"
195
+ since: 1.0.0-alpha.9
196
+ match: '[Ww]ork the ranked actions|[Ff]irst rca\.actions entry'
197
+ - id: kernel-run-next-actions
198
+ kind: vocabulary
199
+ name: run nextActions
200
+ use: "the Kernel answers the items of its menu; the runtime performs the mechanical next actions"
201
+ since: 1.0.0-alpha.9
202
+ match: 'run nextActions|run each\s+in order and never choose'
203
+ - id: event-op-reported
204
+ kind: vocabulary
205
+ name: op-reported
206
+ use: 'the event a filed report records is report-filed; no event named op-reported was ever emitted'
207
+ since: 1.0.0-alpha.9
208
+ match: '(?<![\w-])op-reported(?![\w-])'
157
209
  - id: file-sonar-secrets-keys
158
210
  kind: file
159
211
  name: KEYS.md
@@ -166,3 +218,51 @@ removed:
166
218
  use: 'the examples are analysed on SonarCloud with the SONAR_TOKEN of the runtime secret.env (runtimeSecrets in their services.sonar declaration)'
167
219
  since: 1.0.0-alpha.7
168
220
  match: 'sonarqube-(?:lite-app|shape-slot|starci-ecommerce-app)-token'
221
+ - id: vocab-menu-kinds-moved-to-controllers
222
+ kind: vocabulary
223
+ name: the menu kinds of the origins the controllers now move
224
+ use: 'the Job controller enqueues a red node, a stale proof or attempt, the asset leg and the credential ask itself (scripts/kernel/next-moves.mjs); the Kernel menu keeps leg-ready, draw-redraw, seam-duty and move-unbuilt'
225
+ since: 1.0.0-alpha.9
226
+ match: '(?<![\w-])(?:node-rework|stale-redispatch|enqueue-asset-leg|enqueue-credential-ask|rework-node)(?![\w-])'
227
+ - id: vocab-sonar-stack-sealed-members
228
+ kind: vocabulary
229
+ name: ext/sonar secrets custody
230
+ use: 'the stack secrets are the secret.env variables SONARQUBE_DB_PASSWORD, SONARQUBE_ADMIN_PASSWORD, SONARQUBE_ADMIN_TOKEN and CLOUDFLARE_TUNNEL_TOKEN, passed by starci gate sonar up; ext/sonar tracks no sealed member'
231
+ since: 1.0.0-alpha.9
232
+ match: 'ext/sonar/secrets/(?:sonarqube-(?:db|admin|analysis)-(?:password|token)|cloudflare-starci-local-services-tunnel-token)'
233
+ - id: vocab-ecommerce-sealed-demo-secrets
234
+ kind: vocabulary
235
+ name: ecommerce sealed demo secrets
236
+ use: 'the ecommerce example carries demo-only defaults in its compose files and declares no sealed secret (secrets: [])'
237
+ since: 1.0.0-alpha.9
238
+ match: '(?<![\w-])(?:keycloak|minio)-env(?:\.enc)?(?![\w-])'
239
+ - id: vocab-handover-step-mechanical-pending
240
+ kind: vocabulary
241
+ name: the handover-step menu mode mechanical-pending and its choice rerun-handover-review
242
+ use: 'the Job controller enqueues handover.review itself when the handover is due or its ask was answered approve or question (next action handover-review, scripts/kernel/handover-move.mjs); the handover-step item holds only the typed routing of a reported defect'
243
+ since: 1.0.0-alpha.9
244
+ match: '(?<![\w-])(?:mechanical-pending|rerun-handover-review)(?![\w-])'
245
+ - id: vocab-card-delivery-bound
246
+ kind: vocabulary
247
+ name: the agent card delivery keys maxInlineChars, fileName and fileDirectory
248
+ use: 'one bound, one directory and one cleanup serve every card: allocation.promptFile in modules/models/runtimes.yaml (scripts/agent/prompt-file.mjs); a card keeps delivery.mode and delivery.prompt'
249
+ since: 1.0.0-alpha.9
250
+ match: '(?<![\w-])(?:maxInlineChars|fileDirectory)(?![\w-])'
251
+ - id: vocab-backoff-factor-keys
252
+ kind: vocabulary
253
+ name: the growth factor of allocation.dispatchRefusal and of the backoff of modules/reconciler/host.yaml
254
+ use: 'every backoff is the one retry budget (scripts/lib/retry-budget.mjs), which doubles its interval up to a cap: declare baseMs/capMs or minMs/maxMs only'
255
+ since: 1.0.0-alpha.9
256
+ match: '(?<![\w-])(?:dispatchRefusal|backoff):\s*\{[^}\n]*\bfactor\b'
257
+ - id: vocab-wake-terminal-bound
258
+ kind: vocabulary
259
+ name: the independent wake character bound
260
+ use: 'allocation.promptFile.maxChars is the terminal bound owned by scripts/agent/prompt-file.mjs'
261
+ since: 1.0.0-alpha.9
262
+ match: '(?<![\w-])(?:allocation\.)?wake\.maxChars|\bwake:\s*\{\s*maxChars\b'
263
+ - id: vocab-draft-probe-mutations
264
+ kind: vocabulary
265
+ name: draft probe mutation fields
266
+ use: 'probeDraft reads only and returns verdict, draft and sends; a foreign draft receives no keys'
267
+ since: 1.0.0-alpha.9
268
+ match: '\b(?:draftProbe|probeDraft)\.(?:restore|restored|removed|after)\b'
@@ -93,9 +93,20 @@ allocation:
93
93
  # the decision doorbell; still the same turn turnInterruptGraceMs later, the seat is replaced (KERNEL_TURN_OVERDUE,
94
94
  # scripts/reconciler/controllers/host.mjs turnStep). A busy turn defers every doorbell and notice (kernel-busy).
95
95
  liveness: {activeUnclassifiedMs: 120000, activeStaleMs: 600000, quietMs: 1200000, launchGraceMs: 90000,
96
- kernelTurnBudgetMs: 1200000, supervisorTurnBudgetMs: 1800000, turnInterruptGraceMs: 300000}
96
+ kernelTurnBudgetMs: 1200000, supervisorTurnBudgetMs: 1800000, turnInterruptGraceMs: 300000,
97
+ # The watchdog's replacement of a Kernel seat runs start-workflow (worktree check, install, Orca launch, attestation): bounded here, under the Host controller's
98
+ # seats.kernel.timeoutMs that kills the whole watchdog pass. A start that outlives it is killed and recorded kernel-start-failed (start-timeout), never lost silently.
99
+ kernelStartTimeoutMs: 600000}
97
100
  # one local git read over a job's owned paths (scripts/kernel/owned-path-effects.mjs ownedPathEffects, job-artifacts.mjs).
98
101
  settleGit: {commandMs: 15000}
102
+ # prompt-file.mjs owns terminal delivery, wake bounds, file placement and cleanup.
103
+ promptFile: {maxChars: 2000, ttlMs: 86400000}
104
+ # opVerbs: the bound of the "your verbs" block of an [Op] prompt (scripts/kernel/op-prompt-verbs.mjs): the starci calls the op's contract names,
105
+ # each with the flags the contract uses and the required ones, one line per verb. A worker that has the exact call never runs --help
106
+ # (a measured op spent 15 of its 135 turns on it). `standing` verbs are printed in full by the prompt's own reporting and logging blocks;
107
+ # `omitGroups` are the verbs of the Kernel and above, which an op's contract names only to say who runs them.
108
+ opVerbs: {maxVerbs: 20, maxChars: 3000, lineChars: 190, standing: ["kernel report", "kernel log", "kernel op-contract"],
109
+ omitGroups: [kernel, supervisor, workflow, reconciler]}
99
110
  # Typed logs (scripts/kernel/typed-logs.mjs, the ledger's logs table): perJobCap rows a job's own writes
100
111
  # may store before one log.truncated row closes them (derived event rows are never capped); dataMaxBytes bounds
101
112
  # one row's data after redaction. diff: the caps of a job patch's pre-structured <patch>.json
@@ -187,6 +198,13 @@ allocation:
187
198
  # replacement takes over the loop's lock within handoverMs or is stopped. The reconciler engine checks every checkMs; an engine whose
188
199
  # revision still differs from the live one that long past minIntervalMs + checkMs + handoverMs is a drift row (scripts/reconciler/drift.mjs).
189
200
  selfReload: {minIntervalMs: 300000, handoverMs: 30000, checkMs: 60000, driftGraceMs: 120000}
201
+ # deploy (scripts/reconciler/runtime-deploy.mjs): a deploy waits up to waitMs, polling every pollMs, for the settles, Critic runs and prepared
202
+ # decisions in flight to finish; after the engine restart it waits up to verifyMs for the new leader's fresh heartbeat on the new revision. The affected-spec child
203
+ # may run its declared budget (modules/supervisor/affected-tests.yaml) plus affectedMarginMs before the deploy stops it.
204
+ deploy: {waitMs: 1800000, pollMs: 5000, verifyMs: 120000, affectedMarginMs: 300000}
205
+ # dispatchRefusal (scripts/kernel/dispatch-refusal-memo.mjs): a ready job whose dispatch is refused for the same cause is tried again after baseMs, then twice as long each time (the one retry budget), up to capMs; a change of
206
+ # the cause's fingerprint (runtime revision, a ruling, a resource the refusal names) tries it at once.
207
+ dispatchRefusal: {baseMs: 60000, capMs: 900000}
190
208
  # footprint (scripts/guards/footprint-scan.mjs): one host-wide scan at most every everyMs; a crashed
191
209
  # tick's claim lock is cleared after lockStaleMs.
192
210
  footprint: {everyMs: 600000, lockStaleMs: 60000}
@@ -194,12 +212,15 @@ allocation:
194
212
  jobGuard: {ttlMs: 604800000}
195
213
  # landGate (scripts/supervisor/land.mjs): a land waits waitMs for its turn at the gate; its spec run may take
196
214
  # specsBaseMs plus perSpecMs for every spec it runs, specConcurrency spec files at a time.
197
- landGate: {waitMs: 1800000, specsBaseMs: 600000, perSpecMs: 60000, specConcurrency: 6}
215
+ landGate: {waitMs: 1800000, specsBaseMs: 600000, perSpecMs: 60000, specConcurrency: 6, cliVerbs: 12}
198
216
  # workerClose (scripts/machine/worker-close.mjs; owner rule: a finished worker is closed COMPLETELY): after worker-release and the terminal
199
217
  # close the runtime waits verifyMs for every process of that terminal's shell tree to end (polled every pollMs); a survivor proven to belong
200
218
  # to the tree is stopped and given stopVerifyMs to end before the finding is raised.
201
219
  # A finished [Worker] job whose terminal has no recorded closure is closed again by the Job controller's worker sweep (scripts/supervisor/supervisor-watchdog.mjs):
202
220
  # every leftoverRetryMs (doubling to leftoverRetryMaxMs) for at most leftoverRetryAttempts attempts, each failure recorded with its reason on the job.
221
+ # uatSlot (scripts/uat/slot-collect.mjs, slot-lessee.mjs): a held UAT run checks its lessee (its attempt, the process that launched it) every watchMs and ends with it;
222
+ # the Host pass ends a slot whose lessee is gone, and a slot that recorded no lessee at all once it has been held unknownHoldMs (3 hours: a UAT run is minutes).
223
+ uatSlot: {watchMs: 5000, unknownHoldMs: 10800000}
203
224
  workerClose: {verifyMs: 10000, pollMs: 1000, stopVerifyMs: 5000, leftoverRetryMs: 60000, leftoverRetryMaxMs: 900000, leftoverRetryAttempts: 12}
204
225
  # providerReservation (scripts/machine/provider-reservation-reap.mjs): an admission whose receipt stayed `reserved` this long never reached
205
226
  # worker-start (the consume moves it to `launching` first), so its launcher died and the slot is released. A receipt with no terminal handle and no
@@ -238,20 +259,26 @@ allocation:
238
259
  quitAgent: {waitMs: 6000}
239
260
  # drawRender (scripts/work/draw-render.mjs): a capture waits settleMs after document.fonts.ready.
240
261
  drawRender: {settleMs: 500}
262
+ # layoutRender (scripts/work/layout-render.mjs): the runtime serves the product app for one capture; readyMs is how long the dev server may take to answer
263
+ # its first request (a cold Next compile), navigateMs the page load, settleMs the wait after fonts are ready, stopMs the grace before a still-running server is killed.
264
+ # publicOriginKeys are the scaffold's public-origin variables (its README: the product app's own address and the product app the landing hands over to); each gets the render's origin.
265
+ layoutRender: {readyMs: 180000, navigateMs: 90000, settleMs: 500, stopMs: 5000, publicOriginKeys: [NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_APP_URL]}
241
266
  # drawLoop (scripts/work/draw-loop.mjs, scripts/work/draw/draw-taste.mjs; owner rulings 2026-09-27): interface.draw
242
267
  # draws -> shoots -> evaluates -> fixes in rounds. A loop stops when every machine metric passes and the
243
268
  # independent critic's beauty is at least beautyMin, after maxRounds, or after stallRounds rounds in a row that
244
269
  # neither cut the failures nor raised the beauty; the best round is kept. Taste thresholds: accentBudget is the
245
270
  # largest share of the render the accent colour may fill (the brand art band excluded), bandsPerCardMax the most
246
271
  # hairline bands one card splits into, badgesPerEntityMax the most badges one entity carries. critic is the fresh,
247
- # context-free Orca worker that scores beauty and hierarchy against the product's brand.direction rubric: started
272
+ # context-free Orca worker that scores beauty and hierarchy against the product's brand.direction rubric: picked through
273
+ # the tier picker from the Critic tier (modules/models/tiers.yaml seats.critic, modules/kernel/critic.yaml) with the
274
+ # independence rule as a hard filter, then started
248
275
  # through orchestration worker-start --agent <provider> --model --effort (scripts/agent/lib.mjs startAgent) on a
249
276
  # runtime worktree detached at the empty tree (draw-critic.mjs criticWorkspace; Orca refuses a bare temp dir) holding only the PNGs, the HTML and the rubric; it writes verdict.json and reports worker_done; a
250
- # critic with no worker_done within timeoutMs is stopped and released (outcome timeout, no beauty). The critic is a DIFFERENT model from the drawer: a Codex drawer (the high tier's second member)
251
- # is judged by criticWhenDrawer.codex; for any other drawer the critic is Codex. The images a drawer asks of the imagegen call
252
- # (starci work imagegen) do not make the call's model the drawer. When the drawer (the op's provider, bound to its
253
- # terminal - scripts/guards/op-context.mjs - or draw-loop.mjs round --drawer) is the critic's provider the round is
254
- # judged by criticWhenDrawer.<drawer> instead, and a round with no independent critic has no beauty. directionPrerequisite: interface.draw needs an ACCEPTED brand.direction
277
+ # critic with no worker_done within criticTimeoutMs is stopped and released (outcome timeout, no beauty). The critic is a member of ANOTHER PROVIDER than
278
+ # the drawer: the members of its tier of that provider are removed before the pick, and a tier with no other provider's member refuses the critique with
279
+ # CRITIC_NO_INDEPENDENT_MEMBER (the drawer never judges itself). The images a drawer asks of the imagegen call
280
+ # (starci work imagegen) do not make the call's model the drawer. The drawer is the op's provider, bound to its
281
+ # terminal (scripts/guards/op-context.mjs) or named by draw-loop.mjs round --drawer; a round with no independent critic has no beauty. directionPrerequisite: interface.draw needs an ACCEPTED brand.direction
255
282
  # archetype for its ui record's ui.archetype - brand.mjs checkDirection evidence.ready, the owner's receipt
256
283
  # (scripts/kernel/prerequisites.mjs direction-unaccepted; the Kernel enqueues brand.decide --param
257
284
  # directionArchetype=<archetype> first). false switches the gate off.
@@ -262,9 +289,7 @@ allocation:
262
289
  accentBudget: 0.10
263
290
  bandsPerCardMax: 3
264
291
  badgesPerEntityMax: 2
265
- critic: {provider: codex, model: gpt-6.1-sol, effort: high, timeoutMs: 900000}
266
- criticWhenDrawer:
267
- codex: {provider: claude, model: claude-opus-5-5, effort: high, timeoutMs: 900000}
292
+ criticTimeoutMs: 900000
268
293
  directionPrerequisite: true
269
294
  # How long the workflow that claimed foundation `shell` (the layout chain an interface.draw draws) may hold it before a
270
295
  # waiting draw opens a Supervisor Decision Item (scripts/kernel/shell-foundation.mjs; owner ruling 2026-09-29 draw-from-todo).
@@ -303,8 +328,8 @@ allocation:
303
328
  # reportWindowMs.
304
329
  workerJobs: {leaseTtlMs: 604800000, reportWindowMs: 604800000}
305
330
  # draft (scripts/kernel/clear-draft.mjs): the beat between one Ctrl+U and the re-read that judges
306
- # the input box's answer - a TUI repaints a beat later. clearDraft and probeDraft default to it and
307
- # the wake-delivery draft probe caps its own interval at it.
331
+ # the input box's answer - a TUI repaints a beat later. clearDraft defaults to it and
332
+ # wake-delivery caps its runtime-owned draft clear interval at it.
308
333
  draft: {intervalMs: 300}
309
334
  # housekeeping (scripts/housekeeping/housekeeping.mjs): every retention window and root the host
310
335
  # cleanup reads - no literal may live in the script. tmp: top-level %TEMP% entries whose name starts
@@ -28,6 +28,7 @@ seats:
28
28
  kernelManager: frontier
29
29
  kernel: high
30
30
  worker: high
31
+ critic: frontier
31
32
  # The kernel's own model calls (selection.yaml kernelFunctionKinds) take the tier of their seat.
32
33
  kindSeats:
33
34
  model.assessGoal: planner
@@ -14,7 +14,7 @@ import { buildHfsGraph } from './hfs-graph.mjs';
14
14
  import { checkTiers, TIER_RULE_IDS } from './tiers.mjs';
15
15
  import { checkReachability, REACHABILITY_RULE_IDS } from './reachability.mjs';
16
16
  import { checkDeadExports, DEAD_EXPORT_RULE_IDS } from './dead-exports.mjs';
17
- import { checkRequiredFiles, REQUIRED_FILE_RULE_IDS } from './required-files.mjs';
17
+ import { checkRequiredFiles, REQUIRED_FILE_RULE_IDS, withTreeCache } from './required-files.mjs';
18
18
  import { checkClones, CLONE_RULE_IDS } from './clones.mjs';
19
19
  import { checkSymbols, SYMBOL_RULE_IDS } from './symbols.mjs';
20
20
  import { checkConnectionMap, CONNECTION_RULE_IDS } from './connection-map.mjs';
@@ -321,7 +321,11 @@ function scopeFindings({ config, context, violations, paths, surface }) {
321
321
  * Check a target repository. injectedTypeScript exists only for hermetic rule fixtures. `fast` leaves out the checks
322
322
  * that read the whole repository to answer (clones, dead exports, repository-wide symbols); the pre-push check of the changed owners uses it.
323
323
  */
324
- export function checkArchitecture({ repositoryRoot, injectedTypeScript, paths = [], base, fast = false, hfs: openedHfs, surface = 'all' } = {}) {
324
+ export function checkArchitecture(options = {}) {
325
+ return withTreeCache(() => checkArchitectureOnce(options));
326
+ }
327
+
328
+ function checkArchitectureOnce({ repositoryRoot, injectedTypeScript, paths = [], base, fast = false, hfs: openedHfs, surface = 'all' }) {
325
329
  if (!['all', 'lint', 'check'].includes(surface)) throw new Error(`unknown surface ${surface}`);
326
330
  let config;
327
331
  try {
@@ -49,8 +49,20 @@ function diskFiles(root, relative = '') {
49
49
  return out;
50
50
  }
51
51
 
52
- /** The repository tree as file and directory sets over posix relatives. */
53
- export function treeOf(root) {
52
+ let runTrees = null;
53
+
54
+ /** Runs `run` with one tree read per root: every checker of one machine run shares the repository listing instead of spawning git again. */
55
+ export function withTreeCache(run) {
56
+ const outer = runTrees;
57
+ runTrees = outer ?? new Map();
58
+ try {
59
+ return run();
60
+ } finally {
61
+ runTrees = outer;
62
+ }
63
+ }
64
+
65
+ function readTree(root) {
54
66
  const list = gitFiles(root) ?? diskFiles(root);
55
67
  const files = new Set(list);
56
68
  const directories = new Set();
@@ -61,6 +73,13 @@ export function treeOf(root) {
61
73
  return { files, directories };
62
74
  }
63
75
 
76
+ /** The repository tree as file and directory sets over posix relatives. */
77
+ export function treeOf(root) {
78
+ if (!runTrees) return readTree(root);
79
+ if (!runTrees.has(root)) runTrees.set(root, readTree(root));
80
+ return runTrees.get(root);
81
+ }
82
+
64
83
  /**
65
84
  * The prelude every tree-walking architecture checker shares: the input's config/graph/context, the compiler,
66
85
  * the slot resolver and the tracked repository tree.
@@ -0,0 +1,15 @@
1
+ import fs from 'node:fs';
2
+ import { parseYaml } from '../../engine/yaml.mjs';
3
+
4
+ const parsed = new Map();
5
+
6
+ /** The parsed YAML catalog at `file`; the megabyte of text is parsed once per file state (path, size, mtime), not once per check. */
7
+ export function readCatalogOnce(file) {
8
+ const { size, mtimeMs } = fs.statSync(file);
9
+ const key = `${file}|${size}|${mtimeMs}`;
10
+ if (!parsed.has(key)) {
11
+ parsed.clear();
12
+ parsed.set(key, parseYaml(fs.readFileSync(file, 'utf8')));
13
+ }
14
+ return parsed.get(key);
15
+ }
@@ -57,6 +57,7 @@ import path from 'node:path';
57
57
  import { skillRoot } from '../../engine/runtime-root.mjs';
58
58
  import { ARCHITECTURE_RULE_IDS, checkArchitecture } from './architecture/index.mjs';
59
59
  import { parseYaml } from '../../engine/yaml.mjs';
60
+ import { readCatalogOnce } from './catalog-once.mjs';
60
61
  import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadRuleCatalog, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './slots.mjs';
61
62
  import { RUNTIME_KIND } from './manifest-shape.mjs';
62
63
  import { lsFiles } from '../api/git/ls-files.mjs';
@@ -114,7 +115,7 @@ const refuse = (code, message, details = {}) => { throw new HfsSlotsError(code,
114
115
 
115
116
  /** {code: {title, title_vi, meaning_vi, nextStep_vi}} for the codes asked for, read from the failure-code catalog under `root`. */
116
117
  export function readWhy(root = skillRoot, codes = CHECK_CODES) {
117
- const catalog = parseYaml(fs.readFileSync(path.join(root, FAILURE_CODES_FILE), 'utf8'));
118
+ const catalog = readCatalogOnce(path.join(root, FAILURE_CODES_FILE));
118
119
  const why = {};
119
120
  for (const code of codes) {
120
121
  const entry = catalog?.[code];
@@ -14,14 +14,28 @@ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.res
14
14
  const GIT_SUFFIX_SOURCE = String.raw`\.git$`;
15
15
  const GIT_SUFFIX = new RegExp(GIT_SUFFIX_SOURCE, 'iu');
16
16
 
17
+ // The identity of a folder that has its own .git entry holds while that entry is the same file system object (same inode, same modification time), so a
18
+ // process asks Git once per such folder state instead of at every check. A folder with no .git entry of its own is asked every time.
19
+ const known = new Map();
20
+ const stateOf = (root) => { try { const stat = fs.statSync(path.join(root, '.git')); return `${real(root)}|${stat.ino}|${stat.mtimeMs}`; } catch { return null; } };
21
+
17
22
  /** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
18
23
  function identityOf(root) {
24
+ const state = stateOf(root);
25
+ if (state === null) return askGit(root);
26
+ if (!known.has(state)) known.set(state, askGit(root));
27
+ return known.get(state);
28
+ }
29
+
30
+ function askGit(root) {
19
31
  let inWorkTree = false, repositoryRoot = false, home = null;
20
32
  try {
21
33
  // Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
22
- repositoryRoot = real(git(revParseQuery, root, ['--show-toplevel'])) === real(root);
34
+ // One git call answers both questions: the top level on the first line, the common git dir on the second.
35
+ const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
36
+ repositoryRoot = real(top) === real(root);
23
37
  inWorkTree = true;
24
- const common = path.resolve(root, git(revParseQuery, root, ['--git-common-dir']));
38
+ const common = path.resolve(root, commonDir);
25
39
  if (repositoryRoot && path.basename(common) === '.git') home = path.dirname(common);
26
40
  } catch { /* Not a Git work tree; the caller falls back to the folder and package name. */ }
27
41
  return { inWorkTree, repositoryRoot, home };
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { skillRoot } from '../../engine/runtime-root.mjs';
5
- import { parseYaml } from '../../engine/yaml.mjs';
5
+ import { parseYamlCached } from '../lib/yaml-cached.mjs';
6
6
  import { isPlainObject } from '../../engine/plain-object.mjs';
7
7
  import { SEMVER } from './manifest-shape.mjs';
8
8
  import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
@@ -153,7 +153,7 @@ const deepFreeze = (value) => { if (value && typeof value === 'object') { Object
153
153
  */
154
154
  export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_RULES_FILE), text, manifest } = {}) {
155
155
  let doc;
156
- try { doc = parseYaml(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
156
+ try { doc = parseYamlCached(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
157
157
  const problems = ruleCatalogProblems(doc);
158
158
  if (problems.length) { fail('HFS_RULES_INVALID', `the rule catalog breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems }); }
159
159
  const [major, minor, patch] = doc.version.split('.').map(Number);
@@ -21,7 +21,7 @@
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { skillRoot } from '../../engine/runtime-root.mjs';
24
- import { parseYaml } from '../../engine/yaml.mjs';
24
+ import { parseYamlCached } from '../lib/yaml-cached.mjs';
25
25
  import { braceVariants } from '../lib/glob.mjs';
26
26
  import { APP_KIND, RUNTIME_KIND, manifestKind } from './manifest-shape.mjs';
27
27
  import { APP_SCOPE, manifestShapeProblems, PROFILES } from './slot-manifest-shape.mjs';
@@ -73,7 +73,7 @@ export function appRelativeMessages(side, sideRoot) {
73
73
  */
74
74
  export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_MANIFEST_FILE), text } = {}) {
75
75
  let doc;
76
- try { doc = parseYaml(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_MANIFEST_INVALID', `the slot manifest cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
76
+ try { doc = parseYamlCached(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_MANIFEST_INVALID', `the slot manifest cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
77
77
  const problems = manifestShapeProblems(doc);
78
78
  if (!problems.length) problems.push(...manifestSemanticProblems(doc, { varsOf, braceVariants, compileVariant }));
79
79
  if (problems.length) fail('HFS_MANIFEST_INVALID', `the slot manifest breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems });
@@ -8,3 +8,13 @@ export const readEnv = (name, env = process.env) => env[name];
8
8
 
9
9
  /** True when `env` (default: the process environment) belongs to a spec run (`node --test` marks its children with NODE_TEST_CONTEXT). */
10
10
  export const isSpecRun = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT);
11
+
12
+ /**
13
+ * The environment for a child that is itself a test runner: `env` without the mark of an enclosing spec run. A `node --test` that inherits NODE_TEST_CONTEXT believes it is a
14
+ * subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (starci test affected, the land gate, the release cut, the
15
+ * suite) would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
16
+ */
17
+ export function withoutTestRunner(env = process.env) {
18
+ const { NODE_TEST_CONTEXT: _enclosing, ...rest } = env;
19
+ return rest;
20
+ }
@@ -0,0 +1,14 @@
1
+ // yaml-cached.mjs - parse a YAML text once per distinct text in a process.
2
+ //
3
+ // The HFS slot manifest and rule catalog are large documents that one process asks for again at every check, every render and every fixture. Their
4
+ // bytes do not change between those asks, so the parse (the dominant cost of a load) is keyed by the text itself; each caller gets its own copy of the
5
+ // parsed document, so nothing a caller does to it reaches the next one. A changed file is a different text and parses again.
6
+ import { parseYaml } from '../../engine/yaml.mjs';
7
+
8
+ const parsed = new Map();
9
+
10
+ /** The parsed document of `text`: parsed on the first ask for this text, a structured copy afterwards. Throws what parseYaml throws, every time. */
11
+ export function parseYamlCached(text) {
12
+ if (!parsed.has(text)) parsed.set(text, parseYaml(text));
13
+ return structuredClone(parsed.get(text));
14
+ }