@event4u/agent-config 8.5.0 → 8.7.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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +54 -0
  3. package/dist/agent-src/commands/cost/report.md +20 -0
  4. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +30 -0
  5. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +15 -1
  6. package/dist/agent-src/contexts/execution/subagent-steering.md +54 -1
  7. package/dist/agent-src/contexts/model-recommendations.md +19 -0
  8. package/dist/agent-src/contexts/subagent-configuration.md +8 -0
  9. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/discovery/deprecation-report.md +1 -1
  12. package/dist/discovery/discovery-manifest.json +4 -4
  13. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  14. package/dist/discovery/discovery-manifest.summary.md +1 -1
  15. package/dist/discovery/orphan-report.md +1 -1
  16. package/dist/discovery/packs.json +2 -2
  17. package/dist/discovery/trust-report.md +1 -1
  18. package/dist/discovery/workspaces.json +2 -2
  19. package/dist/install/emit_host_rules_cli.js +185 -0
  20. package/dist/install/emit_host_rules_cli.js.map +1 -0
  21. package/dist/install/install.mjs +6 -0
  22. package/dist/install/plan.js +4 -1
  23. package/dist/install/plan.js.map +1 -1
  24. package/dist/install/rule_scope.js +109 -0
  25. package/dist/install/rule_scope.js.map +1 -0
  26. package/dist/install/rule_scope_cli.js +77 -0
  27. package/dist/install/rule_scope_cli.js.map +1 -0
  28. package/dist/install/wizard-plan.js +14 -1
  29. package/dist/install/wizard-plan.js.map +1 -1
  30. package/dist/mcp/registry-manifest.json +1 -1
  31. package/dist/scripts/_lib/agent_settings.js +963 -0
  32. package/dist/scripts/_lib/agent_settings.js.map +1 -0
  33. package/dist/scripts/_lib/agent_src.js +607 -0
  34. package/dist/scripts/_lib/agent_src.js.map +1 -0
  35. package/dist/scripts/_lib/claude_settings_hooks.js +209 -0
  36. package/dist/scripts/_lib/claude_settings_hooks.js.map +1 -0
  37. package/dist/scripts/_lib/model_tier.js +64 -0
  38. package/dist/scripts/_lib/model_tier.js.map +1 -0
  39. package/dist/scripts/_lib/script_output.js +140 -0
  40. package/dist/scripts/_lib/script_output.js.map +1 -0
  41. package/dist/scripts/_lib/user_global_paths.js +297 -0
  42. package/dist/scripts/_lib/user_global_paths.js.map +1 -0
  43. package/dist/scripts/condense.js +2181 -0
  44. package/dist/scripts/condense.js.map +1 -0
  45. package/dist/server/routes/install.js +18 -2
  46. package/dist/server/routes/install.js.map +1 -1
  47. package/dist/server/schemas/settings.js +2 -0
  48. package/dist/server/schemas/settings.js.map +1 -1
  49. package/docs/CLAIMS.md +7 -0
  50. package/docs/decisions/ADR-116-memory-tripwire-activation-path.md +125 -0
  51. package/docs/decisions/INDEX.md +1 -0
  52. package/docs/proof.md +2 -1
  53. package/package.json +1 -1
  54. package/src/config/agent-settings.template.yml +4 -2
  55. package/src/install/emit_host_rules_cli.ts +190 -0
  56. package/src/install/plan.ts +11 -1
  57. package/src/install/rule_scope.ts +120 -0
  58. package/src/install/rule_scope_cli.ts +79 -0
  59. package/src/install/wizard-plan.ts +22 -1
  60. package/src/scripts/_lib/auto_dispatch.ts +63 -0
  61. package/src/scripts/_lib/subagent_routing.ts +24 -0
  62. package/src/scripts/_lib/subagent_steering.ts +213 -3
  63. package/src/scripts/check_trigger_eval_presence.ts +171 -0
  64. package/src/scripts/install.sh +118 -4
  65. package/src/scripts/trigger_eval_grandfather.json +226 -0
  66. package/src/server/schemas/settings.ts +6 -0
