agentic-workflow-manager 3.11.0 → 3.13.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.
Files changed (43) hide show
  1. package/dist/src/commands/doctor.js +1 -1
  2. package/dist/src/commands/preflight/checks.js +63 -5
  3. package/dist/src/commands/preflight/index.js +6 -1
  4. package/dist/src/commands/sensors/baseline.js +4 -3
  5. package/dist/src/core/context/materializer.js +7 -0
  6. package/dist/src/core/context/orchestrator.js +26 -6
  7. package/dist/src/core/context/strategies/codex-agents.js +69 -15
  8. package/dist/src/core/diagnostics/context.js +11 -6
  9. package/dist/src/core/diagnostics/provider-checks.js +92 -11
  10. package/dist/src/core/init/mutation-targets.js +18 -2
  11. package/dist/src/core/init/provider-facts.js +5 -4
  12. package/dist/src/core/init/steps.js +16 -2
  13. package/dist/src/core/install-planner.js +56 -6
  14. package/dist/src/core/install-transaction.js +55 -6
  15. package/dist/src/core/provider-artifacts.js +1 -1
  16. package/dist/src/core/renderers/copilot-instructions.js +28 -0
  17. package/dist/src/core/renderers/cursor-mdc.js +49 -0
  18. package/dist/src/core/renderers/skill-source.js +50 -0
  19. package/dist/src/core/skill-integrity.js +1 -1
  20. package/dist/src/index.js +8 -0
  21. package/dist/src/providers/index.js +69 -2
  22. package/dist/tests/commands/add.test.js +96 -0
  23. package/dist/tests/commands/doctor.test.js +25 -0
  24. package/dist/tests/commands/init.test.js +56 -0
  25. package/dist/tests/commands/preflight/preflight.test.js +95 -0
  26. package/dist/tests/core/bundle-install.test.js +63 -0
  27. package/dist/tests/core/context/materializer.test.js +8 -0
  28. package/dist/tests/core/context/orchestrator.test.js +51 -0
  29. package/dist/tests/core/context/strategies/codex-agents.test.js +157 -20
  30. package/dist/tests/core/diagnostics/checks.test.js +1 -0
  31. package/dist/tests/core/diagnostics/provider-tier.test.js +292 -0
  32. package/dist/tests/core/init/mutation-targets.test.js +63 -0
  33. package/dist/tests/core/init/provider-facts.test.js +16 -0
  34. package/dist/tests/core/init/steps.test.js +37 -0
  35. package/dist/tests/core/install-planner.test.js +118 -0
  36. package/dist/tests/core/install-transaction.test.js +109 -0
  37. package/dist/tests/core/provider-artifacts.test.js +11 -0
  38. package/dist/tests/core/renderers/copilot-instructions.test.js +47 -0
  39. package/dist/tests/core/renderers/cursor-mdc.test.js +137 -0
  40. package/dist/tests/core/skill-integrity.test.js +18 -0
  41. package/dist/tests/providers/index.test.js +45 -1
  42. package/dist/tests/providers/injection-config.test.js +16 -0
  43. package/package.json +1 -1
