@event4u/agent-config 9.11.0 → 9.12.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 (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/README.md +8 -1
  4. package/dist/agent-src/commands/fix/ci.md +29 -0
  5. package/dist/agent-src/commands/pr/create.md +60 -0
  6. package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
  7. package/dist/agent-src/rules/design-fidelity.md +1 -1
  8. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
  9. package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
  10. package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
  11. package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
  12. package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
  13. package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
  14. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
  15. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  16. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +9 -5
  17. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +9 -6
  18. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +9 -5
  19. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +182 -29
  20. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +311 -24
  21. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  22. package/dist/cli-delegate/{chunk-IR7P7JWA.js → chunk-A3HJ6NTI.js} +271 -59
  23. package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
  24. package/dist/cli-delegate/{chunk-RDGRHAAB.js → chunk-HYWSRNUX.js} +6 -86
  25. package/dist/cli-delegate/{chunk-YMFO6FDN.js → chunk-QFKQLQ7X.js} +313 -81
  26. package/dist/cli-delegate/{chunk-4SV3XGFA.js → chunk-TEGJU2JX.js} +7 -9
  27. package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
  28. package/dist/cli-delegate/cmd_conformance.js +4 -7
  29. package/dist/cli-delegate/cmd_doctor.js +3 -5
  30. package/dist/cli-delegate/cmd_explain.js +153 -85
  31. package/dist/cli-delegate/cmd_export.js +5 -3
  32. package/dist/cli-delegate/cmd_fleet.js +2 -1
  33. package/dist/cli-delegate/cmd_preflight.js +6 -123
  34. package/dist/cli-delegate/cmd_prune.js +5 -3
  35. package/dist/cli-delegate/cmd_settings_migrate.js +5 -3
  36. package/dist/cli-delegate/cmd_sync.js +5 -3
  37. package/dist/cli-delegate/cmd_uninstall.js +6 -5
  38. package/dist/cli-delegate/cmd_validate.js +5 -3
  39. package/dist/discovery/deprecation-report.md +1 -1
  40. package/dist/discovery/discovery-manifest.json +36 -14
  41. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  42. package/dist/discovery/discovery-manifest.summary.md +3 -3
  43. package/dist/discovery/orphan-report.md +1 -1
  44. package/dist/discovery/packs.json +6 -5
  45. package/dist/discovery/trust-report.md +2 -2
  46. package/dist/discovery/workspaces.json +5 -4
  47. package/dist/install/install.mjs +605 -412
  48. package/dist/install/ruleInScope.js +123 -0
  49. package/dist/install/ruleInScope.js.map +1 -0
  50. package/dist/install/rule_scope.js +2 -2
  51. package/dist/install/rule_scope.js.map +1 -1
  52. package/dist/install/wizard-plan.js +10 -1
  53. package/dist/install/wizard-plan.js.map +1 -1
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +405 -2429
  57. package/dist/scripts/condense.js +6 -22
  58. package/dist/scripts/condense.js.map +1 -1
  59. package/docs/guidelines/design-fidelity-mechanics.md +14 -0
  60. package/package.json +1 -1
  61. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +9 -5
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +9 -6
  63. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +9 -5
  64. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +182 -29
  65. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +311 -24
  66. package/src/install/ruleInScope.ts +133 -0
  67. package/src/install/rule_scope.ts +2 -2
  68. package/src/install/wizard-plan.ts +11 -1
  69. package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
  70. package/src/scripts/check_installer_import_purity.ts +295 -0
  71. package/src/scripts/condense.ts +7 -28
  72. package/src/scripts/install-hooks.sh +36 -4
  73. package/src/scripts/install.ts +210 -5
  74. package/src/scripts/lint_ui_stack_bundles.ts +31 -0
  75. package/dist/cli-delegate/chunk-7DSAMZFU.js +0 -356
  76. package/dist/cli-delegate/chunk-BJGXZNPM.js +0 -266
  77. package/dist/cli-delegate/chunk-TFG57JJM.js +0 -1969
@@ -254,6 +254,13 @@ function json_string_ascii(s: string): string {
254
254
  return out;
255
255
  }
256
256
 
257
+ /**
258
+ * Predicate over a SOURCE file path — `true` = the file arrives in the install.
259
+ * Identical shape to `PlanSource.fileFilter` (`src/install/plan.ts`), so the
260
+ * plan path and the deploy path can share one predicate instance.
261
+ */
262
+ export type FileFilter = (srcFile: string) => boolean;
263
+
257
264
  /**
258
265
  * Anchor-relative paths the deploy of `src` → `<anchor>/<dest_rel>` maintains
259
266
  * — written OR skipped-as-identical.
@@ -262,8 +269,20 @@ function json_string_ascii(s: string): string {
262
269
  * symlinked files resolve to file entries, symlinked subdirectories are walked
263
270
  * through their resolved targets. `dest_rel` may be empty (`""`) for plan
264
271
  * entries that deploy into the anchor root.
272
+ *
273
+ * `file_filter` MUST be the same predicate the copy was given. This set is what
274
+ * the reaper treats as "should exist"; a file the copy skipped but this set
275
+ * still lists is never reaped, and a file this set omits while the copy wrote it
276
+ * is deleted on the next deploy. Passing the filter to one and not the other is
277
+ * the failure mode the rule-scoping work exists to avoid — see
278
+ * `install._deploy_global_content`, which derives one filter and hands it to
279
+ * both.
265
280
  */
266
- export function expected_deploy_files(src: string, dest_rel: string): Set<string> {
281
+ export function expected_deploy_files(
282
+ src: string,
283
+ dest_rel: string,
284
+ file_filter: FileFilter | null = null,
285
+ ): Set<string> {
267
286
  const out = new Set<string>();
268
287
  let src_stat: fs.Stats;
269
288
  try {
@@ -273,6 +292,9 @@ export function expected_deploy_files(src: string, dest_rel: string): Set<string
273
292
  return out;
274
293
  }
275
294
  if (!src_stat.isDirectory()) {
295
+ if (file_filter !== null && !file_filter(src)) {
296
+ return out;
297
+ }
276
298
  out.add(as_posix(dest_rel));
277
299
  return out;
278
300
  }
@@ -298,6 +320,12 @@ export function expected_deploy_files(src: string, dest_rel: string): Set<string
298
320
  _walk(fs.realpathSync(entry), rel);
299
321
  continue;
300
322
  }
323
+ // The predicate sees the SOURCE path, never `rel`: the rule-scope
324
+ // predicate reads the file's own frontmatter tags, so a dest-relative
325
+ // path would not resolve. Same contract as the copy twin.
326
+ if (file_filter !== null && !file_filter(entry)) {
327
+ continue;
328
+ }
301
329
  out.add(as_posix(rel));
302
330
  }
303
331
  };
