@aiwg/cli 2026.9.9 → 2026.9.10
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/bin/aiwg.mjs +55 -10
- package/dist/src/artifacts/cli.js +20 -8
- package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
- package/dist/src/cli/handlers/help.js +6 -5
- package/dist/src/cli/handlers/init.js +7 -2
- package/dist/src/cli/handlers/ralph.js +8 -8
- package/dist/src/cli/handlers/steward.js +9 -1
- package/dist/src/cli/handlers/use.js +4 -0
- package/dist/src/cli/handlers/version.js +13 -4
- package/dist/src/cli/services/deployment-verification.js +32 -4
- package/dist/src/cli/watch-service.js +43 -3
- package/dist/src/dataset/fortemi-capability.d.ts +70 -0
- package/dist/src/dataset/fortemi-capability.js +211 -0
- package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
- package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
- package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
- package/dist/src/dataset/fortemi-run-receipt.js +2 -19
- package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
- package/dist/src/dataset/fortemi-schema-path.js +24 -0
- package/dist/src/dataset/index.d.ts +1 -0
- package/dist/src/dataset/index.js +1 -0
- package/dist/src/extensions/project-local-doctor.js +15 -1
- package/dist/src/lint/cli.js +5 -1
- package/dist/src/lint/reporters.js +17 -0
- package/dist/src/lint/runner.js +160 -5
- package/dist/src/policy/authorization.js +1 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +102 -63
- package/package.json +1 -1
- package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
- package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
- package/tools/agents/deploy-agents.mjs +4 -0
- package/tools/agents/providers/base.mjs +160 -12
- package/tools/agents/providers/claude.mjs +61 -4
- package/tools/agents/providers/codex.mjs +9 -0
- package/tools/skills/deploy-skills-codex.mjs +121 -7
|
@@ -281,10 +281,14 @@ export function extractFrameworkSlug(srcPath) {
|
|
|
281
281
|
* Priority: Project models.json > User ~/.config/aiwg/models.json > AIWG defaults
|
|
282
282
|
*/
|
|
283
283
|
export function loadModelConfig(srcRoot) {
|
|
284
|
+
// An addon/framework/bundle `--source` is not the AIWG root, so resolve the
|
|
285
|
+
// shipped defaults through the corpus root; otherwise addon deploys fell
|
|
286
|
+
// through to the hardcoded fallback and deployed bare aliases (#2563).
|
|
287
|
+
const aiwgRoot = (srcRoot && resolveAiwgRoot(srcRoot)) || srcRoot;
|
|
284
288
|
const locations = [
|
|
285
289
|
{ path: path.join(process.cwd(), 'models.json'), label: 'project' },
|
|
286
290
|
{ path: path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'aiwg', 'models.json'), label: 'user' },
|
|
287
|
-
{ path: path.join(
|
|
291
|
+
{ path: path.join(aiwgRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'config', 'models.json'), label: 'AIWG defaults' }
|
|
288
292
|
];
|
|
289
293
|
|
|
290
294
|
for (const loc of locations) {
|
|
@@ -302,9 +306,9 @@ export function loadModelConfig(srcRoot) {
|
|
|
302
306
|
// Fallback to hardcoded defaults if no config found
|
|
303
307
|
return {
|
|
304
308
|
claude: {
|
|
305
|
-
reasoning: { model: 'opus' },
|
|
306
|
-
coding: { model: 'sonnet' },
|
|
307
|
-
efficiency: { model: 'haiku' }
|
|
309
|
+
reasoning: { model: 'claude-opus-4-7' },
|
|
310
|
+
coding: { model: 'claude-sonnet-4-6' },
|
|
311
|
+
efficiency: { model: 'claude-haiku-4-5' }
|
|
308
312
|
},
|
|
309
313
|
factory: {
|
|
310
314
|
reasoning: { model: 'heavy' },
|
|
@@ -312,9 +316,9 @@ export function loadModelConfig(srcRoot) {
|
|
|
312
316
|
efficiency: { model: 'light' }
|
|
313
317
|
},
|
|
314
318
|
shorthand: {
|
|
315
|
-
'opus': 'claude-opus-4-
|
|
319
|
+
'opus': 'claude-opus-4-7',
|
|
316
320
|
'sonnet': 'claude-sonnet-4-6',
|
|
317
|
-
'haiku': 'claude-haiku-4-5
|
|
321
|
+
'haiku': 'claude-haiku-4-5',
|
|
318
322
|
'inherit': 'inherit'
|
|
319
323
|
},
|
|
320
324
|
claude_shorthand: {
|
|
@@ -2354,9 +2358,137 @@ export function getAddonSkillDirs(srcRoot, excludeAddons = []) {
|
|
|
2354
2358
|
*/
|
|
2355
2359
|
export function ruleEnforcementLevel(content) {
|
|
2356
2360
|
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
2357
|
-
if (
|
|
2358
|
-
|
|
2359
|
-
|
|
2361
|
+
if (m) {
|
|
2362
|
+
const e = m[1].match(/^enforcement:\s*([A-Za-z]+)/m);
|
|
2363
|
+
if (e) return e[1].toLowerCase();
|
|
2364
|
+
}
|
|
2365
|
+
// Rules without frontmatter declare the level in the body header.
|
|
2366
|
+
const body = content.match(/^\*\*Enforcement Level\*\*:\s*([A-Za-z]+)/m);
|
|
2367
|
+
return body ? body[1].toLowerCase() : null;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/**
|
|
2371
|
+
* Default inline rule budget (#2562). Every inlined rule is loaded into every
|
|
2372
|
+
* session AND every subagent dispatch, so the always-on set must leave room
|
|
2373
|
+
* for the agent definition, the system prompt, and the task itself inside the
|
|
2374
|
+
* standard 200K window. 64K tokens (~256 KB) keeps a full CRITICAL+HIGH
|
|
2375
|
+
* deployment dispatchable; `AIWG_RULES_INLINE_BUDGET_TOKENS=0` disables the
|
|
2376
|
+
* budget, any other value overrides it.
|
|
2377
|
+
*/
|
|
2378
|
+
export const DEFAULT_RULES_INLINE_BUDGET_TOKENS = 64_000;
|
|
2379
|
+
const RULE_CHARS_PER_TOKEN = 4;
|
|
2380
|
+
|
|
2381
|
+
export function resolveRulesInlineBudgetTokens(env = process.env) {
|
|
2382
|
+
const raw = env.AIWG_RULES_INLINE_BUDGET_TOKENS;
|
|
2383
|
+
if (raw === undefined || raw === '') return DEFAULT_RULES_INLINE_BUDGET_TOKENS;
|
|
2384
|
+
const parsed = Number(raw);
|
|
2385
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_RULES_INLINE_BUDGET_TOKENS;
|
|
2386
|
+
return parsed;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/** Sidecar recording which rules the inline budget moved on demand (#2562). */
|
|
2390
|
+
export const RULE_BUDGET_SIDECAR = '.aiwg-rules-budget.json';
|
|
2391
|
+
|
|
2392
|
+
export function readRuleBudgetSidecar(destDir) {
|
|
2393
|
+
try {
|
|
2394
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(destDir, RULE_BUDGET_SIDECAR), 'utf8'));
|
|
2395
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.demoted)) return null;
|
|
2396
|
+
return { budgetTokens: Number(parsed.budgetTokens) || 0, demoted: parsed.demoted.map(String) };
|
|
2397
|
+
} catch {
|
|
2398
|
+
return null;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Keep a provider rule directory under the inline budget (#2562).
|
|
2404
|
+
*
|
|
2405
|
+
* Rules reach the directory over several deploy passes (the framework pass,
|
|
2406
|
+
* then one per addon), so the budget is enforced against what is actually on
|
|
2407
|
+
* disk after each pass rather than against any single pass's file list. When
|
|
2408
|
+
* the inlined set exceeds the budget, the largest HIGH rules (then unlabelled
|
|
2409
|
+
* ones; never CRITICAL or the indexes) are removed from the directory and
|
|
2410
|
+
* recorded in the sidecar; the next pass skips re-deploying them and the
|
|
2411
|
+
* on-demand index lists them as binding rules fetched via `aiwg show rule`.
|
|
2412
|
+
* A changed budget invalidates the sidecar so the set is recomputed.
|
|
2413
|
+
*
|
|
2414
|
+
* Returns `{ demoted, removed }` — all demoted stems (sidecar state) and the
|
|
2415
|
+
* stems removed by this call.
|
|
2416
|
+
*/
|
|
2417
|
+
export function reconcileInlineRuleBudget(destDir, budgetTokens = resolveRulesInlineBudgetTokens(), opts = {}) {
|
|
2418
|
+
const sidecarPath = path.join(destDir, RULE_BUDGET_SIDECAR);
|
|
2419
|
+
if (!budgetTokens || budgetTokens <= 0) {
|
|
2420
|
+
if (fs.existsSync(sidecarPath) && !opts.dryRun) fs.rmSync(sidecarPath, { force: true });
|
|
2421
|
+
return { demoted: [], removed: [] };
|
|
2422
|
+
}
|
|
2423
|
+
const previous = readRuleBudgetSidecar(destDir);
|
|
2424
|
+
const carried = previous && previous.budgetTokens === budgetTokens ? previous.demoted : [];
|
|
2425
|
+
let files = [];
|
|
2426
|
+
try {
|
|
2427
|
+
files = fs.readdirSync(destDir, { withFileTypes: true })
|
|
2428
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
2429
|
+
.map((entry) => path.join(destDir, entry.name))
|
|
2430
|
+
.filter((file) => {
|
|
2431
|
+
const base = path.basename(file);
|
|
2432
|
+
if (base === 'RULES-INDEX.md' || base === 'RULES-ONDEMAND.md') return false;
|
|
2433
|
+
try { return MANAGED_MARKER_RE.test(fs.readFileSync(file, 'utf8')); } catch { return false; }
|
|
2434
|
+
});
|
|
2435
|
+
} catch {
|
|
2436
|
+
return { demoted: carried, removed: [] };
|
|
2437
|
+
}
|
|
2438
|
+
const { demoted } = applyInlineRuleBudget(files, budgetTokens);
|
|
2439
|
+
const removed = demoted.map((file) => artifactStem(path.basename(file)));
|
|
2440
|
+
if (!opts.dryRun) {
|
|
2441
|
+
for (const file of demoted) fs.rmSync(file, { force: true });
|
|
2442
|
+
}
|
|
2443
|
+
const all = [...new Set([...carried, ...removed])].sort();
|
|
2444
|
+
if (!opts.dryRun) {
|
|
2445
|
+
if (all.length > 0) {
|
|
2446
|
+
fs.writeFileSync(sidecarPath, `${JSON.stringify({ budgetTokens, demoted: all }, null, 2)}\n`, 'utf8');
|
|
2447
|
+
} else if (fs.existsSync(sidecarPath)) {
|
|
2448
|
+
fs.rmSync(sidecarPath, { force: true });
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
return { demoted: all, removed };
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/**
|
|
2455
|
+
* Fit an always-on rule set under the inline budget (#2562).
|
|
2456
|
+
*
|
|
2457
|
+
* CRITICAL rules and the generated indexes are never demoted. When the
|
|
2458
|
+
* remaining set still exceeds the budget, HIGH rules are demoted largest-first
|
|
2459
|
+
* (fewest rules leave the always-on set), then rules with no declared level.
|
|
2460
|
+
* Demoted rules stay reachable via `aiwg show rule <name>` and are listed in
|
|
2461
|
+
* RULES-ONDEMAND.md under their own heading. Returns `{ inline, demoted }`
|
|
2462
|
+
* with the original order preserved for `inline`.
|
|
2463
|
+
*/
|
|
2464
|
+
export function applyInlineRuleBudget(ruleFiles, budgetTokens = resolveRulesInlineBudgetTokens()) {
|
|
2465
|
+
const files = [...(ruleFiles || [])];
|
|
2466
|
+
if (!budgetTokens || budgetTokens <= 0) return { inline: files, demoted: [] };
|
|
2467
|
+
const sized = files.map((file) => {
|
|
2468
|
+
const base = path.basename(file);
|
|
2469
|
+
let content = '';
|
|
2470
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { /* unreadable → keep inline */ }
|
|
2471
|
+
const level = base === 'RULES-INDEX.md' || base === 'RULES-ONDEMAND.md' ? 'critical' : ruleEnforcementLevel(content);
|
|
2472
|
+
return { file, tokens: Math.ceil(Buffer.byteLength(content, 'utf8') / RULE_CHARS_PER_TOKEN), level };
|
|
2473
|
+
});
|
|
2474
|
+
let total = sized.reduce((sum, item) => sum + item.tokens, 0);
|
|
2475
|
+
if (total <= budgetTokens) return { inline: files, demoted: [] };
|
|
2476
|
+
const demotable = sized
|
|
2477
|
+
.filter((item) => item.level !== 'critical')
|
|
2478
|
+
.sort((a, b) => {
|
|
2479
|
+
const rank = (level) => (level === 'high' ? 0 : level === null ? 1 : 2);
|
|
2480
|
+
return rank(a.level) - rank(b.level) || b.tokens - a.tokens;
|
|
2481
|
+
});
|
|
2482
|
+
const demoted = new Set();
|
|
2483
|
+
for (const item of demotable) {
|
|
2484
|
+
if (total <= budgetTokens) break;
|
|
2485
|
+
demoted.add(item.file);
|
|
2486
|
+
total -= item.tokens;
|
|
2487
|
+
}
|
|
2488
|
+
return {
|
|
2489
|
+
inline: files.filter((file) => !demoted.has(file)),
|
|
2490
|
+
demoted: files.filter((file) => demoted.has(file)),
|
|
2491
|
+
};
|
|
2360
2492
|
}
|
|
2361
2493
|
|
|
2362
2494
|
/**
|
|
@@ -2454,8 +2586,9 @@ export function renderOnDemandRuleSection(onDemandFiles, opts = {}) {
|
|
|
2454
2586
|
*/
|
|
2455
2587
|
export function writeOnDemandRuleIndex(destDir, onDemandFiles, opts = {}) {
|
|
2456
2588
|
const indexPath = path.join(destDir, 'RULES-ONDEMAND.md');
|
|
2457
|
-
const
|
|
2458
|
-
|
|
2589
|
+
const demotedNames = [...new Set([...onDemandRuleNames(opts.demotedFiles || []), ...(opts.demotedNames || [])])].sort();
|
|
2590
|
+
const names = onDemandRuleNames(onDemandFiles, demotedNames);
|
|
2591
|
+
if (names.length === 0 && demotedNames.length === 0) {
|
|
2459
2592
|
try {
|
|
2460
2593
|
if (fs.existsSync(indexPath) && !opts.dryRun) fs.rmSync(indexPath);
|
|
2461
2594
|
} catch { /* ignore */ }
|
|
@@ -2475,10 +2608,25 @@ export function writeOnDemandRuleIndex(destDir, onDemandFiles, opts = {}) {
|
|
|
2475
2608
|
...names.map((n) => `- \`${n}\` — \`aiwg show rule ${n}\``),
|
|
2476
2609
|
'',
|
|
2477
2610
|
];
|
|
2611
|
+
if (demotedNames.length > 0) {
|
|
2612
|
+
// HIGH rules that did not fit the inline budget (#2562). They remain
|
|
2613
|
+
// binding; they are fetched on demand so subagent dispatch keeps working.
|
|
2614
|
+
lines.push(
|
|
2615
|
+
'## Binding rules moved on demand to fit the inline budget',
|
|
2616
|
+
'',
|
|
2617
|
+
'These HIGH-enforcement rules still apply in full. They are not inlined because the',
|
|
2618
|
+
`always-on rule set would otherwise exceed the ${(opts.inlineBudgetTokens ?? DEFAULT_RULES_INLINE_BUDGET_TOKENS).toLocaleString()}-token inline budget`,
|
|
2619
|
+
'(AIWG_RULES_INLINE_BUDGET_TOKENS) and break subagent dispatch with "Prompt is too long".',
|
|
2620
|
+
'Fetch any of them before work they govern:',
|
|
2621
|
+
'',
|
|
2622
|
+
...demotedNames.map((n) => `- \`${n}\` — \`aiwg show rule ${n}\``),
|
|
2623
|
+
'',
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2478
2626
|
let content = lines.join('\n');
|
|
2479
2627
|
content = addManagedMarker(content, opts.deployVersion || 'unknown', opts.deploySource || 'bundled');
|
|
2480
2628
|
if (!opts.dryRun) fs.writeFileSync(indexPath, content, 'utf8');
|
|
2481
|
-
return names.length;
|
|
2629
|
+
return names.length + demotedNames.length;
|
|
2482
2630
|
}
|
|
2483
2631
|
|
|
2484
2632
|
export function getAddonRuleFiles(srcRoot, excludeAddons = []) {
|
|
@@ -49,6 +49,9 @@ import {
|
|
|
49
49
|
getAddonRuleFiles,
|
|
50
50
|
listOnDemandRuleFiles,
|
|
51
51
|
writeOnDemandRuleIndex,
|
|
52
|
+
readRuleBudgetSidecar,
|
|
53
|
+
reconcileInlineRuleBudget,
|
|
54
|
+
resolveRulesInlineBudgetTokens,
|
|
52
55
|
assembleRulesIndex,
|
|
53
56
|
normalizeDeploymentMode,
|
|
54
57
|
collectFrameworkArtifacts,
|
|
@@ -162,7 +165,7 @@ export function mapModel(shorthand, modelCfg, modelsConfig) {
|
|
|
162
165
|
export function transformAgent(srcPath, content, opts) {
|
|
163
166
|
const { reasoningModel, codingModel, efficiencyModel } = opts;
|
|
164
167
|
|
|
165
|
-
//
|
|
168
|
+
// Explicit overrides re-map every classified model, pinned or not.
|
|
166
169
|
if (reasoningModel || codingModel || efficiencyModel) {
|
|
167
170
|
const models = {
|
|
168
171
|
reasoning: reasoningModel || 'opus',
|
|
@@ -172,7 +175,40 @@ export function transformAgent(srcPath, content, opts) {
|
|
|
172
175
|
return replaceModelFrontmatter(content, models);
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
|
|
178
|
+
// Default compilation (#2563): a bare alias in source frontmatter is
|
|
179
|
+
// provider-neutral intent, not a deployable pin. Deployed Claude agents get
|
|
180
|
+
// the pinned variant from the models.json `claude` tiers so subagent
|
|
181
|
+
// dispatch never inherits a 1M-context parent (#1442). Already-pinned
|
|
182
|
+
// values, `inherit`, and explicit `[1m]` opt-ins are left untouched.
|
|
183
|
+
const tiers = opts.modelsConfig?.claude;
|
|
184
|
+
if (!tiers) return content;
|
|
185
|
+
return pinBareModelAlias(content, {
|
|
186
|
+
reasoning: tiers.reasoning?.model,
|
|
187
|
+
coding: tiers.coding?.model,
|
|
188
|
+
efficiency: tiers.efficiency?.model,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const BARE_MODEL_ALIAS_ROLE = { opus: 'reasoning', sonnet: 'coding', haiku: 'efficiency' };
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Replace a bare `model: opus|sonnet|haiku` frontmatter alias with the pinned
|
|
196
|
+
* variant for its role. Anything else (pinned ids, `inherit`, `sonnet[1m]`,
|
|
197
|
+
* placeholders) is returned unchanged.
|
|
198
|
+
*/
|
|
199
|
+
export function pinBareModelAlias(content, pinned) {
|
|
200
|
+
if (!content.startsWith('---')) return content;
|
|
201
|
+
const fmEnd = content.indexOf('\n---', 3);
|
|
202
|
+
if (fmEnd === -1) return content;
|
|
203
|
+
const header = content.slice(0, fmEnd + 4);
|
|
204
|
+
const body = content.slice(fmEnd + 4);
|
|
205
|
+
const match = header.match(/^model:[ \t]*['"]?([A-Za-z]+)['"]?[ \t]*$/m);
|
|
206
|
+
if (!match) return content;
|
|
207
|
+
const alias = match[1].toLowerCase();
|
|
208
|
+
const role = BARE_MODEL_ALIAS_ROLE[alias];
|
|
209
|
+
const target = role ? pinned?.[role] : null;
|
|
210
|
+
if (!target || target === alias) return content;
|
|
211
|
+
return header.replace(match[0], `model: ${target}`) + body;
|
|
176
212
|
}
|
|
177
213
|
|
|
178
214
|
/**
|
|
@@ -510,7 +546,7 @@ export async function deploy(opts) {
|
|
|
510
546
|
const agentFiles = [];
|
|
511
547
|
const commandFiles = [];
|
|
512
548
|
const skillDirs = [];
|
|
513
|
-
|
|
549
|
+
let ruleFiles = [];
|
|
514
550
|
|
|
515
551
|
// Check for addon-style directory structure (direct agents/, commands/, skills/ subdirs)
|
|
516
552
|
// This handles deployment when --source points to an addon directory
|
|
@@ -628,7 +664,17 @@ export async function deploy(opts) {
|
|
|
628
664
|
pruneStaleAiwgSkills(kernelDestDir, computeAllKernelNames(srcRoot), opts);
|
|
629
665
|
}
|
|
630
666
|
|
|
667
|
+
const inlineBudgetTokens = resolveRulesInlineBudgetTokens();
|
|
668
|
+
let demotedRuleNames = [];
|
|
631
669
|
if (shouldDeployRules || rulesOnly) {
|
|
670
|
+
// Inline budget (#2562): rules a previous pass already moved on demand are
|
|
671
|
+
// not re-inlined; the directory is reconciled against the budget after
|
|
672
|
+
// this pass deploys (see below).
|
|
673
|
+
const sidecar = readRuleBudgetSidecar(path.join(target, paths.rules));
|
|
674
|
+
if (sidecar && sidecar.budgetTokens === inlineBudgetTokens && sidecar.demoted.length > 0) {
|
|
675
|
+
const skip = new Set(sidecar.demoted);
|
|
676
|
+
ruleFiles = ruleFiles.filter((f) => !skip.has(path.basename(f).replace(/\.md$/, '')));
|
|
677
|
+
}
|
|
632
678
|
// Try assembled rules index (combines all component indexes)
|
|
633
679
|
const assembled = assembleRulesIndex(srcRoot);
|
|
634
680
|
if (assembled) {
|
|
@@ -661,7 +707,17 @@ export async function deploy(opts) {
|
|
|
661
707
|
// On-demand index (#1673): list the MEDIUM/LOW rules that were tier-gated
|
|
662
708
|
// out of the always-on set so agents can fetch them via `aiwg show rule`.
|
|
663
709
|
const rulesDestDir = path.join(target, paths.rules);
|
|
664
|
-
|
|
710
|
+
// Keep the always-on directory small enough that a subagent dispatch
|
|
711
|
+
// still fits (#2562). Demoted HIGH rules remain binding and are listed
|
|
712
|
+
// in RULES-ONDEMAND.md with their fetch hint.
|
|
713
|
+
const budget = reconcileInlineRuleBudget(rulesDestDir, inlineBudgetTokens, opts);
|
|
714
|
+
demotedRuleNames = budget.demoted;
|
|
715
|
+
if (budget.removed.length > 0 && !opts.quiet) {
|
|
716
|
+
console.log(` Inline rule budget: ${budget.removed.length} HIGH rule(s) moved on demand to fit ${inlineBudgetTokens.toLocaleString()} tokens: ${budget.removed.join(', ')} (see RULES-ONDEMAND.md)`);
|
|
717
|
+
}
|
|
718
|
+
const onDemandCount = writeOnDemandRuleIndex(rulesDestDir, listOnDemandRuleFiles(srcRoot), {
|
|
719
|
+
...opts, demotedNames: demotedRuleNames, inlineBudgetTokens,
|
|
720
|
+
});
|
|
665
721
|
if (verbose && onDemandCount > 0) {
|
|
666
722
|
console.log(` On-demand rules (not inlined): ${onDemandCount} → RULES-ONDEMAND.md`);
|
|
667
723
|
}
|
|
@@ -700,6 +756,7 @@ export default {
|
|
|
700
756
|
transformAgent,
|
|
701
757
|
transformCommand,
|
|
702
758
|
mapModel,
|
|
759
|
+
pinBareModelAlias,
|
|
703
760
|
deployAgents,
|
|
704
761
|
deployCommands,
|
|
705
762
|
deploySkills,
|
|
@@ -263,6 +263,9 @@ export async function deployCommands(targetDir, srcRoot, opts) {
|
|
|
263
263
|
if (opts.force) args.push('--force');
|
|
264
264
|
if (opts.mode) args.push('--mode', opts.mode);
|
|
265
265
|
if (opts.copyStandardSkills === true) args.push('--copy-all');
|
|
266
|
+
// A copy-all the deployer forced on the caller's behalf (project-local
|
|
267
|
+
// bundles) still honors the startup listing cap (#2561).
|
|
268
|
+
if (opts.listingBudget === true) args.push('--listing-budget');
|
|
266
269
|
|
|
267
270
|
const child = spawn('node', [scriptPath, ...args], {
|
|
268
271
|
stdio: 'inherit',
|
|
@@ -302,10 +305,16 @@ export async function deploySkills(targetDir, srcRoot, opts) {
|
|
|
302
305
|
|
|
303
306
|
await new Promise((resolve, reject) => {
|
|
304
307
|
const args = ['--source', srcRoot, '--target', crossAgentSkillsDir];
|
|
308
|
+
// Overflow past Codex's startup listing cap lands here instead of being
|
|
309
|
+
// deployed over the cap (#2561); the index still reaches these skills.
|
|
310
|
+
args.push('--standard-target', path.join(targetDir, ...paths.skills.split('/').filter(Boolean)));
|
|
305
311
|
if (opts.dryRun) args.push('--dry-run');
|
|
306
312
|
if (opts.force) args.push('--force');
|
|
307
313
|
if (opts.mode) args.push('--mode', opts.mode);
|
|
308
314
|
if (opts.copyStandardSkills === true) args.push('--copy-all');
|
|
315
|
+
// A copy-all the deployer forced on the caller's behalf (project-local
|
|
316
|
+
// bundles) still honors the startup listing cap (#2561).
|
|
317
|
+
if (opts.listingBudget === true) args.push('--listing-budget');
|
|
309
318
|
|
|
310
319
|
const child = spawn('node', [scriptPath, ...args], {
|
|
311
320
|
stdio: 'inherit',
|
|
@@ -44,6 +44,66 @@ const MAX_NAME_LENGTH = 100;
|
|
|
44
44
|
const MAX_DESCRIPTION_LENGTH = 500;
|
|
45
45
|
const LEGACY_RENAMED_SKILLS = new Set(['aiwg-mcp']);
|
|
46
46
|
|
|
47
|
+
/** Codex's built-in startup skill-listing cap, in characters (see doctor). */
|
|
48
|
+
export const CODEX_LISTING_CHAR_CAP = 8000;
|
|
49
|
+
|
|
50
|
+
export function resolveCodexListingCap(env = process.env) {
|
|
51
|
+
const raw = env.AIWG_CODEX_LISTING_CAP;
|
|
52
|
+
if (raw === undefined || raw === '') return CODEX_LISTING_CHAR_CAP;
|
|
53
|
+
const parsed = Number(raw);
|
|
54
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : CODEX_LISTING_CHAR_CAP;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Chars one skill contributes to Codex's startup listing ("- name: desc\n"). */
|
|
58
|
+
export function listingEntryChars(name, description) {
|
|
59
|
+
return String(name ?? '').length + String(description ?? '').length + 5;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deployedSkillListing(skillDir) {
|
|
63
|
+
let raw;
|
|
64
|
+
try { raw = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); } catch { return null; }
|
|
65
|
+
const fm = raw.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
66
|
+
if (!fm) return null;
|
|
67
|
+
const name = stripWrappingQuotes(fm.match(/^\s*name:\s*(.+?)\s*$/m)?.[1] ?? '');
|
|
68
|
+
const description = stripWrappingQuotes(fm.match(/^\s*description:\s*(.+?)\s*$/m)?.[1] ?? '');
|
|
69
|
+
return name ? { name, description } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decide which planned skills must leave the startup-visible kernel dir so
|
|
74
|
+
* the projected listing stays under the cap (#2561). Existing kernel skills
|
|
75
|
+
* that this run does not replace count toward the projection. Kernel-flagged
|
|
76
|
+
* skills are never demoted; the rest are demoted largest-entry-first so the
|
|
77
|
+
* fewest skills lose startup visibility. Returns the demoted set plus the
|
|
78
|
+
* projected listing sizes for reporting.
|
|
79
|
+
*/
|
|
80
|
+
export function planCodexListingBudget(planned, kernelDir, cap) {
|
|
81
|
+
const incomingNames = new Set(planned.map((item) => item.skill.name));
|
|
82
|
+
let projected = 0;
|
|
83
|
+
let existing = 0;
|
|
84
|
+
if (fs.existsSync(kernelDir)) {
|
|
85
|
+
for (const entry of fs.readdirSync(kernelDir, { withFileTypes: true })) {
|
|
86
|
+
if (!entry.isDirectory()) continue;
|
|
87
|
+
const listing = deployedSkillListing(path.join(kernelDir, entry.name));
|
|
88
|
+
if (!listing || incomingNames.has(listing.name) || incomingNames.has(entry.name)) continue;
|
|
89
|
+
existing += listingEntryChars(listing.name, listing.description);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
projected = existing + planned.reduce((sum, item) => sum + listingEntryChars(item.skill.name, item.skill.description), 0);
|
|
93
|
+
const before = projected;
|
|
94
|
+
const demoted = new Set();
|
|
95
|
+
if (!cap || projected <= cap) return { demoted, before, after: projected, cap };
|
|
96
|
+
const demotable = planned
|
|
97
|
+
.filter((item) => !item.kernel)
|
|
98
|
+
.sort((a, b) => listingEntryChars(b.skill.name, b.skill.description) - listingEntryChars(a.skill.name, a.skill.description));
|
|
99
|
+
for (const item of demotable) {
|
|
100
|
+
if (projected <= cap) break;
|
|
101
|
+
demoted.add(item);
|
|
102
|
+
projected -= listingEntryChars(item.skill.name, item.skill.description);
|
|
103
|
+
}
|
|
104
|
+
return { demoted, before, after: projected, cap };
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
function parseArgs() {
|
|
48
108
|
const args = process.argv.slice(2);
|
|
49
109
|
const cfg = {
|
|
@@ -53,6 +113,17 @@ function parseArgs() {
|
|
|
53
113
|
dryRun: false,
|
|
54
114
|
force: false,
|
|
55
115
|
copyStandardSkills: false,
|
|
116
|
+
// Codex lists every skill under the kernel dir at startup and truncates the
|
|
117
|
+
// listing at 8,000 chars. Skills that would push the listing past the cap
|
|
118
|
+
// are placed on the standard tier instead (#2561). The budget is active
|
|
119
|
+
// only when the caller names a standard tier (`--standard-target`, set by
|
|
120
|
+
// the codex provider for project deploys) or passes `--listing-cap`
|
|
121
|
+
// explicitly; standalone/legacy home-dir invocations keep deploying every
|
|
122
|
+
// selected skill to the target. 0 disables the budget.
|
|
123
|
+
listingCap: resolveCodexListingCap(),
|
|
124
|
+
listingCapExplicit: false,
|
|
125
|
+
listingBudget: false,
|
|
126
|
+
standardTarget: null,
|
|
56
127
|
};
|
|
57
128
|
|
|
58
129
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -63,6 +134,9 @@ function parseArgs() {
|
|
|
63
134
|
else if (a === '--dry-run') cfg.dryRun = true;
|
|
64
135
|
else if (a === '--force') cfg.force = true;
|
|
65
136
|
else if (a === '--copy-all' || a === '--copy-standard-skills') cfg.copyStandardSkills = true;
|
|
137
|
+
else if (a === '--listing-cap' && args[i + 1]) { cfg.listingCap = Math.max(0, Number(args[++i]) || 0); cfg.listingCapExplicit = true; }
|
|
138
|
+
else if (a === '--standard-target' && args[i + 1]) cfg.standardTarget = path.resolve(args[++i]);
|
|
139
|
+
else if (a === '--listing-budget') cfg.listingBudget = true;
|
|
66
140
|
}
|
|
67
141
|
|
|
68
142
|
cfg.mode = normalizeDeploymentMode(cfg.mode);
|
|
@@ -492,6 +566,9 @@ function isFullAiwgSourceRoot(srcRoot) {
|
|
|
492
566
|
}
|
|
493
567
|
}
|
|
494
568
|
|
|
569
|
+
// Plan every skill first so the listing budget can be judged across the
|
|
570
|
+
// whole run rather than per source directory (#2561).
|
|
571
|
+
const planned = [];
|
|
495
572
|
for (const { dir, label } of skillDirs) {
|
|
496
573
|
const found = findSkillDirs(dir);
|
|
497
574
|
if (found.length === 0) continue;
|
|
@@ -503,10 +580,6 @@ function isFullAiwgSourceRoot(srcRoot) {
|
|
|
503
580
|
: found.filter(s => isKernelSkill(s));
|
|
504
581
|
if (skills.length === 0) continue;
|
|
505
582
|
|
|
506
|
-
for (const s of skills) desiredNames.add(path.basename(s));
|
|
507
|
-
|
|
508
|
-
console.log(`\n${label} (${skills.length} skills):`);
|
|
509
|
-
|
|
510
583
|
for (const skillDir of skills) {
|
|
511
584
|
const skill = transformToCodexSkill(skillDir);
|
|
512
585
|
if (!skill) {
|
|
@@ -516,11 +589,52 @@ function isFullAiwgSourceRoot(srcRoot) {
|
|
|
516
589
|
totalSkipped++;
|
|
517
590
|
continue;
|
|
518
591
|
}
|
|
592
|
+
planned.push({ skill, skillDir, label, kernel: isKernelSkill(skillDir) });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
519
595
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
596
|
+
const standardTarget = cfg.standardTarget
|
|
597
|
+
|| path.join(path.dirname(path.dirname(target)), '.codex', '.aiwg', 'skills');
|
|
598
|
+
// An operator `--copy-all` asks for every standard skill in the listing;
|
|
599
|
+
// honoring the cap there would silently undo the request and break the
|
|
600
|
+
// documented kernel/standard contract. `--listing-budget` is how the caller
|
|
601
|
+
// says the copy-all was its own doing (project-local bundles force it so
|
|
602
|
+
// their skills are reachable at all) and the cap still applies (#2561).
|
|
603
|
+
const budgetActive = cfg.listingCapExplicit
|
|
604
|
+
|| cfg.listingBudget
|
|
605
|
+
|| (Boolean(cfg.standardTarget) && !copyStandardSkills);
|
|
606
|
+
const budget = planCodexListingBudget(planned, target, budgetActive ? cfg.listingCap : 0);
|
|
607
|
+
for (const item of planned) {
|
|
608
|
+
if (budget.demoted.has(item)) continue;
|
|
609
|
+
desiredNames.add(path.basename(item.skillDir));
|
|
610
|
+
desiredNames.add(item.skill.name);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let currentLabel = null;
|
|
614
|
+
for (const item of planned) {
|
|
615
|
+
if (item.label !== currentLabel) {
|
|
616
|
+
currentLabel = item.label;
|
|
617
|
+
console.log(`\n${item.label} (${planned.filter((p) => p.label === item.label).length} skills):`);
|
|
523
618
|
}
|
|
619
|
+
const demoted = budget.demoted.has(item);
|
|
620
|
+
const result = deploySkill(item.skill, demoted ? standardTarget : target, { force, dryRun });
|
|
621
|
+
if (demoted) console.log(` ↳ standard tier (${path.relative(process.cwd(), standardTarget) || standardTarget}) — startup listing budget`);
|
|
622
|
+
if (result.action === 'deploy') totalDeployed++;
|
|
623
|
+
else totalSkipped++;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (budget.demoted.size > 0) {
|
|
627
|
+
const names = [...budget.demoted].map((item) => item.skill.name).join(', ');
|
|
628
|
+
console.log(
|
|
629
|
+
`\nCodex listing budget: ${budget.demoted.size} skill(s) placed on the standard tier so the startup listing ` +
|
|
630
|
+
`stays at ~${budget.after.toLocaleString()} of ${budget.cap.toLocaleString()} chars (was ~${budget.before.toLocaleString()}): ${names}. ` +
|
|
631
|
+
'They remain reachable through `aiwg discover` / `aiwg show`. Override with --listing-cap <chars> (0 disables) or AIWG_CODEX_LISTING_CAP.',
|
|
632
|
+
);
|
|
633
|
+
} else if (budgetActive && budget.cap && budget.after > budget.cap) {
|
|
634
|
+
console.log(
|
|
635
|
+
`\nWarning: kernel skills alone estimate ~${budget.after.toLocaleString()} chars, above Codex's ${budget.cap.toLocaleString()}-char listing cap; ` +
|
|
636
|
+
'nothing non-kernel is left to demote.',
|
|
637
|
+
);
|
|
524
638
|
}
|
|
525
639
|
|
|
526
640
|
// Post-deploy cleanup: remove AIWG-managed skills that are stale for the
|