@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.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/ts_check_lane.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Node-toolchain-aware TypeScript lane for `task check` (#1530, #1790).
|
|
3
|
-
|
|
4
|
-
`task check` -> `check:framework-source` historically ran only the Python
|
|
5
|
-
suite + gates; the TypeScript engine (biome lint, tsc build, vitest) ran only
|
|
6
|
-
in the dedicated CI job. That split let a TS lint/format/test failure pass a
|
|
7
|
-
contributor's local `task check` and redden CI after push (PR #1780: a worker's
|
|
8
|
-
local gate was green while CI biome failed on unformatted files).
|
|
9
|
-
|
|
10
|
-
This helper closes the gap WITHOUT regressing the documented invariant that
|
|
11
|
-
`check:framework-source` must not hard-require a Node toolchain in Node-less
|
|
12
|
-
environments (the vendored-consumer guard pattern from #1474). When `pnpm` is on
|
|
13
|
-
PATH it runs `pnpm run lint`, `pnpm run build`, and `pnpm run test` in order,
|
|
14
|
-
failing fast on the first non-zero exit. When `pnpm` is absent it prints a clear
|
|
15
|
-
notice and exits 0 -- the TS lane stays validated by the CI job in that case.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import argparse
|
|
21
|
-
import shutil
|
|
22
|
-
import subprocess # noqa: S404 -- fixed, non-shell pnpm invocations only
|
|
23
|
-
import sys
|
|
24
|
-
from collections.abc import Callable, Sequence
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
from typing import Any
|
|
27
|
-
|
|
28
|
-
# Run order is deliberate: lint (cheapest, catches the PR #1780 biome class
|
|
29
|
-
# first), then build, then the test suite.
|
|
30
|
-
LANE_COMMANDS: tuple[tuple[str, ...], ...] = (
|
|
31
|
-
("run", "lint"),
|
|
32
|
-
("run", "build"),
|
|
33
|
-
("run", "test"),
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
SKIP_NOTICE = (
|
|
37
|
-
"[ts:check-lane] pnpm not found on PATH -- skipping the TypeScript lane "
|
|
38
|
-
"(build/lint/test). The TS engine stays validated by the dedicated CI job. "
|
|
39
|
-
"Install the Node toolchain (pnpm) to run the TS lane locally."
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _resolve_pnpm() -> str | None:
|
|
44
|
-
"""Return the pnpm executable path, or None when it is not installed."""
|
|
45
|
-
return shutil.which("pnpm")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def run_ts_lane(
|
|
49
|
-
project_root: Path,
|
|
50
|
-
*,
|
|
51
|
-
pnpm: str | None,
|
|
52
|
-
runner: Callable[..., Any] = subprocess.run,
|
|
53
|
-
out: Callable[[str], Any] = print,
|
|
54
|
-
) -> int:
|
|
55
|
-
"""Run the TS lane when pnpm is available; skip (exit 0) when it is not.
|
|
56
|
-
|
|
57
|
-
`pnpm`, `runner`, and `out` are injected so the guard logic is unit-testable
|
|
58
|
-
without a real Node toolchain or real subprocess execution.
|
|
59
|
-
"""
|
|
60
|
-
if not pnpm:
|
|
61
|
-
out(SKIP_NOTICE)
|
|
62
|
-
return 0
|
|
63
|
-
|
|
64
|
-
for command in LANE_COMMANDS:
|
|
65
|
-
argv: Sequence[str] = (pnpm, *command)
|
|
66
|
-
result = runner(argv, cwd=str(project_root))
|
|
67
|
-
code = getattr(result, "returncode", 0)
|
|
68
|
-
if code != 0:
|
|
69
|
-
out(f"[ts:check-lane] `pnpm {' '.join(command)}` failed (exit {code}).")
|
|
70
|
-
return code
|
|
71
|
-
return 0
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def main(argv: Sequence[str] | None = None) -> int:
|
|
75
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
76
|
-
parser.add_argument(
|
|
77
|
-
"--project-root",
|
|
78
|
-
default=".",
|
|
79
|
-
help="Repo root that owns the pnpm workspace (default: cwd).",
|
|
80
|
-
)
|
|
81
|
-
args = parser.parse_args(argv)
|
|
82
|
-
return run_ts_lane(Path(args.project_root), pnpm=_resolve_pnpm())
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if __name__ == "__main__":
|
|
86
|
-
sys.exit(main())
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Validate internal links in markdown files."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
EXCLUDE_DIRS = {
|
|
9
|
-
".git", "backup", "node_modules", ".venv", "__pycache__", "dist",
|
|
10
|
-
".planning", "specs", # planning docs and test fixtures
|
|
11
|
-
}
|
|
12
|
-
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
|
13
|
-
|
|
14
|
-
# Skip template variables, reference markers, and example-only links
|
|
15
|
-
SKIP_PATTERNS = re.compile(r"[{}@]|^\[|^\./relative-|^path$")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def main() -> int:
|
|
19
|
-
broken = []
|
|
20
|
-
|
|
21
|
-
for md in sorted(Path(".").rglob("*.md")):
|
|
22
|
-
if any(p in md.parts for p in EXCLUDE_DIRS):
|
|
23
|
-
continue
|
|
24
|
-
# Skip history/archive/ files
|
|
25
|
-
if "history" in md.parts and "archive" in md.parts:
|
|
26
|
-
continue
|
|
27
|
-
try:
|
|
28
|
-
text = md.read_text("utf-8", errors="replace")
|
|
29
|
-
for i, line in enumerate(text.splitlines(), 1):
|
|
30
|
-
for m in LINK_RE.finditer(line):
|
|
31
|
-
target = m.group(2)
|
|
32
|
-
# Skip external URLs, anchors, and mailto
|
|
33
|
-
if target.startswith(("http://", "https://", "mailto:", "#")):
|
|
34
|
-
continue
|
|
35
|
-
# Skip template variables and example links
|
|
36
|
-
if SKIP_PATTERNS.search(target):
|
|
37
|
-
continue
|
|
38
|
-
# Strip anchor and query params
|
|
39
|
-
clean = target.split("#")[0].split("?")[0]
|
|
40
|
-
if not clean:
|
|
41
|
-
continue
|
|
42
|
-
resolved = (md.parent / clean).resolve()
|
|
43
|
-
if not resolved.exists():
|
|
44
|
-
broken.append((str(md), i, target))
|
|
45
|
-
except Exception:
|
|
46
|
-
pass
|
|
47
|
-
|
|
48
|
-
strict = os.environ.get("LINK_CHECK_STRICT", "") == "1" or "--strict" in sys.argv
|
|
49
|
-
|
|
50
|
-
if broken:
|
|
51
|
-
mode = "errors" if strict else "warnings"
|
|
52
|
-
print(f"Found {len(broken)} broken internal link(s) ({mode}):")
|
|
53
|
-
for fp, ln, target in broken[:50]:
|
|
54
|
-
print(f" {fp}:{ln} -> {target}")
|
|
55
|
-
if len(broken) > 50:
|
|
56
|
-
print(f" ... and {len(broken) - 50} more")
|
|
57
|
-
return 1 if strict else 0
|
|
58
|
-
|
|
59
|
-
print("All internal markdown links valid")
|
|
60
|
-
return 0
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if __name__ == "__main__":
|
|
64
|
-
sys.exit(main())
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
validate_strategy_output.py -- Deterministic validation gate for
|
|
4
|
-
strategy output shape (v0.20 contract per #1166 s2).
|
|
5
|
-
|
|
6
|
-
Enforces that any vbrief/ tree produced by a spec-generating strategy (yolo, interview,
|
|
7
|
-
speckit, rapid, enterprise, ...) conforms to the canonical v0.20 output contract.
|
|
8
|
-
|
|
9
|
-
See: strategies/v0-20-contract.md (when present) and the parent epic #1166.
|
|
10
|
-
|
|
11
|
-
Rules enforced (hard fail):
|
|
12
|
-
- All scope vBRIEFs under vbrief/proposed/ (and other lifecycle dirs if present) MUST
|
|
13
|
-
use the date-prefixed filename convention: YYYY-MM-DD-<slug>.vbrief.json
|
|
14
|
-
(catches interview-style bare names like "scaffold.vbrief.json").
|
|
15
|
-
- vbrief/PROJECT-DEFINITION.vbrief.json MUST exist (full project identity).
|
|
16
|
-
- vbrief/specification.vbrief.json MUST NOT be present as a strategy-produced artifact
|
|
17
|
-
(legacy dual-write). The framework's own canonical source-of-truth copy is tolerated,
|
|
18
|
-
as is a post-cutover full-spec consumer tree where specification.vbrief.json is the
|
|
19
|
-
canonical source rendered to SPECIFICATION.md and all lifecycle folders exist.
|
|
20
|
-
- If vbrief/ exists, the five standard lifecycle subfolders should be present or the
|
|
21
|
-
strategy must have created them (proposed/ at minimum for emission).
|
|
22
|
-
|
|
23
|
-
Exit codes:
|
|
24
|
-
0 -- conformant (or framework self with tolerated legacy spec.vbrief)
|
|
25
|
-
1 -- non-conformant output shape (prints actionable errors citing the contract)
|
|
26
|
-
2 -- usage / invocation error
|
|
27
|
-
|
|
28
|
-
Usage:
|
|
29
|
-
uv run python scripts/validate_strategy_output.py [--project-root <path>] [--strict]
|
|
30
|
-
|
|
31
|
-
Wired into:
|
|
32
|
-
- `task check` (via root Taskfile.yml)
|
|
33
|
-
- skills/deft-directive-build/SKILL.md Pre-Cutover Detection Guard (generalized)
|
|
34
|
-
- CI matrix (via task check)
|
|
35
|
-
|
|
36
|
-
Story: s2-deterministic-gate under #1166
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
from __future__ import annotations
|
|
40
|
-
|
|
41
|
-
import argparse
|
|
42
|
-
import re
|
|
43
|
-
import sys
|
|
44
|
-
from pathlib import Path
|
|
45
|
-
|
|
46
|
-
# Filename pattern for v0.20-conformant scope vBRIEFs (date-prefixed).
|
|
47
|
-
# Matches the convention in vbrief/vbrief.md and conventions/vbrief-filenames.md
|
|
48
|
-
DATE_PREFIXED_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.vbrief\.json$")
|
|
49
|
-
GENERATED_SPEC_PURPOSE = "<!-- Purpose: rendered specification -->"
|
|
50
|
-
GENERATED_SPEC_SOURCE = "<!-- Source of truth: vbrief/specification.vbrief.json -->"
|
|
51
|
-
LIFECYCLE_DIRS = ("proposed", "pending", "active", "completed", "cancelled")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _is_deft_framework_root(project_root: Path) -> bool:
|
|
55
|
-
"""Heuristic: is this the deft framework source itself?
|
|
56
|
-
(tolerate its specification.vbrief.json as canonical source, not strategy output)
|
|
57
|
-
"""
|
|
58
|
-
return (
|
|
59
|
-
(project_root / "AGENTS.md").exists()
|
|
60
|
-
and (project_root / "Taskfile.yml").exists()
|
|
61
|
-
and (project_root / "strategies").is_dir()
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _read_text_safe(path: Path) -> str:
|
|
66
|
-
try:
|
|
67
|
-
return path.read_text(encoding="utf-8", errors="replace")
|
|
68
|
-
except OSError:
|
|
69
|
-
return ""
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _has_complete_lifecycle(vbrief_dir: Path) -> bool:
|
|
73
|
-
return all((vbrief_dir / folder).is_dir() for folder in LIFECYCLE_DIRS)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _is_post_cutover_full_spec_state(project_root: Path) -> bool:
|
|
77
|
-
"""Return True for canonical consumer full-spec state.
|
|
78
|
-
|
|
79
|
-
This is deliberately stricter than "specification.vbrief.json exists" so
|
|
80
|
-
the gate still catches strategy-generated legacy dual-writes. A consumer
|
|
81
|
-
may keep ``vbrief/specification.vbrief.json`` as the source of truth only
|
|
82
|
-
after the vBRIEF-centric shape is complete and the root SPECIFICATION.md is
|
|
83
|
-
a rendered export from that source.
|
|
84
|
-
"""
|
|
85
|
-
vbrief_dir = project_root / "vbrief"
|
|
86
|
-
spec_md = _read_text_safe(project_root / "SPECIFICATION.md")
|
|
87
|
-
return (
|
|
88
|
-
# Caller already confirmed specification.vbrief.json exists; the
|
|
89
|
-
# remaining conditions separate canonical state from a legacy dual-write.
|
|
90
|
-
(vbrief_dir / "PROJECT-DEFINITION.vbrief.json").is_file()
|
|
91
|
-
and _has_complete_lifecycle(vbrief_dir)
|
|
92
|
-
and GENERATED_SPEC_PURPOSE in spec_md
|
|
93
|
-
and GENERATED_SPEC_SOURCE in spec_md
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def validate_strategy_output(project_root: Path, strict: bool = False) -> list[str]:
|
|
98
|
-
"""
|
|
99
|
-
Return list of error strings (empty == pass).
|
|
100
|
-
"""
|
|
101
|
-
errors: list[str] = []
|
|
102
|
-
vbrief_dir = project_root / "vbrief"
|
|
103
|
-
|
|
104
|
-
if not vbrief_dir.exists():
|
|
105
|
-
# No vbrief/ produced at all -- some legacy strategies may still do this,
|
|
106
|
-
# but v0.20 contract requires the lifecycle layout. Flag only in strict mode
|
|
107
|
-
# or when other signals present; for now soft (strategies are converging).
|
|
108
|
-
if strict:
|
|
109
|
-
errors.append(
|
|
110
|
-
"vbrief/ directory missing entirely. v0.20 strategies must emit at least "
|
|
111
|
-
"vbrief/proposed/ (with date-prefixed files) + PROJECT-DEFINITION.vbrief.json."
|
|
112
|
-
)
|
|
113
|
-
return errors
|
|
114
|
-
|
|
115
|
-
# 1. PROJECT-DEFINITION.vbrief.json must exist at vbrief/ root.
|
|
116
|
-
proj_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
|
|
117
|
-
if not proj_def.exists():
|
|
118
|
-
errors.append(
|
|
119
|
-
"Missing vbrief/PROJECT-DEFINITION.vbrief.json. "
|
|
120
|
-
"All v0.20-conformant strategy output must include a complete project definition "
|
|
121
|
-
"(see v0-20-contract.md and task project:render)."
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
# 2. Forbid legacy specification.vbrief.json in generated user projects.
|
|
125
|
-
# Tolerate canonical source copies only in the framework tree or in
|
|
126
|
-
# complete post-cutover consumer full-spec state.
|
|
127
|
-
spec_legacy = vbrief_dir / "specification.vbrief.json"
|
|
128
|
-
if (
|
|
129
|
-
spec_legacy.exists()
|
|
130
|
-
and not _is_deft_framework_root(project_root)
|
|
131
|
-
and not _is_post_cutover_full_spec_state(project_root)
|
|
132
|
-
):
|
|
133
|
-
errors.append(
|
|
134
|
-
"Legacy artifact vbrief/specification.vbrief.json present. "
|
|
135
|
-
"v0.20 strategies MUST NOT dual-write the old specification.vbrief.json "
|
|
136
|
-
"alongside scope vBRIEFs in the lifecycle folders. "
|
|
137
|
-
"See strategies/v0-20-contract.md (contract) and issue #1166."
|
|
138
|
-
)
|
|
139
|
-
# Framework source / post-cutover full-spec state tolerated (canonical spec,
|
|
140
|
-
# not strategy output).
|
|
141
|
-
|
|
142
|
-
# 3. Every .vbrief.json under the lifecycle folders (proposed/ primarily, but all)
|
|
143
|
-
# must be date-prefixed. This is the key shape invariant for s2.
|
|
144
|
-
for dname in LIFECYCLE_DIRS:
|
|
145
|
-
dpath = vbrief_dir / dname
|
|
146
|
-
if dpath.exists() and dpath.is_dir():
|
|
147
|
-
for f in sorted(dpath.glob("*.vbrief.json")):
|
|
148
|
-
if not DATE_PREFIXED_RE.match(f.name):
|
|
149
|
-
errors.append(
|
|
150
|
-
f"Non-conformant filename in vbrief/{dname}/: {f.name}. "
|
|
151
|
-
"v0.20 requires strict YYYY-MM-DD-<slug>.vbrief.json "
|
|
152
|
-
"(date prefix from creation). Bare names (e.g. scaffold.vbrief.json) "
|
|
153
|
-
"are pre-v0.20. See strategies/v0-20-contract.md and "
|
|
154
|
-
"vbrief/vbrief.md filename convention."
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
# 4. If proposed/ exists it must not be empty for a strategy that claims to have emitted scope.
|
|
158
|
-
# (light check; real emptiness is often valid for trivial specs)
|
|
159
|
-
# We rely primarily on the filename rule above.
|
|
160
|
-
|
|
161
|
-
return errors
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def main(argv: list[str] | None = None) -> int:
|
|
165
|
-
parser = argparse.ArgumentParser(
|
|
166
|
-
description="Deterministic validation gate for v0.20 strategy output shape."
|
|
167
|
-
)
|
|
168
|
-
parser.add_argument(
|
|
169
|
-
"--project-root",
|
|
170
|
-
type=Path,
|
|
171
|
-
default=Path("."),
|
|
172
|
-
help="Root of the project whose vbrief/ tree to validate (default: cwd)",
|
|
173
|
-
)
|
|
174
|
-
parser.add_argument(
|
|
175
|
-
"--strict",
|
|
176
|
-
action="store_true",
|
|
177
|
-
help="Treat missing vbrief/ as error (useful in CI for generated projects)",
|
|
178
|
-
)
|
|
179
|
-
parser.add_argument(
|
|
180
|
-
"--quiet",
|
|
181
|
-
action="store_true",
|
|
182
|
-
help="Suppress success message on clean exit",
|
|
183
|
-
)
|
|
184
|
-
args = parser.parse_args(argv)
|
|
185
|
-
|
|
186
|
-
project_root = args.project_root.resolve()
|
|
187
|
-
errors = validate_strategy_output(project_root, strict=args.strict)
|
|
188
|
-
|
|
189
|
-
if errors:
|
|
190
|
-
print("❌ Strategy output shape validation FAILED (v0.20 contract gate)", file=sys.stderr)
|
|
191
|
-
for err in errors:
|
|
192
|
-
print(f" • {err}", file=sys.stderr)
|
|
193
|
-
print(
|
|
194
|
-
"\nReference: strategies/v0-20-contract.md (once landed) + "
|
|
195
|
-
"https://github.com/deftai/directive/issues/1166 (s2-deterministic-gate)",
|
|
196
|
-
file=sys.stderr,
|
|
197
|
-
)
|
|
198
|
-
print(
|
|
199
|
-
"Fix: re-run the emitting strategy after the contract migration "
|
|
200
|
-
"stories land, or run `task migrate:vbrief` + `task project:render` "
|
|
201
|
-
"+ `task scope:promote` as appropriate.",
|
|
202
|
-
file=sys.stderr,
|
|
203
|
-
)
|
|
204
|
-
return 1
|
|
205
|
-
|
|
206
|
-
if not args.quiet:
|
|
207
|
-
print("✓ Strategy output shape conforms to v0.20 contract")
|
|
208
|
-
return 0
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if __name__ == "__main__":
|
|
212
|
-
sys.exit(main())
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""vbrief_activate.py -- structural lifecycle move pending/ -> active/ (#810).
|
|
3
|
-
|
|
4
|
-
Companion to ``scripts/preflight_implementation.py``. The preflight gate
|
|
5
|
-
asserts a vBRIEF is eligible for implementation; this helper is the
|
|
6
|
-
ONLY supported way to satisfy it. Behavior:
|
|
7
|
-
|
|
8
|
-
- Already-active vBRIEFs (folder == ``active`` AND status ==
|
|
9
|
-
``running``): print a no-op message and exit 0. Idempotent.
|
|
10
|
-
- Pending vBRIEFs (folder == ``pending``): flip ``plan.status`` from
|
|
11
|
-
``pending`` / ``approved`` to ``running``, stamp ``vBRIEFInfo.updated``
|
|
12
|
-
to current ISO 8601 UTC, atomically move to ``vbrief/active/``.
|
|
13
|
-
- Any other source folder (``proposed``, ``completed``, ``cancelled``,
|
|
14
|
-
``active`` with non-running status, foreign folder): reject with an
|
|
15
|
-
actionable message. Exit 1.
|
|
16
|
-
- Malformed JSON, missing ``plan``, unreadable file: reject. Exit 1.
|
|
17
|
-
|
|
18
|
-
The atomic move uses :func:`pathlib.Path.replace` (POSIX rename
|
|
19
|
-
semantics on Linux/macOS, MoveFileEx on Windows) so concurrent reads
|
|
20
|
-
never see a half-written destination.
|
|
21
|
-
|
|
22
|
-
Mirrors the shape of ``scripts/scope_lifecycle.py`` (the existing
|
|
23
|
-
lifecycle tooling) and ``scripts/preflight_implementation.py`` (the
|
|
24
|
-
preflight companion). Pure stdlib.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
from __future__ import annotations
|
|
28
|
-
|
|
29
|
-
import argparse
|
|
30
|
-
import contextlib
|
|
31
|
-
import json
|
|
32
|
-
import sys
|
|
33
|
-
from datetime import UTC, datetime
|
|
34
|
-
from pathlib import Path
|
|
35
|
-
from typing import Any
|
|
36
|
-
|
|
37
|
-
#: Folders the lifecycle move flows BETWEEN. Source-folder allow-list
|
|
38
|
-
#: defends against silent data loss from accidentally activating a
|
|
39
|
-
#: ``completed/`` or ``cancelled/`` vBRIEF.
|
|
40
|
-
SOURCE_FOLDERS = frozenset({"pending"})
|
|
41
|
-
ACTIVE_FOLDER = "active"
|
|
42
|
-
ELIGIBLE_STATUSES_FOR_FLIP = frozenset({"pending", "approved"})
|
|
43
|
-
TARGET_STATUS = "running"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _utc_now_iso() -> str:
|
|
47
|
-
"""Return an ISO 8601 UTC timestamp with ``Z`` suffix.
|
|
48
|
-
|
|
49
|
-
Matches the existing ``vBRIEFInfo.updated`` format used elsewhere
|
|
50
|
-
in the framework (see ``vbrief/schemas/vbrief-core.schema.json``
|
|
51
|
-
examples and ``scripts/scope_lifecycle.py``).
|
|
52
|
-
"""
|
|
53
|
-
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _load_vbrief(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
57
|
-
"""Load and validate the vBRIEF payload.
|
|
58
|
-
|
|
59
|
-
Returns ``(payload, None)`` on success or ``(None, error_msg)`` on
|
|
60
|
-
failure. Never raises -- malformed input is reported via the
|
|
61
|
-
structured error message.
|
|
62
|
-
"""
|
|
63
|
-
try:
|
|
64
|
-
raw = path.read_text(encoding="utf-8")
|
|
65
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
66
|
-
return None, f"Could not read vBRIEF at {path}: {exc}."
|
|
67
|
-
try:
|
|
68
|
-
payload = json.loads(raw)
|
|
69
|
-
except json.JSONDecodeError as exc:
|
|
70
|
-
return None, f"vBRIEF at {path} is not valid JSON: {exc.msg} (line {exc.lineno})."
|
|
71
|
-
if not isinstance(payload, dict):
|
|
72
|
-
return None, f"vBRIEF at {path} top-level value is not a JSON object."
|
|
73
|
-
return payload, None
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def activate(vbrief_path: Path) -> tuple[int, str]:
|
|
77
|
-
"""Pure activator -- returns ``(exit_code, human_message)``.
|
|
78
|
-
|
|
79
|
-
Performs the lifecycle move + status flip + timestamp stamp
|
|
80
|
-
atomically (load -> validate -> mutate in memory -> write to
|
|
81
|
-
target -> remove source). Idempotent on already-active inputs.
|
|
82
|
-
"""
|
|
83
|
-
if not vbrief_path.exists():
|
|
84
|
-
return 1, f"vBRIEF not found at {vbrief_path}."
|
|
85
|
-
if not vbrief_path.is_file():
|
|
86
|
-
return 1, f"vBRIEF path {vbrief_path} is not a regular file."
|
|
87
|
-
|
|
88
|
-
payload, err = _load_vbrief(vbrief_path)
|
|
89
|
-
if err is not None or payload is None:
|
|
90
|
-
return 1, err or "vBRIEF could not be loaded."
|
|
91
|
-
|
|
92
|
-
plan = payload.get("plan")
|
|
93
|
-
if not isinstance(plan, dict):
|
|
94
|
-
return 1, f"vBRIEF at {vbrief_path} lacks a `plan` object -- malformed."
|
|
95
|
-
|
|
96
|
-
status = plan.get("status")
|
|
97
|
-
if not isinstance(status, str) or not status:
|
|
98
|
-
return (
|
|
99
|
-
1,
|
|
100
|
-
f"vBRIEF at {vbrief_path} lacks `plan.status` -- malformed.",
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
folder = vbrief_path.parent.name
|
|
104
|
-
|
|
105
|
-
# Idempotent no-op: already in the eligible state.
|
|
106
|
-
if folder == ACTIVE_FOLDER and status == TARGET_STATUS:
|
|
107
|
-
return 0, f"No-op: {vbrief_path} already active."
|
|
108
|
-
|
|
109
|
-
# Reject any other ``active/`` state -- e.g. status ``blocked`` or
|
|
110
|
-
# ``completed``. These are NOT activations; the operator should use
|
|
111
|
-
# ``task scope:unblock`` / ``task scope:complete`` etc.
|
|
112
|
-
if folder == ACTIVE_FOLDER:
|
|
113
|
-
return (
|
|
114
|
-
1,
|
|
115
|
-
f"vBRIEF is already in active/ but plan.status is '{status}', "
|
|
116
|
-
f"not '{TARGET_STATUS}'. Use the appropriate task (e.g. "
|
|
117
|
-
f"`task scope:unblock`) instead of `task vbrief:activate`.",
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
# Reject sources outside the allow-list. ``proposed/`` must promote
|
|
121
|
-
# to ``pending/`` first via ``task scope:promote``; ``completed/``
|
|
122
|
-
# and ``cancelled/`` are terminal.
|
|
123
|
-
if folder not in SOURCE_FOLDERS:
|
|
124
|
-
return (
|
|
125
|
-
1,
|
|
126
|
-
f"vBRIEF is in {folder}/ -- only pending/ vBRIEFs can be activated. "
|
|
127
|
-
f"Use the lifecycle tasks (`task scope:promote`, etc.) to move it "
|
|
128
|
-
f"into pending/ first.",
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
# Status sanity-check on the source. The schema's enum allows
|
|
132
|
-
# several pre-implementation states; only those documented as
|
|
133
|
-
# eligible for the flip are honored here.
|
|
134
|
-
if status not in ELIGIBLE_STATUSES_FOR_FLIP:
|
|
135
|
-
return (
|
|
136
|
-
1,
|
|
137
|
-
f"plan.status is '{status}' -- only "
|
|
138
|
-
f"{sorted(ELIGIBLE_STATUSES_FOR_FLIP)} can be flipped to "
|
|
139
|
-
f"'{TARGET_STATUS}'.",
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
# --- Mutate in memory --------------------------------------------------
|
|
143
|
-
plan["status"] = TARGET_STATUS
|
|
144
|
-
info = payload.setdefault("vBRIEFInfo", {})
|
|
145
|
-
if not isinstance(info, dict):
|
|
146
|
-
return (
|
|
147
|
-
1,
|
|
148
|
-
f"vBRIEF at {vbrief_path} has a non-object `vBRIEFInfo` -- malformed.",
|
|
149
|
-
)
|
|
150
|
-
info["updated"] = _utc_now_iso()
|
|
151
|
-
|
|
152
|
-
# --- Resolve destination ----------------------------------------------
|
|
153
|
-
# Walk up two levels from <root>/vbrief/pending/<file>.json to find
|
|
154
|
-
# the ``vbrief/`` parent, then descend into ``active/``.
|
|
155
|
-
vbrief_dir = vbrief_path.parent.parent
|
|
156
|
-
active_dir = vbrief_dir / ACTIVE_FOLDER
|
|
157
|
-
try:
|
|
158
|
-
active_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
-
except OSError as exc:
|
|
160
|
-
return 1, f"Could not create {active_dir}: {exc}."
|
|
161
|
-
|
|
162
|
-
dest = active_dir / vbrief_path.name
|
|
163
|
-
if dest.exists():
|
|
164
|
-
return (
|
|
165
|
-
1,
|
|
166
|
-
f"Refusing to overwrite existing destination {dest}. Resolve the "
|
|
167
|
-
f"collision manually before re-running `task vbrief:activate`.",
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
# --- Atomic write + source removal ------------------------------------
|
|
171
|
-
# Write to a sibling temp file in the destination directory so
|
|
172
|
-
# ``Path.replace`` is a same-filesystem rename (atomic on POSIX +
|
|
173
|
-
# Windows). The source file is removed only after the destination
|
|
174
|
-
# is durable, so a mid-flight crash leaves the original in place.
|
|
175
|
-
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
|
176
|
-
try:
|
|
177
|
-
tmp.write_text(
|
|
178
|
-
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
179
|
-
encoding="utf-8",
|
|
180
|
-
)
|
|
181
|
-
tmp.replace(dest)
|
|
182
|
-
except OSError as exc:
|
|
183
|
-
# Best-effort cleanup of the partial temp file.
|
|
184
|
-
with contextlib.suppress(OSError):
|
|
185
|
-
tmp.unlink(missing_ok=True)
|
|
186
|
-
return 1, f"Could not write {dest}: {exc}."
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
vbrief_path.unlink()
|
|
190
|
-
except OSError as exc:
|
|
191
|
-
return (
|
|
192
|
-
1,
|
|
193
|
-
f"Wrote {dest} but could not remove source {vbrief_path}: {exc}. "
|
|
194
|
-
f"Manual cleanup required.",
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
return 0, f"Activated {vbrief_path.name}: pending/ -> active/ (status: {TARGET_STATUS})."
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
201
|
-
parser = argparse.ArgumentParser(
|
|
202
|
-
prog="vbrief_activate.py",
|
|
203
|
-
description=(
|
|
204
|
-
"Activate a pending vBRIEF: flip plan.status to 'running', "
|
|
205
|
-
"stamp vBRIEFInfo.updated, atomically move to vbrief/active/. "
|
|
206
|
-
"Idempotent on already-active inputs (#810)."
|
|
207
|
-
),
|
|
208
|
-
)
|
|
209
|
-
parser.add_argument(
|
|
210
|
-
"vbrief_path",
|
|
211
|
-
help="Path to the candidate vBRIEF JSON file (in vbrief/pending/).",
|
|
212
|
-
)
|
|
213
|
-
return parser
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def main(argv: list[str] | None = None) -> int:
|
|
217
|
-
parser = _build_parser()
|
|
218
|
-
args = parser.parse_args(argv)
|
|
219
|
-
code, message = activate(Path(args.vbrief_path))
|
|
220
|
-
if code == 0:
|
|
221
|
-
print(message)
|
|
222
|
-
else:
|
|
223
|
-
print(message, file=sys.stderr)
|
|
224
|
-
return code
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if __name__ == "__main__":
|
|
228
|
-
sys.exit(main())
|