@@ -101,7 +101,7 @@ function renderProviderReport(report) {
101
101
  lines.push(picocolors_1.default.bold('AWM · harness status'));
102
102
  lines.push('');
103
103
  for (const provider of report.providers) {
104
- lines.push(`Provider: ${provider.label}`);
104
+ lines.push(`Provider: ${provider.label} ${picocolors_1.default.dim(`(${provider.tier})`)}`);
105
105
  for (const check of provider.checks)
106
106
  lines.push(providerCheckLine(check));
107
107
  lines.push('');
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const status_1 = require("../sensors/status");
11
11
  const init_1 = require("../sensors/init");
12
+ const baseline_1 = require("../sensors/baseline");
12
13
  const paths_1 = require("../../core/paths");
13
14
  const MANIFEST = path_1.default.join('.awm', 'sensors.json');
14
15
  /**
@@ -36,6 +37,18 @@ function readManifest(cwd) {
36
37
  return null;
37
38
  }
38
39
  }
40
+ /**
41
+ * Total sensor entries and how many are enabled (`enabled !== false`, so a missing
42
+ * `enabled` field defaults to counted-as-enabled). Shared by `checkManifest` and
43
+ * `checkSensorsBaseline` — they were previously two copies of the identical
44
+ * predicate, which post-implementation-qa flagged: a hardening applied to one (e.g.
45
+ * guarding a malformed sensor entry) would silently NOT apply to the other unless
46
+ * someone remembered to edit both. One function, two callers, one place to harden.
47
+ */
48
+ function countEnabledSensors(manifest) {
49
+ const entries = Object.values(manifest.sensors ?? {});
50
+ return { total: entries.length, enabled: entries.filter(s => s.enabled !== false).length };
51
+ }
39
52
  /**
40
53
  * A repo may legitimately have no sensors — but it has to SAY so, in a committed file.
41
54
  *
@@ -62,8 +75,7 @@ function checkManifest(cwd, manifest) {
62
75
  remedy: 'fix or regenerate it with `awm sensors init`',
63
76
  };
64
77
  }
65
- const total = Object.keys(manifest.sensors ?? {}).length;
66
- const enabled = Object.values(manifest.sensors ?? {}).filter(s => s.enabled !== false).length;
78
+ const { total, enabled } = countEnabledSensors(manifest);
67
79
  // total === 0 is NOT an opt-out: a deliberate opt-out lists every known sensor NAME
68
80
  // explicitly with `enabled: false` (total > 0, enabled === 0). Zero entries means
69
81
  // nothing was ever configured — most commonly because the registry had no pack.json
@@ -135,6 +147,51 @@ function checkPack(cwd, manifest) {
135
147
  }
136
148
  return { id: 'pack', ok: true, detail: `${manifest.pack} matches the detected stack` };
137
149
  }
150
+ /**
151
+ * Advisory only — `ok` is ALWAYS `true`, same contract as `checkHost` below. A team
152
+ * adopting AWM on a legacy repo starts with pre-existing sensor findings; the ratchet
153
+ * (`awm sensors baseline`, `.awm/sensors.baseline.json`) exists precisely to snapshot
154
+ * those as accepted debt so the gate only fails on genuinely NEW findings. But nothing
155
+ * today surfaces that the mechanism exists — the team discovers it only after hitting a
156
+ * wall of red findings and going looking. This nudges them toward it before that
157
+ * happens. It never blocks preflight: a repo can legitimately have zero debt to
158
+ * snapshot (sensors enabled from day one), and "no baseline yet" is not itself a
159
+ * failure — only the operator's lack of awareness that baselining is an option is the
160
+ * problem this addresses.
161
+ *
162
+ * Only called when a manifest exists (see the conditional spread in `preflight()`) —
163
+ * there is nothing to baseline without sensors configured in the first place, so this
164
+ * mirrors how `checkTools`/`checkPack` are skipped entirely rather than reported on a
165
+ * repo that was never set up.
166
+ *
167
+ * Also requires at least one ENABLED sensor, via the same `countEnabledSensors` helper
168
+ * `checkManifest` uses — a deliberate opt-out (every sensor `enabled: false`) or an
169
+ * unparseable/empty manifest has nothing to baseline either, and nudging "run `awm
170
+ * sensors baseline`" there would be actively misleading rather than merely unnecessary.
171
+ *
172
+ * Presence is checked via `readBaseline` (same function `partition()` uses at gate time
173
+ * to decide suppression), not a raw `fs.existsSync` — a baseline PATH that exists but
174
+ * isn't a readable JSON file (e.g. a stray directory at that path) is treated by the
175
+ * real gate as "no baseline, nothing suppressed"; `existsSync` alone would have reported
176
+ * "baseline present" for that same case, reassuring the operator that debt is being
177
+ * suppressed when it silently is not.
178
+ */
179
+ function checkSensorsBaseline(cwd, manifest) {
180
+ const enabled = manifest ? countEnabledSensors(manifest).enabled : 0;
181
+ if (enabled === 0) {
182
+ return { id: 'sensors-baseline', ok: true, detail: 'no enabled sensors — nothing to baseline' };
183
+ }
184
+ if ((0, baseline_1.readBaseline)(cwd) !== null) {
185
+ return { id: 'sensors-baseline', ok: true, detail: 'baseline present' };
186
+ }
187
+ return {
188
+ id: 'sensors-baseline',
189
+ ok: true,
190
+ detail: 'sensors configured, no baseline yet — awm sensors baseline',
191
+ remedy: 'run `awm sensors baseline` to snapshot pre-existing findings as accepted debt, '
192
+ + 'so the gate only chases new problems',
193
+ };
194
+ }
138
195
  /**
139
196
  * Extract just the hostname portion of a git remote URL — never match against the
140
197
  * full URL string. A bare substring check against the whole remote (`remote.includes
@@ -224,9 +281,10 @@ function preflight(cwd = process.cwd()) {
224
281
  const checks = [
225
282
  checkContext(cwd),
226
283
  checkManifest(cwd, manifest),
227
- // Skipped when there is no manifest: reporting "tools broken" on a repo that was
228
- // never set up buries the one thing the operator needs to read.
229
- ...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest)] : []),
284
+ // Skipped when there is no manifest: reporting "tools broken" (or nudging toward
285
+ // a baseline that has nothing to snapshot) on a repo that was never set up
286
+ // buries the one thing the operator needs to read.
287
+ ...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest), checkSensorsBaseline(cwd, manifest)] : []),
230
288
  // Runs unconditionally — orthogonal to sensor configuration entirely, this is
231
289
  // about PR/MR tooling, not sensors.
232
290
  checkHost(cwd),
@@ -20,7 +20,12 @@ function exitCodeFor(report) {
20
20
  return report.status === 'ready' ? 0 : 1;
21
21
  }
22
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}`
23
+ // Computed from the actual ids present, not hardcoded to the widest id THIS
24
+ // report happens to have — a fixed literal here silently misaligns the moment a
25
+ // longer `PreflightCheck['id']` is added (confirmed: 'sensors-baseline', at 16
26
+ // chars, broke a hardcoded 9-char pad).
27
+ const idWidth = Math.max(0, ...report.checks.map(c => c.id.length));
28
+ const lines = report.checks.map(c => ` ${c.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘')} ${c.id.padEnd(idWidth)} ${c.detail}`
24
29
  + (c.remedy ? `\n ${picocolors_1.default.dim('→ ' + c.remedy)}` : ''));
25
30
  if (report.status === 'ready') {
26
31
  return `${picocolors_1.default.green('✔')} Harness ready — this project can be gated.\n${lines.join('\n')}\n`;
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.BASELINE_FILE = void 0;
6
7
  exports.fingerprint = fingerprint;
7
8
  exports.readBaseline = readBaseline;
8
9
  exports.writeBaseline = writeBaseline;
@@ -11,7 +12,7 @@ exports.buildBaseline = buildBaseline;
11
12
  const crypto_1 = __importDefault(require("crypto"));
12
13
  const fs_1 = __importDefault(require("fs"));
13
14
  const path_1 = __importDefault(require("path"));
14
- const BASELINE_FILE = path_1.default.join('.awm', 'sensors.baseline.json');
15
+ exports.BASELINE_FILE = path_1.default.join('.awm', 'sensors.baseline.json');
15
16
  /**
16
17
  * Mask runs of digits so location noise embedded in messages (e.g. the tsc
17
18
  * formatter writes "... line 199 ...") doesn't change the fingerprint when code
@@ -43,7 +44,7 @@ function fingerprint(sensor, e) {
43
44
  return crypto_1.default.createHash('sha1').update(basis).digest('hex');
44
45
  }
45
46
  function readBaseline(cwd) {
46
- const p = path_1.default.join(cwd, BASELINE_FILE);
47
+ const p = path_1.default.join(cwd, exports.BASELINE_FILE);
47
48
  if (!fs_1.default.existsSync(p))
48
49
  return null;
49
50
  try {
@@ -55,7 +56,7 @@ function readBaseline(cwd) {
55
56
  }
56
57
  function writeBaseline(cwd, baseline) {
57
58
  fs_1.default.mkdirSync(path_1.default.join(cwd, '.awm'), { recursive: true });
58
- fs_1.default.writeFileSync(path_1.default.join(cwd, BASELINE_FILE), JSON.stringify(baseline, null, 2), 'utf-8');
59
+ fs_1.default.writeFileSync(path_1.default.join(cwd, exports.BASELINE_FILE), JSON.stringify(baseline, null, 2), 'utf-8');
59
60
  }
60
61
  /**
61
62
  * Split a sensor's findings into new vs baseline-suppressed. With no accepted
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.globalContextPath = globalContextPath;
7
+ exports.projectContextPath = projectContextPath;
7
8
  exports.materialize = materialize;
8
9
  // cli/src/core/context/materializer.ts
9
10
  const fs_1 = __importDefault(require("fs"));
@@ -13,6 +14,12 @@ const paths_1 = require("../paths");
13
14
  function globalContextPath() {
14
15
  return path_1.default.join((0, paths_1.awmHome)(), 'context', 'awm-context.md');
15
16
  }
17
+ /** Project-scoped materialized-source path — mirrors globalContextPath()'s shape,
18
+ * rooted in the project instead of ~/.awm. This is the MATERIALIZED SOURCE
19
+ * content path, distinct from the injection TARGET file (<projectRoot>/AGENTS.md). */
20
+ function projectContextPath(projectRoot) {
21
+ return path_1.default.join(projectRoot, '.awm', 'context', 'awm-context.md');
22
+ }
16
23
  function materialize(ctx, absPath, scope) {
17
24
  let onDisk = null;
18
25
  try {
@@ -28,25 +28,42 @@ class InjectionOrchestrator {
28
28
  case 'managed-agents-md': return new codex_agents_1.CodexAgentsStrategy();
29
29
  }
30
30
  }
31
+ /** Materialized-source content path for `op.scope` — global under ~/.awm, local under the project. */
32
+ contextPathFor(op) {
33
+ if (this.overrides.contextPathOverride)
34
+ return this.overrides.contextPathOverride;
35
+ if (op.scope === 'local') {
36
+ if (!op.projectRoot)
37
+ throw new Error('projectRoot is required for local-scope context operations');
38
+ return (0, materializer_1.projectContextPath)(op.projectRoot);
39
+ }
40
+ return (0, materializer_1.globalContextPath)();
41
+ }
31
42
  /** Full input: builds context from registry and materializes to disk. Used by installContext only. */
32
43
  inputFor(op) {
33
44
  const ctx = (0, provider_1.buildContext)({ registryRoot: op.registryRoot, profileExtensions: op.profileExtensions });
34
- const absPath = this.overrides.contextPathOverride ?? (0, materializer_1.globalContextPath)();
45
+ const absPath = this.contextPathFor(op);
35
46
  const ref = (0, materializer_1.materialize)(ctx, absPath, op.scope);
36
- return { ref, registryRoot: op.registryRoot, installMethod: op.installMethod, agent: op.agent, scope: op.scope };
47
+ return {
48
+ ref, registryRoot: op.registryRoot, installMethod: op.installMethod,
49
+ agent: op.agent, scope: op.scope, projectRoot: op.projectRoot,
50
+ };
37
51
  }
38
52
  /** Path-only input: no buildContext, no materialize. Safe for remove() which never reads contentHash. */
39
53
  pathInputFor(op) {
40
- const absPath = this.overrides.contextPathOverride ?? (0, materializer_1.globalContextPath)();
54
+ const absPath = this.contextPathFor(op);
41
55
  const ref = { absPath, scope: op.scope, contentHash: '' };
42
- return { ref, registryRoot: op.registryRoot, installMethod: op.installMethod, agent: op.agent, scope: op.scope };
56
+ return {
57
+ ref, registryRoot: op.registryRoot, installMethod: op.installMethod,
58
+ agent: op.agent, scope: op.scope, projectRoot: op.projectRoot,
59
+ };
43
60
  }
44
61
  /**
45
62
  * Status input: builds context from registry (to get expected hash) but does NOT materialize.
46
63
  * Avoids silently correcting a stale file before the strategy can observe it.
47
64
  */
48
65
  statusInputFor(op) {
49
- const absPath = this.overrides.contextPathOverride ?? (0, materializer_1.globalContextPath)();
66
+ const absPath = this.contextPathFor(op);
50
67
  let contentHash = '';
51
68
  try {
52
69
  const ctx = (0, provider_1.buildContext)({ registryRoot: op.registryRoot, profileExtensions: op.profileExtensions });
@@ -59,7 +76,10 @@ class InjectionOrchestrator {
59
76
  throw err;
60
77
  }
61
78
  const ref = { absPath, scope: op.scope, contentHash };
62
- return { ref, registryRoot: op.registryRoot, installMethod: op.installMethod, agent: op.agent, scope: op.scope };
79
+ return {
80
+ ref, registryRoot: op.registryRoot, installMethod: op.installMethod,
81
+ agent: op.agent, scope: op.scope, projectRoot: op.projectRoot,
82
+ };
63
83
  }
64
84
  installContext(op) {
65
85
  const provider = this.provider(op.agent);
@@ -6,7 +6,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.CodexAgentsStrategy = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
- const paths_1 = require("../../paths");
10
9
  const atomic_file_1 = require("../../atomic-file");
11
10
  const managed_block_1 = require("../managed-block");
12
11
  const provider_1 = require("../provider");
@@ -26,6 +25,17 @@ function injectFile(file, markdown) {
26
25
  (0, atomic_file_1.writeFileAtomic)(file, merged);
27
26
  return 'injected';
28
27
  }
28
+ /** For local-scope providers (Cursor/Copilot), context injection (`inject`) and project
29
+ * constitution injection (`injectProject`) target the SAME file — `targetFile`'s local
30
+ * branch resolves both to `<projectRoot>/<localFile>` (AGENTS.md). Global-scope providers
31
+ * (Codex) don't have this collision: context goes to a separate global file, constitution
32
+ * stays project-local. `mergeManagedBlock` supports only one managed slot per file, so for
33
+ * local scope the two payloads must be combined into a single write — `inject`/`status`
34
+ * append `PROJECT_GUIDANCE` here, and `injectProject` skips its own AGENTS.md write for
35
+ * these providers (see its `contextCoversThisFile` check) so there is exactly one writer. */
36
+ function withProjectGuidance(markdown, scope) {
37
+ return scope === 'local' ? `${markdown}\n\n${PROJECT_GUIDANCE}` : markdown;
38
+ }
29
39
  class CodexAgentsStrategy {
30
40
  globalPath(provider) {
31
41
  const injection = provider.injection;
@@ -38,16 +48,16 @@ class CodexAgentsStrategy {
38
48
  return injection.globalPath;
39
49
  }
40
50
  inject(input, provider) {
41
- this.assertGlobalInput(input);
51
+ const scope = this.assertSupportedScope(input, provider);
42
52
  if (!fs_1.default.existsSync(input.ref.absPath)) {
43
53
  throw new Error(`materialized context not found at ${input.ref.absPath}`);
44
54
  }
45
55
  const markdown = fs_1.default.readFileSync(input.ref.absPath, 'utf8');
46
- return injectFile(this.globalPath(provider), markdown);
56
+ return injectFile(this.targetFile(provider, scope, input.projectRoot), withProjectGuidance(markdown, scope));
47
57
  }
48
58
  remove(input, provider) {
49
- this.assertGlobalInput(input);
50
- const file = this.globalPath(provider);
59
+ const scope = this.assertSupportedScope(input, provider);
60
+ const file = this.targetFile(provider, scope, input.projectRoot);
51
61
  if (!fs_1.default.existsSync(file))
52
62
  return;
53
63
  const original = read(file);
@@ -56,8 +66,8 @@ class CodexAgentsStrategy {
56
66
  (0, atomic_file_1.writeFileAtomic)(file, removed);
57
67
  }
58
68
  status(input, provider) {
59
- this.assertGlobalInput(input);
60
- const file = this.globalPath(provider);
69
+ const scope = this.assertSupportedScope(input, provider);
70
+ const file = this.targetFile(provider, scope, input.projectRoot);
61
71
  if (!fs_1.default.existsSync(file))
62
72
  return 'absent';
63
73
  const body = (0, managed_block_1.managedBlockBody)(read(file));
@@ -68,33 +78,77 @@ class CodexAgentsStrategy {
68
78
  const expected = fs_1.default.readFileSync(input.ref.absPath, 'utf8');
69
79
  if ((0, provider_1.sha256)(expected) !== input.ref.contentHash)
70
80
  return 'stale';
71
- return body === (0, managed_block_1.normalizeManagedBody)(expected) ? 'injected' : 'stale';
81
+ return body === (0, managed_block_1.normalizeManagedBody)(withProjectGuidance(expected, scope)) ? 'injected' : 'stale';
72
82
  }
73
- injectGlobal(context) {
83
+ injectGlobal(context, provider) {
74
84
  if (typeof context !== 'object' || context === null) {
75
85
  throw new Error('context must be an object');
76
86
  }
77
87
  if (typeof context.markdown !== 'string' || context.markdown.length === 0) {
78
88
  throw new Error('markdown must be a non-empty string');
79
89
  }
80
- return injectFile(path_1.default.join((0, paths_1.homeDir)(), '.codex', 'AGENTS.md'), context.markdown);
90
+ return injectFile(this.globalPath(provider), context.markdown);
81
91
  }
82
- injectProject(projectRoot) {
92
+ injectProject(projectRoot, provider, agent) {
83
93
  if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
84
94
  throw new Error('projectRoot must be a non-empty string');
85
95
  }
86
- return injectFile(path_1.default.join(projectRoot, 'AGENTS.md'), PROJECT_GUIDANCE);
96
+ // A provider whose context injection is ITSELF local-scope (globalPath === null —
97
+ // today: Cursor, Copilot) already owns this exact file's managed block via `inject()`
98
+ // (see withProjectGuidance) — writing PROJECT_GUIDANCE here too would be a second
99
+ // writer of the same single-slot block, silently overwriting whichever one runs last.
100
+ // Global-scope providers (Codex) have no such collision: their context targets a
101
+ // separate global file, so this is the only writer of the project AGENTS.md.
102
+ const injection = provider.injection;
103
+ const contextCoversThisFile = injection?.type === 'managed-agents-md' && injection.globalPath === null;
104
+ const result = contextCoversThisFile
105
+ ? 'unchanged'
106
+ : injectFile(path_1.default.join(projectRoot, 'AGENTS.md'), PROJECT_GUIDANCE);
107
+ let carrierResult = 'unchanged';
108
+ if (agent === 'cursor') {
109
+ // Cursor's Background/Cloud Agent does not reliably read AGENTS.md (open,
110
+ // staff-acknowledged bug on Cursor's own community forum, unresolved as of
111
+ // this research — see docs/plans/2026-08-07-team-rollout-hardening-design.md,
112
+ // D4 correction note). Interactive Agent mode DOES read AGENTS.md, unaffected.
113
+ // Write the same guidance as a redundant .mdc carrier with alwaysApply: true
114
+ // so the managed context survives regardless of which Cursor mode is active.
115
+ carrierResult = injectFile(path_1.default.join(projectRoot, '.cursor', 'rules', 'awm.mdc'), `---\ndescription: AWM project guidance (redundant carrier — see AGENTS.md)\nglobs:\nalwaysApply: true\n---\n\n${PROJECT_GUIDANCE}`);
116
+ }
117
+ return result === 'injected' || carrierResult === 'injected' ? 'injected' : 'unchanged';
118
+ }
119
+ /** The scope this provider's managed-agents-md injection operates at: 'local'
120
+ * when it has no global AGENTS.md-equivalent (globalPath === null), else 'global'. */
121
+ requiredScope(provider) {
122
+ const injection = provider.injection;
123
+ if (!injection || injection.type !== 'managed-agents-md') {
124
+ throw new Error('CodexAgentsStrategy requires a managed-agents-md provider');
125
+ }
126
+ return injection.globalPath === null ? 'local' : 'global';
127
+ }
128
+ /** The actual file this strategy injects/removes/checks, given a scope. */
129
+ targetFile(provider, scope, projectRoot) {
130
+ if (scope === 'global')
131
+ return this.globalPath(provider);
132
+ if (!projectRoot)
133
+ throw new Error('projectRoot is required for local-scope injection');
134
+ const injection = provider.injection;
135
+ if (!injection || injection.type !== 'managed-agents-md') {
136
+ throw new Error('CodexAgentsStrategy requires a managed-agents-md provider');
137
+ }
138
+ return path_1.default.join(projectRoot, injection.localFile);
87
139
  }
88
- assertGlobalInput(input) {
140
+ assertSupportedScope(input, provider) {
89
141
  if (typeof input !== 'object' || input === null) {
90
142
  throw new Error('input must be an object');
91
143
  }
92
- if (input.scope !== 'global' || input.ref?.scope !== 'global') {
93
- throw new Error('CodexAgentsStrategy supports only global injection');
144
+ const required = this.requiredScope(provider);
145
+ if (input.scope !== required || input.ref?.scope !== required) {
146
+ throw new Error(`CodexAgentsStrategy for ${provider.label} supports only ${required} injection`);
94
147
  }
95
148
  if (typeof input.ref.absPath !== 'string' || input.ref.absPath.length === 0) {
96
149
  throw new Error('input.ref.absPath must be a non-empty string');
97
150
  }
151
+ return required;
98
152
  }
99
153
  }
100
154
  exports.CodexAgentsStrategy = CodexAgentsStrategy;
@@ -111,14 +111,17 @@ function gatherMachine(bundles, agent = 'claude-code') {
111
111
  hookDegraded = hs.overall === 'DEGRADED';
112
112
  }
113
113
  catch { /* sin soporte de hooks → ausente */ }
114
- // devCore (bundle baseline)
114
+ // devCore (bundle baseline) — skillsDir is null only for providers with no global skill
115
+ // discovery mechanism (today: Copilot); treated the same as "nothing linked here yet".
115
116
  const skillsDir = (0, providers_1.providerFor)(agent).skill.global;
116
117
  const baseline = bundles.find((b) => b.scope === 'baseline');
117
118
  let devCorePresent = false;
118
119
  let brokenLinks = [];
119
120
  if (baseline) {
120
121
  const skillNames = (0, bundles_1.resolveBundleSkills)(baseline.name, bundles);
121
- const { linked, broken } = classifyLinks(skillNames, skillsDir);
122
+ const { linked, broken } = skillsDir !== null
123
+ ? classifyLinks(skillNames, skillsDir)
124
+ : { linked: [], broken: [] };
122
125
  const absent = skillNames.filter((s) => !linked.includes(s) && !broken.includes(s));
123
126
  devCorePresent = skillNames.length > 0 && (linked.length + broken.length) > 0;
124
127
  brokenLinks = [...broken, ...absent];
@@ -133,7 +136,7 @@ function gatherMachine(bundles, agent = 'claude-code') {
133
136
  // the real Codex+OpenCode coexistence E2E test
134
137
  // (tests/integration/codex-provider-isolated.test.ts).
135
138
  const agentConfig = (0, providers_1.providerFor)(agent).agent;
136
- if (agentConfig) {
139
+ if (agentConfig && agentConfig.global !== null) {
137
140
  const agentNames = (0, bundles_1.resolveBundleAgents)(baseline.name, bundles);
138
141
  if (agentNames.length > 0) {
139
142
  const filenames = agentNames.map((n) => agentConfig.renderer === 'codex-agent-toml' ? `${n}.toml` : n);
@@ -154,7 +157,7 @@ function gatherMachine(bundles, agent = 'claude-code') {
154
157
  catch { /* sin config → ningún ambient deseado */ }
155
158
  const installed = wanted.filter((name) => {
156
159
  const skillNames = (0, bundles_1.resolveBundleSkills)(name, bundles);
157
- if (skillNames.length === 0)
160
+ if (skillNames.length === 0 || skillsDir === null)
158
161
  return false;
159
162
  const { linked } = classifyLinks(skillNames, skillsDir);
160
163
  return linked.length === skillNames.length;
@@ -165,7 +168,9 @@ function gatherMachine(bundles, agent = 'claude-code') {
165
168
  devCore: { present: devCorePresent, brokenLinks },
166
169
  ambient: { wanted, installed },
167
170
  contextInjection: gatherContextInjection(),
168
- globalSkills: (0, skill_integrity_1.classifyGlobalSkills)(skillsDir, (0, registries_1.contentRoots)()),
171
+ globalSkills: skillsDir !== null
172
+ ? (0, skill_integrity_1.classifyGlobalSkills)(skillsDir, (0, registries_1.contentRoots)())
173
+ : { valid: [], repairable: [], dead: [] },
169
174
  };
170
175
  }
171
176
  function gatherProject(root, bundles, agent = 'claude-code') {
@@ -203,6 +208,6 @@ function gatherContext(opts = {}) {
203
208
  return {
204
209
  machine: gatherMachine(bundles, agent),
205
210
  project: root ? gatherProject(root, bundles, agent) : null,
206
- providers: (0, provider_checks_1.gatherProviderChecks)(agents, scanSkills),
211
+ providers: (0, provider_checks_1.gatherProviderChecks)(agents, scanSkills, root ?? undefined),
207
212
  };
208
213
  }
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.providerTier = providerTier;
6
7
  exports.gatherProviderChecks = gatherProviderChecks;
7
8
  // src/core/diagnostics/provider-checks.ts
8
9
  //
@@ -20,6 +21,26 @@ const provider_version_1 = require("../provider-version");
20
21
  const status_1 = require("../../commands/hooks/status");
21
22
  const orchestrator_1 = require("../context/orchestrator");
22
23
  const registries_1 = require("../registries");
24
+ /** File extension a healthy AWM install actually produces for each non-`'link'` renderer —
25
+ * used by `skillsGlobalCheck` to require AWM-shaped evidence, not just an arbitrary
26
+ * non-empty directory (a user's own unrelated file in `~/.cursor/rules` would otherwise
27
+ * read as `'supported'`). Renderers absent from this map (i.e. `'link'`) never reach the
28
+ * branch that reads it. */
29
+ const RENDERED_SKILL_EXTENSIONS = {
30
+ 'cursor-mdc': '.mdc',
31
+ 'copilot-instructions': '.instructions.md',
32
+ };
33
+ /** Structural classification, computed purely from `provider`'s config shape — see
34
+ * `ProviderTier`'s doc comment in `types.ts` for what each tier means. */
35
+ function providerTier(provider) {
36
+ if (provider.hooks)
37
+ return 'hooks-native';
38
+ if (provider.injection?.type === 'config-instructions')
39
+ return 'config-managed';
40
+ if (provider.injection)
41
+ return 'agents-md-managed';
42
+ return 'context-only';
43
+ }
23
44
  function binaryVersionCheck(agent) {
24
45
  const provider = (0, providers_1.providerFor)(agent);
25
46
  if (!provider.versionCommand || !provider.minimumVersion) {
@@ -41,7 +62,52 @@ function binaryVersionCheck(agent) {
41
62
  };
42
63
  }
43
64
  }
44
- function skillsGlobalCheck(dir, owners, integrity) {
65
+ /** Returns `null` (dropped, same convention as `agentsNativeCheck`/`hookTrustCheck`) when
66
+ * `dir` is null — i.e. the provider has no global skill discovery mechanism at all
67
+ * (today: Copilot, see `globalUnsupportedReason` in providers/index.ts).
68
+ *
69
+ * `renderer` gates which verification is possible: `classifyGlobalSkills` (via `integrity`)
70
+ * only ever sees symlinks — `if (!lst.isSymbolicLink()) continue;` — so for a rendered
71
+ * format (`cursor-mdc`, `copilot-instructions`) it scans a directory of real files and
72
+ * finds nothing, which would make `broken` silently read 0 regardless of whether the
73
+ * rendered files are actually well-formed. Reporting `'healthy'` from a scan that
74
+ * structurally can't see the files it's supposed to check would be a false green, so for
75
+ * any non-`'link'` renderer this reports presence only, honestly, via `detail`. */
76
+ function skillsGlobalCheck(dir, owners, integrity, renderer) {
77
+ if (dir === null)
78
+ return null;
79
+ if (renderer !== 'link') {
80
+ let entries;
81
+ try {
82
+ entries = fs_1.default.readdirSync(dir);
83
+ }
84
+ catch {
85
+ // best-effort: any readdirSync failure (absent dir, EACCES, …) reads the
86
+ // same as "nothing installed" here — a permissions error surfacing as
87
+ // `remediationCode: 'awm-init'` is a worse remedy than none, but this
88
+ // check has no channel to report "can't tell" separately from "absent"
89
+ // (same tradeoff already made by this file's agentsNativeCheck and by
90
+ // skill-integrity.ts's classifyGlobalSkills — a systemic, pre-existing
91
+ // pattern in this codebase, not introduced here).
92
+ entries = [];
93
+ }
94
+ // Require at least one entry with the extension this renderer actually produces,
95
+ // not just ANY file — a directory non-empty only because of the user's own
96
+ // pre-existing, unrelated rule/instructions file must not read as "AWM installed".
97
+ // Still not full integrity verification (a stray file with the right extension but
98
+ // wrong content still passes) — that residual gap is the same honest tradeoff this
99
+ // function's doc comment already accepts for the non-`'link'` branch generally.
100
+ const ext = RENDERED_SKILL_EXTENSIONS[renderer];
101
+ const present = ext ? entries.some((e) => e.endsWith(ext)) : entries.length > 0;
102
+ return {
103
+ id: 'skills.global',
104
+ state: present ? 'supported' : 'absent',
105
+ target: dir,
106
+ owners: owners.length > 1 ? owners : undefined,
107
+ detail: present ? 'rendered install — content integrity not verified' : undefined,
108
+ remediationCode: present ? undefined : 'awm-init',
109
+ };
110
+ }
45
111
  const shared = owners.length > 1;
46
112
  const broken = integrity.repairable.length + integrity.dead.length;
47
113
  // Broken links are checked BEFORE shared: 'shared' is a non-degrading/OK state
@@ -101,7 +167,7 @@ function tomlAgentsHealthy(dir, entries) {
101
167
  */
102
168
  function agentsNativeCheck(agent) {
103
169
  const provider = (0, providers_1.providerFor)(agent);
104
- if (!provider.agent)
170
+ if (!provider.agent || provider.agent.global === null)
105
171
  return null;
106
172
  const dir = provider.agent.global;
107
173
  let entries;
@@ -152,21 +218,34 @@ function hookTrustCheck(agent) {
152
218
  /** R7: reflects the provider's global context-delivery mechanism (config-instructions /
153
219
  * managed-agents-md). claude-code's context rides the SessionStart hook — already
154
220
  * covered by hook.trust, so this check is OMITTED (returns null) rather than reported,
155
- * to avoid double-reporting the same fact as a separate, redundant row. */
156
- function contextGlobalCheck(agent) {
221
+ * to avoid double-reporting the same fact as a separate, redundant row.
222
+ *
223
+ * Scope mirrors `init/steps.ts`'s `stepContextInjection` exactly: a `managed-agents-md`
224
+ * provider with `globalPath === null` (today: Cursor, Copilot) has no user-level
225
+ * AGENTS.md-equivalent file, so its context is legitimately delivered at LOCAL (project)
226
+ * scope instead — asking `contextStatus` about 'global' for these providers always
227
+ * resolved to 'absent' regardless of whether the local injection actually succeeded. The
228
+ * check's `id` stays `'context.global'` either way (stable JSON field); only the
229
+ * underlying scope resolution changes. `projectRoot` is only meaningful when the resolved
230
+ * scope is 'local' — if it's needed but missing, `contextStatus` throws cleanly
231
+ * (`InjectionOrchestrator`'s `contextPathFor`) and the catch below falls back to 'absent',
232
+ * same as any other failure to resolve status. */
233
+ function contextGlobalCheck(agent, projectRoot) {
157
234
  const injection = (0, providers_1.providerFor)(agent).injection;
158
235
  if (!injection || injection.type === 'cc-settings-merge') {
159
236
  return null;
160
237
  }
238
+ const scope = injection.type === 'managed-agents-md' && injection.globalPath === null ? 'local' : 'global';
161
239
  let state = 'absent';
162
240
  try {
163
241
  const orchestrator = new orchestrator_1.InjectionOrchestrator();
164
242
  const result = orchestrator.contextStatus({
165
243
  agent,
166
- scope: 'global',
244
+ scope,
167
245
  registryRoot: (0, registries_1.capabilityRoot)('skills') ?? '',
168
246
  installMethod: 'symlink',
169
247
  profileExtensions: [],
248
+ projectRoot,
170
249
  });
171
250
  state = result === 'injected' ? 'delivered' : result === 'stale' ? 'stale' : 'absent';
172
251
  }
@@ -190,10 +269,12 @@ function contextGlobalCheck(agent) {
190
269
  * snapshot for rollback comparison — a different shape, a different purpose). No file
191
270
  * currently imports both, but the names are close enough to trip up a future reader/editor.
192
271
  */
193
- function gatherProviderChecks(agents, scanSkills) {
272
+ function gatherProviderChecks(agents, scanSkills, projectRoot) {
194
273
  const ownersByDir = new Map();
195
274
  for (const agent of agents) {
196
275
  const dir = (0, providers_1.providerFor)(agent).skill.global;
276
+ if (dir === null)
277
+ continue;
197
278
  const owners = ownersByDir.get(dir) ?? [];
198
279
  owners.push(agent);
199
280
  ownersByDir.set(dir, owners);
@@ -205,15 +286,15 @@ function gatherProviderChecks(agents, scanSkills) {
205
286
  return agents.map((agent) => {
206
287
  const provider = (0, providers_1.providerFor)(agent);
207
288
  const dir = provider.skill.global;
208
- const owners = ownersByDir.get(dir) ?? [agent];
209
- const integrity = scansByDir.get(dir) ?? { valid: [], repairable: [], dead: [] };
289
+ const owners = (dir !== null ? ownersByDir.get(dir) : undefined) ?? [agent];
290
+ const integrity = (dir !== null ? scansByDir.get(dir) : undefined) ?? { valid: [], repairable: [], dead: [] };
210
291
  const checks = [
211
292
  binaryVersionCheck(agent),
212
- skillsGlobalCheck(dir, owners, integrity),
293
+ skillsGlobalCheck(dir, owners, integrity, provider.skill.renderer),
213
294
  agentsNativeCheck(agent),
214
295
  hookTrustCheck(agent),
215
- contextGlobalCheck(agent),
296
+ contextGlobalCheck(agent, projectRoot),
216
297
  ].filter((check) => check !== null);
217
- return { id: agent, label: provider.label, checks };
298
+ return { id: agent, label: provider.label, tier: providerTier(provider), checks };
218
299
  });
219
300
  }