@starci/eslint-canon-fe 8.1.0 → 8.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
+ ## 8.2.0 - 2026-10-09
4
+
5
+ - Changed: the bundled runtime copies (engine config, architecture machine, slot manifest, canon pins, failure codes) follow the runtime of 1.0.0-alpha.9; no rule is added or removed. Version 8.1.0 on the registry carries the copies of an earlier runtime.
6
+
3
7
  ## 8.1.0 - 2026-10-08
4
8
 
5
9
  - New: the `sonar-parity` law (R235 `SCAN_SMELL`): `no-await-in-loop` (S9382), `prefer-code-point` (S7758), `prefer-string-raw` (S7780), `no-nested-conditional` (S3358), `no-void-operator` (S3735), `no-unused-import` (S1128), `prefer-export-from` (S7763) and `no-unused-prop-types` (S6767), each the syntax-tree form of the Sonar rule SonarCloud flagged in the example apps, shared with the back-end canon through the bundled `runtime/scripts/lib/sonar-syntax-rules.mjs`. The factory also turns on the typescript-eslint rules `no-deprecated` (S1874), `no-base-to-string` (S6551) and `require-await` (S7503); `@typescript-eslint/eslint-plugin` becomes a dependency of this package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
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
@@ -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.
@@ -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 });
@@ -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
+ }