@event4u/agent-config 8.6.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 (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  4. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  5. package/dist/discovery/deprecation-report.md +1 -1
  6. package/dist/discovery/discovery-manifest.json +3 -3
  7. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  8. package/dist/discovery/discovery-manifest.summary.md +1 -1
  9. package/dist/discovery/orphan-report.md +1 -1
  10. package/dist/discovery/packs.json +2 -2
  11. package/dist/discovery/trust-report.md +1 -1
  12. package/dist/discovery/workspaces.json +2 -2
  13. package/dist/install/emit_host_rules_cli.js +185 -0
  14. package/dist/install/emit_host_rules_cli.js.map +1 -0
  15. package/dist/install/install.mjs +6 -0
  16. package/dist/install/plan.js +4 -1
  17. package/dist/install/plan.js.map +1 -1
  18. package/dist/install/rule_scope.js +109 -0
  19. package/dist/install/rule_scope.js.map +1 -0
  20. package/dist/install/rule_scope_cli.js +77 -0
  21. package/dist/install/rule_scope_cli.js.map +1 -0
  22. package/dist/install/wizard-plan.js +14 -1
  23. package/dist/install/wizard-plan.js.map +1 -1
  24. package/dist/mcp/registry-manifest.json +1 -1
  25. package/dist/scripts/_lib/agent_settings.js +963 -0
  26. package/dist/scripts/_lib/agent_settings.js.map +1 -0
  27. package/dist/scripts/_lib/agent_src.js +607 -0
  28. package/dist/scripts/_lib/agent_src.js.map +1 -0
  29. package/dist/scripts/_lib/claude_settings_hooks.js +209 -0
  30. package/dist/scripts/_lib/claude_settings_hooks.js.map +1 -0
  31. package/dist/scripts/_lib/model_tier.js +64 -0
  32. package/dist/scripts/_lib/model_tier.js.map +1 -0
  33. package/dist/scripts/_lib/script_output.js +140 -0
  34. package/dist/scripts/_lib/script_output.js.map +1 -0
  35. package/dist/scripts/_lib/user_global_paths.js +297 -0
  36. package/dist/scripts/_lib/user_global_paths.js.map +1 -0
  37. package/dist/scripts/condense.js +2181 -0
  38. package/dist/scripts/condense.js.map +1 -0
  39. package/dist/server/routes/install.js +18 -2
  40. package/dist/server/routes/install.js.map +1 -1
  41. package/dist/server/schemas/settings.js +2 -0
  42. package/dist/server/schemas/settings.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/config/agent-settings.template.yml +4 -2
  45. package/src/install/emit_host_rules_cli.ts +190 -0
  46. package/src/install/plan.ts +11 -1
  47. package/src/install/rule_scope.ts +120 -0
  48. package/src/install/rule_scope_cli.ts +79 -0
  49. package/src/install/wizard-plan.ts +22 -1
  50. package/src/scripts/check_trigger_eval_presence.ts +171 -0
  51. package/src/scripts/install.sh +118 -4
  52. package/src/scripts/trigger_eval_grandfather.json +226 -0
  53. package/src/server/schemas/settings.ts +6 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Install-time rule scoping — Pipeline B of consumer-scoped rule projection
3
+ * (road-to-request-scoped-rule-load Phase 1b).
4
+ *
5
+ * The projection filter (`rule_in_scope`, condense.ts) covered only the
6
+ * maintainer projection; the consumer install pipeline shipped rules
7
+ * unfiltered (dead `EXCLUDE_RULES` in install.sh; no exclude at all on the
8
+ * global payload). This module is the ONE scoping surface both install
9
+ * paths consume — it deliberately re-uses `rule_in_scope` so the install
10
+ * semantics can never drift from the projection semantics.
11
+ *
12
+ * ## The `source-of-truth.md` decision (Phase 1b Step 2 — recorded here)
13
+ *
14
+ * Before this module, `install.sh` excluded `source-of-truth.md` from
15
+ * PROJECT installs (hardcoded name) while the GLOBAL payload shipped it —
16
+ * the documented contradiction. Decision: the rule is excluded from BOTH
17
+ * consumer paths, always (even under legacy-all). Rationale: its Iron Law
18
+ * ("NEVER EDIT ANY GENERATED PROJECTION … ALWAYS WORK IN src/") describes
19
+ * THIS repository's build layout; in a consumer project there is no `src/`
20
+ * authoring tree and the installed files ARE the working surface — the rule
21
+ * is not merely useless there, it actively forbids legitimate consumer
22
+ * edits. Shipping it globally was the bug; project installs had it right.
23
+ * The compat list below is the pre-flip carrier of that decision; once the
24
+ * Phase-1 human gate flips consumer defaults to scoped, the rule drops out
25
+ * via its maintainer-only workspace tag and this list can retire.
26
+ */
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+
31
+ import { rule_in_scope } from '../scripts/condense.js';
32
+
33
+ /**
34
+ * Rules excluded from EVERY consumer install path regardless of scope —
35
+ * same treatment on project and global (see module doc for the decision).
36
+ */
37
+ export const COMPAT_ALWAYS_EXCLUDED: readonly string[] = ['source-of-truth.md'];
38
+
39
+ /** Workspace/pack scope read from consumer settings. `null` axis = unset. */
40
+ export interface RuleScope {
41
+ readonly workspaces: readonly string[] | null;
42
+ readonly packs: readonly string[] | null;
43
+ }
44
+
45
+ export const LEGACY_ALL: RuleScope = { workspaces: null, packs: null };
46
+
47
+ /** Non-empty string-array extraction; anything else → null (legacy-all). */
48
+ function _list(value: unknown): readonly string[] | null {
49
+ if (Array.isArray(value) && value.length > 0) {
50
+ return value.map((v) => String(v));
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Extract `projection.rule_workspaces` / `projection.rule_packs` from an
57
+ * already-parsed settings object (same key contract as condense's
58
+ * `_read_projection_list` — absent / empty = legacy-all).
59
+ */
60
+ export function ruleScopeFromSettings(settings: Record<string, unknown>): RuleScope {
61
+ const proj = settings['projection'];
62
+ if (typeof proj !== 'object' || proj === null || Array.isArray(proj)) {
63
+ return LEGACY_ALL;
64
+ }
65
+ const p = proj as Record<string, unknown>;
66
+ return {
67
+ workspaces: _list(p['rule_workspaces']),
68
+ packs: _list(p['rule_packs']),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Whether a rule FILE (path into the shipped `dist/agent-src/rules` tree)
74
+ * arrives in a consumer install under `scope`.
75
+ *
76
+ * Delegates the scoping decision to the projection-path predicate
77
+ * (`rule_in_scope` — kernel always ships, untagged axes fail safe) and adds
78
+ * the compat exclusion on top. Non-rule files (non-`.md`) always arrive.
79
+ */
80
+ export function ruleFileArrives(sourcePath: string, scope: RuleScope): boolean {
81
+ if (!sourcePath.endsWith('.md')) {
82
+ return true;
83
+ }
84
+ const basename = path.basename(sourcePath);
85
+ if (COMPAT_ALWAYS_EXCLUDED.includes(basename)) {
86
+ return false;
87
+ }
88
+ return rule_in_scope(sourcePath, scope.workspaces, scope.packs);
89
+ }
90
+
91
+ /**
92
+ * Basenames from `rulesDir` that are EXCLUDED under `scope` — the shape the
93
+ * bash installer consumes (one basename per line via the CLI below).
94
+ */
95
+ export function excludedRuleBasenames(rulesDir: string, scope: RuleScope): string[] {
96
+ let entries: string[];
97
+ try {
98
+ entries = fs.readdirSync(rulesDir).sort();
99
+ } catch {
100
+ return [];
101
+ }
102
+ const out: string[] = [];
103
+ for (const name of entries) {
104
+ if (!name.endsWith('.md')) {
105
+ continue;
106
+ }
107
+ const full = path.join(rulesDir, name);
108
+ try {
109
+ if (!fs.statSync(full).isFile()) {
110
+ continue;
111
+ }
112
+ } catch {
113
+ continue;
114
+ }
115
+ if (!ruleFileArrives(full, scope)) {
116
+ out.push(name);
117
+ }
118
+ }
119
+ return out;
120
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CLI twin surface of {@link module:install/rule_scope} for the bash
4
+ * installer (road-to-request-scoped-rule-load Phase 1b).
5
+ *
6
+ * Prints one EXCLUDED rule basename per line for the given rules source
7
+ * dir + consumer settings file. `install.sh` consumes this to replace its
8
+ * dead hardcoded `EXCLUDE_RULES` list; on any failure the caller falls back
9
+ * to the static compat exclusion (fail-safe: over-ship, never under-ship).
10
+ *
11
+ * Usage:
12
+ * rule_scope_cli.ts --rules-dir <dist/agent-src/rules> [--settings <file>]
13
+ *
14
+ * Exit codes: 0 ok (list on stdout, possibly empty) · 2 usage error.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import process from 'node:process';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ import { load_agent_settings } from '../scripts/_lib/agent_settings.js';
22
+
23
+ import { excludedRuleBasenames, ruleScopeFromSettings, LEGACY_ALL } from './rule_scope.js';
24
+
25
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
26
+ let rulesDir = '';
27
+ let settingsFile = '';
28
+ for (let i = 0; i < argv.length; i += 1) {
29
+ const arg = argv[i];
30
+ if (arg === '--rules-dir') {
31
+ rulesDir = argv[i + 1] ?? '';
32
+ i += 1;
33
+ } else if (arg === '--settings') {
34
+ settingsFile = argv[i + 1] ?? '';
35
+ i += 1;
36
+ } else {
37
+ process.stderr.write(`rule_scope_cli: unknown argument: ${arg}\n`);
38
+ return 2;
39
+ }
40
+ }
41
+ if (rulesDir === '') {
42
+ process.stderr.write('usage: rule_scope_cli.ts --rules-dir <dir> [--settings <file>]\n');
43
+ return 2;
44
+ }
45
+
46
+ let scope = LEGACY_ALL;
47
+ if (settingsFile !== '' && fs.existsSync(settingsFile)) {
48
+ try {
49
+ const settings = load_agent_settings({ project_path: settingsFile });
50
+ scope = ruleScopeFromSettings(settings as Record<string, unknown>);
51
+ } catch {
52
+ // Unreadable settings → legacy-all (fail-safe: over-ship).
53
+ scope = LEGACY_ALL;
54
+ }
55
+ }
56
+
57
+ for (const name of excludedRuleBasenames(path.resolve(rulesDir), scope)) {
58
+ process.stdout.write(`${name}\n`);
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ function _isCliEntry(): boolean {
64
+ if (process.argv[1] === undefined) return false;
65
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
66
+ if (import.meta.url === argvUrl) return true;
67
+ try {
68
+ return (
69
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
70
+ fs.realpathSync(path.resolve(process.argv[1]))
71
+ );
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ if (_isCliEntry()) {
78
+ process.exit(main());
79
+ }
@@ -23,6 +23,7 @@ import { isAbsolute, join, resolve } from 'node:path';
23
23
 
24
24
  import type { ConflictPolicy, InstallPlan, InstallTarget } from './types.js';
25
25
  import { buildInstallPlan, type PlanSource } from './plan.js';
26
+ import { ruleFileArrives, LEGACY_ALL, type RuleScope } from './rule_scope.js';
26
27
 
27
28
  /**
28
29
  * Per-tool user-scope anchor directory, mirrored 1:1 from
@@ -152,6 +153,14 @@ export interface WizardExpandInputs {
152
153
  readonly packageRoot: string;
153
154
  /** Override for the user's home directory (tests only). */
154
155
  readonly home?: string;
156
+ /**
157
+ * Workspace/pack scope applied to `rules` sources
158
+ * (road-to-request-scoped-rule-load Phase 1b). Absent = LEGACY_ALL,
159
+ * which still applies the compat exclusion (`source-of-truth.md`) so
160
+ * the global path finally matches the project path — the recorded
161
+ * decision in `rule_scope.ts`.
162
+ */
163
+ readonly ruleScope?: RuleScope;
155
164
  }
156
165
 
157
166
  /**
@@ -172,6 +181,8 @@ export interface WizardExpandInputs {
172
181
  */
173
182
  export function expandWizardSources(inputs: WizardExpandInputs): PlanSource[] {
174
183
  const { toolIds, packageRoot, home } = inputs;
184
+ const scope = inputs.ruleScope ?? LEGACY_ALL;
185
+ const ruleFilter = (srcFile: string): boolean => ruleFileArrives(srcFile, scope);
175
186
  const out: PlanSource[] = [];
176
187
  for (const toolId of toolIds) {
177
188
  const deploy = GLOBAL_DEPLOY_SOURCES[toolId];
@@ -181,7 +192,17 @@ export function expandWizardSources(inputs: WizardExpandInputs): PlanSource[] {
181
192
  for (const [srcRel, destSub] of deploy) {
182
193
  const srcDir = join(packageRoot, srcRel);
183
194
  const destDir = destSub.length === 0 ? destRoot : join(destRoot, destSub);
184
- out.push({ toolId, srcDir, destDir, kind: 'deployed' });
195
+ // Rule sources get the install-time scoping filter — the same
196
+ // predicate the projection path uses (Phase 1b: Pipeline B may
197
+ // never drift from Pipeline A).
198
+ const isRuleSource = srcRel === 'dist/agent-src/rules';
199
+ out.push({
200
+ toolId,
201
+ srcDir,
202
+ destDir,
203
+ kind: 'deployed',
204
+ ...(isRuleSource ? { fileFilter: ruleFilter } : {}),
205
+ });
185
206
  }
186
207
  }
187
208
  return out;
@@ -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