@event4u/agent-config 1.9.1 → 1.13.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 (85) hide show
  1. package/.agent-src/commands/agent-handoff.md +15 -0
  2. package/.agent-src/commands/chat-history-clear.md +98 -0
  3. package/.agent-src/commands/chat-history-resume.md +178 -0
  4. package/.agent-src/commands/chat-history.md +102 -0
  5. package/.agent-src/commands/compress.md +9 -9
  6. package/.agent-src/commands/copilot-agents-init.md +1 -1
  7. package/.agent-src/commands/fix-portability.md +2 -2
  8. package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
  9. package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
  10. package/.agent-src/commands/fix-references.md +2 -2
  11. package/.agent-src/commands/mode.md +5 -5
  12. package/.agent-src/commands/onboard.md +171 -0
  13. package/.agent-src/commands/roadmap-create.md +7 -2
  14. package/.agent-src/commands/roadmap-execute.md +2 -2
  15. package/.agent-src/commands/set-cost-profile.md +101 -0
  16. package/.agent-src/commands/sync-agent-settings.md +122 -0
  17. package/.agent-src/commands/sync-gitignore.md +104 -0
  18. package/.agent-src/commands/tests-execute.md +6 -6
  19. package/.agent-src/commands/upstream-contribute.md +5 -4
  20. package/.agent-src/contexts/augment-infrastructure.md +2 -2
  21. package/.agent-src/contexts/override-system.md +1 -1
  22. package/.agent-src/contexts/subagent-configuration.md +3 -3
  23. package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
  24. package/.agent-src/rules/ask-when-uncertain.md +56 -3
  25. package/.agent-src/rules/augment-portability.md +52 -1
  26. package/.agent-src/rules/augment-source-of-truth.md +10 -10
  27. package/.agent-src/rules/chat-history.md +171 -0
  28. package/.agent-src/rules/docker-commands.md +5 -7
  29. package/.agent-src/rules/docs-sync.md +13 -9
  30. package/.agent-src/rules/improve-before-implement.md +2 -0
  31. package/.agent-src/rules/onboarding-gate.md +94 -0
  32. package/.agent-src/rules/package-ci-checks.md +6 -5
  33. package/.agent-src/rules/roadmap-progress-sync.md +24 -13
  34. package/.agent-src/rules/size-enforcement.md +1 -1
  35. package/.agent-src/rules/skill-quality.md +1 -1
  36. package/.agent-src/rules/think-before-action.md +1 -0
  37. package/.agent-src/rules/user-interaction.md +53 -7
  38. package/.agent-src/scripts/update_roadmap_progress.py +57 -10
  39. package/.agent-src/skills/check-refs/SKILL.md +1 -1
  40. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  41. package/.agent-src/skills/command-writing/SKILL.md +4 -3
  42. package/.agent-src/skills/file-editor/SKILL.md +2 -2
  43. package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
  44. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
  45. package/.agent-src/skills/lint-skills/SKILL.md +1 -1
  46. package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
  47. package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
  48. package/.agent-src/skills/rule-writing/SKILL.md +5 -5
  49. package/.agent-src/skills/terragrunt/SKILL.md +0 -8
  50. package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
  51. package/.agent-src/templates/agent-settings.md +86 -34
  52. package/.agent-src/templates/github-workflows/roadmap-progress-check.yml +63 -0
  53. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
  54. package/.agent-src/templates/scripts/memory_lookup.py +382 -21
  55. package/.agent-src/templates/scripts/memory_status.py +110 -9
  56. package/.claude-plugin/marketplace.json +1 -1
  57. package/AGENTS.md +2 -2
  58. package/CHANGELOG.md +320 -0
  59. package/CONTRIBUTING.md +89 -40
  60. package/README.md +24 -3
  61. package/composer.json +5 -1
  62. package/config/agent-settings.template.yml +45 -6
  63. package/config/gitignore-block.txt +24 -0
  64. package/config/profiles/balanced.ini +5 -0
  65. package/config/profiles/full.ini +5 -0
  66. package/config/profiles/minimal.ini +5 -0
  67. package/docs/customization.md +30 -4
  68. package/docs/getting-started.md +53 -3
  69. package/docs/mcp.md +15 -4
  70. package/package.json +21 -2
  71. package/scripts/agent-config +230 -0
  72. package/scripts/chat_history.py +519 -0
  73. package/scripts/check_portability.py +151 -1
  74. package/scripts/install.py +55 -3
  75. package/scripts/install.sh +50 -21
  76. package/scripts/mcp_render.py +30 -16
  77. package/scripts/memory_lookup.py +143 -7
  78. package/scripts/memory_status.py +76 -14
  79. package/scripts/postinstall.sh +16 -0
  80. package/scripts/release.py +588 -0
  81. package/scripts/sync_agent_settings.py +211 -0
  82. package/scripts/sync_gitignore.py +226 -0
  83. package/templates/agent-config-wrapper.sh +47 -0
  84. package/.agent-src/commands/config-agent-settings.md +0 -126
  85. package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent chat-history log for crash recovery.
