@event4u/agent-config 3.0.0 → 3.1.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 (207) hide show
  1. package/.agent-src/commands/install-via-agent.md +129 -0
  2. package/.agent-src/commands/video/from-script.md +1 -1
  3. package/.agent-src/commands/video.md +1 -1
  4. package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
  5. package/.agent-src/rules/caveman-speak.md +2 -2
  6. package/.agent-src/rules/context-hygiene.md +36 -0
  7. package/.agent-src/rules/engineering-safety-floor.md +102 -0
  8. package/.agent-src/rules/finance-safety-floor.md +114 -0
  9. package/.agent-src/rules/git-history-discipline.md +1 -1
  10. package/.agent-src/rules/no-cheap-questions.md +34 -32
  11. package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
  12. package/.agent-src/rules/strategy-safety-floor.md +114 -0
  13. package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
  14. package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
  15. package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
  16. package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
  17. package/.agent-src/skills/readme-writing/SKILL.md +52 -4
  18. package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
  19. package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
  20. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
  22. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
  23. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
  24. package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
  25. package/.claude-plugin/marketplace.json +2 -1
  26. package/AGENTS.md +10 -8
  27. package/CHANGELOG.md +223 -125
  28. package/README.md +165 -553
  29. package/config/agent-settings.template.yml +0 -7
  30. package/config/discovery/packs.yml +20 -0
  31. package/config/discovery/unassigned-artefacts.yml +2 -0
  32. package/config/gitignore-block.txt +19 -3
  33. package/dist/cli/commands/uiServe.js +13 -4
  34. package/dist/cli/commands/uiServe.js.map +1 -1
  35. package/dist/cli/registry.js +2 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/discovery/deprecation-report.md +7 -0
  38. package/dist/discovery/discovery-manifest.json +2107 -1409
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +9 -9
  41. package/dist/discovery/orphan-report.md +10 -0
  42. package/dist/discovery/packs.json +1002 -0
  43. package/dist/discovery/trust-report.md +26 -0
  44. package/dist/discovery/workspaces.json +705 -0
  45. package/dist/mcp/registry-manifest.json +4 -4
  46. package/dist/router.json +1623 -0
  47. package/dist/server/app.js +11 -3
  48. package/dist/server/app.js.map +1 -1
  49. package/dist/server/io/atomicMultiWrite.js +3 -1
  50. package/dist/server/io/atomicMultiWrite.js.map +1 -1
  51. package/dist/server/io/yamlIO.js +22 -0
  52. package/dist/server/io/yamlIO.js.map +1 -1
  53. package/dist/server/routes/ping.js +8 -0
  54. package/dist/server/routes/ping.js.map +1 -1
  55. package/dist/server/routes/schema.js +2 -2
  56. package/dist/server/routes/schema.js.map +1 -1
  57. package/dist/server/routes/settings.js +104 -23
  58. package/dist/server/routes/settings.js.map +1 -1
  59. package/dist/server/routes/userMd.js +37 -27
  60. package/dist/server/routes/userMd.js.map +1 -1
  61. package/dist/server/routes/wizard.js +256 -20
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +0 -1
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/dist/server/token.js +10 -3
  66. package/dist/server/token.js.map +1 -1
  67. package/dist/server/writeRoot.js +28 -11
  68. package/dist/server/writeRoot.js.map +1 -1
  69. package/dist/server/writeRoot.test.js +22 -4
  70. package/dist/server/writeRoot.test.js.map +1 -1
  71. package/dist/shared/userMd/formAdapter.js +29 -51
  72. package/dist/shared/userMd/formAdapter.js.map +1 -1
  73. package/dist/shared/userMd/schema.js +32 -104
  74. package/dist/shared/userMd/schema.js.map +1 -1
  75. package/dist/shared/userMd/utils.js +64 -50
  76. package/dist/shared/userMd/utils.js.map +1 -1
  77. package/dist/ui/assets/index-D-DY1ywI.js +35 -0
  78. package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
  79. package/dist/ui/index.html +1 -1
  80. package/docs/adrs/router/0001-three-tier-routing.md +5 -5
  81. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
  82. package/docs/architecture.md +3 -3
  83. package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
  84. package/docs/catalog.md +30 -26
  85. package/docs/contracts/CHANGELOG-conventions.md +1 -1
  86. package/docs/contracts/agent-user-schema.md +6 -9
  87. package/docs/contracts/consumer-bridge.md +79 -0
  88. package/docs/contracts/discovery-manifest.md +209 -0
  89. package/docs/contracts/discovery-manifest.schema.json +77 -4
  90. package/docs/contracts/explain-trace.schema.json +1 -1
  91. package/docs/contracts/file-ownership-matrix.json +197 -13
  92. package/docs/contracts/frontmatter-contract.md +140 -0
  93. package/docs/contracts/gui-wizard.md +223 -0
  94. package/docs/contracts/installer-agent-mode.md +137 -0
  95. package/docs/contracts/kernel-membership.md +1 -1
  96. package/docs/contracts/mcp-tool-inventory.md +9 -9
  97. package/docs/contracts/namespace.md +6 -6
  98. package/docs/contracts/provider-lifecycle.md +5 -5
  99. package/docs/contracts/rule-router.md +4 -4
  100. package/docs/contracts/settings-api.md +53 -6
  101. package/docs/contracts/smoke-contracts.md +3 -3
  102. package/docs/contracts/trust-and-safety.md +144 -0
  103. package/docs/customization.md +2 -2
  104. package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
  105. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
  106. package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
  107. package/docs/decisions/ADR-016-installer-architecture.md +189 -0
  108. package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
  109. package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
  110. package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
  111. package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
  112. package/docs/decisions/ADR-021-deployment-shape.md +153 -0
  113. package/docs/decisions/INDEX.md +7 -0
  114. package/docs/deploy/connector-setup.md +129 -0
  115. package/docs/deploy/env-vars.md +70 -0
  116. package/docs/deploy/policy-cookbook.md +130 -0
  117. package/docs/deploy/quickstart.md +112 -0
  118. package/docs/distribution/public-install-smoke.md +68 -0
  119. package/docs/distribution/registries.md +55 -0
  120. package/docs/distribution/telemetry-privacy.md +128 -0
  121. package/docs/distribution/telemetry-schema.md +174 -0
  122. package/docs/featured-skills.md +95 -0
  123. package/docs/getting-started-by-role.md +19 -1
  124. package/docs/getting-started.md +2 -2
  125. package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
  126. package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
  127. package/docs/installation.md +27 -14
  128. package/docs/maintainers/dev-mode.md +105 -0
  129. package/docs/setup/per-ide/claude-desktop.md +3 -2
  130. package/docs/wizard.md +39 -4
  131. package/package.json +18 -1
  132. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  133. package/scripts/_cli/cmd_doctor.py +150 -2
  134. package/scripts/_cli/cmd_explain.py +2 -1
  135. package/scripts/_cli/cmd_migrate_to_global.py +415 -0
  136. package/scripts/_cli/cmd_settings_migrate.py +146 -0
  137. package/scripts/_cli/explain_last/route.py +2 -1
  138. package/scripts/_dispatch.bash +36 -3
  139. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  141. package/scripts/_lib/agent_settings.py +4 -1
  142. package/scripts/_lib/agent_src.py +157 -0
  143. package/scripts/agent-config +17 -6
  144. package/scripts/audit_skill_descriptions.py +18 -6
  145. package/scripts/build_discovery_manifest.py +373 -17
  146. package/scripts/check_artefact_checksums.py +104 -0
  147. package/scripts/check_cluster_patterns.py +20 -4
  148. package/scripts/check_command_count_messaging.py +33 -14
  149. package/scripts/check_council_references.py +43 -4
  150. package/scripts/check_overlay_cascade_subdirs.py +7 -3
  151. package/scripts/check_references.py +5 -2
  152. package/scripts/check_reply_consistency.py +32 -9
  153. package/scripts/check_template_pin_drift.py +24 -7
  154. package/scripts/check_token_optimizer_freshness.py +18 -3
  155. package/scripts/compile_router.py +34 -2
  156. package/scripts/compress.py +162 -44
  157. package/scripts/config/presets.py +19 -1
  158. package/scripts/config/profiles.py +16 -1
  159. package/scripts/discovery_stats.py +70 -0
  160. package/scripts/expected_perms.json +47 -0
  161. package/scripts/generate_index.py +78 -46
  162. package/scripts/generate_ownership_matrix.py +98 -43
  163. package/scripts/generate_pack_manifests.py +183 -0
  164. package/scripts/install +18 -1
  165. package/scripts/install.py +934 -59
  166. package/scripts/install.sh +27 -9
  167. package/scripts/lint_agents_layout.py +93 -13
  168. package/scripts/lint_agents_md.py +1 -1
  169. package/scripts/lint_archived_skills.py +32 -16
  170. package/scripts/lint_bench_corpus.py +14 -2
  171. package/scripts/lint_command_tiers.py +15 -2
  172. package/scripts/lint_featured_skills.py +139 -0
  173. package/scripts/lint_framework_leakage.py +33 -6
  174. package/scripts/lint_global_paths.py +147 -0
  175. package/scripts/lint_orchestration_dsl.py +6 -3
  176. package/scripts/lint_pack_boundaries.py +147 -0
  177. package/scripts/lint_pack_first_win.py +103 -0
  178. package/scripts/lint_readme_jargon.py +131 -0
  179. package/scripts/lint_readme_size.py +33 -0
  180. package/scripts/lint_rule_interactions.py +23 -5
  181. package/scripts/lint_rule_tiers.py +12 -3
  182. package/scripts/lint_trust_coherence.py +212 -0
  183. package/scripts/measure_rule_budget.py +22 -4
  184. package/scripts/move_artefact.py +143 -0
  185. package/scripts/new_skill.py +148 -0
  186. package/scripts/plan_physical_move.py +353 -0
  187. package/scripts/refine_ticket_detect.py +30 -7
  188. package/scripts/schemas/command.schema.json +4 -0
  189. package/scripts/skill_linter.py +248 -118
  190. package/scripts/skill_trigger_eval.py +28 -8
  191. package/scripts/smoke/kernel.sh +1 -1
  192. package/scripts/smoke/router.sh +24 -5
  193. package/scripts/smoke/skills.sh +15 -7
  194. package/scripts/smoke_quickstart.py +11 -2
  195. package/scripts/snapshot_agent_outputs.py +144 -0
  196. package/scripts/update_counts.py +45 -17
  197. package/scripts/validate_decision_engine.py +9 -1
  198. package/scripts/validate_discovery_manifest.py +94 -0
  199. package/scripts/validate_frontmatter.py +39 -20
  200. package/scripts/verify_physical_move.py +185 -0
  201. package/templates/agent-user.md +0 -1
  202. package/templates/agent-user.yml +21 -0
  203. package/templates/minimal/agents-overrides-readme.md +46 -0
  204. package/templates/minimal/overrides-gitkeep +2 -0
  205. package/dist/ui/assets/index-BTRcKDlB.js +0 -39
  206. package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
  207. package/templates/minimal/agents-gitkeep +0 -2
