@deftai/directive-content 0.59.0 → 0.61.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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
package/tasks/project.yml CHANGED
@@ -9,6 +9,12 @@ vars:
9
9
  # own repo root when invoked via ``includes:`` -- consumers got their data
10
10
  # scanned from ``deft/vbrief/``. Switched to ``{{.USER_WORKING_DIR}}`` to
11
11
  # match the convention used by roadmap:render / spec:render / migrate:vbrief.
12
+ #
13
+ # Staleness acknowledgement (#640): ``task project:ack-staleness`` records a
14
+ # completed-scope watermark on PROJECT-DEFINITION ``plan.metadata.staleness_review``
15
+ # so ``task project:render`` stops re-emitting the same narrative staleness flags
16
+ # after review. This is distinct from ``task reconcile:issues``, which reconciles
17
+ # origin freshness on scope vBRIEFs — not PROJECT-DEFINITION narrative heuristics.
12
18
 
13
19
  tasks:
14
20
  _ts-build:
@@ -25,3 +31,23 @@ tasks:
25
31
  - _ts-build
26
32
  cmds:
27
33
  - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" project-render "{{.USER_WORKING_DIR}}/vbrief"
34
+
35
+ ack-staleness:
36
+ desc: >-
37
+ Acknowledge reviewed PROJECT-DEFINITION narrative staleness (#640).
38
+ Distinct from task reconcile:issues (scope origin freshness).
39
+ dir: '{{.USER_WORKING_DIR}}'
40
+ deps:
41
+ - _ts-build
42
+ cmds:
43
+ - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" project-render --acknowledge-staleness "{{.USER_WORKING_DIR}}/vbrief"
44
+
45
+ export-spec:
46
+ desc: >-
47
+ Export SPECIFICATION.md from PROJECT-DEFINITION (+ lifecycle scopes).
48
+ Use --audience=internal to include proposed scopes (#2013 / #1502).
49
+ dir: '{{.USER_WORKING_DIR}}'
50
+ deps:
51
+ - _ts-build
52
+ cmds:
53
+ - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" export-spec {{.CLI_ARGS}}
@@ -5,9 +5,19 @@ vars:
5
5
 
6
6
  tasks:
7
7
  check:
8
- desc: Verify required toolchain is installed (go, uv, git, gh, node, pnpm)
9
- deps: [":ts:build"]
10
- env:
11
- PYTHONUTF8: "1"
8
+ desc: Verify required maintainer toolchain is installed (go, uv, git, gh, node, pnpm)
9
+ deps:
10
+ - task: :engine:_ts-build
12
11
  cmds:
13
- - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" toolchain:check
12
+ - task: :engine:invoke
13
+ vars:
14
+ ENGINE_CMD: 'toolchain-check'
15
+
16
+ check-consumer:
17
+ desc: Verify required consumer toolchain is installed (git, gh, node, pnpm, task) without Python/go/uv (#2022 Phase 3).
18
+ deps:
19
+ - task: :engine:_ts-build
20
+ cmds:
21
+ - task: :engine:invoke
22
+ vars:
23
+ ENGINE_CMD: 'toolchain-check --consumer'
package/tasks/vbrief.yml CHANGED
@@ -15,10 +15,11 @@ tasks:
15
15
  desc: Validate vBRIEF lifecycle folder structure and cross-file consistency
16
16
  dir: '{{.USER_WORKING_DIR}}'
17
17
  deps:
18
- - _ts-build
18
+ - task: :engine:_ts-build
19
19
  cmds:
20
- # Oracle/fallback (parity): scripts/vbrief_validate.py (#1828 Wave 8).
21
- - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" vbrief:validate --vbrief-dir "{{.USER_WORKING_DIR}}/vbrief"
20
+ - task: :engine:invoke
21
+ vars:
22
+ ENGINE_CMD: 'vbrief:validate --vbrief-dir "{{.USER_WORKING_DIR}}/vbrief"'
22
23
 
23
24
  preflight:
24
25
  # Implementation-intent preflight wrapper (#810 / PR #812 fixup;
package/tasks/verify.yml CHANGED
@@ -70,15 +70,11 @@ tasks:
70
70
  desc: "Detection-bound branch-protection gate (#747). Reads plan.policy.allowDirectCommitsToMaster from PROJECT-DEFINITION."
71
71
  dir: '{{.USER_WORKING_DIR}}'
72
72
  deps:
73
- - _ts-build
73
+ - task: :engine:_ts-build
74
74
  cmds:
75
- # Oracle/fallback (parity): scripts/preflight_branch.py (#1828 Wave 8).
76
- # `--allow-missing-project-definition` keeps the framework's own check
77
- # green for fresh checkouts that have not yet run `task setup` (the
78
- # bootstrap fallback path documented in #746 acceptance criterion E).
79
- # Production projects with PROJECT-DEFINITION present will still get
80
- # the full enforcement -- the flag is a no-op when the file exists.
81
- - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" verify:branch --project-root "{{.USER_WORKING_DIR}}" --allow-missing-project-definition
75
+ - task: :engine:invoke
76
+ vars:
77
+ ENGINE_CMD: 'verify:branch --project-root "{{.USER_WORKING_DIR}}" --allow-missing-project-definition'
82
78
 
83
79
  routing:
84
80
  desc: "Operator coding sub-agent model routing gate (#1739). Pre-dispatch (default): fails when a dispatched worker role has no decision in .deft/routing.local.json. Pass --advise for the non-blocking session-start disclosure; --roles a,b to widen the gated set; --provider to override the runtime."
@@ -145,7 +141,7 @@ tasks:
145
141
  desc: "Pre-`start_agent` cache-freshness gate (#1127). Refuses implementation dispatch when the triage cache is stale, missing, or the target issue's latest decision is not `accept`. Subscription-aware via plan.policy.triageScope[] (D12 / #1131). Flags: --for-issue N / --max-age-hours N / --allow-stale / --repo OWNER/NAME / --allow-missing-bootstrap (consumed by the framework's own `task check` so a fresh checkout passes -- consumers leave it OFF)."
146
142
  dir: '{{.USER_WORKING_DIR}}'
147
143
  deps:
148
- - _ts-build
144
+ - task: :engine:_ts-build
149
145
  # Per `conventions/task-caching.md` (#574): NO `sources:` / `generates:`
150
146
  # because the gate forwards user-facing flags via {{.CLI_ARGS}}
151
147
  # (--for-issue / --max-age-hours / --allow-stale / --repo) that
@@ -155,8 +151,9 @@ tasks:
155
151
  # `task triage:bootstrap`; consumer projects leave it off so a
156
152
  # missing cache fails their `task check` loudly.
157
153
  cmds:
158
- # Oracle/fallback (parity): scripts/preflight_cache.py --allow-missing-bootstrap (#1854 s5).
159
- - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" preflight-cache --project-root "{{.USER_WORKING_DIR}}" --allow-missing-bootstrap {{.CLI_ARGS}}
154
+ - task: :engine:invoke
155
+ vars:
156
+ ENGINE_CMD: 'preflight-cache --project-root "{{.USER_WORKING_DIR}}" --allow-missing-bootstrap {{.CLI_ARGS}}'
160
157
 
161
158
  codebase-map-fresh:
162
159
  desc: "Drift gate for the generated .planning/codebase/MAP.md projection (#1595 PR4)."
@@ -276,7 +273,7 @@ tasks:
276
273
  desc: "Pre-merge re-validation that pending/+active/ count is within plan.policy.wipCap (#1124 / D4 of #1119). Catches stale-branch merges + --force overrides. Default cap is 10 per umbrella #1119 Current Shape v3. The framework's own task check passes --allow-over-cap during landing-day overage; consumer projects MUST NOT pass that flag."
277
274
  dir: '{{.USER_WORKING_DIR}}'
278
275
  deps:
279
- - _ts-build
276
+ - task: :engine:_ts-build
280
277
  # Per `conventions/task-caching.md` (#574): NO `sources:` /
281
278
  # `generates:` because the gate forwards user-facing flags via
282
279
  # {{.CLI_ARGS}} (--allow-over-cap / --quiet) that go-task's
@@ -288,5 +285,6 @@ tasks:
288
285
  # OFF so a stale-branch / --force-merge over-cap state fails their
289
286
  # `task check` loudly.
290
287
  cmds:
291
- # Oracle/fallback (parity): scripts/preflight_wip_cap.py (#1828 Wave 8).
292
- - node "{{.DEFT_ROOT}}/packages/cli/dist/bin.js" verify:wip-cap --project-root "{{.USER_WORKING_DIR}}" {{.CLI_ARGS}}
288
+ - task: :engine:invoke
289
+ vars:
290
+ ENGINE_CMD: 'verify:wip-cap --project-root "{{.USER_WORKING_DIR}}" {{.CLI_ARGS}}'
@@ -134,7 +134,7 @@ Three consumer-facing surfaces enforce the branch-policy contract (#746 / #747):
134
134
 
135
135
  - `deft check` -- authoritative consumer pre-commit quality gate. In vendored `.deft/core` installs it runs consumer-safe Deft install/lifecycle gates and does NOT run framework source-repo self-tests. Run `deft check:framework-source` only when explicitly validating the vendored framework payload itself (#1519).
136
136
  - `deft verify:branch` -- branch gate wired into the `deft check` aggregate; refuses a commit on the default branch unless `plan.policy.allowDirectCommitsToMaster = true` (typed) or `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` is set.
137
- - `.githooks/pre-commit` / `pre-push` -- local hooks installed via `deft setup`; verify via `deft verify:hooks-installed`.
137
+ - `.githooks/pre-commit` / `pre-push` -- local hooks installed via `deft setup`; verify via `deft verify:hooks-installed`. After a framework upgrade, run `deft update` to refresh hook templates to the current TS-native `deft verify:*` / `deft preflight-gh` wiring (#2049).
138
138
  - `deft policy:show --field=allowDirectCommitsToMaster` -- inspect the resolved policy; `deft policy:allow-direct-commits -- --confirm` writes the typed override with an audit row.
139
139
 
140
140
  ## Branch Policy Disclosure (#746)
@@ -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
- }