@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.
Files changed (36) hide show
  1. package/bin/aiwg.mjs +55 -10
  2. package/dist/src/artifacts/cli.js +20 -8
  3. package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
  4. package/dist/src/cli/handlers/help.js +6 -5
  5. package/dist/src/cli/handlers/init.js +7 -2
  6. package/dist/src/cli/handlers/ralph.js +8 -8
  7. package/dist/src/cli/handlers/steward.js +9 -1
  8. package/dist/src/cli/handlers/use.js +4 -0
  9. package/dist/src/cli/handlers/version.js +13 -4
  10. package/dist/src/cli/services/deployment-verification.js +32 -4
  11. package/dist/src/cli/watch-service.js +43 -3
  12. package/dist/src/dataset/fortemi-capability.d.ts +70 -0
  13. package/dist/src/dataset/fortemi-capability.js +211 -0
  14. package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
  15. package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
  16. package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
  17. package/dist/src/dataset/fortemi-run-receipt.js +2 -19
  18. package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
  19. package/dist/src/dataset/fortemi-schema-path.js +24 -0
  20. package/dist/src/dataset/index.d.ts +1 -0
  21. package/dist/src/dataset/index.js +1 -0
  22. package/dist/src/extensions/project-local-doctor.js +15 -1
  23. package/dist/src/lint/cli.js +5 -1
  24. package/dist/src/lint/reporters.js +17 -0
  25. package/dist/src/lint/runner.js +160 -5
  26. package/dist/src/policy/authorization.js +1 -1
  27. package/dist/src/smiths/context-pipeline/workspace-context.js +102 -63
  28. package/package.json +1 -1
  29. package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
  30. package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
  31. package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
  32. package/tools/agents/deploy-agents.mjs +4 -0
  33. package/tools/agents/providers/base.mjs +160 -12
  34. package/tools/agents/providers/claude.mjs +61 -4
  35. package/tools/agents/providers/codex.mjs +9 -0
  36. 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(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'config', 'models.json'), label: 'AIWG defaults' }
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-6',
319
+ 'opus': 'claude-opus-4-7',
316
320
  'sonnet': 'claude-sonnet-4-6',
317
- 'haiku': 'claude-haiku-4-5-20251001',
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 (!m) return null;
2358
- const e = m[1].match(/^enforcement:\s*([A-Za-z]+)/m);
2359
- return e ? e[1].toLowerCase() : null;
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 names = onDemandRuleNames(onDemandFiles);
2458
- if (names.length === 0) {
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
- // Only transform if model overrides specified
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
- return content;
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
- const ruleFiles = [];
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
- const onDemandCount = writeOnDemandRuleIndex(rulesDestDir, listOnDemandRuleFiles(srcRoot), opts);
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
- const result = deploySkill(skill, target, { force, dryRun });
521
- if (result.action === 'deploy') totalDeployed++;
522
- else totalSkipped++;
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