multi-forge 0.2.0__py3-none-any.whl
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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parse a walkthrough checklist into structured JSON with state tracking.
|
|
3
|
+
|
|
4
|
+
Provides deterministic bookkeeping so the agent never does arithmetic —
|
|
5
|
+
it only classifies (pass/fail/skip) while this script handles structure,
|
|
6
|
+
counting, and progress tracking.
|
|
7
|
+
|
|
8
|
+
Usage (read-only):
|
|
9
|
+
python3 walkthrough-state.py <checklist> index
|
|
10
|
+
python3 walkthrough-state.py <checklist> step 6.3
|
|
11
|
+
python3 walkthrough-state.py <checklist> summary
|
|
12
|
+
|
|
13
|
+
Usage (state management):
|
|
14
|
+
python3 walkthrough-state.py <checklist> init <state-file> [--mode M] [--force]
|
|
15
|
+
python3 walkthrough-state.py <checklist> record <state-file> <step_id> <results> [--force]
|
|
16
|
+
python3 walkthrough-state.py <checklist> var <state-file> set <key> <value>
|
|
17
|
+
python3 walkthrough-state.py <checklist> var <state-file> get <key>
|
|
18
|
+
python3 walkthrough-state.py <checklist> prereq-check <state-file> <step_id|section_id>
|
|
19
|
+
python3 walkthrough-state.py <checklist> report <state-file>
|
|
20
|
+
python3 walkthrough-state.py <checklist> validate <state-file> --from <step_id>
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
SECTION_RE = re.compile(r"^## (\S+?)\.?\s+(.+)")
|
|
33
|
+
SUBSECTION_RE = re.compile(r"^### (\S+)\s+(.+)")
|
|
34
|
+
ANNOTATION_RE = re.compile(r"^<!--\s*(.+?)\s*-->")
|
|
35
|
+
PREREQ_RE = re.compile(r"^<!--\s*prereq:\s*(.+?)\s*-->")
|
|
36
|
+
ASSERTION_RE = re.compile(r"^- \[ \]\s+(.+)")
|
|
37
|
+
VERSION_RE = re.compile(r"^<!--\s*version:\s*(.+?)\s*-->")
|
|
38
|
+
FENCE_RE = re.compile(r"^```(\w*)")
|
|
39
|
+
|
|
40
|
+
CHECKLIST_INDEX_RE = re.compile(r"^<!--\s*checklist:\s*index\s*-->")
|
|
41
|
+
INDEX_SECTION_RE = re.compile(r"^<!--\s*section:\s*(\S+)\s+(.+?)\s*-->")
|
|
42
|
+
|
|
43
|
+
RESULT_CODES = {"p": "pass", "f": "fail", "s": "skip"}
|
|
44
|
+
EXECUTION_ANNOTATIONS = {"auto", "human:confirm", "human:guided"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _primary_annotation(annotations: list[str]) -> str:
|
|
48
|
+
"""Return the execution annotation for a step, ignoring modifier annotations."""
|
|
49
|
+
for annotation in annotations:
|
|
50
|
+
if annotation in EXECUTION_ANNOTATIONS:
|
|
51
|
+
return annotation
|
|
52
|
+
return "human:confirm"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_index_entries(index_lines: list[str]) -> list[tuple[str, str]]:
|
|
56
|
+
entries: list[tuple[str, str]] = []
|
|
57
|
+
for line in index_lines:
|
|
58
|
+
m = INDEX_SECTION_RE.match(line)
|
|
59
|
+
if not m:
|
|
60
|
+
continue
|
|
61
|
+
section_id = m.group(1)
|
|
62
|
+
relpath = m.group(2).strip()
|
|
63
|
+
entries.append((section_id, relpath))
|
|
64
|
+
return entries
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _next_nonblank_line(lines: list[str], start: int) -> tuple[int | None, str | None]:
|
|
68
|
+
for idx in range(start, len(lines)):
|
|
69
|
+
if lines[idx].strip():
|
|
70
|
+
return idx, lines[idx]
|
|
71
|
+
return None, None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_checklist_lines(lines: list[str], *, extract_version: bool) -> dict:
|
|
75
|
+
version = None
|
|
76
|
+
sections = []
|
|
77
|
+
current_section = None
|
|
78
|
+
current_sub = None
|
|
79
|
+
all_subs = []
|
|
80
|
+
# Prereqs seen outside a subsection body apply to the next ## heading.
|
|
81
|
+
pending_prereqs: list[str] = []
|
|
82
|
+
|
|
83
|
+
in_fence = False
|
|
84
|
+
|
|
85
|
+
for line_idx, line in enumerate(lines):
|
|
86
|
+
m = FENCE_RE.match(line)
|
|
87
|
+
if m:
|
|
88
|
+
if in_fence:
|
|
89
|
+
in_fence = False
|
|
90
|
+
continue
|
|
91
|
+
in_fence = True
|
|
92
|
+
if current_sub is not None:
|
|
93
|
+
lang = m.group(1) or None
|
|
94
|
+
current_sub["_collecting_code"] = True
|
|
95
|
+
current_sub["code_blocks"].append({"code": "", "runnable": lang == "bash"})
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if in_fence:
|
|
99
|
+
if current_sub is not None and current_sub.get("_collecting_code"):
|
|
100
|
+
block = current_sub["code_blocks"][-1]
|
|
101
|
+
if block["code"]:
|
|
102
|
+
block["code"] += "\n"
|
|
103
|
+
block["code"] += line
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Prereq annotations are subsection-level when placed in the annotation block
|
|
107
|
+
# directly under a ### heading. Outside that block, they are only allowed
|
|
108
|
+
# immediately before a ## heading (section-level).
|
|
109
|
+
m = PREREQ_RE.match(line)
|
|
110
|
+
if m:
|
|
111
|
+
in_subsection_annotation_block = (
|
|
112
|
+
current_sub is not None
|
|
113
|
+
and not current_sub["instructions"]
|
|
114
|
+
and not current_sub["code_blocks"]
|
|
115
|
+
and not current_sub["assertions"]
|
|
116
|
+
)
|
|
117
|
+
if not in_subsection_annotation_block:
|
|
118
|
+
_, next_line = _next_nonblank_line(lines, line_idx + 1)
|
|
119
|
+
if next_line is not None and (PREREQ_RE.match(next_line) or SECTION_RE.match(next_line)):
|
|
120
|
+
pending_prereqs = list(
|
|
121
|
+
dict.fromkeys(pending_prereqs + [p.strip() for p in m.group(1).split(",") if p.strip()])
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if current_sub is not None:
|
|
126
|
+
print(
|
|
127
|
+
f"Error: misplaced prereq annotation inside subsection body: {current_sub['id']}",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
print(
|
|
131
|
+
"Place it immediately below the subsection heading for a step-level prereq, "
|
|
132
|
+
"or immediately above the next ## heading for a section-level prereq.",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
print("Error: section-level prereq must appear immediately before a ## heading.", file=sys.stderr)
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
|
|
139
|
+
if version is None:
|
|
140
|
+
if extract_version:
|
|
141
|
+
m = VERSION_RE.match(line)
|
|
142
|
+
if m:
|
|
143
|
+
version = m.group(1).strip()
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
m = SECTION_RE.match(line)
|
|
147
|
+
if m:
|
|
148
|
+
current_section = {
|
|
149
|
+
"id": m.group(1),
|
|
150
|
+
"title": m.group(2).strip(),
|
|
151
|
+
"prereqs": pending_prereqs,
|
|
152
|
+
"subsections": [],
|
|
153
|
+
}
|
|
154
|
+
pending_prereqs = []
|
|
155
|
+
sections.append(current_section)
|
|
156
|
+
current_sub = None
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
m = SUBSECTION_RE.match(line)
|
|
160
|
+
if m:
|
|
161
|
+
current_sub = {
|
|
162
|
+
"id": m.group(1),
|
|
163
|
+
"title": m.group(2).strip(),
|
|
164
|
+
"section_id": current_section["id"] if current_section else None,
|
|
165
|
+
"section_title": current_section["title"] if current_section else None,
|
|
166
|
+
"annotations": [],
|
|
167
|
+
"annotation": None,
|
|
168
|
+
"instructions": "",
|
|
169
|
+
"code_blocks": [],
|
|
170
|
+
"assertions": [],
|
|
171
|
+
}
|
|
172
|
+
if current_section is not None:
|
|
173
|
+
current_section["subsections"].append(current_sub)
|
|
174
|
+
all_subs.append(current_sub)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
if current_sub is None:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
m = ANNOTATION_RE.match(line)
|
|
181
|
+
if m and not current_sub["code_blocks"] and not current_sub["assertions"]:
|
|
182
|
+
current_sub["annotations"].append(m.group(1))
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
m = ASSERTION_RE.match(line)
|
|
186
|
+
if m:
|
|
187
|
+
current_sub["assertions"].append(m.group(1))
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
stripped = line.strip()
|
|
191
|
+
if stripped:
|
|
192
|
+
if current_sub["instructions"]:
|
|
193
|
+
current_sub["instructions"] += "\n"
|
|
194
|
+
current_sub["instructions"] += stripped
|
|
195
|
+
|
|
196
|
+
for sub in all_subs:
|
|
197
|
+
sub.pop("_collecting_code", None)
|
|
198
|
+
# Extract prereq annotations into a dedicated field
|
|
199
|
+
sub_prereqs: list[str] = []
|
|
200
|
+
non_prereq_annotations: list[str] = []
|
|
201
|
+
for ann in sub["annotations"]:
|
|
202
|
+
pm = PREREQ_RE.match(f"<!-- {ann} -->")
|
|
203
|
+
if pm:
|
|
204
|
+
sub_prereqs.extend(p.strip() for p in pm.group(1).split(","))
|
|
205
|
+
else:
|
|
206
|
+
non_prereq_annotations.append(ann)
|
|
207
|
+
sub["prereqs"] = sub_prereqs
|
|
208
|
+
sub["annotations"] = non_prereq_annotations
|
|
209
|
+
sub["annotation"] = _primary_annotation(non_prereq_annotations)
|
|
210
|
+
|
|
211
|
+
for section in sections:
|
|
212
|
+
section["assertion_count"] = sum(len(s["assertions"]) for s in section["subsections"])
|
|
213
|
+
for sub in section["subsections"]:
|
|
214
|
+
sub["assertion_count"] = len(sub["assertions"])
|
|
215
|
+
|
|
216
|
+
total = sum(s["assertion_count"] for s in sections)
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"version": version,
|
|
220
|
+
"total_assertions": total,
|
|
221
|
+
"sections": sections,
|
|
222
|
+
"_all_subs": all_subs,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_index_checklist(index_path: Path, index_lines: list[str]) -> dict:
|
|
227
|
+
version = None
|
|
228
|
+
for line in index_lines:
|
|
229
|
+
if version is None:
|
|
230
|
+
m = VERSION_RE.match(line)
|
|
231
|
+
if m:
|
|
232
|
+
version = m.group(1).strip()
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
if version is None:
|
|
236
|
+
print(f"Error: index checklist missing version: {index_path}", file=sys.stderr)
|
|
237
|
+
print("Add: <!-- version: X.Y.Z -->", file=sys.stderr)
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
entries = _parse_index_entries(index_lines)
|
|
241
|
+
if not entries:
|
|
242
|
+
print(f"Error: index checklist contains no section entries: {index_path}", file=sys.stderr)
|
|
243
|
+
print("Add one or more: <!-- section: <id> <relative_path> -->", file=sys.stderr)
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
|
|
246
|
+
seen_ids: set[str] = set()
|
|
247
|
+
sections: list[dict] = []
|
|
248
|
+
all_subs: list[dict] = []
|
|
249
|
+
|
|
250
|
+
for section_id, relpath in entries:
|
|
251
|
+
if section_id in seen_ids:
|
|
252
|
+
print(f"Error: duplicate section id in index: {section_id}", file=sys.stderr)
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
seen_ids.add(section_id)
|
|
255
|
+
|
|
256
|
+
section_path = index_path.parent / relpath
|
|
257
|
+
if not section_path.exists():
|
|
258
|
+
print(f"Error: section file not found for section {section_id}: {section_path}", file=sys.stderr)
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
|
|
261
|
+
parsed = _parse_checklist_lines(section_path.read_text().splitlines(), extract_version=False)
|
|
262
|
+
if len(parsed["sections"]) != 1:
|
|
263
|
+
print(
|
|
264
|
+
f"Error: section file must contain exactly 1 section: {section_path}",
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
print(f" Found: {len(parsed['sections'])}", file=sys.stderr)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
section = parsed["sections"][0]
|
|
271
|
+
if section["id"] != section_id:
|
|
272
|
+
print(
|
|
273
|
+
f"Error: section id mismatch in {section_path}\n"
|
|
274
|
+
f" Index expects: {section_id}\n"
|
|
275
|
+
f" File declares: {section['id']}",
|
|
276
|
+
file=sys.stderr,
|
|
277
|
+
)
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
|
|
280
|
+
sections.append(section)
|
|
281
|
+
all_subs.extend(parsed["_all_subs"])
|
|
282
|
+
|
|
283
|
+
for i, sub in enumerate(all_subs):
|
|
284
|
+
sub["next"] = all_subs[i + 1]["id"] if i + 1 < len(all_subs) else None
|
|
285
|
+
|
|
286
|
+
for section in sections:
|
|
287
|
+
section["assertion_count"] = sum(len(s["assertions"]) for s in section["subsections"])
|
|
288
|
+
for sub in section["subsections"]:
|
|
289
|
+
sub["assertion_count"] = len(sub["assertions"])
|
|
290
|
+
|
|
291
|
+
total = sum(s["assertion_count"] for s in sections)
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
"version": version,
|
|
295
|
+
"total_assertions": total,
|
|
296
|
+
"sections": sections,
|
|
297
|
+
"_all_subs": all_subs,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def parse_checklist(path: str) -> dict:
|
|
302
|
+
"""Parse a checklist markdown file (or checklist index) into structured data."""
|
|
303
|
+
p = Path(path)
|
|
304
|
+
lines = p.read_text().splitlines()
|
|
305
|
+
if any(CHECKLIST_INDEX_RE.match(line) for line in lines):
|
|
306
|
+
return _parse_index_checklist(p, lines)
|
|
307
|
+
|
|
308
|
+
data = _parse_checklist_lines(lines, extract_version=True)
|
|
309
|
+
for i, sub in enumerate(data["_all_subs"]):
|
|
310
|
+
sub["next"] = data["_all_subs"][i + 1]["id"] if i + 1 < len(data["_all_subs"]) else None
|
|
311
|
+
return data
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# --- Read-only commands (no state file) ---
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_index(data: dict) -> dict:
|
|
318
|
+
"""Full index with sections, subsections, annotations, assertion counts."""
|
|
319
|
+
sections = []
|
|
320
|
+
for s in data["sections"]:
|
|
321
|
+
subs = []
|
|
322
|
+
for sub in s["subsections"]:
|
|
323
|
+
sub_entry: dict = {
|
|
324
|
+
"id": sub["id"],
|
|
325
|
+
"title": sub["title"],
|
|
326
|
+
"annotation": sub["annotation"],
|
|
327
|
+
"assertion_count": sub["assertion_count"],
|
|
328
|
+
}
|
|
329
|
+
if sub["prereqs"]:
|
|
330
|
+
sub_entry["prereqs"] = sub["prereqs"]
|
|
331
|
+
subs.append(sub_entry)
|
|
332
|
+
sec_entry: dict = {
|
|
333
|
+
"id": s["id"],
|
|
334
|
+
"title": s["title"],
|
|
335
|
+
"assertion_count": s["assertion_count"],
|
|
336
|
+
"subsections": subs,
|
|
337
|
+
}
|
|
338
|
+
if s.get("prereqs"):
|
|
339
|
+
sec_entry["prereqs"] = s["prereqs"]
|
|
340
|
+
sections.append(sec_entry)
|
|
341
|
+
return {
|
|
342
|
+
"version": data["version"],
|
|
343
|
+
"total_assertions": data["total_assertions"],
|
|
344
|
+
"sections": sections,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cmd_step(data: dict, step_id: str) -> dict:
|
|
349
|
+
"""Single step details."""
|
|
350
|
+
for sub in data["_all_subs"]:
|
|
351
|
+
if sub["id"] == step_id:
|
|
352
|
+
# Merge section-level and subsection-level prereqs
|
|
353
|
+
section_prereqs: list[str] = []
|
|
354
|
+
for s in data["sections"]:
|
|
355
|
+
if s["id"] == sub["section_id"]:
|
|
356
|
+
section_prereqs = s.get("prereqs", [])
|
|
357
|
+
break
|
|
358
|
+
merged_prereqs = list(dict.fromkeys(section_prereqs + sub.get("prereqs", [])))
|
|
359
|
+
result: dict = {
|
|
360
|
+
"id": sub["id"],
|
|
361
|
+
"title": sub["title"],
|
|
362
|
+
"section": f"{sub['section_id']}. {sub['section_title']}",
|
|
363
|
+
"annotation": sub["annotation"],
|
|
364
|
+
"annotations": sub["annotations"],
|
|
365
|
+
"instructions": sub["instructions"],
|
|
366
|
+
"code_blocks": sub["code_blocks"],
|
|
367
|
+
"assertions": sub["assertions"],
|
|
368
|
+
"assertion_count": len(sub["assertions"]),
|
|
369
|
+
"next": sub["next"],
|
|
370
|
+
}
|
|
371
|
+
if merged_prereqs:
|
|
372
|
+
result["prereqs"] = merged_prereqs
|
|
373
|
+
return result
|
|
374
|
+
print(f"Error: step '{step_id}' not found.", file=sys.stderr)
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_summary(data: dict) -> dict:
|
|
379
|
+
"""Summary template with expected counts per section."""
|
|
380
|
+
sections = []
|
|
381
|
+
for s in data["sections"]:
|
|
382
|
+
sections.append(
|
|
383
|
+
{
|
|
384
|
+
"id": s["id"],
|
|
385
|
+
"title": s["title"],
|
|
386
|
+
"expected": s["assertion_count"],
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
return {
|
|
390
|
+
"total_assertions": data["total_assertions"],
|
|
391
|
+
"sections": sections,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --- State management commands ---
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def checklist_hash(path: str) -> str:
|
|
399
|
+
"""SHA-256 hash of the checklist content (single file or index + section files)."""
|
|
400
|
+
p = Path(path)
|
|
401
|
+
lines = p.read_text().splitlines()
|
|
402
|
+
|
|
403
|
+
h = hashlib.sha256()
|
|
404
|
+
h.update(b"forge-checklist-hash-v1\n")
|
|
405
|
+
|
|
406
|
+
if any(CHECKLIST_INDEX_RE.match(line) for line in lines):
|
|
407
|
+
entries = _parse_index_entries(lines)
|
|
408
|
+
if not entries:
|
|
409
|
+
print(f"Error: index checklist contains no section entries: {p}", file=sys.stderr)
|
|
410
|
+
sys.exit(1)
|
|
411
|
+
|
|
412
|
+
seen_ids: set[str] = set()
|
|
413
|
+
|
|
414
|
+
h.update(b"type:index\n")
|
|
415
|
+
h.update(p.read_bytes())
|
|
416
|
+
for section_id, relpath in entries:
|
|
417
|
+
if section_id in seen_ids:
|
|
418
|
+
print(f"Error: duplicate section id in index: {section_id}", file=sys.stderr)
|
|
419
|
+
sys.exit(1)
|
|
420
|
+
seen_ids.add(section_id)
|
|
421
|
+
|
|
422
|
+
section_path = p.parent / relpath
|
|
423
|
+
if not section_path.exists():
|
|
424
|
+
print(f"Error: section file not found for section {section_id}: {section_path}", file=sys.stderr)
|
|
425
|
+
sys.exit(1)
|
|
426
|
+
|
|
427
|
+
h.update(b"\nsection\n")
|
|
428
|
+
h.update(section_id.encode("utf-8") + b"\n")
|
|
429
|
+
h.update(relpath.encode("utf-8") + b"\n")
|
|
430
|
+
h.update(section_path.read_bytes())
|
|
431
|
+
else:
|
|
432
|
+
h.update(b"type:single\n")
|
|
433
|
+
h.update(p.read_bytes())
|
|
434
|
+
|
|
435
|
+
return f"sha256:{h.hexdigest()}"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def step_hash(step: dict) -> str:
|
|
439
|
+
"""Hash the structural content of a step that affects result validity.
|
|
440
|
+
|
|
441
|
+
Includes: ID, title, annotation, assertion texts (normalized).
|
|
442
|
+
Excludes: instructions, code blocks (presentation only).
|
|
443
|
+
"""
|
|
444
|
+
h = hashlib.sha256()
|
|
445
|
+
h.update(b"forge-step-hash-v1\n")
|
|
446
|
+
h.update(step["id"].encode() + b"\n")
|
|
447
|
+
h.update(step["title"].strip().encode() + b"\n")
|
|
448
|
+
h.update((step.get("annotation") or "").encode() + b"\n")
|
|
449
|
+
for a in step["assertions"]:
|
|
450
|
+
h.update(a.strip().encode() + b"\n")
|
|
451
|
+
return h.hexdigest()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _migrate_v1_to_v2(state: dict, data: dict, checklist_path: str) -> dict:
|
|
455
|
+
"""Auto-migrate v1 state (global hash) to v2 (per-step hash).
|
|
456
|
+
|
|
457
|
+
If the v1 global hash matches the current checklist, step hashes are
|
|
458
|
+
computed with full confidence. If mismatched (checklist was edited since
|
|
459
|
+
init), step hashes are set to null (unverified).
|
|
460
|
+
"""
|
|
461
|
+
old_global = state.get("checklist_hash")
|
|
462
|
+
current_global = checklist_hash(checklist_path) if old_global else None
|
|
463
|
+
trust = old_global is not None and old_global == current_global
|
|
464
|
+
|
|
465
|
+
for step_id, step_data in state.get("steps", {}).items():
|
|
466
|
+
if "hash" in step_data:
|
|
467
|
+
continue
|
|
468
|
+
if trust:
|
|
469
|
+
found = find_step(data, step_id)
|
|
470
|
+
step_data["hash"] = step_hash(found) if found else None
|
|
471
|
+
else:
|
|
472
|
+
step_data["hash"] = None
|
|
473
|
+
|
|
474
|
+
state.pop("checklist_hash", None)
|
|
475
|
+
state["schema_version"] = 2
|
|
476
|
+
return state
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def read_state(path: str) -> dict:
|
|
480
|
+
"""Read and return state JSON. Fail-closed with actionable errors."""
|
|
481
|
+
p = Path(path)
|
|
482
|
+
if not p.exists():
|
|
483
|
+
print(f"Error: state file not found: {path}", file=sys.stderr)
|
|
484
|
+
print("Run 'init' first to create the state file.", file=sys.stderr)
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
try:
|
|
487
|
+
return json.loads(p.read_text())
|
|
488
|
+
except json.JSONDecodeError as e:
|
|
489
|
+
print(f"Error: state file is corrupt: {path}", file=sys.stderr)
|
|
490
|
+
print(f" {e}", file=sys.stderr)
|
|
491
|
+
print("Delete the file and run 'init' again.", file=sys.stderr)
|
|
492
|
+
sys.exit(1)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def write_state(path: str, state: dict) -> None:
|
|
496
|
+
"""Atomic write: write to .tmp then os.replace."""
|
|
497
|
+
state["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
498
|
+
tmp = path + ".tmp"
|
|
499
|
+
Path(tmp).write_text(json.dumps(state, indent=2) + "\n")
|
|
500
|
+
os.replace(tmp, path)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def find_step(data: dict, step_id: str):
|
|
504
|
+
"""Find a subsection by ID."""
|
|
505
|
+
for sub in data["_all_subs"]:
|
|
506
|
+
if sub["id"] == step_id:
|
|
507
|
+
return sub
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def find_section(data: dict, section_id: str):
|
|
512
|
+
"""Find a section by ID."""
|
|
513
|
+
for section in data["sections"]:
|
|
514
|
+
if section["id"] == section_id:
|
|
515
|
+
return section
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def resolve_step_id(data: dict, raw_id: str) -> str:
|
|
520
|
+
"""Resolve a possibly section-level ID to a subsection ID.
|
|
521
|
+
|
|
522
|
+
Accepts '3.1' (exact subsection), '3' (section -> first subsection),
|
|
523
|
+
or '3.0' (section.0 shorthand -> first subsection).
|
|
524
|
+
"""
|
|
525
|
+
# Exact subsection match
|
|
526
|
+
if find_step(data, raw_id):
|
|
527
|
+
return raw_id
|
|
528
|
+
|
|
529
|
+
# Try section-level: '3' or '3.0' -> first subsection of section 3
|
|
530
|
+
section_id = raw_id.rsplit(".0", 1)[0] if raw_id.endswith(".0") else raw_id
|
|
531
|
+
section = find_section(data, section_id)
|
|
532
|
+
if section and section.get("subsections"):
|
|
533
|
+
return section["subsections"][0]["id"]
|
|
534
|
+
|
|
535
|
+
return raw_id # Return as-is; caller handles the error
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _current_run_scope(state: dict) -> Optional[str]:
|
|
539
|
+
"""Return the current run scope, if the caller recorded one."""
|
|
540
|
+
return state.get("vars", {}).get("RUN_SCOPE")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _section_status_keys(section_id: str) -> tuple[str, str]:
|
|
544
|
+
return f"SECTION_{section_id}_STATUS", f"SECTION_{section_id}_SCOPE"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _section_state(data: dict, state: dict, section_id: str, *, run_scope: Optional[str] = None) -> dict:
|
|
548
|
+
"""Classify a section using recorded step results in the current run scope."""
|
|
549
|
+
section = find_section(data, section_id)
|
|
550
|
+
if section is None:
|
|
551
|
+
raise ValueError(f"Unknown section: {section_id}")
|
|
552
|
+
|
|
553
|
+
missing_steps: list[str] = []
|
|
554
|
+
stale_steps: list[str] = []
|
|
555
|
+
has_failure = False
|
|
556
|
+
has_non_skip = False
|
|
557
|
+
has_recorded_steps = False
|
|
558
|
+
|
|
559
|
+
for sub in section["subsections"]:
|
|
560
|
+
step_data = state.get("steps", {}).get(sub["id"])
|
|
561
|
+
if step_data is None:
|
|
562
|
+
missing_steps.append(sub["id"])
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
if run_scope is not None and step_data.get("scope") != run_scope:
|
|
566
|
+
stale_steps.append(sub["id"])
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
results = step_data["results"]
|
|
570
|
+
has_recorded_steps = True
|
|
571
|
+
if any(result != "skip" for result in results):
|
|
572
|
+
has_non_skip = True
|
|
573
|
+
if any(result == "fail" for result in results):
|
|
574
|
+
has_failure = True
|
|
575
|
+
|
|
576
|
+
if stale_steps:
|
|
577
|
+
status = "stale_run"
|
|
578
|
+
elif missing_steps:
|
|
579
|
+
status = "not_run"
|
|
580
|
+
elif has_failure:
|
|
581
|
+
status = "failed"
|
|
582
|
+
elif has_recorded_steps and not has_non_skip:
|
|
583
|
+
status = "skipped"
|
|
584
|
+
else:
|
|
585
|
+
status = "passed"
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
"status": status,
|
|
589
|
+
"missing_steps": missing_steps,
|
|
590
|
+
"stale_steps": stale_steps,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _refresh_section_status_vars(data: dict, state: dict) -> None:
|
|
595
|
+
"""Recompute derived SECTION_* vars from the currently valid step records."""
|
|
596
|
+
vars_dict = state.setdefault("vars", {})
|
|
597
|
+
for key in list(vars_dict):
|
|
598
|
+
if key.startswith("SECTION_") and (key.endswith("_STATUS") or key.endswith("_SCOPE")):
|
|
599
|
+
del vars_dict[key]
|
|
600
|
+
|
|
601
|
+
run_scope = _current_run_scope(state)
|
|
602
|
+
for section in data["sections"]:
|
|
603
|
+
section_state = _section_state(data, state, section["id"], run_scope=run_scope)
|
|
604
|
+
if section_state["status"] in {"passed", "failed"}:
|
|
605
|
+
status_key, scope_key = _section_status_keys(section["id"])
|
|
606
|
+
vars_dict[status_key] = section_state["status"]
|
|
607
|
+
if run_scope is not None:
|
|
608
|
+
vars_dict[scope_key] = run_scope
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def cmd_init(data: dict, checklist_path: str, state_path: str, mode: str, force: bool) -> dict:
|
|
612
|
+
"""Create initial state file."""
|
|
613
|
+
if Path(state_path).exists() and not force:
|
|
614
|
+
print(f"Error: state file already exists: {state_path}", file=sys.stderr)
|
|
615
|
+
print("Use --force to overwrite.", file=sys.stderr)
|
|
616
|
+
sys.exit(1)
|
|
617
|
+
|
|
618
|
+
first_step = data["_all_subs"][0]["id"] if data["_all_subs"] else None
|
|
619
|
+
total_steps = len(data["_all_subs"])
|
|
620
|
+
|
|
621
|
+
state = {
|
|
622
|
+
"schema_version": 2,
|
|
623
|
+
"checklist_version": data["version"],
|
|
624
|
+
"mode": mode,
|
|
625
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
626
|
+
"last_updated": datetime.now(timezone.utc).isoformat(),
|
|
627
|
+
"current_step": first_step,
|
|
628
|
+
"vars": {},
|
|
629
|
+
"steps": {},
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
Path(state_path).parent.mkdir(parents=True, exist_ok=True)
|
|
633
|
+
write_state(state_path, state)
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
"status": "initialized",
|
|
637
|
+
"sections": len(data["sections"]),
|
|
638
|
+
"steps": total_steps,
|
|
639
|
+
"assertions": data["total_assertions"],
|
|
640
|
+
"current_step": first_step,
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def cmd_record(data: dict, checklist_path: str, state_path: str, step_id: str, results_csv: str, force: bool) -> dict:
|
|
645
|
+
"""Record assertion results for a step."""
|
|
646
|
+
state = read_state(state_path)
|
|
647
|
+
|
|
648
|
+
# Auto-migrate v1 state files
|
|
649
|
+
if state.get("schema_version", 1) < 2:
|
|
650
|
+
state = _migrate_v1_to_v2(state, data, checklist_path)
|
|
651
|
+
|
|
652
|
+
# Find the step in the checklist
|
|
653
|
+
step = find_step(data, step_id)
|
|
654
|
+
if step is None:
|
|
655
|
+
print(f"Error: step '{step_id}' not found in checklist.", file=sys.stderr)
|
|
656
|
+
sys.exit(1)
|
|
657
|
+
|
|
658
|
+
# Reject overwrite
|
|
659
|
+
if step_id in state["steps"] and not force:
|
|
660
|
+
print(f"Error: step '{step_id}' already recorded. Use --force to overwrite.", file=sys.stderr)
|
|
661
|
+
sys.exit(1)
|
|
662
|
+
|
|
663
|
+
# Parse and validate results
|
|
664
|
+
codes = [c.strip() for c in results_csv.split(",")]
|
|
665
|
+
expected_count = len(step["assertions"])
|
|
666
|
+
if len(codes) != expected_count:
|
|
667
|
+
print(
|
|
668
|
+
f"Error: step '{step_id}' expects {expected_count} assertions, got {len(codes)} results.",
|
|
669
|
+
file=sys.stderr,
|
|
670
|
+
)
|
|
671
|
+
sys.exit(1)
|
|
672
|
+
|
|
673
|
+
results = []
|
|
674
|
+
for c in codes:
|
|
675
|
+
if c not in RESULT_CODES:
|
|
676
|
+
print(f"Error: invalid result code '{c}'. Use p (pass), f (fail), s (skip).", file=sys.stderr)
|
|
677
|
+
sys.exit(1)
|
|
678
|
+
results.append(RESULT_CODES[c])
|
|
679
|
+
|
|
680
|
+
# Update state with per-step hash and the current run scope (if any).
|
|
681
|
+
step_entry = {"results": results, "hash": step_hash(step)}
|
|
682
|
+
current_scope = _current_run_scope(state)
|
|
683
|
+
if current_scope is not None:
|
|
684
|
+
step_entry["scope"] = current_scope
|
|
685
|
+
state["steps"][step_id] = step_entry
|
|
686
|
+
state["current_step"] = step["next"]
|
|
687
|
+
_refresh_section_status_vars(data, state)
|
|
688
|
+
write_state(state_path, state)
|
|
689
|
+
|
|
690
|
+
# Compute progress for output
|
|
691
|
+
step_pass = sum(1 for r in results if r == "pass")
|
|
692
|
+
step_total = len(results)
|
|
693
|
+
|
|
694
|
+
# Section progress
|
|
695
|
+
section_id = step["section_id"]
|
|
696
|
+
section_expected = 0
|
|
697
|
+
section_recorded = 0
|
|
698
|
+
for s in data["sections"]:
|
|
699
|
+
if s["id"] == section_id:
|
|
700
|
+
section_expected = s["assertion_count"]
|
|
701
|
+
for sub in s["subsections"]:
|
|
702
|
+
if sub["id"] in state["steps"]:
|
|
703
|
+
section_recorded += len(state["steps"][sub["id"]]["results"])
|
|
704
|
+
break
|
|
705
|
+
|
|
706
|
+
# Overall progress (only count steps that exist in the current checklist)
|
|
707
|
+
checklist_ids = {sub["id"] for sub in data["_all_subs"]}
|
|
708
|
+
overall_recorded = sum(len(s["results"]) for sid, s in state["steps"].items() if sid in checklist_ids)
|
|
709
|
+
overall_total = data["total_assertions"]
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
"step": step_id,
|
|
713
|
+
"step_results": f"{step_pass}/{step_total} pass",
|
|
714
|
+
"section_progress": f"{section_recorded}/{section_expected}",
|
|
715
|
+
"section_status": state["vars"].get(f"SECTION_{section_id}_STATUS"),
|
|
716
|
+
"overall_progress": f"{overall_recorded}/{overall_total}",
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def cmd_var(state_path: str, action: str, key: str, value=None) -> dict:
|
|
721
|
+
"""Store or retrieve a variable in state."""
|
|
722
|
+
state = read_state(state_path)
|
|
723
|
+
|
|
724
|
+
if action == "set":
|
|
725
|
+
if value is None:
|
|
726
|
+
print("Error: 'var set' requires a value.", file=sys.stderr)
|
|
727
|
+
sys.exit(1)
|
|
728
|
+
state["vars"][key] = value
|
|
729
|
+
write_state(state_path, state)
|
|
730
|
+
return {"action": "set", "key": key, "value": value}
|
|
731
|
+
|
|
732
|
+
elif action == "get":
|
|
733
|
+
if key not in state["vars"]:
|
|
734
|
+
return {"action": "get", "key": key, "exists": False}
|
|
735
|
+
return {"action": "get", "key": key, "value": state["vars"][key], "exists": True}
|
|
736
|
+
|
|
737
|
+
else:
|
|
738
|
+
print(f"Error: unknown var action '{action}'. Use 'set' or 'get'.", file=sys.stderr)
|
|
739
|
+
sys.exit(1)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _step_prereq_status(state: dict, step_id: str, run_scope: Optional[str] = None) -> str:
|
|
743
|
+
"""Check if a single step was completed in the current run scope."""
|
|
744
|
+
step_data = state.get("steps", {}).get(step_id)
|
|
745
|
+
if step_data is None:
|
|
746
|
+
return "not_run"
|
|
747
|
+
if run_scope is not None and step_data.get("scope") != run_scope:
|
|
748
|
+
return "stale_run"
|
|
749
|
+
results = step_data.get("results", [])
|
|
750
|
+
if any(result == "fail" for result in results):
|
|
751
|
+
return "failed"
|
|
752
|
+
if results and all(result == "skip" for result in results):
|
|
753
|
+
return "skipped"
|
|
754
|
+
return "passed"
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def cmd_prereq_check(data: dict, state_path: str, step_id: str) -> dict:
|
|
758
|
+
"""Check prerequisites for a step. Returns ok/missing/statuses."""
|
|
759
|
+
state = read_state(state_path)
|
|
760
|
+
|
|
761
|
+
# Find the step and its section
|
|
762
|
+
target_sub = None
|
|
763
|
+
target_section = None
|
|
764
|
+
for s in data["sections"]:
|
|
765
|
+
for sub in s["subsections"]:
|
|
766
|
+
if sub["id"] == step_id:
|
|
767
|
+
target_sub = sub
|
|
768
|
+
target_section = s
|
|
769
|
+
break
|
|
770
|
+
if target_sub:
|
|
771
|
+
break
|
|
772
|
+
|
|
773
|
+
if target_sub is None:
|
|
774
|
+
# Try as a section ID (e.g., "5" -> check first subsection's prereqs)
|
|
775
|
+
for s in data["sections"]:
|
|
776
|
+
if s["id"] == step_id:
|
|
777
|
+
target_section = s
|
|
778
|
+
if s["subsections"]:
|
|
779
|
+
target_sub = s["subsections"][0]
|
|
780
|
+
break
|
|
781
|
+
|
|
782
|
+
if target_section is None:
|
|
783
|
+
print(f"Error: step or section '{step_id}' not found.", file=sys.stderr)
|
|
784
|
+
sys.exit(1)
|
|
785
|
+
|
|
786
|
+
# Merge section + subsection prereqs
|
|
787
|
+
section_prereqs = target_section.get("prereqs", [])
|
|
788
|
+
sub_prereqs = target_sub.get("prereqs", []) if target_sub else []
|
|
789
|
+
all_prereqs = list(dict.fromkeys(section_prereqs + sub_prereqs))
|
|
790
|
+
|
|
791
|
+
if not all_prereqs:
|
|
792
|
+
return {"ok": True, "required": [], "missing": [], "blocking": [], "resolvable": [], "statuses": {}}
|
|
793
|
+
|
|
794
|
+
# Check each prereq against the current run scope.
|
|
795
|
+
run_scope = _current_run_scope(state)
|
|
796
|
+
statuses: dict[str, str] = {}
|
|
797
|
+
missing: list[str] = []
|
|
798
|
+
blocking: list[str] = []
|
|
799
|
+
for prereq_id in all_prereqs:
|
|
800
|
+
if "." in prereq_id:
|
|
801
|
+
# Subsection-level prereq (e.g., "3.2"): check if step was recorded
|
|
802
|
+
status = _step_prereq_status(state, prereq_id, run_scope)
|
|
803
|
+
else:
|
|
804
|
+
# Section-level prereq (e.g., "3"): check full section completion
|
|
805
|
+
section_state = _section_state(data, state, prereq_id, run_scope=run_scope)
|
|
806
|
+
status = section_state["status"]
|
|
807
|
+
statuses[prereq_id] = status
|
|
808
|
+
if status != "passed":
|
|
809
|
+
blocking.append(prereq_id)
|
|
810
|
+
if status == "not_run":
|
|
811
|
+
missing.append(prereq_id)
|
|
812
|
+
|
|
813
|
+
# For each missing step-level prereq, check if it's resolvable:
|
|
814
|
+
# its section prereqs are all satisfied, so the agent can run it immediately.
|
|
815
|
+
resolvable: list[str] = []
|
|
816
|
+
for prereq_id in missing:
|
|
817
|
+
if "." not in prereq_id:
|
|
818
|
+
continue # Section-level prereqs are too broad to auto-resolve
|
|
819
|
+
# Find the section this prereq step belongs to
|
|
820
|
+
prereq_step = find_step(data, prereq_id)
|
|
821
|
+
if prereq_step is None:
|
|
822
|
+
continue
|
|
823
|
+
prereq_section = find_section(data, prereq_step["section_id"])
|
|
824
|
+
if prereq_section is None:
|
|
825
|
+
continue
|
|
826
|
+
# Check if the prereq step's section prereqs are all satisfied
|
|
827
|
+
section_prereqs = prereq_section.get("prereqs", [])
|
|
828
|
+
all_section_prereqs_ok = True
|
|
829
|
+
for sp in section_prereqs:
|
|
830
|
+
if "." in sp:
|
|
831
|
+
sp_status = _step_prereq_status(state, sp, run_scope)
|
|
832
|
+
else:
|
|
833
|
+
sp_state = _section_state(data, state, sp, run_scope=run_scope)
|
|
834
|
+
sp_status = sp_state["status"]
|
|
835
|
+
if sp_status != "passed":
|
|
836
|
+
all_section_prereqs_ok = False
|
|
837
|
+
break
|
|
838
|
+
if all_section_prereqs_ok:
|
|
839
|
+
resolvable.append(prereq_id)
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
"ok": len(blocking) == 0,
|
|
843
|
+
"required": all_prereqs,
|
|
844
|
+
"missing": missing,
|
|
845
|
+
"blocking": blocking,
|
|
846
|
+
"resolvable": resolvable,
|
|
847
|
+
"statuses": statuses,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def cmd_report(data: dict, checklist_path: str, state_path: str) -> dict:
|
|
852
|
+
"""Generate final summary by joining state with checklist structure."""
|
|
853
|
+
state = read_state(state_path)
|
|
854
|
+
|
|
855
|
+
# Auto-migrate v1 state files
|
|
856
|
+
if state.get("schema_version", 1) < 2:
|
|
857
|
+
state = _migrate_v1_to_v2(state, data, checklist_path)
|
|
858
|
+
write_state(state_path, state)
|
|
859
|
+
|
|
860
|
+
# Per-step hash validation (fail-open: warn, don't exit)
|
|
861
|
+
changed_steps = []
|
|
862
|
+
unverified_steps = []
|
|
863
|
+
orphaned_steps = []
|
|
864
|
+
|
|
865
|
+
checklist_step_ids = {sub["id"] for sub in data["_all_subs"]}
|
|
866
|
+
for sid, sdata in state.get("steps", {}).items():
|
|
867
|
+
if sid not in checklist_step_ids:
|
|
868
|
+
orphaned_steps.append(sid)
|
|
869
|
+
continue
|
|
870
|
+
stored_hash = sdata.get("hash")
|
|
871
|
+
if stored_hash is None:
|
|
872
|
+
unverified_steps.append(sid)
|
|
873
|
+
continue
|
|
874
|
+
found = find_step(data, sid)
|
|
875
|
+
if found and step_hash(found) != stored_hash:
|
|
876
|
+
changed_steps.append({"id": sid, "reason": "step content changed since recorded"})
|
|
877
|
+
|
|
878
|
+
sections = []
|
|
879
|
+
total_pass = 0
|
|
880
|
+
total_fail = 0
|
|
881
|
+
total_skip = 0
|
|
882
|
+
failures = []
|
|
883
|
+
gaps = []
|
|
884
|
+
|
|
885
|
+
for s in data["sections"]:
|
|
886
|
+
s_pass = 0
|
|
887
|
+
s_fail = 0
|
|
888
|
+
s_skip = 0
|
|
889
|
+
|
|
890
|
+
for sub in s["subsections"]:
|
|
891
|
+
if sub["id"] not in state["steps"]:
|
|
892
|
+
gaps.append(sub["id"])
|
|
893
|
+
continue
|
|
894
|
+
|
|
895
|
+
results = state["steps"][sub["id"]]["results"]
|
|
896
|
+
for i, r in enumerate(results):
|
|
897
|
+
if r == "pass":
|
|
898
|
+
s_pass += 1
|
|
899
|
+
elif r == "fail":
|
|
900
|
+
s_fail += 1
|
|
901
|
+
failures.append(
|
|
902
|
+
{
|
|
903
|
+
"step": sub["id"],
|
|
904
|
+
"title": sub["title"],
|
|
905
|
+
"assertion_index": i,
|
|
906
|
+
"text": sub["assertions"][i] if i < len(sub["assertions"]) else "?",
|
|
907
|
+
}
|
|
908
|
+
)
|
|
909
|
+
elif r == "skip":
|
|
910
|
+
s_skip += 1
|
|
911
|
+
|
|
912
|
+
sections.append(
|
|
913
|
+
{
|
|
914
|
+
"id": s["id"],
|
|
915
|
+
"title": s["title"],
|
|
916
|
+
"expected": s["assertion_count"],
|
|
917
|
+
"pass": s_pass,
|
|
918
|
+
"fail": s_fail,
|
|
919
|
+
"skip": s_skip,
|
|
920
|
+
}
|
|
921
|
+
)
|
|
922
|
+
total_pass += s_pass
|
|
923
|
+
total_fail += s_fail
|
|
924
|
+
total_skip += s_skip
|
|
925
|
+
|
|
926
|
+
result = {
|
|
927
|
+
"total": {
|
|
928
|
+
"expected": data["total_assertions"],
|
|
929
|
+
"pass": total_pass,
|
|
930
|
+
"fail": total_fail,
|
|
931
|
+
"skip": total_skip,
|
|
932
|
+
},
|
|
933
|
+
"sections": sections,
|
|
934
|
+
"failures": failures,
|
|
935
|
+
"gaps": gaps,
|
|
936
|
+
"complete": len(gaps) == 0,
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
# Attach warnings (agent decides how to present these)
|
|
940
|
+
if changed_steps or unverified_steps or orphaned_steps:
|
|
941
|
+
result["warnings"] = {}
|
|
942
|
+
if changed_steps:
|
|
943
|
+
result["warnings"]["changed_steps"] = changed_steps
|
|
944
|
+
if unverified_steps:
|
|
945
|
+
result["warnings"]["unverified_steps"] = unverified_steps
|
|
946
|
+
if orphaned_steps:
|
|
947
|
+
result["warnings"]["orphaned_steps"] = orphaned_steps
|
|
948
|
+
|
|
949
|
+
return result
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def cmd_validate(data: dict, checklist_path: str, state_path: str, from_step: str) -> dict:
|
|
953
|
+
"""Pre-flight validation for resume. Checks hashes and clears stale future steps.
|
|
954
|
+
|
|
955
|
+
Steps before from_step: validate stored hash vs current checklist.
|
|
956
|
+
Steps at/after from_step: clear from state to prevent phantom progress.
|
|
957
|
+
Returns JSON with changed_steps, unverified_steps, cleared_steps.
|
|
958
|
+
"""
|
|
959
|
+
state = read_state(state_path)
|
|
960
|
+
|
|
961
|
+
# Auto-migrate v1 state files
|
|
962
|
+
if state.get("schema_version", 1) < 2:
|
|
963
|
+
state = _migrate_v1_to_v2(state, data, checklist_path)
|
|
964
|
+
|
|
965
|
+
# Resolve section-level IDs (e.g., '3' or '3.0' -> '3.1')
|
|
966
|
+
from_step = resolve_step_id(data, from_step)
|
|
967
|
+
|
|
968
|
+
# Build step order from checklist
|
|
969
|
+
step_order = [sub["id"] for sub in data["_all_subs"]]
|
|
970
|
+
try:
|
|
971
|
+
from_index = step_order.index(from_step)
|
|
972
|
+
except ValueError:
|
|
973
|
+
print(f"Error: step '{from_step}' not found in checklist.", file=sys.stderr)
|
|
974
|
+
sys.exit(1)
|
|
975
|
+
|
|
976
|
+
before_steps = set(step_order[:from_index])
|
|
977
|
+
at_or_after_steps = set(step_order[from_index:])
|
|
978
|
+
|
|
979
|
+
changed_steps = []
|
|
980
|
+
unverified_steps = []
|
|
981
|
+
cleared_steps = []
|
|
982
|
+
orphaned_steps = []
|
|
983
|
+
|
|
984
|
+
all_checklist_ids = set(step_order)
|
|
985
|
+
for sid, sdata in list(state.get("steps", {}).items()):
|
|
986
|
+
# Orphaned steps (no longer in checklist): purge
|
|
987
|
+
if sid not in all_checklist_ids:
|
|
988
|
+
orphaned_steps.append(sid)
|
|
989
|
+
del state["steps"][sid]
|
|
990
|
+
continue
|
|
991
|
+
|
|
992
|
+
# Steps at/after resume point: clear to prevent phantom progress
|
|
993
|
+
if sid in at_or_after_steps:
|
|
994
|
+
cleared_steps.append(sid)
|
|
995
|
+
del state["steps"][sid]
|
|
996
|
+
continue
|
|
997
|
+
|
|
998
|
+
# Steps before resume point: validate hash
|
|
999
|
+
if sid in before_steps:
|
|
1000
|
+
stored_hash = sdata.get("hash")
|
|
1001
|
+
if stored_hash is None:
|
|
1002
|
+
unverified_steps.append(sid)
|
|
1003
|
+
continue
|
|
1004
|
+
found = find_step(data, sid)
|
|
1005
|
+
if found and step_hash(found) != stored_hash:
|
|
1006
|
+
changed_steps.append({"id": sid, "reason": "step content changed since recorded"})
|
|
1007
|
+
|
|
1008
|
+
# Update current_step to the resume point
|
|
1009
|
+
state["current_step"] = from_step
|
|
1010
|
+
_refresh_section_status_vars(data, state)
|
|
1011
|
+
write_state(state_path, state)
|
|
1012
|
+
|
|
1013
|
+
status = "ok"
|
|
1014
|
+
if changed_steps or unverified_steps:
|
|
1015
|
+
status = "warnings"
|
|
1016
|
+
|
|
1017
|
+
return {
|
|
1018
|
+
"status": status,
|
|
1019
|
+
"changed_steps": changed_steps,
|
|
1020
|
+
"unverified_steps": unverified_steps,
|
|
1021
|
+
"cleared_steps": cleared_steps,
|
|
1022
|
+
"orphaned_steps": orphaned_steps,
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
# --- CLI dispatch ---
|
|
1027
|
+
|
|
1028
|
+
COMMANDS = ["index", "step", "summary", "init", "record", "var", "prereq-check", "report", "validate"]
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def main():
|
|
1032
|
+
if len(sys.argv) < 3:
|
|
1033
|
+
print(f"Usage: {sys.argv[0]} <checklist> <command> [args...]", file=sys.stderr)
|
|
1034
|
+
print(f"Commands: {', '.join(COMMANDS)}", file=sys.stderr)
|
|
1035
|
+
sys.exit(1)
|
|
1036
|
+
|
|
1037
|
+
checklist_path = sys.argv[1]
|
|
1038
|
+
command = sys.argv[2]
|
|
1039
|
+
rest = sys.argv[3:]
|
|
1040
|
+
|
|
1041
|
+
if command not in COMMANDS:
|
|
1042
|
+
print(f"Error: unknown command '{command}'. Valid: {', '.join(COMMANDS)}", file=sys.stderr)
|
|
1043
|
+
sys.exit(1)
|
|
1044
|
+
|
|
1045
|
+
# Parse checklist (needed for all commands)
|
|
1046
|
+
data = parse_checklist(checklist_path)
|
|
1047
|
+
|
|
1048
|
+
# Read-only commands
|
|
1049
|
+
if command == "index":
|
|
1050
|
+
result = cmd_index(data)
|
|
1051
|
+
|
|
1052
|
+
elif command == "step":
|
|
1053
|
+
if not rest:
|
|
1054
|
+
print("Error: 'step' requires a step ID (e.g., 6.3)", file=sys.stderr)
|
|
1055
|
+
sys.exit(1)
|
|
1056
|
+
result = cmd_step(data, rest[0])
|
|
1057
|
+
|
|
1058
|
+
elif command == "summary":
|
|
1059
|
+
result = cmd_summary(data)
|
|
1060
|
+
|
|
1061
|
+
# State commands
|
|
1062
|
+
elif command == "init":
|
|
1063
|
+
force = "--force" in rest
|
|
1064
|
+
mode = "walkthrough"
|
|
1065
|
+
positional = []
|
|
1066
|
+
skip_next = False
|
|
1067
|
+
for i, arg in enumerate(rest):
|
|
1068
|
+
if skip_next:
|
|
1069
|
+
skip_next = False
|
|
1070
|
+
continue
|
|
1071
|
+
if arg == "--force":
|
|
1072
|
+
continue
|
|
1073
|
+
if arg == "--mode" and i + 1 < len(rest):
|
|
1074
|
+
mode = rest[i + 1]
|
|
1075
|
+
skip_next = True
|
|
1076
|
+
continue
|
|
1077
|
+
positional.append(arg)
|
|
1078
|
+
if not positional:
|
|
1079
|
+
print("Error: 'init' requires a state file path.", file=sys.stderr)
|
|
1080
|
+
sys.exit(1)
|
|
1081
|
+
state_path = positional[0]
|
|
1082
|
+
result = cmd_init(data, checklist_path, state_path, mode, force)
|
|
1083
|
+
|
|
1084
|
+
elif command == "record":
|
|
1085
|
+
if len(rest) < 3:
|
|
1086
|
+
print("Error: 'record' requires <state-file> <step_id> <results>", file=sys.stderr)
|
|
1087
|
+
sys.exit(1)
|
|
1088
|
+
state_path, step_id, results_csv = rest[0], rest[1], rest[2]
|
|
1089
|
+
force = "--force" in rest
|
|
1090
|
+
result = cmd_record(data, checklist_path, state_path, step_id, results_csv, force)
|
|
1091
|
+
|
|
1092
|
+
elif command == "var":
|
|
1093
|
+
if len(rest) < 3:
|
|
1094
|
+
print("Error: 'var' requires <state-file> set|get <key> [<value>]", file=sys.stderr)
|
|
1095
|
+
sys.exit(1)
|
|
1096
|
+
state_path, action, key = rest[0], rest[1], rest[2]
|
|
1097
|
+
value = rest[3] if len(rest) > 3 else None
|
|
1098
|
+
result = cmd_var(state_path, action, key, value)
|
|
1099
|
+
|
|
1100
|
+
elif command == "prereq-check":
|
|
1101
|
+
if len(rest) < 2:
|
|
1102
|
+
print("Error: 'prereq-check' requires <state-file> <step_id|section_id>", file=sys.stderr)
|
|
1103
|
+
sys.exit(1)
|
|
1104
|
+
result = cmd_prereq_check(data, rest[0], rest[1])
|
|
1105
|
+
|
|
1106
|
+
elif command == "report":
|
|
1107
|
+
if not rest:
|
|
1108
|
+
print("Error: 'report' requires a state file path.", file=sys.stderr)
|
|
1109
|
+
sys.exit(1)
|
|
1110
|
+
result = cmd_report(data, checklist_path, rest[0])
|
|
1111
|
+
|
|
1112
|
+
elif command == "validate":
|
|
1113
|
+
from_step = None
|
|
1114
|
+
positional = []
|
|
1115
|
+
skip_next = False
|
|
1116
|
+
for i, arg in enumerate(rest):
|
|
1117
|
+
if skip_next:
|
|
1118
|
+
skip_next = False
|
|
1119
|
+
continue
|
|
1120
|
+
if arg == "--from" and i + 1 < len(rest):
|
|
1121
|
+
from_step = rest[i + 1]
|
|
1122
|
+
skip_next = True
|
|
1123
|
+
continue
|
|
1124
|
+
positional.append(arg)
|
|
1125
|
+
if not positional:
|
|
1126
|
+
print("Error: 'validate' requires a state file path.", file=sys.stderr)
|
|
1127
|
+
sys.exit(1)
|
|
1128
|
+
if not from_step:
|
|
1129
|
+
print("Error: 'validate' requires --from <step_id>.", file=sys.stderr)
|
|
1130
|
+
sys.exit(1)
|
|
1131
|
+
result = cmd_validate(data, checklist_path, positional[0], from_step)
|
|
1132
|
+
|
|
1133
|
+
print(json.dumps(result, indent=2))
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
if __name__ == "__main__":
|
|
1137
|
+
main()
|