agentic-workflow-manager 3.7.0 → 3.9.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
+ }
@@ -0,0 +1,123 @@
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.preflight = preflight;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const status_1 = require("../sensors/status");
10
+ const init_1 = require("../sensors/init");
11
+ const MANIFEST = path_1.default.join('.awm', 'sensors.json');
12
+ /**
13
+ * The agent needs project context delivered every session. A repo with neither file
14
+ * hands every agent — and every teammate's agent — a blank slate.
15
+ */
16
+ function checkContext(cwd) {
17
+ const present = ['AGENTS.md', 'CLAUDE.md', 'CONSTITUTION.md']
18
+ .filter(f => fs_1.default.existsSync(path_1.default.join(cwd, f)));
19
+ if (present.length === 0) {
20
+ return {
21
+ id: 'context',
22
+ ok: false,
23
+ detail: 'no AGENTS.md, CLAUDE.md or CONSTITUTION.md',
24
+ remedy: 'run the project-context-init skill (AGENTS.md) or project-constitution (CONSTITUTION.md)',
25
+ };
26
+ }
27
+ return { id: 'context', ok: true, detail: present.join(', ') };
28
+ }
29
+ function readManifest(cwd) {
30
+ try {
31
+ return JSON.parse(fs_1.default.readFileSync(path_1.default.join(cwd, MANIFEST), 'utf-8'));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /**
38
+ * A repo may legitimately have no sensors — but it has to SAY so, in a committed file.
39
+ *
40
+ * That is why opting out requires a manifest with its sensors disabled rather than
41
+ * simply having no manifest. "We decided not to gate this repo" and "nobody ever ran
42
+ * `awm sensors init`" look identical from the outside, and on a team the second one is
43
+ * the common case. Requiring the manifest turns the decision into a reviewable diff.
44
+ */
45
+ function checkManifest(cwd, manifest) {
46
+ if (!fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST))) {
47
+ return {
48
+ id: 'manifest',
49
+ ok: false,
50
+ detail: 'no .awm/sensors.json',
51
+ remedy: 'run `awm sensors init` (to opt out deliberately, init and set every sensor '
52
+ + '`"enabled": false` — an unconfigured repo and a deliberate opt-out must not look alike)',
53
+ };
54
+ }
55
+ if (!manifest) {
56
+ return {
57
+ id: 'manifest',
58
+ ok: false,
59
+ detail: '.awm/sensors.json is not valid JSON',
60
+ remedy: 'fix or regenerate it with `awm sensors init`',
61
+ };
62
+ }
63
+ const total = Object.keys(manifest.sensors ?? {}).length;
64
+ const enabled = Object.values(manifest.sensors ?? {}).filter(s => s.enabled !== false).length;
65
+ return {
66
+ id: 'manifest',
67
+ ok: true,
68
+ detail: enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
69
+ : `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`,
70
+ };
71
+ }
72
+ /** Every enabled sensor's command must resolve. This is the check nothing was calling. */
73
+ function checkTools(cwd) {
74
+ const status = (0, status_1.computeSensorStatus)(cwd);
75
+ if (status.overall === 'NOT_CONFIGURED') {
76
+ return { id: 'tools', ok: false, detail: 'no manifest to check', remedy: 'run `awm sensors init`' };
77
+ }
78
+ const broken = Object.entries(status.checks).filter(([, c]) => !c.ok);
79
+ if (broken.length > 0) {
80
+ return {
81
+ id: 'tools',
82
+ ok: false,
83
+ detail: broken.map(([name, c]) => `${name}: ${c.detail}`).join('; '),
84
+ remedy: 'install the missing tools/configs, or disable those sensors deliberately',
85
+ };
86
+ }
87
+ return { id: 'tools', ok: true, detail: `${Object.keys(status.checks).length} sensor(s) runnable` };
88
+ }
89
+ /**
90
+ * A manifest pinned to `generic` on a tree that clearly has a stack means the real
91
+ * sensors for that stack are simply absent — the gate runs, reports green, and has
92
+ * checked almost nothing. `runSensors` self-heals this at run time via `reconcilePack`,
93
+ * but only when a registry is reachable; saying it out loud here costs nothing.
94
+ */
95
+ function checkPack(cwd, manifest) {
96
+ if (!manifest)
97
+ return { id: 'pack', ok: true, detail: 'skipped (no manifest)' };
98
+ const detection = (0, init_1.detectStack)(cwd);
99
+ if (manifest.pack === 'generic' && detection.pack !== 'generic') {
100
+ return {
101
+ id: 'pack',
102
+ ok: false,
103
+ detail: `manifest on 'generic' but the tree looks like '${detection.pack}' (${detection.indicators.join(', ')})`,
104
+ remedy: 'run `awm sensors init` to pick up the real pack for this stack',
105
+ };
106
+ }
107
+ return { id: 'pack', ok: true, detail: `${manifest.pack} matches the detected stack` };
108
+ }
109
+ function preflight(cwd = process.cwd()) {
110
+ const manifest = readManifest(cwd);
111
+ const manifestExists = fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST));
112
+ const checks = [
113
+ checkContext(cwd),
114
+ checkManifest(cwd, manifest),
115
+ // Skipped when there is no manifest: reporting "tools broken" on a repo that was
116
+ // never set up buries the one thing the operator needs to read.
117
+ ...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest)] : []),
118
+ ];
119
+ const status = !manifestExists ? 'not_configured'
120
+ : checks.every(c => c.ok) ? 'ready'
121
+ : 'degraded';
122
+ return { status, checks };
123
+ }
@@ -0,0 +1,50 @@
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.registerPreflightCommand = registerPreflightCommand;
9
+ const picocolors_1 = __importDefault(require("picocolors"));
10
+ const checks_1 = require("./checks");
11
+ /**
12
+ * Exit code. Anything but `ready` exits 1.
13
+ *
14
+ * Unlike `awm sensors run` — which exits 0 on `not_certified` because exit 2 is a
15
+ * blocking error in Claude Code hooks — preflight is never a hook. It is invoked
16
+ * explicitly by a phase gate, so the exit code can carry the verdict and the caller
17
+ * does not have to remember to read a field out of JSON.
18
+ */
19
+ function exitCodeFor(report) {
20
+ return report.status === 'ready' ? 0 : 1;
21
+ }
22
+ function formatReport(report) {
23
+ const lines = report.checks.map(c => ` ${c.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘')} ${c.id.padEnd(9)} ${c.detail}`
24
+ + (c.remedy ? `\n ${picocolors_1.default.dim('→ ' + c.remedy)}` : ''));
25
+ if (report.status === 'ready') {
26
+ return `${picocolors_1.default.green('✔')} Harness ready — this project can be gated.\n${lines.join('\n')}\n`;
27
+ }
28
+ const headline = report.status === 'not_configured'
29
+ ? `${picocolors_1.default.red('✘')} AWM is not configured in this project.`
30
+ : `${picocolors_1.default.red('✘')} Harness degraded — it declares sensors it cannot run.`;
31
+ return `${headline}\n${lines.join('\n')}\n\n`
32
+ + ` ${picocolors_1.default.bold('Do not hand this off to an unattended run.')} Every quality phase downstream\n`
33
+ + ` (implementer, reviewers, post-qa) consumes \`awm sensors run\`. With the harness in\n`
34
+ + ` this state the gate reports on checks that never ran, and nobody finds out until a\n`
35
+ + ` bad change is already merged.\n`;
36
+ }
37
+ function registerPreflightCommand(program) {
38
+ program
39
+ .command('preflight')
40
+ .description('verify the project harness can actually gate before development starts')
41
+ .option('--json', 'emit the report as JSON')
42
+ .option('--cwd <path>', 'project directory to check (default: current)')
43
+ .action((opts) => {
44
+ const report = (0, checks_1.preflight)(opts.cwd ?? process.cwd());
45
+ process.stdout.write(opts.json ? JSON.stringify(report, null, 2) + '\n' : formatReport(report));
46
+ const code = exitCodeFor(report);
47
+ if (code !== 0)
48
+ process.exit(code);
49
+ });
50
+ }
package/dist/src/index.js CHANGED
@@ -26,6 +26,8 @@ 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");
30
+ const preflight_1 = require("./commands/preflight");
29
31
  const doctor_1 = require("./commands/doctor");