3
+
4
+ Maintains `.agent-chat-history` in the project root — a JSONL file whose
5
+ first line is a header (session id, fingerprint, frequency mode) and
6
+ whose remaining lines are append-only entries (user messages, phases,
7
+ tool calls, questions, answers, decisions, commits).
8
+
9
+ Ownership is established via SHA-256 of the first user message in the
10
+ conversation, stored in the header. Agents read this on every turn to
11
+ detect whether the file belongs to the current conversation.
12
+
13
+ File path defaults to `.agent-chat-history` in CWD and can be overridden
14
+ via `$AGENT_CHAT_HISTORY_FILE` (used by tests).
15
+
16
+ Usage:
17
+ python3 scripts/chat_history.py init --first-user-msg "..." [--freq per_phase]
18
+ python3 scripts/chat_history.py append --type phase --json '{...}'
19
+ python3 scripts/chat_history.py status
20
+ python3 scripts/chat_history.py check --first-user-msg "..."
21
+ python3 scripts/chat_history.py state --first-user-msg "..."
22
+ python3 scripts/chat_history.py adopt --first-user-msg "..."
23
+ python3 scripts/chat_history.py reset --first-user-msg "..." --entries-json '[...]' [--freq per_phase]
24
+ python3 scripts/chat_history.py prepend --entries-json '[...]'
25
+ python3 scripts/chat_history.py read [--last N | --all]
26
+ python3 scripts/chat_history.py clear
27
+ python3 scripts/chat_history.py rotate --max-kb 256 --mode rotate
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import datetime as dt
34
+ import hashlib
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+ import uuid
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ DEFAULT_FILE = ".agent-chat-history"
44
+ SCHEMA_VERSION = 2
45
+ FORMER_FPS_CAP = 10
46
+ VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
47
+ VALID_OVERFLOW = {"rotate", "compress"}
48
+ _WS_RE = re.compile(r"\s+")
49
+
50
+
51
+ def file_path() -> Path:
52
+ return Path(os.environ.get("AGENT_CHAT_HISTORY_FILE") or DEFAULT_FILE)
53
+
54
+
55
+ def _now() -> str:
56
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
57
+
58
+
59
+ def fingerprint(first_user_msg: str) -> str:
60
+ """SHA-256 of the normalized first user message (whitespace collapsed)."""
61
+ normalized = _WS_RE.sub(" ", first_user_msg or "").strip()
62
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
63
+
64
+
65
+ def _preview(msg: str, n: int = 80) -> str:
66
+ flat = _WS_RE.sub(" ", msg or "").strip()
67
+ return flat[:n]
68
+
69
+
70
+ def read_header(path: Path | None = None) -> dict[str, Any] | None:
71
+ """Read the header. Migrates v1 headers in memory (adds `former_fps: []`).
72
+
73
+ The on-disk file is not rewritten by this read; migration is lazy and
74
+ happens on the next write (init/adopt/reset).
75
+ """
76
+ p = path or file_path()
77
+ if not p.is_file() or p.stat().st_size == 0:
78
+ return None
79
+ try:
80
+ with p.open(encoding="utf-8") as fh:
81
+ first = fh.readline().strip()
82
+ if not first:
83
+ return None
84
+ obj = json.loads(first)
85
+ if not (isinstance(obj, dict) and obj.get("t") == "header"):
86
+ return None
87
+ obj.setdefault("former_fps", [])
88
+ if not isinstance(obj["former_fps"], list):
89
+ obj["former_fps"] = []
90
+ return obj
91
+ except (json.JSONDecodeError, OSError):
92
+ return None
93
+
94
+
95
+ def _build_header(first_user_msg: str, freq: str,
96
+ former_fps: list[str] | None = None) -> dict[str, Any]:
97
+ return {
98
+ "t": "header",
99
+ "v": SCHEMA_VERSION,
100
+ "session": str(uuid.uuid4()),
101
+ "started": _now(),
102
+ "fp": fingerprint(first_user_msg),
103
+ "preview": _preview(first_user_msg),
104
+ "freq": freq,
105
+ "former_fps": list(former_fps or []),
106
+ }
107
+
108
+
109
+ def init(first_user_msg: str, freq: str = "per_phase", *,
110
+ path: Path | None = None) -> dict[str, Any]:
111
+ """Overwrite the file with a fresh header for a new session."""
112
+ if freq not in VALID_FREQS:
113
+ raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
114
+ p = path or file_path()
115
+ header = _build_header(first_user_msg, freq)
116
+ p.parent.mkdir(parents=True, exist_ok=True)
117
+ with p.open("w", encoding="utf-8") as fh:
118
+ fh.write(json.dumps(header, ensure_ascii=False) + "\n")
119
+ return header
120
+
121
+
122
+ def append(entry: dict[str, Any], *, path: Path | None = None) -> None:
123
+ """Append one entry. Entry must be a dict; `ts` is auto-filled."""
124
+ if not isinstance(entry, dict) or not entry.get("t"):
125
+ raise ValueError("entry must be a dict with non-empty 't' key")
126
+ if entry["t"] == "header":
127
+ raise ValueError("use init() to write the header, not append()")
128
+ entry.setdefault("ts", _now())
129
+ p = path or file_path()
130
+ with p.open("a", encoding="utf-8") as fh:
131
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
132
+
133
+
134
+ def check_ownership(first_user_msg: str, *,
135
+ path: Path | None = None) -> str:
136
+ """Return 'match', 'mismatch', or 'missing' (legacy 3-state).
137
+
138
+ Kept for backward compatibility. Prefer `ownership_state()` for the
139
+ 4-state view that distinguishes foreign from returning sessions.
140
+ """
141
+ header = read_header(path)
142
+ if not header:
143
+ return "missing"
144
+ return "match" if header.get("fp") == fingerprint(first_user_msg) else "mismatch"
145
+
146
+
147
+ def ownership_state(first_user_msg: str, *,
148
+ path: Path | None = None) -> str:
149
+ """Return 'match', 'returning', 'foreign', or 'missing'.
150
+
151
+ `match` — current fp equals header.fp (silent append)
152
+ `returning` — current fp appears in header.former_fps (this chat once
153
+ owned the file; another session took it over since)
154
+ `foreign` — current fp is neither match nor former (new chat finds
155
+ an existing file from an unknown session)
156
+ `missing` — no file or no valid header
157
+ """
158
+ header = read_header(path)
159
+ if not header:
160
+ return "missing"
161
+ fp = fingerprint(first_user_msg)
162
+ if header.get("fp") == fp:
163
+ return "match"
164
+ former = header.get("former_fps") or []
165
+ return "returning" if fp in former else "foreign"
166
+
167
+
168
+ def _push_former_fp(former_fps: list[str], old_fp: str,
169
+ new_fp: str) -> list[str]:
170
+ """Move old_fp into former_fps with dedup + cap. Never include new_fp."""
171
+ seen: list[str] = []
172
+ for fp in [old_fp, *former_fps]:
173
+ if fp and fp != new_fp and fp not in seen:
174
+ seen.append(fp)
175
+ return seen[:FORMER_FPS_CAP]
176
+
177
+
178
+ def adopt(first_user_msg: str, *, path: Path | None = None) -> dict[str, Any]:
179
+ """Rewrite the header's fingerprint to the current conversation's.
180
+
181
+ Preserves all body entries. Pushes the previous `fp` onto
182
+ `former_fps` (dedup, capped at FORMER_FPS_CAP) so this former owner
183
+ can later be detected as 'returning' if the original chat comes back.
184
+ """
185
+ p = path or file_path()
186
+ header = read_header(p)
187
+ if not header:
188
+ raise FileNotFoundError(f"no header in {p}")
189
+ old_fp = header.get("fp", "")
190
+ new_fp = fingerprint(first_user_msg)
191
+ header["v"] = SCHEMA_VERSION
192
+ header["fp"] = new_fp
193
+ header["preview"] = _preview(first_user_msg)
194
+ header["adopted_at"] = _now()
195
+ header["former_fps"] = _push_former_fp(
196
+ header.get("former_fps") or [], old_fp, new_fp,
197
+ )
198
+ with p.open(encoding="utf-8") as fh:
199
+ lines = fh.readlines()
200
+ lines[0] = json.dumps(header, ensure_ascii=False) + "\n"
201
+ tmp = p.with_suffix(p.suffix + ".tmp")
202
+ tmp.write_text("".join(lines), encoding="utf-8")
203
+ tmp.replace(p)
204
+ return header
205
+
206
+
207
+ def _normalize_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
208
+ """Validate + fill `ts` on each entry. Reject headers and empty `t`."""
209
+ out: list[dict[str, Any]] = []
210
+ for raw in entries or []:
211
+ if not isinstance(raw, dict) or not raw.get("t"):
212
+ raise ValueError("each entry must be a dict with non-empty 't' key")
213
+ if raw["t"] == "header":
214
+ raise ValueError("entries must not contain headers")
215
+ e = dict(raw)
216
+ e.setdefault("ts", _now())
217
+ out.append(e)
218
+ return out
219
+
220
+
221
+ def reset_with_entries(first_user_msg: str,
222
+ entries: list[dict[str, Any]],
223
+ freq: str = "per_phase", *,
224
+ former_fps: list[str] | None = None,
225
+ path: Path | None = None) -> dict[str, Any]:
226
+ """Discard current file contents and rewrite with a fresh header + entries.
227
+
228
+ Used for the 'Replace' flow: the in-memory history supersedes whatever
229
+ is on disk. If `former_fps` is None and a header exists, the old fp is
230
+ preserved via `_push_former_fp` so the returning/foreign state logic
231
+ still works on later switches.
232
+ """
233
+ if freq not in VALID_FREQS:
234
+ raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
235
+ p = path or file_path()
236
+ new_fp = fingerprint(first_user_msg)
237
+ if former_fps is None:
238
+ existing = read_header(p)
239
+ if existing:
240
+ former_fps = _push_former_fp(
241
+ existing.get("former_fps") or [],
242
+ existing.get("fp", ""),
243
+ new_fp,
244
+ )
245
+ else:
246
+ former_fps = []
247
+ header = _build_header(first_user_msg, freq, former_fps=former_fps)
248
+ body = _normalize_entries(entries)
249
+ p.parent.mkdir(parents=True, exist_ok=True)
250
+ lines = [json.dumps(header, ensure_ascii=False)]
251
+ lines += [json.dumps(e, ensure_ascii=False) for e in body]
252
+ tmp = p.with_suffix(p.suffix + ".tmp")
253
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
254
+ tmp.replace(p)
255
+ return header
256
+
257
+
258
+ def prepend_entries(entries: list[dict[str, Any]], *,
259
+ path: Path | None = None) -> int:
260
+ """Insert entries right after the header, before existing body entries.
261
+
262
+ Used for the 'Merge' flow: the in-memory history (older) is placed
263
+ before the file's existing body (newer from the adopting session).
264
+ Returns the number of entries prepended. Header untouched.
265
+ """
266
+ p = path or file_path()
267
+ if not p.is_file():
268
+ raise FileNotFoundError(f"no file at {p}")
269
+ with p.open(encoding="utf-8") as fh:
270
+ existing = fh.readlines()
271
+ if not existing:
272
+ raise ValueError(f"empty file at {p}")
273
+ header_line = existing[0]
274
+ body = existing[1:]
275
+ new_lines = [json.dumps(e, ensure_ascii=False) + "\n"
276
+ for e in _normalize_entries(entries)]
277
+ tmp = p.with_suffix(p.suffix + ".tmp")
278
+ tmp.write_text(header_line + "".join(new_lines) + "".join(body),
279
+ encoding="utf-8")
280
+ tmp.replace(p)
281
+ return len(new_lines)
282
+
283
+
284
+ def clear(*, path: Path | None = None) -> None:
285
+ p = path or file_path()
286
+ if p.exists():
287
+ p.unlink()
288
+
289
+
290
+ def read_entries(last: int | None = None, *,
291
+ path: Path | None = None) -> list[dict[str, Any]]:
292
+ """Return entries (excluding the header) as a list of dicts.
293
+
294
+ `last=None` returns all entries; `last=N` returns the trailing N.
295
+ Malformed lines are skipped silently.
296
+ """
297
+ p = path or file_path()
298
+ if not p.is_file():
299
+ return []
300
+ entries: list[dict[str, Any]] = []
301
+ with p.open(encoding="utf-8") as fh:
302
+ for i, line in enumerate(fh):
303
+ line = line.strip()
304
+ if not line:
305
+ continue
306
+ try:
307
+ obj = json.loads(line)
308
+ except json.JSONDecodeError:
309
+ continue
310
+ if i == 0 and isinstance(obj, dict) and obj.get("t") == "header":
311
+ continue
312
+ if isinstance(obj, dict):
313
+ entries.append(obj)
314
+ if last is not None and last >= 0:
315
+ entries = entries[-last:]
316
+ return entries
317
+
318
+
319
+ def status(*, path: Path | None = None) -> dict[str, Any]:
320
+ p = path or file_path()
321
+ if not p.is_file():
322
+ return {"exists": False, "path": str(p)}
323
+ header = read_header(p)
324
+ size = p.stat().st_size
325
+ with p.open(encoding="utf-8") as fh:
326
+ entry_count = sum(1 for _ in fh) - (1 if header else 0)
327
+ return {
328
+ "exists": True,
329
+ "path": str(p),
330
+ "size_bytes": size,
331
+ "size_kb": round(size / 1024, 1),
332
+ "entries": max(entry_count, 0),
333
+ "header": header,
334
+ }
335
+
336
+
337
+ def overflow_handle(max_kb: int, mode: str = "rotate", *,
338
+ path: Path | None = None) -> dict[str, Any]:
339
+ """Enforce max_kb. Returns {'action', 'kept', 'dropped'}.
340
+
341
+ Rotate: drop oldest entries (keep header) until file ≤ max_kb.
342
+ Compress: mark oldest 50% as stale and leave a `needs_compress`
343
+ marker entry for the agent to rewrite on next turn.
344
+ """
345
+ if mode not in VALID_OVERFLOW:
346
+ raise ValueError(f"mode must be one of {sorted(VALID_OVERFLOW)}")
347
+ p = path or file_path()
348
+ if not p.is_file() or p.stat().st_size <= max_kb * 1024:
349
+ return {"action": "noop", "kept": None, "dropped": 0}
350
+ with p.open(encoding="utf-8") as fh:
351
+ lines = fh.readlines()
352
+ if not lines:
353
+ return {"action": "noop", "kept": 0, "dropped": 0}
354
+ header_line = lines[0]
355
+ entries = lines[1:]
356
+ if mode == "rotate":
357
+ budget = max_kb * 1024 - len(header_line.encode("utf-8"))
358
+ kept: list[str] = []
359
+ total = 0
360
+ for line in reversed(entries):
361
+ size = len(line.encode("utf-8"))
362
+ if total + size > budget:
363
+ break
364
+ kept.append(line)
365
+ total += size
366
+ kept.reverse()
367
+ dropped = len(entries) - len(kept)
368
+ tmp = p.with_suffix(p.suffix + ".tmp")
369
+ tmp.write_text(header_line + "".join(kept), encoding="utf-8")
370
+ tmp.replace(p)
371
+ return {"action": "rotate", "kept": len(kept), "dropped": dropped}
372
+ marker = {
373
+ "t": "needs_compress",
374
+ "ts": _now(),
375
+ "reason": f"file exceeded {max_kb} KB, compress-mode requested",
376
+ }
377
+ append(marker, path=p)
378
+ return {"action": "compress_marked", "kept": len(entries), "dropped": 0}
379
+
380
+
381
+ def _cmd_init(args) -> int:
382
+ h = init(args.first_user_msg, freq=args.freq)
383
+ print(json.dumps(h, ensure_ascii=False))
384
+ return 0
385
+
386
+
387
+ def _cmd_append(args) -> int:
388
+ entry = json.loads(args.json) if args.json else {}
389
+ entry.setdefault("t", args.type)
390
+ if not entry.get("t"):
391
+ print("error: --type or a 't' key in --json is required", file=sys.stderr)
392
+ return 2
393
+ append(entry)
394
+ return 0
395
+
396
+
397
+ def _cmd_status(_args) -> int:
398
+ print(json.dumps(status(), ensure_ascii=False, indent=2))
399
+ return 0
400
+
401
+
402
+ def _cmd_check(args) -> int:
403
+ print(check_ownership(args.first_user_msg))
404
+ return 0
405
+
406
+
407
+ def _cmd_state(args) -> int:
408
+ print(ownership_state(args.first_user_msg))
409
+ return 0
410
+
411
+
412
+ def _cmd_adopt(args) -> int:
413
+ h = adopt(args.first_user_msg)
414
+ print(json.dumps(h, ensure_ascii=False))
415
+ return 0
416
+
417
+
418
+ def _load_entries_arg(args) -> list[dict[str, Any]]:
419
+ if getattr(args, "entries_stdin", False):
420
+ raw = sys.stdin.read()
421
+ else:
422
+ raw = args.entries_json or "[]"
423
+ try:
424
+ data = json.loads(raw)
425
+ except json.JSONDecodeError as exc:
426
+ raise ValueError(f"invalid JSON for entries: {exc}") from exc
427
+ if not isinstance(data, list):
428
+ raise ValueError("entries must be a JSON array")
429
+ return data
430
+
431
+
432
+ def _cmd_reset(args) -> int:
433
+ entries = _load_entries_arg(args)
434
+ h = reset_with_entries(args.first_user_msg, entries, freq=args.freq)
435
+ print(json.dumps(h, ensure_ascii=False))
436
+ return 0
437
+
438
+
439
+ def _cmd_prepend(args) -> int:
440
+ entries = _load_entries_arg(args)
441
+ n = prepend_entries(entries)
442
+ print(json.dumps({"prepended": n}, ensure_ascii=False))
443
+ return 0
444
+
445
+
446
+ def _cmd_clear(_args) -> int:
447
+ clear()
448
+ return 0
449
+
450
+
451
+ def _cmd_read(args) -> int:
452
+ last = None if args.all else args.last
453
+ entries = read_entries(last=last)
454
+ print(json.dumps(entries, ensure_ascii=False, indent=2))
455
+ return 0
456
+
457
+
458
+ def _cmd_rotate(args) -> int:
459
+ result = overflow_handle(args.max_kb, mode=args.mode)
460
+ print(json.dumps(result, ensure_ascii=False))
461
+ return 0
462
+
463
+
464
+ def main(argv: list[str] | None = None) -> int:
465
+ ap = argparse.ArgumentParser(description=__doc__)
466
+ sub = ap.add_subparsers(dest="cmd", required=True)
467
+ p_init = sub.add_parser("init")
468
+ p_init.add_argument("--first-user-msg", required=True)
469
+ p_init.add_argument("--freq", default="per_phase", choices=sorted(VALID_FREQS))
470
+ p_init.set_defaults(func=_cmd_init)
471
+ p_app = sub.add_parser("append")
472
+ p_app.add_argument("--type", help="entry type (t field)")
473
+ p_app.add_argument("--json", help="JSON object with entry fields")
474
+ p_app.set_defaults(func=_cmd_append)
475
+ sub.add_parser("status").set_defaults(func=_cmd_status)
476
+ p_chk = sub.add_parser("check")
477
+ p_chk.add_argument("--first-user-msg", required=True)
478
+ p_chk.set_defaults(func=_cmd_check)
479
+ p_state = sub.add_parser("state")
480
+ p_state.add_argument("--first-user-msg", required=True)
481
+ p_state.set_defaults(func=_cmd_state)
482
+ p_ado = sub.add_parser("adopt")
483
+ p_ado.add_argument("--first-user-msg", required=True)
484
+ p_ado.set_defaults(func=_cmd_adopt)
485
+ p_reset = sub.add_parser("reset")
486
+ p_reset.add_argument("--first-user-msg", required=True)
487
+ p_reset.add_argument("--freq", default="per_phase",
488
+ choices=sorted(VALID_FREQS))
489
+ g_r = p_reset.add_mutually_exclusive_group(required=True)
490
+ g_r.add_argument("--entries-json",
491
+ help="JSON array of entry dicts")
492
+ g_r.add_argument("--entries-stdin", action="store_true",
493
+ help="read JSON array from stdin")
494
+ p_reset.set_defaults(func=_cmd_reset)
495
+ p_prep = sub.add_parser("prepend")
496
+ g_p = p_prep.add_mutually_exclusive_group(required=True)
497
+ g_p.add_argument("--entries-json",
498
+ help="JSON array of entry dicts")
499
+ g_p.add_argument("--entries-stdin", action="store_true",
500
+ help="read JSON array from stdin")
501
+ p_prep.set_defaults(func=_cmd_prepend)
502
+ sub.add_parser("clear").set_defaults(func=_cmd_clear)
503
+ p_read = sub.add_parser("read")
504
+ grp = p_read.add_mutually_exclusive_group()
505
+ grp.add_argument("--last", type=int, default=5,
506
+ help="return the trailing N entries (default: 5)")
507
+ grp.add_argument("--all", action="store_true",
508
+ help="return all entries")
509
+ p_read.set_defaults(func=_cmd_read)
510
+ p_rot = sub.add_parser("rotate")
511
+ p_rot.add_argument("--max-kb", type=int, default=256)
512
+ p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
513
+ p_rot.set_defaults(func=_cmd_rotate)
514
+ args = ap.parse_args(argv)
515
+ return args.func(args)
516
+
517
+
518
+ if __name__ == "__main__":
519
+ sys.exit(main())
@@ -248,10 +248,137 @@ def check_file(filepath: Path, patterns: list, allowlist: list) -> List[Violatio
248
248
  return violations
249
249
 
250
250
 
251
+ # ── Task-command detector ───────────────────────────────────────────────
252
+ # Artefact files shipped in the package must not reference `task <name>`
253
+ # invocations (per augment-portability rule). Consumer projects may not
254
+ # have Taskfile installed; agents must use direct script paths instead.
255
+ ARTIFACT_SUBDIRS = ["skills", "rules", "commands", "guidelines", "personas", "contexts"]
256
+
257
+ # Inline code: `task foo` or `task foo-bar` or `task foo:bar`
258
+ _TASK_INLINE_RE = re.compile(r"`task\s+([a-z][a-z0-9:_-]*)`")
259
+ # Code-fence line: "task foo …" (optional leading whitespace)
260
+ _TASK_FENCE_RE = re.compile(r"^\s*task\s+([a-z][a-z0-9:_-]*)\b")
261
+
262
+ # Files that legitimately document the forbidden pattern — they define
263
+ # the rule itself. Any path containing one of these suffixes is skipped
264
+ # by the task-invocation detector (but still scanned for layer 1 + 2).
265
+ _TASK_DETECTOR_SKIP = (
266
+ "rules/augment-portability.md",
267
+ )
268
+
269
+
270
+ def check_task_invocations(filepath: Path) -> List[Violation]:
271
+ """Flag `task <cmd>` invocations in inline code or code fence lines."""
272
+ violations: List[Violation] = []
273
+ try:
274
+ lines = filepath.read_text(encoding="utf-8").splitlines()
275
+ except Exception:
276
+ return violations
277
+
278
+ in_code_block = False
279
+ for i, line in enumerate(lines, 1):
280
+ stripped = line.strip()
281
+ if stripped.startswith("```"):
282
+ in_code_block = not in_code_block
283
+ continue
284
+ if in_code_block:
285
+ m = _TASK_FENCE_RE.search(line)
286
+ if m:
287
+ violations.append(Violation(
288
+ file=str(filepath), line=i, match=m.group(0).strip(),
289
+ pattern_name="task-invocation", severity="error",
290
+ context=stripped,
291
+ ))
292
+ else:
293
+ for m in _TASK_INLINE_RE.finditer(line):
294
+ violations.append(Violation(
295
+ file=str(filepath), line=i, match=m.group(0),
296
+ pattern_name="task-invocation", severity="error",
297
+ context=stripped,
298
+ ))
299
+
300
+ return violations
301
+
302
+
303
+ # ── Direct script-invocation detector ───────────────────────────────────
304
+ # Artefacts shipped to consumers must use the `./agent-config` CLI for
305
+ # commands it already covers. Direct `python3 scripts/…` / `bash scripts/…`
306
+ # invocations only work inside the package repo, not in a consumer project
307
+ # where the scripts live under node_modules/ or vendor/.
308
+ #
309
+ # Each entry: (regex, suggested replacement). Patterns match inside inline
310
+ # backticks OR anywhere on a code-fence line.
311
+ _CLI_INVOCATION_MAP: list[tuple[re.Pattern, str]] = [
312
+ (
313
+ re.compile(r"python3\s+scripts/mcp_render\.py\s+--check\b"),
314
+ "./agent-config mcp:check",
315
+ ),
316
+ (
317
+ re.compile(r"python3\s+scripts/mcp_render\.py\b"),
318
+ "./agent-config mcp:render",
319
+ ),
320
+ (
321
+ re.compile(r"python3\s+\.(?:agent-src|augment)/scripts/update_roadmap_progress\.py\s+--check\b"),
322
+ "./agent-config roadmap:progress-check",
323
+ ),
324
+ (
325
+ re.compile(r"python3\s+\.(?:agent-src|augment)/scripts/update_roadmap_progress\.py\b"),
326
+ "./agent-config roadmap:progress",
327
+ ),
328
+ (
329
+ re.compile(r"bash\s+scripts/first-run\.sh\b"),
330
+ "./agent-config first-run",
331
+ ),
332
+ ]
333
+
334
+ # Paths that legitimately document the raw invocations (e.g. the CLI's
335
+ # own help, the portability rule that defines the mapping).
336
+ _CLI_DETECTOR_SKIP = (
337
+ "rules/augment-portability.md",
338
+ )
339
+
340
+
341
+ def check_cli_invocations(filepath: Path) -> List[Violation]:
342
+ """Flag direct script invocations that should go through `./agent-config`."""
343
+ violations: List[Violation] = []
344
+ try:
345
+ lines = filepath.read_text(encoding="utf-8").splitlines()
346
+ except Exception:
347
+ return violations
348
+
349
+ in_code_block = False
350
+ for i, line in enumerate(lines, 1):
351
+ stripped = line.strip()
352
+ if stripped.startswith("```"):
353
+ in_code_block = not in_code_block
354
+ continue
355
+
356
+ # In prose lines, only check content inside inline `...` spans to
357
+ # avoid false positives in running text. In code fences, check the
358
+ # whole line.
359
+ if in_code_block:
360
+ segments = [line]
361
+ else:
362
+ segments = re.findall(r"`([^`]+)`", line)
363
+
364
+ for seg in segments:
365
+ for pattern, replacement in _CLI_INVOCATION_MAP:
366
+ m = pattern.search(seg)
367
+ if m:
368
+ violations.append(Violation(
369
+ file=str(filepath), line=i, match=m.group(0),
370
+ pattern_name=f"cli-bypass → use `{replacement}`",
371
+ severity="error", context=stripped,
372
+ ))
373
+ break # one hit per segment is enough
374
+
375
+ return violations
376
+
377
+
251
378
  def scan_all(root: Path) -> tuple[List[Violation], list[str]]:
252
379
  """Scan all package files for portability violations. Returns (violations, detected_identifiers).
253
380
 
254
- Scanning has two layers:
381
+ Scanning has four layers:
255
382
  1. Auto-detected identifiers — applied to `.agent-src/` and
256
383
  `.agent-src.uncompressed/` only. The package's own root AGENTS.md and
257
384
  copilot-instructions.md are meta docs ABOUT the package, so the
@@ -259,6 +386,13 @@ def scan_all(root: Path) -> tuple[List[Violation], list[str]]:
259
386
  2. Optional FORBIDDEN_IDENTIFIERS from AGENT_CONFIG_BLOCKLIST —
260
387
  applied to every scanned file, including the root files. Catches
261
388
  leakage from renamed or adjacent projects in downstream forks.
389
+ 3. `task <name>` invocations inside artefact subdirs — skills, rules,
390
+ commands, guidelines, personas, contexts. These shipped artefacts
391
+ run in consumer projects that may not have Taskfile installed.
392
+ 4. Direct script invocations that bypass the `./agent-config` CLI
393
+ (e.g. `python3 scripts/mcp_render.py`). Same artefact-subdir scope
394
+ as layer 3; consumer projects only have the package under
395
+ `node_modules/` or `vendor/`, so the raw paths never resolve.
262
396
  """
263
397
  patterns, detected = _compile_patterns(root)
264
398
  forbidden = _compile_forbidden_patterns()
@@ -279,6 +413,22 @@ def scan_all(root: Path) -> tuple[List[Violation], list[str]]:
279
413
  if f.is_file():
280
414
  violations.extend(check_file(f, forbidden, allowlist))
281
415
 
416
+ # Layer 3 + 4: artefact-subdir-only scans (task invocations, CLI bypass)
417
+ for scan_dir in SCAN_DIRS:
418
+ base = root / scan_dir
419
+ if not base.exists():
420
+ continue
421
+ for sub in ARTIFACT_SUBDIRS:
422
+ d = base / sub
423
+ if not d.exists():
424
+ continue
425
+ for f in sorted(d.rglob("*.md")):
426
+ path_str = str(f)
427
+ if not any(path_str.endswith(skip) for skip in _TASK_DETECTOR_SKIP):
428
+ violations.extend(check_task_invocations(f))
429
+ if not any(path_str.endswith(skip) for skip in _CLI_DETECTOR_SKIP):
430
+ violations.extend(check_cli_invocations(f))
431
+
282
432
  return violations, detected
283
433
 
284
434