@event4u/agent-config 1.19.0 → 1.20.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 (88) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +4 -7
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/sync-gitignore.md +1 -1
  9. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  10. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  11. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  12. package/.agent-src/rules/direct-answers.md +10 -2
  13. package/.agent-src/rules/language-and-tone.md +37 -6
  14. package/.agent-src/rules/no-attribution-footers.md +48 -0
  15. package/.agent-src/rules/no-roadmap-references.md +1 -1
  16. package/.agent-src/rules/skill-quality.md +49 -0
  17. package/.agent-src/rules/user-interaction.md +21 -5
  18. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  19. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  20. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  21. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  22. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  23. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  24. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  25. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  26. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  27. package/.agent-src/templates/agent-settings.md +5 -26
  28. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +7 -5
  29. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +0 -4
  30. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +0 -4
  31. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  32. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  33. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  34. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +2 -3
  35. package/.agent-src/templates/skill.md +30 -1
  36. package/.claude-plugin/marketplace.json +8 -4
  37. package/AGENTS.md +44 -3
  38. package/CHANGELOG.md +111 -0
  39. package/README.md +6 -6
  40. package/config/agent-settings.template.yml +19 -13
  41. package/config/gitignore-block.txt +4 -4
  42. package/docs/architecture.md +3 -3
  43. package/docs/catalog.md +14 -12
  44. package/docs/contracts/adr-chat-history-split.md +10 -1
  45. package/docs/contracts/command-clusters.md +1 -1
  46. package/docs/contracts/cross-wing-handoff.md +133 -0
  47. package/docs/contracts/file-ownership-matrix.json +341 -126
  48. package/docs/contracts/hook-architecture-v1.md +8 -1
  49. package/docs/contracts/memory-visibility-v1.md +8 -24
  50. package/docs/customization.md +1 -1
  51. package/docs/getting-started.md +21 -29
  52. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  53. package/docs/hook-payload-capture.md +221 -0
  54. package/docs/migrations/commands-1.15.0.md +17 -12
  55. package/docs/skills-catalog.md +5 -4
  56. package/llms.txt +4 -3
  57. package/package.json +1 -1
  58. package/scripts/agent-config +1 -1
  59. package/scripts/ai_council/_default_prices.py +4 -4
  60. package/scripts/ai_council/clients.py +1 -1
  61. package/scripts/ai_council/modes.py +3 -4
  62. package/scripts/ai_council/pricing.py +10 -9
  63. package/scripts/build_rule_trigger_matrix.py +1 -9
  64. package/scripts/chat_history.py +952 -596
  65. package/scripts/check_references.py +12 -2
  66. package/scripts/council_cli.py +54 -4
  67. package/scripts/hook_manifest.yaml +33 -0
  68. package/scripts/hooks/augment-chat-history.sh +10 -0
  69. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  70. package/scripts/hooks/dispatch_hook.py +35 -0
  71. package/scripts/hooks_status.py +12 -1
  72. package/scripts/install-hooks.sh +2 -2
  73. package/scripts/install.sh +37 -0
  74. package/scripts/lint_handoffs.py +214 -0
  75. package/scripts/lint_hook_manifest.py +2 -1
  76. package/scripts/redact_hook_capture.py +148 -0
  77. package/scripts/schemas/skill.schema.json +5 -0
  78. package/scripts/skill_linter.py +163 -1
  79. package/scripts/update_prices.py +3 -3
  80. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  81. package/.agent-src/commands/chat-history/clear.md +0 -103
  82. package/.agent-src/commands/chat-history/resume.md +0 -183
  83. package/.agent-src/rules/chat-history-cadence.md +0 -143
  84. package/.agent-src/rules/chat-history-ownership.md +0 -124
  85. package/.agent-src/rules/chat-history-visibility.md +0 -97
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  88. package/scripts/check_phase_coupling.py +0 -148
@@ -33,8 +33,10 @@ class BrokenRef:
33
33
 
34
34
  SCAN_DIRS = [".agent-src", "agents"]
35
35
  SKIP_DIRS = [
36
- "agents/roadmaps/archive", # archived roadmaps have historical refs
37
- "agents/council-sessions", # per-user audit trail (gitignored), captured provider output
36
+ "agents/roadmaps/archive", # archived roadmaps have historical refs
37
+ "agents/council-sessions", # per-user audit trail (gitignored), captured provider output
38
+ "agents/council-questions", # design Q&A trail — forward-refs to planned artifacts
39
+ "agents/analysis", # plate-comparison working docs — forward-refs to planned artifacts
38
40
  ]
