@event4u/agent-config 2.8.0 → 2.10.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/personas/engineering-manager.md +133 -0
- package/.agent-src/personas/finance-partner.md +129 -0
- package/.agent-src/personas/people-strategist.md +126 -0
- package/.agent-src/personas/strategist.md +129 -0
- package/.agent-src/rules/no-roadmap-references.md +19 -0
- package/.agent-src/skills/build-buy-partner/SKILL.md +145 -0
- package/.agent-src/skills/comp-banding/SKILL.md +160 -0
- package/.agent-src/skills/competitive-moat-analysis/SKILL.md +152 -0
- package/.agent-src/skills/contracts-cognition/SKILL.md +147 -0
- package/.agent-src/skills/data-handling-judgment/SKILL.md +155 -0
- package/.agent-src/skills/forecasting/SKILL.md +164 -0
- package/.agent-src/skills/hiring-loop-design/SKILL.md +167 -0
- package/.agent-src/skills/market-entry-analysis/SKILL.md +144 -0
- package/.agent-src/skills/onboarding-program/SKILL.md +157 -0
- package/.agent-src/skills/one-on-one-cadence/SKILL.md +161 -0
- package/.agent-src/skills/org-design/SKILL.md +158 -0
- package/.agent-src/skills/perf-feedback-craft/SKILL.md +157 -0
- package/.agent-src/skills/privacy-review/SKILL.md +160 -0
- package/.agent-src/skills/runway-cognition/SKILL.md +136 -0
- package/.agent-src/skills/scenario-modeling/SKILL.md +139 -0
- package/.agent-src/skills/throughput-vs-morale-tradeoff/SKILL.md +165 -0
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +54 -7
- package/.agent-src/skills/vision-articulation/SKILL.md +146 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/scripts/telemetry/settings.py +65 -0
- package/.agent-src/templates/scripts/tier_usage_report.py +183 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +32 -3
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +147 -1
- package/.claude-plugin/marketplace.json +18 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +134 -0
- package/README.md +34 -14
- package/config/agent-settings.template.yml +28 -0
- package/docs/architecture.md +37 -11
- package/docs/catalog.md +22 -4
- package/docs/contracts/adr-forecast-construction-shape.md +89 -0
- package/docs/contracts/adr-wing4-context-spine.md +125 -0
- package/docs/contracts/command-clusters.md +41 -0
- package/docs/contracts/command-surface-tiers.md +25 -9
- package/docs/contracts/context-spine.md +8 -0
- package/docs/contracts/decision-trace-v1.md +30 -0
- package/docs/contracts/hook-architecture-v1.md +46 -0
- package/docs/contracts/mcp-beta-criteria.md +129 -0
- package/docs/contracts/memory-visibility-v1.md +33 -0
- package/docs/contracts/settings-sync-yaml-subset.md +138 -0
- package/docs/guidelines/wing4-handoff.md +127 -0
- package/docs/mcp-server.md +1 -1
- package/docs/readme-split-plan.md +102 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +527 -14
- package/scripts/_cli/cmd_settings_check.py +171 -0
- package/scripts/_cli/cmd_validate.py +10 -0
- package/scripts/agent-config +59 -18
- package/scripts/chat_history.py +19 -0
- package/scripts/check_council_references.py +46 -5
- package/scripts/hooks/dispatch_hook.py +5 -1
- package/scripts/hooks/replay_hook.py +144 -0
- package/scripts/hooks/state_io.py +24 -1
- package/scripts/hooks_doctor.py +184 -0
- package/scripts/install.py +5 -0
- package/scripts/lint_context_spine_usage.py +1 -0
- package/scripts/lint_hook_concern_budget.py +203 -0
- package/scripts/mcp_server/__init__.py +1 -0
- package/scripts/mcp_server/server.py +4 -3
- package/scripts/roadmap_progress_hook.py +11 -0
- package/scripts/schemas/skill.schema.json +2 -2
- package/scripts/skill_linter.py +107 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""``agent-config settings:check`` — validate ``.agent-settings.yml`` against the supported YAML subset.
|
|
2
|
+
|
|
3
|
+
Read-only. Implements P3.2 of road-to-proof-not-features.md. The contract
|
|
4
|
+
this checks against is pinned in
|
|
5
|
+
``docs/contracts/settings-sync-yaml-subset.md``; out-of-subset constructs
|
|
6
|
+
cause :class:`sync_yaml_rt` to raise ``ValueError`` during a sync. This
|
|
7
|
+
CLI surfaces the same findings *before* a sync runs, so users can fix
|
|
8
|
+
their file without watching the merge fail.
|
|
9
|
+
|
|
10
|
+
Output line format::
|
|
11
|
+
|
|
12
|
+
line:N <kind> <verdict> <fix hint>
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
|
|
16
|
+
* ``0`` — file is inside the supported subset (or absent and ``--allow-missing``).
|
|
17
|
+
* ``1`` — one or more findings (verdict ``not supported``).
|
|
18
|
+
* ``2`` — file absent (without ``--allow-missing``) or unreadable.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Imported lazily inside ``main`` so a missing engine cannot break ``--help``.
|
|
28
|
+
|
|
29
|
+
DEFAULT_PATH = ".agent-settings.yml"
|
|
30
|
+
|
|
31
|
+
# Out-of-subset patterns detected by a line-level pre-scan. Each rule is
|
|
32
|
+
# (label, regex, fix hint). The regex is applied to the *stripped* body
|
|
33
|
+
# of each non-comment line so leading indent does not affect matching.
|
|
34
|
+
_PRESCAN_RULES: tuple[tuple[str, re.Pattern[str], str], ...] = (
|
|
35
|
+
(
|
|
36
|
+
"multi-doc separator",
|
|
37
|
+
re.compile(r"^(---|\.\.\.)\s*(#.*)?$"),
|
|
38
|
+
"remove the separator — one YAML document per file only.",
|
|
39
|
+
),
|
|
40
|
+
(
|
|
41
|
+
"complex key",
|
|
42
|
+
re.compile(r"^\?\s"),
|
|
43
|
+
"rewrite as a plain ``key: value`` mapping line.",
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"block-scalar indicator",
|
|
47
|
+
re.compile(r":\s*[|>][+-]?\s*(#.*)?$"),
|
|
48
|
+
"inline the value as a single-line quoted scalar.",
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"tagged scalar",
|
|
52
|
+
re.compile(r":\s*!!?[A-Za-z_]"),
|
|
53
|
+
"remove the ``!tag``; the parser does not honour it.",
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"anchor / alias",
|
|
57
|
+
re.compile(r":\s*[&*][A-Za-z_]"),
|
|
58
|
+
"expand the anchor inline — anchors / aliases are not supported.",
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
"nested flow-mapping",
|
|
62
|
+
re.compile(r":\s*\{[^}]*:[^}]*\}"),
|
|
63
|
+
"rewrite as a block-style nested mapping (indented child keys).",
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _scan_line(stripped: str) -> tuple[str, str] | None:
|
|
69
|
+
if not stripped or stripped.startswith("#"):
|
|
70
|
+
return None
|
|
71
|
+
for label, pattern, hint in _PRESCAN_RULES:
|
|
72
|
+
if pattern.search(stripped):
|
|
73
|
+
return label, hint
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _scan_text(text: str) -> list[dict]:
|
|
78
|
+
findings: list[dict] = []
|
|
79
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
80
|
+
stripped = raw.strip()
|
|
81
|
+
if "\t" in raw[: len(raw) - len(raw.lstrip(" \t"))]:
|
|
82
|
+
findings.append({
|
|
83
|
+
"line": lineno,
|
|
84
|
+
"kind": "tab in indent",
|
|
85
|
+
"verdict": "not supported",
|
|
86
|
+
"hint": "replace leading tabs with 2 or 4 spaces.",
|
|
87
|
+
})
|
|
88
|
+
continue
|
|
89
|
+
hit = _scan_line(stripped)
|
|
90
|
+
if hit is not None:
|
|
91
|
+
label, hint = hit
|
|
92
|
+
findings.append({
|
|
93
|
+
"line": lineno,
|
|
94
|
+
"kind": label,
|
|
95
|
+
"verdict": "not supported",
|
|
96
|
+
"hint": hint,
|
|
97
|
+
})
|
|
98
|
+
return findings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _format(finding: dict) -> str:
|
|
102
|
+
return (
|
|
103
|
+
f" ❌ line:{finding['line']:<4} "
|
|
104
|
+
f"{finding['kind']:<22} {finding['verdict']:<14} {finding['hint']}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse(argv: list[str]) -> argparse.Namespace:
|
|
109
|
+
parser = argparse.ArgumentParser(
|
|
110
|
+
prog="agent-config settings:check",
|
|
111
|
+
description=(
|
|
112
|
+
"Validate .agent-settings.yml against the supported YAML subset "
|
|
113
|
+
"(docs/contracts/settings-sync-yaml-subset.md). Read-only."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument("--path", default=DEFAULT_PATH,
|
|
117
|
+
help=f"target settings file (default: ./{DEFAULT_PATH})")
|
|
118
|
+
parser.add_argument("--allow-missing", action="store_true",
|
|
119
|
+
help="exit 0 when the file is absent (CI-friendly)")
|
|
120
|
+
parser.add_argument("--quiet", action="store_true",
|
|
121
|
+
help="suppress non-essential output")
|
|
122
|
+
return parser.parse_args(argv)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: list[str]) -> int:
|
|
126
|
+
opts = _parse(argv)
|
|
127
|
+
target = Path(opts.path)
|
|
128
|
+
if not target.is_file():
|
|
129
|
+
if opts.allow_missing:
|
|
130
|
+
if not opts.quiet:
|
|
131
|
+
print(f"✅ {target}: file absent (allow-missing).")
|
|
132
|
+
return 0
|
|
133
|
+
print(f"❌ {target}: file not found.", file=sys.stderr)
|
|
134
|
+
print(" Run `./agent-config sync-agent-settings` to create it.", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
try:
|
|
137
|
+
text = target.read_text(encoding="utf-8")
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
print(f"❌ {target}: cannot read: {exc}", file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
findings = _scan_text(text)
|
|
143
|
+
if not findings:
|
|
144
|
+
# Final gate: run the round-trip parser to catch anything the
|
|
145
|
+
# pre-scan missed (mismatched indent, malformed mapping lines).
|
|
146
|
+
from scripts import sync_yaml_rt as _rt # noqa: PLC0415
|
|
147
|
+
try:
|
|
148
|
+
_rt.parse(text)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
findings.append({
|
|
151
|
+
"line": 0, "kind": "parser",
|
|
152
|
+
"verdict": "not supported", "hint": str(exc),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
if not findings:
|
|
156
|
+
if not opts.quiet:
|
|
157
|
+
print(f"✅ {target}: inside the supported subset "
|
|
158
|
+
"(docs/contracts/settings-sync-yaml-subset.md).")
|
|
159
|
+
return 0
|
|
160
|
+
print(f"❌ {target}: {len(findings)} finding(s) outside the supported subset.",
|
|
161
|
+
file=sys.stderr)
|
|
162
|
+
for finding in findings:
|
|
163
|
+
print(_format(finding), file=sys.stderr)
|
|
164
|
+
print("", file=sys.stderr)
|
|
165
|
+
print(" Contract: docs/contracts/settings-sync-yaml-subset.md",
|
|
166
|
+
file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -130,6 +130,7 @@ def main(argv: list[str]) -> int:
|
|
|
130
130
|
if data is None:
|
|
131
131
|
_emit(opts.quiet, f"❌ No manifest found at {manifest}")
|
|
132
132
|
_emit(opts.quiet, " Run `./agent-config init --tools=<id>` to create one.")
|
|
133
|
+
_emit(opts.quiet, " Diagnose: `./agent-config doctor --check manifest-integrity`")
|
|
133
134
|
return 1
|
|
134
135
|
|
|
135
136
|
entries = list(data.get("tools") or [])
|
|
@@ -157,6 +158,15 @@ def main(argv: list[str]) -> int:
|
|
|
157
158
|
_emit(opts.quiet, "")
|
|
158
159
|
_emit(opts.quiet, "Run `./agent-config sync` to replay missing bridges, or")
|
|
159
160
|
_emit(opts.quiet, "`./agent-config init --tools=<id> --force` to refresh the manifest.")
|
|
161
|
+
# Deeplink: route per-kind to the matching `doctor` check so users can
|
|
162
|
+
# copy-paste even though `doctor` is Tier-1 and absent from --help.
|
|
163
|
+
kinds = {issue["kind"] for issue in issues}
|
|
164
|
+
if "version_drift" in kinds:
|
|
165
|
+
_emit(opts.quiet, "Diagnose: `./agent-config doctor --check lockfile-freshness`")
|
|
166
|
+
if kinds & {"marker_missing", "scope_divergence"}:
|
|
167
|
+
_emit(opts.quiet, "Diagnose: `./agent-config doctor --check bridge-drift`")
|
|
168
|
+
if "manifest_corrupt" in kinds:
|
|
169
|
+
_emit(opts.quiet, "Diagnose: `./agent-config doctor --check manifest-integrity`")
|
|
160
170
|
return 1
|
|
161
171
|
|
|
162
172
|
|
package/scripts/agent-config
CHANGED
|
@@ -62,17 +62,6 @@ Tier 0 — daily-driver (init → sync → validate → work):
|
|
|
62
62
|
(Option-A loop; called by the /work command)
|
|
63
63
|
implement-ticket Drive the work_engine Python engine on a ticket envelope
|
|
64
64
|
(Option-A loop; called by the /implement-ticket command)
|
|
65
|
-
first-run Guided first-run setup — cost profile, settings, tooling
|
|
66
|
-
keys:install-anthropic Install the Anthropic API key for the AI Council
|
|
67
|
-
(interactive, /dev/tty only, writes ~/.config/agent-config/anthropic.key 0600)
|
|
68
|
-
keys:install-openai Install the OpenAI API key for the AI Council
|
|
69
|
-
(interactive, /dev/tty only, writes ~/.config/agent-config/openai.key 0600)
|
|
70
|
-
council:estimate Pre-call council cost preview (no API call, no spend)
|
|
71
|
-
Usage: council:estimate <question> [--input-mode prompt|roadmap]
|
|
72
|
-
council:run Run the council. Requires --confirm to spend.
|
|
73
|
-
Usage: council:run <question> --output <path> --confirm
|
|
74
|
-
council:render Re-render a saved council responses JSON to markdown
|
|
75
|
-
Usage: council:render <responses.json>
|
|
76
65
|
help Show this help (default Tier-0; --tier=1|all expands)
|
|
77
66
|
--version, -V Print package version
|
|
78
67
|
EOF
|
|
@@ -110,6 +99,17 @@ Tier 1 — power-user (release shape, audit, migration):
|
|
|
110
99
|
Flags: --json | --project=<path>
|
|
111
100
|
migrate One-shot migration off legacy composer / npm install paths
|
|
112
101
|
Flags: --dry-run (detect only)
|
|
102
|
+
first-run Guided first-run setup — cost profile, settings, tooling
|
|
103
|
+
keys:install-anthropic Install the Anthropic API key for the AI Council
|
|
104
|
+
(interactive, /dev/tty only, writes ~/.config/agent-config/anthropic.key 0600)
|
|
105
|
+
keys:install-openai Install the OpenAI API key for the AI Council
|
|
106
|
+
(interactive, /dev/tty only, writes ~/.config/agent-config/openai.key 0600)
|
|
107
|
+
council:estimate Pre-call council cost preview (no API call, no spend)
|
|
108
|
+
Usage: council:estimate <question> [--input-mode prompt|roadmap]
|
|
109
|
+
council:run Run the council. Requires --confirm to spend.
|
|
110
|
+
Usage: council:run <question> --output <path> --confirm
|
|
111
|
+
council:render Re-render a saved council responses JSON to markdown
|
|
112
|
+
Usage: council:render <responses.json>
|
|
113
113
|
EOF
|
|
114
114
|
fi
|
|
115
115
|
|
|
@@ -124,12 +124,25 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
124
124
|
(one-line MCP server onboarding; idempotent)
|
|
125
125
|
mcp:run Run the built-in MCP server over stdio
|
|
126
126
|
(requires `mcp:setup` first; see docs/mcp-server.md)
|
|
127
|
+
(experimental — beta gates: docs/contracts/mcp-beta-criteria.md)
|
|
127
128
|
roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
|
|
128
129
|
roadmap:progress-check Fail if agents/roadmaps-progress.md is stale (for CI)
|
|
130
|
+
settings:check Validate .agent-settings.yml against the YAML-subset contract
|
|
131
|
+
(docs/contracts/settings-sync-yaml-subset.md). Read-only.
|
|
132
|
+
Exit 0 clean, 1 finding(s), 2 file absent / unreadable.
|
|
129
133
|
hooks:install Install the pre-commit roadmap-progress hook
|
|
130
134
|
(use --print to dump it, --force to overwrite an existing hook)
|
|
131
135
|
hooks:status Print the runtime hook matrix (per-platform install + bindings)
|
|
132
136
|
Flags: --format json|table, --strict (CI), --project-root <path>
|
|
137
|
+
hooks:doctor Diagnose hook health: concerns + fail-open/closed posture,
|
|
138
|
+
last dispatcher feedback per concern, missing trampolines.
|
|
139
|
+
Wraps hooks:status. Read-only.
|
|
140
|
+
Flags: --format json|table, --strict (CI), --project-root <path>
|
|
141
|
+
hooks:replay Replay a fixture through the universal dispatcher with
|
|
142
|
+
AGENT_CONFIG_REPLAY=1 (no writes under agents/state/).
|
|
143
|
+
Usage: hooks:replay --platform <name> --event <event>
|
|
144
|
+
--payload <path|event-name> [--native-event <native>]
|
|
145
|
+
[--manifest <path>] [--json] [--dry-run]
|
|
133
146
|
migrate-state Migrate a legacy .implement-ticket-state.json file
|
|
134
147
|
to the v1 .work-state.json schema (preserves .bak)
|
|
135
148
|
memory:lookup Retrieve memory entries (text or JSON envelope)
|
|
@@ -161,7 +174,7 @@ EOF
|
|
|
161
174
|
if [[ "$tier" == "0" ]]; then
|
|
162
175
|
cat <<'EOF'
|
|
163
176
|
|
|
164
|
-
(Hidden:
|
|
177
|
+
(Hidden: 15 Tier-1 + 26 Tier-2 commands. Run `./agent-config --help --tier=1`
|
|
165
178
|
or `--tier=all` to see them. Tier criteria: docs/contracts/command-surface-tiers.md.)
|
|
166
179
|
EOF
|
|
167
180
|
fi
|
|
@@ -173,14 +186,8 @@ Examples (Tier 0):
|
|
|
173
186
|
./agent-config sync --dry-run
|
|
174
187
|
./agent-config sync
|
|
175
188
|
./agent-config validate
|
|
176
|
-
./agent-config first-run
|
|
177
189
|
./agent-config work --state-file .work-state.json --prompt-file prompt.txt
|
|
178
190
|
./agent-config implement-ticket --state-file .work-state.json
|
|
179
|
-
./agent-config keys:install-anthropic
|
|
180
|
-
./agent-config keys:install-openai
|
|
181
|
-
./agent-config council:estimate prompt.txt
|
|
182
|
-
./agent-config council:run prompt.txt --output agents/council-sessions/out.json --confirm
|
|
183
|
-
./agent-config council:render agents/council-sessions/out.json
|
|
184
191
|
EOF
|
|
185
192
|
|
|
186
193
|
if [[ "$tier" == "1" || "$tier" == "all" ]]; then
|
|
@@ -203,6 +210,12 @@ Examples (Tier 1):
|
|
|
203
210
|
./agent-config versions --json
|
|
204
211
|
./agent-config init --offline --tools=claude-code,cursor --yes
|
|
205
212
|
./agent-config update --offline --to=2.2.0
|
|
213
|
+
./agent-config first-run
|
|
214
|
+
./agent-config keys:install-anthropic
|
|
215
|
+
./agent-config keys:install-openai
|
|
216
|
+
./agent-config council:estimate prompt.txt
|
|
217
|
+
./agent-config council:run prompt.txt --output agents/council-sessions/out.json --confirm
|
|
218
|
+
./agent-config council:render agents/council-sessions/out.json
|
|
206
219
|
EOF
|
|
207
220
|
fi
|
|
208
221
|
|
|
@@ -216,7 +229,9 @@ Examples (Tier 2):
|
|
|
216
229
|
./agent-config mcp:setup
|
|
217
230
|
./agent-config mcp:run
|
|
218
231
|
./agent-config roadmap:progress
|
|
232
|
+
./agent-config settings:check
|
|
219
233
|
./agent-config hooks:install
|
|
234
|
+
./agent-config hooks:replay --platform augment --event post_tool_use --payload post_tool_use --json
|
|
220
235
|
./agent-config migrate-state
|
|
221
236
|
./agent-config memory:lookup --types domain-invariants --key billing
|
|
222
237
|
./agent-config memory:signal --type architecture-decision --path src/Foo.php --body "…"
|
|
@@ -506,6 +521,20 @@ cmd_hooks_status() {
|
|
|
506
521
|
exec python3 "$script" "$@"
|
|
507
522
|
}
|
|
508
523
|
|
|
524
|
+
cmd_hooks_doctor() {
|
|
525
|
+
require_python3
|
|
526
|
+
local script
|
|
527
|
+
script="$(resolve_script "scripts/hooks_doctor.py")" || return 1
|
|
528
|
+
exec python3 "$script" "$@"
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
cmd_hooks_replay() {
|
|
532
|
+
require_python3
|
|
533
|
+
local script
|
|
534
|
+
script="$(resolve_script "scripts/hooks/replay_hook.py")" || return 1
|
|
535
|
+
exec python3 "$script" "$@"
|
|
536
|
+
}
|
|
537
|
+
|
|
509
538
|
cmd_chat_history_checkpoint() {
|
|
510
539
|
require_python3
|
|
511
540
|
local script
|
|
@@ -675,6 +704,15 @@ cmd_validate() {
|
|
|
675
704
|
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_validate "$@"
|
|
676
705
|
}
|
|
677
706
|
|
|
707
|
+
# `agent-config settings:check` — read-only YAML-subset validator for
|
|
708
|
+
# `.agent-settings.yml` (P3.2 of road-to-proof-not-features.md). Contract
|
|
709
|
+
# pinned in docs/contracts/settings-sync-yaml-subset.md. Exit 0 clean,
|
|
710
|
+
# 1 finding(s), 2 file absent / unreadable.
|
|
711
|
+
cmd_settings_check() {
|
|
712
|
+
require_python3
|
|
713
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_settings_check "$@"
|
|
714
|
+
}
|
|
715
|
+
|
|
678
716
|
# `agent-config uninstall` — remove bridge markers (project) or lockfile
|
|
679
717
|
# entries (global). Idempotent. Pass `--purge` to also delete deployed
|
|
680
718
|
# content directories under user-scope anchors (destructive). See
|
|
@@ -743,6 +781,8 @@ main() {
|
|
|
743
781
|
context-hygiene:hook) cmd_context_hygiene_hook "$@" ;;
|
|
744
782
|
dispatch:hook) cmd_dispatch_hook "$@" ;;
|
|
745
783
|
hooks:status) cmd_hooks_status "$@" ;;
|
|
784
|
+
hooks:doctor) cmd_hooks_doctor "$@" ;;
|
|
785
|
+
hooks:replay) cmd_hooks_replay "$@" ;;
|
|
746
786
|
telemetry:record) cmd_telemetry_record "$@" ;;
|
|
747
787
|
telemetry:status) cmd_telemetry_status "$@" ;;
|
|
748
788
|
telemetry:report) cmd_telemetry_report "$@" ;;
|
|
@@ -756,6 +796,7 @@ main() {
|
|
|
756
796
|
export) cmd_export "$@" ;;
|
|
757
797
|
sync) cmd_sync "$@" ;;
|
|
758
798
|
validate) cmd_validate "$@" ;;
|
|
799
|
+
settings:check) cmd_settings_check "$@" ;;
|
|
759
800
|
uninstall) cmd_uninstall "$@" ;;
|
|
760
801
|
prune) cmd_prune "$@" ;;
|
|
761
802
|
doctor) cmd_doctor "$@" ;;
|
package/scripts/chat_history.py
CHANGED
|
@@ -48,6 +48,15 @@ SCHEMA_VERSION = 4
|
|
|
48
48
|
DEFAULT_MAX_SESSIONS = 5
|
|
49
49
|
VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
|
|
50
50
|
VALID_OVERFLOW = {"rotate", "compress"}
|
|
51
|
+
|
|
52
|
+
# Replay-mode signal — when set, every write to the on-disk transcript
|
|
53
|
+
# is a no-op. Honoured per `docs/contracts/hook-architecture-v1.md`
|
|
54
|
+
# § Replay mode so fixture dispatches never mutate real session state.
|
|
55
|
+
REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_replay_mode() -> bool:
|
|
59
|
+
return os.environ.get(REPLAY_ENV_VAR, "").strip() == "1"
|
|
51
60
|
_WS_RE = re.compile(r"\s+")
|
|
52
61
|
SESSION_ID_LEN = 16
|
|
53
62
|
SESSION_ID_UNKNOWN = "<unknown>"
|
|
@@ -247,6 +256,8 @@ def init(freq: str = "per_phase", *,
|
|
|
247
256
|
raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
|
|
248
257
|
p = path or file_path()
|
|
249
258
|
header = _build_header(freq)
|
|
259
|
+
if _is_replay_mode():
|
|
260
|
+
return header
|
|
250
261
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
251
262
|
with p.open("w", encoding="utf-8") as fh:
|
|
252
263
|
fh.write(json.dumps(header, ensure_ascii=False) + "\n")
|
|
@@ -318,6 +329,8 @@ def append(entry: dict[str, Any], *, path: Path | None = None,
|
|
|
318
329
|
entry["s"] = session
|
|
319
330
|
elif "s" not in entry and _session_tag_enabled():
|
|
320
331
|
entry["s"] = _last_body_session_id(p)
|
|
332
|
+
if _is_replay_mode():
|
|
333
|
+
return
|
|
321
334
|
with p.open("a", encoding="utf-8") as fh:
|
|
322
335
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
323
336
|
|
|
@@ -328,7 +341,11 @@ def _atomic_write_text(p: Path, text: str) -> None:
|
|
|
328
341
|
Multiple processes writing to the same target use disjoint tmp paths
|
|
329
342
|
(PID + uuid), so concurrent writes no longer collide on a shared
|
|
330
343
|
``.tmp`` file. The final ``replace`` is atomic on POSIX.
|
|
344
|
+
|
|
345
|
+
Under `AGENT_CONFIG_REPLAY=1` the call is a no-op.
|
|
331
346
|
"""
|
|
347
|
+
if _is_replay_mode():
|
|
348
|
+
return
|
|
332
349
|
tmp = p.with_suffix(
|
|
333
350
|
f"{p.suffix}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp",
|
|
334
351
|
)
|
|
@@ -405,6 +422,8 @@ def prepend_entries(entries: list[dict[str, Any]], *,
|
|
|
405
422
|
|
|
406
423
|
|
|
407
424
|
def clear(*, path: Path | None = None) -> None:
|
|
425
|
+
if _is_replay_mode():
|
|
426
|
+
return
|
|
408
427
|
p = path or file_path()
|
|
409
428
|
if p.exists():
|
|
410
429
|
p.unlink()
|
|
@@ -14,8 +14,10 @@ council files. Directory mentions and placeholder paths
|
|
|
14
14
|
output-path convention, not a live reference.
|
|
15
15
|
|
|
16
16
|
Forbidden hits in this codebase exist today (kernel-membership ADRs
|
|
17
|
-
cite real session JSONs as decision traces).
|
|
18
|
-
|
|
17
|
+
cite real session JSONs as decision traces). Two source/target shapes
|
|
18
|
+
are exempt structurally — see STRUCTURAL_CARVEOUTS below — because
|
|
19
|
+
they encode immutable decision provenance, not transient drafting
|
|
20
|
+
state. Anything else needs an inline pragma at the end of the line:
|
|
19
21
|
|
|
20
22
|
`agents/council-sessions/...json` <!-- council-ref-allowed: <reason> -->
|
|
21
23
|
|
|
@@ -82,6 +84,31 @@ ALLOWLIST_FILES: frozenset[str] = frozenset({
|
|
|
82
84
|
|
|
83
85
|
INLINE_PRAGMA = re.compile(r"<!--\s*council-ref-allowed:[^>]*-->")
|
|
84
86
|
|
|
87
|
+
# Structural carve-outs — (source_pattern, target_pattern) pairs where
|
|
88
|
+
# the reference is immutable decision provenance rather than transient
|
|
89
|
+
# drafting state. Driven by the 2026-05-14 P3.4 council round
|
|
90
|
+
# (agents/council-sessions/2026-05-14-p3-4-references/synthesis.md).
|
|
91
|
+
#
|
|
92
|
+
# Each entry: source file matches `source` regex AND the captured
|
|
93
|
+
# reference path matches `target` regex → reference is allowed without
|
|
94
|
+
# an inline pragma.
|
|
95
|
+
STRUCTURAL_CARVEOUTS: tuple[tuple[re.Pattern[str], re.Pattern[str]], ...] = (
|
|
96
|
+
# (a) evaluation-context → council-question:
|
|
97
|
+
# the question file is a frozen function-parameter / spend-gate
|
|
98
|
+
# input, not a documentation link.
|
|
99
|
+
(
|
|
100
|
+
re.compile(r"^agents/contexts/evaluation-[^/]+\.md$"),
|
|
101
|
+
re.compile(r"^agents/council-questions/[^/]+\.md$"),
|
|
102
|
+
),
|
|
103
|
+
# (b) contract → council-session-synthesis:
|
|
104
|
+
# the synthesis file is the audit-trail receipt the contract cites
|
|
105
|
+
# as decision provenance; the contract inlines the decision body.
|
|
106
|
+
(
|
|
107
|
+
re.compile(r"^docs/contracts/[^/]+\.md$"),
|
|
108
|
+
re.compile(r"^agents/council-sessions/[^/]+/synthesis\.md$"),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
85
112
|
|
|
86
113
|
def _is_allowlisted(rel: str) -> bool:
|
|
87
114
|
if rel in ALLOWLIST_FILES:
|
|
@@ -89,8 +116,17 @@ def _is_allowlisted(rel: str) -> bool:
|
|
|
89
116
|
return any(rel.startswith(prefix) for prefix in ALLOWLIST_PREFIXES)
|
|
90
117
|
|
|
91
118
|
|
|
119
|
+
def _is_structurally_allowed(source_rel: str, target_capture: str) -> bool:
|
|
120
|
+
"""True when (source, target) matches a structural carve-out pair."""
|
|
121
|
+
for src_re, tgt_re in STRUCTURAL_CARVEOUTS:
|
|
122
|
+
if src_re.match(source_rel) and tgt_re.match(target_capture):
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
92
127
|
def _scan_file(path: Path) -> list[tuple[int, str]]:
|
|
93
128
|
findings: list[tuple[int, str]] = []
|
|
129
|
+
rel = path.as_posix()
|
|
94
130
|
try:
|
|
95
131
|
text = path.read_text(encoding="utf-8")
|
|
96
132
|
except (OSError, UnicodeDecodeError):
|
|
@@ -99,6 +135,8 @@ def _scan_file(path: Path) -> list[tuple[int, str]]:
|
|
|
99
135
|
if INLINE_PRAGMA.search(line):
|
|
100
136
|
continue
|
|
101
137
|
for m in PATTERN.finditer(line):
|
|
138
|
+
if _is_structurally_allowed(rel, m.group(0)):
|
|
139
|
+
continue
|
|
102
140
|
findings.append((ln, m.group(0)))
|
|
103
141
|
return findings
|
|
104
142
|
|
|
@@ -136,10 +174,13 @@ def main() -> int:
|
|
|
136
174
|
print(
|
|
137
175
|
"\nRule: .agent-src/rules/no-roadmap-references.md (council clause)\n"
|
|
138
176
|
"Fix: inline the convergence summary (members + date) instead of\n"
|
|
139
|
-
"linking the file.
|
|
177
|
+
"linking the file. Two source/target shapes are exempt structurally\n"
|
|
178
|
+
"(evaluation-context → council-question, contract →\n"
|
|
179
|
+
"council-session-synthesis) — see STRUCTURAL_CARVEOUTS in this\n"
|
|
180
|
+
"script. Otherwise append "
|
|
140
181
|
"<!-- council-ref-allowed: <reason> --> on the same line to\n"
|
|
141
|
-
"suppress when the reference is genuinely required (ADR
|
|
142
|
-
"
|
|
182
|
+
"suppress when the reference is genuinely required (ADR decision\n"
|
|
183
|
+
"trace)."
|
|
143
184
|
)
|
|
144
185
|
return 1
|
|
145
186
|
|
|
@@ -37,7 +37,7 @@ MANIFEST_PATH = REPO_ROOT / "scripts" / "hook_manifest.yaml"
|
|
|
37
37
|
# Lazy import — we want this module to be importable even if the
|
|
38
38
|
# hooks package state_io has changed (test isolation).
|
|
39
39
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
40
|
-
from state_io import atomic_write_json, feedback_dir # noqa: E402
|
|
40
|
+
from state_io import atomic_write_json, feedback_dir, is_replay_mode # noqa: E402
|
|
41
41
|
|
|
42
42
|
EXIT_ALLOW = 0
|
|
43
43
|
EXIT_BLOCK = 1
|
|
@@ -272,6 +272,10 @@ def _write_feedback(envelope: dict, session_id: str, entries: list[dict],
|
|
|
272
272
|
not control flow. We only swallow IO errors here; fail-open
|
|
273
273
|
matches the dispatcher's overall posture.
|
|
274
274
|
"""
|
|
275
|
+
# Replay mode skips feedback emission entirely so fixture replays
|
|
276
|
+
# never create per-session dirs under agents/state/.dispatcher/.
|
|
277
|
+
if is_replay_mode():
|
|
278
|
+
return
|
|
275
279
|
workspace = envelope.get("workspace_root") or str(Path.cwd())
|
|
276
280
|
state_root = Path(workspace) / "agents" / "state"
|
|
277
281
|
fb_dir = feedback_dir(state_root, session_id)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fixture-driven hook replay — read-only dispatch through the runtime.
|
|
3
|
+
|
|
4
|
+
Reads a stdin payload fixture from `tests/fixtures/hooks/` (one file per
|
|
5
|
+
event in `EVENT_VOCABULARY`), sets `AGENT_CONFIG_REPLAY=1`, and invokes
|
|
6
|
+
the universal dispatcher with the platform / event / payload tuple. The
|
|
7
|
+
replay flag tells `state_io` (and concerns that honour it) to skip every
|
|
8
|
+
write under `agents/state/` so the replay never mutates real session
|
|
9
|
+
state.
|
|
10
|
+
|
|
11
|
+
Invocation:
|
|
12
|
+
|
|
13
|
+
python3 scripts/hooks/replay_hook.py \\
|
|
14
|
+
--platform <name> \\
|
|
15
|
+
--event <agent-config-event> \\
|
|
16
|
+
--payload tests/fixtures/hooks/<event>.json \\
|
|
17
|
+
[--native-event <native>] \\
|
|
18
|
+
[--manifest <path>] \\
|
|
19
|
+
[--json]
|
|
20
|
+
|
|
21
|
+
The `--json` flag prints a structured replay summary on stdout
|
|
22
|
+
(platform, event, dispatcher exit code, captured stderr lines).
|
|
23
|
+
Non-zero exit is propagated from the dispatcher.
|
|
24
|
+
|
|
25
|
+
Contract reference: `docs/contracts/hook-architecture-v1.md` § Replay
|
|
26
|
+
mode. Roadmap step: P2.4b of `agents/roadmaps/road-to-proof-not-features.md`.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
38
|
+
DISPATCHER = REPO_ROOT / "scripts" / "hooks" / "dispatch_hook.py"
|
|
39
|
+
DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
|
|
40
|
+
FIXTURE_DIR = REPO_ROOT / "tests" / "fixtures" / "hooks"
|
|
41
|
+
REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_payload(arg: str) -> Path:
|
|
45
|
+
"""Accept either an absolute path, a path relative to CWD, or a bare
|
|
46
|
+
event name that resolves to `tests/fixtures/hooks/<name>.json`."""
|
|
47
|
+
candidate = Path(arg)
|
|
48
|
+
if candidate.is_file():
|
|
49
|
+
return candidate
|
|
50
|
+
bare = FIXTURE_DIR / f"{arg}.json"
|
|
51
|
+
if bare.is_file():
|
|
52
|
+
return bare
|
|
53
|
+
raise FileNotFoundError(
|
|
54
|
+
f"replay_hook: payload not found — tried '{candidate}' and '{bare}'")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_argparser() -> argparse.ArgumentParser:
|
|
58
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
59
|
+
p.add_argument("--platform", required=True,
|
|
60
|
+
help="Platform key as declared in hook_manifest.yaml "
|
|
61
|
+
"(augment, claude, cursor, cline, windsurf, gemini, copilot).")
|
|
62
|
+
p.add_argument("--event", required=True,
|
|
63
|
+
help="agent-config event (see EVENT_VOCABULARY in "
|
|
64
|
+
"scripts/hooks/dispatch_hook.py).")
|
|
65
|
+
p.add_argument("--payload", required=True,
|
|
66
|
+
help="Path to a fixture JSON file, or a bare event name "
|
|
67
|
+
"resolved under tests/fixtures/hooks/.")
|
|
68
|
+
p.add_argument("--native-event", default="",
|
|
69
|
+
help="Optional native event name for diagnostics.")
|
|
70
|
+
p.add_argument("--manifest", default=str(DEFAULT_MANIFEST),
|
|
71
|
+
help=f"Hook manifest path (default: {DEFAULT_MANIFEST}).")
|
|
72
|
+
p.add_argument("--json", action="store_true",
|
|
73
|
+
help="Emit a structured summary on stdout.")
|
|
74
|
+
p.add_argument("--dry-run", action="store_true",
|
|
75
|
+
help="Resolve concerns and print the dispatch plan; "
|
|
76
|
+
"do not invoke concerns.")
|
|
77
|
+
return p
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: list[str] | None = None) -> int:
|
|
81
|
+
args = _build_argparser().parse_args(argv)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
payload_path = _resolve_payload(args.payload)
|
|
85
|
+
except FileNotFoundError as exc:
|
|
86
|
+
sys.stderr.write(f"❌ {exc}\n")
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
payload_text = payload_path.read_text(encoding="utf-8")
|
|
90
|
+
# Validate JSON early so dispatcher stderr stays focused on real
|
|
91
|
+
# concern problems. Empty / non-object payloads are still dispatched
|
|
92
|
+
# — that mirrors the platform contract (stdin can be empty).
|
|
93
|
+
try:
|
|
94
|
+
decoded = json.loads(payload_text) if payload_text.strip() else {}
|
|
95
|
+
except (ValueError, TypeError) as exc:
|
|
96
|
+
sys.stderr.write(f"❌ replay_hook: invalid JSON in {payload_path}: {exc}\n")
|
|
97
|
+
return 2
|
|
98
|
+
|
|
99
|
+
env = dict(os.environ)
|
|
100
|
+
env[REPLAY_ENV_VAR] = "1"
|
|
101
|
+
|
|
102
|
+
cmd = [sys.executable, str(DISPATCHER),
|
|
103
|
+
"--platform", args.platform,
|
|
104
|
+
"--event", args.event,
|
|
105
|
+
"--manifest", args.manifest]
|
|
106
|
+
if args.native_event:
|
|
107
|
+
cmd.extend(["--native-event", args.native_event])
|
|
108
|
+
if args.dry_run:
|
|
109
|
+
cmd.append("--dry-run")
|
|
110
|
+
|
|
111
|
+
proc = subprocess.run(
|
|
112
|
+
cmd, input=payload_text, capture_output=True, text=True, env=env,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if args.json:
|
|
117
|
+
summary = {
|
|
118
|
+
"platform": args.platform,
|
|
119
|
+
"event": args.event,
|
|
120
|
+
"native_event": args.native_event or "",
|
|
121
|
+
"payload": str(payload_path.relative_to(REPO_ROOT)
|
|
122
|
+
if str(payload_path).startswith(str(REPO_ROOT))
|
|
123
|
+
else payload_path),
|
|
124
|
+
"session_id": decoded.get("session_id") if isinstance(decoded, dict) else None,
|
|
125
|
+
"exit_code": proc.returncode,
|
|
126
|
+
"dispatcher_stdout": (proc.stdout or "").strip(),
|
|
127
|
+
"dispatcher_stderr": (proc.stderr or "").strip(),
|
|
128
|
+
"replay_mode": True,
|
|
129
|
+
}
|
|
130
|
+
print(json.dumps(summary, indent=2))
|
|
131
|
+
else:
|
|
132
|
+
if proc.stdout:
|
|
133
|
+
sys.stdout.write(proc.stdout)
|
|
134
|
+
if proc.stderr:
|
|
135
|
+
sys.stderr.write(proc.stderr)
|
|
136
|
+
sys.stderr.write(
|
|
137
|
+
f"replay_hook: platform={args.platform} event={args.event} "
|
|
138
|
+
f"payload={payload_path.name} rc={proc.returncode} "
|
|
139
|
+
f"(AGENT_CONFIG_REPLAY=1, no writes)\n")
|
|
140
|
+
return proc.returncode
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__": # pragma: no cover
|
|
144
|
+
sys.exit(main())
|