@@ -32,14 +32,43 @@ import yaml
32
32
 
33
33
  sys.path.insert(0, str(Path(__file__).resolve().parent))
34
34
  from _lib.script_output import info, success, flush_summary, resolve_level # noqa: E402
35
+ from _lib.agent_src import ( # noqa: E402
36
+ artefact_roots,
37
+ iter_all_sources,
38
+ resolve_logical,
39
+ )
35
40
 
36
41
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
42
+ # Legacy single-root anchor — kept for backward compatibility with callers
43
+ # that pass it explicitly. Multi-root iteration (post-ADR-017 physical
44
+ # move) goes through `_lib.agent_src` helpers.
37
45
  SOURCE_DIR = PROJECT_ROOT / ".agent-src.uncompressed"
38
46
  TARGET_DIR = PROJECT_ROOT / ".agent-src"
39
47
  AUGMENT_DIR = PROJECT_ROOT / ".augment"
40
48
  HASH_FILE = PROJECT_ROOT / ".compression-hashes.json"
41
49
  SETTINGS_FILE = PROJECT_ROOT / ".agent-settings.yml"
42
50
 
51
+
52
+ def _iter_sources():
53
+ """Yield (physical_path, logical_relpath) for every source artefact.
54
+
55
+ Wraps `_lib.agent_src.iter_all_sources` so the compressor walks every
56
+ active source root (legacy `.agent-src.uncompressed/` plus any
57
+ `packages/*/.agent-src.uncompressed/`) and keys outputs by the
58
+ logical relative path that survives the physical move (ADR-017).
59
+ """
60
+ yield from iter_all_sources()
61
+
62
+
63
+ def _resolve_source(relative: str) -> Path | None:
64
+ """Find the physical path that backs a logical relative path."""
65
+ return resolve_logical(relative)
66
+
67
+
68
+ def _any_source_root_exists() -> bool:
69
+ """True if at least one artefact source root contains files."""
70
+ return bool(artefact_roots())
71
+
43
72
  # Self-projection tool toggle — see .agent-tools.yml. When the file is
