@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.
- package/.agent-src/commands/agent-handoff.md +14 -10
- package/.agent-src/commands/chat-history/import.md +170 -0
- package/.agent-src/commands/chat-history/learn.md +178 -0
- package/.agent-src/commands/chat-history/show.md +17 -18
- package/.agent-src/commands/chat-history.md +26 -25
- package/.agent-src/commands/council/default.md +4 -7
- package/.agent-src/commands/create-pr.md +28 -8
- package/.agent-src/commands/sync-gitignore.md +1 -1
- package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
- package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
- package/.agent-src/rules/direct-answers.md +10 -2
- package/.agent-src/rules/language-and-tone.md +37 -6
- package/.agent-src/rules/no-attribution-footers.md +48 -0
- package/.agent-src/rules/no-roadmap-references.md +1 -1
- package/.agent-src/rules/skill-quality.md +49 -0
- package/.agent-src/rules/user-interaction.md +21 -5
- package/.agent-src/skills/ai-council/SKILL.md +4 -5
- package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
- package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
- package/.agent-src/skills/md-language-check/SKILL.md +1 -1
- package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
- package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
- package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
- package/.agent-src/templates/agent-settings.md +5 -26
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +7 -5
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +0 -4
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +0 -4
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +2 -3
- package/.agent-src/templates/skill.md +30 -1
- package/.claude-plugin/marketplace.json +8 -4
- package/AGENTS.md +44 -3
- package/CHANGELOG.md +111 -0
- package/README.md +6 -6
- package/config/agent-settings.template.yml +19 -13
- package/config/gitignore-block.txt +4 -4
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +14 -12
- package/docs/contracts/adr-chat-history-split.md +10 -1
- package/docs/contracts/command-clusters.md +1 -1
- package/docs/contracts/cross-wing-handoff.md +133 -0
- package/docs/contracts/file-ownership-matrix.json +341 -126
- package/docs/contracts/hook-architecture-v1.md +8 -1
- package/docs/contracts/memory-visibility-v1.md +8 -24
- package/docs/customization.md +1 -1
- package/docs/getting-started.md +21 -29
- package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
- package/docs/hook-payload-capture.md +221 -0
- package/docs/migrations/commands-1.15.0.md +17 -12
- package/docs/skills-catalog.md +5 -4
- package/llms.txt +4 -3
- package/package.json +1 -1
- package/scripts/agent-config +1 -1
- package/scripts/ai_council/_default_prices.py +4 -4
- package/scripts/ai_council/clients.py +1 -1
- package/scripts/ai_council/modes.py +3 -4
- package/scripts/ai_council/pricing.py +10 -9
- package/scripts/build_rule_trigger_matrix.py +1 -9
- package/scripts/chat_history.py +952 -596
- package/scripts/check_references.py +12 -2
- package/scripts/council_cli.py +54 -4
- package/scripts/hook_manifest.yaml +33 -0
- package/scripts/hooks/augment-chat-history.sh +10 -0
- package/scripts/hooks/cowork-dispatcher.sh +98 -0
- package/scripts/hooks/dispatch_hook.py +35 -0
- package/scripts/hooks_status.py +12 -1
- package/scripts/install-hooks.sh +2 -2
- package/scripts/install.sh +37 -0
- package/scripts/lint_handoffs.py +214 -0
- package/scripts/lint_hook_manifest.py +2 -1
- package/scripts/redact_hook_capture.py +148 -0
- package/scripts/schemas/skill.schema.json +5 -0
- package/scripts/skill_linter.py +163 -1
- package/scripts/update_prices.py +3 -3
- package/.agent-src/commands/chat-history/checkpoint.md +0 -126
- package/.agent-src/commands/chat-history/clear.md +0 -103
- package/.agent-src/commands/chat-history/resume.md +0 -183
- package/.agent-src/rules/chat-history-cadence.md +0 -143
- package/.agent-src/rules/chat-history-ownership.md +0 -124
- package/.agent-src/rules/chat-history-visibility.md +0 -97
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
- 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",
|
|
37
|
-
"agents/council-sessions",
|
|
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),
|
package/scripts/council_cli.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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:
|
package/scripts/hooks_status.py
CHANGED
|
@@ -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
|
|
package/scripts/install-hooks.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/scripts/install.sh
CHANGED
|
@@ -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:]))
|