@deftai/directive-content 0.55.2 → 0.56.1

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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
package/UPGRADING.md CHANGED
@@ -27,7 +27,53 @@ If your current install uses the frozen Go installer (`deft-install`), migrate o
27
27
  3. In your project, run `directive agents:refresh` to update AGENTS.md with the npm-based managed section.
28
28
  4. Verify with `directive doctor` — the install-integrity check confirms the npm payload is current.
29
29
 
30
- The frozen Go installer remains available at [GitHub Releases](https://github.com/deftai/directive/releases) as a no-Node fallback but receives no further updates (#1912). After this one-time step, `npm i -g @deftai/directive@latest` is the only upgrade command you need.
30
+ The frozen Go installer remains available at [GitHub Releases](https://github.com/deftai/directive/releases) as a legacy / offline bridge but receives no further updates (#1912); Node ≥ 20 is still required to run Deft afterward. After this one-time step, `npm i -g @deftai/directive@latest` is the only upgrade command you need.
31
+
32
+ ---
33
+
34
+ ## Legacy layout refused by the npm CLI (#1912)
35
+
36
+ If you run `npx @deftai/directive init` (or `update`) on a project that still
37
+ uses a **legacy on-disk layout**, the npm CLI **refuses** and exits non-zero
38
+ **without depositing or refreshing anything**. The npm path never migrates a
39
+ legacy layout; the frozen final Go installer is the one-and-only migration
40
+ bridge. This is the run-from-npm, use-time gate that backs the one-time
41
+ migration above.
42
+
43
+ **Legacy layouts the npm CLI refuses:**
44
+
45
+ - a git-clone or git-submodule deposit of the framework;
46
+ - a legacy `deft/`-prefixed install root (the canonical root is `.deft/core/`);
47
+ - a pre-v0.27 AGENTS.md with a sentinel-only managed-section (no v2/v3
48
+ managed-section markers);
49
+ - an orphan `.deft/VERSION` manifest with no `.deft/core/` directory.
50
+
51
+ **The two-step recovery (version-neutral):**
52
+
53
+ 1. **Run the frozen final Go bridge installer** to migrate the old layout to the
54
+ canonical `.deft/core/` vendored layout. Download the binary for your
55
+ platform from [GitHub Releases](https://github.com/deftai/directive/releases)
56
+ (see [Legacy and offline install](https://github.com/deftai/directive#legacy-and-offline-install-go-installer-1912))
57
+ and run it from your project directory. The bridge is **frozen** — always
58
+ the latest published release; there is no version to memorise.
59
+ 2. **Re-run the npm path** once the layout is canonical-vendored:
60
+
61
+ ```bash
62
+ npx @deftai/directive init # or: npx @deftai/directive update
63
+ ```
64
+
65
+ After step 1 the layout is `.deft/core/`, so the npm CLI takes over cleanly and
66
+ `npm i -g @deftai/directive@latest` is your only future upgrade command.
67
+
68
+ > **Why a pointer, not a baked command?** The npm CLI, `directive doctor`, and
69
+ > AGENTS.md never bake a Go-installer version number or a literal upgrade command
70
+ > into your installed files. They signpost this stable doc + the Releases page so
71
+ > the bridge always resolves fresh — the upgrade instructions can never go stale
72
+ > inside the artifact being upgraded.
73
+
74
+ `directive doctor` emits the same signpost (a `legacy-layout` check that fails
75
+ with this URL) whenever it detects a legacy layout, so an agent or operator who
76
+ runs the doctor first gets pointed at this exact two-step before touching `init`.
31
77
 
32
78
  ---
33
79
 
package/events/README.md CHANGED
@@ -58,9 +58,9 @@ emission is appended as a single JSON line.
58
58
  for pairing semantics, enforces required-payload contracts, and persists to
59
59
  `<project_root>/.deft-cache/events.jsonl` (or a path injected via `log_path` /
60
60
  `DEFT_EVENT_LOG`). The log lives under the already-gitignored `.deft-cache/`
61
- rather than `.deft/`, which is no longer blanket-gitignored now that
62
- `.deft/core/` is a committed payload (#11 / #1465). Use this helper when
63
- emitting behavioral events from
61
+ rather than `.deft/`, because `.deft/` is not blanket-gitignored on hybrid
62
+ installs (`.deft/core/` is gitignored and reconstituted by `directive init`,
63
+ per #1942 / #1465). Use this helper when emitting behavioral events from
64
64
  skills (`python -m scripts._events emit ...`).
65
65
 
66
66
  ## Adding an event
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@deftai/directive-content",
3
- "version": "0.55.2",
4
- "description": "Shippable Directive framework content in the consumer .deft/core/ layout (C1 flatten). Refs #11, #1669.",
3
+ "version": "0.56.1",
4
+ "description": "Shippable Directive framework content in the consumer .deft/core/ layout (C1 flatten), plus the engine surfaces (.githooks/, Taskfile.yml, tasks/, scripts/) the deposit wires. Refs #11, #1669, #1967.",
5
5
  "type": "module",
6
6
  "files": [
7
- "**/*"
7
+ "**/*",
8
+ ".githooks/"
8
9
  ],
9
10
  "repository": {
10
11
  "type": "git",
@@ -16,7 +17,7 @@
16
17
  "provenance": true
17
18
  },
18
19
  "scripts": {
19
- "prepack": "node --input-type=module -e \"import{cpSync,existsSync,readdirSync,rmSync}from'node:fs';import{dirname,join}from'node:path';import{fileURLToPath}from'node:url';const pkg=dirname(fileURLToPath(import.meta.url));const src=join(pkg,'..','..','content');for(const name of readdirSync(src)){const from=join(src,name);const to=join(pkg,name);if(existsSync(to))rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true});}\"",
20
+ "prepack": "node --input-type=module -e \"import{cpSync,existsSync,readdirSync,rmSync}from'node:fs';import{dirname,join}from'node:path';import{fileURLToPath}from'node:url';const pkg=dirname(fileURLToPath(import.meta.url));const root=join(pkg,'..','..');const keep=(s)=>!s.includes('__pycache__')&&!s.endsWith('.pyc');const src=join(root,'content');for(const name of readdirSync(src)){const from=join(src,name);const to=join(pkg,name);if(existsSync(to))rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,filter:keep});}for(const name of ['.githooks','Taskfile.yml','tasks','scripts']){const from=join(root,name);if(!existsSync(from))continue;const to=join(pkg,name);if(existsSync(to))rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,filter:keep});}\"",
20
21
  "postpack": "node --input-type=module -e \"import{readdirSync,rmSync}from'node:fs';import{dirname,join}from'node:path';import{fileURLToPath}from'node:url';const pkg=dirname(fileURLToPath(import.meta.url));for(const name of readdirSync(pkg)){if(name==='package.json')continue;rmSync(join(pkg,name),{recursive:true,force:true});}\""
21
22
  }
22
23
  }
@@ -0,0 +1,494 @@
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
+ }