@@ -554,7 +582,15 @@ function prune_empty_dirs(prune_candidates: Set<string>, anchor_resolved: string
554
582
  }
555
583
 
556
584
  /** `anchor in node.parents` — is `anchor` a strict ancestor of `node`? */
557
- function is_ancestor(anchor: string, node: string): boolean {
585
+ /**
586
+ * Is `node` strictly inside `anchor`? Pure path arithmetic, no I/O — callers
587
+ * pass already-resolved (realpath'd) paths when symlink escape matters.
588
+ *
589
+ * Exported for `install._copy_dir_dereferencing_symlinks`, which must reject a
590
+ * symlink whose target leaves the package root. One definition rather than a
591
+ * second copy of the `path.relative` idiom.
592
+ */
593
+ export function is_ancestor(anchor: string, node: string): boolean {
558
594
  const rel = path.relative(anchor, node);
559
595
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
560
596
  }
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Guard against a maintainer-only CLI being bundled into the consumer installer.
4
+ *
5
+ * ## The incident this exists to prevent (road-to-consistent-rule-scoping, 2026-07-31)
6
+ *
7
+ * `src/install/rule_scope.ts` imported the rule-scoping predicate from
8
+ * `src/scripts/condense.ts` — deliberately, so install semantics could not drift
9
+ * from projection semantics. Correct intent. But `condense.ts` is maintainer CLI
10
+ * tooling that ends in a module-level self-invoke:
11
+ *
12
+ * const isMain = _isCliEntry();
13
+ * if (isMain) { process.exit(main()); }
14
+ *
15
+ * `_isCliEntry()` compares `import.meta.url` against `process.argv[1]`. That is
16
+ * right for a file run directly — and wrong once esbuild BUNDLES the module,
17
+ * because `import.meta.url` then resolves to the BUNDLE's own URL, which *is*
18
+ * `argv[1]` when a consumer runs `node dist/install/install.mjs`. The guard
19
+ * fired inside the installer, `condense.main()` ran, found no maintainer source
20
+ * tree in a consumer checkout, printed "No source directory found" and called
21
+ * `process.exit(1)` — killing the install AFTER the payload had already synced.
22
+ *
23
+ * All four `smoke-public-install` legs went red. Nothing caught it earlier:
24
+ * `prepack-check` verifies that imports resolve to SHIPPED paths (they did),
25
+ * the bundle-freshness gate only byte-diffs the rebuilt output, and the
26
+ * behavioural smoke test only runs post-push in its own workflow.
27
+ *
28
+ * ## What this checks
29
+ *
30
+ * The transitive relative-import closure of the installer bundle entry must
31
+ * contain NO module-level `process.exit(` outside the entry itself.
32
+ *
33
+ * "Module-level" is decided structurally, not textually: the AST walk descends
34
+ * through top-level statements and nested blocks/ifs/try but STOPS at any
35
+ * function, method, class, arrow or accessor body. So
36
+ * `function main() { process.exit(1) }` is fine — it only runs when called —
37
+ * while `if (isMain) { process.exit(main()) }` is not, because importing the
38
+ * module runs it.
39
+ *
40
+ * The entry module is exempt: it IS the CLI, and its own exit is the point.
41
+ *
42
+ * ## Why this shape and not the alternatives
43
+ *
44
+ * - **Not a string scan of the built bundle.** That reports a mystery literal
45
+ * with no cause. This reports the import CHAIN
46
+ * (`install.ts → rule_scope.ts → condense.ts`), which is the actionable fact.
47
+ * - **Not "forbid importing condense.ts".** A denylist of one file rots the
48
+ * moment a second CLI module grows an entry guard. The hazard is the
49
+ * module-level exit, so that is what is detected.
50
+ * - **Runs on SOURCE, so it needs no build.** It fails in `task ci` before a
51
+ * push instead of on four CI legs after one.
52
+ *
53
+ * Exit 0 clean, 1 on any finding. No network, no build, no spend.
54
+ */
55
+
56
+ import * as fs from 'node:fs';
57
+ import * as path from 'node:path';
58
+ import { fileURLToPath } from 'node:url';
59
+
60
+ import ts from 'typescript';
61
+
62
+ const _HERE = path.dirname(fileURLToPath(import.meta.url));
63
+ const REPO_ROOT = path.resolve(_HERE, '..', '..');
64
+
65
+ /**
66
+ * Bundle entry points that ship to consumers as a single file.
67
+ *
68
+ * Keep in sync with the `build:*bundle` scripts in package.json. Each entry is
69
+ * exempt from the check for its own module-level exit — it is the CLI.
70
+ */
71
+ const BUNDLE_ENTRIES: readonly string[] = [
72
+ 'src/scripts/install.ts', // → dist/install/install.mjs (build:install-bundle)
73
+ ];
74
+
75
+ interface Finding {
76
+ /** The offending module, repo-relative POSIX. */
77
+ readonly module: string;
78
+ /** 1-indexed line of the offending call. */
79
+ readonly line: number;
80
+ /** How the entry reaches it, entry first. */
81
+ readonly chain: readonly string[];
82
+ }
83
+
84
+ function toPosix(p: string): string {
85
+ return p.split(path.sep).join('/');
86
+ }
87
+
88
+ function relToRepo(abs: string, root: string = REPO_ROOT): string {
89
+ return toPosix(path.relative(root, abs));
90
+ }
91
+
92
+ /**
93
+ * Resolve a relative import specifier to a source file on disk.
94
+ *
95
+ * The codebase imports with `.js` extensions (NodeNext) while the sources are
96
+ * `.ts`, so try the TypeScript twin first, then the literal path, then an
97
+ * `index` file. Returns `null` for anything unresolvable — a bare package
98
+ * specifier, a type-only path that no longer exists, or a `.json` asset.
99
+ */
100
+ function resolveImport(fromFile: string, specifier: string): string | null {
101
+ if (!specifier.startsWith('.')) return null;
102
+ const base = path.resolve(path.dirname(fromFile), specifier);
103
+ const candidates = [
104
+ base.replace(/\.js$/, '.ts'),
105
+ base.replace(/\.js$/, '.tsx'),
106
+ base,
107
+ `${base}.ts`,
108
+ path.join(base, 'index.ts'),
109
+ ];
110
+ for (const c of candidates) {
111
+ try {
112
+ if (fs.statSync(c).isFile()) return c;
113
+ } catch {
114
+ // next candidate
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** Relative import specifiers declared by `source`, in source order. */
121
+ function importSpecifiers(source: ts.SourceFile): string[] {
122
+ const out: string[] = [];
123
+ const visit = (node: ts.Node): void => {
124
+ if (
125
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
126
+ node.moduleSpecifier !== undefined &&
127
+ ts.isStringLiteral(node.moduleSpecifier)
128
+ ) {
129
+ out.push(node.moduleSpecifier.text);
130
+ } else if (
131
+ ts.isCallExpression(node) &&
132
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
133
+ node.arguments.length > 0 &&
134
+ ts.isStringLiteral(node.arguments[0] as ts.Node)
135
+ ) {
136
+ // A dynamic import() is still bundled by esbuild unless code-split,
137
+ // so it carries the same hazard as a static one.
138
+ out.push((node.arguments[0] as ts.StringLiteral).text);
139
+ }
140
+ ts.forEachChild(node, visit);
141
+ };
142
+ ts.forEachChild(source, visit);
143
+ return out;
144
+ }
145
+
146
+ /** True for nodes whose body only runs when called — the walk stops here. */
147
+ function isDeferredScope(node: ts.Node): boolean {
148
+ return (
149
+ ts.isFunctionDeclaration(node) ||
150
+ ts.isFunctionExpression(node) ||
151
+ ts.isArrowFunction(node) ||
152
+ ts.isMethodDeclaration(node) ||
153
+ ts.isConstructorDeclaration(node) ||
154
+ ts.isGetAccessorDeclaration(node) ||
155
+ ts.isSetAccessorDeclaration(node) ||
156
+ ts.isClassDeclaration(node) ||
157
+ ts.isClassExpression(node)
158
+ );
159
+ }
160
+
161
+ /** Is `node` a `process.exit(...)` call? */
162
+ function isProcessExitCall(node: ts.Node): boolean {
163
+ if (!ts.isCallExpression(node)) return false;
164
+ const callee = node.expression;
165
+ if (!ts.isPropertyAccessExpression(callee)) return false;
166
+ return (
167
+ ts.isIdentifier(callee.expression) &&
168
+ callee.expression.text === 'process' &&
169
+ callee.name.text === 'exit'
170
+ );
171
+ }
172
+
173
+ /**
174
+ * 1-indexed lines of every `process.exit(` that executes at MODULE LOAD.
175
+ *
176
+ * Descends through statement-level structure (blocks, if/else, try/catch/finally,
177
+ * loops, labels) and stops at any deferred scope, so a call inside a function is
178
+ * not reported while one inside a top-level `if` is.
179
+ */
180
+ function moduleLevelExitLines(source: ts.SourceFile): number[] {
181
+ const lines: number[] = [];
182
+ const visit = (node: ts.Node): void => {
183
+ if (isDeferredScope(node)) return;
184
+ if (isProcessExitCall(node)) {
185
+ lines.push(source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1);
186
+ }
187
+ ts.forEachChild(node, visit);
188
+ };
189
+ ts.forEachChild(source, visit);
190
+ return lines;
191
+ }
192
+
193
+ function parse(file: string): ts.SourceFile | null {
194
+ let text: string;
195
+ try {
196
+ text = fs.readFileSync(file, 'utf-8');
197
+ } catch {
198
+ return null;
199
+ }
200
+ return ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true);
201
+ }
202
+
203
+ /**
204
+ * Walk one entry's closure; return every module-level exit found below it.
205
+ *
206
+ * `root` defaults to the repo; tests pass a fixture tree so the chain-reporting
207
+ * itself is covered, not only the per-file detector.
208
+ */
209
+ function auditEntry(entryRel: string, root: string = REPO_ROOT): Finding[] {
210
+ const entryAbs = path.join(root, entryRel);
211
+ if (!fs.existsSync(entryAbs)) {
212
+ process.stderr.write(
213
+ `check-installer-import-purity: entry ${entryRel} does not exist — ` +
214
+ 'update BUNDLE_ENTRIES\n',
215
+ );
216
+ process.exitCode = 1;
217
+ return [];
218
+ }
219
+ const findings: Finding[] = [];
220
+ const seen = new Set<string>([entryAbs]);
221
+ // BFS so the reported chain is a shortest path — the least confusing one.
222
+ const queue: Array<{ file: string; chain: string[] }> = [
223
+ { file: entryAbs, chain: [entryRel] },
224
+ ];
225
+ while (queue.length > 0) {
226
+ const { file, chain } = queue.shift() as { file: string; chain: string[] };
227
+ const source = parse(file);
228
+ if (source === null) continue;
229
+
230
+ // The entry is the CLI; its own module-level exit is intended.
231
+ if (file !== entryAbs) {
232
+ for (const line of moduleLevelExitLines(source)) {
233
+ findings.push({ module: relToRepo(file, root), line, chain });
234
+ }
235
+ }
236
+
237
+ for (const spec of importSpecifiers(source)) {
238
+ const target = resolveImport(file, spec);
239
+ if (target === null || seen.has(target)) continue;
240
+ seen.add(target);
241
+ queue.push({ file: target, chain: [...chain, relToRepo(target, root)] });
242
+ }
243
+ }
244
+ return findings;
245
+ }
246
+
247
+ function main(): number {
248
+ const all: Finding[] = [];
249
+ for (const entry of BUNDLE_ENTRIES) {
250
+ all.push(...auditEntry(entry));
251
+ }
252
+ if (all.length === 0) {
253
+ process.stdout.write(
254
+ `✅ installer import purity: ${BUNDLE_ENTRIES.length} bundle entr` +
255
+ `${BUNDLE_ENTRIES.length === 1 ? 'y' : 'ies'} reach no module-level ` +
256
+ 'process.exit()\n',
257
+ );
258
+ return 0;
259
+ }
260
+ process.stdout.write('❌ installer import purity — module-level process.exit() reachable:\n');
261
+ for (const f of all) {
262
+ process.stdout.write(`\n ${f.module}:${f.line}\n`);
263
+ process.stdout.write(` reached via: ${f.chain.join(' → ')}\n`);
264
+ }
265
+ process.stdout.write(
266
+ '\n Why this fails the build: esbuild bundles the whole closure into\n' +
267
+ ' dist/install/install.mjs. A module-level process.exit() then runs when a\n' +
268
+ ' CONSUMER loads the installer — and a CLI entry guard comparing\n' +
269
+ ' import.meta.url to process.argv[1] evaluates TRUE inside a bundle, because\n' +
270
+ ' the bundle IS argv[1].\n\n' +
271
+ ' Fix: move the code the installer actually needs into a module with no CLI\n' +
272
+ ' entry and no top-level exit, and let the CLI re-export it. Do NOT relax\n' +
273
+ ' this gate — the failure it prevents kills a consumer install after the\n' +
274
+ ' payload has already been written.\n',
275
+ );
276
+ return 1;
277
+ }
278
+
279
+ const _isCliEntry = (): boolean => {
280
+ if (process.argv[1] === undefined) return false;
281
+ try {
282
+ return (
283
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
284
+ fs.realpathSync(path.resolve(process.argv[1]))
285
+ );
286
+ } catch {
287
+ return false;
288
+ }
289
+ };
290
+
291
+ if (_isCliEntry()) {
292
+ process.exit(main());
293
+ }
294
+
295
+ export { auditEntry, moduleLevelExitLines, BUNDLE_ENTRIES };
@@ -238,6 +238,13 @@ function _stripChars(s: string, chars: string): string {
238
238
  // the repo root.
239
239
  // =============================================================================
240
240
 
241
+ // road-to-consistent-rule-scoping: the predicate moved to a side-effect-free
242
+ // module so the consumer installer can import it WITHOUT pulling this file's
243
+ // module-level CLI self-invoke into its bundle. Re-exported here because four
244
+ // in-repo consumers import it from `condense.js`.
245
+ export { rule_in_scope } from '../install/ruleInScope.js';
246
+ import { rule_in_scope } from '../install/ruleInScope.js';
247
+
241
248
  const _HERE = path.dirname(fileURLToPath(import.meta.url)); // <repo>/src/scripts
242
249
  const _DEFAULT_PROJECT_ROOT = path.resolve(_HERE, '..', '..');
243
250
 
@@ -1014,34 +1021,6 @@ function _read_rule_packs(): string[] | null {
1014
1021
  * `null` (no role filtering), so every existing 2- and 3-arg call site
1015
1022
  * compiles and behaves unchanged.
1016
1023
  */
1017
- export function rule_in_scope(
1018
- source_path: string,
1019
- scope: readonly string[] | null,
1020
- pack_scope: readonly string[] | null = null,
1021
- role_scope: readonly string[] | null = null,
1022
- ): boolean {
1023
- if (scope === null && pack_scope === null && role_scope === null) {
1024
- return true;
1025
- }
1026
- const [meta] = _parse_frontmatter(_readText(source_path));
1027
- if (meta['type'] === 'always' || meta['alwaysApply'] === true) {
1028
- return true; // kernel always projects
1029
- }
1030
- const axis = (key: string, configured: readonly string[] | null): boolean => {
1031
- if (configured === null) {
1032
- return true;
1033
- }
1034
- const values = Array.isArray(meta[key])
1035
- ? (meta[key] as unknown[]).map((w) => String(w))
1036
- : [];
1037
- if (values.length === 0) {
1038
- return true; // untagged → fail safe: ship it
1039
- }
1040
- return values.some((v) => configured.includes(v));
1041
- };
1042
- return axis('workspaces', scope) && axis('packs', pack_scope) && axis('roles', role_scope);
1043
- }
1044
-
1045
1024
  /** List rule basenames under RULES_SOURCE, workspace/pack scope applied. */
1046
1025
  function _scoped_rule_basenames(): string[] {
1047
1026
  const scope = _read_rule_workspaces();
@@ -51,6 +51,27 @@ else
51
51
  fi
52
52
  fi
53
53
 
54
+ # road-to-local-ci-trust Phase 2: the CI-only repo-content gates, as one task.
55
+ # Deliberately placed AFTER the consistency block above — `task consistency`
56
+ # runs `task sync`, which generates the `.augment/` projection. Several
57
+ # preflight gates read that tree, so on a FRESH worktree (this repo's standard
58
+ # workflow) running preflight first would fail with "produced by regeneration
59
+ # but absent before" — red for a reason the contributor did not cause, which is
60
+ # exactly the anti-pattern this gate set exists to avoid. Measured 15s against
61
+ # the 25s `pre_push_budget_seconds` ceiling in src/config/ci-local-parity.yml.
62
+ # `check_enforcement_coverage` (30.7s) stays out of preflight by design.
63
+ echo "🔍 Preflight — the CI-only repo-content gates..."
64
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_PREFLIGHT:-}" = "1" ]; then
65
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_PREFLIGHT=1"
66
+ elif ! command -v task >/dev/null 2>&1; then
67
+ echo "⚠️ 'task' not found — skipping preflight for this push."
68
+ elif ! task preflight; then
69
+ echo "❌ Preflight failed. These gates run in CI too, so pushing now buys a"
70
+ echo " red run and a fixup re-push. Fix the failures above, or"
71
+ echo " AGENT_CONFIG_SKIP_PREPUSH_PREFLIGHT=1 for a genuine WIP push."
72
+ exit 1
73
+ fi
74
+
54
75
  echo "🔍 Checking for leftover conflict markers / unmerged paths..."
55
76
  if ! ./scripts-run src/scripts/check_no_conflict_markers --quiet; then
56
77
  echo "❌ Conflict markers or unmerged paths present. Resolve them (e.g. 'git checkout HEAD -- <file>' or finish the merge), then re-push."
@@ -127,11 +148,22 @@ if git diff --cached --name-only | grep -qE '^agents/roadmaps(-progress\.md|/)';
127
148
  ./scripts-run .augment/scripts/update_roadmap_progress --check
128
149
  rstatus=$?
129
150
  if [ $rstatus -ne 0 ]; then
151
+ # The check fails for THREE distinct reasons and prints the real one
152
+ # above: a stale dashboard, an unresolved `[~]` deferred item (Iron
153
+ # Law 3), or a completed-but-unarchived roadmap. Naming only the first
154
+ # sent a maintainer chasing a regeneration that was already correct —
155
+ # and a safety gate whose text points at the wrong cause trains people
156
+ # to distrust the gate, which costs more than the wrong message.
130
157
  echo ""
131
- echo "❌ Commit blocked — agents/roadmaps-progress.md is stale."
132
- echo " Run './agent-config roadmap:progress' (or"
133
- echo " './scripts-run .augment/scripts/update_roadmap_progress'),"
134
- echo " stage agents/roadmaps-progress.md, then re-commit."
158
+ echo "❌ Commit blocked — the roadmap dashboard gate failed."
159
+ echo " The cause is printed above. The three it reports:"
160
+ echo " · dashboard stale → './agent-config roadmap:progress',"
161
+ echo " then stage agents/roadmaps-progress.md"
162
+ echo " · Iron Law 3 → unresolved '[~]' deferred items. Surface"
163
+ echo " them and ask the user; NOT the agent's"
164
+ echo " to clear (roadmap-management § 4b)"
165
+ echo " · completed roadmap → 'git mv' it into agents/roadmaps/archive/,"
166
+ echo " then regenerate"
135
167
  echo " To bypass for an unrelated WIP commit: git commit --no-verify"
136
168
  exit 1
137
169
  fi