agentic-workflow-manager 3.7.0 → 3.8.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.
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CONFIG_FILE = exports.DEFAULT_FILES = void 0;
|
|
7
|
+
exports.estimateTokens = estimateTokens;
|
|
8
|
+
exports.measure = measure;
|
|
9
|
+
exports.readConfig = readConfig;
|
|
10
|
+
exports.writeConfig = writeConfig;
|
|
11
|
+
exports.checkBudget = checkBudget;
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
/**
|
|
15
|
+
* The feedforward context budget.
|
|
16
|
+
*
|
|
17
|
+
* `AGENTS.md`, `CONSTITUTION.md` and `CLAUDE.md` are injected into EVERY agent session
|
|
18
|
+
* before a single line of code is read, so their size is a per-session tax paid
|
|
19
|
+
* forever. They grow because curing a lesson is an append and pruning one is a
|
|
20
|
+
* judgement call nobody is forced to make.
|
|
21
|
+
*
|
|
22
|
+
* Prose does not hold this line. Measured on a real repo, `AGENTS.md` went 73KB → 141KB
|
|
23
|
+
* across 45 revisions and never shrank once, while `harness-retro` already carried an
|
|
24
|
+
* explicit "merge and prune, never append raw" rule. Adding a second copy of an ignored
|
|
25
|
+
* rule has no expected effect, so this measures instead.
|
|
26
|
+
*
|
|
27
|
+
* It does NOT forbid growth — it makes growth deliberate. First check pins the current
|
|
28
|
+
* total; later checks report when the files have grown past it. The ways out are pruning
|
|
29
|
+
* back under, or raising `maxBytes` in a committed diff a human reviews.
|
|
30
|
+
*
|
|
31
|
+
* **Where this runs matters as much as what it measures.** It belongs at the last moment
|
|
32
|
+
* a human is guaranteed to be present — the pre-handoff gate at the end of
|
|
33
|
+
* `writing-plans`, before execution is handed to subagents. Wired into `awm sensors run`
|
|
34
|
+
* instead, it would fire during unattended overnight runs and strand the very PR the
|
|
35
|
+
* user came back expecting. Hence a command, not a sensor.
|
|
36
|
+
*/
|
|
37
|
+
/** Injected into every session by the AWM session hook / context contract. */
|
|
38
|
+
exports.DEFAULT_FILES = ['AGENTS.md', 'CONSTITUTION.md', 'CLAUDE.md'];
|
|
39
|
+
exports.CONFIG_FILE = path_1.default.join('.awm', 'context-budget.json');
|
|
40
|
+
/** Rough but stable: ~4 bytes per token for prose. Reporting only — never a gate input. */
|
|
41
|
+
function estimateTokens(bytes) {
|
|
42
|
+
return Math.round(bytes / 4 / 1000);
|
|
43
|
+
}
|
|
44
|
+
function measure(cwd, files) {
|
|
45
|
+
const breakdown = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
let s;
|
|
49
|
+
try {
|
|
50
|
+
s = fs_1.default.statSync(path_1.default.join(cwd, f));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue; // a repo need not have all three
|
|
54
|
+
}
|
|
55
|
+
if (!s.isFile())
|
|
56
|
+
continue;
|
|
57
|
+
total += s.size;
|
|
58
|
+
breakdown.push({ file: f, bytes: s.size });
|
|
59
|
+
}
|
|
60
|
+
return { total, breakdown };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read the pinned budget. A malformed or partial config returns null so the caller
|
|
64
|
+
* re-pins rather than treating an unreadable budget as an absent limit — the quiet
|
|
65
|
+
* direction of wrong, where the check silently stops checking.
|
|
66
|
+
*/
|
|
67
|
+
function readConfig(cwd) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = JSON.parse(fs_1.default.readFileSync(path_1.default.join(cwd, exports.CONFIG_FILE), 'utf-8'));
|
|
70
|
+
if (typeof raw.maxBytes !== 'number' || !Number.isFinite(raw.maxBytes))
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
files: Array.isArray(raw.files) && raw.files.length ? raw.files : exports.DEFAULT_FILES,
|
|
74
|
+
maxBytes: raw.maxBytes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function writeConfig(cwd, config) {
|
|
82
|
+
fs_1.default.mkdirSync(path_1.default.join(cwd, '.awm'), { recursive: true });
|
|
83
|
+
const body = {
|
|
84
|
+
_comment: 'Context budget for files injected into every agent session. Raising maxBytes '
|
|
85
|
+
+ 'is allowed but must be a deliberate, reviewed change — see writing-plans, '
|
|
86
|
+
+ 'Context Budget Gate.',
|
|
87
|
+
...config,
|
|
88
|
+
};
|
|
89
|
+
fs_1.default.writeFileSync(path_1.default.join(cwd, exports.CONFIG_FILE), JSON.stringify(body, null, 2) + '\n', 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Measure the injected context against the pinned budget.
|
|
93
|
+
*
|
|
94
|
+
* On the first check there is nothing to compare against, so the current total is
|
|
95
|
+
* pinned and the result is `pinned`. Pinning rather than failing means adopting this
|
|
96
|
+
* never blocks a repo that is already large — it only stops it getting larger.
|
|
97
|
+
*/
|
|
98
|
+
function checkBudget(cwd) {
|
|
99
|
+
const config = readConfig(cwd);
|
|
100
|
+
if (!config) {
|
|
101
|
+
const { total, breakdown } = measure(cwd, exports.DEFAULT_FILES);
|
|
102
|
+
writeConfig(cwd, { files: exports.DEFAULT_FILES, maxBytes: total });
|
|
103
|
+
return { status: 'pinned', totalBytes: total, maxBytes: total, breakdown };
|
|
104
|
+
}
|
|
105
|
+
const { total, breakdown } = measure(cwd, config.files);
|
|
106
|
+
return {
|
|
107
|
+
status: total > config.maxBytes ? 'over' : 'within',
|
|
108
|
+
totalBytes: total,
|
|
109
|
+
maxBytes: config.maxBytes,
|
|
110
|
+
breakdown,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.exitCodeFor = exitCodeFor;
|
|
7
|
+
exports.formatReport = formatReport;
|
|
8
|
+
exports.registerContextBudgetCommand = registerContextBudgetCommand;
|
|
9
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
+
const budget_1 = require("./budget");
|
|
11
|
+
const KB = (bytes) => `${(bytes / 1024).toFixed(0)}KB`;
|
|
12
|
+
/**
|
|
13
|
+
* Exit code. `over` exits 1 so a caller that wants a hard gate can have one — but the
|
|
14
|
+
* skill that invokes this (writing-plans, Context Budget Gate) deliberately does not
|
|
15
|
+
* treat it as one. It runs at the last attended moment and presents a choice; blocking
|
|
16
|
+
* would strand the unattended runs this whole design exists to protect.
|
|
17
|
+
*/
|
|
18
|
+
function exitCodeFor(report) {
|
|
19
|
+
return report.status === 'over' ? 1 : 0;
|
|
20
|
+
}
|
|
21
|
+
function formatReport(report) {
|
|
22
|
+
const tokens = `~${(0, budget_1.estimateTokens)(report.totalBytes)}k tokens`;
|
|
23
|
+
const breakdown = report.breakdown.map(b => `${b.file} ${KB(b.bytes)}`).join(', ');
|
|
24
|
+
if (report.status === 'pinned') {
|
|
25
|
+
return `${picocolors_1.default.green('✔')} Context budget pinned at ${KB(report.totalBytes)} (${tokens} per session).\n`
|
|
26
|
+
+ ` ${breakdown}\n`
|
|
27
|
+
+ ` Saved to ${budget_1.CONFIG_FILE} — commit it. Growth past this point will report here.\n`;
|
|
28
|
+
}
|
|
29
|
+
if (report.status === 'within') {
|
|
30
|
+
return `${picocolors_1.default.green('✔')} Context budget OK: ${KB(report.totalBytes)} of ${KB(report.maxBytes)} (${tokens} per session).\n`
|
|
31
|
+
+ ` ${breakdown}\n`;
|
|
32
|
+
}
|
|
33
|
+
const over = report.totalBytes - report.maxBytes;
|
|
34
|
+
return `${picocolors_1.default.yellow('⚠')} Context budget exceeded: ${KB(report.totalBytes)} vs ${KB(report.maxBytes)} `
|
|
35
|
+
+ `(over by ${KB(over)}).\n`
|
|
36
|
+
+ ` ${breakdown}\n`
|
|
37
|
+
+ ` These files are injected into EVERY session — ${tokens} spent before any code is read.\n\n`
|
|
38
|
+
+ ` Decide now, while you are here:\n`
|
|
39
|
+
+ ` 1. Prune — cheapest moment: that context is already loaded and you are\n`
|
|
40
|
+
+ ` choosing what matters for the work about to start.\n`
|
|
41
|
+
+ ` 2. Raise — edit "maxBytes" in ${budget_1.CONFIG_FILE}; a reviewed decision to keep\n`
|
|
42
|
+
+ ` paying for this in every future session.\n`
|
|
43
|
+
+ ` 3. Accept — proceed and note it in the plan.\n`;
|
|
44
|
+
}
|
|
45
|
+
function registerContextBudgetCommand(program) {
|
|
46
|
+
program
|
|
47
|
+
.command('context-budget')
|
|
48
|
+
.description('check the size of the files injected into every agent session')
|
|
49
|
+
.option('--json', 'emit the report as JSON')
|
|
50
|
+
.option('--cwd <path>', 'directory to measure (default: current)')
|
|
51
|
+
.action((opts) => {
|
|
52
|
+
const report = (0, budget_1.checkBudget)(opts.cwd ?? process.cwd());
|
|
53
|
+
process.stdout.write(opts.json ? JSON.stringify(report, null, 2) + '\n' : formatReport(report));
|
|
54
|
+
const code = exitCodeFor(report);
|
|
55
|
+
if (code !== 0)
|
|
56
|
+
process.exit(code);
|
|
57
|
+
});
|
|
58
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ const miro_1 = require("./core/miro");
|
|
|
26
26
|
const hooks_1 = require("./commands/hooks");
|
|
27
27
|
const sensors_1 = require("./commands/sensors");
|
|
28
28
|
const ledger_1 = require("./commands/ledger");
|
|
29
|
+
const context_budget_1 = require("./commands/context-budget");
|
|
29
30
|
const doctor_1 = require("./commands/doctor");
|
|
30
31
|
const backup_1 = require("./commands/backup");
|
|
31
32
|
const init_1 = require("./commands/init");
|
|
@@ -606,6 +607,7 @@ miroCmd.command('sync <storyMapPath>')
|
|
|
606
607
|
(0, hooks_1.registerHooksCommand)(program);
|
|
607
608
|
(0, sensors_1.registerSensorsCommand)(program);
|
|
608
609
|
(0, ledger_1.registerLedgerCommand)(program);
|
|
610
|
+
(0, context_budget_1.registerContextBudgetCommand)(program);
|
|
609
611
|
(0, doctor_1.registerDoctorCommand)(program);
|
|
610
612
|
(0, backup_1.registerBackupCommand)(program);
|
|
611
613
|
(0, init_1.registerInitCommand)(program);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const budget_1 = require("../../../src/commands/context-budget/budget");
|
|
10
|
+
const context_budget_1 = require("../../../src/commands/context-budget");
|
|
11
|
+
function project(files) {
|
|
12
|
+
// CLAUDE.md: no test may reach the real ~/.awm. Everything here is a tmpdir.
|
|
13
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-budget-'));
|
|
14
|
+
for (const [name, bytes] of Object.entries(files)) {
|
|
15
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, name), 'a'.repeat(bytes));
|
|
16
|
+
}
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
describe('checkBudget', () => {
|
|
20
|
+
const dirs = [];
|
|
21
|
+
const make = (f) => { const d = project(f); dirs.push(d); return d; };
|
|
22
|
+
afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
|
|
23
|
+
it('pins the current total on the first check instead of failing', () => {
|
|
24
|
+
// Adopting this must never block a repo that is already large — it only stops
|
|
25
|
+
// it getting larger. A first-run failure would make it unadoptable exactly
|
|
26
|
+
// where it is most needed.
|
|
27
|
+
const dir = make({ 'AGENTS.md': 5000, 'CONSTITUTION.md': 3000 });
|
|
28
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
29
|
+
expect(report.status).toBe('pinned');
|
|
30
|
+
expect(report.totalBytes).toBe(8000);
|
|
31
|
+
expect((0, budget_1.readConfig)(dir).maxBytes).toBe(8000);
|
|
32
|
+
});
|
|
33
|
+
it('reports over once the files grow past the pin', () => {
|
|
34
|
+
const dir = make({ 'AGENTS.md': 5000 });
|
|
35
|
+
(0, budget_1.checkBudget)(dir); // pin at 5000
|
|
36
|
+
fs_1.default.appendFileSync(path_1.default.join(dir, 'AGENTS.md'), 'b'.repeat(2000));
|
|
37
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
38
|
+
expect(report.status).toBe('over');
|
|
39
|
+
expect(report.totalBytes).toBe(7000);
|
|
40
|
+
expect(report.maxBytes).toBe(5000);
|
|
41
|
+
});
|
|
42
|
+
it('goes quiet again once pruned back under budget', () => {
|
|
43
|
+
const dir = make({ 'AGENTS.md': 5000 });
|
|
44
|
+
(0, budget_1.checkBudget)(dir);
|
|
45
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), 'a'.repeat(4000));
|
|
46
|
+
expect((0, budget_1.checkBudget)(dir).status).toBe('within');
|
|
47
|
+
});
|
|
48
|
+
it('does not re-pin on later checks, so the budget is a real ratchet', () => {
|
|
49
|
+
// If a later check re-pinned, growth would always look like the new normal and
|
|
50
|
+
// the budget would never mean anything.
|
|
51
|
+
const dir = make({ 'AGENTS.md': 5000 });
|
|
52
|
+
(0, budget_1.checkBudget)(dir);
|
|
53
|
+
fs_1.default.appendFileSync(path_1.default.join(dir, 'AGENTS.md'), 'b'.repeat(9000));
|
|
54
|
+
(0, budget_1.checkBudget)(dir);
|
|
55
|
+
expect((0, budget_1.readConfig)(dir).maxBytes).toBe(5000);
|
|
56
|
+
});
|
|
57
|
+
it('counts only the files that exist', () => {
|
|
58
|
+
const dir = make({ 'AGENTS.md': 1000 }); // no CONSTITUTION.md, no CLAUDE.md
|
|
59
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
60
|
+
expect(report.totalBytes).toBe(1000);
|
|
61
|
+
expect(report.breakdown.map(b => b.file)).toEqual(['AGENTS.md']);
|
|
62
|
+
});
|
|
63
|
+
it('re-pins when the config is unreadable rather than treating it as no limit', () => {
|
|
64
|
+
// An unparseable budget must not silently disable the check — that is the quiet
|
|
65
|
+
// direction of wrong, where it stops checking and still reports success.
|
|
66
|
+
const dir = make({ 'AGENTS.md': 2000 });
|
|
67
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
68
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, budget_1.CONFIG_FILE), '{ not json');
|
|
69
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
70
|
+
expect(report.status).toBe('pinned');
|
|
71
|
+
expect((0, budget_1.readConfig)(dir).maxBytes).toBe(2000);
|
|
72
|
+
});
|
|
73
|
+
it('honours a custom file list from the config', () => {
|
|
74
|
+
const dir = make({ 'AGENTS.md': 1000, 'OTHER.md': 500 });
|
|
75
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
76
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, budget_1.CONFIG_FILE), JSON.stringify({ files: ['OTHER.md'], maxBytes: 100 }));
|
|
77
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
78
|
+
expect(report.totalBytes).toBe(500);
|
|
79
|
+
expect(report.status).toBe('over');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('reporting', () => {
|
|
83
|
+
it('exits non-zero only when over budget', () => {
|
|
84
|
+
expect((0, context_budget_1.exitCodeFor)({ status: 'over', totalBytes: 2, maxBytes: 1, breakdown: [] })).toBe(1);
|
|
85
|
+
expect((0, context_budget_1.exitCodeFor)({ status: 'within', totalBytes: 1, maxBytes: 2, breakdown: [] })).toBe(0);
|
|
86
|
+
expect((0, context_budget_1.exitCodeFor)({ status: 'pinned', totalBytes: 1, maxBytes: 1, breakdown: [] })).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
it('offers the three choices when over, since this runs while a human is present', () => {
|
|
89
|
+
const out = (0, context_budget_1.formatReport)({
|
|
90
|
+
status: 'over',
|
|
91
|
+
totalBytes: 227_000,
|
|
92
|
+
maxBytes: 224_000,
|
|
93
|
+
breakdown: [{ file: 'AGENTS.md', bytes: 145_000 }],
|
|
94
|
+
});
|
|
95
|
+
expect(out).toContain('Prune');
|
|
96
|
+
expect(out).toContain('Raise');
|
|
97
|
+
expect(out).toContain('Accept');
|
|
98
|
+
expect(out).toMatch(/~5[0-9]k tokens/);
|
|
99
|
+
});
|
|
100
|
+
});
|