44
73
  # absent (e.g. tests run in tmp dirs, consumer projects), `_active_tools`
45
74
  # returns ``None`` which is treated as "emit every tool".
@@ -148,8 +177,8 @@ def mark_done(relative_path: str) -> None:
148
177
  relative paths in the shipped layer (P1 of road-to-path-fixes.md).
149
178
  Idempotent — re-running is a no-op.
150
179
  """
151
- source_file = SOURCE_DIR / relative_path
152
- if not source_file.exists():
180
+ source_file = _resolve_source(relative_path)
181
+ if source_file is None or not source_file.exists():
153
182
  print(f"❌ Source file not found: {relative_path}")
154
183
  sys.exit(1)
155
184
  apply_path_rewriter(relative_path)
@@ -181,12 +210,9 @@ def mark_all_done() -> None:
181
210
  """Mark ALL .md files as compressed (e.g. after initial full compression)."""
182
211
  hashes = load_hashes()
183
212
  count = 0
184
- for source_file in sorted(SOURCE_DIR.rglob("*")):
185
- if source_file.is_dir():
186
- continue
213
+ for source_file, relative in _iter_sources():
187
214
  if not should_compress(source_file):
188
215
  continue
189
- relative = str(source_file.relative_to(SOURCE_DIR))
190
216
  hashes[relative] = file_hash(source_file)
191
217
  count += 1
192
218
  save_hashes(hashes)
@@ -194,15 +220,17 @@ def mark_all_done() -> None:
194
220
 
195
221
 
196
222
  def list_changed_md(source_dir: Path) -> list:
197
- """List .md files whose source hash differs from stored hash (= need recompression)."""
223
+ """List .md files whose source hash differs from stored hash (= need recompression).
224
+
225
+ The ``source_dir`` parameter is retained for backward compatibility but
226
+ ignored — iteration walks every active source root (ADR-017).
227
+ """
228
+ del source_dir # multi-root: ignored, kept for signature stability
198
229
  hashes = load_hashes()
199
230
  changed = []
200
- for source_file in sorted(source_dir.rglob("*")):
201
- if source_file.is_dir():
202
- continue
231
+ for source_file, relative in _iter_sources():
203
232
  if not should_compress(source_file):
204
233
  continue
205
- relative = str(source_file.relative_to(source_dir))
206
234
  current_hash = file_hash(source_file)
207
235
  stored_hash = hashes.get(relative)
208
236
  if stored_hash != current_hash:
@@ -211,12 +239,12 @@ def list_changed_md(source_dir: Path) -> list:
211
239
 
212
240
 
213
241
  def find_stale_hashes(source_dir: Path) -> list:
214
- """Find hashes stored for source files that no longer exist."""
242
+ """Find hashes stored for source files that no longer exist in any root."""
243
+ del source_dir # multi-root: ignored, kept for signature stability
215
244
  hashes = load_hashes()
216
245
  stale = []
217
246
  for relative in sorted(hashes.keys()):
218
- source_file = source_dir / relative
219
- if not source_file.exists():
247
+ if _resolve_source(relative) is None:
220
248
  stale.append(relative)
221
249
  return stale
222
250
 
@@ -240,10 +268,16 @@ def should_compress(filepath: Path) -> bool:
240
268
  return False
241
269
  if filepath.name in COPY_AS_IS:
242
270
  return False
243
- try:
244
- rel_parts = filepath.relative_to(SOURCE_DIR).parts
245
- except ValueError:
246
- rel_parts = filepath.parts
271
+ # Determine the logical relative path so the COPY_AS_IS_DIRS check
272
+ # works for both legacy (`.agent-src.uncompressed/`) and post-move
273
+ # (`packages/*/.agent-src.uncompressed/`) source roots.
274
+ rel_parts: tuple[str, ...] = filepath.parts
275
+ for root in artefact_roots():
276
+ try:
277
+ rel_parts = filepath.relative_to(root).parts
278
+ break
279
+ except ValueError:
280
+ continue
247
281
  if rel_parts and rel_parts[0] in COPY_AS_IS_DIRS:
248
282
  return False
249
283
  return True
@@ -256,7 +290,8 @@ def copy_file(source: Path, target: Path) -> None:
256
290
 
257
291
 
258
292
  def cleanup_stale(source_dir: Path, target_dir: Path) -> int:
259
- """Delete files in target that don't exist in source. Returns count."""
293
+ """Delete files in target that don't exist in any source root. Returns count."""
294
+ del source_dir # multi-root: ignored, kept for signature stability
260
295
  deleted = 0