39
41
  ROOT = Path(".")
40
42
 
@@ -280,6 +282,14 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
280
282
  # checkable file paths.
281
283
  if not resolved and raw_ref.startswith("agents/state/"):
282
284
  resolved = True
285
+ # `agents/.agent-prices.md` is a runtime-bootstrapped pricing
286
+ # cache — gitignored (.gitignore:/agents/.agent-prices.md),
287
+ # auto-generated by scripts/ai_council/pricing.py from
288
+ # _default_prices.py if missing. Same class as agents/state/*
289
+ # but a single named file, not a directory pattern, so the
290
+ # carve-out stays narrow.
291
+ if not resolved and raw_ref == "agents/.agent-prices.md":
292
+ resolved = True
283
293
  if not resolved:
284
294
  broken.append(BrokenRef(
285
295
  file=str(filepath), line=i, ref=m.group(1),
@@ -62,12 +62,17 @@ def build_members(
62
62
  settings: dict[str, Any],
63
63
  *,
64
64
  invocation_mode: str | None = None,
65
+ model_overrides: dict[str, str] | None = None,
65
66
  ) -> list[ExternalAIClient]:
66
67
  """Construct enabled council members from settings.
67
68
 
68
69
  Honours `ai_council.enabled` (master switch) and per-member
69
70
  `enabled` flags. Raises `CouncilDisabledError` when the council is
70
71
  off or no member is wired up.
72
+
73
+ `model_overrides` is a per-invocation `{member_name: model_id}`
74
+ map that wins over the per-member `model` in settings. Members not
75
+ listed fall back to the settings value, then the per-client default.
71
76
  """
72
77
  ai = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
73
78
  if not ai.get("enabled"):
@@ -77,6 +82,13 @@ def build_members(
77
82
  )
78
83
  members_cfg = ai.get("members") or {}
79
84
  global_mode = ai.get("mode")
85
+ overrides = model_overrides or {}
86
+ unknown = set(overrides) - set(members_cfg)
87
+ if unknown:
88
+ raise CouncilDisabledError(
89
+ f"--model targets unknown member(s) {sorted(unknown)!r}; "
90
+ f"known members: {sorted(members_cfg)!r}."
91
+ )
80
92
  members: list[ExternalAIClient] = []
81
93
  for name, cfg in members_cfg.items():
82
94
  cfg = cfg or {}
@@ -88,7 +100,7 @@ def build_members(
88
100
  member_settings=cfg,
89
101
  global_mode=global_mode,
90
102
  )
91
- model = cfg.get("model")
103
+ model = overrides.get(name) or cfg.get("model")
92
104
  if mode == "api" and name == "anthropic":
93
105
  members.append(AnthropicClient(model=model or "claude-sonnet-4-5",
94
106
  api_key=load_anthropic_key()))
@@ -163,7 +175,11 @@ def cmd_estimate(
163
175
  if settings is None:
164
176
  settings = load_settings()
165
177
  if members is None:
166
- members = build_members(settings, invocation_mode=args.mode_override)
178
+ members = build_members(
179
+ settings,
180
+ invocation_mode=args.mode_override,
181
+ model_overrides=_parse_model_overrides(getattr(args, "model", None)),
182
+ )
167
183
  if table is None:
168
184
  table = load_prices()
169
185
  question, _ = build_question(
@@ -219,7 +235,11 @@ def cmd_run(
219
235
  if settings is None:
220
236
  settings = load_settings()
221
237
  if members is None:
222
- members = build_members(settings, invocation_mode=args.mode_override)
238
+ members = build_members(
239
+ settings,
240
+ invocation_mode=args.mode_override,
241
+ model_overrides=_parse_model_overrides(getattr(args, "model", None)),
242
+ )
223
243
  if table is None:
224
244
  table = load_prices()
225
245
  question, artefact = build_question(
@@ -295,6 +315,28 @@ def cmd_render(args: argparse.Namespace) -> int:
295
315
  # ── argparse + main ─────────────────────────────────────────────────
296
316
 
297
317
 
318
+ def _parse_model_overrides(items: list[str] | None) -> dict[str, str]:
319
+ """Parse repeated `--model name=model-id` flags into a dict.
320
+
321
+ Empty/None list → empty dict (no override). Bad shape raises
322
+ `argparse.ArgumentTypeError` so the CLI surfaces the error.
323
+ """
324
+ out: dict[str, str] = {}
325
+ for raw in items or []:
326
+ if "=" not in raw:
327
+ raise argparse.ArgumentTypeError(
328
+ f"--model expects '<member>=<model-id>', got {raw!r}."
329
+ )
330
+ name, model = raw.split("=", 1)
331
+ name, model = name.strip(), model.strip()
332
+ if not name or not model:
333
+ raise argparse.ArgumentTypeError(
334
+ f"--model member and model-id must both be non-empty: {raw!r}."
335
+ )
336
+ out[name] = model
337
+ return out
338
+
339
+
298
340
  def _add_common_input_args(p: argparse.ArgumentParser) -> None:
299
341
  p.add_argument("question", type=str,
300
342
  help="Path to the question file (text or roadmap).")
@@ -305,6 +347,13 @@ def _add_common_input_args(p: argparse.ArgumentParser) -> None:
305
347
  help="Per-member output budget (default: 1024).")
306
348
  p.add_argument("--mode-override", choices=["api", "manual"], default=None,
307
349
  help="Override every member's transport mode.")
350
+ p.add_argument("--model", action="append", default=None, dest="model",
351
+ metavar="MEMBER=MODEL_ID",
352
+ help="Per-invocation model override, e.g. "
353
+ "--model anthropic=claude-sonnet-4-5. Repeatable. "
354
+ "Wins over `ai_council.members.<name>.model` in "
355
+ ".agent-settings.yml; the settings file is not "
356
+ "modified.")
308
357
  p.add_argument("--original-ask", default="",
309
358
  help="The user's framing sentence (flows into handoff).")
310
359
 
@@ -347,7 +396,8 @@ def main(argv: list[str] | None = None) -> int:
347
396
  except CouncilDisabledError as exc:
348
397
  sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
349
398
  return 2
350
- except (BundleTooLarge, InvalidModeError, FileNotFoundError) as exc:
399
+ except (BundleTooLarge, InvalidModeError, FileNotFoundError,
400
+ argparse.ArgumentTypeError) as exc:
351
401
  sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
352
402
  return 2
353
403
  return 1
@@ -60,6 +60,27 @@ platforms:
60
60
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
61
61
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff]
62
62
 
63
+ # Cowork — the Claude desktop app's local-agent-mode runtime, built
64
+ # on top of the Claude Code CLI. Same lifecycle vocabulary, same
65
+ # event-payload shape (PascalCase native names; Stop carries
66
+ # `transcript_path`). Listed as a separate platform so chat-history
67
+ # entries can attribute events to Cowork vs CLI Claude Code via the
68
+ # `agent` field.
69
+ #
70
+ # Upstream caveat (anthropics/claude-code#40495, #27398): Cowork
71
+ # sessions currently ignore all three Claude Code settings sources
72
+ # (user, project, env), and `--setting-sources user` excludes
73
+ # plugin-scope hooks. Until those land, the dispatcher binding
74
+ # below is structurally ready but lifecycle events do not fire.
75
+ # Decision matrix + upstream blockers tracked in
76
+ # agents/contexts/chat-history-platform-hooks.md § Cowork.
77
+ cowork:
78
+ session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
79
+ session_end: [chat-history]
80
+ stop: [chat-history, verify-before-complete]
81
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
82
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff]
83
+
63
84
  # Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
64
85
  # IDE and CLI; `~/.cursor/hooks.json` (user) is opt-in via
65
86
  # `install.py --cursor-user-hooks` and uses scripts/hooks/cursor-dispatcher.sh
@@ -146,6 +167,18 @@ native_event_aliases:
146
167
  PostToolUse: post_tool_use
147
168
  PreToolUse: pre_tool_use
148
169
  PreCompact: pre_compact
170
+ # Cowork shares Claude Code's PascalCase event vocabulary verbatim
171
+ # (Cowork is Claude Code under the hood). Native names are duplicated
172
+ # here rather than aliased so dispatcher logs / feedback carry the
173
+ # platform tag the operator expects.
174
+ cowork:
175
+ SessionStart: session_start
176
+ SessionEnd: session_end
177
+ Stop: stop
178
+ UserPromptSubmit: user_prompt_submit
179
+ PostToolUse: post_tool_use
180
+ PreToolUse: pre_tool_use
181
+ PreCompact: pre_compact
149
182
  cursor:
150
183
  sessionStart: session_start
151
184
  sessionEnd: session_end
@@ -21,6 +21,16 @@ set -u
21
21
 
22
22
  EVENT_DATA="$(cat)"
23
23
 
24
+ # Debug-only: when ~/.augment/.chat-history-debug exists, dump the raw
25
+ # stdin payload for offline inspection. No-op otherwise. Used to probe
26
+ # what Augment's hook events actually carry.
27
+ if [ -f "$HOME/.augment/.chat-history-debug" ]; then
28
+ DUMP_DIR="$HOME/.augment/chat-history-debug"
29
+ mkdir -p "$DUMP_DIR" 2>/dev/null
30
+ printf '%s' "$EVENT_DATA" \
31
+ > "$DUMP_DIR/event-$(date +%Y%m%d-%H%M%S)-$$.json" 2>/dev/null || true
32
+ fi
33
+
24
34
  # Extract workspace_roots[0] using whichever JSON tool is available.
25
35
  WORKSPACE=""
26
36
  if command -v jq >/dev/null 2>&1; then
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Cowork universal hook trampoline.
3
+ #
4
+ # Cowork is the Claude desktop app's local-agent-mode runtime. It runs
5
+ # the Claude Code CLI inside a sandbox VM, so hook events use the
6
+ # Claude Code shape (PascalCase native names; Stop carries
7
+ # `transcript_path`). The payload typically carries `cwd` (Claude
8
+ # Code's standard field) rather than Augment's `workspace_roots[0]`.
9
+ # We accept either, falling back through both.
10
+ #
11
+ # Upstream caveat: as of writing this trampoline, lifecycle events do
12
+ # not actually fire from Cowork sessions —
13
+ # anthropics/claude-code#40495 reports all three Claude Code settings
14
+ # sources (user, project, env) are ignored inside Cowork's sandbox,
15
+ # and #27398 reports plugin-scope `hooks/hooks.json` is excluded
16
+ # because Cowork spawns the CLI with `--setting-sources user`. The
17
+ # trampoline is structurally ready; install plumbing is deferred
18
+ # until upstream resolves the bugs and a stable settings location is
19
+ # documented. See `agents/contexts/chat-history-platform-hooks.md`
20
+ # § Cowork.
21
+ #
22
+ # Behaviour mirrors cursor-dispatcher.sh:
23
+ # - Read JSON event from stdin into a buffer.
24
+ # - Extract cwd (Claude Code) or workspace_roots[0] (fallback);
25
+ # bail silently when neither resolves to a directory.
26
+ # - cd into that workspace; bail silently when it lacks ./agent-config.
27
+ # - Re-pipe the original JSON into
28
+ # ./agent-config dispatch:hook --platform cowork \
29
+ # --event $1 --native-event $2
30
+ # - Always exit 0 — chat-history must never block the agent loop.
31
+
32
+ set -u
33
+
34
+ # Args from the platform's hooks config (whatever shape Cowork ends up
35
+ # using once upstream lands the fix):
36
+ # $1 = agent-config event name (session_start, post_tool_use, …)
37
+ # $2 = Cowork-native event name (SessionStart, PostToolUse, …)
38
+ EVENT="${1-}"
39
+ NATIVE_EVENT="${2-}"
40
+
41
+ if [ -z "$EVENT" ]; then
42
+ exit 0
43
+ fi
44
+
45
+ EVENT_DATA="$(cat)"
46
+
47
+ # Debug-only: when ~/.claude/.cowork-chat-history-debug exists, dump
48
+ # the raw stdin payload for offline inspection. No-op otherwise.
49
+ # Useful once upstream fixes #40495 to verify the actual payload shape.
50
+ if [ -f "$HOME/.claude/.cowork-chat-history-debug" ]; then
51
+ DUMP_DIR="$HOME/.claude/cowork-chat-history-debug"
52
+ mkdir -p "$DUMP_DIR" 2>/dev/null
53
+ printf '%s' "$EVENT_DATA" \
54
+ > "$DUMP_DIR/event-$(date +%Y%m%d-%H%M%S)-$$.json" 2>/dev/null || true
55
+ fi
56
+
57
+ # Extract workspace path. Try Claude Code's `cwd` first, then fall
58
+ # back to Augment-style `workspace_roots[0]`. Either is acceptable —
59
+ # we need a directory containing ./agent-config.
60
+ WORKSPACE=""
61
+ if command -v jq >/dev/null 2>&1; then
62
+ WORKSPACE="$(printf '%s' "$EVENT_DATA" \
63
+ | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)"
64
+ elif command -v python3 >/dev/null 2>&1; then
65
+ WORKSPACE="$(printf '%s' "$EVENT_DATA" | python3 -c '
66
+ import json, sys
67
+ try:
68
+ data = json.load(sys.stdin)
69
+ except Exception:
70
+ sys.exit(0)
71
+ cwd = data.get("cwd")
72
+ if isinstance(cwd, str) and cwd:
73
+ print(cwd)
74
+ sys.exit(0)
75
+ roots = data.get("workspace_roots") or []
76
+ if roots:
77
+ print(roots[0])
78
+ ' 2>/dev/null)"
79
+ fi
80
+
81
+ if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
82
+ exit 0
83
+ fi
84
+
85
+ cd "$WORKSPACE" 2>/dev/null || exit 0
86
+
87
+ if [ ! -x ./agent-config ]; then
88
+ exit 0
89
+ fi
90
+
91
+ printf '%s' "$EVENT_DATA" \
92
+ | ./agent-config dispatch:hook \
93
+ --platform cowork \
94
+ --event "$EVENT" \
95
+ --native-event "$NATIVE_EVENT" \
96
+ >/dev/null 2>&1 || true
97
+
98
+ exit 0
@@ -167,6 +167,40 @@ def _resolve_concerns(manifest: dict, platform: str, event: str) -> list[dict]:
167
167
  return out
168
168
 
169
169
 
170
+ def _maybe_capture_payload(args: argparse.Namespace, payload_text: str) -> None:
171
+ """Write the raw stdin payload to a capture directory when
172
+ ``AGENT_HOOK_CAPTURE_DIR`` is set. Used by the verified-platforms
173
+ discovery roadmap (`agents/roadmaps/road-to-verified-chat-history-platforms.md`)
174
+ to lock real payload shapes before extractor branches are added.
175
+
176
+ Fail-silent: any IO / JSON error must not break dispatch.
177
+ """
178
+ capture_dir = os.environ.get("AGENT_HOOK_CAPTURE_DIR", "").strip()
179
+ if not capture_dir:
180
+ return
181
+ try:
182
+ target = Path(capture_dir).expanduser()
183
+ target.mkdir(parents=True, exist_ok=True)
184
+ try:
185
+ payload = json.loads(payload_text) if payload_text.strip() else {}
186
+ except (ValueError, TypeError):
187
+ payload = {"_raw_text": payload_text}
188
+ record = {
189
+ "captured_at": _now_iso(),
190
+ "platform": args.platform,
191
+ "event": args.event,
192
+ "native_event": args.native_event or "",
193
+ "raw_payload": payload,
194
+ }
195
+ ts = int(time.time() * 1000)
196
+ native = (args.native_event or args.event).replace("/", "_")
197
+ fname = f"{args.platform}__{native}__{ts}__{os.getpid()}.json"
198
+ (target / fname).write_text(
199
+ json.dumps(record, indent=2) + "\n", encoding="utf-8")
200
+ except OSError:
201
+ return
202
+
203
+
170
204
  def _build_envelope(args: argparse.Namespace, payload_text: str) -> dict:
171
205
  try:
172
206
  payload = json.loads(payload_text) if payload_text.strip() else {}
@@ -298,6 +332,7 @@ def main(argv: list[str] | None = None) -> int:
298
332
  manifest = _load_yaml(manifest_path)
299
333
 
300
334
  payload_text = "" if sys.stdin.isatty() else sys.stdin.read()
335
+ _maybe_capture_payload(args, payload_text)
301
336
  concerns = _resolve_concerns(manifest, args.platform, args.event)
302
337
 
303
338
  if args.dry_run:
@@ -25,9 +25,20 @@ import dispatch_hook # noqa: E402 — reuse the manifest loader
25
25
 
26
26
  # (label, project-relative bridge path, install hint).
27
27
  # Path may be a directory (cline) — existence => any file inside.
28
+ #
29
+ # Cowork has no project-scope bridge path: the Claude desktop app's
30
+ # local-agent-mode runtime is upstream-blocked from reading any of
31
+ # Claude Code's three settings sources (anthropics/claude-code#40495,
32
+ # #27398). We register cowork here so the manifest's `cowork:`
33
+ # bindings are surfaced in the status report, but the empty bridge
34
+ # path resolves to status="n/a" — strict mode does not fail on
35
+ # n/a (see _final_exit_code), matching Copilot's no-bridge posture.
36
+ # Once upstream lands the fix and a stable settings location is
37
+ # documented, swap the empty path here for that location.
28
38
  PLATFORM_BRIDGES: dict[str, tuple[str, str]] = {
29
39
  "augment": (".augment/settings.json", "scripts/install.py"),
30
40
  "claude": (".claude/settings.json", "scripts/install.py"),
41
+ "cowork": ("", "upstream-blocked: anthropics/claude-code#40495 + #27398 (settings.json ignored in Cowork sandbox)"),
31
42
  "cursor": (".cursor/hooks.json", "scripts/install.py"),
32
43
  "cline": (".clinerules/hooks", "scripts/install.py"),
33
44
  "windsurf": (".windsurf/hooks.json", "scripts/install.py"),
@@ -65,7 +76,7 @@ def collect(project_root: Path, manifest: dict) -> dict:
65
76
  "bridge_path": rel or None,
66
77
  "fallback_only": fallback_only,
67
78
  "bindings": bindings,
68
- "hint": hint if status in {"missing", "empty", "degraded"} else None,
79
+ "hint": hint if status in {"missing", "empty", "degraded", "n/a"} else None,
69
80
  })
70
81
  return {"schema_version": 1, "platforms": rows}
71
82
 
@@ -63,7 +63,7 @@ echo "✅ Pre-commit hook installed."
63
63
  # lifecycle hooks) cannot fire SessionStart/Stop/PostToolUse. Git hooks
64
64
  # are the platform-agnostic lifecycle surface that fires regardless of
65
65
  # IDE — every commit, merge, checkout, and rewrite turns into a phase
66
- # boundary in .agent-chat-history when an agent session is active.
66
+ # boundary in agents/.agent-chat-history when an agent session is active.
67
67
  #
68
68
  # The hooks are silent no-ops when no agent session is active (the
69
69
  # chat_history.py hook-append script returns "skipped_no_sidecar" with
@@ -75,7 +75,7 @@ write_chat_history_hook() {
75
75
  local phase_tag="$2"
76
76
  cat > "$HOOKS_DIR/$name" << EOF
77
77
  #!/usr/bin/env bash
78
- # $name: append a phase boundary to .agent-chat-history when an agent
78
+ # $name: append a phase boundary to agents/.agent-chat-history when an agent
79
79
  # session is active. Silent no-op otherwise. Never blocks git.
80
80
 
81
81
  if [ -x ./agent-config ]; then
@@ -563,6 +563,40 @@ copy_if_missing() {
563
563
  cp "$source" "$target"
564
564
  }
565
565
 
566
+ # Migrate legacy infra files from project root to agents/.
567
+ # Pre-2.x layout: .agent-chat-history (+ .bak), .agent-prices.md lived at
568
+ # the project root. They now live under agents/. Move them in place before
569
+ # any other content sync so the updated gitignore block (which lists
570
+ # /agents/.agent-chat-history*) and the chat-history hooks operate on the
571
+ # already-migrated layout. Idempotent: skips silently if the target already
572
+ # exists; never overwrites.
573
+ migrate_legacy_root_infra() {
574
+ local project_root="$1"
575
+ local agents_dir="$project_root/agents"
576
+ local items=(".agent-chat-history" ".agent-chat-history.bak" ".agent-prices.md")
577
+
578
+ for name in "${items[@]}"; do
579
+ local old="$project_root/$name"
580
+ local new="$agents_dir/$name"
581
+
582
+ [[ -e "$old" ]] || continue
583
+
584
+ if [[ -e "$new" ]]; then
585
+ log_warn "Legacy $name found at project root, but agents/$name already exists — leaving root copy in place"
586
+ continue
587
+ fi
588
+
589
+ if $DRY_RUN; then
590
+ log_verbose "would migrate $name → agents/$name"
591
+ continue
592
+ fi
593
+
594
+ mkdir -p "$agents_dir"
595
+ mv "$old" "$new"
596
+ log_info "Migrated $name → agents/$name"
597
+ done
598
+ }
599
+
566
600
  # Ensure .gitignore contains the managed agent-config block.
567
601
  # Delegates to scripts/sync_gitignore.py so the installer and the
568
602
  # standalone /sync-gitignore command share one source of truth
@@ -632,6 +666,9 @@ main() {
632
666
  $DRY_RUN && ! $QUIET && echo " Mode: DRY RUN"
633
667
  echo ""
634
668
 
669
+ # 0. Migrate legacy infra files (root → agents/) before any content sync.
670
+ migrate_legacy_root_infra "$TARGET_DIR"
671
+
635
672
  # 1. Hybrid sync payload → target/.augment/
636
673
  sync_hybrid "$SOURCE_PAYLOAD" "$TARGET_DIR/.augment"
637
674
  log_info "Synced .augment/ (rules copied, rest symlinked)"
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Lint cross-wing handoffs declared in senior-tier skills' ``## Related Skills`` blocks.
3
+
4
+ Builds a directed graph from every ``tier: senior`` skill's Related Skills
5
+ block (markdown links pointing at peer ``SKILL.md`` files), then enforces
6
+ the rules from ``docs/contracts/cross-wing-handoff.md`` § 4:
7
+
8
+ handoff_cycle — graph must be a DAG.
9
+ handoff_dangling — every linked target must exist.
10
+ handoff_tier_mismatch — senior may delegate only to senior.
11
+
12
+ Hooked into ``task lint-handoffs`` and ``task ci`` (between ``lint-skills``
13
+ and ``test``). Output mirrors ``scripts/skill_linter.py``: ``file:line:reason``.
14
+
15
+ Exit codes:
16
+ 0 no violations
17
+ 1 one or more violations
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Iterable
26
+
27
+ REPO = Path(__file__).resolve().parents[1]
28
+ SKILLS_DIR = REPO / ".agent-src.uncompressed" / "skills"
29
+
30
+ LINK_RE = re.compile(r"\[`?([a-z0-9][a-z0-9-]*)`?\]\(([^)]+SKILL\.md)\)")
31
+ RELATED_HEADING_RE = re.compile(r"^##\s+Related\s+Skills\s*$", re.IGNORECASE)
32
+ NEXT_HEADING_RE = re.compile(r"^##\s+\S")
33
+ WHEN_USE_RE = re.compile(r"^\*\*WHEN\s+to\s+use\s+this\*\*\s*$", re.IGNORECASE)
34
+ WHEN_NOT_RE = re.compile(r"^\*\*WHEN\s+NOT\s+to\s+use\s+this\*\*\s*$", re.IGNORECASE)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Violation:
39
+ file: Path
40
+ line: int
41
+ code: str
42
+ message: str
43
+
44
+ def render(self, repo: Path) -> str:
45
+ rel = self.file.relative_to(repo) if self.file.is_absolute() else self.file
46
+ return f"{rel}:{self.line}:{self.code}: {self.message}"
47
+
48
+
49
+ def parse_frontmatter_tier(text: str) -> str | None:
50
+ if not text.startswith("---\n"):
51
+ return None
52
+ end = text.find("\n---\n", 4)
53
+ if end == -1:
54
+ return None
55
+ for raw in text[4:end].splitlines():
56
+ if ":" not in raw:
57
+ continue
58
+ key, _, val = raw.partition(":")
59
+ if key.strip() == "tier":
60
+ return val.strip().strip('"').strip("'")
61
+ return None
62
+
63
+
64
+ def extract_related_block(text: str) -> tuple[int, list[tuple[int, str]]] | None:
65
+ """Return (block_start_line, [(line, raw_line), ...]) for ``## Related Skills``."""
66
+ lines = text.splitlines()
67
+ start: int | None = None
68
+ for idx, line in enumerate(lines):
69
+ if RELATED_HEADING_RE.match(line):
70
+ start = idx
71
+ break
72
+ if start is None:
73
+ return None
74
+ body: list[tuple[int, str]] = []
75
+ for idx in range(start + 1, len(lines)):
76
+ if NEXT_HEADING_RE.match(lines[idx]):
77
+ break
78
+ body.append((idx + 1, lines[idx]))
79
+ return start + 1, body
80
+
81
+
82
+ def split_when_subblocks(body: list[tuple[int, str]]) -> tuple[
83
+ list[tuple[int, str]], list[tuple[int, str]]
84
+ ]:
85
+ """Split a ``## Related Skills`` body into (when_to_use, when_not_to_use).
86
+
87
+ WHEN-to-use links are composition (delegation) edges — graph for cycles.
88
+ WHEN-NOT-to-use links are alternative pointers (peer cognition the user
89
+ picks instead) — never composition edges. Lines outside both sub-blocks
90
+ are treated as WHEN-to-use for backward compatibility.
91
+ """
92
+ when_use: list[tuple[int, str]] = []
93
+ when_not: list[tuple[int, str]] = []
94
+ current = when_use
95
+ for lineno, raw in body:
96
+ if WHEN_USE_RE.match(raw):
97
+ current = when_use
98
+ continue
99
+ if WHEN_NOT_RE.match(raw):
100
+ current = when_not
101
+ continue
102
+ current.append((lineno, raw))
103
+ return when_use, when_not
104
+
105
+
106
+ def extract_links(body: list[tuple[int, str]]) -> list[tuple[int, str, str]]:
107
+ """Yield ``(line, slug, target_path)`` for every markdown link in the block."""
108
+ out: list[tuple[int, str, str]] = []
109
+ for lineno, raw in body:
110
+ for match in LINK_RE.finditer(raw):
111
+ out.append((lineno, match.group(1), match.group(2)))
112
+ return out
113
+
114
+
115
+ def resolve_target(skill_file: Path, link: str) -> Path:
116
+ return (skill_file.parent / link).resolve()
117
+
118
+
119
+ def detect_cycles(graph: dict[Path, set[Path]]) -> list[list[Path]]:
120
+ cycles: list[list[Path]] = []
121
+ visited: set[Path] = set()
122
+ stack: list[Path] = []
123
+ on_stack: set[Path] = set()
124
+
125
+ def dfs(node: Path) -> None:
126
+ if node in on_stack:
127
+ i = stack.index(node)
128
+ cycles.append(stack[i:] + [node])
129
+ return
130
+ if node in visited:
131
+ return
132
+ visited.add(node)
133
+ on_stack.add(node)
134
+ stack.append(node)
135
+ for nxt in graph.get(node, ()):
136
+ dfs(nxt)
137
+ stack.pop()
138
+ on_stack.discard(node)
139
+
140
+ for node in list(graph):
141
+ dfs(node)
142
+ return cycles
143
+
144
+
145
+ def lint(skills_dir: Path) -> list[Violation]:
146
+ senior_skills: dict[Path, str] = {}
147
+ all_skills: dict[Path, str] = {}
148
+ for skill_md in sorted(skills_dir.rglob("SKILL.md")):
149
+ text = skill_md.read_text(encoding="utf-8")
150
+ tier = parse_frontmatter_tier(text)
151
+ all_skills[skill_md.resolve()] = tier or ""
152
+ if tier == "senior":
153
+ senior_skills[skill_md.resolve()] = text
154
+
155
+ violations: list[Violation] = []
156
+ graph: dict[Path, set[Path]] = {}
157
+
158
+ for skill_path, text in senior_skills.items():
159
+ block = extract_related_block(text)
160
+ if block is None:
161
+ continue
162
+ _, body = block
163
+ when_use, when_not = split_when_subblocks(body)
164
+
165
+ # WHEN-to-use links: composition edges (graph) + dangling/tier checks.
166
+ for lineno, slug, link in extract_links(when_use):
167
+ target = resolve_target(skill_path, link)
168
+ graph.setdefault(skill_path, set()).add(target)
169
+ if target not in all_skills:
170
+ violations.append(Violation(skill_path, lineno, "handoff_dangling",
171
+ f"link to `{slug}` resolves to missing file {link}"))
172
+ continue
173
+ if all_skills[target] != "senior":
174
+ violations.append(Violation(skill_path, lineno, "handoff_tier_mismatch",
175
+ f"senior skill links to non-senior `{slug}` "
176
+ f"(tier={all_skills[target] or 'unset'!r})"))
177
+
178
+ # WHEN-NOT-to-use links: alternative pointers, NOT composition edges.
179
+ # Dangling + tier-mismatch still apply (a broken alternative is wrong);
180
+ # cycles do not (mutual "use X instead" pointers are intentional).
181
+ for lineno, slug, link in extract_links(when_not):
182
+ target = resolve_target(skill_path, link)
183
+ if target not in all_skills:
184
+ violations.append(Violation(skill_path, lineno, "handoff_dangling",
185
+ f"link to `{slug}` resolves to missing file {link}"))
186
+ continue
187
+ if all_skills[target] != "senior":
188
+ violations.append(Violation(skill_path, lineno, "handoff_tier_mismatch",
189
+ f"senior skill links to non-senior `{slug}` "
190
+ f"(tier={all_skills[target] or 'unset'!r})"))
191
+
192
+ for cycle in detect_cycles(graph):
193
+ names = " → ".join(p.parent.name for p in cycle)
194
+ violations.append(Violation(cycle[0], 1, "handoff_cycle",
195
+ f"composition cycle: {names}"))
196
+ return violations
197
+
198
+
199
+ def main(argv: list[str] | None = None) -> int:
200
+ skills_dir = SKILLS_DIR
201
+ if argv:
202
+ skills_dir = Path(argv[0]).resolve()
203
+ violations = lint(skills_dir)
204
+ if not violations:
205
+ print(f"✅ lint_handoffs: no violations under {skills_dir.relative_to(REPO)}")
206
+ return 0
207
+ for v in violations:
208
+ print(v.render(REPO))
209
+ print(f"\n❌ lint_handoffs: {len(violations)} violation(s)", file=sys.stderr)
210
+ return 1
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main(sys.argv[1:]))