@@ -117,6 +117,12 @@ export interface OrchEntry {
117
117
  token_delta: number;
118
118
  outcome: string;
119
119
  verify_mode: string;
120
+ /** Routing extension (road-to-cost-aware-model-routing) — null on pre-extension lines. */
121
+ task_class: string | null;
122
+ tier_chosen: string | null;
123
+ tier_source: string | null;
124
+ escalated_from: string | null;
125
+ verify_result_by_tier: Record<string, string> | null;
120
126
  }
121
127
 
122
128
  /**
@@ -129,9 +135,8 @@ export interface OrchEntry {
129
135
  *
130
136
  * user_override_rate cannot be measured from telemetry alone — returns 0.
131
137
  */
132
- export function readOrchestrationMetrics(
133
- lines: string[],
134
- ): GuardrailMetrics {
138
+ /** Parse orchestration entries from audit-log-v1 JSONL lines (shared by all aggregators). */
139
+ export function parseOrchEntries(lines: string[]): OrchEntry[] {
135
140
  const entries: OrchEntry[] = [];
136
141
  for (const line of lines) {
137
142
  const trimmed = line.trim();
@@ -141,17 +146,33 @@ export function readOrchestrationMetrics(
141
146
  if (parsed['input_kind'] !== 'orchestration') continue;
142
147
  const orch = parsed['orchestration'] as Record<string, unknown> | undefined;
143
148
  if (!orch) continue;
149
+ const vbt = orch['verify_result_by_tier'];
144
150
  entries.push({
145
151
  task_size_estimate: Number(orch['task_size_estimate'] ?? 0),
146
152
  spawn_count: Number(orch['spawn_count'] ?? 0),
147
153
  token_delta: Number(orch['token_delta'] ?? 0),
148
154
  outcome: String(orch['outcome'] ?? ''),
149
155
  verify_mode: String(orch['verify_mode'] ?? ''),
156
+ task_class: orch['task_class'] == null ? null : String(orch['task_class']),
157
+ tier_chosen: orch['tier_chosen'] == null ? null : String(orch['tier_chosen']),
158
+ tier_source: orch['tier_source'] == null ? null : String(orch['tier_source']),
159
+ escalated_from: orch['escalated_from'] == null ? null : String(orch['escalated_from']),
160
+ verify_result_by_tier:
161
+ vbt != null && typeof vbt === 'object' && !Array.isArray(vbt)
162
+ ? Object.fromEntries(Object.entries(vbt as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
163
+ : null,
150
164
  });
151
165
  } catch {
152
166
  // skip malformed lines
153
167
  }
154
168
  }
169
+ return entries;
170
+ }
171
+
172
+ export function readOrchestrationMetrics(
173
+ lines: string[],
174
+ ): GuardrailMetrics {
175
+ const entries = parseOrchEntries(lines);
155
176
 
156
177
  if (entries.length === 0) {
157
178
  return { token_ratio: 1, spawn_failure_rate: 0, verify_skip_rate: 0, user_override_rate: 0 };
@@ -174,3 +195,192 @@ export function readOrchestrationMetrics(
174
195
  user_override_rate: 0, // cannot measure from telemetry alone
175
196
  };
176
197
  }
198
+
199
+ /**
200
+ * Per-tier / per-class routing aggregates (road-to-cost-aware-model-routing
201
+ * Phase 0). Pure aggregation over the routing telemetry fields — feeds the
202
+ * `/cost:report` per-tier view and the Phase-2 tripwires. Pre-extension lines
203
+ * (null routing fields) are ignored per aggregate.
204
+ */
205
+ export interface TierRoutingMetrics {
206
+ /** Dispatch count per tier_chosen. */
207
+ dispatches_by_tier: Record<string, number>;
208
+ /** Dispatch count per task_class. */
209
+ dispatches_by_class: Record<string, number>;
210
+ /** Escalation count per task_class (entries with escalated_from set). */
211
+ escalations_by_class: Record<string, number>;
212
+ /** escalations_by_class / dispatches_by_class, per class present in both. */
213
+ escalation_rate_by_class: Record<string, number>;
214
+ /** Per tier: pass / (pass + fail) over verify_result_by_tier attempts. */
215
+ verify_pass_rate_by_tier: Record<string, number>;
216
+ }
217
+
218
+ export function readTierRoutingMetrics(lines: string[]): TierRoutingMetrics {
219
+ const entries = parseOrchEntries(lines);
220
+
221
+ const dispatches_by_tier: Record<string, number> = {};
222
+ const dispatches_by_class: Record<string, number> = {};
223
+ const escalations_by_class: Record<string, number> = {};
224
+ const verifyCounts: Record<string, { pass: number; fail: number }> = {};
225
+
226
+ for (const e of entries) {
227
+ if (e.tier_chosen !== null) {
228
+ dispatches_by_tier[e.tier_chosen] = (dispatches_by_tier[e.tier_chosen] ?? 0) + 1;
229
+ }
230
+ if (e.task_class !== null) {
231
+ dispatches_by_class[e.task_class] = (dispatches_by_class[e.task_class] ?? 0) + 1;
232
+ if (e.escalated_from !== null) {
233
+ escalations_by_class[e.task_class] = (escalations_by_class[e.task_class] ?? 0) + 1;
234
+ }
235
+ }
236
+ if (e.verify_result_by_tier !== null) {
237
+ for (const [tier, result] of Object.entries(e.verify_result_by_tier)) {
238
+ const c = (verifyCounts[tier] ??= { pass: 0, fail: 0 });
239
+ if (result === 'pass') c.pass += 1;
240
+ else if (result === 'fail') c.fail += 1;
241
+ // 'skipped' counts toward neither pass nor fail.
242
+ }
243
+ }
244
+ }
245
+
246
+ const escalation_rate_by_class: Record<string, number> = {};
247
+ for (const [cls, n] of Object.entries(dispatches_by_class)) {
248
+ escalation_rate_by_class[cls] = (escalations_by_class[cls] ?? 0) / n;
249
+ }
250
+
251
+ const verify_pass_rate_by_tier: Record<string, number> = {};
252
+ for (const [tier, c] of Object.entries(verifyCounts)) {
253
+ const attempts = c.pass + c.fail;
254
+ if (attempts > 0) verify_pass_rate_by_tier[tier] = c.pass / attempts;
255
+ }
256
+
257
+ return {
258
+ dispatches_by_tier,
259
+ dispatches_by_class,
260
+ escalations_by_class,
261
+ escalation_rate_by_class,
262
+ verify_pass_rate_by_tier,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Cost-routing tripwires (road-to-cost-aware-model-routing Phase 2) —
268
+ * steering policy over the routing telemetry, surfaced never auto-flipped.
269
+ */
270
+ export const ROUTING_TRIPWIRES = {
271
+ /**
272
+ * Per-class escalation rate above which the class's static default tier
273
+ * should be promoted: cascading a class that escalates this often costs
274
+ * more than starting on the higher tier.
275
+ */
276
+ escalation_promotion_rate: 0.4,
277
+ /** Verify-pass rate drop (absolute) below the trailing baseline that counts as drift. */
278
+ verify_pass_drift_tolerance: 0.1,
279
+ /** Minimum dispatches per class before the escalation tripwire may fire (noise floor). */
280
+ min_class_dispatches: 5,
281
+ } as const;
282
+
283
+ /**
284
+ * Tripwire (i): classes whose escalation rate exceeds the promotion
285
+ * threshold — their static default tier is wrong; promote it, stop cascading.
286
+ * Classes below the dispatch noise floor never fire.
287
+ */
288
+ export function escalationPromotionCandidates(m: TierRoutingMetrics): string[] {
289
+ const candidates: string[] = [];
290
+ for (const [cls, rate] of Object.entries(m.escalation_rate_by_class)) {
291
+ const dispatches = m.dispatches_by_class[cls] ?? 0;
292
+ if (dispatches < ROUTING_TRIPWIRES.min_class_dispatches) continue;
293
+ if (rate > ROUTING_TRIPWIRES.escalation_promotion_rate) candidates.push(cls);
294
+ }
295
+ return candidates.sort();
296
+ }
297
+
298
+ /**
299
+ * Tripwire (ii): tiers whose current verify-pass rate dropped below the
300
+ * trailing baseline by more than the tolerance — verifier or model drift.
301
+ * Tiers absent from either side are skipped (no baseline → no drift claim).
302
+ */
303
+ export function verifyPassDrift(
304
+ current: Record<string, number>,
305
+ trailingBaseline: Record<string, number>,
306
+ ): string[] {
307
+ const drifting: string[] = [];
308
+ for (const [tier, baseline] of Object.entries(trailingBaseline)) {
309
+ const now = current[tier];
310
+ if (now === undefined) continue;
311
+ if (now < baseline - ROUTING_TRIPWIRES.verify_pass_drift_tolerance) drifting.push(tier);
312
+ }
313
+ return drifting.sort();
314
+ }
315
+
316
+ /**
317
+ * Verify-fail escalation (road-to-cost-aware-model-routing Phase 4 / M3).
318
+ *
319
+ * A verification FAILURE on a downshifted return re-dispatches the slice one
320
+ * tier up, consuming the existing N=3 budget — never a new budget. Confined
321
+ * to tier_source static|inferred; inherit slices keep same-tier retry.
322
+ * The trigger is the judge verdict / deterministic verify result — never the
323
+ * subagent's self-reported confidence.
324
+ */
325
+ export type EscalationTier = 'lite' | 'medium' | 'high';
326
+
327
+ export interface VerifyFailInputs {
328
+ /** Tier of the attempt that just failed verification. */
329
+ failed_tier: EscalationTier;
330
+ /** Provenance of the slice's tier (telemetry `tier_source`). */
331
+ tier_source: 'static' | 'inferred' | 'inherit';
332
+ /** Consecutive failed attempts on this slice INCLUDING the one that just failed. */
333
+ consecutive_failures: number;
334
+ }
335
+
336
+ export interface VerifyFailDecision {
337
+ action: 'escalate' | 'retry-same-tier' | 'slice-failed';
338
+ /** Tier for the next attempt; null when the slice is failed. */
339
+ next_tier: EscalationTier | null;
340
+ reason: string;
341
+ }
342
+
343
+ const TIER_ABOVE: Record<EscalationTier, EscalationTier | null> = {
344
+ lite: 'medium',
345
+ medium: 'high',
346
+ high: null,
347
+ };
348
+
349
+ export function escalateOnVerifyFail(inp: VerifyFailInputs): VerifyFailDecision {
350
+ // Outside the downshift path → existing same-tier retry semantics, budget unchanged.
351
+ if (inp.tier_source === 'inherit') {
352
+ if (budgetHalt(inp.consecutive_failures)) {
353
+ return { action: 'slice-failed', next_tier: null, reason: 'N=3 budget exhausted on session tier' };
354
+ }
355
+ return {
356
+ action: 'retry-same-tier',
357
+ next_tier: inp.failed_tier,
358
+ reason: 'inherit slice — no downshift decision, same-tier retry semantics unchanged',
359
+ };
360
+ }
361
+
362
+ // Downshifted slice: two failed attempts mark the slice failed — the third
363
+ // "attempt" of the N=3 budget is the orchestrator's replan at session tier.
364
+ if (inp.consecutive_failures >= 2) {
365
+ return {
366
+ action: 'slice-failed',
367
+ next_tier: null,
368
+ reason: 'two failed attempts on a downshifted slice — mark failed, orchestrator replans at session tier',
369
+ };
370
+ }
371
+
372
+ const above = TIER_ABOVE[inp.failed_tier];
373
+ if (above === null) {
374
+ return {
375
+ action: 'slice-failed',
376
+ next_tier: null,
377
+ reason: 'verify-fail on the highest tier — no tier above, mark failed and replan',
378
+ };
379
+ }
380
+
381
+ return {
382
+ action: 'escalate',
383
+ next_tier: above,
384
+ reason: `verify-fail on ${inp.failed_tier} → re-dispatch one tier up (${above}); consumes the N=3 budget`,
385
+ };
386
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Trigger-eval PRESENCE ratchet (road-to-composition-ratchet Phase 1).
4
+ *
5
+ * `check_trigger_evals.ts` validates freshness + structure of triggers.json
6
+ * files that EXIST; it cannot flag a new skill that ships without one, even
7
+ * though the drafting protocol mandates the stub. This gate closes that gap
8
+ * with a shrink-only grandfather allowlist
9
+ * (`src/scripts/trigger_eval_grandfather.json`, frozen 2026-07-08 at 221
10
+ * entries):
11
+ *
12
+ * 1. Every skill under `src/skills/` must either carry
13
+ * `evals/triggers.json` or appear in the allowlist.
14
+ * 2. An allowlist entry whose skill directory no longer exists is stale
15
+ * and must be removed.
16
+ * 3. An allowlist entry whose skill HAS a triggers.json must be removed —
17
+ * the list may only shrink (ratchet), never regrow.
18
+ *
19
+ * Structure + freshness of existing files stay owned by
20
+ * `check_trigger_evals.ts`; this gate checks presence only.
21
+ *
22
+ * Exit codes: 0 ok · 1 violation(s) · 2 usage / unreadable allowlist.
23
+ * `--quiet` suppresses the ✅ summary (errors always print to stderr).
24
+ */
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { fileURLToPath, pathToFileURL } from 'node:url';
28
+
29
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
30
+ const SKILLS_DIR = path.join(REPO_ROOT, 'src', 'skills');
31
+ const ALLOWLIST_PATH = path.join(REPO_ROOT, 'src', 'scripts', 'trigger_eval_grandfather.json');
32
+
33
+ interface Allowlist {
34
+ skills: string[];
35
+ }
36
+
37
+ function load_allowlist(p: string): Allowlist | null {
38
+ try {
39
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as unknown;
40
+ if (
41
+ typeof raw !== 'object' ||
42
+ raw === null ||
43
+ !Array.isArray((raw as { skills?: unknown }).skills) ||
44
+ !(raw as { skills: unknown[] }).skills.every((s) => typeof s === 'string')
45
+ ) {
46
+ return null;
47
+ }
48
+ return { skills: (raw as { skills: string[] }).skills };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function list_skill_dirs(root: string): string[] {
55
+ return fs
56
+ .readdirSync(root, { withFileTypes: true })
57
+ .filter((e) => e.isDirectory())
58
+ .map((e) => e.name)
59
+ .sort();
60
+ }
61
+
62
+ function has_triggers(skill: string): boolean {
63
+ return fs.existsSync(path.join(SKILLS_DIR, skill, 'evals', 'triggers.json'));
64
+ }
65
+
66
+ interface Findings {
67
+ errors: string[];
68
+ covered: number;
69
+ total: number;
70
+ }
71
+
72
+ function check(skills: string[], allowlisted: string[]): Findings {
73
+ const allow = new Set(allowlisted);
74
+ const skillSet = new Set(skills);
75
+ const errors: string[] = [];
76
+ let covered = 0;
77
+
78
+ for (const skill of skills) {
79
+ if (has_triggers(skill)) {
80
+ covered += 1;
81
+ if (allow.has(skill)) {
82
+ errors.push(
83
+ `${skill}: has evals/triggers.json but is still in the grandfather allowlist — remove the entry (shrink-only ratchet)`,
84
+ );
85
+ }
86
+ } else if (!allow.has(skill)) {
87
+ errors.push(
88
+ `${skill}: missing evals/triggers.json and not grandfathered — new/changed skills must ship a trigger-eval set (see artifact-drafting-protocol Phase C)`,
89
+ );
90
+ }
91
+ }
92
+
93
+ for (const entry of allowlisted) {
94
+ if (!skillSet.has(entry)) {
95
+ errors.push(
96
+ `allowlist entry "${entry}" has no matching skill directory — remove the stale entry`,
97
+ );
98
+ }
99
+ }
100
+
101
+ return { errors, covered, total: skills.length };
102
+ }
103
+
104
+ function parse_args(argv: string[]): { quiet: boolean } | null {
105
+ let quiet = false;
106
+ for (const a of argv) {
107
+ if (a === '--quiet' || a === '-q') {
108
+ quiet = true;
109
+ } else {
110
+ return null;
111
+ }
112
+ }
113
+ return { quiet };
114
+ }
115
+
116
+ function main(): number {
117
+ const args = parse_args(process.argv.slice(2));
118
+ if (args === null) {
119
+ process.stderr.write('usage: check_trigger_eval_presence [--quiet]\n');
120
+ return 2;
121
+ }
122
+
123
+ const allowlist = load_allowlist(ALLOWLIST_PATH);
124
+ if (allowlist === null) {
125
+ process.stderr.write(
126
+ `❌ check-trigger-eval-presence: unreadable or malformed allowlist at ${path.relative(REPO_ROOT, ALLOWLIST_PATH)}\n`,
127
+ );
128
+ return 2;
129
+ }
130
+
131
+ const { errors, covered, total } = check(list_skill_dirs(SKILLS_DIR), allowlist.skills);
132
+
133
+ if (errors.length) {
134
+ process.stderr.write('❌ check-trigger-eval-presence: presence-ratchet violation(s):\n');
135
+ for (const e of errors) {
136
+ process.stderr.write(` - ${e}\n`);
137
+ }
138
+ return 1;
139
+ }
140
+
141
+ if (!args.quiet) {
142
+ process.stdout.write(
143
+ `✅ check-trigger-eval-presence: ${covered}/${total} skills carry evals/triggers.json (${allowlist.skills.length} grandfathered, shrink-only)\n`,
144
+ );
145
+ }
146
+ return 0;
147
+ }
148
+
149
+ const _HERE = fileURLToPath(import.meta.url);
150
+ function _isCliEntry(): boolean {
151
+ if (process.argv[1] === undefined) {
152
+ return false;
153
+ }
154
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
155
+ if (import.meta.url === argvUrl) {
156
+ return true;
157
+ }
158
+ try {
159
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
160
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
161
+ return here === argv;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ if (_isCliEntry() || process.argv[1] === _HERE) {
168
+ process.exit(main());
169
+ }
170
+
171
+ export { ALLOWLIST_PATH, check, load_allowlist, parse_args, main };
@@ -23,8 +23,14 @@ set -euo pipefail
23
23
  COPY_DIRS="rules" # Subdirectories where files must be real copies (space-separated)
24
24
 
25
25
  # Rules that are internal to the agent-config package and should NOT be shipped to consumers.
26
- # These are only relevant when developing the agent-config package itself.
27
- EXCLUDE_RULES="source-of-truth.md augment-portability.md docs-sync.md"
26
+ # Static COMPAT floor only (see src/install/rule_scope.ts for the recorded
27
+ # source-of-truth.md decision — same treatment on project AND global paths).
28
+ # The full workspace/pack scoping list is resolved at runtime by
29
+ # resolve_excluded_rules() via src/install/rule_scope_cli.ts, which re-uses
30
+ # the projection predicate (rule_in_scope) so install semantics can never
31
+ # drift from projection semantics. This variable is the fail-safe fallback
32
+ # when no node/tsx runtime is available (over-ship, never under-ship).
33
+ EXCLUDE_RULES="source-of-truth.md"
28
34
 
29
35
  # Files inside target/.augment/ that are NOT managed by sync (created by the bridge installer).
30
36
  # Never remove them in clean_stale even though they are absent in the source manifest.
@@ -284,6 +290,47 @@ create_symlink() {
284
290
 
285
291
  # --- Core functions ---
286
292
 
293
+ # Resolve the full excluded-rule list for this install via the shared
294
+ # scoping CLI (workspace/pack scope from <TARGET>/.agent-settings.yml,
295
+ # router/frontmatter truth, kernel-safe). Overwrites EXCLUDE_RULES on
296
+ # success; keeps the static compat fallback on any failure
297
+ # (road-to-request-scoped-rule-load Phase 1b).
298
+ resolve_excluded_rules() {
299
+ local rules_src="$SOURCE_PAYLOAD/rules"
300
+ local settings_file="$TARGET_DIR/.agent-settings.yml"
301
+ local cli="$SOURCE_DIR/src/install/rule_scope_cli.ts"
302
+ [[ -f "$cli" && -d "$rules_src" ]] || { log_verbose "rule-scope CLI unavailable — static exclude fallback"; return 0; }
303
+
304
+ local tsx_bin=""
305
+ if [[ -x "$SOURCE_DIR/node_modules/.bin/tsx" ]]; then
306
+ tsx_bin="$SOURCE_DIR/node_modules/.bin/tsx"
307
+ elif command -v npx >/dev/null 2>&1; then
308
+ tsx_bin="npx --yes tsx"
309
+ else
310
+ log_verbose "no tsx/npx runtime — static exclude fallback"
311
+ return 0
312
+ fi
313
+
314
+ local resolved
315
+ local -a cli_args=(--rules-dir "$rules_src")
316
+ [[ -f "$settings_file" ]] && cli_args+=(--settings "$settings_file")
317
+ # TMPDIR pinned for the child: tsx writes its transform cache under
318
+ # $TMPDIR — on macOS TMPDIR is exported, and callers (tests, wrappers)
319
+ # may point it INTO the install target, leaking tsx-cache files into a
320
+ # consumer tree / breaking dry-run's zero-file guarantee.
321
+ # shellcheck disable=SC2086
322
+ if resolved=$(TMPDIR=/tmp $tsx_bin "$cli" "${cli_args[@]}" 2>/dev/null); then
323
+ # Newline list → space list (is_excluded_rule iterates words).
324
+ EXCLUDE_RULES="$(echo "$resolved" | tr '\n' ' ')"
325
+ local n
326
+ n="$(echo "$resolved" | grep -c . || true)"
327
+ log_verbose "rule scope resolved: $n excluded rule(s)"
328
+ else
329
+ log_verbose "rule-scope CLI failed — static exclude fallback"
330
+ fi
331
+ return 0
332
+ }
333
+
287
334
  # Check if a relative path matches an excluded rule
288
335
  is_excluded_rule() {
289
336
  local rel_path="$1"
@@ -470,6 +517,60 @@ clean_stale() {
470
517
  }
471
518
 
472
519
 
520
+ # Emit host-NATIVE rule surfaces for consumer Cursor (.mdc with globs) and
521
+ # Windsurf (.windsurf/rules + .windsurfrules) via the exported condense
522
+ # emitters — so consumer installs carry the same trigger signal as the
523
+ # maintainer projection (road-to-request-scoped-rule-load Phase 1b Step 3;
524
+ # the recorded install-time-vs-dist decision lives in
525
+ # src/install/emit_host_rules_cli.ts). Sets CURSOR_NATIVE_EMITTED /
526
+ # WINDSURF_NATIVE_EMITTED so the legacy surfaces (raw-md symlink farm,
527
+ # bash frontmatter-stripping concatenator) run only as no-node fallbacks.
528
+ CURSOR_NATIVE_EMITTED=false
529
+ WINDSURF_NATIVE_EMITTED=false
530
+ emit_host_rules() {
531
+ local project_root="$1"
532
+ local rules_dir="$project_root/.augment/rules"
533
+ local cli="$SOURCE_DIR/src/install/emit_host_rules_cli.ts"
534
+ [[ -d "$rules_dir" && -f "$cli" ]] || return 0
535
+
536
+ local -a tools=()
537
+ is_tool_enabled "cursor" && tools+=("cursor")
538
+ is_tool_enabled "windsurf" && tools+=("windsurf")
539
+ [[ ${#tools[@]} -gt 0 ]] || return 0
540
+
541
+ if $DRY_RUN; then
542
+ log_verbose "emit host-native rules (${tools[*]})"
543
+ return 0
544
+ fi
545
+
546
+ local tsx_bin=""
547
+ if [[ -x "$SOURCE_DIR/node_modules/.bin/tsx" ]]; then
548
+ tsx_bin="$SOURCE_DIR/node_modules/.bin/tsx"
549
+ elif command -v npx >/dev/null 2>&1; then
550
+ tsx_bin="npx --yes tsx"
551
+ else
552
+ log_verbose "no tsx/npx runtime — legacy host-rule surfaces"
553
+ return 0
554
+ fi
555
+
556
+ local tools_csv
557
+ tools_csv="$(IFS=,; echo "${tools[*]}")"
558
+ # TMPDIR pinned: tsx cache must never land in the install target.
559
+ # shellcheck disable=SC2086
560
+ if TMPDIR=/tmp $tsx_bin "$cli" --rules-dir "$rules_dir" \
561
+ --project-root "$project_root" --tools "$tools_csv" >/dev/null 2>&1; then
562
+ for tool in "${tools[@]}"; do
563
+ case "$tool" in
564
+ cursor) CURSOR_NATIVE_EMITTED=true; log_info "Emitted native .cursor/rules/*.mdc" ;;
565
+ windsurf) WINDSURF_NATIVE_EMITTED=true; log_info "Emitted native .windsurf/rules + .windsurfrules" ;;
566
+ esac
567
+ done
568
+ else
569
+ log_verbose "host-rule emission failed — legacy fallback surfaces"
570
+ fi
571
+ return 0
572
+ }
573
+
473
574
  # Create tool-specific rule symlinks (filtered by --tools selection).
474
575
  # Map: tool ID → (target dir, relative prefix from target → .augment/rules).
475
576
  create_tool_symlinks() {
@@ -482,7 +583,11 @@ create_tool_symlinks() {
482
583
  local -a tool_dirs=()
483
584
  local -a rel_prefixes=()
484
585
  is_tool_enabled "claude-code" && { tool_ids+=("claude-code"); tool_dirs+=(".claude/rules"); rel_prefixes+=("../../.augment/rules"); }
485
- is_tool_enabled "cursor" && { tool_ids+=("cursor"); tool_dirs+=(".cursor/rules"); rel_prefixes+=("../../.augment/rules"); }
586
+ # Cursor gets NATIVE .mdc files when emit_host_rules succeeded — the
587
+ # raw-md symlink farm is the no-node fallback only (Phase 1b Step 3).
588
+ if ! $CURSOR_NATIVE_EMITTED; then
589
+ is_tool_enabled "cursor" && { tool_ids+=("cursor"); tool_dirs+=(".cursor/rules"); rel_prefixes+=("../../.augment/rules"); }
590
+ fi
486
591
  is_tool_enabled "cline" && { tool_ids+=("cline"); tool_dirs+=(".clinerules"); rel_prefixes+=("../.augment/rules"); }
487
592
 
488
593
  if [[ ${#tool_dirs[@]} -eq 0 ]]; then
@@ -596,6 +701,9 @@ generate_windsurfrules() {
596
701
  local project_root="$1"
597
702
  local rules_dir="$project_root/.augment/rules"
598
703
 
704
+ # Native emission (frontmatter-aware, trigger-preserving) already wrote
705
+ # .windsurfrules — this bash concatenator is the no-node fallback only.
706
+ $WINDSURF_NATIVE_EMITTED && { log_verbose "skip bash .windsurfrules (native emitted)"; return 0; }
599
707
  [[ -d "$rules_dir" ]] || return 0
600
708
  is_tool_enabled "windsurf" || { log_verbose "skip .windsurfrules (windsurf not selected)"; return 0; }
601
709
 
@@ -1107,6 +1215,10 @@ main() {
1107
1215
  # install the file does not exist yet → defaults preserved.
1108
1216
  resolve_settings
1109
1217
 
1218
+ # 0c. Resolve the excluded-rule set for this install (workspace/pack
1219
+ # scope via the shared rule_scope CLI; static compat fallback).
1220
+ resolve_excluded_rules
1221
+
1110
1222
  # 1. Hybrid sync payload → target/.augment/
1111
1223
  sync_hybrid "$SOURCE_PAYLOAD" "$TARGET_DIR/.augment"
1112
1224
  if $USE_RULES_SYMLINKS; then
@@ -1130,7 +1242,9 @@ main() {
1130
1242
  log_verbose "skip .github/copilot-instructions.md (copilot not selected)"
1131
1243
  fi
1132
1244
 
1133
- # 3. Create tool-specific symlinks
1245
+ # 3. Emit host-native rule surfaces (cursor .mdc / windsurf), then
1246
+ # create tool-specific symlinks for the remaining tools.
1247
+ emit_host_rules "$TARGET_DIR"
1134
1248
  create_tool_symlinks "$TARGET_DIR"
1135
1249
  create_skill_symlinks "$TARGET_DIR"
1136
1250