261
296
  if not target_dir.exists():
262
297
  return 0
@@ -265,8 +300,7 @@ def cleanup_stale(source_dir: Path, target_dir: Path) -> int:
265
300
  if target_file.is_dir():
266
301
  continue
267
302
  relative = target_file.relative_to(target_dir)
268
- source_file = source_dir / relative
269
- if not source_file.exists():
303
+ if _resolve_source(str(relative)) is None:
270
304
  print(f" Deleting stale: {relative}")
271
305
  target_file.unlink()
272
306
  deleted += 1
@@ -281,14 +315,17 @@ def cleanup_stale(source_dir: Path, target_dir: Path) -> int:
281
315
 
282
316
 
283
317
  def sync_non_md(source_dir: Path, target_dir: Path) -> int:
284
- """Copy all non-.md files (and COPY_AS_IS .md files) from source to target. Returns count."""
318
+ """Copy all non-.md files (and COPY_AS_IS .md files) from every source
319
+ root to target. Returns count."""
320
+ del source_dir # multi-root: ignored, kept for signature stability
285
321
  copied = 0
286
- for source_file in sorted(source_dir.rglob("*")):
287
- if source_file.is_dir():
288
- continue
322
+ seen: set[str] = set()
323
+ for source_file, relative in _iter_sources():
289
324
  if should_compress(source_file):
290
325
  continue # .md files are compressed by the agent, not copied here
291
- relative = source_file.relative_to(source_dir)
326
+ if relative in seen:
327
+ continue
328
+ seen.add(relative)
292
329
  target_file = target_dir / relative
293
330
  copy_file(source_file, target_file)
294
331
  print(f" Copied: {relative}")
@@ -298,36 +335,41 @@ def sync_non_md(source_dir: Path, target_dir: Path) -> int:
298
335
 
299
336
  def list_md_files(source_dir: Path) -> list:
300
337
  """List all .md files that need compression by the agent."""
301
- files = []
302
- for source_file in sorted(source_dir.rglob("*")):
303
- if source_file.is_dir():
338
+ del source_dir # multi-root: ignored, kept for signature stability
339
+ files: list[str] = []
340
+ seen: set[str] = set()
341
+ for source_file, relative in _iter_sources():
342
+ if not should_compress(source_file):
304
343
  continue
305
- if should_compress(source_file):
306
- files.append(str(source_file.relative_to(source_dir)))
307
- return files
344
+ if relative in seen:
345
+ continue
346
+ seen.add(relative)
347
+ files.append(relative)
348
+ return sorted(files)
308
349
 
309
350
 
310
351
  def check_sync(source_dir: Path, target_dir: Path) -> tuple:
311
- """Check if target is in sync with source. Returns (missing, stale) lists."""
352
+ """Check if target is in sync with source(s). Returns (missing, stale) lists."""
353
+ del source_dir # multi-root: ignored, kept for signature stability
312
354
  missing = []
313
355
  stale = []
314
356
 
315
- # Files in source but not in target
316
- for source_file in sorted(source_dir.rglob("*")):
317
- if source_file.is_dir():
357
+ # Files in any source root but not in target
358
+ seen: set[str] = set()
359
+ for _source_file, relative in _iter_sources():
360
+ if relative in seen:
318
361
  continue
