@starci/eslint-grammar-offset-pop 0.12.13 → 0.13.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 +10 -0
- package/README.md +4 -4
- package/config.mjs +2 -2
- package/index.d.ts +4 -4
- package/index.mjs +1 -1
- package/package.json +23 -15
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +461 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +29 -0
- package/runtime/engine/release-config.mjs +28 -0
- package/runtime/engine/removed-vocabulary.mjs +26 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +19 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +458 -0
- package/runtime/modules/models/registry.yaml +221 -0
- package/runtime/modules/models/runtimes.yaml +556 -0
- package/runtime/modules/models/tiers.yaml +66 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +157 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +86 -0
- package/runtime/scripts/hfs/edition-slots.mjs +77 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +435 -0
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +362 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +80 -0
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +91 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +89 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +149 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/lib/i18n.mjs +85 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +31 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/yaml-cached.mjs +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-10-27
|
|
4
|
+
|
|
5
|
+
- Changed: versioned together with Grammar 0.13.0; `@starci/eslint-grammar-core` 0.13.0 is a peer dependency, installed by the app beside this package. Offset Pop is unchanged and refuses `core`.
|
|
6
|
+
- Changed: the package lives in github.com/starci183/starci-harness under `packages/` and publishes from there; its bundled `runtime/` is committed source, edited in place (no generator, no `prepack`/`pretest` hook).
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## 0.12.14 - 2026-10-27
|
|
10
|
+
|
|
11
|
+
- Fixed: the bundled runtime copy now carries the static import closure of its slot view, so the slot modules it imports are present.
|
|
12
|
+
|
|
3
13
|
## 0.12.13 - 2026-10-26
|
|
4
14
|
|
|
5
15
|
- Changed: versioned together with Grammar 0.12.13.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @starci/eslint-grammar-offset-pop
|
|
2
2
|
|
|
3
|
-
Offset Pop composes the Common renderers and types. This package inherits their checks from the exact matching `@starci/eslint-grammar-
|
|
3
|
+
Offset Pop composes the Common renderers and types. This package inherits their checks from the exact matching `@starci/eslint-grammar-core` release; the rule implementations and shared infrastructure have one owner. Offset Pop has no additional component API rules yet. Its visual choices belong to its DNA and packaged stylesheet.
|
|
4
4
|
|
|
5
|
-
Rule ownership follows `FE-RULE-OWNERSHIP` in `
|
|
5
|
+
Rule ownership follows `FE-RULE-OWNERSHIP` in `contexts/code/fe/README.md` of the StarCi harness. The family's inheritance is declared in `contexts/grammars/offset-pop.md`; the app-shell law is `contexts/ui/composition.md`.
|
|
6
6
|
|
|
7
7
|
The managed `fe/eslint.config.mjs` consumes the universal canon and the selected family:
|
|
8
8
|
|
|
@@ -18,13 +18,13 @@ The inherited rule IDs use the `starci-grammar` prefix. `rules`, `recommended`,
|
|
|
18
18
|
|
|
19
19
|
## Migrating from @starci/eslint-canon-fe 8.x
|
|
20
20
|
|
|
21
|
-
The pinned canon-fe release moved the twenty Grammar component rules (listed in the `@starci/eslint-grammar-
|
|
21
|
+
The pinned canon-fe release moved the twenty Grammar component rules (listed in the `@starci/eslint-grammar-core`
|
|
22
22
|
README and the pinned canon-fe changelog) out of the universal canon. An Offset Pop app receives them
|
|
23
23
|
through this package under the `starci-grammar/` namespace with unchanged IDs, codes and severity.
|
|
24
24
|
|
|
25
25
|
1. Set `sides.fe.grammar` to `offset-pop` in `hfs.json`; no family is inferred.
|
|
26
26
|
2. Install this package at its canon-pins version beside `@starci/eslint-canon-fe` 9 in the app
|
|
27
|
-
root `devDependencies`; it brings the matching `@starci/eslint-grammar-
|
|
27
|
+
root `devDependencies`; it brings the matching `@starci/eslint-grammar-core`. `starci app check`
|
|
28
28
|
refuses canon-fe 9 without it (`FE_STACK_FAMILY_LINT_MISSING`).
|
|
29
29
|
3. Compose `starciFeConfig({ hfs })` first and `offsetPopGrammarConfig({ hfs })` second (the block
|
|
30
30
|
above); `starci app sync` renders that managed `fe/eslint.config.mjs`.
|
package/config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { coreGrammarConfig } from "@starci/eslint-grammar-core"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Attach the inherited Common API laws for a front end selecting Offset Pop.
|
|
@@ -6,4 +6,4 @@ import { starciGrammarConfig } from "@starci/eslint-grammar-starci"
|
|
|
6
6
|
* @param {{ hfs: object }} input - The front-end HFS view from the universal canon's loadHfs.
|
|
7
7
|
* @returns {Array<object>} Family rules at error under the shared starci-grammar namespace.
|
|
8
8
|
*/
|
|
9
|
-
export const offsetPopGrammarConfig = ({ hfs }) =>
|
|
9
|
+
export const offsetPopGrammarConfig = ({ hfs }) => coreGrammarConfig({ hfs, family: "offset-pop" })
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { coreGrammarConfig } from "@starci/eslint-grammar-core"
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
4
|
default,
|
|
@@ -8,9 +8,9 @@ export {
|
|
|
8
8
|
lawOwners,
|
|
9
9
|
ruleDeclarations,
|
|
10
10
|
why,
|
|
11
|
-
} from "@starci/eslint-grammar-
|
|
11
|
+
} from "@starci/eslint-grammar-core"
|
|
12
12
|
|
|
13
13
|
/** Attach the inherited Common API checks for the selected Offset Pop family. */
|
|
14
14
|
export declare function offsetPopGrammarConfig(input: {
|
|
15
|
-
hfs: Parameters<typeof
|
|
16
|
-
}): ReturnType<typeof
|
|
15
|
+
hfs: Parameters<typeof coreGrammarConfig>[0]["hfs"]
|
|
16
|
+
}): ReturnType<typeof coreGrammarConfig>
|
package/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-grammar-offset-pop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Offset Pop Grammar lint: the inherited Common component contract over its own visual family.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eslint",
|
|
7
|
+
"eslintplugin",
|
|
8
|
+
"starci",
|
|
9
|
+
"grammar",
|
|
10
|
+
"offset-pop"
|
|
11
|
+
],
|
|
5
12
|
"type": "module",
|
|
6
13
|
"license": "MIT",
|
|
14
|
+
"author": "starci183",
|
|
15
|
+
"homepage": "https://github.com/starci183/starci-harness/tree/main/packages/eslint-grammar-offset-pop#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/starci183/starci-harness.git",
|
|
19
|
+
"directory": "packages/eslint-grammar-offset-pop"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/starci183/starci-harness/issues"
|
|
23
|
+
},
|
|
7
24
|
"private": false,
|
|
8
25
|
"main": "./index.mjs",
|
|
9
26
|
"types": "./index.d.ts",
|
|
@@ -28,30 +45,21 @@
|
|
|
28
45
|
},
|
|
29
46
|
"peerDependencies": {
|
|
30
47
|
"eslint": ">=9",
|
|
31
|
-
"typescript": ">=5.9"
|
|
32
|
-
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"@starci/eslint-grammar-starci": "0.12.13"
|
|
48
|
+
"typescript": ">=5.9",
|
|
49
|
+
"@starci/eslint-grammar-core": "0.13.0"
|
|
35
50
|
},
|
|
36
51
|
"devDependencies": {
|
|
52
|
+
"@starci/eslint-canon-fe": "file:../eslint-canon-fe",
|
|
53
|
+
"@starci/eslint-grammar-core": "file:../eslint-grammar-core",
|
|
37
54
|
"@typescript-eslint/parser": "8.71.0",
|
|
38
55
|
"eslint": "9.39.5",
|
|
39
56
|
"eslint-plugin-react-hooks": "7.1.1",
|
|
40
57
|
"typescript": "5.9.3"
|
|
41
58
|
},
|
|
42
59
|
"scripts": {
|
|
43
|
-
"prepack": "node ../../hfs/prep-runtime.mjs",
|
|
44
|
-
"pretest": "node ../../hfs/prep-runtime.mjs",
|
|
45
60
|
"test": "node --test \"*.spec.mjs\""
|
|
46
61
|
},
|
|
47
62
|
"publishConfig": {
|
|
48
63
|
"access": "public"
|
|
49
|
-
}
|
|
50
|
-
"keywords": [
|
|
51
|
-
"eslint",
|
|
52
|
-
"eslintplugin",
|
|
53
|
-
"starci",
|
|
54
|
-
"grammar",
|
|
55
|
-
"offset-pop"
|
|
56
|
-
]
|
|
64
|
+
}
|
|
57
65
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// by-code-unit.mjs - the comparator of a bare `Array.prototype.sort()`, owned beside the engine files that digest and canonicalize (scripts/lib/list.mjs re-exports it).
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The comparator of a bare `Array.prototype.sort()`: values compared as strings by UTF-16 code unit. Pass it to `sort` and
|
|
5
|
+
* `toSorted` so the order is stated, and stays byte-identical to the default order that digests and canonical output depend on.
|
|
6
|
+
*/
|
|
7
|
+
export const byCodeUnit = (a, b) => {
|
|
8
|
+
const left = String(a);
|
|
9
|
+
const right = String(b);
|
|
10
|
+
if (left < right) return -1;
|
|
11
|
+
return left > right ? 1 : 0;
|
|
12
|
+
};
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {skillRoot} from './runtime-root.mjs';
|
|
5
|
+
import {parseYaml} from './yaml.mjs';
|
|
6
|
+
import {isPlainObject as plain} from './plain-object.mjs';
|
|
7
|
+
import {invalid,validateRoots,ROOT_KEYS} from './invalid-config.mjs';
|
|
8
|
+
import {validateSonar} from './sonar-config.mjs';
|
|
9
|
+
import {validateRelease,RELEASE_KEYS} from './release-config.mjs';
|
|
10
|
+
import {validateOrca,ORCA_KEYS} from './orca-config.mjs';
|
|
11
|
+
import {validateResources,RESOURCE_KEYS} from './resources-config.mjs';
|
|
12
|
+
import { ENV_NAME, connectorSecret } from './secrets.mjs';
|
|
13
|
+
import {byCodeUnit} from './by-code-unit.mjs';
|
|
14
|
+
import {EFFORT_LEVELS,MODELS_KEYS,validateModelsBlock,refuseRemovedKeys,shippedTiers} from './model-config.mjs';
|
|
15
|
+
const knownNames=names=>[...names].sort(byCodeUnit).join(', ');
|
|
16
|
+
export {readDotenv,connectorSecret} from './secrets.mjs';
|
|
17
|
+
|
|
18
|
+
export const configRoot=skillRoot;
|
|
19
|
+
/** The owner-facing language when config.yaml `language` is absent — the one default every reader shares (scripts/lib/i18n.mjs ownerLanguage). A base-tier value: machine (home.mjs) re-exports it, not the reverse. */
|
|
20
|
+
export const DEFAULT_OWNER_LANGUAGE='en';
|
|
21
|
+
// Parsed once per file version (mtime + size) per process; each caller gets its own copy.
|
|
22
|
+
// The one runtimes.yaml loader: it throws on a missing or unparsable file, so no caller ever
|
|
23
|
+
// reasons on a silent empty document. The pool map (provider, roles, models, maxParallel)
|
|
24
|
+
// lives in the ONE model catalog — modules/models/registry.yaml `pools` — and is merged in
|
|
25
|
+
// under `runtimes` so consumers read the same shape as before.
|
|
26
|
+
let runtimeProfileCache=null;
|
|
27
|
+
export function runtimeProfile(){
|
|
28
|
+
const source=fileURLToPath(new URL('../modules/models/runtimes.yaml',import.meta.url));
|
|
29
|
+
const registryFile=fileURLToPath(new URL('../modules/models/registry.yaml',import.meta.url));
|
|
30
|
+
let stat;try{stat=fs.statSync(source);}catch{throw new Error('Missing modules/models/runtimes.yaml');}
|
|
31
|
+
let rstat;try{rstat=fs.statSync(registryFile);}catch{throw new Error('Missing modules/models/registry.yaml');}
|
|
32
|
+
const version=`${stat.mtimeMs}:${stat.size}:${rstat.mtimeMs}:${rstat.size}`;
|
|
33
|
+
if(runtimeProfileCache?.version!==version){
|
|
34
|
+
const doc=parseYaml(fs.readFileSync(source,'utf8'));
|
|
35
|
+
const registry=parseYaml(fs.readFileSync(registryFile,'utf8'));
|
|
36
|
+
runtimeProfileCache={version,profile:{...doc,runtimes:registry?.pools??{},models:registry?.models??{}}};
|
|
37
|
+
}
|
|
38
|
+
return structuredClone(runtimeProfileCache.profile);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* modules/models/runtimes.yaml `allocation` — where the workers' operating numbers live: the dispatch lease
|
|
42
|
+
* TTL, the liveness and cadence windows, the slicing weights, the failure cooldowns. Code reads them from
|
|
43
|
+
* here; a literal copy of any of them in a source file would be a second authority.
|
|
44
|
+
*/
|
|
45
|
+
export function allocationSettings(){
|
|
46
|
+
const allocation=runtimeProfile()?.allocation??{};
|
|
47
|
+
return allocation.admission?{...allocation,admission:{...allocation.admission,...shippedTiers().usage}}:allocation;
|
|
48
|
+
}
|
|
49
|
+
// These are current owner declarations, never a historical runtime owner's consent.
|
|
50
|
+
function validateOwnerProfile(value,name,pathsKey){
|
|
51
|
+
if(value===null)return;
|
|
52
|
+
const bad=invalid(name);
|
|
53
|
+
if(!plain(value))bad(' must be an owner approval mapping or null.');
|
|
54
|
+
const keys=new Set(['approvedBy','approvalRef',pathsKey,...(name==='launchTrust'?['profile']:[])]);
|
|
55
|
+
for(const key of Object.keys(value))if(!keys.has(key))bad(` has unknown key ${key}.`);
|
|
56
|
+
if(value.approvedBy!=='owner')bad('.approvedBy must be owner; adoption must come from the current owner.');
|
|
57
|
+
if(typeof value.approvalRef!=='string'||!value.approvalRef.trim())bad('.approvalRef must identify the current owner approval.');
|
|
58
|
+
if(name==='launchTrust'&&!['automatic','declined'].includes(value.profile))bad('.profile must be automatic or declined.');
|
|
59
|
+
const paths=value[pathsKey];
|
|
60
|
+
if(!Array.isArray(paths)||!paths.length||paths.some(p=>typeof p!=='string'||!(path.isAbsolute(p)||path.win32.isAbsolute(p))||/[\r\n\0]/.test(p)))bad(`.${pathsKey} must list exact absolute repository roots.`);
|
|
61
|
+
}
|
|
62
|
+
export function launchTrustSettings(config=loadConfig()){
|
|
63
|
+
const value=config?.launchTrust??null;validateOwnerProfile(value,'launchTrust','roots');return value;
|
|
64
|
+
}
|
|
65
|
+
export function workflowPurgeSettings(config=loadConfig()){
|
|
66
|
+
const retention=config?.retention??null;
|
|
67
|
+
if(retention===null)return null;
|
|
68
|
+
const bad=invalid('retention');
|
|
69
|
+
if(!plain(retention)||Object.keys(retention).some(k=>k!=='workflowPurge'))bad(' must be {workflowPurge?} or null.');
|
|
70
|
+
const value=retention.workflowPurge??null;validateOwnerProfile(value,'retention.workflowPurge','repos');return value;
|
|
71
|
+
}
|
|
72
|
+
/** One positive millisecond value out of `allocation`, by dotted key. Refuses when the contract omits it. */
|
|
73
|
+
export function allocationMs(dotted){
|
|
74
|
+
const raw=dotted.split('.').reduce((node,key)=>(node==null?node:node[key]),allocationSettings());
|
|
75
|
+
const value=Number(raw);
|
|
76
|
+
if(!Number.isFinite(value)||value<=0)throw new Error(`modules/models/runtimes.yaml allocation.${dotted} must declare a positive number of milliseconds`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* modules/models/runtimes.yaml allocation.slicing.gears — the declared gear vocabulary. A gear is an
|
|
81
|
+
* index into allocation.slicing.size.<class>.agents, so the two lists are one authority; code never
|
|
82
|
+
* carries a literal gear. Refuses when the contract omits the list or spells it with a non-integer.
|
|
83
|
+
*/
|
|
84
|
+
export function slicingGears(){
|
|
85
|
+
const gears=allocationSettings()?.slicing?.gears;
|
|
86
|
+
if(!Array.isArray(gears)||!gears.length||gears.some(value=>!Number.isInteger(value)||value<1))
|
|
87
|
+
throw new Error('modules/models/runtimes.yaml allocation.slicing.gears must declare a non-empty list of positive integers');
|
|
88
|
+
return [...gears];
|
|
89
|
+
}
|
|
90
|
+
/** The gear an absent `parallel` block means. The first declared gear, never a literal. */
|
|
91
|
+
export const defaultParallelGear=()=>slicingGears()[0];
|
|
92
|
+
/** The owner's parallelism gear: config.yaml `parallel.gear`, or the first declared gear when absent. */
|
|
93
|
+
export function parallelGear(config=loadConfig()){
|
|
94
|
+
validateConfig(config);
|
|
95
|
+
return config?.parallel?.gear??defaultParallelGear();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* config.yaml `connectors` — the owner's public ask channel (docs/connectors.md). Everything defaults off.
|
|
99
|
+
* Secrets never live here: `tokenEnv`/`botTokenEnv` NAME the environment variable holding the secret
|
|
100
|
+
* (or `<NAME>_FILE` pointing at a custody file); a value that is not an env var name is refused, so a
|
|
101
|
+
* pasted token fails closed instead of landing in a plain-text file.
|
|
102
|
+
*/
|
|
103
|
+
const CLOUDFLARE_MODES=['off','quick','named'];
|
|
104
|
+
/** The serve-ask port band, both ends included (scripts/kernel/ask-server.mjs scans [first..last]); the gateway stays outside it. */
|
|
105
|
+
export const ASK_PORT_BAND=[6969,7069];
|
|
106
|
+
export const CONNECTOR_DEFAULTS=Object.freeze({
|
|
107
|
+
repos:[],
|
|
108
|
+
gateway:{port:7070},
|
|
109
|
+
cloudflare:{mode:'off',tunnel:null,credentialsFile:null,tokenEnv:'CLOUDFLARE_TUNNEL_TOKEN',hostname:null,access:false},
|
|
110
|
+
telegram:{enabled:false,botTokenEnv:'TELEGRAM_BOT_TOKEN',chatId:null,exposeCredentialAsks:false},
|
|
111
|
+
});
|
|
112
|
+
const HOSTNAME=/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
113
|
+
const TUNNEL_REF=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
114
|
+
const CHAT_ID=/^(?:-?\d{1,20}|@[A-Za-z]\w{4,31})$/;
|
|
115
|
+
const SECRET_KEYS=new Set(['token','tunnelToken','botToken','secret','apiToken','password']);
|
|
116
|
+
const closedConnectorShape=(bad,node,where,keys)=>{
|
|
117
|
+
if(!plain(node))bad(`.${where} must be a mapping.`);
|
|
118
|
+
for(const key of Object.keys(node)){
|
|
119
|
+
if(SECRET_KEYS.has(key))bad(`.${where}.${key}: secrets never live in config.yaml — set the environment variable the *Env key names instead.`);
|
|
120
|
+
if(!keys.includes(key))bad(`.${where} has unknown key ${key} (allowed: ${keys.join(', ')}).`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const connectorEnvName=(bad,where,value)=>{if(value!==undefined&&(typeof value!=='string'||!ENV_NAME.test(value)))bad(`.${where} must name an environment variable (UPPER_SNAKE), never hold the secret itself.`);};
|
|
124
|
+
const validateCloudflareMode=(cf,bad)=>{if(cf.mode!==undefined&&!CLOUDFLARE_MODES.includes(cf.mode))bad(`.cloudflare.mode must be one of ${CLOUDFLARE_MODES.join(' | ')}.`);};
|
|
125
|
+
const validateCloudflareDetails=(cf,bad)=>{
|
|
126
|
+
if(cf.hostname!==undefined&&cf.hostname!==null&&(typeof cf.hostname!=='string'||!HOSTNAME.test(cf.hostname)))bad('.cloudflare.hostname must be a bare hostname (no scheme, no path) or null.');
|
|
127
|
+
if(cf.access!==undefined&&typeof cf.access!=='boolean')bad('.cloudflare.access must be true or false.');
|
|
128
|
+
if(cf.tunnel!==undefined&&cf.tunnel!==null&&(typeof cf.tunnel!=='string'||!TUNNEL_REF.test(cf.tunnel)))bad('.cloudflare.tunnel must be the named tunnel UUID (or name) or null.');
|
|
129
|
+
if(cf.credentialsFile!==undefined&&cf.credentialsFile!==null&&(typeof cf.credentialsFile!=='string'||!cf.credentialsFile.trim()))bad('.cloudflare.credentialsFile must be the tunnel credentials JSON path or null.');
|
|
130
|
+
};
|
|
131
|
+
const validateCloudflareModeConstraints=(cf,bad)=>{if(cf.credentialsFile&&!cf.tunnel){bad('.cloudflare.credentialsFile needs cloudflare.tunnel (the tunnel UUID the credentials belong to).');}if(cf.mode==='named'&&!cf.hostname){bad('.cloudflare.mode named needs cloudflare.hostname (the public hostname routed to the gateway).');}if(cf.mode==='quick'&&(cf.tunnel||cf.credentialsFile)){bad('.cloudflare.mode quick runs no named tunnel; tunnel/credentialsFile are for mode named.');}if(cf.mode==='quick'&&cf.hostname){bad('.cloudflare.mode quick gets a random trycloudflare.com hostname; set hostname only for mode named.');}};
|
|
132
|
+
const validateCloudflareConnector=cf=>{
|
|
133
|
+
const bad=invalid('connectors');
|
|
134
|
+
closedConnectorShape(bad,cf,'cloudflare',['mode','tunnel','credentialsFile','tokenEnv','hostname','access']);
|
|
135
|
+
validateCloudflareMode(cf,bad);
|
|
136
|
+
connectorEnvName(bad,'cloudflare.tokenEnv',cf.tokenEnv);
|
|
137
|
+
validateCloudflareDetails(cf,bad);
|
|
138
|
+
validateCloudflareModeConstraints(cf,bad);
|
|
139
|
+
};
|
|
140
|
+
const validateTelegramConnector=tg=>{
|
|
141
|
+
const bad=invalid('connectors');
|
|
142
|
+
closedConnectorShape(bad,tg,'telegram',['enabled','botTokenEnv','chatId','exposeCredentialAsks']);
|
|
143
|
+
if(tg.enabled!==undefined&&typeof tg.enabled!=='boolean')bad('.telegram.enabled must be true or false.');
|
|
144
|
+
connectorEnvName(bad,'telegram.botTokenEnv',tg.botTokenEnv);
|
|
145
|
+
if(tg.chatId!==undefined&&tg.chatId!==null&&!((Number.isSafeInteger(tg.chatId))||(typeof tg.chatId==='string'&&CHAT_ID.test(tg.chatId))))
|
|
146
|
+
bad('.telegram.chatId must be a numeric chat id, an @channel name, or null.');
|
|
147
|
+
if(tg.exposeCredentialAsks!==undefined&&typeof tg.exposeCredentialAsks!=='boolean')bad('.telegram.exposeCredentialAsks must be true or false.');
|
|
148
|
+
if(tg.enabled===true&&(tg.chatId===undefined||tg.chatId===null))bad('.telegram.enabled needs telegram.chatId.');
|
|
149
|
+
};
|
|
150
|
+
function validateConnectors(connectors){
|
|
151
|
+
if(connectors===null)return;
|
|
152
|
+
const bad=invalid('connectors');
|
|
153
|
+
if(!plain(connectors))bad(' must be {repos?, gateway?, cloudflare?, telegram?} or null.');
|
|
154
|
+
closedConnectorShape(bad,connectors,'',['repos','gateway','cloudflare','telegram']);
|
|
155
|
+
if(connectors.repos!==undefined&&(!Array.isArray(connectors.repos)||connectors.repos.some(repo=>typeof repo!=='string'||!repo.trim())))bad('.repos must be a list of repository paths.');
|
|
156
|
+
if(connectors.gateway!==undefined){
|
|
157
|
+
closedConnectorShape(bad,connectors.gateway,'gateway',['port']);
|
|
158
|
+
const port=connectors.gateway.port;
|
|
159
|
+
if(port!==undefined&&(!Number.isInteger(port)||port<1024||port>65535||(port>=ASK_PORT_BAND[0]&&port<=ASK_PORT_BAND[1])))
|
|
160
|
+
bad(`.gateway.port must be an integer port in 1024..65535 outside the serve-ask band ${ASK_PORT_BAND.join('..')}.`);
|
|
161
|
+
}
|
|
162
|
+
if(connectors.cloudflare!==undefined)validateCloudflareConnector(connectors.cloudflare);
|
|
163
|
+
if(connectors.telegram!==undefined)validateTelegramConnector(connectors.telegram);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The normalized `connectors` block: defaults filled in, the secrets' PRESENCE (booleans, never values),
|
|
167
|
+
* whether each connector is ready to run, and the owner-facing warnings the security posture earns.
|
|
168
|
+
*/
|
|
169
|
+
const connectorWarnings=(cloudflare,telegram)=>{
|
|
170
|
+
const warnings=[];
|
|
171
|
+
if(cloudflare.mode==='quick')warnings.push('cloudflare.mode quick: the ask link is public and unauthenticated — anyone holding the URL can open and answer the form; the hostname changes on every restart.');
|
|
172
|
+
if(cloudflare.mode==='named'&&!cloudflare.access)warnings.push(`cloudflare.access is false: ${cloudflare.hostname} is not fronted by Cloudflare Access, so the nonce URL is the only credential.`);
|
|
173
|
+
if(cloudflare.auth==='credentials-file'&&!cloudflare.credentialsPresent)warnings.push(`cloudflare.credentialsFile ${cloudflare.credentialsFile} does not exist.`);
|
|
174
|
+
if(cloudflare.auth==='token'&&!cloudflare.tokenPresent)warnings.push(`cloudflare.mode named: the tunnel token is not set — export ${cloudflare.tokenEnv} (or ${cloudflare.tokenEnv}_FILE).`);
|
|
175
|
+
if(telegram.enabled&&!telegram.botTokenPresent)warnings.push(`telegram.enabled: the bot token is not set — export ${telegram.botTokenEnv} (or ${telegram.botTokenEnv}_FILE).`);
|
|
176
|
+
if(telegram.exposeCredentialAsks)warnings.push('telegram.exposeCredentialAsks is true: credential asks are posted as public links; Telegram cloud chats are not end-to-end encrypted.');
|
|
177
|
+
return warnings;
|
|
178
|
+
};
|
|
179
|
+
export function connectorsConfig(config=loadConfig(),env=process.env,root=configRoot){
|
|
180
|
+
validateConfig(config);
|
|
181
|
+
const raw=plain(config?.connectors)?config.connectors:{},d=CONNECTOR_DEFAULTS;
|
|
182
|
+
const cloudflare={...d.cloudflare,...raw.cloudflare},telegram={...d.telegram,...raw.telegram};
|
|
183
|
+
if(cloudflare.credentialsFile)cloudflare.credentialsFile=path.resolve(root,cloudflare.credentialsFile.replace(/^~(?=[\\/])/,env.USERPROFILE??env.HOME??'~'));
|
|
184
|
+
cloudflare.tokenPresent=connectorSecret(cloudflare.tokenEnv,env)!==null;
|
|
185
|
+
cloudflare.credentialsPresent=Boolean(cloudflare.credentialsFile&&fs.existsSync(cloudflare.credentialsFile));
|
|
186
|
+
const namedAuth=cloudflare.credentialsFile?'credentials-file':'token';
|
|
187
|
+
cloudflare.auth=cloudflare.mode==='named'?namedAuth:null;
|
|
188
|
+
cloudflare.publicBase=cloudflare.mode==='named'?`https://${cloudflare.hostname}`:null;
|
|
189
|
+
telegram.chatId=telegram.chatId===null?null:String(telegram.chatId);
|
|
190
|
+
telegram.botTokenPresent=connectorSecret(telegram.botTokenEnv,env)!==null;
|
|
191
|
+
telegram.configured=telegram.enabled===true&&telegram.chatId!==null&&telegram.botTokenPresent;
|
|
192
|
+
return {repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...raw.gateway},cloudflare,telegram,warnings:connectorWarnings(cloudflare,telegram)};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* config.yaml `asks` — whether an owner ask that carries a recommended option is answered with it
|
|
196
|
+
* instead of being served (scripts/kernel/ask-server.mjs autoAcceptAsk). `excludes` names the ask classes
|
|
197
|
+
* that always reach the owner: `credential` (secret fields, or kind credential/account/access/consent),
|
|
198
|
+
* `handover` (every handover.review ask — excluded even when the owner drops it from the list),
|
|
199
|
+
* `draw-review` (the opt-out: an interface.draw drawing review the owner did not ask for is otherwise
|
|
200
|
+
* accepted without the owner) and any ask kind of modules/ops/ops/provision.ask.yaml.
|
|
201
|
+
*/
|
|
202
|
+
export const ASK_KINDS=Object.freeze(['information','credential','account','access','consent','authority','business-decision','irreversible-confirmation']);
|
|
203
|
+
const ASK_EXCLUDE_CLASSES=Object.freeze([...ASK_KINDS,'handover','draw-review']);
|
|
204
|
+
export const ASKS_DEFAULTS=Object.freeze({autoAcceptRecommended:false,excludes:Object.freeze(['credential','irreversible-confirmation','handover'])});
|
|
205
|
+
const validateAskExcludes=(bad,excludes)=>{
|
|
206
|
+
if(!Array.isArray(excludes))bad(`.excludes must be a list of ask classes (${ASK_EXCLUDE_CLASSES.join(', ')}).`);
|
|
207
|
+
for(const entry of excludes)if(typeof entry!=='string'||!ASK_EXCLUDE_CLASSES.includes(entry))bad(`.excludes entry ${JSON.stringify(entry)} is not an ask class (${ASK_EXCLUDE_CLASSES.join(', ')}).`);
|
|
208
|
+
if(new Set(excludes).size!==excludes.length)bad('.excludes names each class once.');
|
|
209
|
+
};
|
|
210
|
+
function validateAsks(asks){
|
|
211
|
+
if(asks===null)return;
|
|
212
|
+
const bad=invalid('asks');
|
|
213
|
+
if(!plain(asks))bad(' must be {autoAcceptRecommended?, excludes?} or null.');
|
|
214
|
+
for(const key of Object.keys(asks))if(!CONFIG_KEY_TREE.asks.includes(key))bad(` has unknown key ${key} (allowed: ${CONFIG_KEY_TREE.asks.join(', ')}).`);
|
|
215
|
+
if(asks.autoAcceptRecommended!==undefined&&typeof asks.autoAcceptRecommended!=='boolean')bad('.autoAcceptRecommended must be true or false.');
|
|
216
|
+
const excludes=asks.excludes;
|
|
217
|
+
if(excludes!==undefined&&excludes!==null)validateAskExcludes(bad,excludes);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* config.yaml `uat` — the machine-wide UAT ceiling: at most `maxConcurrent` UAT runs (browsers, players,
|
|
221
|
+
* the apps they drive) execute at once on this host; the rest queue for a slot (scripts/uat/uat-slots.mjs).
|
|
222
|
+
*/
|
|
223
|
+
export const UAT_DEFAULTS=Object.freeze({maxConcurrent:10});
|
|
224
|
+
function validateUat(uat){
|
|
225
|
+
if(uat===null)return;
|
|
226
|
+
const bad=invalid('uat');
|
|
227
|
+
if(!plain(uat))bad(' must be {maxConcurrent?} or null.');
|
|
228
|
+
for(const key of Object.keys(uat))if(!CONFIG_KEY_TREE.uat.includes(key))bad(` has unknown key ${key} (allowed: ${CONFIG_KEY_TREE.uat.join(', ')}).`);
|
|
229
|
+
if(uat.maxConcurrent!==undefined&&uat.maxConcurrent!==null&&!(Number.isInteger(uat.maxConcurrent)&&uat.maxConcurrent>=1))bad('.maxConcurrent must be a positive integer (default 10) or null.');
|
|
230
|
+
}
|
|
231
|
+
/** The owner's UAT concurrency settings: {maxConcurrent, source}. An absent or null block is the default. */
|
|
232
|
+
export function uatSettings(config=loadConfig()){
|
|
233
|
+
if(config?.uat!==undefined)validateUat(config.uat);
|
|
234
|
+
const value=plain(config?.uat)?config.uat.maxConcurrent:null;
|
|
235
|
+
return Number.isInteger(value)?{maxConcurrent:value,source:'uat'}:{maxConcurrent:UAT_DEFAULTS.maxConcurrent,source:'default'};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* config.yaml `allocation` — {grants?}: ['<pool>=<slots>@<role>+<role>'], the owner's default grant, applied to every
|
|
239
|
+
* workflow, that opens a capacityAuthority explicit-workflow-quota pool (Devin) for those roles up to <slots> running jobs.
|
|
240
|
+
*/
|
|
241
|
+
const GRANT=/^([a-z0-9][a-z0-9.-]*)=(\d+)@([a-z]+(?:\+[a-z]+)*)$/;
|
|
242
|
+
/** One grant string `<pool>=<slots>@<role>+<role>` as {pool, slots, roles}, or null when it is not that shape. */
|
|
243
|
+
/** The closed set of top-level config.yaml blocks: the validator accepts these and refuses every other key. */
|
|
244
|
+
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']);
|
|
245
|
+
function parseAllocationGrant(text){
|
|
246
|
+
const m=typeof text==='string'?GRANT.exec(text.trim()):null;
|
|
247
|
+
return m?{pool:m[1],slots:Number(m[2]),roles:m[3].split('+')}:null;
|
|
248
|
+
}
|
|
249
|
+
const validateGrant=(bad,text,runtimes,seen)=>{
|
|
250
|
+
const grant=parseAllocationGrant(text);
|
|
251
|
+
if(!grant)bad(`grants entry ${JSON.stringify(text)} is not "<pool>=<slots>@<role>+<role>" (e.g. devin-agent=10@implement+verify+write).`);
|
|
252
|
+
const runtime=runtimes[grant.pool];
|
|
253
|
+
if(!plain(runtime))bad(`grants entry ${text}: ${grant.pool} is not a modules/models/registry.yaml pool.`);
|
|
254
|
+
if(seen.has(grant.pool)){bad(`grants names ${grant.pool} more than once.`);} seen.add(grant.pool);
|
|
255
|
+
const max=Number(runtime.maxParallel); if(!(grant.slots>=1&&(!Number.isFinite(max)||grant.slots<=max)))bad(`grants entry ${text}: slots must be 1..${Number.isFinite(max)?max:'maxParallel'} (registry.yaml maxParallel).`);
|
|
256
|
+
const unserved=grant.roles.filter(role=>!(runtime.roles??[]).includes(role)); if(unserved.length)bad(`grants entry ${text}: ${grant.pool} does not serve role ${unserved.join(', ')} (registry.yaml roles: ${(runtime.roles??[]).join(', ')}).`);
|
|
257
|
+
};
|
|
258
|
+
const validateGrants=(bad,grants,runtimes)=>{
|
|
259
|
+
if(!Array.isArray(grants))bad('grants must be a list of "<pool>=<slots>@<role>+<role>" strings.');
|
|
260
|
+
const seen=new Set();
|
|
261
|
+
for(const text of grants)validateGrant(bad,text,runtimes,seen);
|
|
262
|
+
};
|
|
263
|
+
function validateAgentSeat(seat,name,profile){
|
|
264
|
+
const runtimes=profile?.runtimes??{},knownProviders=new Set(Object.values(runtimes).map(runtime=>runtime?.provider).filter(Boolean));
|
|
265
|
+
if(!plain(seat)||Object.keys(seat).some(key=>!SEAT_KEYS.includes(key))||Object.values(seat).some(value=>value!==null&&(typeof value!=='string'||!value.trim())))
|
|
266
|
+
throw new Error(`Invalid config.yaml: ${name} must be {agent?, model?, effort?} with string-or-null values (agent and model pin the seat: an \`only\` bias).`);
|
|
267
|
+
if(typeof seat.agent==='string'&&!knownProviders.has(seat.agent))throw new Error(`Invalid config.yaml: ${name}.agent ${seat.agent} is not declared by a runtime (known: ${knownNames(knownProviders)}).`);
|
|
268
|
+
if(seat.effort!==undefined&&seat.effort!==null&&!EFFORT_LEVELS.has(seat.effort))throw new Error(`Invalid config.yaml: ${name}.effort must use the effort vocabulary.`);
|
|
269
|
+
}
|
|
270
|
+
const validateReconcilerBlock=r=>{
|
|
271
|
+
const ctl=r?.controllers;
|
|
272
|
+
if(!plain(r)||Object.keys(r).some(key=>!CONFIG_KEY_TREE.reconciler.includes(key))||(r.enabled!==undefined&&typeof r.enabled!=='boolean')||!(r.profile===undefined||r.profile===null||['operational','observe'].includes(r.profile))||!(ctl===undefined||ctl===null||(plain(ctl)&&Object.entries(ctl).every(([name,c])=>/^[a-z][a-z0-9-]*$/.test(name)&&plain(c)&&Object.keys(c).every(key=>key==='mode')&&['off','shadow','active'].includes(c.mode)))))
|
|
273
|
+
throw new Error('Invalid config.yaml: reconciler must be {enabled?: boolean, profile?: operational|observe, controllers?: {<name>: {mode: off|shadow|active}}}, or null.');
|
|
274
|
+
};
|
|
275
|
+
const validateAllocationBlock=(allocation,runtimes)=>{
|
|
276
|
+
if(!plain(allocation)||Object.keys(allocation).some(key=>!CONFIG_KEY_TREE.allocation.includes(key)))
|
|
277
|
+
throw new Error('Invalid config.yaml: allocation must be {grants?: [<pool>=<slots>@<role>+<role>]}.');
|
|
278
|
+
if(allocation.grants!==undefined&&allocation.grants!==null)validateGrants(invalid('allocation.'),allocation.grants,runtimes);
|
|
279
|
+
};
|
|
280
|
+
const validateParallelBlock=parallel=>{
|
|
281
|
+
const gears=slicingGears();
|
|
282
|
+
if(!plain(parallel)||Object.keys(parallel).some(key=>!CONFIG_KEY_TREE.parallel.includes(key))||!Number.isInteger(parallel.gear))
|
|
283
|
+
throw new Error('Invalid config.yaml: parallel must be {gear: <integer>}.');
|
|
284
|
+
if(!gears.includes(parallel.gear))
|
|
285
|
+
throw new Error(`Invalid config.yaml: parallel.gear ${parallel.gear} is not declared by modules/models/runtimes.yaml allocation.slicing.gears (known: ${gears.join(', ')}).`);
|
|
286
|
+
};
|
|
287
|
+
const validateSupervisorBlock=(supervisor,profile)=>{
|
|
288
|
+
const interval=supervisor?.pollIntervalMs,repos=supervisor?.repos,stall=supervisor?.stallMinutes;
|
|
289
|
+
if(!plain(supervisor)||Object.keys(supervisor).some(key=>!CONFIG_KEY_TREE.supervisor.includes(key))||!(interval===null||interval===undefined||(Number.isInteger(interval)&&interval>=60000)))
|
|
290
|
+
throw new Error('Invalid config.yaml: supervisor must be {mode?, pollIntervalMs?, repos?, stallMinutes?, kernel?, workers?, landGate?, frozenMinutes?} with an integer of at least 60000 ms, or null.');
|
|
291
|
+
// mode: where the Supervisor role runs (scripts/machine/home.mjs supervisorMode) - chat (default: the owner's desktop
|
|
292
|
+
// chat session owns channel 'main' and ticks itself) or kernel (the optional [Supervisor] Orca kernel, start-supervisor.mjs).
|
|
293
|
+
if(!(supervisor.mode===undefined||supervisor.mode===null||['chat','kernel'].includes(supervisor.mode)))
|
|
294
|
+
throw new Error('Invalid config.yaml: supervisor.mode must be chat or kernel, or null.');
|
|
295
|
+
// The [Supervisor] kernel seat (scripts/machine/home.mjs supervisorSettings): kernel {agent?, model?, effort?}
|
|
296
|
+
// pins its agent (default: the kernel pin), workers {base?, max?} its adaptive [Worker] cap (0 to 10; the shipped default is 0 fix workers),
|
|
297
|
+
// landGate {mode?: shared|exclusive, push?: boolean} the land gate (scripts/supervisor/land.mjs); push (default true, owner ruling push-main-after-scoped-check) lets `starci release push` send main to the remote after a green scoped check, false is the owner's lock on the remote.
|
|
298
|
+
const seat=supervisor.kernel,workers=supervisor.workers,gate=supervisor.landGate;
|
|
299
|
+
if(seat!==undefined&&seat!==null)validateAgentSeat(seat,'supervisor.kernel',profile);
|
|
300
|
+
if(!(workers===undefined||workers===null||(plain(workers)&&Object.keys(workers).every(key=>['base','max'].includes(key)&&Number.isInteger(workers[key])&&workers[key]>=0&&workers[key]<=10))))
|
|
301
|
+
throw new Error('Invalid config.yaml: supervisor.workers must be {base?, max?} integers from 0 to 10, or null.');
|
|
302
|
+
if(!(gate===undefined||gate===null||(plain(gate)&&Object.keys(gate).every(key=>(key==='mode'&&['shared','exclusive'].includes(gate.mode))||(key==='push'&&typeof gate.push==='boolean')))))
|
|
303
|
+
throw new Error('Invalid config.yaml: supervisor.landGate must be {mode?: shared|exclusive, push?: boolean}, or null.');
|
|
304
|
+
// stallMinutes: scripts/supervisor/stall.mjs calls a running workflow STALLED after this many minutes with no progress.
|
|
305
|
+
// frozenMinutes: scripts/supervisor/supervisor-watchdog.mjs reads a busy seat frame with no turn progress for this long as frozen.
|
|
306
|
+
const frozen=supervisor.frozenMinutes;
|
|
307
|
+
if(!(frozen===undefined||frozen===null||(Number.isInteger(frozen)&&frozen>=1)))
|
|
308
|
+
throw new Error('Invalid config.yaml: supervisor.frozenMinutes must be an integer of at least 1, or null.');
|
|
309
|
+
if(!(stall===undefined||stall===null||(Number.isInteger(stall)&&stall>=5)))
|
|
310
|
+
throw new Error('Invalid config.yaml: supervisor.stallMinutes must be an integer of at least 5, or null.');
|
|
311
|
+
if(!(repos===undefined||repos===null||(Array.isArray(repos)&&repos.every(repo=>typeof repo==='string'&&repo.trim()))))
|
|
312
|
+
throw new Error('Invalid config.yaml: supervisor.repos must be a list of ledger-owner repository paths, or null.');
|
|
313
|
+
};
|
|
314
|
+
const validateDelegationBlock=d=>{if(!plain(d)||Object.keys(d).some(key=>!CONFIG_KEY_TREE.delegation.includes(key))||typeof d.asks!=='string'||!d.asks.trim()||typeof d.until!=='string'||Number.isNaN(Date.parse(d.until))||(d.excludes!==undefined&&(!Array.isArray(d.excludes)||d.excludes.some(x=>typeof x!=='string'))))throw new Error('Invalid config.yaml: delegation must be {asks: <delegate>, until: <ISO time>, excludes?: [<class>], note?} or null.');};
|
|
315
|
+
const validateBudgetsBlock=budgets=>{if(!plain(budgets)||Object.keys(budgets).some(key=>!CONFIG_KEY_TREE.budgets.includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))throw new Error('Invalid config.yaml: budgets must be {maxOps?} with positive-integer-or-null values.');};
|
|
316
|
+
const validateRootKeys=(config)=>{
|
|
317
|
+
const allowed=new Set(CONFIG_BLOCKS);
|
|
318
|
+
if(!plain(config)||Object.keys(config).some(key=>!allowed.has(key))||typeof config.language!=='string'||!/^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/.test(config.language)||!(config.model===null||typeof config.model==='string'&&config.model.trim())||!EFFORT_LEVELS.has(config.effort))
|
|
319
|
+
throw new Error('Invalid config.yaml: expected language, model, effort and the closed set of config blocks.');
|
|
320
|
+
};
|
|
321
|
+
const validateEarlyConfigBlocks=(config)=>{
|
|
322
|
+
if(config?.launchTrust!==undefined){launchTrustSettings(config);} if(config?.retention!==undefined){workflowPurgeSettings(config);}
|
|
323
|
+
if(config?.connectors!==undefined){validateConnectors(config.connectors);} if(config?.asks!==undefined){validateAsks(config.asks);}
|
|
324
|
+
if(config?.uat!==undefined){validateUat(config.uat);} if(config?.debugLoop!==undefined){validateDebugLoop(config.debugLoop);}
|
|
325
|
+
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);}
|
|
326
|
+
};
|
|
327
|
+
const validateConfigBlocks=(config,knownProviders,runtimes,profile)=>{
|
|
328
|
+
// specs (owner 2026-09-28): {unit?, e2e?} booleans - each family a boolean; absent = its default (SPEC_DEFAULTS: unit on, e2e off; specsSettings).
|
|
329
|
+
if(config?.specs!==undefined&&config.specs!==null&&(!plain(config.specs)||Object.keys(config.specs).some(key=>!CONFIG_KEY_TREE.specs.includes(key)||typeof config.specs[key]!=='boolean')))throw new Error(`Invalid config.yaml: specs must be {${SPEC_FAMILIES.map(k=>k+'?: boolean').join(', ')}}, or null.`);
|
|
330
|
+
// reconciler (scripts/reconciler/state.mjs reconcilerConfig): {enabled?: boolean, profile?: operational|observe, controllers?: {<name>: {mode: off|shadow|active}}}, or null.
|
|
331
|
+
if(config?.reconciler!==undefined&&config.reconciler!==null){validateReconcilerBlock(config.reconciler);} if(config?.allocation!==undefined){validateAllocationBlock(config.allocation,runtimes);}
|
|
332
|
+
if(config?.kernel!==undefined){validateAgentSeat(config.kernel,'kernel',profile);} if(config?.parallel!==undefined){validateParallelBlock(config.parallel);}
|
|
333
|
+
if(config?.supervisor!==undefined){validateSupervisorBlock(config.supervisor,profile);} if(config?.delegation!==undefined&&config.delegation!==null){validateDelegationBlock(config.delegation);} if(config?.budgets!==undefined){validateBudgetsBlock(config.budgets);}
|
|
334
|
+
};
|
|
335
|
+
export function validateConfig(config){
|
|
336
|
+
const profile=runtimeProfile(),runtimes=profile?.runtimes??{};
|
|
337
|
+
refuseRemovedKeys(config);
|
|
338
|
+
validateEarlyConfigBlocks(config);
|
|
339
|
+
const knownProviders=new Set(Object.values(runtimes).map(runtime=>runtime?.provider).filter(Boolean));
|
|
340
|
+
validateConfigBlocks(config,knownProviders,runtimes,profile);
|
|
341
|
+
validateRootKeys(config);
|
|
342
|
+
validateModelsBlock(config,profile);
|
|
343
|
+
return config;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The owner allocation control: `allocation.grants` only. null = the owner declared no grants list: grant-gated pools
|
|
347
|
+
* keep their ungated routing; a declared list, even [], is the whole set of grants.
|
|
348
|
+
*/
|
|
349
|
+
export function configuredAllocationPolicy(config=loadConfig()){
|
|
350
|
+
validateConfig(config);
|
|
351
|
+
const allocation=plain(config.allocation)?config.allocation:null;
|
|
352
|
+
const grants=Array.isArray(allocation?.grants)
|
|
353
|
+
?Object.fromEntries(allocation.grants.map(parseAllocationGrant).map(({pool,slots,roles})=>[pool,{slots,roles}]))
|
|
354
|
+
:null;
|
|
355
|
+
return {grants,source:allocation?'allocation':'default'};
|
|
356
|
+
}
|
|
357
|
+
/** The variable that confines the owner's `config.yaml` to a directory tree: set, an owner file is read only from under that directory (e2e/setup/isolated-temp.mjs points it at a spec's own temp root). */
|
|
358
|
+
const OWNER_CONFIG_WITHIN_ENV='STARCI_OWNER_CONFIG_WITHIN';
|
|
359
|
+
const foldPath=p=>process.platform==='win32'?path.resolve(p).toLowerCase():path.resolve(p);
|
|
360
|
+
/**
|
|
361
|
+
* Whether the owner's `config.yaml` under `root` is visible to this process. Unconfined (the variable unset) every owner file is visible; confined, only the files under the named
|
|
362
|
+
* directory, where a fixture wrote them, so the checkout's live file (roots.temp, supervisor.landGate, launch trust) never decides a result and a lane clone, the release host and a
|
|
363
|
+
* clean checkout run the same suite. The engine knows nothing of why a caller confines it: the spec preload sets the variable.
|
|
364
|
+
*/
|
|
365
|
+
const ownerFileVisible=(root,env=process.env)=>{
|
|
366
|
+
const within=env[OWNER_CONFIG_WITHIN_ENV];
|
|
367
|
+
if(!within)return true;
|
|
368
|
+
const rel=path.relative(foldPath(within),foldPath(root));
|
|
369
|
+
return !rel.startsWith('..')&&!path.isAbsolute(rel);
|
|
370
|
+
};
|
|
371
|
+
/** The validated config one yaml file holds, or null when the file is absent. */
|
|
372
|
+
const readYamlConfig=(root,name)=>{const yaml=path.join(root,name);return fs.existsSync(yaml)?validateConfig(parseYaml(fs.readFileSync(yaml,'utf8'))):null;};
|
|
373
|
+
function readExample(root=configRoot){const example=readYamlConfig(root,'config.example.yaml');if(example!==null){return example;}throw new Error('Missing config.example.yaml');}
|
|
374
|
+
/**
|
|
375
|
+
* The owner config reader: `config.yaml` is the per-project file (gitignored,
|
|
376
|
+
* seeded verbatim from `config.example.yaml` by the installer — comments and
|
|
377
|
+
* all). Returns the validated owner config, or null when that file does not
|
|
378
|
+
* exist; falling back to the example's defaults is `loadConfig`.
|
|
379
|
+
*/
|
|
380
|
+
function readOwnerConfig(root=configRoot){return ownerFileVisible(root)?readYamlConfig(root,'config.yaml'):null;}
|
|
381
|
+
/**
|
|
382
|
+
* The tolerant read the kernel boot and the router share: an owner file that is absent, unparsable or
|
|
383
|
+
* short of the closed schema must never stop a workflow from routing. Returns
|
|
384
|
+
* {file, config, error, invalid} — `config` is the validated config, or the raw parse when it fails the
|
|
385
|
+
* schema (with `invalid` naming the reason), or null when the file is absent or `error` says why it
|
|
386
|
+
* could not be read at all.
|
|
387
|
+
*/
|
|
388
|
+
export function inspectOwnerConfig(root=configRoot){
|
|
389
|
+
const file=path.join(root,'config.yaml');
|
|
390
|
+
if(!ownerFileVisible(root)||!fs.existsSync(file))return {file,config:null,error:null,invalid:null};
|
|
391
|
+
let parsed;
|
|
392
|
+
try{parsed=parseYaml(fs.readFileSync(file,'utf8'))??null;}
|
|
393
|
+
catch(error){return {file,config:null,error:`config.yaml unparsable: ${error.message}`,invalid:null};}
|
|
394
|
+
try{return {file,config:validateConfig(parsed),error:null,invalid:null};}
|
|
395
|
+
catch(error){return {file,config:parsed,error:null,invalid:error.message};}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The owner's spec switches (config.yaml root `specs`, owner 2026-09-28; defaults set 2026-09-29, none needs a config block):
|
|
399
|
+
* unit - product unit specs in workflows. Default on: a code-writing op writes/updates the unit specs of the source it
|
|
400
|
+
* adds or changes and runs only those; the full unit suite is unit.verify's or the approved /starci release flow's. false = the ops'
|
|
401
|
+
* policy.specsToggle deferral path (scripts/route/spec-deferral.mjs);
|
|
402
|
+
* e2e - product e2e in workflows. Default OFF: e2e runs only when the goal or the owner explicitly asks (set true,
|
|
403
|
+
* or `starci kernel run-deferred-tests`); e2e.verify then runs the FULL e2e suite.
|
|
404
|
+
* An absent, null or unreadable owner file reads as the defaults: unit on, e2e off. The runtime itself has no specs (owner ruling no-runtime-specs), so there is no switch for them.
|
|
405
|
+
*/
|
|
406
|
+
const SPEC_FAMILIES=Object.freeze(['unit','e2e']);
|
|
407
|
+
const SEAT_KEYS=Object.freeze(['agent','model','effort']);
|
|
408
|
+
/** The keys each config block accepts directly under it (the validators read these lists; docs/config-format.md and the example are checked against them). Blocks whose keys are open or deeper than this are not listed. */
|
|
409
|
+
const CONFIG_KEY_TREE=Object.freeze({
|
|
410
|
+
models:MODELS_KEYS,allocation:Object.freeze(['grants']),kernel:SEAT_KEYS,budgets:Object.freeze(['maxOps']),parallel:Object.freeze(['gear']),
|
|
411
|
+
supervisor:Object.freeze(['mode','pollIntervalMs','repos','stallMinutes','kernel','workers','landGate','frozenMinutes']),
|
|
412
|
+
delegation:Object.freeze(['asks','until','excludes','note']),asks:Object.freeze(['autoAcceptRecommended','excludes']),uat:Object.freeze(['maxConcurrent']),
|
|
413
|
+
specs:SPEC_FAMILIES,reconciler:Object.freeze(['enabled','profile','controllers']),debugLoop:Object.freeze(['interval','worktreeLimit']),
|
|
414
|
+
orca:ORCA_KEYS,roots:ROOT_KEYS,resources:RESOURCE_KEYS,release:RELEASE_KEYS,
|
|
415
|
+
});
|
|
416
|
+
export const SPEC_DEFAULTS=Object.freeze({unit:true,e2e:false});
|
|
417
|
+
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]]));}
|
|
418
|
+
/**
|
|
419
|
+
* config.yaml `debugLoop` (skills/starci, scripts/debug/digest): {interval?, worktreeLimit?}, each optional.
|
|
420
|
+
* interval <n>s | <n>m | <n>h — the cadence of the chat /loop that runs `starci debug digest`.
|
|
421
|
+
* worktreeLimit integer >= 1 — more registered worktrees than this in one repository is a core-watch alert.
|
|
422
|
+
*/
|
|
423
|
+
export const DURATION_PATTERN=/^(\d+)([smh])$/;
|
|
424
|
+
export const DEBUG_LOOP_DEFAULTS=Object.freeze({interval:'10m',worktreeLimit:40});
|
|
425
|
+
/** '10m' | '90s' | '1h' in milliseconds, or null for anything else (zero included). */
|
|
426
|
+
export function durationMs(text){const m=DURATION_PATTERN.exec(String(text??'').trim());const ms=m?Number(m[1])*{s:1000,m:60000,h:3600000}[m[2]]:0;return ms>0?ms:null;}
|
|
427
|
+
function validateDebugLoop(block){
|
|
428
|
+
if(block===null)return;
|
|
429
|
+
const bad=invalid('debugLoop');
|
|
430
|
+
if(!plain(block))bad(' must be {interval?: <n>s|<n>m|<n>h, worktreeLimit?: <integer >= 1>} or null.');
|
|
431
|
+
for(const key of Object.keys(block))if(!CONFIG_KEY_TREE.debugLoop.includes(key))bad(` has unknown key ${key} (allowed: ${CONFIG_KEY_TREE.debugLoop.join(', ')}).`);
|
|
432
|
+
if(block.interval!==undefined&&durationMs(block.interval)===null)bad('.interval must be <n>s, <n>m or <n>h with n >= 1 (e.g. 10m).');
|
|
433
|
+
if(block.worktreeLimit!==undefined&&!(Number.isInteger(block.worktreeLimit)&&block.worktreeLimit>=1))bad('.worktreeLimit must be an integer >= 1.');
|
|
434
|
+
}
|
|
435
|
+
/** The owner's debug-loop settings: {interval, intervalMs, worktreeLimit}; an absent block or key is the default. */
|
|
436
|
+
export function debugLoopSettings(config=loadConfig()){
|
|
437
|
+
validateDebugLoop(config?.debugLoop??null);
|
|
438
|
+
const block=plain(config?.debugLoop)?config.debugLoop:{};
|
|
439
|
+
const interval=block.interval??DEBUG_LOOP_DEFAULTS.interval;
|
|
440
|
+
return {interval,intervalMs:durationMs(interval),worktreeLimit:block.worktreeLimit??DEBUG_LOOP_DEFAULTS.worktreeLimit};
|
|
441
|
+
}
|
|
442
|
+
/** The owner config of `root` (config.yaml), else the shipped example. The installer seeds config.yaml (scripts/install/install.mjs seedConfig); this reader never writes. */
|
|
443
|
+
export function loadConfig(root=configRoot){
|
|
444
|
+
return readOwnerConfig(root)??readExample(root);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The owner's standing delegation of ask answers (config.yaml `delegation`), or null when absent or expired. */
|
|
448
|
+
export function activeDelegation(config=loadConfig(),now=Date.now()){const d=config?.delegation;if(!d||Date.parse(d.until)<=now){return null;}return {asks:d.asks,until:d.until,excludes:d.excludes??[],note:d.note??null};}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The owner's auto-accept policy for recommended asks (config.yaml `asks`): {autoAcceptRecommended,
|
|
452
|
+
* excludes, source}. An absent or null block is the default (off). `handover` is always in excludes.
|
|
453
|
+
* Validates first, so a malformed block throws rather than reading as on.
|
|
454
|
+
*/
|
|
455
|
+
export function askAutoAcceptPolicy(config=loadConfig()){
|
|
456
|
+
if(config?.asks!==undefined)validateAsks(config.asks);
|
|
457
|
+
const asks=plain(config?.asks)?config.asks:null;
|
|
458
|
+
const listed=asks&&Array.isArray(asks.excludes)?asks.excludes:ASKS_DEFAULTS.excludes;
|
|
459
|
+
const excludes=listed.includes('handover')?[...listed]:[...listed,'handover'];
|
|
460
|
+
return {autoAcceptRecommended:asks?.autoAcceptRecommended===true,excludes,source:asks?'asks':'default'};
|
|
461
|
+
}
|