@aiwg/cli 2026.9.7 → 2026.9.9
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/dist/src/artifacts/index-builder.js +43 -1
- package/dist/src/artifacts/query-engine.js +7 -0
- package/dist/src/cli/handlers/installation.js +102 -2
- package/dist/src/cli/handlers/mc.js +87 -17
- package/dist/src/cli/handlers/refresh.js +67 -7
- package/dist/src/cli/handlers/repo-access.js +155 -4
- package/dist/src/cli/handlers/setup.js +5 -5
- package/dist/src/cli/handlers/steward.js +30 -1
- package/dist/src/cli/handlers/use.js +9 -4
- package/dist/src/cli/handlers/version.js +40 -14
- package/dist/src/cli/handlers/workspace-context.js +8 -0
- package/dist/src/cli/services/deployment-verification.js +39 -6
- package/dist/src/config/aiwg-config.js +95 -3
- package/dist/src/config/cli.js +16 -1
- package/dist/src/config/gitignore.js +5 -0
- package/dist/src/extensions/claude-hooks-installer.js +22 -6
- package/dist/src/lint/runner.js +138 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +51 -1
- package/package.json +1 -1
- package/tools/agents/deploy-agents.mjs +4 -0
- package/tools/agents/providers/base.mjs +101 -4
|
@@ -712,6 +712,33 @@ export function parseFlowDoc(content, filePath) {
|
|
|
712
712
|
searchTerms: [...searchTerms],
|
|
713
713
|
};
|
|
714
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Best-effort absolute path to the AIWG install root, for error text that must
|
|
717
|
+
* name where the framework graph can actually be built (#2530).
|
|
718
|
+
*/
|
|
719
|
+
function resolveInstallRootHint() {
|
|
720
|
+
try {
|
|
721
|
+
// The running module lives under the install root; walk up to the package.
|
|
722
|
+
let dir = path.dirname(new URL(import.meta.url).pathname);
|
|
723
|
+
for (let i = 0; i < 10; i += 1) {
|
|
724
|
+
const pkg = path.join(dir, 'package.json');
|
|
725
|
+
if (fs.existsSync(pkg)) {
|
|
726
|
+
try {
|
|
727
|
+
const content = JSON.parse(fs.readFileSync(pkg, 'utf8'));
|
|
728
|
+
if (content.name === 'aiwg' || content.name === '@aiwg/cli')
|
|
729
|
+
return dir;
|
|
730
|
+
}
|
|
731
|
+
catch { /* keep walking */ }
|
|
732
|
+
}
|
|
733
|
+
const parent = path.dirname(dir);
|
|
734
|
+
if (parent === dir)
|
|
735
|
+
break;
|
|
736
|
+
dir = parent;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch { /* fall through */ }
|
|
740
|
+
return '<aiwg install root>';
|
|
741
|
+
}
|
|
715
742
|
export async function buildIndex(cwd, options = {}) {
|
|
716
743
|
const { force = false, verbose = false, scope, outputDir, graph, explicit = true } = options;
|
|
717
744
|
const startTime = Date.now();
|
|
@@ -748,7 +775,22 @@ export async function buildIndex(cwd, options = {}) {
|
|
|
748
775
|
return;
|
|
749
776
|
}
|
|
750
777
|
console.error(`Error: No scan directories found: ${scanDirs.join(', ')}`);
|
|
751
|
-
|
|
778
|
+
// The framework graph scans the AIWG corpus, which only exists at the install
|
|
779
|
+
// root — never in a consumer project. Saying "run from a project with the
|
|
780
|
+
// required directories" sends the operator looking in the wrong place (#2530).
|
|
781
|
+
if (graph === 'framework') {
|
|
782
|
+
const installRoot = resolveInstallRootHint();
|
|
783
|
+
console.log('The framework graph indexes the AIWG corpus and can only be built at the');
|
|
784
|
+
console.log('install root, not in a consumer project. Build it there, then sync here:');
|
|
785
|
+
console.log('');
|
|
786
|
+
console.log(` cd ${installRoot} && aiwg index build --graph framework --force`);
|
|
787
|
+
console.log(` cd ${cwd} && aiwg index sync --backend fortemi-core --graph framework`);
|
|
788
|
+
console.log('');
|
|
789
|
+
console.log("As an immediate workaround, discovery also works with '--backend local'.");
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
console.log('Run this command from a project with the required directories.');
|
|
793
|
+
}
|
|
752
794
|
process.exit(1);
|
|
753
795
|
}
|
|
754
796
|
// Determine output index directory
|
|
@@ -59,6 +59,13 @@ function canonicalLocalityRank(entryPath) {
|
|
|
59
59
|
const normalized = entryPath.replace(/\\/g, '/');
|
|
60
60
|
if (normalized.startsWith('.aiwg/') || normalized.includes('/.aiwg/'))
|
|
61
61
|
return 0;
|
|
62
|
+
// Top-level persona mirrors are the least canonical source for a name a bundle
|
|
63
|
+
// also owns. Without this they fell into the catch-all below and scored 1,
|
|
64
|
+
// beating the bundle copy at 2 — the opposite of #1643. It only ever looked
|
|
65
|
+
// correct because a populated user index supplied provenance and `scopeRank`
|
|
66
|
+
// never reached this fallback (#2544).
|
|
67
|
+
if (normalized.startsWith('agentic/code/agents/') || normalized.includes('/agentic/code/agents/'))
|
|
68
|
+
return 4;
|
|
62
69
|
if (normalized.includes('/plugins/') || normalized.startsWith('agentic/code/plugins/'))
|
|
63
70
|
return 3;
|
|
64
71
|
if (normalized.includes('/frameworks/') ||
|
|
@@ -29,16 +29,84 @@ function display(status, json) {
|
|
|
29
29
|
for (const item of status.drift)
|
|
30
30
|
console.log(` - ${item}`);
|
|
31
31
|
}
|
|
32
|
+
const remedy = remediation(status);
|
|
33
|
+
if (remedy) {
|
|
34
|
+
console.log('');
|
|
35
|
+
console.log('Resolve:');
|
|
36
|
+
for (const line of remedy)
|
|
37
|
+
console.log(` ${line}`);
|
|
38
|
+
}
|
|
32
39
|
console.log('');
|
|
33
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* `switch` and `adopt` only write installation.json — they cannot change which
|
|
43
|
+
* binary is on PATH. So when the declaration and reality disagree, neither command
|
|
44
|
+
* resolves it and the operator needs a shell step this output never mentioned.
|
|
45
|
+
* Print it, tailored to the direction of the drift (#2534).
|
|
46
|
+
*/
|
|
47
|
+
export function remediation(status) {
|
|
48
|
+
if (status.state !== 'mismatch')
|
|
49
|
+
return null;
|
|
50
|
+
const canonicalMethod = status.identity?.method;
|
|
51
|
+
const canonicalRoot = status.identity?.root;
|
|
52
|
+
const actualMethod = status.actualMethod;
|
|
53
|
+
if (canonicalMethod === 'source' && canonicalRoot && actualMethod !== 'source') {
|
|
54
|
+
return [
|
|
55
|
+
`The declared source install is not what runs. Put it on PATH:`,
|
|
56
|
+
` cd ${canonicalRoot} && npm link`,
|
|
57
|
+
`Then re-run 'aiwg installation show' to confirm State: aligned.`,
|
|
58
|
+
`('aiwg installation switch' would only rewrite the declaration, which already says source.)`,
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
if (canonicalMethod === 'npm' && actualMethod === 'source') {
|
|
62
|
+
return [
|
|
63
|
+
`The declared npm package is not what runs. Restore it:`,
|
|
64
|
+
` npm install -g aiwg`,
|
|
65
|
+
`If a source checkout was linked, unlink it first: npm unlink -g aiwg`,
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
return [
|
|
69
|
+
`Declaration and reality disagree (${canonicalMethod ?? 'unrecorded'} vs ${actualMethod}).`,
|
|
70
|
+
`'switch' and 'adopt' are declaration-only and cannot change PATH.`,
|
|
71
|
+
`Install or link the intended root, then re-run 'aiwg installation show'.`,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
function usage() {
|
|
75
|
+
return `
|
|
76
|
+
aiwg installation — inspect, adopt, or switch the canonical global installation
|
|
77
|
+
|
|
78
|
+
Usage:
|
|
79
|
+
aiwg installation show [--json]
|
|
80
|
+
aiwg installation adopt --method <npm|web|source> [--run-mode <normal|development>] [--yes]
|
|
81
|
+
aiwg installation switch --root <path> --method <npm|web|source> [--manager <absolute-path>]
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
--json Machine-readable output
|
|
85
|
+
--config-dir Override the installation config directory
|
|
86
|
+
--manager Absolute path to the package manager executable
|
|
87
|
+
--channel Release channel (stable|edge)
|
|
88
|
+
--run-mode normal|development (derived from --method when omitted)
|
|
89
|
+
--yes Confirm an adopt that abandons the declared install
|
|
90
|
+
|
|
91
|
+
Notes:
|
|
92
|
+
These commands are declaration-only: they record which installation is
|
|
93
|
+
canonical, they do not change which binary is on PATH. When \`show\` reports
|
|
94
|
+
State: mismatch, it prints the concrete command that resolves it.
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
34
97
|
export const installationHandler = {
|
|
35
98
|
id: 'installation',
|
|
36
99
|
name: 'Installation',
|
|
37
100
|
description: 'Inspect, adopt, or deliberately switch the canonical global installation',
|
|
38
101
|
category: 'maintenance',
|
|
39
102
|
aliases: [],
|
|
103
|
+
async help() {
|
|
104
|
+
return { exitCode: 0, message: usage(), rawOutput: true };
|
|
105
|
+
},
|
|
40
106
|
async execute(ctx) {
|
|
41
107
|
const [action = 'show'] = ctx.args;
|
|
108
|
+
if (action === 'help')
|
|
109
|
+
return { exitCode: 0, message: usage(), rawOutput: true };
|
|
42
110
|
const json = ctx.args.includes('--json');
|
|
43
111
|
const actualRoot = getPackageRoot();
|
|
44
112
|
const common = {
|
|
@@ -54,11 +122,43 @@ export const installationHandler = {
|
|
|
54
122
|
}
|
|
55
123
|
if (action === 'adopt') {
|
|
56
124
|
const method = valueAfter(ctx.args, '--method');
|
|
125
|
+
// adopt resolves a mismatch by rewriting canonical to match whatever is
|
|
126
|
+
// running — i.e. by abandoning the declared install. That is the opposite
|
|
127
|
+
// of what an operator standardizing on a source checkout wants, so make it
|
|
128
|
+
// a deliberate choice rather than a silent capitulation (#2534).
|
|
129
|
+
const before = inspectInstallation({
|
|
130
|
+
...common,
|
|
131
|
+
identity: loadInstallationIdentity({ ...common, createIfMissing: true }),
|
|
132
|
+
});
|
|
133
|
+
const declaredMethod = before.identity?.method;
|
|
134
|
+
const abandoning = before.state === 'mismatch'
|
|
135
|
+
&& declaredMethod
|
|
136
|
+
&& declaredMethod !== before.actualMethod;
|
|
137
|
+
if (abandoning && !ctx.args.includes('--yes')) {
|
|
138
|
+
return {
|
|
139
|
+
exitCode: 2,
|
|
140
|
+
rawOutput: true,
|
|
141
|
+
message: [
|
|
142
|
+
`Refusing to adopt: this would abandon the declared ${declaredMethod} install.`,
|
|
143
|
+
``,
|
|
144
|
+
` declared: ${declaredMethod} at ${before.identity?.root ?? '(unrecorded)'}`,
|
|
145
|
+
` running: ${before.actualMethod} at ${before.actualRoot}`,
|
|
146
|
+
``,
|
|
147
|
+
`adopt rewrites the declaration to match what is running; it does not change`,
|
|
148
|
+
`which binary is on PATH. If you meant to keep the declared install, run`,
|
|
149
|
+
`'aiwg installation show' for the command that puts it back on PATH.`,
|
|
150
|
+
`If you really mean to abandon it, re-run with --yes.`,
|
|
151
|
+
].join('\n'),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
57
154
|
const status = adoptInstallation({
|
|
58
155
|
...common,
|
|
59
156
|
method,
|
|
60
157
|
runMode: valueAfter(ctx.args, '--run-mode'),
|
|
61
158
|
});
|
|
159
|
+
if (abandoning) {
|
|
160
|
+
console.log(`Warning: adopted the running ${status.actualMethod} install; the previously declared ${declaredMethod} install is no longer canonical.`);
|
|
161
|
+
}
|
|
62
162
|
display(status, json);
|
|
63
163
|
return { exitCode: 0 };
|
|
64
164
|
}
|
|
@@ -66,7 +166,7 @@ export const installationHandler = {
|
|
|
66
166
|
const root = valueAfter(ctx.args, '--root');
|
|
67
167
|
const method = valueAfter(ctx.args, '--method');
|
|
68
168
|
if (!root || !method) {
|
|
69
|
-
return { exitCode: 2, message:
|
|
169
|
+
return { exitCode: 2, message: `switch requires --root and --method\n${usage()}`, rawOutput: true };
|
|
70
170
|
}
|
|
71
171
|
const status = switchInstallation({
|
|
72
172
|
...common,
|
|
@@ -77,7 +177,7 @@ export const installationHandler = {
|
|
|
77
177
|
display(status, json);
|
|
78
178
|
return { exitCode: 0 };
|
|
79
179
|
}
|
|
80
|
-
return { exitCode: 2, message:
|
|
180
|
+
return { exitCode: 2, message: `Unknown installation action: ${action}\n${usage()}`, rawOutput: true };
|
|
81
181
|
},
|
|
82
182
|
};
|
|
83
183
|
//# sourceMappingURL=installation.js.map
|
|
@@ -228,7 +228,13 @@ Drain queued missions in a session by launching each as a ralph loop. Missions
|
|
|
228
228
|
without --completion criteria are skipped with a warning.
|
|
229
229
|
|
|
230
230
|
--accept-cost Skip the cost-warning gate (required for non-TTY contexts
|
|
231
|
-
when estimated cumulative cost exceeds $5). See #1450
|
|
231
|
+
when estimated cumulative cost exceeds $5). See #1450.
|
|
232
|
+
|
|
233
|
+
The estimate is the cumulative iteration floor (missions x max-iterations x
|
|
234
|
+
~$1.60 cache cost per headless iteration). A mission's --max-total-cost caps its
|
|
235
|
+
share of that estimate only on a provider that reports spend; on a provider that
|
|
236
|
+
reports none the ceiling is inert (#1766), so it is reported but not subtracted.
|
|
237
|
+
See #2522.`,
|
|
232
238
|
status: `Usage: aiwg mc status [<session-id>] [--json]
|
|
233
239
|
|
|
234
240
|
Show mission status for a session. Auto-syncs from ralph loop state files.
|
|
@@ -479,6 +485,71 @@ async function mcDispatch(ctx) {
|
|
|
479
485
|
* detached process, so they DO run in parallel after launch — the loop here
|
|
480
486
|
* is just for orderly dispatch, not for parallel scheduling)
|
|
481
487
|
*/
|
|
488
|
+
/**
|
|
489
|
+
* Per-iteration cache-creation floor for a headless claude session (sonnet
|
|
490
|
+
* baseline; opus is ~$3.90).
|
|
491
|
+
*/
|
|
492
|
+
export const SONNET_CACHE_USD = 1.60;
|
|
493
|
+
/** Estimate at or above which `mc run` warns and refuses in non-TTY contexts. */
|
|
494
|
+
export const COST_WARNING_THRESHOLD_USD = 5.0;
|
|
495
|
+
/**
|
|
496
|
+
* Providers whose headless stream reports spend, so a declared `--max-total-cost`
|
|
497
|
+
* ceiling can actually fire mid-loop.
|
|
498
|
+
*
|
|
499
|
+
* Only `claude` emits cost on its stream-json events, which is what
|
|
500
|
+
* `SessionLauncher._extractUsageStats` reads. Adapters without stream-json
|
|
501
|
+
* (codex, factory, opencode, deepseek) emit no usage events at all — #1766
|
|
502
|
+
* recorded that a spend ceiling on those is inert and never fires. Adapters that
|
|
503
|
+
* do stream JSON but have not been observed carrying cost fields (omp, pi) stay
|
|
504
|
+
* out of this set deliberately: assuming an observability we have not seen would
|
|
505
|
+
* weaken the gate in exactly the case where the operator has least protection.
|
|
506
|
+
*/
|
|
507
|
+
export const COST_REPORTING_PROVIDERS = new Set(['claude']);
|
|
508
|
+
/**
|
|
509
|
+
* Estimate cumulative spend for a set of queued missions (#2522).
|
|
510
|
+
*
|
|
511
|
+
* The floor for each mission is `maxIterations × SONNET_CACHE_USD`. A declared
|
|
512
|
+
* `maxTotalCost` bounds that floor **only** when the target provider reports
|
|
513
|
+
* spend — on a provider that reports none, the ceiling is inert (#1766), so
|
|
514
|
+
* subtracting it would make the warning weaker precisely where the operator has
|
|
515
|
+
* no enforced protection. Those ceilings are counted and surfaced instead.
|
|
516
|
+
*/
|
|
517
|
+
export function estimateMissionRunCost(missions, provider) {
|
|
518
|
+
const spendObservable = COST_REPORTING_PROVIDERS.has(provider);
|
|
519
|
+
let estimateUsd = 0;
|
|
520
|
+
let iterationFloorUsd = 0;
|
|
521
|
+
let declaredCeilingUsd = 0;
|
|
522
|
+
let cappedMissions = 0;
|
|
523
|
+
let inertCeilingMissions = 0;
|
|
524
|
+
for (const mission of missions) {
|
|
525
|
+
const floor = mission.maxIterations * SONNET_CACHE_USD;
|
|
526
|
+
iterationFloorUsd += floor;
|
|
527
|
+
const ceiling = typeof mission.maxTotalCost === 'number' && Number.isFinite(mission.maxTotalCost)
|
|
528
|
+
? mission.maxTotalCost
|
|
529
|
+
: undefined;
|
|
530
|
+
if (ceiling === undefined) {
|
|
531
|
+
estimateUsd += floor;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
declaredCeilingUsd += ceiling;
|
|
535
|
+
if (spendObservable) {
|
|
536
|
+
cappedMissions += 1;
|
|
537
|
+
estimateUsd += Math.min(floor, ceiling);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
inertCeilingMissions += 1;
|
|
541
|
+
estimateUsd += floor;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
estimateUsd,
|
|
546
|
+
iterationFloorUsd,
|
|
547
|
+
declaredCeilingUsd,
|
|
548
|
+
cappedMissions,
|
|
549
|
+
inertCeilingMissions,
|
|
550
|
+
spendObservable,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
482
553
|
async function mcRun(ctx) {
|
|
483
554
|
const positional = getPositionalArgs(ctx.args);
|
|
484
555
|
const sessionId = positional[0];
|
|
@@ -493,24 +564,28 @@ async function mcRun(ctx) {
|
|
|
493
564
|
ui.info(`No queued missions in session ${session.id}. Run \`aiwg mc dispatch ${session.id} "<objective>"\` to add one.`);
|
|
494
565
|
return { exitCode: 0 };
|
|
495
566
|
}
|
|
496
|
-
// #1450 P0: cost warning gate.
|
|
567
|
+
// #1450 P0: cost warning gate, with declared ceilings honored (#2522).
|
|
497
568
|
//
|
|
498
569
|
// Each headless claude session pays a ~$1.60 cache-creation cost on iteration
|
|
499
570
|
// 1 before any user-meaningful work (sonnet baseline; opus is ~$3.90). Across
|
|
500
571
|
// N missions × M iterations the floor compounds quickly. Warn before launch
|
|
501
572
|
// and refuse in non-TTY contexts unless --accept-cost is set.
|
|
502
|
-
//
|
|
503
|
-
// Estimate is intentionally conservative: cumulative iteration floor =
|
|
504
|
-
// missions × max_iterations × sonnet_cache_cost. Real spend may be lower if
|
|
505
|
-
// missions complete in fewer iterations.
|
|
506
573
|
const eligible = queued.filter(m => m.mode !== 'pty-orchestrator' && !!m.completion);
|
|
507
|
-
const
|
|
508
|
-
const
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
|
|
574
|
+
const projectRoot = ctx.cwd || process.cwd();
|
|
575
|
+
const frameworkRoot = ctx.frameworkRoot;
|
|
576
|
+
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
|
|
577
|
+
const cfg = await readAiwgConfig(projectRoot).catch(() => null);
|
|
578
|
+
const provider = cfg?.providers[0] ?? 'unknown';
|
|
579
|
+
const estimate = estimateMissionRunCost(eligible, provider);
|
|
580
|
+
if (estimate.estimateUsd >= COST_WARNING_THRESHOLD_USD && !acceptCost) {
|
|
512
581
|
ui.blank();
|
|
513
|
-
ui.warn(`Cost estimate: ~$${estimateUsd.toFixed(2)} (${eligible.length} missions × iteration floors × ~$${SONNET_CACHE_USD.toFixed(2)} cache cost per claude headless iter).`);
|
|
582
|
+
ui.warn(`Cost estimate: ~$${estimate.estimateUsd.toFixed(2)} (${eligible.length} missions × iteration floors × ~$${SONNET_CACHE_USD.toFixed(2)} cache cost per claude headless iter).`);
|
|
583
|
+
if (estimate.cappedMissions > 0) {
|
|
584
|
+
ui.warn(`Declared ceilings: $${estimate.declaredCeilingUsd.toFixed(2)} across ${estimate.cappedMissions} mission(s) — counted toward the estimate because ${provider} reports spend.`);
|
|
585
|
+
}
|
|
586
|
+
if (estimate.inertCeilingMissions > 0) {
|
|
587
|
+
ui.warn(`${estimate.inertCeilingMissions} mission(s) declare --max-total-cost totalling $${estimate.declaredCeilingUsd.toFixed(2)}, but ${provider} does not report spend — those ceilings cannot fire and are NOT subtracted from this estimate (#1766).`);
|
|
588
|
+
}
|
|
514
589
|
ui.warn('Actual spend may be lower if missions complete early, higher if model is opus or context grows.');
|
|
515
590
|
if (!process.stdout.isTTY) {
|
|
516
591
|
ui.error('Refusing to launch in non-interactive context. Re-run with `--accept-cost` to proceed.');
|
|
@@ -531,12 +606,7 @@ async function mcRun(ctx) {
|
|
|
531
606
|
let launched = 0;
|
|
532
607
|
let skipped = 0;
|
|
533
608
|
let failed = 0;
|
|
534
|
-
const projectRoot = ctx.cwd || process.cwd();
|
|
535
|
-
const frameworkRoot = ctx.frameworkRoot;
|
|
536
|
-
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
|
|
537
609
|
const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
|
|
538
|
-
const cfg = await readAiwgConfig(projectRoot).catch(() => null);
|
|
539
|
-
const provider = cfg?.providers[0] ?? 'unknown';
|
|
540
610
|
const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
|
|
541
611
|
const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), {
|
|
542
612
|
maxConcurrent,
|
|
@@ -40,6 +40,48 @@ const PROVIDER_AGENT_DIRS = {
|
|
|
40
40
|
export async function currentBundledAgentBasenames(frameworkRoot) {
|
|
41
41
|
return new Set((await collectPackagedAgentInventory(frameworkRoot)).keys());
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Basenames of every rule the current package can deploy.
|
|
45
|
+
*
|
|
46
|
+
* Symmetric to {@link currentBundledAgentBasenames}. A rule absent from this set
|
|
47
|
+
* is residue from a deploy model that no longer writes it (#2540).
|
|
48
|
+
*
|
|
49
|
+
* Every group that can ship a `rules/` directory must be listed here: the set is
|
|
50
|
+
* the prune's definition of "still shipped", so a missed group makes live rules
|
|
51
|
+
* look orphaned and deletes them. `extensions` ships 22 rules and was the group
|
|
52
|
+
* this nearly lost.
|
|
53
|
+
*/
|
|
54
|
+
export const BUNDLED_RULE_SOURCE_GROUPS = ['frameworks', 'addons', 'plugins', 'extensions'];
|
|
55
|
+
export async function currentBundledRuleBasenames(frameworkRoot) {
|
|
56
|
+
const names = new Set();
|
|
57
|
+
const codeRoot = path.join(frameworkRoot, 'agentic', 'code');
|
|
58
|
+
for (const group of BUNDLED_RULE_SOURCE_GROUPS) {
|
|
59
|
+
let units;
|
|
60
|
+
try {
|
|
61
|
+
units = await fs.readdir(path.join(codeRoot, group), { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const unit of units) {
|
|
67
|
+
if (!unit.isDirectory())
|
|
68
|
+
continue;
|
|
69
|
+
const rulesDir = path.join(codeRoot, group, unit.name, 'rules');
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(rulesDir, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.isFile() && entry.name.endsWith('.md'))
|
|
79
|
+
names.add(entry.name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
43
85
|
async function readFrameworkVersion(frameworkRoot) {
|
|
44
86
|
try {
|
|
45
87
|
const pkg = JSON.parse(await fs.readFile(path.join(frameworkRoot, 'package.json'), 'utf8'));
|
|
@@ -186,6 +228,7 @@ export async function partitionTrackedPaths(projectRoot, relativePaths) {
|
|
|
186
228
|
}
|
|
187
229
|
export async function pruneStaleManagedAgentFiles(options) {
|
|
188
230
|
const desired = await currentBundledAgentBasenames(options.frameworkRoot);
|
|
231
|
+
const desiredRules = await currentBundledRuleBasenames(options.frameworkRoot);
|
|
189
232
|
const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
|
|
190
233
|
const crossProvider = options.crossProvider ?? 'skip';
|
|
191
234
|
const removals = [];
|
|
@@ -204,7 +247,10 @@ export async function pruneStaleManagedAgentFiles(options) {
|
|
|
204
247
|
// provider only drops agents whose source no longer ships them.
|
|
205
248
|
if (!isTargetProvider && crossProvider === 'skip')
|
|
206
249
|
continue;
|
|
207
|
-
|
|
250
|
+
// Rules were excluded from the target-provider pass, so a project carried
|
|
251
|
+
// every rule any past version ever deployed. On long-lived projects that is
|
|
252
|
+
// the bulk of the startup-context budget (#2540).
|
|
253
|
+
const kinds = isTargetProvider ? ['agents', 'rules'] : PRUNABLE_ARTIFACT_KINDS;
|
|
208
254
|
const hits = await collectManagedProviderArtifacts(options.projectRoot, provider, kinds);
|
|
209
255
|
const eligible = hits.filter((hit) => {
|
|
210
256
|
if (isTargetProvider) {
|
|
@@ -212,6 +258,8 @@ export async function pruneStaleManagedAgentFiles(options) {
|
|
|
212
258
|
// marker to the top-level package version makes a successful refresh
|
|
213
259
|
// delete freshly restored addon agents, so the active provider removes
|
|
214
260
|
// only artifacts absent from current sources.
|
|
261
|
+
if (hit.kind === 'rules')
|
|
262
|
+
return !desiredRules.has(path.basename(hit.relativePath));
|
|
215
263
|
return !desired.has(hit.artifactName);
|
|
216
264
|
}
|
|
217
265
|
return currentVersion !== null && isOlderManagedVersion(hit.version, currentVersion);
|
|
@@ -583,7 +631,11 @@ export const refreshHandler = {
|
|
|
583
631
|
}
|
|
584
632
|
let staleAgentRemovals = [];
|
|
585
633
|
let trackedSkipped = [];
|
|
586
|
-
|
|
634
|
+
// A dry run still reports what a real run would remove. Skipping the pass
|
|
635
|
+
// entirely meant `refresh --dry-run` printed "Checking for stale deployments..."
|
|
636
|
+
// and nothing else, so orphaned artifacts were invisible until they had
|
|
637
|
+
// pushed the project over its context budget (#2540).
|
|
638
|
+
if (deploymentFailures.length === 0) {
|
|
587
639
|
try {
|
|
588
640
|
const pruneResult = await pruneStaleManagedAgentFiles({
|
|
589
641
|
projectRoot: ctx.cwd,
|
|
@@ -591,6 +643,7 @@ export const refreshHandler = {
|
|
|
591
643
|
provider: detectedProvider,
|
|
592
644
|
crossProvider: pruneOtherProviders ? 'prune' : 'skip',
|
|
593
645
|
allowTrackedDeletes: pruneTracked,
|
|
646
|
+
dryRun,
|
|
594
647
|
});
|
|
595
648
|
staleAgentRemovals = pruneResult.removals;
|
|
596
649
|
trackedSkipped = pruneResult.trackedSkipped;
|
|
@@ -607,19 +660,24 @@ export const refreshHandler = {
|
|
|
607
660
|
}
|
|
608
661
|
if (staleAgentRemovals.length > 0 && !quiet) {
|
|
609
662
|
const total = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
|
|
610
|
-
ui.warn(
|
|
663
|
+
ui.warn(`${dryRun ? 'Would remove' : 'Removed'} ${total} stale AIWG-managed file${total === 1 ? '' : 's'} ` +
|
|
611
664
|
`across ${staleAgentRemovals.length} provider${staleAgentRemovals.length === 1 ? '' : 's'}`);
|
|
612
665
|
for (const removal of staleAgentRemovals) {
|
|
613
666
|
const shown = removal.paths.slice(0, 3).join(', ');
|
|
614
667
|
const remainder = removal.paths.length - 3;
|
|
615
668
|
ui.dim(` ${removal.provider}: ${removal.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
|
|
616
669
|
}
|
|
617
|
-
ui.dim(
|
|
670
|
+
ui.dim(dryRun
|
|
671
|
+
? ' Re-run without --dry-run to remove them.'
|
|
672
|
+
: ' Review `git status` before committing — deployed artifacts may be tracked.');
|
|
618
673
|
}
|
|
619
674
|
// #2506: keep the recorded deployment state consistent with what the
|
|
620
675
|
// prune actually left on disk, so a later run does not trust counts
|
|
621
|
-
// for artifacts that no longer exist.
|
|
622
|
-
|
|
676
|
+
// for artifacts that no longer exist. A dry run deleted nothing, so the
|
|
677
|
+
// recorded state is already accurate.
|
|
678
|
+
if (!dryRun) {
|
|
679
|
+
await reconcileDeployedToAfterPrune(ctx.cwd, staleAgentRemovals, pruneOtherProviders ? detectedProvider : null);
|
|
680
|
+
}
|
|
623
681
|
}
|
|
624
682
|
catch {
|
|
625
683
|
if (!quiet)
|
|
@@ -685,7 +743,9 @@ export const refreshHandler = {
|
|
|
685
743
|
// #2506: a run that deleted artifacts is not an "up to date" run.
|
|
686
744
|
const removed = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
|
|
687
745
|
if (!quiet) {
|
|
688
|
-
ui.warn(
|
|
746
|
+
ui.warn(dryRun
|
|
747
|
+
? `Deployments current, but ${removed} stale artifact(s) would be removed — review the list above`
|
|
748
|
+
: `Deployments current, but ${removed} stale artifact(s) were removed this run — review the list above`);
|
|
689
749
|
}
|
|
690
750
|
}
|
|
691
751
|
else {
|