319
- relative = str(source_file.relative_to(source_dir))
362
+ seen.add(relative)
320
363
  if not (target_dir / relative).exists():
321
364
  missing.append(relative)
322
365
 
323
- # Files in target but not in source (stale)
366
+ # Files in target but not in any source root (stale)
324
367
  if target_dir.exists():
325
368
  for target_file in sorted(target_dir.rglob("*")):
326
369
  if target_file.is_dir():
327
370
  continue
328
371
  relative = str(target_file.relative_to(target_dir))
329
-
330
- if not (source_dir / relative).exists():
372
+ if _resolve_source(relative) is None:
331
373
  stale.append(relative)
332
374
 
333
375
  return missing, stale
@@ -512,11 +554,80 @@ def _rewrite_body_links(body: str, prefix: str) -> str:
512
554
  return _BODY_DOCS_RE.sub(prefix + r"\1", body)
513
555
 
514
556
 
557
+ # ── Human-review banner injection (Phase 5.3 / ADR-018) ───────────────────
558
+ # Source artefacts may set `trust.human_review_required: true` in their
559
+ # frontmatter. The compressor injects a short, parser-stable banner block
560
+ # at the top of the projected body so every downstream surface (agent
561
+ # memory, .augment, .claude, etc.) surfaces the gate. Idempotent — the
562
+ # marker comment prevents double-injection on re-compress.
563
+
564
+ _HRR_BANNER_MARKER = "<!-- agent-config:human-review-banner -->"
565
+
566
+ # Plain YAML list item — any scalar, used to read `packs:` / `workspaces:`
567
+ # blocks where values are bare identifiers, not file paths.
568
+ _FM_PLAIN_LIST_RE = re.compile(r'^\s*-\s*(["\']?)([^"\'\n]+?)\1\s*$')
569
+
570
+
571
+ def _parse_trust_and_owner(fm_lines):
572
+ """Extract `trust.level`, `human_review_required`, and an owner hint
573
+ from a frontmatter line list. Owner falls back to the first pack
574
+ prefix (`finance-basic` → `finance`), then the first workspace,
575
+ then `unknown`.
576
+ """
577
+ level = None
578
+ hrr = False
579
+ packs: list[str] = []
580
+ workspaces: list[str] = []
581
+ in_trust = False
582
+ in_packs = False
583
+ in_workspaces = False
584
+ for line in fm_lines:
585
+ stripped = line.lstrip()
586
+ indent = len(line) - len(stripped)
587
+ if indent == 0 and stripped.endswith(":"):
588
+ key = stripped[:-1]
589
+ in_trust = key == "trust"
590
+ in_packs = key == "packs"
591
+ in_workspaces = key == "workspaces"
592
+ continue
593
+ if in_trust and stripped.startswith("level:"):
594
+ level = stripped.split(":", 1)[1].strip().strip('"').strip("'")
595
+ elif in_trust and stripped.startswith("human_review_required:"):
596
+ val = stripped.split(":", 1)[1].strip()
597
+ hrr = val.lower() == "true"
598
+ elif in_packs or in_workspaces:
599
+ m = _FM_PLAIN_LIST_RE.match(line)
600
+ if m:
601
+ value = m.group(2).strip()
602
+ (packs if in_packs else workspaces).append(value)
603
+ owner = "unknown"
604
+ if packs:
605
+ owner = packs[0].split("-")[0]
606
+ elif workspaces:
607
+ owner = workspaces[0]
608
+ return level, hrr, owner
609
+
610
+
611
+ def _inject_hrr_banner(body: str, level: str, owner: str) -> str:
612
+ """Prepend the HUMAN_REVIEW banner block to `body`. Idempotent — a
613
+ body that already carries `_HRR_BANNER_MARKER` is returned unchanged.
614
+ """
615
+ if _HRR_BANNER_MARKER in body:
616
+ return body
617
+ banner = (
618
+ f"{_HRR_BANNER_MARKER}\n"
619
+ f"> HUMAN REVIEW REQUIRED · trust: {level} · owner: {owner}\n\n"
620
+ )
621
+ return banner + body.lstrip("\n")
622
+
623
+
515
624
  def _rewrite_paths(content: str, source_relative_path: str) -> str:
516
625
  """Rewrite logical / legacy paths in `content` for a file shipped at
517
626
  `.agent-src/{source_relative_path}`. Idempotent.
518
627
 
519
628
  See module-level comment above for the full pattern catalog.
629
+ Also injects the HUMAN_REVIEW banner when the source frontmatter
630
+ sets `trust.human_review_required: true` (Phase 5.3 / ADR-018).
520
631
  """
521
632
  prefix = _depth_prefix(source_relative_path)
522
633
  fm_lines, body = _split_frontmatter(content)
@@ -524,6 +635,9 @@ def _rewrite_paths(content: str, source_relative_path: str) -> str:
524
635
  if fm_lines is None:
525
636
  return body
526
637
  new_fm = _rewrite_frontmatter_lines(fm_lines, prefix)
