@deftai/directive-content 0.58.0 → 0.60.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 (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,494 +0,0 @@
1
- #!/usr/bin/env python3
2
- """scripts/_agents_md.py -- shared AGENTS.md managed-section helpers (#1389).
3
-
4
- Single source of truth for the AGENTS.md managed-section parse / render /
5
- freshness-plan logic. Extracted verbatim from the ``run`` module so BOTH
6
- ``run`` (the CLI entry point) and ``scripts/doctor.py`` (the canonical
7
- doctor) can import the same implementation instead of duplicating it.
8
-
9
- Why this module exists
10
- ----------------------
11
- After the Epic-1 doctor carve (#1335 / #1336) ``scripts/doctor.py`` became
12
- the owner of doctor core logic, but the AGENTS.md managed-section helpers it
13
- needs to compute a freshness verdict still lived in ``run``. The doctor
14
- module could not import them cleanly (``run`` has heavy import-time side
15
- effects -- rich / prompt_toolkit / textual probes), so
16
- ``_agents_refresh_plan`` was left as an interim stub that always reported
17
- ``{"state": "unreadable"}`` and produced a spurious AGENTS.md-freshness
18
- warning on every consumer ``task doctor`` run (#1389).
19
-
20
- This module is intentionally PURE: stdlib-only, no rich / prompt_toolkit /
21
- textual, and NO side effects at import time. Importing it from either rail
22
- is therefore safe and cheap.
23
-
24
- The marker contract mirrors ``run``: v1 (#1044 v0.26), v2 (#992 PR1 v0.27)
25
- and v3 (#1046 PR-B, with ``sha`` / ``refreshed`` / ``session`` provenance
26
- attributes on the open tag). ``cmd_agents_refresh`` stamps the v3 attributes
27
- at write time; the staleness classifier normalises the open tag to the bare
28
- v3 form before byte-comparing so the per-refresh attributes never poison
29
- idempotency.
30
-
31
- Story: #1389 (follow-up to #1335 / #1336 doctor carve; refs #1308 / #1309).
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- import re
37
- import subprocess
38
- import sys
39
- import uuid
40
- from collections.abc import Callable
41
- from datetime import UTC, datetime
42
- from pathlib import Path
43
-
44
- sys.path.insert(0, str(Path(__file__).resolve().parent))
45
-
46
- from _content_root import content_root # noqa: E402
47
-
48
- # --- Managed-section marker contract (verbatim from run) -------------------
49
- #
50
- # v1 was the v0.26 marker (``<!-- deft:managed-section v1 -->``). v2 was the
51
- # v0.27 marker (#992 PR1). v3 is the #1046 PR-B marker that carries refresh
52
- # provenance as attributes on the open tag: ``<!-- deft:managed-section v3
53
- # sha=<framework-sha> refreshed=<iso> session=<id> -->``. The parser accepts
54
- # v1, v2 AND v3 (with or without attributes) so a consumer whose AGENTS.md is
55
- # bracketed by a legacy v1/v2 marker classifies as ``stale`` and the
56
- # bracketed block is byte-replaced in place by the current v3 render (#1044)
57
- # -- never appended as a second managed block.
58
- _AGENTS_MANAGED_OPEN = "<!-- deft:managed-section v3 -->"
59
- _AGENTS_MANAGED_OPEN_V2_LITERAL = "<!-- deft:managed-section v2 -->"
60
- _AGENTS_MANAGED_OPEN_V3_LITERAL = "<!-- deft:managed-section v3 -->"
61
- _AGENTS_MANAGED_CLOSE = "<!-- /deft:managed-section -->"
62
-
63
- # Accepts v1, v2 and v3 (with-or-without attributes). Group 1 = version
64
- # (1, 2 or 3). Group 2 = the raw attribute string or '' when no attributes
65
- # are present. v1 acceptance (#1044) is the load-bearing fix that routes a
66
- # legacy marker through the in-place byte-replace path instead of the
67
- # legacy-wrap append path.
68
- _AGENTS_MANAGED_OPEN_RE = re.compile(
69
- r"<!--\s*deft:managed-section\s+v(1|2|3)(?:\s+([^>]*?))?\s*-->"
70
- )
71
-
72
- # Recognised attribute keys on the v3 marker. Extra keys are tolerated
73
- # (parsed into ``extras``) so a future minor extension does not require a
74
- # marker rebump; absence of any recognised key is also tolerated (the bare
75
- # ``v3`` form is the canonical template-shipped marker; attributes are
76
- # stamped at refresh time by ``cmd_agents_refresh``).
77
- _AGENTS_MANAGED_V3_ATTR_KEYS: tuple[str, ...] = ("sha", "refreshed", "session")
78
-
79
-
80
- # --- Framework-root + template resolution ----------------------------------
81
-
82
-
83
- def framework_root() -> Path:
84
- """Return the framework root (the directory that owns ``templates/``).
85
-
86
- This module lives at ``<framework-root>/scripts/_agents_md.py`` in both
87
- the source checkout and a consumer install (``<deftDir>/scripts/``), so
88
- the framework root is two parents up. Mirrors ``run::get_script_dir()``
89
- (which returns the directory containing ``run`` at the framework root).
90
- """
91
- return Path(__file__).resolve().parent.parent
92
-
93
-
94
- def _agents_template_path() -> Path:
95
- """Return the absolute path to the canonical AGENTS.md template."""
96
- return content_root(framework_root()) / "templates" / "agents-entry.md"
97
-
98
-
99
- def _read_agents_template() -> str | None:
100
- """Return the AGENTS.md template text, or None when not readable.
101
-
102
- The Go installer embeds the same file via ``//go:embed`` in
103
- ``templates/embed.go``; the Python rail reads it from disk at runtime so
104
- ``cmd_agents_refresh`` works against the live framework checkout.
105
- """
106
- candidate = _agents_template_path()
107
- if not candidate.is_file():
108
- return None
109
- try:
110
- return candidate.read_text(encoding="utf-8")
111
- except OSError:
112
- return None
113
-
114
-
115
- # --- Managed-section parse / render helpers ---------------------------------
116
-
117
-
118
- def _find_managed_open_marker(text: str) -> re.Match | None:
119
- """Return the regex match for the open marker (v1, v2 OR v3), or None."""
120
- return _AGENTS_MANAGED_OPEN_RE.search(text)
121
-
122
-
123
- def _iter_managed_sections(text: str) -> list[tuple]:
124
- """Yield ``(start, end, block)`` triples for every managed section (#1044).
125
-
126
- Walks ``text`` left-to-right collecting every well-formed
127
- ``<open>...<close>`` region. ``start`` is the open marker's first byte
128
- index, ``end`` is the byte index just past the close marker, and
129
- ``block`` is the corresponding text slice. Used by
130
- ``_agents_refresh_plan`` to collapse the duplicate-block recovery case
131
- (a v1 marker coexisting with a v3 marker because a partial upgrade ran
132
- the append path before this fix) into a single v3 block at the position
133
- of the first managed section -- preserving surrounding user content
134
- order.
135
- """
136
- results: list[tuple] = []
137
- pos = 0
138
- while pos <= len(text):
139
- open_match = _AGENTS_MANAGED_OPEN_RE.search(text, pos)
140
- if open_match is None:
141
- break
142
- close_idx = text.find(_AGENTS_MANAGED_CLOSE, open_match.end())
143
- if close_idx < 0:
144
- break
145
- end = close_idx + len(_AGENTS_MANAGED_CLOSE)
146
- results.append((open_match.start(), end, text[open_match.start():end]))
147
- pos = end
148
- return results
149
-
150
-
151
- def _parse_managed_section_attrs(extracted: str) -> dict | None:
152
- """Parse the open-marker attributes from an extracted managed section.
153
-
154
- Returns a dict with keys ``version`` (int, 1, 2 or 3), ``sha`` (str or
155
- None), ``refreshed`` (ISO 8601 str or None), ``session`` (str or None),
156
- and ``extras`` (dict of unrecognised ``key=value`` pairs). Returns None
157
- when ``extracted`` does not match the open marker regex. v1 markers
158
- (#1044 back-compat) parse with ``version=1`` and ``None`` for every
159
- provenance attribute -- v1 never carried attributes.
160
-
161
- Attribute syntax is ``key=value`` separated by whitespace. Quoted values
162
- (``key='value'``, ``key="value"``) are unwrapped automatically. Unknown
163
- keys are captured in ``extras`` rather than silently dropped.
164
- """
165
- match = _find_managed_open_marker(extracted)
166
- if match is None:
167
- return None
168
- version = int(match.group(1))
169
- attrs_raw = match.group(2) or ""
170
- result: dict = {
171
- "version": version,
172
- "sha": None,
173
- "refreshed": None,
174
- "session": None,
175
- "extras": {},
176
- }
177
- for raw_pair in attrs_raw.split():
178
- if "=" not in raw_pair:
179
- continue
180
- key, _, value = raw_pair.partition("=")
181
- key = key.strip().lower()
182
- value = value.strip().strip("'\"")
183
- if not key:
184
- continue
185
- if key in _AGENTS_MANAGED_V3_ATTR_KEYS:
186
- result[key] = value
187
- else:
188
- result["extras"][key] = value
189
- return result
190
-
191
-
192
- def _strip_managed_section_attrs(section: str) -> str:
193
- """Normalise the open marker to the bare v3 form (#1046 PR-B, #1044).
194
-
195
- Replaces any legacy v1 / v2 / attributed-v3 open marker with the bare
196
- ``<!-- deft:managed-section v3 -->`` literal so byte-equality comparisons
197
- against the rendered template are not poisoned by per-refresh ``sha`` /
198
- ``refreshed`` / ``session`` tokens. Only the FIRST open marker is
199
- normalised. Pure -- no I/O.
200
- """
201
- return _AGENTS_MANAGED_OPEN_RE.sub(
202
- _AGENTS_MANAGED_OPEN_V3_LITERAL, section, count=1
203
- )
204
-
205
-
206
- def _render_managed_section(template_text: str) -> str | None:
207
- """Extract the deft:managed-section block from the template.
208
-
209
- Returns the byte sequence (newlines normalised to ``\\n``) bracketed by
210
- the open/close markers, INCLUSIVE, with the open marker normalised to the
211
- bare v3 form (the canonical staleness-comparison baseline). Returns None
212
- when either marker is missing.
213
-
214
- Placeholder substitution is intentionally a no-op: the documented tokens
215
- (``{{UPSTREAM_SHA}}``, etc.) are inherited from the webinstaller
216
- pin-marker contract and rendered there. Leaving them as literal text
217
- keeps ``--check`` byte-stable when the framework is checked out without
218
- git metadata.
219
- """
220
- normalised = template_text.replace("\r\n", "\n")
221
- open_match = _find_managed_open_marker(normalised)
222
- if open_match is None:
223
- return None
224
- open_idx = open_match.start()
225
- close_idx = normalised.find(_AGENTS_MANAGED_CLOSE, open_match.end())
226
- if close_idx < 0:
227
- return None
228
- end = close_idx + len(_AGENTS_MANAGED_CLOSE)
229
- block = normalised[open_idx:end]
230
- return _strip_managed_section_attrs(block)
231
-
232
-
233
- def _extract_managed_section(text: str) -> str | None:
234
- """Pull the managed-section block out of the consumer's AGENTS.md text.
235
-
236
- Returns the bracketed block (normalised to LF, marker bytes preserved
237
- verbatim including any v3 ``sha=/refreshed=/session=`` attributes) or
238
- None if either marker is absent. Accepts BOTH the legacy v2 and the
239
- canonical v3 open markers (#1046 PR-B back-compat parser).
240
- """
241
- normalised = text.replace("\r\n", "\n")
242
- open_match = _find_managed_open_marker(normalised)
243
- if open_match is None:
244
- return None
245
- open_idx = open_match.start()
246
- close_idx = normalised.find(_AGENTS_MANAGED_CLOSE, open_match.end())
247
- if close_idx < 0:
248
- return None
249
- end = close_idx + len(_AGENTS_MANAGED_CLOSE)
250
- return normalised[open_idx:end]
251
-
252
-
253
- # --- Framework SHA + session id + timestamps -------------------------------
254
-
255
-
256
- def _now_utc() -> datetime:
257
- """Return UTC-aware ``datetime.now`` (split out for test monkeypatching)."""
258
- return datetime.now(UTC)
259
-
260
-
261
- def _now_utc_iso() -> str:
262
- """UTC ISO-8601 timestamp at seconds precision."""
263
- return _now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
264
-
265
-
266
- def _resolve_framework_sha() -> str:
267
- """Return the current framework checkout's HEAD sha (short form).
268
-
269
- Resolution: ``git rev-parse --short=12 HEAD`` rooted at the framework
270
- root. Falls back to ``unknown`` on subprocess failure (git missing /
271
- non-git checkout / hook permission error). Best-effort -- the v3 marker
272
- tolerates the fallback string verbatim so refresh remains idempotent
273
- across environments lacking git metadata.
274
- """
275
- script_dir = str(framework_root())
276
- try:
277
- result = subprocess.run(
278
- ["git", "rev-parse", "--short=12", "HEAD"],
279
- capture_output=True,
280
- text=True,
281
- timeout=5,
282
- check=False,
283
- cwd=script_dir,
284
- )
285
- except (subprocess.TimeoutExpired, OSError):
286
- return "unknown"
287
- if result.returncode != 0:
288
- return "unknown"
289
- sha = (result.stdout or "").strip()
290
- return sha or "unknown"
291
-
292
-
293
- def _new_session_id() -> str:
294
- """Return a freshly-synthesised 12-char session id (#1046 PR-B AC-5)."""
295
- return uuid.uuid4().hex[:12]
296
-
297
-
298
- def _attribute_render_managed_section(
299
- rendered: str,
300
- *,
301
- framework_sha: str,
302
- refreshed: str,
303
- session_id: str,
304
- ) -> str:
305
- """Inject v3 attributes into a bare-rendered managed section (#1046 PR-B AC-5).
306
-
307
- Takes the byte-stable ``rendered`` block (open marker = bare
308
- ``<!-- deft:managed-section v3 -->``) emitted by
309
- ``_render_managed_section`` and produces the attribute-rich form
310
- consumers write to disk::
311
-
312
- <!-- deft:managed-section v3 sha=<sha> refreshed=<iso> session=<id> -->
313
-
314
- Only the open marker is mutated -- the body bytes between the open/close
315
- markers are preserved verbatim so subsequent staleness classification
316
- (after attribute stripping) returns ``current``.
317
- """
318
- attr_string = f"v3 sha={framework_sha} refreshed={refreshed} session={session_id}"
319
- attributed_open = f"<!--{' '}deft:managed-section {attr_string} -->"
320
- return rendered.replace(_AGENTS_MANAGED_OPEN_V3_LITERAL, attributed_open, 1)
321
-
322
-
323
- def _wrap_legacy_in_markers(existing: str, rendered: str) -> str:
324
- """Produce the once-per-project legacy-to-marker migration body.
325
-
326
- The consumer's existing pre-marker AGENTS.md content is preserved
327
- verbatim ABOVE the new managed section -- so user notes outside the deft
328
- block survive the migration. The rendered managed-section block is
329
- appended (with a blank-line separator) so subsequent refreshes can
330
- operate on the bracketed region in place.
331
- """
332
- body = existing.replace("\r\n", "\n").rstrip("\n")
333
- if body:
334
- return body + "\n\n" + rendered + "\n"
335
- return rendered + "\n"
336
-
337
-
338
- # --- Refresh-plan verdict --------------------------------------------------
339
-
340
-
341
- def _agents_refresh_plan(
342
- project_root: Path,
343
- *,
344
- read_template: Callable[[], str | None] | None = None,
345
- resolve_sha: Callable[[], str] | None = None,
346
- now_iso: Callable[[], str] | None = None,
347
- new_session: Callable[[], str] | None = None,
348
- ) -> dict:
349
- """Compute the plan ``cmd_agents_refresh`` would apply (no I/O writes).
350
-
351
- The plan dict reports a ``state`` (``current`` / ``stale`` / ``missing``
352
- / ``absent`` / ``unreadable`` / ``template-missing`` /
353
- ``template-malformed``) and the byte-content the command would write on a
354
- non-current state. The stale / absent / missing payloads carry the
355
- v3-attributed marker stamped with a fresh sha / refreshed / session
356
- triple so each refresh records its own session lineage (#1046 PR-B
357
- AC-5). The staleness check itself ignores those attributes -- both the
358
- extracted block and the rendered template are normalised to the bare
359
- ``v3`` marker before byte-comparing, so re-running refresh on a current
360
- file is a no-op.
361
-
362
- The four ``read_template`` / ``resolve_sha`` / ``now_iso`` /
363
- ``new_session`` seams are injectable so ``run`` can route its own
364
- (monkeypatchable) helpers through the shared implementation while
365
- ``scripts/doctor.py`` calls it with the module defaults. They default to
366
- this module's own pure helpers when omitted (#1389).
367
- """
368
- _read = read_template or _read_agents_template
369
- _sha = resolve_sha or _resolve_framework_sha
370
- _now = now_iso or _now_utc_iso
371
- _session = new_session or _new_session_id
372
-
373
- template_text = _read()
374
- if template_text is None:
375
- return {
376
- "state": "template-missing",
377
- "path": str(project_root / "AGENTS.md"),
378
- "rendered": None,
379
- "existing": None,
380
- "new_content": None,
381
- }
382
- rendered = _render_managed_section(template_text)
383
- if rendered is None:
384
- return {
385
- "state": "template-malformed",
386
- "path": str(project_root / "AGENTS.md"),
387
- "rendered": None,
388
- "existing": None,
389
- "new_content": None,
390
- }
391
- framework_sha = _sha()
392
- refreshed = _now()
393
- session_id = _session()
394
- attributed_rendered = _attribute_render_managed_section(
395
- rendered,
396
- framework_sha=framework_sha,
397
- refreshed=refreshed,
398
- session_id=session_id,
399
- )
400
- agents_md = project_root / "AGENTS.md"
401
- if not agents_md.is_file():
402
- return {
403
- "state": "absent",
404
- "path": str(agents_md),
405
- "rendered": rendered,
406
- "attributed_rendered": attributed_rendered,
407
- "sha": framework_sha,
408
- "refreshed": refreshed,
409
- "session": session_id,
410
- "existing": None,
411
- "new_content": attributed_rendered + "\n",
412
- }
413
- try:
414
- existing = agents_md.read_text(encoding="utf-8", errors="replace")
415
- except OSError as exc:
416
- return {
417
- "state": "unreadable",
418
- "path": str(agents_md),
419
- "rendered": rendered,
420
- "existing": None,
421
- "new_content": None,
422
- "error": str(exc),
423
- }
424
- normalised = existing.replace("\r\n", "\n")
425
- blocks = _iter_managed_sections(normalised)
426
- if not blocks:
427
- # Legacy file with no markers -- one-time migration: wrap the
428
- # existing content + render the managed-section beneath it.
429
- new_content = _wrap_legacy_in_markers(normalised, attributed_rendered)
430
- return {
431
- "state": "missing",
432
- "path": str(agents_md),
433
- "rendered": rendered,
434
- "attributed_rendered": attributed_rendered,
435
- "sha": framework_sha,
436
- "refreshed": refreshed,
437
- "session": session_id,
438
- "existing": existing,
439
- "new_content": new_content,
440
- }
441
- if len(blocks) > 1:
442
- # Duplicate-block recovery (#1044): collapse to a single
443
- # v3-attributed block at the position of the FIRST block so
444
- # surrounding user content order is preserved. Walk in reverse to
445
- # keep slice indices valid as we remove each block.
446
- first_start = blocks[0][0]
447
- new_content = normalised
448
- for start, end, _ in reversed(blocks):
449
- new_content = new_content[:start] + new_content[end:]
450
- new_content = (
451
- new_content[:first_start]
452
- + attributed_rendered
453
- + new_content[first_start:]
454
- )
455
- return {
456
- "state": "stale",
457
- "path": str(agents_md),
458
- "rendered": rendered,
459
- "attributed_rendered": attributed_rendered,
460
- "sha": framework_sha,
461
- "refreshed": refreshed,
462
- "session": session_id,
463
- "existing": existing,
464
- "new_content": new_content,
465
- }
466
- # Single managed block -- the canonical refresh path.
467
- extracted = blocks[0][2]
468
- # Force-upgrade v1 / v2 -> v3 even when body bytes match.
469
- extracted_attrs = _parse_managed_section_attrs(extracted)
470
- is_legacy_marker = (
471
- extracted_attrs is not None and extracted_attrs["version"] in (1, 2)
472
- )
473
- if not is_legacy_marker and _strip_managed_section_attrs(extracted) == rendered:
474
- return {
475
- "state": "current",
476
- "path": str(agents_md),
477
- "rendered": rendered,
478
- "existing": existing,
479
- "new_content": existing,
480
- }
481
- # Stale: byte-replace the bracketed block in place with the
482
- # v3-attributed rendered block.
483
- new_content = normalised.replace(extracted, attributed_rendered, 1)
484
- return {
485
- "state": "stale",
486
- "path": str(agents_md),
487
- "rendered": rendered,
488
- "attributed_rendered": attributed_rendered,
489
- "sha": framework_sha,
490
- "refreshed": refreshed,
491
- "session": session_id,
492
- "existing": existing,
493
- "new_content": new_content,
494
- }