@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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- 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
|
|
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/`,
|
|
62
|
-
`.deft/core/` is
|
|
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.
|
|
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
|
|
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
|
+
}
|