638
+ level, hrr, owner = _parse_trust_and_owner(fm_lines)
639
+ if hrr and level:
640
+ body = _inject_hrr_banner(body, level, owner)
527
641
  return "---\n" + "\n".join(new_fm) + "\n---\n" + body
528
642
 
529
643
 
@@ -1096,8 +1210,11 @@ def clean_tools() -> None:
1096
1210
 
1097
1211
 
1098
1212
  def main() -> None:
1099
- if not SOURCE_DIR.exists():
1100
- print(f"❌ Source directory not found: {SOURCE_DIR}")
1213
+ # Multi-root awareness (ADR-017): tolerate a missing legacy
1214
+ # `.agent-src.uncompressed/` as long as at least one package-scoped
1215
+ # source root carries artefacts.
1216
+ if not SOURCE_DIR.exists() and not _any_source_root_exists():
1217
+ print(f"❌ No source directory found (looked at {SOURCE_DIR} and packages/*/.agent-src.uncompressed)")
1101
1218
  sys.exit(1)
1102
1219
 
1103
1220
  arg = sys.argv[1] if len(sys.argv) > 1 else "--sync"
@@ -1149,9 +1266,10 @@ def main() -> None:
1149
1266
  copied = sync_non_md(SOURCE_DIR, TARGET_DIR)
1150
1267
  print(f"\n--- Cleanup stale files ---")
1151
1268
  deleted = cleanup_stale(SOURCE_DIR, TARGET_DIR)
1152
- # Also cleanup stale hashes
1269
+ # Also cleanup stale hashes (multi-root aware — resolve against
1270
+ # every artefact root, not just the legacy SOURCE_DIR).
1153
1271
  hashes = load_hashes()
1154
- stale_keys = [k for k in hashes if not (SOURCE_DIR / k).exists()]
1272
+ stale_keys = [k for k in hashes if _resolve_source(k) is None]
1155
1273
  for k in stale_keys:
1156
1274
  del hashes[k]
1157
1275
  if stale_keys:
@@ -93,7 +93,25 @@ def _load_yaml(path: Path) -> dict[str, Any]:
93
93
 
94
94
 
95
95
  def _preset_file(project_root: Path, preset_id: str) -> Path:
96
- return project_root / PRESETS_DIRNAME / f"{preset_id}.yml"
96
+ # Legacy single-root layout honor when present so tests that mock a
97
+ # ``.agent-src.uncompressed/`` sub-tree under ``project_root`` keep working.
98
+ legacy = project_root / PRESETS_DIRNAME / f"{preset_id}.yml"
99
+ if legacy.exists():
100
+ return legacy
101
+ # Monorepo layout — scan every package root via the agent_src helper.
102
+ try:
103
+ import sys as _sys
104
+ scripts_root = Path(__file__).resolve().parents[1]
105
+ if str(scripts_root) not in _sys.path:
106
+ _sys.path.insert(0, str(scripts_root))
107
+ from _lib.agent_src import artefact_roots # type: ignore
108
+ except Exception:
109
+ return legacy
110
+ for root in artefact_roots():
111
+ candidate = root / "presets" / f"{preset_id}.yml"
112
+ if candidate.exists():
113
+ return candidate
114
+ return legacy
97
115
 
98
116
 
99
117
  def _coerce_scalar(raw: str) -> Any:
@@ -89,7 +89,22 @@ def _load_yaml(path: Path) -> dict[str, Any]:
89
89
 
90
90
 
91
91
  def _profile_file(project_root: Path, profile_id: str) -> Path:
92
- return project_root / PROFILES_DIRNAME / f"{profile_id}.yml"
92
+ legacy = project_root / PROFILES_DIRNAME / f"{profile_id}.yml"
93
+ if legacy.exists():
94
+ return legacy
95
+ try:
96
+ import sys as _sys
97
+ scripts_root = Path(__file__).resolve().parents[1]
98
+ if str(scripts_root) not in _sys.path:
99
+ _sys.path.insert(0, str(scripts_root))
100
+ from _lib.agent_src import artefact_roots # type: ignore
101
+ except Exception:
102
+ return legacy
103
+ for root in artefact_roots():
104
+ candidate = root / "profiles" / f"{profile_id}.yml"
105
+ if candidate.exists():
106
+ return candidate
107
+ return legacy
93
108
 
94
109
 
