@sushanglewis/lincoln 1.5.0 → 1.5.2
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/dist/main.d.ts +3 -0
- package/dist/main.js +1286 -916
- package/dist/main.js.map +1 -1
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.js +18 -0
- package/dist/postinstall.js.map +1 -0
- package/framework/.claude/agents/_contract.md +79 -0
- package/framework/.claude/agents/architect.md +31 -0
- package/framework/.claude/agents/default.md +151 -0
- package/framework/.claude/agents/designer.md +33 -0
- package/framework/.claude/agents/engineer.md +42 -0
- package/framework/.claude/agents/external/NOTICES.md +12 -0
- package/framework/.claude/agents/external/everything-claude-code.manifest.yaml +23 -0
- package/framework/.claude/agents/external/oh-my-claudecode.manifest.yaml +26 -0
- package/framework/.claude/agents/external/wshobson-agents.manifest.yaml +23 -0
- package/framework/.claude/agents/frontend-engineer.md +32 -0
- package/framework/.claude/agents/knowledge-curator.md +28 -0
- package/framework/.claude/agents/pm.md +38 -0
- package/framework/.claude/agents/qa.md +28 -0
- package/framework/.claude/agents/release-coordinator.md +25 -0
- package/framework/.claude/agents/researcher.md +41 -0
- package/framework/.claude/harnesses/claude-code.yaml +15 -0
- package/framework/.claude/harnesses/codex.yaml +40 -0
- package/framework/.claude/harnesses/command-map.yaml +255 -0
- package/framework/.claude/harnesses/opencode.yaml +32 -0
- package/framework/.claude/harnesses/scenarios.yaml +45 -0
- package/framework/.claude/hooks/on-pr-event.sh +69 -0
- package/framework/.claude/hooks/on-session-start.sh +427 -0
- package/framework/.claude/hooks/on-stop.sh +104 -0
- package/framework/.claude/hooks/post-tool-use.sh +161 -0
- package/framework/.claude/hooks/pre-tool-use.sh +246 -0
- package/framework/.claude/schemas/agent-definition.schema.json +35 -0
- package/framework/.claude/schemas/benchmark-report.schema.json +148 -0
- package/framework/.claude/schemas/pm-to-ux-handoff.schema.yaml +128 -0
- package/framework/.claude/schemas/stage-definition.schema.json +233 -0
- package/framework/.claude/schemas/workflow-stage.schema.json +86 -0
- package/framework/.claude/schemas/workflow-template.schema.json +88 -0
- package/framework/.claude/settings.json +14 -0
- package/framework/.claude/skills/build-product-prototype/SKILL.md +35 -0
- package/framework/.claude/skills/build-product-prototype/prompts/main.md +78 -0
- package/framework/.claude/skills/clarify-requirements/SKILL.md +32 -0
- package/framework/.claude/skills/clarify-requirements/prompts/main.md +81 -0
- package/framework/.claude/skills/dependencies.yaml +139 -0
- package/framework/.claude/skills/draft-product-design/SKILL.md +40 -0
- package/framework/.claude/skills/draft-product-design/prompts/main.md +47 -0
- package/framework/.claude/skills/lc-analyze-frameworks/SKILL.md +43 -0
- package/framework/.claude/skills/lc-analyze-frameworks/prompts/main.md +95 -0
- package/framework/.claude/skills/lc-benchmark/SKILL.md +61 -0
- package/framework/.claude/skills/lc-build-codebase-knowledge/SKILL.md +38 -0
- package/framework/.claude/skills/lc-build-codebase-knowledge/prompts/build-codebase-knowledge.md +61 -0
- package/framework/.claude/skills/lc-collect-intelligence/SKILL.md +43 -0
- package/framework/.claude/skills/lc-collect-intelligence/prompts/main.md +72 -0
- package/framework/.claude/skills/lc-competitive-analysis/SKILL.md +43 -0
- package/framework/.claude/skills/lc-competitive-analysis/prompts/main.md +70 -0
- package/framework/.claude/skills/lc-explore-opensource/SKILL.md +36 -0
- package/framework/.claude/skills/lc-explore-opensource/prompts/explore-opensource.md +77 -0
- package/framework/.claude/skills/lc-first-principles/SKILL.md +43 -0
- package/framework/.claude/skills/lc-first-principles/prompts/main.md +73 -0
- package/framework/.claude/skills/lc-handoff/SKILL.md +34 -0
- package/framework/.claude/skills/lc-init-branch/SKILL.md +52 -0
- package/framework/.claude/skills/lc-market-research/SKILL.md +43 -0
- package/framework/.claude/skills/lc-market-research/prompts/main.md +79 -0
- package/framework/.claude/skills/lc-product-research/SKILL.md +43 -0
- package/framework/.claude/skills/lc-product-research/prompts/main.md +80 -0
- package/framework/.claude/skills/lc-research-report/SKILL.md +43 -0
- package/framework/.claude/skills/lc-research-report/prompts/main.md +90 -0
- package/framework/.claude/skills/lc-research-scope/SKILL.md +43 -0
- package/framework/.claude/skills/lc-research-scope/prompts/main.md +82 -0
- package/framework/.claude/skills/lc-setup/SKILL.md +68 -0
- package/framework/.claude/skills/lc-setup/prompts/main.md +88 -0
- package/framework/.claude/skills/lc-stage/SKILL.md +45 -0
- package/framework/.claude/skills/lc-stakeholder-research/SKILL.md +43 -0
- package/framework/.claude/skills/lc-stakeholder-research/prompts/main.md +65 -0
- package/framework/.claude/skills/lc-status/SKILL.md +25 -0
- package/framework/.claude/skills/lc-storytelling/SKILL.md +43 -0
- package/framework/.claude/skills/lc-storytelling/prompts/main.md +77 -0
- package/framework/.claude/skills/lc-wf/SKILL.md +54 -0
- package/framework/.claude/skills/lc-workflow/SKILL.md +48 -0
- package/framework/.claude/skills/lc-workflow/validators/validate.py +755 -0
- package/framework/.claude/skills/lc-workflow-router/SKILL.md +41 -0
- package/framework/.claude/skills/lc-workflow-router/prompts/intake-prompt.md +91 -0
- package/framework/.claude/skills/lc-workflow-router/prompts/router-prompt.md +44 -0
- package/framework/.claude/skills/plan-tdd-development/SKILL.md +31 -0
- package/framework/.claude/skills/plan-tdd-development/prompts/main.md +44 -0
- package/framework/.claude/skills/process-interview/SKILL.md +32 -0
- package/framework/.claude/skills/process-interview/prompts/main.md +47 -0
- package/framework/.claude/skills/propose-with-openspec/SKILL.md +37 -0
- package/framework/.claude/skills/propose-with-openspec/prompts/main.md +53 -0
- package/framework/.claude/skills/split-to-github/SKILL.md +32 -0
- package/framework/.claude/skills/split-to-github/prompts/main.md +42 -0
- package/framework/.claude/skills/sync-to-knowledge/SKILL.md +35 -0
- package/framework/.claude/skills/sync-to-knowledge/prompts/main.md +54 -0
- package/framework/.claude/skills/workflow-continue/SKILL.md +24 -0
- package/framework/.claude/skills/workflow-continue/prompts/main.md +25 -0
- package/framework/.claude/stages/build-codebase-knowledge.yaml +42 -0
- package/framework/.claude/stages/clarify.yaml +59 -0
- package/framework/.claude/stages/explore-opensource.yaml +40 -0
- package/framework/.claude/stages/implement.yaml +60 -0
- package/framework/.claude/stages/ingest.yaml +40 -0
- package/framework/.claude/stages/lc-analyze-frameworks.yaml +37 -0
- package/framework/.claude/stages/lc-collect-intelligence.yaml +37 -0
- package/framework/.claude/stages/lc-competitive-analysis.yaml +37 -0
- package/framework/.claude/stages/lc-first-principles.yaml +37 -0
- package/framework/.claude/stages/lc-market-research.yaml +37 -0
- package/framework/.claude/stages/lc-product-research.yaml +37 -0
- package/framework/.claude/stages/lc-research-report.yaml +38 -0
- package/framework/.claude/stages/lc-research-scope.yaml +38 -0
- package/framework/.claude/stages/lc-stakeholder-research.yaml +37 -0
- package/framework/.claude/stages/lc-storytelling.yaml +37 -0
- package/framework/.claude/stages/product-design-docs.yaml +66 -0
- package/framework/.claude/stages/product-prototype.yaml +53 -0
- package/framework/.claude/stages/propose.yaml +45 -0
- package/framework/.claude/stages/split.yaml +41 -0
- package/framework/.claude/stages/sync-knowledge.yaml +49 -0
- package/framework/.claude/stages/tdd-development-plan.yaml +43 -0
- package/framework/.claude/stages/workflow-router.yaml +45 -0
- package/framework/.claude/templates/solo-workflow-context.yaml +23 -0
- package/framework/.claude/workflows/README.md +93 -0
- package/framework/.claude/workflows/bug-fix.yaml +81 -0
- package/framework/.claude/workflows/design-spike.yaml +55 -0
- package/framework/.claude/workflows/existing-project-iteration.yaml +106 -0
- package/framework/.claude/workflows/interview-to-knowledge.yaml +189 -0
- package/framework/.claude/workflows/oss-first-design.yaml +100 -0
- package/framework/.claude/workflows/pm-research.yaml +115 -0
- package/framework/.claude-plugin/hooks.json +6 -0
- package/framework/.claude-plugin/marketplace.json +32 -0
- package/framework/.claude-plugin/plugin.json +57 -0
- package/framework/.version-bump.json +43 -0
- package/framework/CLAUDE.md +140 -0
- package/framework/CONTRIBUTING.md +219 -0
- package/framework/LICENSE +21 -0
- package/framework/README.en.md +139 -0
- package/framework/README.md +139 -0
- package/framework/RELEASE.md +334 -0
- package/framework/SKILL.md +24 -0
- package/framework/USAGE.md +356 -0
- package/framework/manifest.json +1214 -0
- package/framework/requirements.txt +4 -0
- package/framework/scripts/__init__.py +0 -0
- package/framework/scripts/build_codebase_knowledge.py +76 -0
- package/framework/scripts/bump_version.py +181 -0
- package/framework/scripts/check-dependency-drift.py +69 -0
- package/framework/scripts/check-harness-drift.sh +57 -0
- package/framework/scripts/check-main-merge-hygiene.py +128 -0
- package/framework/scripts/check-skill-dependencies.sh +124 -0
- package/framework/scripts/init-lincoln-branch.sh +300 -0
- package/framework/scripts/init-project.sh +184 -0
- package/framework/scripts/lc-benchmark-cli.py +66 -0
- package/framework/scripts/lincoln-audit.py +288 -0
- package/framework/scripts/lincoln-setup.py +651 -0
- package/framework/scripts/lincoln-status.py +448 -0
- package/framework/scripts/lincoln_benchmark.py +224 -0
- package/framework/scripts/lincoln_benchmark_eval.py +104 -0
- package/framework/scripts/lincoln_benchmark_metrics.py +818 -0
- package/framework/scripts/lincoln_benchmark_report.py +141 -0
- package/framework/scripts/lincoln_command_map.py +289 -0
- package/framework/scripts/lincoln_dependency_manager.py +335 -0
- package/framework/scripts/lincoln_documents.py +175 -0
- package/framework/scripts/lincoln_harness_adapter.py +319 -0
- package/framework/scripts/lincoln_index.py +255 -0
- package/framework/scripts/lincoln_paths.py +198 -0
- package/framework/scripts/lincoln_prd.py +227 -0
- package/framework/scripts/lincoln_role.py +46 -0
- package/framework/scripts/lincoln_scenario.py +59 -0
- package/framework/scripts/lincoln_session_start_metrics.py +71 -0
- package/framework/scripts/lincoln_skill_prompt.py +48 -0
- package/framework/scripts/lincoln_trace.py +335 -0
- package/framework/scripts/lincoln_workflow.py +199 -0
- package/framework/scripts/list-active-lincoln-branches.sh +87 -0
- package/framework/scripts/package-lincoln-plugin.py +340 -0
- package/framework/scripts/run-infrastructure-tests.py +84 -0
- package/framework/scripts/stage_loader.py +1071 -0
- package/framework/scripts/static-check.sh +356 -0
- package/framework/scripts/sync-external-agents.sh +210 -0
- package/framework/scripts/sync-framework-package.mjs +218 -0
- package/framework/scripts/task_tool_guard.py +213 -0
- package/framework/scripts/track-artifacts.py +140 -0
- package/framework/scripts/validate_stage.py +350 -0
- package/{README.md → framework/tools/lincoln/README.md} +4 -4
- package/framework/tools/lincoln/package-lock.json +3349 -0
- package/framework/tools/lincoln/package.json +48 -0
- package/framework/tools/lincoln/src/cli.tsx +60 -0
- package/framework/tools/lincoln/src/components/AudioMeter.tsx +34 -0
- package/framework/tools/lincoln/src/components/CancelledScreen.tsx +11 -0
- package/framework/tools/lincoln/src/components/DevicesMenu.tsx +79 -0
- package/framework/tools/lincoln/src/components/HintBar.tsx +25 -0
- package/framework/tools/lincoln/src/components/LogView.tsx +36 -0
- package/framework/tools/lincoln/src/components/ModelMenu.tsx +122 -0
- package/framework/tools/lincoln/src/components/ReadyScreen.tsx +54 -0
- package/framework/tools/lincoln/src/components/RecordingApp.tsx +211 -0
- package/framework/tools/lincoln/src/components/RecordingScreen.tsx +41 -0
- package/framework/tools/lincoln/src/components/SelectMenu.tsx +53 -0
- package/framework/tools/lincoln/src/components/StatusPanel.tsx +52 -0
- package/framework/tools/lincoln/src/components/StopConfirmation.tsx +32 -0
- package/framework/tools/lincoln/src/config/args.ts +35 -0
- package/framework/tools/lincoln/src/config/loadConfig.ts +81 -0
- package/framework/tools/lincoln/src/config/resolveConfig.ts +85 -0
- package/framework/tools/lincoln/src/devices/listDevices.ts +11 -0
- package/framework/tools/lincoln/src/devices/parseDevices.ts +25 -0
- package/framework/tools/lincoln/src/hooks/useKeyHandler.ts +63 -0
- package/framework/tools/lincoln/src/main.tsx +4 -0
- package/framework/tools/lincoln/src/models/models.ts +34 -0
- package/framework/tools/lincoln/src/models/runWarmup.ts +46 -0
- package/framework/tools/lincoln/src/models/warmupProgress.ts +30 -0
- package/framework/tools/lincoln/src/recording/spawnRecorder.ts +127 -0
- package/framework/tools/lincoln/src/recording/useRecorder.ts +146 -0
- package/framework/tools/lincoln/src/utils/amplitudeToMeter.ts +4 -0
- package/framework/tools/lincoln/src/utils/formatDuration.ts +6 -0
- package/framework/tools/lincoln/src/utils/sessionId.ts +25 -0
- package/framework/tools/lincoln/tests/cli.test.tsx +52 -0
- package/framework/tools/lincoln/tests/components/AudioMeter.test.tsx +31 -0
- package/framework/tools/lincoln/tests/components/CancelledScreen.test.tsx +12 -0
- package/framework/tools/lincoln/tests/components/ReadyScreen.test.tsx +68 -0
- package/framework/tools/lincoln/tests/components/RecordingApp.menus.test.tsx +158 -0
- package/framework/tools/lincoln/tests/components/RecordingApp.test.tsx +248 -0
- package/framework/tools/lincoln/tests/components/RecordingScreen.test.tsx +37 -0
- package/framework/tools/lincoln/tests/components/SelectMenu.test.tsx +59 -0
- package/framework/tools/lincoln/tests/components/StopConfirmation.test.tsx +38 -0
- package/framework/tools/lincoln/tests/config/args.test.ts +49 -0
- package/framework/tools/lincoln/tests/config/loadConfig.test.ts +59 -0
- package/framework/tools/lincoln/tests/config/resolveConfig.test.ts +72 -0
- package/framework/tools/lincoln/tests/devices/parseDevices.test.ts +21 -0
- package/framework/tools/lincoln/tests/hooks/useKeyHandler.enabled.test.tsx +44 -0
- package/framework/tools/lincoln/tests/hooks/useKeyHandler.test.tsx +130 -0
- package/framework/tools/lincoln/tests/models/models.test.ts +27 -0
- package/framework/tools/lincoln/tests/models/warmupProgress.test.ts +25 -0
- package/framework/tools/lincoln/tests/recording/spawnRecorder.test.ts +85 -0
- package/framework/tools/lincoln/tests/recording/useRecorder.test.tsx +172 -0
- package/framework/tools/lincoln/tests/utils/amplitudeToMeter.test.ts +22 -0
- package/framework/tools/lincoln/tests/utils/formatDuration.test.ts +11 -0
- package/framework/tools/lincoln/tests/utils/sessionId.test.ts +30 -0
- package/framework/tools/lincoln/tsconfig.json +22 -0
- package/framework/tools/lincoln/tsconfig.tests.json +10 -0
- package/framework/tools/lincoln/tsup.config.ts +17 -0
- package/framework/tools/lincoln/vitest.config.ts +20 -0
- package/framework/tools/lincoln-installer/package-lock.json +3611 -0
- package/framework/tools/lincoln-installer/package.json +53 -0
- package/framework/tools/lincoln-installer/src/cli.tsx +63 -0
- package/framework/tools/lincoln-installer/src/components/HarnessSelectScreen.tsx +43 -0
- package/framework/tools/lincoln-installer/src/components/InstallerApp.tsx +98 -0
- package/framework/tools/lincoln-installer/src/components/OptionsScreen.tsx +56 -0
- package/framework/tools/lincoln-installer/src/components/ProgressScreen.tsx +25 -0
- package/framework/tools/lincoln-installer/src/components/ResultScreen.tsx +40 -0
- package/framework/tools/lincoln-installer/src/components/SelectMenu.tsx +73 -0
- package/framework/tools/lincoln-installer/src/components/SummaryScreen.tsx +49 -0
- package/framework/tools/lincoln-installer/src/components/UpdateApp.tsx +135 -0
- package/framework/tools/lincoln-installer/src/components/WelcomeScreen.tsx +22 -0
- package/framework/tools/lincoln-installer/src/config/args.ts +76 -0
- package/framework/tools/lincoln-installer/src/config/resolveConfig.ts +33 -0
- package/framework/tools/lincoln-installer/src/config/updateArgs.ts +67 -0
- package/framework/tools/lincoln-installer/src/hooks/useSetup.ts +149 -0
- package/framework/tools/lincoln-installer/src/main.tsx +11 -0
- package/framework/tools/lincoln-installer/src/types.ts +19 -0
- package/framework/tools/lincoln-installer/src/update-main.tsx +11 -0
- package/framework/tools/lincoln-installer/src/update.tsx +90 -0
- package/framework/tools/lincoln-installer/src/utils/detectHarness.ts +51 -0
- package/framework/tools/lincoln-installer/src/utils/runSetup.ts +62 -0
- package/framework/tools/lincoln-installer/src/utils/updateRelease.ts +454 -0
- package/framework/tools/lincoln-installer/tests/components/InstallerApp.test.tsx +88 -0
- package/framework/tools/lincoln-installer/tests/components/SelectMenu.test.tsx +26 -0
- package/framework/tools/lincoln-installer/tests/components/UpdateApp.test.tsx +170 -0
- package/framework/tools/lincoln-installer/tests/components/screens.test.tsx +310 -0
- package/framework/tools/lincoln-installer/tests/config/args.test.ts +66 -0
- package/framework/tools/lincoln-installer/tests/config/resolveConfig.test.ts +75 -0
- package/framework/tools/lincoln-installer/tests/config/updateArgs.test.ts +52 -0
- package/framework/tools/lincoln-installer/tests/hooks/useSetup.test.tsx +213 -0
- package/framework/tools/lincoln-installer/tests/update.test.tsx +108 -0
- package/framework/tools/lincoln-installer/tests/utils/detectHarness.test.ts +40 -0
- package/framework/tools/lincoln-installer/tests/utils/runSetup.test.ts +45 -0
- package/framework/tools/lincoln-installer/tests/utils/updateRelease.test.ts +559 -0
- package/framework/tools/lincoln-installer/tsconfig.json +22 -0
- package/framework/tools/lincoln-installer/tsconfig.tests.json +8 -0
- package/framework/tools/lincoln-installer/tsup.config.ts +17 -0
- package/framework/tools/lincoln-installer/vitest.config.ts +20 -0
- package/framework/tools/lincoln-record/Cargo.lock +1896 -0
- package/framework/tools/lincoln-record/Cargo.toml +24 -0
- package/framework/tools/lincoln-record/diarize/diarize.py +117 -0
- package/framework/tools/lincoln-record/diarize/merge.py +55 -0
- package/framework/tools/lincoln-record/src/audio/capture/microphone.rs +194 -0
- package/framework/tools/lincoln-record/src/audio/capture/mod.rs +1 -0
- package/framework/tools/lincoln-record/src/audio/devices.rs +20 -0
- package/framework/tools/lincoln-record/src/audio/mod.rs +5 -0
- package/framework/tools/lincoln-record/src/audio/resampler.rs +44 -0
- package/framework/tools/lincoln-record/src/audio/saver.rs +87 -0
- package/framework/tools/lincoln-record/src/audio/source.rs +104 -0
- package/framework/tools/lincoln-record/src/cli.rs +96 -0
- package/framework/tools/lincoln-record/src/config.rs +135 -0
- package/framework/tools/lincoln-record/src/lib.rs +6 -0
- package/framework/tools/lincoln-record/src/main.rs +242 -0
- package/framework/tools/lincoln-record/src/model/mod.rs +248 -0
- package/framework/tools/lincoln-record/src/output/metadata.rs +30 -0
- package/framework/tools/lincoln-record/src/output/mod.rs +2 -0
- package/framework/tools/lincoln-record/src/output/transcript.rs +42 -0
- package/framework/tools/lincoln-record/src/transcription/diarization.rs +64 -0
- package/framework/tools/lincoln-record/src/transcription/mod.rs +3 -0
- package/framework/tools/lincoln-record/src/transcription/provider.rs +43 -0
- package/framework/tools/lincoln-record/src/transcription/whisper.rs +176 -0
- package/framework/tools/lincoln-record/tests/cli_args.rs +103 -0
- package/framework/tools/lincoln-record/tests/config_test.rs +70 -0
- package/framework/tools/lincoln-record/tests/devices_test.rs +23 -0
- package/framework/tools/lincoln-record/tests/diarization_tests.rs +101 -0
- package/framework/tools/lincoln-record/tests/integration_tests.rs +44 -0
- package/framework/tools/lincoln-record/tests/model_test.rs +68 -0
- package/framework/tools/lincoln-record/tests/transcription_tests.rs +73 -0
- package/package.json +19 -13
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Lincoln stage loader / dispatcher.
|
|
4
|
+
|
|
5
|
+
Loads stage-specific context from `.claude/stages/<stage>.yaml`, runs
|
|
6
|
+
entry/exit validators, and manages the per-process workflow state file at
|
|
7
|
+
`<process_slug>/workflow-stage.yaml`.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python scripts/stage_loader.py --stage clarify --action load
|
|
11
|
+
python scripts/stage_loader.py --stage product-design-docs --action validate-entry
|
|
12
|
+
python scripts/stage_loader.py --stage product-design-docs --action validate-exit
|
|
13
|
+
python scripts/stage_loader.py --stage clarify --action transition-next
|
|
14
|
+
python scripts/stage_loader.py --action recover
|
|
15
|
+
python scripts/stage_loader.py --stage clarify --action approve-gate
|
|
16
|
+
python scripts/stage_loader.py --stage clarify --action append-node --node-id clarify --status in_progress
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import glob
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
34
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
35
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
from scripts import lincoln_trace
|
|
40
|
+
from scripts.lincoln_documents import write_documents_index
|
|
41
|
+
from scripts.lincoln_index import write_package_index
|
|
42
|
+
from scripts.lincoln_paths import (
|
|
43
|
+
LEGACY_STATE_PATH,
|
|
44
|
+
ROOT_STATE_PATH,
|
|
45
|
+
get_process_slug,
|
|
46
|
+
interpolate_process_path,
|
|
47
|
+
process_package_root,
|
|
48
|
+
resolve_state_path as resolve_lincoln_state_path,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
WORKFLOW_PATH = PROJECT_ROOT / ".claude" / "workflows" / "interview-to-knowledge.yaml"
|
|
52
|
+
WORKFLOW_TEMPLATE_DIR = PROJECT_ROOT / ".claude" / "workflows"
|
|
53
|
+
DEFAULT_WORKFLOW_PATH = WORKFLOW_PATH
|
|
54
|
+
|
|
55
|
+
STATE_PATH = ROOT_STATE_PATH
|
|
56
|
+
STAGES_DIR = PROJECT_ROOT / ".claude" / "stages"
|
|
57
|
+
VALIDATOR_PATH = PROJECT_ROOT / "scripts" / "validate_stage.py"
|
|
58
|
+
|
|
59
|
+
REQUIRED_STATE_KEYS = {
|
|
60
|
+
"schema_version",
|
|
61
|
+
"workflow",
|
|
62
|
+
"current_run",
|
|
63
|
+
"nodes",
|
|
64
|
+
"recovery",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
LEGACY_REQUIRED_STATE_KEYS = {
|
|
68
|
+
"schema_version",
|
|
69
|
+
"workflow",
|
|
70
|
+
"current_run",
|
|
71
|
+
"stages",
|
|
72
|
+
"variables",
|
|
73
|
+
"recovery",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Helpers
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def now_iso() -> str:
|
|
83
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_yaml(path: Path) -> Any:
|
|
87
|
+
if not path.exists():
|
|
88
|
+
raise FileNotFoundError(path)
|
|
89
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def save_yaml(path: Path, data: Any) -> None:
|
|
93
|
+
"""Write YAML atomically to avoid corrupting the state file."""
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".yaml.tmp")
|
|
96
|
+
try:
|
|
97
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
98
|
+
yaml.dump(data, f, allow_unicode=True, sort_keys=False, width=120)
|
|
99
|
+
os.replace(tmp_path, path)
|
|
100
|
+
except Exception:
|
|
101
|
+
if os.path.exists(tmp_path):
|
|
102
|
+
os.unlink(tmp_path)
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_workflow_path(template_name: str | None = None) -> Path:
|
|
107
|
+
if not template_name:
|
|
108
|
+
return DEFAULT_WORKFLOW_PATH
|
|
109
|
+
template_path = WORKFLOW_TEMPLATE_DIR / f"{template_name}.yaml"
|
|
110
|
+
if template_path.exists():
|
|
111
|
+
return template_path
|
|
112
|
+
raise FileNotFoundError(f"Workflow template not found: {template_path}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_workflow(template_name: str | None = None) -> dict[str, Any]:
|
|
116
|
+
path = resolve_workflow_path(template_name)
|
|
117
|
+
data = load_yaml(path)
|
|
118
|
+
return data.get("workflow", data)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def list_workflow_templates() -> list[str]:
|
|
122
|
+
if not WORKFLOW_TEMPLATE_DIR.exists():
|
|
123
|
+
return []
|
|
124
|
+
return sorted(p.stem for p in WORKFLOW_TEMPLATE_DIR.glob("*.yaml"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_legacy_state(state: dict[str, Any]) -> bool:
|
|
128
|
+
"""Detect whether a state file uses the legacy schema (has 'stages' key)."""
|
|
129
|
+
return "stages" in state and "nodes" not in state
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_state(path: Path | None = None) -> dict[str, Any]:
|
|
133
|
+
path = resolve_state_path(path)
|
|
134
|
+
if not path.exists():
|
|
135
|
+
raise FileNotFoundError(path)
|
|
136
|
+
state = load_yaml(path)
|
|
137
|
+
if not isinstance(state, dict):
|
|
138
|
+
raise ValueError(f"State file {path} must contain a YAML mapping")
|
|
139
|
+
|
|
140
|
+
if _is_legacy_state(state):
|
|
141
|
+
missing = LEGACY_REQUIRED_STATE_KEYS - set(state.keys())
|
|
142
|
+
if missing:
|
|
143
|
+
raise ValueError(f"Legacy state file missing keys: {', '.join(missing)}")
|
|
144
|
+
else:
|
|
145
|
+
missing = REQUIRED_STATE_KEYS - set(state.keys())
|
|
146
|
+
if missing:
|
|
147
|
+
raise ValueError(f"State file missing keys: {', '.join(missing)}")
|
|
148
|
+
return state
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def save_state(state: dict[str, Any], path: Path | None = None) -> None:
|
|
152
|
+
path = resolve_state_path(path)
|
|
153
|
+
state["current_run"]["last_updated_at"] = now_iso()
|
|
154
|
+
save_yaml(path, state)
|
|
155
|
+
write_package_index(state, path)
|
|
156
|
+
write_documents_index(state, path)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_state_path(path: Path | None = None) -> Path:
|
|
160
|
+
"""Return the actual state file path to use, preferring new over legacy."""
|
|
161
|
+
return resolve_lincoln_state_path(path, PROJECT_ROOT)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def find_stage(workflow: dict[str, Any], stage_id: str) -> dict[str, Any]:
|
|
165
|
+
for step in workflow.get("steps", []):
|
|
166
|
+
if step.get("id") == stage_id:
|
|
167
|
+
return step
|
|
168
|
+
raise ValueError(f"Stage '{stage_id}' not found in workflow")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def compute_next_stage(workflow: dict[str, Any], stage_id: str) -> str | None:
|
|
172
|
+
steps = workflow.get("steps", [])
|
|
173
|
+
ids = [s["id"] for s in steps]
|
|
174
|
+
try:
|
|
175
|
+
idx = ids.index(stage_id)
|
|
176
|
+
except ValueError:
|
|
177
|
+
raise ValueError(f"Stage '{stage_id}' not found in workflow")
|
|
178
|
+
if idx + 1 < len(ids):
|
|
179
|
+
return ids[idx + 1]
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def interpolate(value: str, variables: dict[str, Any]) -> str:
|
|
184
|
+
"""Replace {var} placeholders with variable values."""
|
|
185
|
+
|
|
186
|
+
def replacer(match: re.Match) -> str:
|
|
187
|
+
key = match.group(1)
|
|
188
|
+
if key in variables and variables[key] is not None:
|
|
189
|
+
return str(variables[key])
|
|
190
|
+
return match.group(0)
|
|
191
|
+
|
|
192
|
+
return re.sub(r"\{(\w+)\}", replacer, value)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def interpolate_artifact(value: str, state: dict[str, Any], state_file: Path | None = None) -> str:
|
|
196
|
+
value = interpolate(value, get_variables(state))
|
|
197
|
+
return interpolate_process_path(value, state, resolve_state_path(state_file))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_variables(state: dict[str, Any]) -> dict[str, Any]:
|
|
201
|
+
"""Extract variables from state, handling both new and legacy schemas."""
|
|
202
|
+
if _is_legacy_state(state):
|
|
203
|
+
return state.get("variables", {})
|
|
204
|
+
return state.get("current_run", {}).get("variables", {})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_stage_state(state: dict[str, Any], stage_id: str) -> dict[str, Any]:
|
|
208
|
+
"""Get stage state from legacy schema."""
|
|
209
|
+
return state.get("stages", {}).get(stage_id, {})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_nodes(state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
213
|
+
"""Get nodes array from new schema."""
|
|
214
|
+
return state.get("nodes", [])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_latest_node_for_stage(state: dict[str, Any], stage_id: str) -> dict[str, Any] | None:
|
|
218
|
+
"""Return the latest node for a given stage, or None."""
|
|
219
|
+
nodes = get_nodes(state)
|
|
220
|
+
matching = [n for n in nodes if n.get("stage_id") == stage_id]
|
|
221
|
+
if not matching:
|
|
222
|
+
return None
|
|
223
|
+
return matching[-1]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_stage_yaml(stage_id: str) -> dict[str, Any]:
|
|
227
|
+
"""Load the single-YAML stage definition."""
|
|
228
|
+
path = STAGES_DIR / f"{stage_id}.yaml"
|
|
229
|
+
if not path.exists():
|
|
230
|
+
raise FileNotFoundError(f"Stage YAML not found: {path}")
|
|
231
|
+
return load_yaml(path)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def list_stage_ids() -> list[str]:
|
|
235
|
+
"""Return all stage IDs discovered from .claude/stages/*.yaml."""
|
|
236
|
+
if not STAGES_DIR.exists():
|
|
237
|
+
return []
|
|
238
|
+
return sorted(p.stem for p in STAGES_DIR.glob("*.yaml") if p.stem != "stage-manifest")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_skill_routing() -> dict[str, Any]:
|
|
242
|
+
"""Build skill routing from stage YAMLs for backward compatibility."""
|
|
243
|
+
routing: dict[str, Any] = {"routing": {}}
|
|
244
|
+
for stage_id in list_stage_ids():
|
|
245
|
+
try:
|
|
246
|
+
stage = load_stage_yaml(stage_id)
|
|
247
|
+
skills = stage.get("skills", {})
|
|
248
|
+
routing["routing"][stage_id] = {
|
|
249
|
+
"required": skills.get("required", []),
|
|
250
|
+
"optional": skills.get("optional", []),
|
|
251
|
+
"human_gate": stage.get("human_gate", False),
|
|
252
|
+
}
|
|
253
|
+
except Exception:
|
|
254
|
+
continue
|
|
255
|
+
return routing
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_stage_skills(_routing_data: dict[str, Any] | None, stage_id: str) -> dict[str, list[str]]:
|
|
259
|
+
"""Extract required/optional skills for a stage from stage YAML."""
|
|
260
|
+
try:
|
|
261
|
+
stage = load_stage_yaml(stage_id)
|
|
262
|
+
skills = stage.get("skills", {})
|
|
263
|
+
return {
|
|
264
|
+
"required": skills.get("required", []),
|
|
265
|
+
"optional": skills.get("optional", []),
|
|
266
|
+
}
|
|
267
|
+
except Exception:
|
|
268
|
+
return {"required": [], "optional": []}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def run_validator(
|
|
272
|
+
phase: str,
|
|
273
|
+
check_name: str,
|
|
274
|
+
args: list[str],
|
|
275
|
+
state_file: Path | None = None,
|
|
276
|
+
project_root: Path | None = None,
|
|
277
|
+
) -> tuple[int, str, str]:
|
|
278
|
+
"""Run a validator check via validate_stage.py subprocess."""
|
|
279
|
+
if not VALIDATOR_PATH.exists():
|
|
280
|
+
raise FileNotFoundError(f"Validator not found: {VALIDATOR_PATH}")
|
|
281
|
+
|
|
282
|
+
args_str = ",".join(args)
|
|
283
|
+
cmd = [
|
|
284
|
+
sys.executable,
|
|
285
|
+
str(VALIDATOR_PATH),
|
|
286
|
+
"--phase",
|
|
287
|
+
phase,
|
|
288
|
+
"--check",
|
|
289
|
+
check_name,
|
|
290
|
+
]
|
|
291
|
+
if args_str:
|
|
292
|
+
cmd.extend(["--args", args_str])
|
|
293
|
+
if state_file is not None:
|
|
294
|
+
cmd.extend(["--state-file", str(state_file)])
|
|
295
|
+
|
|
296
|
+
env = os.environ.copy()
|
|
297
|
+
if project_root is not None:
|
|
298
|
+
env["LINCOLN_PROJECT_ROOT"] = str(project_root)
|
|
299
|
+
|
|
300
|
+
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
301
|
+
return result.returncode, result.stdout, result.stderr
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Context resolution
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def resolve_stage_context(
|
|
310
|
+
stage_id: str,
|
|
311
|
+
state: dict[str, Any],
|
|
312
|
+
state_file: Path | None = None,
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
"""Return a deterministic context dict for the current stage.
|
|
315
|
+
|
|
316
|
+
Merges:
|
|
317
|
+
1. stage YAML (identity, agents, skills, artifacts, context, gates)
|
|
318
|
+
2. workflow template step (for ordering and name override)
|
|
319
|
+
3. interpolated variables from workflow-stage.yaml
|
|
320
|
+
"""
|
|
321
|
+
stage = load_stage_yaml(stage_id)
|
|
322
|
+
template_name = state.get("workflow", {}).get("template")
|
|
323
|
+
workflow = load_workflow(template_name)
|
|
324
|
+
workflow_step = find_stage(workflow, stage_id)
|
|
325
|
+
|
|
326
|
+
variables = get_variables(state)
|
|
327
|
+
variables.setdefault("process_slug", get_process_slug(state, resolve_state_path(state_file)))
|
|
328
|
+
|
|
329
|
+
context = stage.get("context", {})
|
|
330
|
+
interpolated_context = {
|
|
331
|
+
key: interpolate(value, variables)
|
|
332
|
+
for key, value in context.items()
|
|
333
|
+
if isinstance(value, str)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
artifacts = stage.get("artifacts", {})
|
|
337
|
+
required_artifacts = [
|
|
338
|
+
interpolate_artifact(str(art), state, state_file)
|
|
339
|
+
for art in artifacts.get("required", [])
|
|
340
|
+
]
|
|
341
|
+
optional_artifacts = [
|
|
342
|
+
interpolate_artifact(str(art), state, state_file)
|
|
343
|
+
for art in artifacts.get("optional", [])
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
agent = stage.get("agent", {})
|
|
347
|
+
skills = stage.get("skills", {})
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
"stage_id": stage_id,
|
|
351
|
+
"stage_name": workflow_step.get("name", stage.get("name", stage_id)),
|
|
352
|
+
"description": stage.get("description", ""),
|
|
353
|
+
"human_gate": stage.get("human_gate", False),
|
|
354
|
+
"next_stage": compute_next_stage(workflow, stage_id),
|
|
355
|
+
"primary_agent": agent.get("primary"),
|
|
356
|
+
"review_agents": agent.get("reviewers", []),
|
|
357
|
+
"handoff_to": agent.get("handoff_to"),
|
|
358
|
+
"required_skills": skills.get("required", []),
|
|
359
|
+
"optional_skills": skills.get("optional", []),
|
|
360
|
+
"required_artifacts": required_artifacts,
|
|
361
|
+
"optional_artifacts": optional_artifacts,
|
|
362
|
+
"context": interpolated_context,
|
|
363
|
+
"context_path": str((STAGES_DIR / f"{stage_id}.yaml").relative_to(PROJECT_ROOT)),
|
|
364
|
+
"workflow_template": template_name,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Actions
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def action_load(stage_id: str, state: dict[str, Any], state_file: Path | None = None) -> dict[str, Any]:
|
|
374
|
+
context = resolve_stage_context(stage_id, state, state_file)
|
|
375
|
+
|
|
376
|
+
if _is_legacy_state(state):
|
|
377
|
+
stage_status = state.get("stages", {}).get(stage_id, {}).get("status")
|
|
378
|
+
else:
|
|
379
|
+
latest_node = get_latest_node_for_stage(state, stage_id)
|
|
380
|
+
stage_status = latest_node.get("status") if latest_node else state.get("current_run", {}).get("status")
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"stage_id": context["stage_id"],
|
|
384
|
+
"stage_name": context["stage_name"],
|
|
385
|
+
"stage_status": stage_status,
|
|
386
|
+
"human_gate": context["human_gate"],
|
|
387
|
+
"next_stage": context["next_stage"],
|
|
388
|
+
"context": json.dumps(context["context"], ensure_ascii=False),
|
|
389
|
+
"context_paths": [context["context_path"]],
|
|
390
|
+
"required_skills": context["required_skills"],
|
|
391
|
+
"optional_skills": context["optional_skills"],
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _check_artifacts_present(stage_id: str, state: dict[str, Any], state_file: Path | None = None) -> bool:
|
|
396
|
+
"""Check that all required artifacts for the stage exist and are non-empty."""
|
|
397
|
+
stage = load_stage_yaml(stage_id)
|
|
398
|
+
artifacts = stage.get("artifacts", {})
|
|
399
|
+
required = artifacts.get("required", [])
|
|
400
|
+
if not required:
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
all_present = True
|
|
404
|
+
for art in required:
|
|
405
|
+
path = interpolate_artifact(str(art), state, state_file)
|
|
406
|
+
if "*" in path or "?" in path:
|
|
407
|
+
matches = list(PROJECT_ROOT.glob(path))
|
|
408
|
+
if not matches or all(m.stat().st_size == 0 for m in matches):
|
|
409
|
+
print(f"FAIL: artifact missing or empty: {path}", file=sys.stderr)
|
|
410
|
+
all_present = False
|
|
411
|
+
else:
|
|
412
|
+
for match in matches:
|
|
413
|
+
print(f"PASS: artifact present: {match.relative_to(PROJECT_ROOT)}")
|
|
414
|
+
else:
|
|
415
|
+
target = PROJECT_ROOT / path
|
|
416
|
+
if not target.exists() or target.stat().st_size == 0:
|
|
417
|
+
print(f"FAIL: artifact missing or empty: {path}", file=sys.stderr)
|
|
418
|
+
all_present = False
|
|
419
|
+
else:
|
|
420
|
+
print(f"PASS: artifact present: {path}")
|
|
421
|
+
return all_present
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _check_human_approved(stage_id: str, state: dict[str, Any]) -> bool:
|
|
425
|
+
"""Check that the latest node for the stage is marked as approved."""
|
|
426
|
+
latest_node = get_latest_node_for_stage(state, stage_id)
|
|
427
|
+
if latest_node is None:
|
|
428
|
+
print(f"FAIL: no node found for stage '{stage_id}'", file=sys.stderr)
|
|
429
|
+
return False
|
|
430
|
+
if bool(latest_node.get("gate_passed")) and latest_node.get("approved_by"):
|
|
431
|
+
print(f"PASS: human approved for stage '{stage_id}'")
|
|
432
|
+
return True
|
|
433
|
+
print(f"FAIL: stage '{stage_id}' not approved", file=sys.stderr)
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _check_previous_stage_completed(stage_id: str, prev_stage_id: str, state: dict[str, Any]) -> bool:
|
|
438
|
+
"""Check that the prerequisite stage has a completed node."""
|
|
439
|
+
prev_node = get_latest_node_for_stage(state, prev_stage_id)
|
|
440
|
+
if prev_node and prev_node.get("status") == "completed":
|
|
441
|
+
print(f"PASS: previous stage '{prev_stage_id}' completed")
|
|
442
|
+
return True
|
|
443
|
+
print(f"FAIL: previous stage '{prev_stage_id}' not completed", file=sys.stderr)
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _run_gate_check(
|
|
448
|
+
phase: str,
|
|
449
|
+
check: dict[str, Any],
|
|
450
|
+
stage_id: str,
|
|
451
|
+
state: dict[str, Any],
|
|
452
|
+
state_file: Path | None = None,
|
|
453
|
+
) -> bool:
|
|
454
|
+
"""Run a single gate check. Returns True if passed."""
|
|
455
|
+
check_name = check.get("check")
|
|
456
|
+
raw_args = check.get("args", [])
|
|
457
|
+
variables = get_variables(state)
|
|
458
|
+
variables.setdefault("process_slug", get_process_slug(state, resolve_state_path(state_file)))
|
|
459
|
+
args = [
|
|
460
|
+
interpolate_process_path(interpolate(str(a), variables), state, resolve_state_path(state_file))
|
|
461
|
+
for a in raw_args
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
if check_name == "artifacts_present":
|
|
465
|
+
return _check_artifacts_present(stage_id, state, state_file)
|
|
466
|
+
|
|
467
|
+
if check_name in ("human_approved", "gate_review_passed"):
|
|
468
|
+
return _check_human_approved(stage_id, state)
|
|
469
|
+
|
|
470
|
+
if check_name == "previous_stage_completed":
|
|
471
|
+
if not args:
|
|
472
|
+
print(f"FAIL: previous_stage_completed requires an argument", file=sys.stderr)
|
|
473
|
+
return False
|
|
474
|
+
return _check_previous_stage_completed(stage_id, args[0], state)
|
|
475
|
+
|
|
476
|
+
# Structural checks delegated to validate_stage.py
|
|
477
|
+
exit_code, stdout, stderr = run_validator(phase, check_name, args, state_file)
|
|
478
|
+
if exit_code == 0:
|
|
479
|
+
print(f"PASS: {phase} check '{check_name}'")
|
|
480
|
+
return True
|
|
481
|
+
print(f"FAIL: {phase} check '{check_name}'", file=sys.stderr)
|
|
482
|
+
if stdout:
|
|
483
|
+
print(stdout, file=sys.stderr)
|
|
484
|
+
if stderr:
|
|
485
|
+
print(stderr, file=sys.stderr)
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def action_validate(
|
|
490
|
+
stage_id: str,
|
|
491
|
+
state: dict[str, Any],
|
|
492
|
+
phase: str,
|
|
493
|
+
state_file: Path | None = None,
|
|
494
|
+
) -> int:
|
|
495
|
+
"""Run entry/exit checks from the stage YAML gates."""
|
|
496
|
+
stage = load_stage_yaml(stage_id)
|
|
497
|
+
checks = stage.get("gates", {}).get(phase, [])
|
|
498
|
+
|
|
499
|
+
if _is_legacy_state(state):
|
|
500
|
+
stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
|
|
501
|
+
stage_state["started_at"] = stage_state.get("started_at") or now_iso()
|
|
502
|
+
if phase == "entry":
|
|
503
|
+
stage_state["status"] = "entry_validating"
|
|
504
|
+
save_state(state, state_file)
|
|
505
|
+
|
|
506
|
+
if "validator_history" not in stage_state:
|
|
507
|
+
stage_state["validator_history"] = []
|
|
508
|
+
else:
|
|
509
|
+
if phase == "entry":
|
|
510
|
+
state["current_run"]["status"] = "entry_validating"
|
|
511
|
+
save_state(state, state_file)
|
|
512
|
+
|
|
513
|
+
all_passed = True
|
|
514
|
+
for check in checks:
|
|
515
|
+
passed = _run_gate_check(phase, check, stage_id, state, state_file)
|
|
516
|
+
if _is_legacy_state(state):
|
|
517
|
+
stage_state["validator_history"].append(
|
|
518
|
+
{
|
|
519
|
+
"phase": phase,
|
|
520
|
+
"check": check.get("check"),
|
|
521
|
+
"exit_code": 0 if passed else 1,
|
|
522
|
+
"run_at": now_iso(),
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
if not passed:
|
|
526
|
+
all_passed = False
|
|
527
|
+
|
|
528
|
+
if not all_passed:
|
|
529
|
+
if _is_legacy_state(state):
|
|
530
|
+
stage_state["status"] = "validation_failed"
|
|
531
|
+
stage_state["retry_count"] = stage_state.get("retry_count", 0) + 1
|
|
532
|
+
else:
|
|
533
|
+
state["current_run"]["status"] = "validation_failed"
|
|
534
|
+
save_state(state, state_file)
|
|
535
|
+
return 1
|
|
536
|
+
|
|
537
|
+
now = now_iso()
|
|
538
|
+
if _is_legacy_state(state):
|
|
539
|
+
if phase == "entry":
|
|
540
|
+
stage_state["status"] = "in_progress"
|
|
541
|
+
stage_state["entry_checks_passed"] = True
|
|
542
|
+
stage_state["entry_checks_run_at"] = now
|
|
543
|
+
else:
|
|
544
|
+
stage_state["exit_checks_passed"] = True
|
|
545
|
+
stage_state["exit_checks_run_at"] = now
|
|
546
|
+
stage_state["error_message"] = None
|
|
547
|
+
else:
|
|
548
|
+
if phase == "entry":
|
|
549
|
+
state["current_run"]["status"] = "in_progress"
|
|
550
|
+
save_state(state, state_file)
|
|
551
|
+
|
|
552
|
+
print(f"PASS: all {phase} checks for '{stage_id}'")
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def action_record_artifacts(
|
|
557
|
+
stage_id: str,
|
|
558
|
+
state: dict[str, Any],
|
|
559
|
+
state_file: Path | None = None,
|
|
560
|
+
) -> list[str]:
|
|
561
|
+
"""Discover existing artifact paths declared by the stage YAML and record them."""
|
|
562
|
+
stage = load_stage_yaml(stage_id)
|
|
563
|
+
variables = get_variables(state)
|
|
564
|
+
variables.setdefault("process_slug", get_process_slug(state, resolve_state_path(state_file)))
|
|
565
|
+
|
|
566
|
+
recorded: list[str] = []
|
|
567
|
+
artifacts = stage.get("artifacts", {})
|
|
568
|
+
for art in artifacts.get("required", []) + artifacts.get("optional", []):
|
|
569
|
+
path = interpolate_artifact(str(art), state, state_file)
|
|
570
|
+
if "*" in path or "?" in path:
|
|
571
|
+
for match in PROJECT_ROOT.glob(path):
|
|
572
|
+
if match.exists():
|
|
573
|
+
recorded.append(str(match.relative_to(PROJECT_ROOT)))
|
|
574
|
+
else:
|
|
575
|
+
target = PROJECT_ROOT / path
|
|
576
|
+
if target.exists():
|
|
577
|
+
recorded.append(str(path))
|
|
578
|
+
|
|
579
|
+
latest_node = get_latest_node_for_stage(state, stage_id)
|
|
580
|
+
if latest_node is None:
|
|
581
|
+
latest_node = {
|
|
582
|
+
"stage_id": stage_id,
|
|
583
|
+
"node_id": f"{stage_id}-{now_iso()}",
|
|
584
|
+
"status": "in_progress",
|
|
585
|
+
"started_at": now_iso(),
|
|
586
|
+
"completed_at": None,
|
|
587
|
+
"gate_passed": False,
|
|
588
|
+
"approved_by": None,
|
|
589
|
+
"artifacts": [],
|
|
590
|
+
"handoff_file": None,
|
|
591
|
+
}
|
|
592
|
+
state.setdefault("nodes", []).append(latest_node)
|
|
593
|
+
|
|
594
|
+
existing = set(latest_node.get("artifacts", []))
|
|
595
|
+
existing.update(recorded)
|
|
596
|
+
latest_node["artifacts"] = sorted(existing)
|
|
597
|
+
|
|
598
|
+
save_state(state, state_file)
|
|
599
|
+
print(f"Recorded {len(recorded)} artifacts for stage '{stage_id}'")
|
|
600
|
+
for art in recorded:
|
|
601
|
+
print(f" - {art}")
|
|
602
|
+
return recorded
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def action_approve_gate(
|
|
606
|
+
stage_id: str,
|
|
607
|
+
state: dict[str, Any],
|
|
608
|
+
state_file: Path | None = None,
|
|
609
|
+
approved_by: str = "human-pm",
|
|
610
|
+
) -> int:
|
|
611
|
+
"""Mark the current/latest node for the stage as gate_passed: true."""
|
|
612
|
+
if _is_legacy_state(state):
|
|
613
|
+
stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
|
|
614
|
+
stage_state["human_gate_passed"] = True
|
|
615
|
+
stage_state["human_gate_passed_at"] = now_iso()
|
|
616
|
+
save_state(state, state_file)
|
|
617
|
+
print(f"PASS: gate approved for stage '{stage_id}' (legacy mode)")
|
|
618
|
+
return 0
|
|
619
|
+
|
|
620
|
+
latest_node = get_latest_node_for_stage(state, stage_id)
|
|
621
|
+
if latest_node is None:
|
|
622
|
+
new_node = {
|
|
623
|
+
"stage_id": stage_id,
|
|
624
|
+
"node_id": f"{stage_id}-{now_iso()}",
|
|
625
|
+
"status": "completed",
|
|
626
|
+
"gate_passed": True,
|
|
627
|
+
"approved_by": approved_by,
|
|
628
|
+
"started_at": now_iso(),
|
|
629
|
+
"completed_at": now_iso(),
|
|
630
|
+
"artifacts": [],
|
|
631
|
+
"handoff_file": None,
|
|
632
|
+
}
|
|
633
|
+
state.setdefault("nodes", []).append(new_node)
|
|
634
|
+
else:
|
|
635
|
+
latest_node["gate_passed"] = True
|
|
636
|
+
latest_node["approved_by"] = approved_by
|
|
637
|
+
latest_node["completed_at"] = now_iso()
|
|
638
|
+
|
|
639
|
+
save_state(state, state_file)
|
|
640
|
+
print(f"PASS: gate approved for stage '{stage_id}' by '{approved_by}'")
|
|
641
|
+
return 0
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def action_append_node(
|
|
645
|
+
stage_id: str,
|
|
646
|
+
state: dict[str, Any],
|
|
647
|
+
node_id: str,
|
|
648
|
+
status: str,
|
|
649
|
+
state_file: Path | None = None,
|
|
650
|
+
handoff_file: str | None = None,
|
|
651
|
+
artifacts: list[str] | None = None,
|
|
652
|
+
) -> int:
|
|
653
|
+
"""Append a node record to the nodes array."""
|
|
654
|
+
if _is_legacy_state(state):
|
|
655
|
+
stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
|
|
656
|
+
stage_state["status"] = status
|
|
657
|
+
if status == "completed":
|
|
658
|
+
stage_state["completed_at"] = now_iso()
|
|
659
|
+
if "started_at" not in stage_state or stage_state["started_at"] is None:
|
|
660
|
+
stage_state["started_at"] = now_iso()
|
|
661
|
+
if artifacts:
|
|
662
|
+
existing = set(stage_state.get("artifacts_produced", []))
|
|
663
|
+
existing.update(artifacts)
|
|
664
|
+
stage_state["artifacts_produced"] = list(existing)
|
|
665
|
+
save_state(state, state_file)
|
|
666
|
+
print(f"Legacy node appended: stage '{stage_id}' -> status '{status}'")
|
|
667
|
+
return 0
|
|
668
|
+
|
|
669
|
+
node = {
|
|
670
|
+
"stage_id": stage_id,
|
|
671
|
+
"node_id": node_id,
|
|
672
|
+
"status": status,
|
|
673
|
+
"started_at": now_iso(),
|
|
674
|
+
"completed_at": None,
|
|
675
|
+
"gate_passed": False,
|
|
676
|
+
"approved_by": None,
|
|
677
|
+
"artifacts": artifacts or [],
|
|
678
|
+
"handoff_file": handoff_file,
|
|
679
|
+
}
|
|
680
|
+
state.setdefault("nodes", []).append(node)
|
|
681
|
+
|
|
682
|
+
state["current_run"]["current_stage"] = stage_id
|
|
683
|
+
state["current_run"]["status"] = status
|
|
684
|
+
|
|
685
|
+
save_state(state, state_file)
|
|
686
|
+
print(f"Node appended: '{node_id}' for stage '{stage_id}' with status '{status}'")
|
|
687
|
+
return 0
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def action_transition_next(stage_id: str, state: dict[str, Any], state_file: Path | None = None) -> str | None:
|
|
691
|
+
template_name = state.get("workflow", {}).get("template")
|
|
692
|
+
workflow = load_workflow(template_name)
|
|
693
|
+
next_stage_id = compute_next_stage(workflow, stage_id)
|
|
694
|
+
|
|
695
|
+
if _is_legacy_state(state):
|
|
696
|
+
stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
|
|
697
|
+
stage_state["status"] = "completed"
|
|
698
|
+
stage_state["completed_at"] = now_iso()
|
|
699
|
+
|
|
700
|
+
started_at = stage_state.get("started_at")
|
|
701
|
+
completed_at = stage_state.get("completed_at")
|
|
702
|
+
if started_at and completed_at:
|
|
703
|
+
try:
|
|
704
|
+
start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
705
|
+
end_dt = datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
|
|
706
|
+
stage_state["duration_seconds"] = int((end_dt - start_dt).total_seconds())
|
|
707
|
+
except (ValueError, TypeError):
|
|
708
|
+
stage_state["duration_seconds"] = None
|
|
709
|
+
else:
|
|
710
|
+
stage_state["duration_seconds"] = None
|
|
711
|
+
|
|
712
|
+
if next_stage_id:
|
|
713
|
+
next_state = state.setdefault("stages", {}).setdefault(next_stage_id, {})
|
|
714
|
+
next_state.update(
|
|
715
|
+
{
|
|
716
|
+
"status": "not_started",
|
|
717
|
+
"started_at": None,
|
|
718
|
+
"completed_at": None,
|
|
719
|
+
"entry_checks_passed": None,
|
|
720
|
+
"entry_checks_run_at": None,
|
|
721
|
+
"exit_checks_passed": None,
|
|
722
|
+
"exit_checks_run_at": None,
|
|
723
|
+
"human_gate_passed": None,
|
|
724
|
+
"human_gate_passed_at": None,
|
|
725
|
+
"artifacts_produced": [],
|
|
726
|
+
"error_message": None,
|
|
727
|
+
"retry_count": 0,
|
|
728
|
+
}
|
|
729
|
+
)
|
|
730
|
+
state["current_run"]["current_stage"] = next_stage_id
|
|
731
|
+
state["current_run"]["previous_stage"] = stage_id
|
|
732
|
+
else:
|
|
733
|
+
state["current_run"]["current_stage"] = None
|
|
734
|
+
state["current_run"]["previous_stage"] = stage_id
|
|
735
|
+
state["current_run"]["status"] = "completed"
|
|
736
|
+
else:
|
|
737
|
+
now = now_iso()
|
|
738
|
+
recorded_artifacts = action_record_artifacts(stage_id, state, state_file)
|
|
739
|
+
completed_node = {
|
|
740
|
+
"stage_id": stage_id,
|
|
741
|
+
"node_id": f"{stage_id}-completed",
|
|
742
|
+
"status": "completed",
|
|
743
|
+
"started_at": now,
|
|
744
|
+
"completed_at": now,
|
|
745
|
+
"gate_passed": True,
|
|
746
|
+
"approved_by": "system",
|
|
747
|
+
"artifacts": recorded_artifacts,
|
|
748
|
+
"handoff_file": None,
|
|
749
|
+
}
|
|
750
|
+
state.setdefault("nodes", []).append(completed_node)
|
|
751
|
+
|
|
752
|
+
if next_stage_id:
|
|
753
|
+
state["current_run"]["current_stage"] = next_stage_id
|
|
754
|
+
state["current_run"]["previous_stage"] = stage_id
|
|
755
|
+
state["current_run"]["status"] = "not_started"
|
|
756
|
+
else:
|
|
757
|
+
state["current_run"]["current_stage"] = None
|
|
758
|
+
state["current_run"]["previous_stage"] = stage_id
|
|
759
|
+
state["current_run"]["status"] = "completed"
|
|
760
|
+
|
|
761
|
+
save_state(state, state_file)
|
|
762
|
+
print(f"Transitioned from '{stage_id}' to '{next_stage_id}'")
|
|
763
|
+
return next_stage_id
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def action_recover(state: dict[str, Any], state_file: Path | None = None) -> dict[str, Any]:
|
|
767
|
+
template_name = state.get("workflow", {}).get("template")
|
|
768
|
+
workflow = load_workflow(template_name)
|
|
769
|
+
step_ids = [s["id"] for s in workflow.get("steps", [])]
|
|
770
|
+
|
|
771
|
+
if _is_legacy_state(state):
|
|
772
|
+
stages = state.get("stages", {})
|
|
773
|
+
last_completed = None
|
|
774
|
+
for sid in step_ids:
|
|
775
|
+
if stages.get(sid, {}).get("status") == "completed":
|
|
776
|
+
last_completed = sid
|
|
777
|
+
|
|
778
|
+
resume_point = state["current_run"].get("current_stage") or last_completed
|
|
779
|
+
if resume_point and stages.get(resume_point, {}).get("status") == "completed":
|
|
780
|
+
resume_point = compute_next_stage(workflow, resume_point)
|
|
781
|
+
|
|
782
|
+
state["recovery"]["last_validated_checkpoint"] = last_completed
|
|
783
|
+
state["recovery"]["can_resume_from"] = resume_point
|
|
784
|
+
save_state(state, state_file)
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
"last_completed": last_completed,
|
|
788
|
+
"can_resume_from": resume_point,
|
|
789
|
+
"current_stage": state["current_run"].get("current_stage"),
|
|
790
|
+
"current_status": state["current_run"].get("status"),
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
nodes = get_nodes(state)
|
|
794
|
+
last_completed = None
|
|
795
|
+
for node in reversed(nodes):
|
|
796
|
+
if node.get("status") == "completed" and node.get("stage_id") in step_ids:
|
|
797
|
+
last_completed = node.get("stage_id")
|
|
798
|
+
break
|
|
799
|
+
|
|
800
|
+
current_stage = state["current_run"].get("current_stage")
|
|
801
|
+
resume_point = current_stage or last_completed
|
|
802
|
+
if resume_point:
|
|
803
|
+
latest = get_latest_node_for_stage(state, resume_point)
|
|
804
|
+
if latest and latest.get("status") == "completed":
|
|
805
|
+
resume_point = compute_next_stage(workflow, resume_point)
|
|
806
|
+
|
|
807
|
+
state["recovery"]["last_validated_checkpoint"] = last_completed
|
|
808
|
+
state["recovery"]["can_resume_from"] = resume_point
|
|
809
|
+
save_state(state, state_file)
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
"last_completed": last_completed,
|
|
813
|
+
"can_resume_from": resume_point,
|
|
814
|
+
"current_stage": current_stage,
|
|
815
|
+
"current_status": state["current_run"].get("status"),
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def action_status(state: dict[str, Any]) -> dict[str, Any]:
|
|
820
|
+
"""Return status report by delegating to lc-status logic."""
|
|
821
|
+
import importlib.util
|
|
822
|
+
status_path = PROJECT_ROOT / "scripts" / "lincoln-status.py"
|
|
823
|
+
spec = importlib.util.spec_from_file_location("lincoln_status", status_path)
|
|
824
|
+
if spec and spec.loader:
|
|
825
|
+
mod = importlib.util.module_from_spec(spec)
|
|
826
|
+
spec.loader.exec_module(mod)
|
|
827
|
+
return mod.build_status_report(state)
|
|
828
|
+
try:
|
|
829
|
+
from lincoln_status import build_status_report
|
|
830
|
+
return build_status_report(state)
|
|
831
|
+
except ImportError:
|
|
832
|
+
raise RuntimeError("Could not import lc-status module")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def action_handoff_report(stage_id: str, state: dict[str, Any], state_file: Path) -> str:
|
|
836
|
+
"""Generate <process_slug>/handoffs/lc-handoff-{stage}.md and refresh pm-to-ux.handoff.yaml status."""
|
|
837
|
+
template_name = state.get("workflow", {}).get("template")
|
|
838
|
+
workflow = load_workflow(template_name)
|
|
839
|
+
stage_def = find_stage(workflow, stage_id)
|
|
840
|
+
|
|
841
|
+
if _is_legacy_state(state):
|
|
842
|
+
stage_state = state.get("stages", {}).get(stage_id, {})
|
|
843
|
+
artifacts_produced = stage_state.get("artifacts_produced", [])
|
|
844
|
+
else:
|
|
845
|
+
latest_node = get_latest_node_for_stage(state, stage_id)
|
|
846
|
+
stage_state = latest_node or {}
|
|
847
|
+
artifacts_produced = stage_state.get("artifacts", [])
|
|
848
|
+
|
|
849
|
+
lines = []
|
|
850
|
+
lines.append("# Lincoln Handoff Report")
|
|
851
|
+
lines.append("")
|
|
852
|
+
lines.append(f"**Generated:** {now_iso()}")
|
|
853
|
+
lines.append(f"**Branch:** {state.get('current_run', {}).get('branch', 'unknown')}")
|
|
854
|
+
lines.append(f"**Run ID:** {state.get('current_run', {}).get('run_id', 'unknown')}")
|
|
855
|
+
lines.append("")
|
|
856
|
+
lines.append("## Current Stage")
|
|
857
|
+
lines.append(f"- **Stage:** {stage_id}")
|
|
858
|
+
lines.append(f"- **Name:** {stage_def.get('name', stage_id)}")
|
|
859
|
+
if _is_legacy_state(state):
|
|
860
|
+
lines.append(f"- **Status:** {stage_state.get('status', 'unknown')}")
|
|
861
|
+
else:
|
|
862
|
+
lines.append(f"- **Status:** {stage_state.get('status', state.get('current_run', {}).get('status', 'unknown'))}")
|
|
863
|
+
lines.append("")
|
|
864
|
+
lines.append("## Waiting For")
|
|
865
|
+
lines.append(f"- **Waiting for:** {'human' if stage_def.get('human_gate') and not stage_state.get('gate_passed') else 'agent'}")
|
|
866
|
+
lines.append("")
|
|
867
|
+
lines.append("## Artifacts")
|
|
868
|
+
if artifacts_produced:
|
|
869
|
+
lines.append("### Produced")
|
|
870
|
+
for art in artifacts_produced:
|
|
871
|
+
lines.append(f"- [x] {art}")
|
|
872
|
+
|
|
873
|
+
required = [
|
|
874
|
+
interpolate_artifact(str(art), state, resolve_state_path(None))
|
|
875
|
+
for art in stage_def.get("artifacts", [])
|
|
876
|
+
]
|
|
877
|
+
if required:
|
|
878
|
+
lines.append("### Required")
|
|
879
|
+
for art in required:
|
|
880
|
+
checked = "x" if art in artifacts_produced else " "
|
|
881
|
+
lines.append(f"- [{checked}] {art}")
|
|
882
|
+
lines.append("")
|
|
883
|
+
lines.append("## Next Action")
|
|
884
|
+
lines.append(f"{stage_state.get('status', 'unknown')}")
|
|
885
|
+
lines.append("")
|
|
886
|
+
lines.append("---")
|
|
887
|
+
lines.append("*This file is auto-generated by `scripts/stage_loader.py --action handoff-report`")
|
|
888
|
+
lines.append("*PM→UX handoff contract status is also refreshed in `handoffs/pm-to-ux/pm-to-ux.handoff.yaml`.*")
|
|
889
|
+
|
|
890
|
+
content = "\n".join(lines)
|
|
891
|
+
project_root = state_file.parents[1]
|
|
892
|
+
handoff_dir = process_package_root(state=state, state_path=state_file, project_root=project_root) / "handoffs"
|
|
893
|
+
handoff_dir.mkdir(parents=True, exist_ok=True)
|
|
894
|
+
handoff_path = handoff_dir / f"lc-handoff-{stage_id}.md"
|
|
895
|
+
handoff_path.write_text(content, encoding="utf-8")
|
|
896
|
+
|
|
897
|
+
# Refresh the PM→UX handoff contract status fields if the contract exists.
|
|
898
|
+
pm_to_ux_path = handoff_dir / "pm-to-ux" / "pm-to-ux.handoff.yaml"
|
|
899
|
+
if pm_to_ux_path.exists():
|
|
900
|
+
try:
|
|
901
|
+
contract = load_yaml(pm_to_ux_path)
|
|
902
|
+
if isinstance(contract, dict):
|
|
903
|
+
approval = contract.setdefault("approval", {})
|
|
904
|
+
gate_passed = bool(stage_state.get("gate_passed"))
|
|
905
|
+
approval["pm_confirmed"] = gate_passed
|
|
906
|
+
if gate_passed:
|
|
907
|
+
approval["approved_at"] = stage_state.get("completed_at") or now_iso()
|
|
908
|
+
approval["approved_by"] = stage_state.get("approved_by") or "human-pm"
|
|
909
|
+
else:
|
|
910
|
+
approval["approved_at"] = ""
|
|
911
|
+
approval["approved_by"] = ""
|
|
912
|
+
save_yaml(pm_to_ux_path, contract)
|
|
913
|
+
print(f"Refreshed PM→UX handoff contract: {pm_to_ux_path.relative_to(project_root)}")
|
|
914
|
+
except Exception as exc:
|
|
915
|
+
print(f"WARNING: could not refresh PM→UX handoff contract: {exc}", file=sys.stderr)
|
|
916
|
+
|
|
917
|
+
# Record a structured handoff event in the session trace so benchmark metrics
|
|
918
|
+
# can detect handoffs without regex-parsing the original Bash command.
|
|
919
|
+
lincoln_trace.append_trace_entry(
|
|
920
|
+
state_file,
|
|
921
|
+
"LincolnHandoff",
|
|
922
|
+
{"stage": stage_id},
|
|
923
|
+
0,
|
|
924
|
+
stage=stage_id,
|
|
925
|
+
run_id=state.get("current_run", {}).get("run_id"),
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
return str(handoff_path.relative_to(project_root))
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def action_update_last_updated(state_file: Path) -> None:
|
|
932
|
+
"""Update current_run.last_updated_at and persist state."""
|
|
933
|
+
state = load_state(state_file)
|
|
934
|
+
state["current_run"]["last_updated_at"] = now_iso()
|
|
935
|
+
save_state(state, state_file)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def action_benchmark_report(state_file: Path, trigger: str) -> dict[str, str] | None:
|
|
939
|
+
"""Generate a benchmark report for the current process state."""
|
|
940
|
+
from scripts import lincoln_benchmark
|
|
941
|
+
|
|
942
|
+
result = lincoln_benchmark.write_benchmark_report(state_file, trigger, PROJECT_ROOT)
|
|
943
|
+
if result is None:
|
|
944
|
+
return None
|
|
945
|
+
|
|
946
|
+
def _rel(path: Path) -> str:
|
|
947
|
+
try:
|
|
948
|
+
return str(path.relative_to(PROJECT_ROOT))
|
|
949
|
+
except ValueError:
|
|
950
|
+
return str(path)
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
"markdown": _rel(result["markdown"]),
|
|
954
|
+
"json": _rel(result["json"]),
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
# ---------------------------------------------------------------------------
|
|
959
|
+
# CLI
|
|
960
|
+
# ---------------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def main() -> int:
|
|
964
|
+
parser = argparse.ArgumentParser(description="Lincoln stage loader")
|
|
965
|
+
parser.add_argument("--stage", help="Stage ID")
|
|
966
|
+
parser.add_argument("--action",
|
|
967
|
+
required=True,
|
|
968
|
+
choices=[
|
|
969
|
+
"load",
|
|
970
|
+
"validate-entry",
|
|
971
|
+
"validate-exit",
|
|
972
|
+
"record-artifacts",
|
|
973
|
+
"approve-gate",
|
|
974
|
+
"append-node",
|
|
975
|
+
"transition-next",
|
|
976
|
+
"recover",
|
|
977
|
+
"status",
|
|
978
|
+
"handoff-report",
|
|
979
|
+
"benchmark-report",
|
|
980
|
+
"update-last-updated",
|
|
981
|
+
],
|
|
982
|
+
)
|
|
983
|
+
parser.add_argument("--state-file", type=Path, default=None)
|
|
984
|
+
parser.add_argument("--project-root", type=Path, default=PROJECT_ROOT)
|
|
985
|
+
parser.add_argument("--approved-by", default="human-pm", help="Approver name for approve-gate")
|
|
986
|
+
parser.add_argument("--node-id", help="Node ID for append-node")
|
|
987
|
+
parser.add_argument("--status", help="Node status for append-node")
|
|
988
|
+
parser.add_argument("--handoff-file", help="Handoff file path for append-node")
|
|
989
|
+
parser.add_argument("--artifacts", help="Comma-separated artifact paths for append-node")
|
|
990
|
+
parser.add_argument("--trigger", default="manual", help="Trigger type for benchmark-report")
|
|
991
|
+
args = parser.parse_args()
|
|
992
|
+
|
|
993
|
+
state_file = resolve_state_path(args.state_file)
|
|
994
|
+
state = load_state(state_file)
|
|
995
|
+
|
|
996
|
+
if args.action == "load":
|
|
997
|
+
if not args.stage:
|
|
998
|
+
parser.error("--stage is required for load")
|
|
999
|
+
result = action_load(args.stage, state, state_file)
|
|
1000
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1001
|
+
return 0
|
|
1002
|
+
|
|
1003
|
+
if args.action in ("validate-entry", "validate-exit"):
|
|
1004
|
+
if not args.stage:
|
|
1005
|
+
parser.error("--stage is required for validate-*")
|
|
1006
|
+
phase = args.action.split("-")[1]
|
|
1007
|
+
return action_validate(args.stage, state, phase, state_file)
|
|
1008
|
+
|
|
1009
|
+
if args.action == "record-artifacts":
|
|
1010
|
+
if not args.stage:
|
|
1011
|
+
parser.error("--stage is required for record-artifacts")
|
|
1012
|
+
action_record_artifacts(args.stage, state, state_file)
|
|
1013
|
+
return 0
|
|
1014
|
+
|
|
1015
|
+
if args.action == "approve-gate":
|
|
1016
|
+
if not args.stage:
|
|
1017
|
+
parser.error("--stage is required for approve-gate")
|
|
1018
|
+
return action_approve_gate(args.stage, state, state_file, args.approved_by)
|
|
1019
|
+
|
|
1020
|
+
if args.action == "append-node":
|
|
1021
|
+
if not args.node_id:
|
|
1022
|
+
parser.error("--node-id is required for append-node")
|
|
1023
|
+
if not args.status:
|
|
1024
|
+
parser.error("--status is required for append-node")
|
|
1025
|
+
stage_id = args.stage or args.node_id
|
|
1026
|
+
artifacts = [a.strip() for a in args.artifacts.split(",")] if args.artifacts else None
|
|
1027
|
+
return action_append_node(
|
|
1028
|
+
stage_id, state, args.node_id, args.status,
|
|
1029
|
+
state_file, args.handoff_file, artifacts,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
if args.action == "transition-next":
|
|
1033
|
+
if not args.stage:
|
|
1034
|
+
parser.error("--stage is required for transition-next")
|
|
1035
|
+
action_transition_next(args.stage, state, state_file)
|
|
1036
|
+
return 0
|
|
1037
|
+
|
|
1038
|
+
if args.action == "recover":
|
|
1039
|
+
result = action_recover(state, state_file)
|
|
1040
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1041
|
+
return 0
|
|
1042
|
+
|
|
1043
|
+
if args.action == "status":
|
|
1044
|
+
result = action_status(state)
|
|
1045
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1046
|
+
return 0
|
|
1047
|
+
|
|
1048
|
+
if args.action == "handoff-report":
|
|
1049
|
+
if not args.stage:
|
|
1050
|
+
parser.error("--stage is required for handoff-report")
|
|
1051
|
+
path = action_handoff_report(args.stage, state, state_file)
|
|
1052
|
+
print(f"Handoff report written to: {path}")
|
|
1053
|
+
return 0
|
|
1054
|
+
|
|
1055
|
+
if args.action == "update-last-updated":
|
|
1056
|
+
action_update_last_updated(state_file)
|
|
1057
|
+
return 0
|
|
1058
|
+
|
|
1059
|
+
if args.action == "benchmark-report":
|
|
1060
|
+
result = action_benchmark_report(state_file, args.trigger)
|
|
1061
|
+
if result is None:
|
|
1062
|
+
print("Benchmark report skipped")
|
|
1063
|
+
else:
|
|
1064
|
+
print(f"Benchmark report written to:\n {result['markdown']}\n {result['json']}")
|
|
1065
|
+
return 0
|
|
1066
|
+
|
|
1067
|
+
return 0
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
if __name__ == "__main__":
|
|
1071
|
+
sys.exit(main())
|