30
32
  const backup_1 = require("./commands/backup");
31
33
  const init_1 = require("./commands/init");
@@ -606,6 +608,8 @@ miroCmd.command('sync <storyMapPath>')
606
608
  (0, hooks_1.registerHooksCommand)(program);
607
609
  (0, sensors_1.registerSensorsCommand)(program);
608
610
  (0, ledger_1.registerLedgerCommand)(program);
611
+ (0, context_budget_1.registerContextBudgetCommand)(program);
612
+ (0, preflight_1.registerPreflightCommand)(program);
609
613
  (0, doctor_1.registerDoctorCommand)(program);
610
614
  (0, backup_1.registerBackupCommand)(program);
611
615
  (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
+ });
@@ -0,0 +1,123 @@
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 checks_1 = require("../../../src/commands/preflight/checks");
10
+ const preflight_1 = require("../../../src/commands/preflight");
11
+ /** CLAUDE.md: no test may reach the real ~/.awm. Everything here is a tmpdir. */
12
+ function project(opts = {}) {
13
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-'));
14
+ for (const f of opts.context ?? ['AGENTS.md'])
15
+ fs_1.default.writeFileSync(path_1.default.join(dir, f), '# ctx\n');
16
+ for (const f of opts.files ?? []) {
17
+ fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(dir, f)), { recursive: true });
18
+ fs_1.default.writeFileSync(path_1.default.join(dir, f), '');
19
+ }
20
+ for (const b of opts.bins ?? []) {
21
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'node_modules', '.bin'), { recursive: true });
22
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'node_modules', '.bin', b), '');
23
+ }
24
+ if (opts.manifest !== undefined) {
25
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
26
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), typeof opts.manifest === 'string' ? opts.manifest : JSON.stringify(opts.manifest));
27
+ }
28
+ return dir;
29
+ }
30
+ const dirs = [];
31
+ const make = (o) => { const d = project(o); dirs.push(d); return d; };
32
+ afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
33
+ const check = (r, id) => r.checks.find(c => c.id === id);
34
+ describe('preflight', () => {
35
+ it('reports not_configured when no sensor manifest exists', () => {
36
+ // The team-rollout case: a developer clones the repo and never runs
37
+ // `awm sensors init`. Today nothing notices until an unattended run is already
38
+ // in flight and every quality phase is consuming a gate that certifies nothing.
39
+ const dir = make();
40
+ const report = (0, checks_1.preflight)(dir);
41
+ expect(report.status).toBe('not_configured');
42
+ expect(check(report, 'manifest').ok).toBe(false);
43
+ });
44
+ it('keeps not_configured and degraded apart', () => {
45
+ // "You never set this up" and "you set it up and it broke" need different
46
+ // remedies. Collapsing them is how an absent check reads as a passing one.
47
+ const never = make();
48
+ const broken = make({
49
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } }, // no local eslint
50
+ });
51
+ expect((0, checks_1.preflight)(never).status).toBe('not_configured');
52
+ expect((0, checks_1.preflight)(broken).status).toBe('degraded');
53
+ });
54
+ it('is ready when the declared sensors can actually run', () => {
55
+ const dir = make({
56
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
57
+ bins: ['eslint'],
58
+ files: ['package.json'],
59
+ });
60
+ const report = (0, checks_1.preflight)(dir);
61
+ expect(report.status).toBe('ready');
62
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(0);
63
+ });
64
+ it('catches a sensor whose tool is not installed locally', () => {
65
+ // This is the check that existed and nothing in the flow was calling.
66
+ const dir = make({
67
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
68
+ files: ['package.json'],
69
+ });
70
+ const report = (0, checks_1.preflight)(dir);
71
+ expect(report.status).toBe('degraded');
72
+ expect(check(report, 'tools').detail).toContain('eslint');
73
+ });
74
+ it('accepts a deliberate opt-out, but only when it is written down', () => {
75
+ // A repo may legitimately have no sensors — it just has to SAY so in a committed
76
+ // file, so "we decided not to gate this" cannot be mistaken for "nobody set it up".
77
+ const optedOut = make({
78
+ manifest: { pack: 'generic', sensors: { security: { cmd: 'semgrep .', enabled: false } } },
79
+ });
80
+ const report = (0, checks_1.preflight)(optedOut);
81
+ expect(report.status).toBe('ready');
82
+ expect(check(report, 'manifest').detail).toContain('opt-out');
83
+ });
84
+ it('flags a manifest stuck on generic while the tree has a real stack', () => {
85
+ // The gate would run, report green, and have checked almost nothing.
86
+ const dir = make({
87
+ manifest: { pack: 'generic', sensors: {} },
88
+ files: ['package.json'],
89
+ });
90
+ const report = (0, checks_1.preflight)(dir);
91
+ expect(report.status).toBe('degraded');
92
+ expect(check(report, 'pack').ok).toBe(false);
93
+ });
94
+ it('flags a repo with no context contract at all', () => {
95
+ const dir = make({
96
+ context: [],
97
+ manifest: { pack: 'generic', sensors: {} },
98
+ });
99
+ expect(check((0, checks_1.preflight)(dir), 'context').ok).toBe(false);
100
+ });
101
+ it('treats an unparseable manifest as a failure, not as absent', () => {
102
+ const dir = make({ manifest: '{ not json' });
103
+ const report = (0, checks_1.preflight)(dir);
104
+ expect(report.status).toBe('degraded');
105
+ expect(check(report, 'manifest').detail).toContain('not valid JSON');
106
+ });
107
+ it('exits non-zero for anything but ready, so the caller need not parse JSON', () => {
108
+ // Unlike `awm sensors run` — which exits 0 on not_certified because exit 2 blocks
109
+ // Claude Code hooks — preflight is never a hook, so the verdict rides the exit code
110
+ // instead of depending on every agent remembering to read a field.
111
+ expect((0, preflight_1.exitCodeFor)({ status: 'not_configured', checks: [] })).toBe(1);
112
+ expect((0, preflight_1.exitCodeFor)({ status: 'degraded', checks: [] })).toBe(1);
113
+ expect((0, preflight_1.exitCodeFor)({ status: 'ready', checks: [] })).toBe(0);
114
+ });
115
+ it('tells the operator not to hand a broken harness to an unattended run', () => {
116
+ const out = (0, preflight_1.formatReport)({
117
+ status: 'not_configured',
118
+ checks: [{ id: 'manifest', ok: false, detail: 'no .awm/sensors.json', remedy: 'run `awm sensors init`' }],
119
+ });
120
+ expect(out).toContain('unattended');
121
+ expect(out).toContain('awm sensors init');
122
+ });
123
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.7.0",
3
+ "version": "3.9.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"