95
110
  def _build_resolved(
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Pretty-print the ``stats`` block from the committed discovery manifest.
3
+
4
+ Cheap sanity surface for developers: counts by category, lifecycle, and
5
+ trust level — answers "how many skills are professional vs core?" in
6
+ one terminal line. Reads only the committed manifest; no scan, no
7
+ generation. See ADR-015.
8
+
9
+ CLI:
10
+ python scripts/discovery_stats.py [--manifest PATH]
11
+
12
+ Exit codes:
13
+ 0 printed
14
+ 1 manifest missing or malformed
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ ROOT = Path(__file__).resolve().parents[1]
24
+ DEFAULT_MANIFEST = ROOT / "dist" / "discovery" / "discovery-manifest.json"
25
+
26
+
27
+ def _fmt_row(label: str, counts: dict[str, int]) -> str:
28
+ parts = [f"{k}={v}" for k, v in counts.items()]
29
+ return f" {label:<14} " + " ".join(parts)
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
34
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
35
+ args = parser.parse_args(argv)
36
+
37
+ if not args.manifest.exists():
38
+ print(
39
+ f"error: manifest not found at {args.manifest} "
40
+ "— run `task build-discovery` first.",
41
+ file=sys.stderr,
42
+ )
43
+ return 1
44
+
45
+ try:
46
+ manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
47
+ except json.JSONDecodeError as exc:
48
+ print(f"error: invalid JSON: {exc}", file=sys.stderr)
49
+ return 1
50
+
51
+ stats = manifest.get("stats")
52
+ if not isinstance(stats, dict):
53
+ print("error: manifest has no `stats` block (regenerate)", file=sys.stderr)
54
+ return 1
55
+
56
+ rel = args.manifest.relative_to(ROOT) if args.manifest.is_absolute() else args.manifest
57
+ print(f"Discovery stats — {rel}")
58
+ print(f" total {stats.get('total_artefacts', 0)}")
59
+ print(_fmt_row("by category", stats.get("by_category", {})))
60
+ print(_fmt_row("by lifecycle", stats.get("by_lifecycle", {})))
61
+ print(_fmt_row("by trust", stats.get("by_trust_level", {})))
62
+ if stats.get("unassigned_count"):
63
+ print(f" unassigned {stats['unassigned_count']}")
64
+ if stats.get("documented_unassigned_count"):
65
+ print(f" documented {stats['documented_unassigned_count']}")
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema_version": "expected-perms/v1",
3
+ "$comment": "Expected POSIX modes for the global install tree. Consumed by scripts/lint_global_paths.py as an entry-gate for the migrate-to-global subcommand (road-to-global-only-install Phase 5.0 / A7). Octal modes are strings so JSON tooling never widens 0700 → 700.",
4
+ "global_root": {
5
+ "path": "~/.event4u/agent-config",
6
+ "expected_mode": "0700",
7
+ "kind": "directory"
8
+ },
9
+ "files": [
10
+ {
11
+ "glob": "~/.event4u/agent-config/.agent-settings.yml",
12
+ "expected_mode": "0600",
13
+ "required": false
14
+ },
15
+ {
16
+ "glob": "~/.event4u/agent-config/.agent-user.yml",
17
+ "expected_mode": "0600",
18
+ "required": false
19
+ },
20
+ {
21
+ "glob": "~/.event4u/agent-config/.wizard-state.json",
22
+ "expected_mode": "0600",
23
+ "required": false
24
+ },
25
+ {
26
+ "glob": "~/.event4u/agent-config/**/*.key",
27
+ "expected_mode": "0600",
28
+ "required": false
29
+ }
30
+ ],
31
+ "directories": [
32
+ {
33
+ "glob": "~/.event4u/agent-config/state",
34
+ "expected_mode": "0700",
35
+ "required": false
36
+ },
37
+ {
38
+ "glob": "~/.event4u/agent-config/settings",
39
+ "expected_mode": "0700",
40
+ "required": false
41
+ }
42
+ ],
43
+ "forbid_symlink_escape": {
44
+ "root": "~/.event4u/agent-config",
45
+ "$comment": "All symlinks under the global root MUST resolve to paths still under the global root. Cross-root escapes are a security finding (allows external writes via a writable symlink target)."
46
+ }
47
+ }
@@ -19,6 +19,12 @@ from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
 
21
21
  ROOT = Path(__file__).resolve().parent.parent
22
+ sys.path.insert(0, str(ROOT / "scripts"))
23
+ from _lib.agent_src import artefact_roots # noqa: E402
24
+
25
+ # Legacy single-root anchor — kept as fallback for pure-compressed
26
+ # consumer projections. Multi-root discovery uses artefact_roots() so
27
+ # generate_index works across packages/* per ADR-017.
22
28
  SRC = ROOT / ".agent-src.uncompressed"
23
29
  GUIDELINES = ROOT / "docs" / "guidelines"
24
30
  INDEX_PATH = ROOT / "agents" / "index.md"
@@ -62,59 +68,79 @@ def _truncate(text: str, limit: int = 200) -> str:
62
68
 
63
69
 
64
70
  def _collect_skills() -> list[Entry]:
65
- out = []
66
- for skill_dir in sorted((SRC / "skills").iterdir()):
67
- skill_md = skill_dir / "SKILL.md"
68
- if not skill_md.exists():
71
+ """Walk every source root for skills; first root wins per logical id."""
72
+ seen: dict[str, Entry] = {}
73
+ for src_root in artefact_roots():
74
+ skills_root = src_root / "skills"
75
+ if not skills_root.is_dir():
69
76
  continue
70
- fm = _parse_frontmatter(skill_md.read_text(encoding="utf-8"))
71
- name = fm.get("name") or skill_dir.name
72
- out.append(Entry(
73
- kind="skill",
74
- name=name,
75
- description=_truncate(fm.get("description", "")),
76
- extra="",
77
- path=f".agent-src.uncompressed/skills/{skill_dir.name}/SKILL.md",
78
- ))
79
- return out
77
+ for skill_dir in sorted(skills_root.iterdir()):
78
+ if not skill_dir.is_dir() or skill_dir.name in seen:
79
+ continue
80
+ skill_md = skill_dir / "SKILL.md"
81
+ if not skill_md.exists():
82
+ continue
83
+ fm = _parse_frontmatter(skill_md.read_text(encoding="utf-8"))
84
+ name = fm.get("name") or skill_dir.name
85
+ seen[skill_dir.name] = Entry(
86
+ kind="skill",
87
+ name=name,
88
+ description=_truncate(fm.get("description", "")),
89
+ extra="",
90
+ path=skill_md.relative_to(ROOT).as_posix(),
91
+ )
92
+ return [seen[k] for k in sorted(seen)]
80
93
 
81
94
 
82
95
  def _collect_rules() -> list[Entry]:
83
- out = []
84
- for rule_md in sorted((SRC / "rules").glob("*.md")):
85
- fm = _parse_frontmatter(rule_md.read_text(encoding="utf-8"))
86
- out.append(Entry(
87
- kind="rule",
88
- name=rule_md.stem,
89
- description=_truncate(fm.get("description", "")),
90
- extra=fm.get("type", "?"),
91
- path=f".agent-src.uncompressed/rules/{rule_md.name}",
92
- ))
93
- return out
96
+ """Walk every source root for rules; first root wins per logical id."""
97
+ seen: dict[str, Entry] = {}
98
+ for src_root in artefact_roots():
99
+ rules_root = src_root / "rules"
100
+ if not rules_root.is_dir():
101
+ continue
102
+ for rule_md in sorted(rules_root.glob("*.md")):
103
+ if rule_md.stem in seen:
104
+ continue
105
+ fm = _parse_frontmatter(rule_md.read_text(encoding="utf-8"))
106
+ seen[rule_md.stem] = Entry(
107
+ kind="rule",
108
+ name=rule_md.stem,
109
+ description=_truncate(fm.get("description", "")),
110
+ extra=fm.get("type", "?"),
111
+ path=rule_md.relative_to(ROOT).as_posix(),
112
+ )
113
+ return [seen[k] for k in sorted(seen)]
94
114
 
95
115
 
96
116
  def _collect_commands() -> list[Entry]:
97
- out = []
98
- cmd_dir = SRC / "commands"
99
- for cmd_md in sorted(cmd_dir.rglob("*.md")):
100
- if cmd_md.name == "AGENTS.md":
117
+ """Walk every source root for commands; first root wins per logical id."""
118
+ seen: dict[str, Entry] = {}
119
+ for src_root in artefact_roots():
120
+ cmd_dir = src_root / "commands"
121
+ if not cmd_dir.is_dir():
101
122
  continue
102
- fm = _parse_frontmatter(cmd_md.read_text(encoding="utf-8"))
103
- is_shim = bool(fm.get("superseded_by"))
104
- extra = ""
105
- if is_shim:
106
- extra = f"shim → /{fm['superseded_by']}"
107
- elif fm.get("cluster"):
108
- extra = f"cluster: {fm['cluster']}"
109
- rel = cmd_md.relative_to(cmd_dir)
110
- out.append(Entry(
111
- kind="shim" if is_shim else "command",
112
- name=fm.get("name") or cmd_md.stem,
113
- description=_truncate(fm.get("description", "")),
114
- extra=extra,
115
- path=f".agent-src.uncompressed/commands/{rel}",
116
- ))
117
- return out
123
+ for cmd_md in sorted(cmd_dir.rglob("*.md")):
124
+ if cmd_md.name == "AGENTS.md":
125
+ continue
126
+ rel = cmd_md.relative_to(cmd_dir).as_posix()
127
+ if rel in seen:
128
+ continue
129
+ fm = _parse_frontmatter(cmd_md.read_text(encoding="utf-8"))
130
+ is_shim = bool(fm.get("superseded_by"))
131
+ extra = ""
132
+ if is_shim:
133
+ extra = f"shim /{fm['superseded_by']}"
134
+ elif fm.get("cluster"):
135
+ extra = f"cluster: {fm['cluster']}"
136
+ seen[rel] = Entry(
137
+ kind="shim" if is_shim else "command",
138
+ name=fm.get("name") or cmd_md.stem,
139
+ description=_truncate(fm.get("description", "")),
140
+ extra=extra,
141
+ path=cmd_md.relative_to(ROOT).as_posix(),
142
+ )
143
+ return [seen[k] for k in sorted(seen)]
118
144
 
119
145
 
120
146
  def _collect_guidelines() -> list[Entry]:
@@ -137,8 +163,14 @@ def _collect_guidelines() -> list[Entry]:
137
163
  # Path rewriter for the public catalog: link to the shipped surface
138
164
  # (`.agent-src/`) instead of the source-of-truth (`.agent-src.uncompressed/`),
139
165
  # which is excluded from `package.json#files` and `composer.json` archives.
166
+ # Post-ADR-017 the source-of-truth lives at packages/*/.agent-src.uncompressed/,
167
+ # so we strip whichever prefix matches and pin to the flat .agent-src/ output.
140
168
  def _to_shipped_path(path: str) -> str:
141
- return path.replace(".agent-src.uncompressed/", ".agent-src/", 1)
169
+ from _lib.agent_src import strip_source_prefix
170
+ logical = strip_source_prefix(path)
171
+ if logical is not None:
172
+ return f".agent-src/{logical}"
173
+ return path
142
174
 
143
175
 
144
176
  def _render_table(