@pilotspace/add 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -1
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/bin/cli.js +240 -26
- package/docs/02-the-flow.md +1 -1
- package/docs/03-step-1-specify.md +4 -0
- package/docs/04-step-2-scenarios.md +4 -0
- package/docs/06-step-4-tests.md +4 -0
- package/docs/17-components.md +23 -5
- package/docs/18-personas.md +80 -0
- package/docs/add-milestone-task-lifecycle.png +0 -0
- package/docs/add-task-growth-wheel.png +0 -0
- package/docs/appendix-c-glossary.md +8 -4
- package/docs/appendix-d-worked-example.md +79 -0
- package/package.json +3 -1
- package/personas-teacher/LICENSE +21 -0
- package/personas-teacher/README.md +1067 -0
- package/personas-teacher/VENDOR.md +13 -0
- package/personas-teacher/academic/academic-anthropologist.md +125 -0
- package/personas-teacher/academic/academic-geographer.md +127 -0
- package/personas-teacher/academic/academic-historian.md +123 -0
- package/personas-teacher/academic/academic-narratologist.md +118 -0
- package/personas-teacher/academic/academic-psychologist.md +118 -0
- package/personas-teacher/design/design-brand-guardian.md +322 -0
- package/personas-teacher/design/design-image-prompt-engineer.md +236 -0
- package/personas-teacher/design/design-inclusive-visuals-specialist.md +71 -0
- package/personas-teacher/design/design-persona-walkthrough.md +272 -0
- package/personas-teacher/design/design-ui-designer.md +383 -0
- package/personas-teacher/design/design-ux-architect.md +469 -0
- package/personas-teacher/design/design-ux-researcher.md +329 -0
- package/personas-teacher/design/design-visual-storyteller.md +149 -0
- package/personas-teacher/design/design-whimsy-injector.md +438 -0
- package/personas-teacher/divisions.json +21 -0
- package/personas-teacher/engineering/engineering-ai-data-remediation-engineer.md +211 -0
- package/personas-teacher/engineering/engineering-ai-engineer.md +146 -0
- package/personas-teacher/engineering/engineering-autonomous-optimization-architect.md +107 -0
- package/personas-teacher/engineering/engineering-backend-architect.md +236 -0
- package/personas-teacher/engineering/engineering-cms-developer.md +536 -0
- package/personas-teacher/engineering/engineering-code-reviewer.md +76 -0
- package/personas-teacher/engineering/engineering-codebase-onboarding-engineer.md +173 -0
- package/personas-teacher/engineering/engineering-data-engineer.md +306 -0
- package/personas-teacher/engineering/engineering-database-optimizer.md +176 -0
- package/personas-teacher/engineering/engineering-devops-automator.md +376 -0
- package/personas-teacher/engineering/engineering-drupal-shopping-cart.md +360 -0
- package/personas-teacher/engineering/engineering-email-intelligence-engineer.md +353 -0
- package/personas-teacher/engineering/engineering-embedded-firmware-engineer.md +173 -0
- package/personas-teacher/engineering/engineering-feishu-integration-developer.md +598 -0
- package/personas-teacher/engineering/engineering-filament-optimization-specialist.md +283 -0
- package/personas-teacher/engineering/engineering-frontend-developer.md +225 -0
- package/personas-teacher/engineering/engineering-git-workflow-master.md +84 -0
- package/personas-teacher/engineering/engineering-incident-response-commander.md +444 -0
- package/personas-teacher/engineering/engineering-it-service-manager.md +561 -0
- package/personas-teacher/engineering/engineering-minimal-change-engineer.md +207 -0
- package/personas-teacher/engineering/engineering-mobile-app-builder.md +493 -0
- package/personas-teacher/engineering/engineering-multi-agent-systems-architect.md +600 -0
- package/personas-teacher/engineering/engineering-orgscript-engineer.md +113 -0
- package/personas-teacher/engineering/engineering-prompt-engineer.md +202 -0
- package/personas-teacher/engineering/engineering-rapid-prototyper.md +462 -0
- package/personas-teacher/engineering/engineering-senior-developer.md +176 -0
- package/personas-teacher/engineering/engineering-software-architect.md +112 -0
- package/personas-teacher/engineering/engineering-solidity-smart-contract-engineer.md +522 -0
- package/personas-teacher/engineering/engineering-sre.md +90 -0
- package/personas-teacher/engineering/engineering-technical-writer.md +393 -0
- package/personas-teacher/engineering/engineering-voice-ai-integration-engineer.md +561 -0
- package/personas-teacher/engineering/engineering-wechat-mini-program-developer.md +350 -0
- package/personas-teacher/engineering/engineering-wordpress-shopping-cart.md +346 -0
- package/personas-teacher/examples/README.md +48 -0
- package/personas-teacher/examples/nexus-spatial-discovery.md +852 -0
- package/personas-teacher/examples/workflow-book-chapter.md +55 -0
- package/personas-teacher/examples/workflow-landing-page.md +119 -0
- package/personas-teacher/examples/workflow-startup-mvp.md +155 -0
- package/personas-teacher/examples/workflow-with-memory.md +238 -0
- package/personas-teacher/finance/finance-bookkeeper-controller.md +260 -0
- package/personas-teacher/finance/finance-financial-analyst.md +234 -0
- package/personas-teacher/finance/finance-fpa-analyst.md +263 -0
- package/personas-teacher/finance/finance-investment-researcher.md +272 -0
- package/personas-teacher/finance/finance-tax-strategist.md +239 -0
- package/personas-teacher/game-development/blender/blender-addon-engineer.md +234 -0
- package/personas-teacher/game-development/game-audio-engineer.md +264 -0
- package/personas-teacher/game-development/game-designer.md +167 -0
- package/personas-teacher/game-development/godot/godot-gameplay-scripter.md +334 -0
- package/personas-teacher/game-development/godot/godot-multiplayer-engineer.md +297 -0
- package/personas-teacher/game-development/godot/godot-shader-developer.md +266 -0
- package/personas-teacher/game-development/level-designer.md +208 -0
- package/personas-teacher/game-development/narrative-designer.md +243 -0
- package/personas-teacher/game-development/roblox-studio/roblox-avatar-creator.md +297 -0
- package/personas-teacher/game-development/roblox-studio/roblox-experience-designer.md +305 -0
- package/personas-teacher/game-development/roblox-studio/roblox-systems-scripter.md +325 -0
- package/personas-teacher/game-development/technical-artist.md +229 -0
- package/personas-teacher/game-development/unity/unity-architect.md +271 -0
- package/personas-teacher/game-development/unity/unity-editor-tool-developer.md +310 -0
- package/personas-teacher/game-development/unity/unity-multiplayer-engineer.md +321 -0
- package/personas-teacher/game-development/unity/unity-shader-graph-artist.md +269 -0
- package/personas-teacher/game-development/unreal-engine/unreal-multiplayer-architect.md +313 -0
- package/personas-teacher/game-development/unreal-engine/unreal-systems-engineer.md +310 -0
- package/personas-teacher/game-development/unreal-engine/unreal-technical-artist.md +256 -0
- package/personas-teacher/game-development/unreal-engine/unreal-world-builder.md +273 -0
- package/personas-teacher/gis/gis-3d-scene-developer.md +111 -0
- package/personas-teacher/gis/gis-analyst.md +91 -0
- package/personas-teacher/gis/gis-bim-specialist.md +108 -0
- package/personas-teacher/gis/gis-cartography-designer.md +150 -0
- package/personas-teacher/gis/gis-drone-reality-mapping.md +120 -0
- package/personas-teacher/gis/gis-geoai-ml-engineer.md +105 -0
- package/personas-teacher/gis/gis-geoprocessing-specialist.md +97 -0
- package/personas-teacher/gis/gis-qa-engineer.md +133 -0
- package/personas-teacher/gis/gis-solution-engineer.md +101 -0
- package/personas-teacher/gis/gis-spatial-data-engineer.md +97 -0
- package/personas-teacher/gis/gis-spatial-data-scientist.md +111 -0
- package/personas-teacher/gis/gis-technical-consultant.md +86 -0
- package/personas-teacher/gis/gis-web-gis-developer.md +108 -0
- package/personas-teacher/marketing/marketing-aeo-foundations.md +264 -0
- package/personas-teacher/marketing/marketing-agentic-search-optimizer.md +311 -0
- package/personas-teacher/marketing/marketing-ai-citation-strategist.md +170 -0
- package/personas-teacher/marketing/marketing-app-store-optimizer.md +321 -0
- package/personas-teacher/marketing/marketing-baidu-seo-specialist.md +226 -0
- package/personas-teacher/marketing/marketing-bilibili-content-strategist.md +199 -0
- package/personas-teacher/marketing/marketing-book-co-author.md +110 -0
- package/personas-teacher/marketing/marketing-carousel-growth-engine.md +199 -0
- package/personas-teacher/marketing/marketing-china-ecommerce-operator.md +283 -0
- package/personas-teacher/marketing/marketing-china-market-localization-strategist.md +283 -0
- package/personas-teacher/marketing/marketing-content-creator.md +54 -0
- package/personas-teacher/marketing/marketing-cross-border-ecommerce.md +259 -0
- package/personas-teacher/marketing/marketing-douyin-strategist.md +149 -0
- package/personas-teacher/marketing/marketing-email-strategist.md +249 -0
- package/personas-teacher/marketing/marketing-global-podcast-strategist.md +206 -0
- package/personas-teacher/marketing/marketing-growth-hacker.md +54 -0
- package/personas-teacher/marketing/marketing-instagram-curator.md +113 -0
- package/personas-teacher/marketing/marketing-kuaishou-strategist.md +223 -0
- package/personas-teacher/marketing/marketing-linkedin-content-creator.md +214 -0
- package/personas-teacher/marketing/marketing-livestream-commerce-coach.md +305 -0
- package/personas-teacher/marketing/marketing-multi-platform-publisher.md +217 -0
- package/personas-teacher/marketing/marketing-podcast-strategist.md +277 -0
- package/personas-teacher/marketing/marketing-pr-communications-manager.md +473 -0
- package/personas-teacher/marketing/marketing-private-domain-operator.md +308 -0
- package/personas-teacher/marketing/marketing-reddit-community-builder.md +123 -0
- package/personas-teacher/marketing/marketing-seo-specialist.md +321 -0
- package/personas-teacher/marketing/marketing-short-video-editing-coach.md +412 -0
- package/personas-teacher/marketing/marketing-social-media-strategist.md +125 -0
- package/personas-teacher/marketing/marketing-tiktok-strategist.md +125 -0
- package/personas-teacher/marketing/marketing-twitter-engager.md +126 -0
- package/personas-teacher/marketing/marketing-video-optimization-specialist.md +119 -0
- package/personas-teacher/marketing/marketing-wechat-official-account.md +145 -0
- package/personas-teacher/marketing/marketing-weibo-strategist.md +240 -0
- package/personas-teacher/marketing/marketing-x-twitter-intelligence-analyst.md +161 -0
- package/personas-teacher/marketing/marketing-xiaohongshu-specialist.md +138 -0
- package/personas-teacher/marketing/marketing-zhihu-strategist.md +162 -0
- package/personas-teacher/paid-media/paid-media-auditor.md +71 -0
- package/personas-teacher/paid-media/paid-media-creative-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-paid-social-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-ppc-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-programmatic-buyer.md +71 -0
- package/personas-teacher/paid-media/paid-media-search-query-analyst.md +71 -0
- package/personas-teacher/paid-media/paid-media-tracking-specialist.md +71 -0
- package/personas-teacher/product/product-behavioral-nudge-engine.md +80 -0
- package/personas-teacher/product/product-feedback-synthesizer.md +119 -0
- package/personas-teacher/product/product-manager.md +469 -0
- package/personas-teacher/product/product-sprint-prioritizer.md +154 -0
- package/personas-teacher/product/product-trend-researcher.md +159 -0
- package/personas-teacher/project-management/project-management-experiment-tracker.md +198 -0
- package/personas-teacher/project-management/project-management-jira-workflow-steward.md +230 -0
- package/personas-teacher/project-management/project-management-meeting-notes-specialist.md +95 -0
- package/personas-teacher/project-management/project-management-project-shepherd.md +194 -0
- package/personas-teacher/project-management/project-management-studio-operations.md +200 -0
- package/personas-teacher/project-management/project-management-studio-producer.md +203 -0
- package/personas-teacher/project-management/project-manager-senior.md +135 -0
- package/personas-teacher/sales/sales-account-strategist.md +227 -0
- package/personas-teacher/sales/sales-coach.md +271 -0
- package/personas-teacher/sales/sales-deal-strategist.md +180 -0
- package/personas-teacher/sales/sales-discovery-coach.md +225 -0
- package/personas-teacher/sales/sales-engineer.md +182 -0
- package/personas-teacher/sales/sales-offer-lead-gen-strategist.md +257 -0
- package/personas-teacher/sales/sales-outbound-strategist.md +201 -0
- package/personas-teacher/sales/sales-pipeline-analyst.md +267 -0
- package/personas-teacher/sales/sales-proposal-strategist.md +217 -0
- package/personas-teacher/security/security-appsec-engineer.md +491 -0
- package/personas-teacher/security/security-architect.md +304 -0
- package/personas-teacher/security/security-blockchain-security-auditor.md +463 -0
- package/personas-teacher/security/security-cloud-security-architect.md +523 -0
- package/personas-teacher/security/security-compliance-auditor.md +158 -0
- package/personas-teacher/security/security-incident-responder.md +437 -0
- package/personas-teacher/security/security-penetration-tester.md +399 -0
- package/personas-teacher/security/security-senior-secops.md +750 -0
- package/personas-teacher/security/security-threat-detection-engineer.md +534 -0
- package/personas-teacher/security/security-threat-intelligence-analyst.md +644 -0
- package/personas-teacher/spatial-computing/macos-spatial-metal-engineer.md +337 -0
- package/personas-teacher/spatial-computing/terminal-integration-specialist.md +70 -0
- package/personas-teacher/spatial-computing/visionos-spatial-engineer.md +54 -0
- package/personas-teacher/spatial-computing/xr-cockpit-interaction-specialist.md +32 -0
- package/personas-teacher/spatial-computing/xr-immersive-developer.md +32 -0
- package/personas-teacher/spatial-computing/xr-interface-architect.md +32 -0
- package/personas-teacher/specialized/accounts-payable-agent.md +185 -0
- package/personas-teacher/specialized/agentic-identity-trust.md +387 -0
- package/personas-teacher/specialized/agents-orchestrator.md +367 -0
- package/personas-teacher/specialized/automation-governance-architect.md +216 -0
- package/personas-teacher/specialized/business-strategist.md +488 -0
- package/personas-teacher/specialized/change-management-consultant.md +497 -0
- package/personas-teacher/specialized/chief-financial-officer.md +388 -0
- package/personas-teacher/specialized/corporate-training-designer.md +192 -0
- package/personas-teacher/specialized/customer-service.md +398 -0
- package/personas-teacher/specialized/customer-success-manager.md +460 -0
- package/personas-teacher/specialized/data-consolidation-agent.md +60 -0
- package/personas-teacher/specialized/data-privacy-officer.md +412 -0
- package/personas-teacher/specialized/esg-sustainability-officer.md +396 -0
- package/personas-teacher/specialized/government-digital-presales-consultant.md +363 -0
- package/personas-teacher/specialized/grant-writer.md +511 -0
- package/personas-teacher/specialized/healthcare-customer-service.md +389 -0
- package/personas-teacher/specialized/healthcare-marketing-compliance.md +395 -0
- package/personas-teacher/specialized/hospitality-guest-services.md +603 -0
- package/personas-teacher/specialized/hr-onboarding.md +451 -0
- package/personas-teacher/specialized/identity-graph-operator.md +260 -0
- package/personas-teacher/specialized/language-translator.md +264 -0
- package/personas-teacher/specialized/legal-billing-time-tracking.md +569 -0
- package/personas-teacher/specialized/legal-client-intake.md +492 -0
- package/personas-teacher/specialized/legal-document-review.md +454 -0
- package/personas-teacher/specialized/loan-officer-assistant.md +555 -0
- package/personas-teacher/specialized/lsp-index-engineer.md +314 -0
- package/personas-teacher/specialized/ma-integration-manager.md +427 -0
- package/personas-teacher/specialized/medical-billing-coding-specialist.md +491 -0
- package/personas-teacher/specialized/operations-manager.md +399 -0
- package/personas-teacher/specialized/organizational-psychologist.md +391 -0
- package/personas-teacher/specialized/personal-growth-mentor.md +159 -0
- package/personas-teacher/specialized/real-estate-buyer-seller.md +596 -0
- package/personas-teacher/specialized/recruitment-specialist.md +509 -0
- package/personas-teacher/specialized/report-distribution-agent.md +65 -0
- package/personas-teacher/specialized/retail-customer-returns.md +566 -0
- package/personas-teacher/specialized/sales-data-extraction-agent.md +67 -0
- package/personas-teacher/specialized/sales-outreach.md +425 -0
- package/personas-teacher/specialized/specialized-chief-of-staff.md +279 -0
- package/personas-teacher/specialized/specialized-civil-engineer.md +356 -0
- package/personas-teacher/specialized/specialized-cultural-intelligence-strategist.md +88 -0
- package/personas-teacher/specialized/specialized-developer-advocate.md +317 -0
- package/personas-teacher/specialized/specialized-document-generator.md +55 -0
- package/personas-teacher/specialized/specialized-french-consulting-market.md +192 -0
- package/personas-teacher/specialized/specialized-korean-business-navigator.md +216 -0
- package/personas-teacher/specialized/specialized-mcp-builder.md +248 -0
- package/personas-teacher/specialized/specialized-model-qa.md +488 -0
- package/personas-teacher/specialized/specialized-pricing-analyst.md +243 -0
- package/personas-teacher/specialized/specialized-salesforce-architect.md +180 -0
- package/personas-teacher/specialized/specialized-strategy-duel-agent.md +130 -0
- package/personas-teacher/specialized/specialized-workflow-architect.md +597 -0
- package/personas-teacher/specialized/study-abroad-advisor.md +282 -0
- package/personas-teacher/specialized/supply-chain-strategist.md +582 -0
- package/personas-teacher/specialized/zk-steward.md +211 -0
- package/personas-teacher/strategy/EXECUTIVE-BRIEF.md +95 -0
- package/personas-teacher/strategy/QUICKSTART.md +194 -0
- package/personas-teacher/strategy/coordination/agent-activation-prompts.md +401 -0
- package/personas-teacher/strategy/coordination/handoff-templates.md +357 -0
- package/personas-teacher/strategy/nexus-strategy.md +1110 -0
- package/personas-teacher/strategy/playbooks/phase-0-discovery.md +178 -0
- package/personas-teacher/strategy/playbooks/phase-1-strategy.md +238 -0
- package/personas-teacher/strategy/playbooks/phase-2-foundation.md +278 -0
- package/personas-teacher/strategy/playbooks/phase-3-build.md +286 -0
- package/personas-teacher/strategy/playbooks/phase-4-hardening.md +332 -0
- package/personas-teacher/strategy/playbooks/phase-5-launch.md +277 -0
- package/personas-teacher/strategy/playbooks/phase-6-operate.md +318 -0
- package/personas-teacher/strategy/runbooks/scenario-enterprise-feature.md +157 -0
- package/personas-teacher/strategy/runbooks/scenario-incident-response.md +217 -0
- package/personas-teacher/strategy/runbooks/scenario-marketing-campaign.md +187 -0
- package/personas-teacher/strategy/runbooks/scenario-startup-mvp.md +154 -0
- package/personas-teacher/support/support-analytics-reporter.md +365 -0
- package/personas-teacher/support/support-executive-summary-generator.md +212 -0
- package/personas-teacher/support/support-finance-tracker.md +442 -0
- package/personas-teacher/support/support-infrastructure-maintainer.md +618 -0
- package/personas-teacher/support/support-legal-compliance-checker.md +588 -0
- package/personas-teacher/support/support-support-responder.md +585 -0
- package/personas-teacher/testing/testing-accessibility-auditor.md +316 -0
- package/personas-teacher/testing/testing-api-tester.md +306 -0
- package/personas-teacher/testing/testing-evidence-collector.md +210 -0
- package/personas-teacher/testing/testing-performance-benchmarker.md +268 -0
- package/personas-teacher/testing/testing-reality-checker.md +236 -0
- package/personas-teacher/testing/testing-test-results-analyzer.md +305 -0
- package/personas-teacher/testing/testing-tool-evaluator.md +394 -0
- package/personas-teacher/testing/testing-workflow-optimizer.md +450 -0
- package/personas-teacher/tools.json +19 -0
- package/skill/add/SKILL.md +17 -12
- package/skill/add/advisor.md +31 -34
- package/skill/add/components.md +21 -23
- package/skill/add/confidence.md +1 -1
- package/skill/add/deltas.md +12 -13
- package/skill/add/design.md +52 -49
- package/skill/add/fold.md +18 -16
- package/skill/add/intake.md +4 -3
- package/skill/add/loop.md +1 -2
- package/skill/add/phases/0-ground.md +32 -34
- package/skill/add/phases/0-setup.md +22 -21
- package/skill/add/phases/1-specify.md +8 -6
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/4-tests.md +9 -10
- package/skill/add/phases/5-build.md +17 -13
- package/skill/add/phases/6-verify.md +7 -4
- package/skill/add/phases/7-observe.md +2 -1
- package/skill/add/report-template.md +61 -18
- package/skill/add/run.md +4 -3
- package/skill/add/scope.md +2 -2
- package/skill/add/sensitivity.md +51 -0
- package/skill/add/streams.md +24 -23
- package/tooling/add.py +1076 -39
- package/tooling/add_engine/autonomy.py +36 -1
- package/tooling/add_engine/constants.py +56 -3
- package/tooling/add_engine/guidelines.py +22 -20
- package/tooling/add_engine/predicates.py +56 -3
- package/tooling/add_engine/search.py +165 -0
- package/tooling/templates/GLOSSARY.md.tmpl +16 -0
- package/tooling/templates/MILESTONE.md.tmpl +9 -0
- package/tooling/templates/PROMPT.persona.md.tmpl +44 -0
- package/tooling/templates/TASK.fast.md.tmpl +2 -1
- package/tooling/templates/TASK.md.tmpl +36 -6
- package/tooling/templates/gitignore.tmpl +12 -0
- package/tooling/templates/personas/_template.md.tmpl +60 -0
package/tooling/add.py
CHANGED
|
@@ -36,8 +36,11 @@ from add_engine.constants import ( # the _-prefixed names (import * skips them)
|
|
|
36
36
|
_GITIGNORE_BODY, _GUIDE_BEGIN, _GUIDE_END,
|
|
37
37
|
_RULE_REF_LINE, _FALLBACK_TASK, _FALLBACK_TASK_FAST,
|
|
38
38
|
_DEFAULT_WIDTH,
|
|
39
|
-
_DELTA_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
|
|
39
|
+
_DELTA_RE, _PERSONA_TAG_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
|
|
40
|
+
_SEED_POINTER_RE, # shared (delta-task-backlink) — reads the `[→ slug]` seed stamp back
|
|
40
41
|
_AUTONOMY_LEVELS, # shared (autonomy resolvers + _AUTONOMY_ORDER/cmd_autonomy)
|
|
42
|
+
_STREAMS_POSTURES, # shared (streams resolvers + cmd_streams) — run-mode streams half
|
|
43
|
+
_SENSITIVITY_VALUES, # shared (_task_sensitivity + cmd_freeze/status/audit) — risk-class taxonomy
|
|
41
44
|
)
|
|
42
45
|
|
|
43
46
|
# --- terminal-render primitives (moved to add_engine/render.py) -------------
|
|
@@ -78,8 +81,12 @@ from add_engine.taskdoc import (
|
|
|
78
81
|
# --- autonomy-level resolvers (moved to add_engine/autonomy.py) -------------
|
|
79
82
|
from add_engine.autonomy import (
|
|
80
83
|
_autonomy_level, _effective_autonomy, _project_autonomy, _project_autonomy_token,
|
|
84
|
+
_project_streams, # run-mode streams half (persist-run-mode) — read live from PROJECT.md
|
|
81
85
|
)
|
|
82
86
|
|
|
87
|
+
# --- keyword/substring corpus search (NEW — add_engine/search.py) -----------
|
|
88
|
+
from add_engine.search import _search_corpus
|
|
89
|
+
|
|
83
90
|
|
|
84
91
|
def _phase_index(name: str) -> int:
|
|
85
92
|
"""Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
|
|
@@ -150,10 +157,151 @@ def _render_template(name: str, **subs: str) -> str:
|
|
|
150
157
|
text = text.replace("{{" + key + "}}", val)
|
|
151
158
|
return text
|
|
152
159
|
|
|
160
|
+
|
|
161
|
+
# --- TASK.md milestone backlink (task-milestone-backlink) --------------------
|
|
162
|
+
# The task↔milestone link is mirrored into the TASK.md header so the file names its
|
|
163
|
+
# own parent. The engine WRITES it (new-task) and MAINTAINS it (set-milestone); a
|
|
164
|
+
# milestone-free task reads the "(none)" sentinel, never blank. Keeping it engine-owned
|
|
165
|
+
# is what makes it drift-proof — `check` flags a hand-edited line that disagrees.
|
|
166
|
+
_MILESTONE_BACKLINK = "(none)"
|
|
167
|
+
_MILESTONE_LINE_RE = re.compile(r"(?m)^milestone:[^\n]*$")
|
|
168
|
+
_SLUG_LINE_RE = re.compile(r"(?m)^slug:[^\n]*$")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _milestone_backlink_value(milestone) -> str:
|
|
172
|
+
"""The header value for a milestone slug (or the sentinel when milestone-free)."""
|
|
173
|
+
return milestone if milestone else _MILESTONE_BACKLINK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _set_milestone_line(text: str, value: str) -> str:
|
|
177
|
+
"""Rewrite (or insert) the TASK.md header `milestone:` backlink — idempotent.
|
|
178
|
+
|
|
179
|
+
A grandfathered file lacking the line gets it inserted right after `slug:`; with no
|
|
180
|
+
slug line either, the text is returned unchanged (degrade-safe — never corrupts a doc).
|
|
181
|
+
"""
|
|
182
|
+
line = f"milestone: {value}"
|
|
183
|
+
if _MILESTONE_LINE_RE.search(text):
|
|
184
|
+
return _MILESTONE_LINE_RE.sub(lambda _m: line, text, count=1)
|
|
185
|
+
m = _SLUG_LINE_RE.search(text)
|
|
186
|
+
if not m:
|
|
187
|
+
return text
|
|
188
|
+
return text[:m.end()] + "\n" + line + text[m.end():]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read_milestone_line(text: str):
|
|
192
|
+
"""The current `milestone:` backlink value in a TASK.md header, or None if absent."""
|
|
193
|
+
m = _MILESTONE_LINE_RE.search(text)
|
|
194
|
+
return m.group(0)[len("milestone:"):].strip() if m else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- MILESTONE.md release backlink (milestone-release-backlink) --------------
|
|
198
|
+
# The milestone↔release link is mirrored into the MILESTONE.md header: the template seeds
|
|
199
|
+
# `release: pending`; cmd_release STAMPS it to the cut version (the stamp rides the same
|
|
200
|
+
# all-or-nothing batch as CHANGELOG + RELEASES). The mirror of _set_milestone_line, one
|
|
201
|
+
# scope level up — keying on `^release:`, inserting after the `stage:` line if absent.
|
|
202
|
+
_RELEASE_LINE_RE = re.compile(r"(?m)^release:[^\n]*$")
|
|
203
|
+
_STAGE_LINE_RE = re.compile(r"(?m)^stage:[^\n]*$")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _set_release_line(text: str, value: str) -> str:
|
|
207
|
+
"""Rewrite (or insert) the MILESTONE.md header `release:` backlink — idempotent.
|
|
208
|
+
|
|
209
|
+
A grandfathered file lacking the line gets it inserted right after the `stage:` line;
|
|
210
|
+
with no stage line either, the text is returned unchanged (degrade-safe)."""
|
|
211
|
+
line = f"release: {value}"
|
|
212
|
+
if _RELEASE_LINE_RE.search(text):
|
|
213
|
+
return _RELEASE_LINE_RE.sub(lambda _m: line, text, count=1)
|
|
214
|
+
m = _STAGE_LINE_RE.search(text)
|
|
215
|
+
if not m:
|
|
216
|
+
return text
|
|
217
|
+
return text[:m.end()] + "\n" + line + text[m.end():]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- §0 GROUND drift anchor (ground-anchor-sha) -----------------------------
|
|
221
|
+
# §0 line numbers rot during BUILD while symbols survive (PR40 audit). The engine SEEDS a
|
|
222
|
+
# `Ground SHA:` field (the AI fills it via git — NO-EXEC: add.py never shells out) and `check`
|
|
223
|
+
# WARNs when a §0 cites bare line numbers without one, so drift is detectable not silent.
|
|
224
|
+
_GROUND_SHA_RE = re.compile(r"(?m)^Ground SHA:[ \t]*(.*?)[ \t]*$")
|
|
225
|
+
_LINE_REF_RE = re.compile(r"l\.\d+")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _ground_section(text: str) -> str:
|
|
229
|
+
"""The §0 GROUND block of a TASK.md — from the `## 0` heading to the next `## ` heading."""
|
|
230
|
+
m = re.search(r"(?m)^## 0\b", text)
|
|
231
|
+
if not m:
|
|
232
|
+
return ""
|
|
233
|
+
rest = text[m.end():]
|
|
234
|
+
nxt = re.search(r"(?m)^## ", rest)
|
|
235
|
+
return rest[:nxt.start()] if nxt else rest
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_ground_sha(text: str):
|
|
239
|
+
"""The §0 `Ground SHA:` value, or None if absent or still a `<…>` placeholder."""
|
|
240
|
+
m = _GROUND_SHA_RE.search(_ground_section(text))
|
|
241
|
+
if not m:
|
|
242
|
+
return None
|
|
243
|
+
val = m.group(1).strip()
|
|
244
|
+
return None if (not val or val.startswith("<")) else val
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _ground_cites_line_ref(text: str) -> bool:
|
|
248
|
+
"""True iff the §0 GROUND block cites a bare line number (the `l.NNN` idiom)."""
|
|
249
|
+
return bool(_LINE_REF_RE.search(_ground_section(text)))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _seeded_delta_pointers(text: str) -> list[str]:
|
|
253
|
+
"""The task slugs `[SPEC · seeded] … [→ <slug>]` lines point at (delta-task-backlink). PURE.
|
|
254
|
+
|
|
255
|
+
Walks the delta→task lineage backward: each seeded SPEC delta carries the slug it was seeded
|
|
256
|
+
into (the `[→ <slug>]` stamp `_resolve_spec_delta` appends). `check` flags a pointer that no
|
|
257
|
+
longer resolves to a live or archived task. Order-preserving; open/dropped deltas are ignored."""
|
|
258
|
+
out: list[str] = []
|
|
259
|
+
for ln in text.splitlines():
|
|
260
|
+
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
261
|
+
if not m or m.group(2) != "seeded":
|
|
262
|
+
continue
|
|
263
|
+
p = _SEED_POINTER_RE.search(m.group(3))
|
|
264
|
+
if p:
|
|
265
|
+
out.append(p.group(1))
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --- tidy a closed TASK.md (strip-scaffold-at-done) --------------------------
|
|
270
|
+
# A live TASK.md carries `<!-- … -->` instruction comments that guide the active phase; once the
|
|
271
|
+
# task is `done` they are dead weight (PR40 audit). cmd_gate strips them on a COMPLETING gate.
|
|
272
|
+
# Content-safe: fenced code blocks (```…```, incl. the frozen §3) pass through BYTE-EXACT — only
|
|
273
|
+
# comments OUTSIDE a fence are removed; idempotent.
|
|
274
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
275
|
+
_TRAILING_WS_RE = re.compile(r"(?m)[ \t]+$")
|
|
276
|
+
_BLANK_RUN_RE = re.compile(r"\n{3,}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _strip_live_scaffold(text: str) -> str:
|
|
280
|
+
"""Remove `<!-- … -->` instruction comments from a TASK.md — fences untouched, idempotent.
|
|
281
|
+
|
|
282
|
+
Splits on fenced code blocks so a comment inside a ``` fence (e.g. the frozen §3) is never
|
|
283
|
+
touched; in the non-fence segments it drops comment spans, trims the trailing whitespace a
|
|
284
|
+
removal leaves on a line, and collapses 3+ consecutive newlines to one blank line."""
|
|
285
|
+
segs = re.split(r"(```.*?```)", text, flags=re.DOTALL)
|
|
286
|
+
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
287
|
+
s = _HTML_COMMENT_RE.sub("", segs[i])
|
|
288
|
+
s = _TRAILING_WS_RE.sub("", s)
|
|
289
|
+
segs[i] = _BLANK_RUN_RE.sub("\n\n", s)
|
|
290
|
+
return "".join(segs)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _contract_fingerprint(raw3: str) -> str:
|
|
294
|
+
"""md5 of the §3 CONTRACT CONTENT — comment-normalized + outer-whitespace-canonical (the
|
|
295
|
+
instruction comment is scaffolding, not contract). Used on BOTH tamper-guard sides
|
|
296
|
+
(_tripwire_snapshot + _tripwire_divergence) so the at-done strip — which removes the §3
|
|
297
|
+
comment and shifts the section's boundary whitespace — never reads as `contract_tampered`,
|
|
298
|
+
while a real fenced-shape edit still does."""
|
|
299
|
+
return _md5_text(_strip_live_scaffold(raw3).strip())
|
|
300
|
+
|
|
153
301
|
# --- state/markdown predicates (moved to add_engine/predicates.py) -----------
|
|
154
302
|
from add_engine.predicates import (
|
|
155
303
|
_phase_owner, _setup_locked, _milestone_confirmed, _section_unfilled,
|
|
156
|
-
_task_done,
|
|
304
|
+
_task_done, _persona_missing, _persona_slug_valid, _rule_coverage_gaps,
|
|
157
305
|
)
|
|
158
306
|
|
|
159
307
|
# --- git-native identity/actor seam (moved to add_engine/identity.py) --------
|
|
@@ -228,11 +376,18 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
228
376
|
new = text
|
|
229
377
|
for pat, repl in rules:
|
|
230
378
|
new = re.sub(pat, repl, new, count=1)
|
|
231
|
-
# component-aware-add (per-component-verify): record WHICH green-bar
|
|
232
|
-
# against
|
|
379
|
+
# component-aware-add (per-component-verify) + component-registry-fill: record WHICH green-bar
|
|
380
|
+
# the bound task gated against AND the component's verify COMMAND, right after the Outcome line.
|
|
381
|
+
# Unbound / neither declared -> no line (byte-identical). green-bar-only output is unchanged.
|
|
233
382
|
_bar = _task_green_bar(root, slug)
|
|
234
|
-
|
|
235
|
-
|
|
383
|
+
_vfy = _task_verify(root, slug)
|
|
384
|
+
if _bar or _vfy:
|
|
385
|
+
_parts = [f"component: {_task_component(root, slug)}"]
|
|
386
|
+
if _bar:
|
|
387
|
+
_parts.append(f"expected green-bar: {_bar}")
|
|
388
|
+
if _vfy:
|
|
389
|
+
_parts.append(f"verify: {_vfy}")
|
|
390
|
+
_line = " · ".join(_parts) # deterministic per task -> idempotent on re-stamp
|
|
236
391
|
if _line not in new:
|
|
237
392
|
new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
|
|
238
393
|
if new != text: # no-op = no write (mtime stable)
|
|
@@ -383,6 +538,19 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
383
538
|
continue
|
|
384
539
|
_atomic_write(dest, rendered)
|
|
385
540
|
|
|
541
|
+
# --run-mode: apply the paired autonomy + streams posture into PROJECT.md.
|
|
542
|
+
# ONLY when the flag is explicitly set — absent flag leaves PROJECT.md byte-identical.
|
|
543
|
+
run_mode = getattr(args, "run_mode", None)
|
|
544
|
+
if run_mode is not None:
|
|
545
|
+
_level = run_mode # "auto" | "conservative"
|
|
546
|
+
_posture = "parallel" if run_mode == "auto" else "sequential"
|
|
547
|
+
proj_md = root / "PROJECT.md"
|
|
548
|
+
if proj_md.exists():
|
|
549
|
+
_text = proj_md.read_text(encoding="utf-8")
|
|
550
|
+
_text = _autonomy_decl_line(_text, _level)
|
|
551
|
+
_text = _streams_decl_line(_text, _posture)
|
|
552
|
+
_atomic_write(proj_md, _text)
|
|
553
|
+
|
|
386
554
|
state = {
|
|
387
555
|
"project": proj_name,
|
|
388
556
|
"stage": args.stage,
|
|
@@ -489,10 +657,18 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
489
657
|
rendered = _render_template(
|
|
490
658
|
"TASK.fast.md" if fast else "TASK.md",
|
|
491
659
|
title=title, slug=slug, date=date.today().isoformat(),
|
|
492
|
-
stage=state["stage"], autonomy=autonomy
|
|
660
|
+
stage=state["stage"], autonomy=autonomy,
|
|
661
|
+
milestone=_milestone_backlink_value(milestone))
|
|
493
662
|
if feature_override: # pre-fill §1 from the seeded delta
|
|
494
663
|
rendered = re.sub(r"(?m)^Feature:.*$",
|
|
495
664
|
lambda _m: f"Feature: {feature_override}", rendered, count=1)
|
|
665
|
+
if from_delta: # delta-task-backlink: §0 reverse link
|
|
666
|
+
# pre-fill the §0 Related-intent PLACEHOLDER only (the `<…>` line a fresh full template
|
|
667
|
+
# carries) — mirrors the §1 Feature pre-fill, gated by from_delta, count=1. The fast
|
|
668
|
+
# template has no §0 Related-intent line, so the sub is a silent no-op there.
|
|
669
|
+
_bl = f"Related intent: seeded from {prior} spec-delta — \"{delta_text}\" [← {prior}]"
|
|
670
|
+
rendered = re.sub(r"(?m)^Related intent:\s*<.*>\s*$",
|
|
671
|
+
lambda _m: _bl, rendered, count=1)
|
|
496
672
|
seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
|
|
497
673
|
if flipped_prior is not None: # consume the source delta -> seeded
|
|
498
674
|
seed_writes.append((prior_md, flipped_prior))
|
|
@@ -690,6 +866,15 @@ def cmd_freeze(args: argparse.Namespace) -> None:
|
|
|
690
866
|
if not _flag_well_formed(raw3):
|
|
691
867
|
_die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
|
|
692
868
|
f"('Least-sure flag surfaced at freeze:' + a [part] tag) before it freezes")
|
|
869
|
+
# the human declares the risk-CLASS at freeze (risk-sensitivity-taxonomy): a present-but-
|
|
870
|
+
# unknown sensitivity token is refused here (validate-then-write — nothing is written);
|
|
871
|
+
# an absent token is grandfathered (allowed), a valid member proceeds. The engine never
|
|
872
|
+
# classifies — it only validates the human's declaration.
|
|
873
|
+
_valid_sens = _project_sensitivity_values(root) # base ∪ project GLOSSARY classes (sensitivity-glossary)
|
|
874
|
+
if _task_sensitivity(_task_header(root, slug), valid=_valid_sens) == "?":
|
|
875
|
+
_die(f"sensitivity_invalid: {slug} declares an unknown sensitivity — use one of "
|
|
876
|
+
f"{', '.join(_valid_sens)} (or add the class to GLOSSARY.md's '## Sensitivity classes', "
|
|
877
|
+
"or omit the line)")
|
|
693
878
|
# --- write ---
|
|
694
879
|
ver = _next_freeze_version(state, slug)
|
|
695
880
|
who = args.by or identity._actor_stamp(state)["name"]
|
|
@@ -835,6 +1020,10 @@ def cmd_phase(args: argparse.Namespace) -> None:
|
|
|
835
1020
|
# loop sets phase=build directly (never via cmd_phase) and so stays exempt.
|
|
836
1021
|
if args.phase == "build":
|
|
837
1022
|
_build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
|
|
1023
|
+
# cross-component-recency: the `phase contract` override runs the SAME consumer HOLD `advance`
|
|
1024
|
+
# runs (existence + recency), so it is not a backdoor around producer_contract_unfrozen/_stale.
|
|
1025
|
+
if args.phase == "contract":
|
|
1026
|
+
_consumer_contract_hold(root, state, slug)
|
|
838
1027
|
state["tasks"][slug]["phase"] = args.phase
|
|
839
1028
|
state["tasks"][slug]["updated"] = _now()
|
|
840
1029
|
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
@@ -863,12 +1052,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
863
1052
|
# the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
|
|
864
1053
|
# `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
|
|
865
1054
|
if nxt == "contract":
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
|
|
871
|
-
f".add/contracts/{_cid}.json")
|
|
1055
|
+
# existence + RECENCY (cross-component-recency): a missing snapshot HARD-STOPs
|
|
1056
|
+
# producer_contract_unfrozen; a present-but-stale leftover (live producer drifted/unfroze)
|
|
1057
|
+
# HARD-STOPs producer_contract_stale. Shared with cmd_phase so the override is no backdoor.
|
|
1058
|
+
_consumer_contract_hold(root, state, slug)
|
|
872
1059
|
# flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
|
|
873
1060
|
# into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
|
|
874
1061
|
# verified marker so `audit` enforces the flag on THIS record only (open/new
|
|
@@ -947,6 +1134,71 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
947
1134
|
# not be able to fool the guard either way).
|
|
948
1135
|
_RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
|
|
949
1136
|
|
|
1137
|
+
# sensitivity taxonomy (risk-sensitivity-taxonomy): the risk-CLASS the human declares in the
|
|
1138
|
+
# TASK header at freeze — same anchored declaration grammar as risk:/autonomy: (line-start or
|
|
1139
|
+
# `·`, value stops at whitespace/`<`/`#`/`|`), so a title/prose substring is never a declaration.
|
|
1140
|
+
_SENSITIVITY_RE = re.compile(r"(?:^|·)[ \t]*sensitivity:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1141
|
+
|
|
1142
|
+
def _task_sensitivity(hdr: str, valid=None):
|
|
1143
|
+
"""The declared sensitivity from a TASK.md header region (HTML comments already stripped by
|
|
1144
|
+
_task_header). A member of `valid`, None when no `sensitivity:` line is present, or "?" when a
|
|
1145
|
+
REAL token outside `valid` was written. `valid` defaults to _SENSITIVITY_VALUES (the universal
|
|
1146
|
+
base — back-compat); callers that honor a project's GLOSSARY domain classes pass
|
|
1147
|
+
valid=_project_sensitivity_values(root) (sensitivity-glossary). PURE — the engine validates a
|
|
1148
|
+
human-declared token against the project's vocabulary, it never infers it (mirrors _autonomy_level)."""
|
|
1149
|
+
if valid is None:
|
|
1150
|
+
valid = _SENSITIVITY_VALUES
|
|
1151
|
+
m = _SENSITIVITY_RE.search(hdr)
|
|
1152
|
+
if not m:
|
|
1153
|
+
return None
|
|
1154
|
+
tok = m.group(1).strip().lower()
|
|
1155
|
+
return tok if tok in valid else "?"
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
# sensitivity-glossary: a project EXTENDS the universal base with domain risk-classes declared in
|
|
1159
|
+
# GLOSSARY.md's "## Sensitivity classes" section (the AI keeps it current per the skill guide). The
|
|
1160
|
+
# base four stay method-universal (advisor-gate-relax keys off `mechanical`) — a project never
|
|
1161
|
+
# REMOVES them. Read live like _project_autonomy/_project_streams (no state.json field). The first
|
|
1162
|
+
# GLOSSARY reader in the engine; degrade-safe by construction (design-for-failure).
|
|
1163
|
+
_SENS_CLASSES_HEADING_RE = re.compile(r"(?im)^##[ \t]+sensitivity[ \t]+classes\b.*$")
|
|
1164
|
+
# a domain line is "- <token>: …" or "- <token> — …"; the token must START with a letter, so a
|
|
1165
|
+
# placeholder ("- <token>: …") begins with "<" and never matches, and the ": "/"—" separator keeps
|
|
1166
|
+
# a prose bullet from being read as a class. HTML comments are stripped FIRST (mirrors _project_
|
|
1167
|
+
# autonomy/_project_streams) so a commented-out template example is never a declaration.
|
|
1168
|
+
_SENS_CLASS_LINE_RE = re.compile(r"(?m)^[ \t]*-[ \t]+([A-Za-z][\w-]*)[ \t]*(?::|—)")
|
|
1169
|
+
|
|
1170
|
+
def _project_sensitivity_domain(root: Path) -> tuple:
|
|
1171
|
+
"""Domain sensitivity tokens declared in GLOSSARY.md's "## Sensitivity classes" section,
|
|
1172
|
+
lowercased and deduped in document order. FAIL-SAFE: no GLOSSARY.md / no section / unreadable
|
|
1173
|
+
-> () (the caller always unions the base in, so the vocabulary is never empty)."""
|
|
1174
|
+
try:
|
|
1175
|
+
text = (root / "GLOSSARY.md").read_text(encoding="utf-8")
|
|
1176
|
+
except OSError:
|
|
1177
|
+
return ()
|
|
1178
|
+
text = re.sub(r"<!--.*?-->", "", text, flags=re.S) # a commented example is never a declaration
|
|
1179
|
+
m = _SENS_CLASSES_HEADING_RE.search(text)
|
|
1180
|
+
if not m:
|
|
1181
|
+
return ()
|
|
1182
|
+
body = text[m.end():]
|
|
1183
|
+
nxt = re.search(r"(?m)^##[ \t]", body) # stop at the next section heading
|
|
1184
|
+
if nxt:
|
|
1185
|
+
body = body[:nxt.start()]
|
|
1186
|
+
seen, out = set(), []
|
|
1187
|
+
for tok in (t.strip().lower() for t in _SENS_CLASS_LINE_RE.findall(body)):
|
|
1188
|
+
if tok and tok not in seen:
|
|
1189
|
+
seen.add(tok)
|
|
1190
|
+
out.append(tok)
|
|
1191
|
+
return tuple(out)
|
|
1192
|
+
|
|
1193
|
+
def _project_sensitivity_values(root: Path) -> tuple:
|
|
1194
|
+
"""The full sensitivity vocabulary for a project: the universal base _SENSITIVITY_VALUES
|
|
1195
|
+
(always present, listed first) ∪ the project's GLOSSARY.md domain classes. Base-first, deduped."""
|
|
1196
|
+
out = list(_SENSITIVITY_VALUES)
|
|
1197
|
+
for tok in _project_sensitivity_domain(root):
|
|
1198
|
+
if tok not in out:
|
|
1199
|
+
out.append(tok)
|
|
1200
|
+
return tuple(out)
|
|
1201
|
+
|
|
950
1202
|
# the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
|
|
951
1203
|
# manual < conservative < auto, declared as a per-task `autonomy:` header token.
|
|
952
1204
|
# anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
|
|
@@ -969,6 +1221,66 @@ def _autonomy_lowered(hdr: str) -> bool:
|
|
|
969
1221
|
return _autonomy_level(hdr) in ("manual", "conservative")
|
|
970
1222
|
|
|
971
1223
|
|
|
1224
|
+
# advisor-gate-relax helpers: read the "### Advisor 3-lens verdict" SUB-SECTION
|
|
1225
|
+
# of body6, not the whole §6, so the refute-read's Verdict/Residue lines are
|
|
1226
|
+
# never mistaken for the advisor's fields. Fail-safe: both return False when the
|
|
1227
|
+
# advisor block is absent → the guard fires (conservative by design).
|
|
1228
|
+
|
|
1229
|
+
def _advisor_slice(body6: str) -> str:
|
|
1230
|
+
"""Return the '### Advisor 3-lens verdict' sub-section text from §6 body.
|
|
1231
|
+
Returns '' when the block is absent (fail-safe)."""
|
|
1232
|
+
m = re.search(r"(?m)^### Advisor 3-lens verdict\b", body6)
|
|
1233
|
+
if not m:
|
|
1234
|
+
return ""
|
|
1235
|
+
nxt = re.search(r"(?m)^### ", body6[m.end():])
|
|
1236
|
+
end = m.end() + nxt.start() if nxt else len(body6)
|
|
1237
|
+
return body6[m.start():end]
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _advisor_verdict_is_pass(body6: str) -> bool:
|
|
1241
|
+
"""True iff the Advisor 3-lens verdict sub-section declares Verdict: PASS…
|
|
1242
|
+
Scoped to the advisor block only — the §6 Refute-read Verdict line is excluded.
|
|
1243
|
+
Fail-safe: False when the advisor block is absent (guard fires)."""
|
|
1244
|
+
slc = _advisor_slice(body6)
|
|
1245
|
+
m = re.search(r"(?m)^Verdict:[ \t]*(\S+)", slc)
|
|
1246
|
+
return bool(m) and m.group(1).upper().startswith("PASS")
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _advisor_no_residue(body6: str) -> bool:
|
|
1250
|
+
"""True iff the Advisor 3-lens verdict sub-section declares Residue: none.
|
|
1251
|
+
Fail-safe: False when the advisor block is absent (guard fires)."""
|
|
1252
|
+
slc = _advisor_slice(body6)
|
|
1253
|
+
m = re.search(r"(?m)^Residue:[ \t]*(\S+)", slc)
|
|
1254
|
+
return bool(m) and m.group(1).strip().lower() == "none"
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
# step-spawn-hint (advisor-gated-autonomy): the engine's pinned terse copy of each phase
|
|
1258
|
+
# guide's Advisor hook — one spawn idiom per phase. contract/done are ABSENT (no batch to
|
|
1259
|
+
# fan out: a freeze is one human decision; done is closed). Advisory ONLY — the engine never
|
|
1260
|
+
# spawns; the line just NAMES the agent shape a parallel run would use at this step.
|
|
1261
|
+
_SPAWN_HINTS = {
|
|
1262
|
+
"ground": "broad sweep",
|
|
1263
|
+
"specify": "domain researcher",
|
|
1264
|
+
"scenarios": "wide scenario sweep",
|
|
1265
|
+
"tests": "red-suite test-author",
|
|
1266
|
+
"build": "independent well-scoped batch",
|
|
1267
|
+
"verify": "earned-green refute-read",
|
|
1268
|
+
"observe": "lessons-mining reviewer",
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _spawn_hint_line(task: dict, autonomy: str) -> str | None:
|
|
1273
|
+
"""PURE: the one-line per-step spawn hint for the ACTIVE task, or None. Suppressed when
|
|
1274
|
+
the autonomy level is `manual` (the human drives every step) or the phase has no idiom
|
|
1275
|
+
(contract/done). The tier reflects declared risk: a `risk: high` task earns `top`
|
|
1276
|
+
(your strongest agent), everything else `mid`. Never spawns, never mutates state."""
|
|
1277
|
+
phase = task.get("phase")
|
|
1278
|
+
if autonomy == "manual" or phase not in _SPAWN_HINTS:
|
|
1279
|
+
return None
|
|
1280
|
+
tier = "top" if task.get("risk") == "high" else "mid"
|
|
1281
|
+
return f"spawn hint: {phase} → {_SPAWN_HINTS[phase]} (tier: {tier})"
|
|
1282
|
+
|
|
1283
|
+
|
|
972
1284
|
def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
|
|
973
1285
|
"""True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
|
|
974
1286
|
SINGLE source the footer marker and the guide TEXT marker both render (task
|
|
@@ -1014,8 +1326,16 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1014
1326
|
# the mechanized high-risk guard: an unguarded high-risk header refuses
|
|
1015
1327
|
# COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
|
|
1016
1328
|
# owns the gate. HARD-STOP is never blocked — stopping is always allowed.
|
|
1329
|
+
# advisor-gate-relax: a mechanical task whose Advisor 3-lens verdict shows
|
|
1330
|
+
# PASS + Residue: none is the one exception — it may auto-complete even
|
|
1331
|
+
# without a lowered dial. Only sensitivity:"mechanical" qualifies; security/
|
|
1332
|
+
# data/architecture never relax; an absent advisor block is fail-safe False.
|
|
1017
1333
|
hdr = _task_header(root, slug)
|
|
1018
|
-
|
|
1334
|
+
body6 = _raw_phase_bodies(root, slug).get(6, "")
|
|
1335
|
+
_relaxed = (_task_sensitivity(hdr) == "mechanical"
|
|
1336
|
+
and _advisor_verdict_is_pass(body6)
|
|
1337
|
+
and _advisor_no_residue(body6))
|
|
1338
|
+
if _RISK_HIGH_RE.search(hdr) and not _autonomy_lowered(hdr) and not _relaxed:
|
|
1019
1339
|
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
1020
1340
|
"without a lowered autonomy level — run `add.py autonomy set conservative` "
|
|
1021
1341
|
"(or manual); a human must own a high-risk gate (run.md guard)")
|
|
@@ -1063,10 +1383,22 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1063
1383
|
_sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
|
|
1064
1384
|
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
1065
1385
|
_stamp_adr_record(root, state, slug) # adr-at-observe: harvest §7 Decisions (ADR) — AFTER §6 is mirrored
|
|
1386
|
+
if completing: # strip-scaffold-at-done: tidy the now-closed
|
|
1387
|
+
_tf = root / "tasks" / slug / "TASK.md" # TASK.md LAST (after the stampers) — drop the
|
|
1388
|
+
try: # live-phase `<!-- -->` comments; fences untouched.
|
|
1389
|
+
_tt = _tf.read_text(encoding="utf-8")
|
|
1390
|
+
_st = _strip_live_scaffold(_tt)
|
|
1391
|
+
if _st != _tt:
|
|
1392
|
+
_atomic_write(_tf, _st)
|
|
1393
|
+
except OSError:
|
|
1394
|
+
pass # degrade-safe — the verdict is already in state
|
|
1066
1395
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
1067
1396
|
_gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
|
|
1068
1397
|
if _gbar:
|
|
1069
1398
|
print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
|
|
1399
|
+
_vfy = _task_verify(root, slug) # component-registry-fill: surface the verify command
|
|
1400
|
+
if _vfy:
|
|
1401
|
+
print(f"verify: {_vfy} # run this suite — the engine does not (NO-EXEC)")
|
|
1070
1402
|
# the engine-sourced next step (next-footer-engine): a completing gate hands off to the
|
|
1071
1403
|
# state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
|
|
1072
1404
|
print(_next_footer(root, state))
|
|
@@ -1097,6 +1429,20 @@ def _autonomy_decl_line(text: str, level: str) -> str:
|
|
|
1097
1429
|
return f"autonomy: {level}\n" + text
|
|
1098
1430
|
|
|
1099
1431
|
|
|
1432
|
+
def _streams_decl_line(text: str, posture: str) -> str:
|
|
1433
|
+
"""Rewrite the SINGLE `streams:` declaration line to `posture`, PRESERVING its trailing comment,
|
|
1434
|
+
idempotently (replace in place, count=1 — never a second line). If absent, insert it after a
|
|
1435
|
+
leading `#` heading (PROJECT.md), else prepend. PURE on the text; the caller does the atomic
|
|
1436
|
+
write. Mirrors _autonomy_decl_line — streams is project-scoped, so there is no slug-line branch."""
|
|
1437
|
+
pat = re.compile(r"(?m)^(streams:[ \t]*)[^\s<#|]+(.*)$")
|
|
1438
|
+
if pat.search(text):
|
|
1439
|
+
return pat.sub(lambda m: f"{m.group(1)}{posture}{m.group(2)}", text, count=1)
|
|
1440
|
+
lines = text.splitlines(keepends=True)
|
|
1441
|
+
if lines and lines[0].lstrip().startswith("#"):
|
|
1442
|
+
return lines[0] + f"streams: {posture}\n" + "".join(lines[1:])
|
|
1443
|
+
return f"streams: {posture}\n" + text
|
|
1444
|
+
|
|
1445
|
+
|
|
1100
1446
|
def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
|
|
1101
1447
|
"""RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
|
|
1102
1448
|
freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
|
|
@@ -1146,6 +1492,25 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1146
1492
|
_print_autonomy(root, state, slug)
|
|
1147
1493
|
|
|
1148
1494
|
|
|
1495
|
+
def cmd_streams(args: argparse.Namespace) -> None:
|
|
1496
|
+
"""show / set the project STREAMS posture — the parallel-vs-sequential half of the run mode
|
|
1497
|
+
(persist-run-mode). Project-scoped (parallelism is ACROSS tasks, so there is no per-task posture)
|
|
1498
|
+
and unguarded by a raise (parallel drops no human gate — it only overlaps builds). The setter
|
|
1499
|
+
mirrors cmd_autonomy's --project branch: a PURE _streams_decl_line + atomic write into PROJECT.md.
|
|
1500
|
+
state.json is UNTOUCHED — the posture lives in PROJECT.md beside autonomy."""
|
|
1501
|
+
root = _require_root() # reused -> "no .add/ project found …"
|
|
1502
|
+
if (getattr(args, "action", None) or "show") == "show":
|
|
1503
|
+
print(f"streams: {_project_streams(root)}")
|
|
1504
|
+
return
|
|
1505
|
+
posture = args.posture
|
|
1506
|
+
if posture not in _STREAMS_POSTURES:
|
|
1507
|
+
_die("streams_posture_invalid: posture must be one of "
|
|
1508
|
+
f"{', '.join(_STREAMS_POSTURES)} (got {posture!r})")
|
|
1509
|
+
target = root / "PROJECT.md"
|
|
1510
|
+
_atomic_write(target, _streams_decl_line(target.read_text(encoding="utf-8"), posture))
|
|
1511
|
+
print(f"project streams -> {posture}")
|
|
1512
|
+
|
|
1513
|
+
|
|
1149
1514
|
def cmd_todo(args: argparse.Namespace) -> None:
|
|
1150
1515
|
"""Capture / list / close a lightweight backlog todo (task: todo-capture).
|
|
1151
1516
|
|
|
@@ -1425,18 +1790,41 @@ def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
|
|
|
1425
1790
|
return PLAIN
|
|
1426
1791
|
|
|
1427
1792
|
|
|
1793
|
+
_STATUS_PAGE_SIZE = 10 # status-pagination: default cap on milestones:/tasks: lists; --all lifts it
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _sorted_by_updated(items: dict) -> list:
|
|
1797
|
+
"""Return `items.items()` sorted by each record's `updated` timestamp, newest first.
|
|
1798
|
+
Read/serialization-time only — never mutates the caller's dict or state.json order."""
|
|
1799
|
+
return sorted(items.items(), key=lambda kv: kv[1].get("updated") or "", reverse=True)
|
|
1800
|
+
|
|
1801
|
+
|
|
1428
1802
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
1803
|
+
show_all = getattr(args, "all", False)
|
|
1429
1804
|
if getattr(args, "json", False):
|
|
1430
1805
|
root, state = _load_state_for_json()
|
|
1431
1806
|
tasks = state.get("tasks") or {}
|
|
1807
|
+
task_slug = getattr(args, "task", None)
|
|
1808
|
+
if task_slug:
|
|
1809
|
+
t = tasks.get(task_slug)
|
|
1810
|
+
if t is None:
|
|
1811
|
+
_die("unknown_task")
|
|
1812
|
+
print(json.dumps({"slug": task_slug, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
1813
|
+
"milestone": t.get("milestone"),
|
|
1814
|
+
"owner": t.get("owner"), "assignee": t.get("assignee")}))
|
|
1815
|
+
return
|
|
1432
1816
|
milestones = state.get("milestones") or {}
|
|
1817
|
+
sorted_ms = _sorted_by_updated(milestones)
|
|
1818
|
+
page_ms = sorted_ms if show_all else sorted_ms[:_STATUS_PAGE_SIZE]
|
|
1433
1819
|
ms_list = []
|
|
1434
|
-
for mslug, m in
|
|
1820
|
+
for mslug, m in page_ms:
|
|
1435
1821
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1436
1822
|
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
1437
1823
|
"done": sum(1 for t in members if _task_done(t)),
|
|
1438
1824
|
"total": len(members),
|
|
1439
1825
|
"owner": m.get("owner"), "assignee": m.get("assignee")})
|
|
1826
|
+
sorted_tasks = _sorted_by_updated(tasks)
|
|
1827
|
+
page_tasks = sorted_tasks if show_all else sorted_tasks[:_STATUS_PAGE_SIZE]
|
|
1440
1828
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1441
1829
|
print(json.dumps({
|
|
1442
1830
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
@@ -1445,10 +1833,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1445
1833
|
"active_milestones": list(state.get("active_milestones") or []),
|
|
1446
1834
|
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
1447
1835
|
"milestones": ms_list,
|
|
1836
|
+
"milestones_total": len(milestones),
|
|
1448
1837
|
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
1449
1838
|
"milestone": t.get("milestone"),
|
|
1450
1839
|
"owner": t.get("owner"), "assignee": t.get("assignee")}
|
|
1451
|
-
for s, t in
|
|
1840
|
+
for s, t in page_tasks],
|
|
1841
|
+
"tasks_total": len(tasks),
|
|
1452
1842
|
"graduation_ready": grad_ready,
|
|
1453
1843
|
"stage_criteria": {"met": grad_met, "total": grad_total}}))
|
|
1454
1844
|
return
|
|
@@ -1463,6 +1853,9 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1463
1853
|
# project autonomy default (task init-auto-default): the posture new tasks INHERIT,
|
|
1464
1854
|
# read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
|
|
1465
1855
|
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
1856
|
+
# run mode (persist-run-mode): the combined streams + autonomy posture, both read LIVE from
|
|
1857
|
+
# PROJECT.md so the human sees the whole run-mode throttle every session. Advisory; engine never spawns.
|
|
1858
|
+
print(f"run mode: {_project_streams(root)} + {_project_autonomy(root)}")
|
|
1466
1859
|
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1467
1860
|
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1468
1861
|
_who = identity._whoami(state)
|
|
@@ -1483,6 +1876,9 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1483
1876
|
_gr_cited, _gr_total = _exit_criteria_cited(root, _active_ms)
|
|
1484
1877
|
_gr_state = "auto-ready ✓" if _goal_auto_ready(root, _active_ms) else "NOT auto-ready"
|
|
1485
1878
|
print(f"goal-ready: {_gr_state} ({_gr_cited}/{_gr_total} exit criteria cite a verifier)")
|
|
1879
|
+
# dag-plan snapshot freshness (persist-dag-plan): per-active-milestone, read-only —
|
|
1880
|
+
# fresh ✓ / stale / none / unreadable vs the LIVE depends_on edges. Advisory; never writes.
|
|
1881
|
+
print(_dag_plan_status_line(root, state, _active_ms))
|
|
1486
1882
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
1487
1883
|
if (root / "PROJECT.md").exists():
|
|
1488
1884
|
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
@@ -1504,12 +1900,16 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1504
1900
|
active_ms = _active_milestone(state)
|
|
1505
1901
|
if milestones:
|
|
1506
1902
|
print("milestones:")
|
|
1507
|
-
|
|
1903
|
+
_sorted_ms = _sorted_by_updated(milestones)
|
|
1904
|
+
_page_ms = _sorted_ms if show_all else _sorted_ms[:_STATUS_PAGE_SIZE]
|
|
1905
|
+
for mslug, m in _page_ms:
|
|
1508
1906
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1509
1907
|
done = sum(1 for t in members if _task_done(t))
|
|
1510
1908
|
mark = "*" if mslug in (state.get("active_milestones") or []) else " "
|
|
1511
1909
|
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
1512
1910
|
f" status={m.get('status', 'active')}")
|
|
1911
|
+
if not show_all and len(_sorted_ms) > _STATUS_PAGE_SIZE:
|
|
1912
|
+
print(f" … {len(_sorted_ms) - _STATUS_PAGE_SIZE} more (see status --all)")
|
|
1513
1913
|
# graduation cue (v22): project-global + read-only. Fires only when every milestone
|
|
1514
1914
|
# is done AND the human's PROJECT.md stage-goal-criteria are all checked — additive
|
|
1515
1915
|
# (a new line solely when ready; the non-ready output is byte-identical to before).
|
|
@@ -1578,6 +1978,19 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1578
1978
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1579
1979
|
if active and active in tasks:
|
|
1580
1980
|
print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
|
|
1981
|
+
# the human-declared risk-CLASS (risk-sensitivity-taxonomy): present-only when a valid
|
|
1982
|
+
# sensitivity is declared; "unset" cue when absent; "?" surfaces a typo to fix at freeze.
|
|
1983
|
+
_sens = _task_sensitivity(_task_header(root, active), valid=_project_sensitivity_values(root))
|
|
1984
|
+
print(f"sensitivity: {('unset' if _sens is None else _sens)}")
|
|
1985
|
+
# step-spawn-hint (advisor-gated-autonomy): one advisory line naming the agent shape a
|
|
1986
|
+
# parallel run would fan out at THIS step. Present-only (None → no line): suppressed under
|
|
1987
|
+
# the `manual` dial and at contract/done. The tier reflects declared `risk: high`.
|
|
1988
|
+
_hint = _spawn_hint_line(
|
|
1989
|
+
{"phase": tasks[active].get("phase"),
|
|
1990
|
+
"risk": "high" if _RISK_HIGH_RE.search(_task_header(root, active)) else None},
|
|
1991
|
+
_project_autonomy(root))
|
|
1992
|
+
if _hint:
|
|
1993
|
+
print(_hint)
|
|
1581
1994
|
# owner/assignee of the active task (ownership-assignment) — present-only, never a
|
|
1582
1995
|
# placeholder; an unassigned active task adds no line (additive-cue convention).
|
|
1583
1996
|
_own = _fmt_ownership(tasks[active])
|
|
@@ -1607,12 +2020,16 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1607
2020
|
print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
|
|
1608
2021
|
return
|
|
1609
2022
|
print("tasks :")
|
|
1610
|
-
|
|
2023
|
+
_sorted_tasks = _sorted_by_updated(tasks)
|
|
2024
|
+
_page_tasks = _sorted_tasks if show_all else _sorted_tasks[:_STATUS_PAGE_SIZE]
|
|
2025
|
+
for slug, t in _page_tasks:
|
|
1611
2026
|
mark = "*" if slug == active else " "
|
|
1612
2027
|
deps = t.get("depends_on") or []
|
|
1613
2028
|
dep_s = f" deps={','.join(deps)}" if deps else ""
|
|
1614
2029
|
ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
|
|
1615
2030
|
print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
|
|
2031
|
+
if not show_all and len(_sorted_tasks) > _STATUS_PAGE_SIZE:
|
|
2032
|
+
print(f" … {len(_sorted_tasks) - _STATUS_PAGE_SIZE} more (see status --all)")
|
|
1616
2033
|
# fold-pressure nudge: surface unfolded competency deltas so emission can't
|
|
1617
2034
|
# silently outrun the human fold (read-only; v11). Silent when none are open.
|
|
1618
2035
|
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
@@ -1737,6 +2154,14 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1737
2154
|
print(f"guide : {gp}")
|
|
1738
2155
|
elif phase in _PHASE_GUIDE_FILES:
|
|
1739
2156
|
print("guide : (phase guides not installed — npx @pilotspace/add init)")
|
|
2157
|
+
# step-spawn-hint (advisor-gated-autonomy): one advisory line naming the agent shape a parallel
|
|
2158
|
+
# run would fan out at THIS step. Present-only: suppressed under `manual` and at contract/done.
|
|
2159
|
+
_hint = _spawn_hint_line(
|
|
2160
|
+
{"phase": phase,
|
|
2161
|
+
"risk": "high" if _RISK_HIGH_RE.search(_task_header(root, slug)) else None},
|
|
2162
|
+
_project_autonomy(root))
|
|
2163
|
+
if _hint:
|
|
2164
|
+
print(_hint)
|
|
1740
2165
|
if phase == "verify":
|
|
1741
2166
|
print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
|
|
1742
2167
|
elif phase == "done":
|
|
@@ -2179,12 +2604,25 @@ def _missing_captures(root: Path) -> list[str]:
|
|
|
2179
2604
|
if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
|
|
2180
2605
|
|
|
2181
2606
|
|
|
2607
|
+
def _federation_source_confined(root: Path, source: str) -> bool:
|
|
2608
|
+
"""True iff a federation manifest `source` resolves INSIDE the sibling-repo allowlist — the
|
|
2609
|
+
workspace dir (root.parent.parent) that holds this project and its sibling checkouts. A legit
|
|
2610
|
+
cross-repo source is one level up + down (`../<repo>/…`); an absolute path or a `../../` escape
|
|
2611
|
+
resolves OUTSIDE and returns False. PURE + TOTAL — never raises (every error, incl. an embedded
|
|
2612
|
+
NUL, → False, fail-closed), so cmd_federate can gate on it BEFORE any read (federation-harden)."""
|
|
2613
|
+
try:
|
|
2614
|
+
allow = root.parent.parent.resolve()
|
|
2615
|
+
return _confined(root.parent / source, allow)
|
|
2616
|
+
except (OSError, ValueError):
|
|
2617
|
+
return False
|
|
2618
|
+
|
|
2619
|
+
|
|
2182
2620
|
def cmd_federate(args: argparse.Namespace) -> None:
|
|
2183
2621
|
"""Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
|
|
2184
2622
|
this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
|
|
2185
2623
|
the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
|
|
2186
|
-
`consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown /
|
|
2187
|
-
invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
|
|
2624
|
+
`consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / escaping /
|
|
2625
|
+
missing / invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
|
|
2188
2626
|
root = find_root()
|
|
2189
2627
|
if root is None:
|
|
2190
2628
|
_die("no_project")
|
|
@@ -2193,6 +2631,13 @@ def cmd_federate(args: argparse.Namespace) -> None:
|
|
|
2193
2631
|
if fid not in fed:
|
|
2194
2632
|
_die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
|
|
2195
2633
|
f"repo's published snapshot source before pulling")
|
|
2634
|
+
# federation-harden: confine the source to the sibling-repo allowlist BEFORE any read — an
|
|
2635
|
+
# absolute path or a `../../` traversal must HARD-STOP and land nothing (never read a path
|
|
2636
|
+
# that escapes the project's sibling repos). Checked after the id lookup, before read_bytes.
|
|
2637
|
+
if not _federation_source_confined(root, fed[fid]["source"]):
|
|
2638
|
+
_die(f"federation_source_escapes: the source '{fed[fid]['source']}' for '{fid}' resolves "
|
|
2639
|
+
f"outside the sibling-repo allowlist (the workspace dir) — refusing to read a path "
|
|
2640
|
+
f"that escapes the project's sibling repos")
|
|
2196
2641
|
source = (root.parent / fed[fid]["source"])
|
|
2197
2642
|
try:
|
|
2198
2643
|
raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
|
|
@@ -2214,6 +2659,72 @@ def cmd_federate(args: argparse.Namespace) -> None:
|
|
|
2214
2659
|
print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
|
|
2215
2660
|
|
|
2216
2661
|
|
|
2662
|
+
def cmd_components(args: argparse.Namespace) -> None:
|
|
2663
|
+
"""Read-only: print + validate the component registry (.add/components.toml).
|
|
2664
|
+
|
|
2665
|
+
Opt-in — with no registry this is a friendly single-component no-op (exit 0). Prints
|
|
2666
|
+
the parsed components/contracts/federation, then the existing RED integrity findings
|
|
2667
|
+
(_component_findings + _contract_findings), then the new schema-lint WARNs
|
|
2668
|
+
(_component_schema_findings); exits 1 IFF a RED finding exists (a typo/WARN never fails).
|
|
2669
|
+
NO-EXEC — `verify` is shown as data, never run. Reads no docs/ chapter."""
|
|
2670
|
+
root = find_root()
|
|
2671
|
+
if root is None:
|
|
2672
|
+
_die("no_project")
|
|
2673
|
+
if not (root / "components.toml").exists():
|
|
2674
|
+
print("single-component project (no components.toml) — nothing to validate")
|
|
2675
|
+
return
|
|
2676
|
+
comps, cons, feds = _components(root), _contracts(root), _federation(root)
|
|
2677
|
+
for name in sorted(comps):
|
|
2678
|
+
c = comps[name]
|
|
2679
|
+
print(f"component {name} root={c['root']} verify={c.get('verify') or '-'} "
|
|
2680
|
+
f"green_bar={c.get('green_bar') or '-'} language={c.get('language') or '-'}")
|
|
2681
|
+
for cid in sorted(cons):
|
|
2682
|
+
c = cons[cid]
|
|
2683
|
+
print(f"contract {cid} producer={c['producer']} consumers={c['consumers']}")
|
|
2684
|
+
for fid in sorted(feds):
|
|
2685
|
+
f = feds[fid]
|
|
2686
|
+
print(f"federation {fid} source={f['source']} pin={f.get('pin') or '-'}")
|
|
2687
|
+
reds = sorted(_component_findings(root) + _contract_findings(root))
|
|
2688
|
+
warns = sorted(_component_schema_findings(root))
|
|
2689
|
+
for code, detail in reds:
|
|
2690
|
+
print(f"ERROR {code}: {detail}")
|
|
2691
|
+
for code, detail in warns:
|
|
2692
|
+
print(f"WARN {code}: {detail}")
|
|
2693
|
+
head = f"components: {len(comps)} · contracts: {len(cons)} · federation: {len(feds)}"
|
|
2694
|
+
if reds or warns:
|
|
2695
|
+
seg = ([f"{len(reds)} error(s)"] if reds else []) + ([f"{len(warns)} warning(s)"] if warns else [])
|
|
2696
|
+
print(f"{head} — {', '.join(seg)}")
|
|
2697
|
+
else:
|
|
2698
|
+
print(f"{head} — valid")
|
|
2699
|
+
if reds:
|
|
2700
|
+
raise SystemExit(1)
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
def cmd_search(args: argparse.Namespace) -> None:
|
|
2704
|
+
"""Read-only keyword/substring search over the milestone/task corpus (active
|
|
2705
|
+
+ archived) — title/goal/rationale (milestone) or title/Feature (task) lines
|
|
2706
|
+
only, never full body, never graph traversal (context-search, search-index).
|
|
2707
|
+
Fresh per-call scan via add_engine.search._search_corpus — no persisted
|
|
2708
|
+
index/cache. Exit 0 always, including zero matches; --json mirrors
|
|
2709
|
+
check/ready's own machine-readable convention."""
|
|
2710
|
+
root = find_root()
|
|
2711
|
+
if root is None:
|
|
2712
|
+
_die("no_project")
|
|
2713
|
+
hits = _search_corpus(root, args.keywords)
|
|
2714
|
+
if getattr(args, "json", False):
|
|
2715
|
+
print(json.dumps([{k: h[k] for k in ("slug", "kind", "status", "snippet")}
|
|
2716
|
+
for h in hits], ensure_ascii=False, indent=2))
|
|
2717
|
+
return
|
|
2718
|
+
query = " ".join(args.keywords)
|
|
2719
|
+
if not hits:
|
|
2720
|
+
print(f"no matches for: {query}")
|
|
2721
|
+
return
|
|
2722
|
+
print(f"{len(hits)} match(es) for: {query}")
|
|
2723
|
+
for h in hits:
|
|
2724
|
+
print(f"{h['slug']} [{h['kind']}, {h['status']}] ({h['count']} match(es))")
|
|
2725
|
+
print(f" {h['snippet']}")
|
|
2726
|
+
|
|
2727
|
+
|
|
2217
2728
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
2218
2729
|
"""Read-only integrity check of the .add project. Exit 1 if anything fails."""
|
|
2219
2730
|
as_json = getattr(args, "json", False)
|
|
@@ -2257,6 +2768,46 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2257
2768
|
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
2258
2769
|
warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add "
|
|
2259
2770
|
"intake flow (or attach with --milestone)"))
|
|
2771
|
+
# backlink-drift (task-milestone-backlink): the TASK.md `milestone:` header mirrors state.
|
|
2772
|
+
# WARN (never red, warn-never-block) when a PRESENT line disagrees; an ABSENT line is a
|
|
2773
|
+
# grandfathered task — silent, never retro-red. Degrade-safe: an unreadable file skips here.
|
|
2774
|
+
try:
|
|
2775
|
+
_task_text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
2776
|
+
except OSError:
|
|
2777
|
+
_task_text = None
|
|
2778
|
+
_bl = _read_milestone_line(_task_text) if _task_text is not None else None
|
|
2779
|
+
if _bl is not None and _bl != _milestone_backlink_value(ms):
|
|
2780
|
+
warnings.append((f"task '{slug}'", f"milestone backlink '{_bl}' disagrees with state "
|
|
2781
|
+
f"'{_milestone_backlink_value(ms)}' — re-run `add.py set-milestone "
|
|
2782
|
+
f"{slug} {ms or 'none'}` to re-sync"))
|
|
2783
|
+
# §0 drift anchor (ground-anchor-sha): a §0 that cites bare line numbers (l.NNN) with no
|
|
2784
|
+
# `Ground SHA:` has undetectable drift. WARN (never red, warn-never-block); a §0 with a SHA,
|
|
2785
|
+
# with no line refs, or an unreadable file is silent. Reuses the read above (one read).
|
|
2786
|
+
if _task_text is not None and _ground_cites_line_ref(_task_text) and \
|
|
2787
|
+
_read_ground_sha(_task_text) is None:
|
|
2788
|
+
warnings.append((f"task '{slug}'", "§0 cites line numbers (l.NNN) with no `Ground SHA:` — "
|
|
2789
|
+
"record `git rev-parse --short HEAD` so drift is detectable"))
|
|
2790
|
+
# dangling lineage (delta-task-backlink): a `[SPEC · seeded] … [→ ptr]` whose pointer task
|
|
2791
|
+
# is neither live nor archived. WARN (never red); reuses the read above. `_archived_task_slugs`
|
|
2792
|
+
# is the same resolver `cmd_ready` trusts (archived ⇒ was PASS-done), so a healthy
|
|
2793
|
+
# completed-then-archived seed stays silent.
|
|
2794
|
+
if _task_text is not None:
|
|
2795
|
+
_arch = _archived_task_slugs(state)
|
|
2796
|
+
for _ptr in _seeded_delta_pointers(_task_text):
|
|
2797
|
+
if _ptr not in tasks and _ptr not in _arch:
|
|
2798
|
+
warnings.append((f"task '{slug}'", f"seeded SPEC delta points at '{_ptr}' which no "
|
|
2799
|
+
"longer exists (dangling lineage) — re-point or drop the delta"))
|
|
2800
|
+
# rule-id-coverage: a §1 Must/Reject ID with no §2 scenario tag and no §4 `covers:`
|
|
2801
|
+
# reference is a coverage gap. WARN only, runs in ANY phase (a gap in already-shipped
|
|
2802
|
+
# work must still surface — the whole point of the check); opt-in per task via
|
|
2803
|
+
# `_rule_coverage_gaps`'s own tag-presence gate, so a task that never adopted the M#/
|
|
2804
|
+
# R:code convention is silently grandfathered — never retro-flagged.
|
|
2805
|
+
if _task_text is not None:
|
|
2806
|
+
_spans = _phase_spans(_task_text)
|
|
2807
|
+
for _rid, _kind in _rule_coverage_gaps(_spans.get(1, ""), _spans.get(2, ""), _spans.get(4, "")):
|
|
2808
|
+
warnings.append((f"task '{slug}'", f"rule '{_rid}' ({_kind}) has no §2 scenario tag "
|
|
2809
|
+
"and no §4 test covering it (coverage gap) — add a scenario tag "
|
|
2810
|
+
"or a covers: line"))
|
|
2260
2811
|
# autonomy level (task explicit-autonomy-dial): a REAL out-of-set token is a hard
|
|
2261
2812
|
# unknown_autonomy_level; a LIVE task (phase before done/observe) with no `autonomy:`
|
|
2262
2813
|
# line is implicit_autonomy — a WARN, never red. Done/observe predecessors are SKIPPED
|
|
@@ -2290,6 +2841,28 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2290
2841
|
warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
|
|
2291
2842
|
f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
|
|
2292
2843
|
"after reviewing the producer's new frozen shape"))
|
|
2844
|
+
# cross-component-recency: a consumer whose landed snapshot's LIVE producer drifted/unfroze
|
|
2845
|
+
# since the snapshot — surfaced EARLY (never red) before the consumer re-enters §3, the
|
|
2846
|
+
# check twin of the producer_contract_stale advance HARD-STOP. Degrade-safe.
|
|
2847
|
+
_ccons = _task_consumes(root, slug)
|
|
2848
|
+
if _ccons and _ccons in _contracts(root):
|
|
2849
|
+
_csnap = _contract_snapshot(root, _ccons)
|
|
2850
|
+
if _csnap.exists():
|
|
2851
|
+
try:
|
|
2852
|
+
_chash = json.loads(_csnap.read_text(encoding="utf-8")).get("hash")
|
|
2853
|
+
except (OSError, ValueError, AttributeError):
|
|
2854
|
+
_chash = None
|
|
2855
|
+
if _chash is None:
|
|
2856
|
+
# cross-component-recency R1: a present-but-hash-less snapshot degrades the
|
|
2857
|
+
# recency check to existence-only (frozen behavior) — SURFACE the blind spot
|
|
2858
|
+
# (never red) so a hand-tampered/hash-stripped snapshot is not silently trusted.
|
|
2859
|
+
warnings.append((f"task '{slug}'", f"contract_snapshot_hashless — the landed "
|
|
2860
|
+
f"snapshot for '{_ccons}' carries no hash; recency cannot be "
|
|
2861
|
+
"verified (re-publish the producer contract / re-cross contract→tests)"))
|
|
2862
|
+
elif _producer_snapshot_stale(root, _ccons, _chash):
|
|
2863
|
+
warnings.append((f"task '{slug}'", f"contract_producer_stale — the live producer "
|
|
2864
|
+
f"of '{_ccons}' changed or re-opened its §3 since the landed "
|
|
2865
|
+
"snapshot; re-cross the producer contract→tests, then re-enter"))
|
|
2293
2866
|
for dep in t.get("depends_on") or []:
|
|
2294
2867
|
checks.append((dep in tasks or dep in archived_slugs,
|
|
2295
2868
|
f"task '{slug}' dep '{dep}' resolves", "unknown task"))
|
|
@@ -2337,6 +2910,27 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2337
2910
|
"(scope_violation pending) — the verify gate "
|
|
2338
2911
|
"will refuse it"))
|
|
2339
2912
|
|
|
2913
|
+
# persona-setup: validate each persona living doc (.add/personas/*.md) presence-based
|
|
2914
|
+
# (measure-not-block) — a missing required key/section is a WARN naming the slug, never a
|
|
2915
|
+
# hard failure; a conformant persona is an INFO affirmation. NO-EXEC: pure read + predicate.
|
|
2916
|
+
personas_dir = root / "personas"
|
|
2917
|
+
if personas_dir.is_dir():
|
|
2918
|
+
for pf in sorted(personas_dir.glob("*.md")):
|
|
2919
|
+
slug = pf.stem
|
|
2920
|
+
if not _persona_slug_valid(slug):
|
|
2921
|
+
warnings.append((f"persona '{slug}'",
|
|
2922
|
+
"persona_slug_invalid — rename to alphanumeric with - or _ only"))
|
|
2923
|
+
continue
|
|
2924
|
+
try:
|
|
2925
|
+
missing = _persona_missing(pf.read_text(encoding="utf-8"))
|
|
2926
|
+
except OSError:
|
|
2927
|
+
missing = ["(unreadable)"]
|
|
2928
|
+
if missing:
|
|
2929
|
+
warnings.append((f"persona '{slug}'",
|
|
2930
|
+
"persona_schema_incomplete: missing " + ", ".join(missing)))
|
|
2931
|
+
else:
|
|
2932
|
+
infos.append((f"persona '{slug}'", "schema-conformant"))
|
|
2933
|
+
|
|
2340
2934
|
# drift: a done milestone must have no unfinished tasks
|
|
2341
2935
|
for mslug, m in milestones.items():
|
|
2342
2936
|
if m.get("status") == "done":
|
|
@@ -2375,6 +2969,17 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2375
2969
|
f"task '{_at}' froze its contract without grounding — fill the "
|
|
2376
2970
|
"§0 GROUND anchors the contract cites (add.py guide)"))
|
|
2377
2971
|
|
|
2972
|
+
# sensitivity-glossary: nudge a project to declare its DOMAIN sensitivity classes. WARN, NEVER
|
|
2973
|
+
# red (measure-not-block, mirrors goal_not_auto_ready) — the base four always apply; this only
|
|
2974
|
+
# invites the project's own risk-class vocabulary into GLOSSARY.md (the AI maintains it per the
|
|
2975
|
+
# skill guide). Fires IFF the "## Sensitivity classes" section declares no domain class.
|
|
2976
|
+
if not _project_sensitivity_domain(root):
|
|
2977
|
+
warnings.append(("sensitivity_classes_unset",
|
|
2978
|
+
"no domain sensitivity classes declared — add the project's risk-class "
|
|
2979
|
+
"vocabulary to GLOSSARY.md's '## Sensitivity classes' section (the base "
|
|
2980
|
+
"security|data|architecture|mechanical always apply; the AI keeps the "
|
|
2981
|
+
"domain classes current — see the sensitivity skill guide)"))
|
|
2982
|
+
|
|
2378
2983
|
# wave-ledger fork-base (engine-merge-base-enforcement): the engine EXECUTES the
|
|
2379
2984
|
# streams.md rule — every roster echo must match `base:`. A FILLED mismatch is red at
|
|
2380
2985
|
# ANY status; a pending row is red at `status: merging` (merge-time strictness) but only
|
|
@@ -2417,11 +3022,23 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2417
3022
|
# is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
|
|
2418
3023
|
# HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
|
|
2419
3024
|
for _fid, _fspec in _federation(root).items():
|
|
2420
|
-
if not (root
|
|
3025
|
+
if not _federation_source_confined(root, _fspec["source"]):
|
|
3026
|
+
# federation-harden: an out-of-allowlist source surfaced EARLY as a never-red WARN
|
|
3027
|
+
# (escape takes precedence over unreadable — a real /etc/passwd passes is_file()).
|
|
3028
|
+
warnings.append((f"federation '{_fid}'",
|
|
3029
|
+
f"federation_source_escapes — '{_fspec['source']}' resolves outside the "
|
|
3030
|
+
f"sibling-repo allowlist; `federate pull {_fid}` will HARD-STOP"))
|
|
3031
|
+
elif not (root.parent / _fspec["source"]).is_file():
|
|
2421
3032
|
warnings.append((f"federation '{_fid}'",
|
|
2422
3033
|
f"federation_source_unreadable — the producer snapshot at "
|
|
2423
3034
|
f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
|
|
2424
3035
|
"will hard-stop until the producer repo publishes it"))
|
|
3036
|
+
# components-validator: the schema-lint (typo'd/unknown key · wrong-type value · unknown
|
|
3037
|
+
# table) is a never-red WARN — measure-not-block, forward-compat-safe. Rides `warnings`,
|
|
3038
|
+
# NEVER `checks`/`failed`. Silent when no components.toml. `add.py components` is the
|
|
3039
|
+
# richer surface; this catches the same typos EARLY in CI without failing the build.
|
|
3040
|
+
for _scode, _sdetail in _component_schema_findings(root):
|
|
3041
|
+
warnings.append((_scode, _sdetail))
|
|
2425
3042
|
|
|
2426
3043
|
# UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
|
|
2427
3044
|
# composes the token + catalog/tree validators + the cross-file prop-token resolution.
|
|
@@ -3108,6 +3725,88 @@ def cmd_waves(args: argparse.Namespace) -> None:
|
|
|
3108
3725
|
print("\n".join(_wave_block_lines(state, t, s)))
|
|
3109
3726
|
|
|
3110
3727
|
|
|
3728
|
+
# --- persisted DAG-plan snapshot (persist-dag-plan) --------------------------------
|
|
3729
|
+
# `waves` recomputes the schedule live and is the authority; `dag-plan` MATERIALIZES that
|
|
3730
|
+
# computed schedule into a committed, auditable per-milestone snapshot + a freshness check
|
|
3731
|
+
# against the live edges. The snapshot is NEVER the authority — only a checkable projection.
|
|
3732
|
+
def _dag_plan_path(root: Path, mslug: str) -> Path:
|
|
3733
|
+
return root / "milestones" / mslug / "dag-plan.json"
|
|
3734
|
+
|
|
3735
|
+
|
|
3736
|
+
def _edges_fingerprint(state: dict, mslug: str) -> str:
|
|
3737
|
+
"""md5 over the EDGE STRUCTURE: every member's (done OR open) sorted depends_on. PURE.
|
|
3738
|
+
Invariant under a task COMPLETING (phase/gate change, not edges) — so completion is NOT
|
|
3739
|
+
drift — and changes only when a dep is added/removed/redirected or a member added/removed."""
|
|
3740
|
+
tasks = state.get("tasks") or {}
|
|
3741
|
+
edges = {s: sorted(t.get("depends_on") or [])
|
|
3742
|
+
for s, t in tasks.items() if t.get("milestone") == mslug}
|
|
3743
|
+
return _md5_text(json.dumps(edges, sort_keys=True))
|
|
3744
|
+
|
|
3745
|
+
|
|
3746
|
+
def _dag_plan_freshness(root: Path, state: dict, mslug: str) -> tuple[str, dict | None]:
|
|
3747
|
+
"""Read the snapshot and compare its stored fingerprint to the live one. Fail-safe:
|
|
3748
|
+
absent -> ("none", None) · unreadable/garbled -> ("unreadable", None) · match -> "fresh"
|
|
3749
|
+
· mismatch -> "stale". Never raises on a missing/corrupt file (no traceback to the user)."""
|
|
3750
|
+
sp = _dag_plan_path(root, mslug)
|
|
3751
|
+
if not sp.exists():
|
|
3752
|
+
return ("none", None)
|
|
3753
|
+
try:
|
|
3754
|
+
snap = json.loads(sp.read_text(encoding="utf-8"))
|
|
3755
|
+
except (OSError, ValueError):
|
|
3756
|
+
return ("unreadable", None)
|
|
3757
|
+
if not isinstance(snap, dict) or "edges_fingerprint" not in snap:
|
|
3758
|
+
return ("unreadable", None)
|
|
3759
|
+
live = _edges_fingerprint(state, mslug)
|
|
3760
|
+
return (("fresh" if snap.get("edges_fingerprint") == live else "stale"), snap)
|
|
3761
|
+
|
|
3762
|
+
|
|
3763
|
+
def _dag_plan_status_line(root: Path, state: dict, mslug: str) -> str:
|
|
3764
|
+
"""The single `dag-plan:` line cmd_status prints for the active milestone."""
|
|
3765
|
+
fresh, snap = _dag_plan_freshness(root, state, mslug)
|
|
3766
|
+
if fresh == "fresh":
|
|
3767
|
+
return "dag-plan: fresh ✓"
|
|
3768
|
+
if fresh == "stale":
|
|
3769
|
+
return f"dag-plan: stale (edges changed since {(snap or {}).get('generated', '?')})"
|
|
3770
|
+
if fresh == "unreadable":
|
|
3771
|
+
return "dag-plan: unreadable — run add.py dag-plan"
|
|
3772
|
+
return "dag-plan: none — run add.py dag-plan"
|
|
3773
|
+
|
|
3774
|
+
|
|
3775
|
+
def cmd_dag_plan(args: argparse.Namespace) -> None:
|
|
3776
|
+
"""RECORD-ONLY: materialize the active (or --milestone) milestone's computed DAG schedule
|
|
3777
|
+
into a committed snapshot + an edge fingerprint. Reuses cmd_waves' rejects; writes NOTHING
|
|
3778
|
+
on any reject. Idempotent: an unchanged fingerprint leaves the file byte-identical."""
|
|
3779
|
+
root = _require_root()
|
|
3780
|
+
state = load_state(root)
|
|
3781
|
+
mslug = getattr(args, "milestone", None) or _active_milestone(state)
|
|
3782
|
+
if not mslug:
|
|
3783
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
3784
|
+
if mslug not in (state.get("milestones") or {}):
|
|
3785
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
|
|
3786
|
+
sched = _wave_schedule(state, mslug)
|
|
3787
|
+
if "cycle" in sched:
|
|
3788
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
3789
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
3790
|
+
live_fp = _edges_fingerprint(state, mslug)
|
|
3791
|
+
sp = _dag_plan_path(root, mslug)
|
|
3792
|
+
# idempotent: an unchanged fingerprint leaves the snapshot (and its `generated` date) untouched
|
|
3793
|
+
if sp.exists():
|
|
3794
|
+
try:
|
|
3795
|
+
cur = json.loads(sp.read_text(encoding="utf-8"))
|
|
3796
|
+
except (OSError, ValueError):
|
|
3797
|
+
cur = None
|
|
3798
|
+
if isinstance(cur, dict) and cur.get("edges_fingerprint") == live_fp:
|
|
3799
|
+
print(f"dag-plan: {mslug} already fresh ✓ (generated {cur.get('generated', '?')})")
|
|
3800
|
+
return
|
|
3801
|
+
snap = {"milestone": mslug, "generated": date.today().isoformat(),
|
|
3802
|
+
"edges_fingerprint": live_fp, "schedule": sched}
|
|
3803
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
3804
|
+
_atomic_write(sp, json.dumps(snap, indent=2, sort_keys=True) + "\n")
|
|
3805
|
+
nwaves = len(sched.get("waves") or [])
|
|
3806
|
+
ntasks = sum(len(w) for w in (sched.get("waves") or []))
|
|
3807
|
+
print(f"dag-plan: wrote {mslug} — {nwaves} wave(s), {ntasks} task(s) (fresh ✓)")
|
|
3808
|
+
|
|
3809
|
+
|
|
3111
3810
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
3112
3811
|
root = _require_root()
|
|
3113
3812
|
state = load_state(root)
|
|
@@ -3324,6 +4023,17 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
3324
4023
|
state["tasks"][task]["milestone"] = new
|
|
3325
4024
|
state["tasks"][task]["updated"] = _now()
|
|
3326
4025
|
save_state(root, state)
|
|
4026
|
+
# keep the TASK.md `milestone:` backlink in lockstep with state (task-milestone-backlink):
|
|
4027
|
+
# rewrite the header line (insert it if a grandfathered file lacks it). Degrade-safe — a
|
|
4028
|
+
# missing/unreadable TASK.md never blocks the move (state is already the source of truth).
|
|
4029
|
+
task_md = root / "tasks" / task / "TASK.md"
|
|
4030
|
+
try:
|
|
4031
|
+
_txt = task_md.read_text(encoding="utf-8")
|
|
4032
|
+
_new_txt = _set_milestone_line(_txt, _milestone_backlink_value(new))
|
|
4033
|
+
if _new_txt != _txt:
|
|
4034
|
+
_atomic_write(task_md, _new_txt)
|
|
4035
|
+
except OSError:
|
|
4036
|
+
pass
|
|
3327
4037
|
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
3328
4038
|
print(_next_footer(root, state))
|
|
3329
4039
|
|
|
@@ -3498,7 +4208,10 @@ def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
|
3498
4208
|
except (ValueError, OSError):
|
|
3499
4209
|
rel = str(f)
|
|
3500
4210
|
tests[rel] = h
|
|
3501
|
-
|
|
4211
|
+
# strip-scaffold-at-done: fingerprint the contract CONTENT (comment-normalized, see
|
|
4212
|
+
# _contract_fingerprint) so the at-done comment strip is invisible to the tamper guard; a real
|
|
4213
|
+
# fenced-shape edit still changes it. Mirrored byte-for-byte in _tripwire_divergence.
|
|
4214
|
+
return {"contract_md5": _contract_fingerprint(raw3), "tests": tests}
|
|
3502
4215
|
|
|
3503
4216
|
|
|
3504
4217
|
def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
@@ -3506,7 +4219,9 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
|
3506
4219
|
path directly (never re-globs), so a weakened, deleted, or unreadable test file
|
|
3507
4220
|
and an edited frozen §3 all surface. Fail-closed: an unreadable file -> diverged."""
|
|
3508
4221
|
diffs: list[str] = []
|
|
3509
|
-
|
|
4222
|
+
# compare the contract CONTENT fingerprint (strip-scaffold-at-done) — same normalization as the
|
|
4223
|
+
# snapshot, so the at-done comment strip never reads as tampering; a real shape edit still does.
|
|
4224
|
+
if _contract_fingerprint(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
|
|
3510
4225
|
diffs.append("contract_tampered")
|
|
3511
4226
|
rootp = root.parent.resolve()
|
|
3512
4227
|
for rel, snap in (tw.get("tests") or {}).items():
|
|
@@ -3525,7 +4240,10 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
|
3525
4240
|
# likewise NOT a source touch — counting one produced repeated false `scope_violation`s in
|
|
3526
4241
|
# consuming projects (`.next/`, `coverage/`, `tsconfig.tsbuildinfo`, whose `incremental`
|
|
3527
4242
|
# rewrite even races a clean re-snapshot), so they are pruned here too.
|
|
3528
|
-
|
|
4243
|
+
# `.claude` is an agent-tool internal dir (config/skills/worktrees) like `.serena` — never a
|
|
4244
|
+
# task's declared source; without it, the walk descends into `.claude/worktrees/<wt>/` (linked
|
|
4245
|
+
# git worktrees: full branch checkouts) and their churn produces false `scope_violation`s.
|
|
4246
|
+
_SCOPE_EXCLUDE_DIRS = (".git", ".add", ".claude", "__pycache__", "node_modules", ".serena",
|
|
3529
4247
|
".next", "coverage", "test-results")
|
|
3530
4248
|
_SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildinfo by suffix
|
|
3531
4249
|
_SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
|
|
@@ -3572,6 +4290,16 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
|
|
|
3572
4290
|
return (_components(root).get(comp) or {}).get("green_bar") or None
|
|
3573
4291
|
|
|
3574
4292
|
|
|
4293
|
+
def _task_verify(root: Path, slug: str) -> str | None:
|
|
4294
|
+
"""The `verify` COMMAND of the task's bound component (component-registry-fill) — the literal
|
|
4295
|
+
suite the operator runs at the gate. Twin of _task_green_bar: unbound / "?" / no verify
|
|
4296
|
+
declared all yield None. PURE. SURFACED as data only — the engine NEVER executes it (NO-EXEC)."""
|
|
4297
|
+
comp = _task_component(root, slug)
|
|
4298
|
+
if not comp or comp == "?":
|
|
4299
|
+
return None
|
|
4300
|
+
return (_components(root).get(comp) or {}).get("verify") or None
|
|
4301
|
+
|
|
4302
|
+
|
|
3575
4303
|
def _component_findings(root: Path) -> list[tuple[str, str]]:
|
|
3576
4304
|
"""The loud gate surface for the registry — the codes a degrade-safe read passes
|
|
3577
4305
|
over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
|
|
@@ -3624,6 +4352,64 @@ def _task_consumes(root: Path, slug: str) -> str | None:
|
|
|
3624
4352
|
return m.group(1).strip() if m else None
|
|
3625
4353
|
|
|
3626
4354
|
|
|
4355
|
+
def _producer_snapshot_stale(root: Path, cid: str, snap_hash: str | None) -> bool:
|
|
4356
|
+
"""True IFF a LIVE producer task backs `cid` (some task under root/tasks/ whose header carries
|
|
4357
|
+
`produces: cid`) AND that producer's current §3 is NOT frozen, OR its frozen body-hash differs
|
|
4358
|
+
from `snap_hash` (the landed snapshot's hash) — i.e. the producer re-opened/changed its contract
|
|
4359
|
+
since the snapshot was written (cross-component-recency). No live producer task (archived in a
|
|
4360
|
+
prior milestone, or a federation/external snapshot) -> False = existence-only, recency is the
|
|
4361
|
+
producer repo's job via the federation version pin. snap_hash None / unreadable §3 -> False (not
|
|
4362
|
+
confirmable here). PURE + TOTAL — never raises (a read error is a non-confirmation, not a block)."""
|
|
4363
|
+
if snap_hash is None:
|
|
4364
|
+
return False
|
|
4365
|
+
tasks_dir = root / "tasks"
|
|
4366
|
+
if not tasks_dir.is_dir():
|
|
4367
|
+
return False
|
|
4368
|
+
try:
|
|
4369
|
+
producers = sorted(td.name for td in tasks_dir.iterdir() if td.is_dir())
|
|
4370
|
+
except OSError:
|
|
4371
|
+
return False
|
|
4372
|
+
for pslug in producers:
|
|
4373
|
+
if _task_produces(root, pslug) != cid:
|
|
4374
|
+
continue
|
|
4375
|
+
raw3 = _raw_phase_bodies(root, pslug).get(3, "")
|
|
4376
|
+
if "FROZEN @" not in raw3:
|
|
4377
|
+
return True # live producer exists but its §3 is not frozen
|
|
4378
|
+
return _contract_body_hash(raw3) != snap_hash # frozen but drifted from the landed snapshot
|
|
4379
|
+
return False # no live producer backs cid -> existence-only
|
|
4380
|
+
|
|
4381
|
+
|
|
4382
|
+
def _consumer_contract_hold(root: Path, state: dict, slug: str) -> None:
|
|
4383
|
+
"""The cross-component consumer HOLD at the §3 boundary, shared by cmd_advance (nxt=="contract")
|
|
4384
|
+
and cmd_phase (phase=="contract") so the admin override is not a backdoor (mirrors the
|
|
4385
|
+
phase build -> _build_entry precedent). A `consumes: <cid>` task targeting a DECLARED contract:
|
|
4386
|
+
a MISSING snapshot HARD-STOPs `producer_contract_unfrozen` (producer hasn't frozen); a PRESENT
|
|
4387
|
+
snapshot whose live producer has drifted/unfrozen HARD-STOPs `producer_contract_stale`
|
|
4388
|
+
(cross-component-recency — a stale leftover must not admit a consumer). No consumes / undeclared
|
|
4389
|
+
id -> return (byte-identical; a typo'd id is a cmd_check finding). `state` is accepted for call-site
|
|
4390
|
+
symmetry with _build_entry (unused here — the hold reads the task + contract files). Validate-
|
|
4391
|
+
then-write: every _die precedes the caller's phase bump, so a refused task does not move."""
|
|
4392
|
+
cid = _task_consumes(root, slug)
|
|
4393
|
+
if not cid:
|
|
4394
|
+
return
|
|
4395
|
+
cmap = _contracts(root)
|
|
4396
|
+
if cid not in cmap:
|
|
4397
|
+
return
|
|
4398
|
+
snap = _contract_snapshot(root, cid)
|
|
4399
|
+
if not snap.exists():
|
|
4400
|
+
_die(f"producer_contract_unfrozen: the producer '{cmap[cid].get('producer', '?')}' of "
|
|
4401
|
+
f"contract '{cid}' must freeze its contract before you write §3 — wait for "
|
|
4402
|
+
f".add/contracts/{cid}.json")
|
|
4403
|
+
try:
|
|
4404
|
+
snap_hash = json.loads(snap.read_text(encoding="utf-8")).get("hash")
|
|
4405
|
+
except (OSError, ValueError, AttributeError):
|
|
4406
|
+
snap_hash = None
|
|
4407
|
+
if _producer_snapshot_stale(root, cid, snap_hash):
|
|
4408
|
+
_die(f"producer_contract_stale: the live producer of contract '{cid}' changed or re-opened "
|
|
4409
|
+
f"its §3 since the landed .add/contracts/{cid}.json — re-cross the producer "
|
|
4410
|
+
"contract->tests to refresh the snapshot, then re-enter (never build against a stale shape)")
|
|
4411
|
+
|
|
4412
|
+
|
|
3627
4413
|
def _contract_body_hash(raw3: str) -> str:
|
|
3628
4414
|
"""md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
|
|
3629
4415
|
version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
|
|
@@ -3644,6 +4430,67 @@ def _contract_findings(root: Path) -> list[tuple[str, str]]:
|
|
|
3644
4430
|
return findings
|
|
3645
4431
|
|
|
3646
4432
|
|
|
4433
|
+
# ── components.toml schema-lint (components-validator) ─────────────────────────────────
|
|
4434
|
+
# The SOFT typo surface: the keys/tables a DEGRADE-SAFE read silently drops. All three
|
|
4435
|
+
# codes are WARN-severity (measure-not-block) — forward-compat-safe (an older engine
|
|
4436
|
+
# reading a newer file flags, never fails). Surfaced at `check` (as warnings) AND by
|
|
4437
|
+
# `add.py components`. PURE · DEGRADE-SAFE · NO-EXEC: reads only .add/components.toml,
|
|
4438
|
+
# never raises, never executes `verify`. A parse break is _component_findings' RED job.
|
|
4439
|
+
_SCHEMA_KNOWN_KEYS: dict[str, tuple[str, ...]] = {
|
|
4440
|
+
"component": ("root", "verify", "green_bar", "language"),
|
|
4441
|
+
"contract": ("producer", "consumers"),
|
|
4442
|
+
"federation": ("source", "pin"),
|
|
4443
|
+
}
|
|
4444
|
+
_SCHEMA_KEY_TYPES: dict[str, dict[str, type]] = {
|
|
4445
|
+
# `root` is intentionally absent — a missing/non-str root is already RED `components_malformed`
|
|
4446
|
+
"component": {"verify": str, "green_bar": str, "language": str},
|
|
4447
|
+
"contract": {"producer": str, "consumers": list},
|
|
4448
|
+
"federation": {"source": str, "pin": str},
|
|
4449
|
+
}
|
|
4450
|
+
_SCHEMA_TYPENAME = {str: "a string", list: "a list"}
|
|
4451
|
+
|
|
4452
|
+
|
|
4453
|
+
def _component_schema_findings(root: Path) -> list[tuple[str, str]]:
|
|
4454
|
+
"""Schema-lint the components.toml registry for typos a degrade-safe read drops:
|
|
4455
|
+
an unknown/misspelled key, a wrong-type value on a known key, or an unrecognized
|
|
4456
|
+
top-level table. Returns [(code, detail)] (deterministic file order); [] when the
|
|
4457
|
+
file is absent / opted-out / unparseable (no double-report with the RED surface).
|
|
4458
|
+
All codes WARN: component_unknown_key · component_type_mismatch · component_unknown_table."""
|
|
4459
|
+
if tomllib is None:
|
|
4460
|
+
return []
|
|
4461
|
+
try:
|
|
4462
|
+
data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
|
|
4463
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
4464
|
+
return [] # a parse break is _component_findings' RED job
|
|
4465
|
+
if not isinstance(data, dict):
|
|
4466
|
+
return []
|
|
4467
|
+
out: list[tuple[str, str]] = []
|
|
4468
|
+
for top in data: # 1. unrecognized top-level tables/keys
|
|
4469
|
+
if top not in _SCHEMA_KNOWN_KEYS:
|
|
4470
|
+
out.append(("component_unknown_table",
|
|
4471
|
+
f"top-level [{top}] is not a known table "
|
|
4472
|
+
f"(expected one of: {', '.join(_SCHEMA_KNOWN_KEYS)})"))
|
|
4473
|
+
for table, known in _SCHEMA_KNOWN_KEYS.items(): # 2/3. per-entry keys + value types
|
|
4474
|
+
entries = data.get(table)
|
|
4475
|
+
if not isinstance(entries, dict):
|
|
4476
|
+
continue
|
|
4477
|
+
types = _SCHEMA_KEY_TYPES[table]
|
|
4478
|
+
for name, spec in entries.items():
|
|
4479
|
+
if not isinstance(spec, dict):
|
|
4480
|
+
continue # a non-table entry is the RED surface's job
|
|
4481
|
+
for key, val in spec.items():
|
|
4482
|
+
if key not in known:
|
|
4483
|
+
out.append(("component_unknown_key",
|
|
4484
|
+
f"[{table}.{name}] has unknown key {key!r} "
|
|
4485
|
+
f"(known: {', '.join(known)})"))
|
|
4486
|
+
elif key in types and not isinstance(val, types[key]):
|
|
4487
|
+
out.append(("component_type_mismatch",
|
|
4488
|
+
f"[{table}.{name}].{key} should be "
|
|
4489
|
+
f"{_SCHEMA_TYPENAME.get(types[key], types[key].__name__)}, "
|
|
4490
|
+
f"got {type(val).__name__}"))
|
|
4491
|
+
return out
|
|
4492
|
+
|
|
4493
|
+
|
|
3647
4494
|
def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
3648
4495
|
"""Resolve the §5 'Scope (may touch):' declaration to project-root-relative
|
|
3649
4496
|
strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
|
|
@@ -4273,8 +5120,15 @@ def _flag_well_formed(raw3: str) -> bool:
|
|
|
4273
5120
|
refused unless it takes the honest escape 'none material — biggest risk: X'.
|
|
4274
5121
|
why/cost stay a human-read convention, never machine keywords (evidence: the
|
|
4275
5122
|
lived flags use em-dash/prose, never literal because/if-wrong). HTML comments
|
|
4276
|
-
(template hints) never count.
|
|
4277
|
-
|
|
5123
|
+
(template hints) never count. Fence-aware (mirrors _strip_live_scaffold): a
|
|
5124
|
+
frozen §3 may legitimately quote a bare `<!--` inside its own fenced code
|
|
5125
|
+
block (documenting an HTML-comment invariant) — that must never merge with
|
|
5126
|
+
an unrelated `-->` found later in the raw text. PURE — fail-closed on a
|
|
5127
|
+
missing label."""
|
|
5128
|
+
segs = re.split(r"(```.*?```)", raw3, flags=re.DOTALL)
|
|
5129
|
+
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
5130
|
+
segs[i] = re.sub(r"<!--.*?-->", "", segs[i], flags=re.S)
|
|
5131
|
+
body = "".join(segs)
|
|
4278
5132
|
m = _FLAG_LABEL_RE.search(body)
|
|
4279
5133
|
if not m:
|
|
4280
5134
|
return False
|
|
@@ -4700,7 +5554,12 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
4700
5554
|
delta_text, evidence = em.group(1).strip(), em.group(2).strip()
|
|
4701
5555
|
else:
|
|
4702
5556
|
delta_text, evidence = tail, ""
|
|
4703
|
-
|
|
5557
|
+
# OPTIONAL persona target + section hint (persona-self-improve) — None for a plain lesson.
|
|
5558
|
+
pm = _PERSONA_TAG_RE.search(unit[0])
|
|
5559
|
+
persona = pm.group(1).strip() if pm else None
|
|
5560
|
+
hint = pm.group(2).strip() if pm else None
|
|
5561
|
+
by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence,
|
|
5562
|
+
"persona": persona, "hint": hint})
|
|
4704
5563
|
return by_comp
|
|
4705
5564
|
|
|
4706
5565
|
|
|
@@ -4826,7 +5685,11 @@ def _select_spec_delta(text: str, match: str | None = None,
|
|
|
4826
5685
|
# literal, both exempt via MACHINE_CONSTANTS.
|
|
4827
5686
|
_FOLD_VERB = "fold" # the subcommand / decision-record verb
|
|
4828
5687
|
_FOLDED = "folded" # the resolved status value
|
|
4829
|
-
|
|
5688
|
+
# group(2) carries any OPTIONAL persona annotation (`· persona:<slug> · <hint>`) so flipping
|
|
5689
|
+
# open->folded preserves it byte-for-byte (persona-self-improve).
|
|
5690
|
+
_COMP_OPEN_TOKEN_RE = re.compile(
|
|
5691
|
+
r"(\[\s*(?:DDD|SDD|UDD|TDD|ADD)\s*·\s*)open"
|
|
5692
|
+
r"(\s*(?:·\s*persona:[^\s·\]]+\s*·\s*[^·\]]+?\s*)?\])")
|
|
4830
5693
|
|
|
4831
5694
|
# competency -> (foundation file, section-heading PREFIX) — fold.md's routing table. DDD/SDD/UDD
|
|
4832
5695
|
# land in PROJECT.md sections; TDD/ADD in CONVENTIONS.md (they ARE the engine). Total over the five.
|
|
@@ -4840,6 +5703,13 @@ _FOLD_ROUTES = {
|
|
|
4840
5703
|
_KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail section (every session adds one row)
|
|
4841
5704
|
_TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
|
|
4842
5705
|
|
|
5706
|
+
# persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
|
|
5707
|
+
# foundation file. The section HINT picks the growable section; only these two are routable.
|
|
5708
|
+
_PERSONA_FOLD_SECTIONS = {
|
|
5709
|
+
"critical-rule": "## Critical Rules",
|
|
5710
|
+
"success-metric": "## Success Metrics",
|
|
5711
|
+
}
|
|
5712
|
+
|
|
4843
5713
|
|
|
4844
5714
|
def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
|
|
4845
5715
|
"""Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
|
|
@@ -4934,11 +5804,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
4934
5804
|
prev_v = int(vm.group(1))
|
|
4935
5805
|
new_v = prev_v + 1
|
|
4936
5806
|
|
|
4937
|
-
#
|
|
5807
|
+
# A lesson with a `persona:<slug>` target routes to a persona living doc, NOT a foundation file
|
|
5808
|
+
# (persona-self-improve). Partition first so the foundation-route checks skip persona lessons.
|
|
5809
|
+
persona_sel = [it for it in selected if it.get("persona")]
|
|
5810
|
+
found_sel = [it for it in selected if not it.get("persona")]
|
|
5811
|
+
|
|
5812
|
+
# routing — every FOUNDATION lesson's destination section (and the audit-trail section) must exist.
|
|
4938
5813
|
conventions_md = root / "CONVENTIONS.md"
|
|
4939
5814
|
conventions_text = conventions_md.read_text(encoding="utf-8") if conventions_md.exists() else ""
|
|
4940
5815
|
file_text = {"PROJECT.md": project_text, "CONVENTIONS.md": conventions_text}
|
|
4941
|
-
for it in
|
|
5816
|
+
for it in found_sel:
|
|
4942
5817
|
fname, heading = _FOLD_ROUTES[it["comp"]]
|
|
4943
5818
|
if not _section_present(file_text[fname], heading):
|
|
4944
5819
|
_die(f"missing_route_section: {fname} has no '{heading}' section for a "
|
|
@@ -4947,6 +5822,21 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
4947
5822
|
_die(f"missing_route_section: PROJECT.md has no '{_KEY_DECISIONS_HEADING}' "
|
|
4948
5823
|
"section for the audit-trail row — add the section header and re-run")
|
|
4949
5824
|
|
|
5825
|
+
# persona routing — validate fail-closed BEFORE any write (design-for-failure): the section hint
|
|
5826
|
+
# must be routable and the target persona file must exist. No network, no child launch — pure IO.
|
|
5827
|
+
persona_paths: dict[str, Path] = {}
|
|
5828
|
+
for it in persona_sel:
|
|
5829
|
+
hint = (it.get("hint") or "").strip()
|
|
5830
|
+
if hint not in _PERSONA_FOLD_SECTIONS:
|
|
5831
|
+
_die(f"persona_section_unroutable: '{hint or '(none)'}' is not a growable persona "
|
|
5832
|
+
f"section — use one of {', '.join(_PERSONA_FOLD_SECTIONS)}")
|
|
5833
|
+
slug = it["persona"]
|
|
5834
|
+
ppath = root / "personas" / f"{slug}.md"
|
|
5835
|
+
if not _persona_slug_valid(slug) or not ppath.is_file():
|
|
5836
|
+
_die(f"missing_persona_target: no .add/personas/{slug}.md for a persona lesson — "
|
|
5837
|
+
"seed the persona first (setup) or fix the slug")
|
|
5838
|
+
persona_paths[slug] = ppath
|
|
5839
|
+
|
|
4950
5840
|
# ── build EVERY edit in memory before writing anything ──────────────────────────────────────
|
|
4951
5841
|
comps_filter = {want_comp} if want_comp else None
|
|
4952
5842
|
task_new: dict[str, str] = {}
|
|
@@ -4962,15 +5852,40 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
4962
5852
|
return (f"- ({it['comp']}) {it['text']}{ev} "
|
|
4963
5853
|
f"[{_FOLDED} foundation-version {new_v} · from {it['task']}]")
|
|
4964
5854
|
|
|
4965
|
-
|
|
5855
|
+
def _persona_bullet(it):
|
|
5856
|
+
ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
|
|
5857
|
+
return (f"- {it['text']}{ev} "
|
|
5858
|
+
f"[{_FOLDED} {date.today().isoformat()} · from {it['task']}]")
|
|
5859
|
+
|
|
5860
|
+
# transcribe FOUNDATION lessons verbatim (reverse so canonical-order first lands on top, newest-first).
|
|
4966
5861
|
proj_text, conv_text = project_text, conventions_text
|
|
4967
|
-
for it in reversed(
|
|
5862
|
+
for it in reversed(found_sel):
|
|
4968
5863
|
fname, heading = _FOLD_ROUTES[it["comp"]]
|
|
4969
5864
|
if fname == "PROJECT.md":
|
|
4970
5865
|
proj_text = _prepend_to_section(proj_text, heading, _bullet(it))
|
|
4971
5866
|
else:
|
|
4972
5867
|
conv_text = _prepend_to_section(conv_text, heading, _bullet(it))
|
|
4973
5868
|
|
|
5869
|
+
# transcribe PERSONA lessons into their living docs — PREPEND newest-first under the hinted
|
|
5870
|
+
# section, NEVER clobbering (every prior persona line survives; the schema stays conformant).
|
|
5871
|
+
from collections import Counter
|
|
5872
|
+
persona_new: dict[str, str] = {}
|
|
5873
|
+
for it in reversed(persona_sel):
|
|
5874
|
+
slug = it["persona"]
|
|
5875
|
+
before = persona_new.get(slug) or persona_paths[slug].read_text(encoding="utf-8")
|
|
5876
|
+
heading = _PERSONA_FOLD_SECTIONS[it["hint"].strip()]
|
|
5877
|
+
if not _section_present(before, heading): # defensive: a conformant persona has it
|
|
5878
|
+
_die(f"persona_section_unroutable: .add/personas/{slug}.md has no '{heading}' section")
|
|
5879
|
+
after = _prepend_to_section(before, heading, _persona_bullet(it))
|
|
5880
|
+
# never-clobber INVARIANT: every pre-existing line must survive the merge.
|
|
5881
|
+
if Counter(before.splitlines()) - Counter(after.splitlines()):
|
|
5882
|
+
_die(f"persona_clobber_forbidden: consolidating into {slug}.md would drop existing content")
|
|
5883
|
+
# post-merge INVARIANT: the four required persona sections survive.
|
|
5884
|
+
if _persona_missing(after):
|
|
5885
|
+
_die(f"persona_clobber_forbidden: consolidating into {slug}.md broke the persona schema "
|
|
5886
|
+
f"(missing {', '.join(_persona_missing(after))})")
|
|
5887
|
+
persona_new[slug] = after
|
|
5888
|
+
|
|
4974
5889
|
counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
|
|
4975
5890
|
count_str = " · ".join(f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c])
|
|
4976
5891
|
scope = "all" if not (want_task or want_comp) else " ".join(
|
|
@@ -4993,6 +5908,10 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
4993
5908
|
for slug, body in task_new.items():
|
|
4994
5909
|
writes.append((root / "tasks" / slug / "TASK.md", body))
|
|
4995
5910
|
touched.append(f"{len(task_new)} TASK.md")
|
|
5911
|
+
for slug, body in persona_new.items():
|
|
5912
|
+
writes.append((persona_paths[slug], body))
|
|
5913
|
+
if persona_new:
|
|
5914
|
+
touched.append(f"{len(persona_new)} persona")
|
|
4996
5915
|
_atomic_write_many(writes)
|
|
4997
5916
|
|
|
4998
5917
|
print(f"{_FOLDED} {len(selected)} lessons -> foundation-version {new_v}")
|
|
@@ -5067,8 +5986,14 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
5067
5986
|
# F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
|
|
5068
5987
|
# high-risk record must show a guarded dial AND a human at the gate —
|
|
5069
5988
|
# catches post-gate header tampering and auto-resolved high-risk gates.
|
|
5989
|
+
# advisor-gate-relax: a mechanical task whose Advisor 3-lens verdict shows
|
|
5990
|
+
# PASS + Residue: none is exempt — the outer condition is False so both
|
|
5991
|
+
# sub-branches are skipped. Non-mechanical (security/data/architecture)
|
|
5992
|
+
# never relax; an absent advisor block is fail-safe False → guard fires.
|
|
5070
5993
|
hdr = _task_header(root, slug)
|
|
5071
|
-
if _RISK_HIGH_RE.search(hdr)
|
|
5994
|
+
if _RISK_HIGH_RE.search(hdr) and not (_task_sensitivity(hdr) == "mechanical"
|
|
5995
|
+
and _advisor_verdict_is_pass(s6)
|
|
5996
|
+
and _advisor_no_residue(s6)):
|
|
5072
5997
|
if not _autonomy_lowered(hdr):
|
|
5073
5998
|
f(slug, "unguarded_high_risk_auto",
|
|
5074
5999
|
"risk: high declared but autonomy is not lowered (manual or conservative)")
|
|
@@ -5119,24 +6044,68 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
|
5119
6044
|
shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
|
|
5120
6045
|
ABSENT block grandfathers a legacy task — never retro-flagged);
|
|
5121
6046
|
risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
|
|
6047
|
+
sensitivity_unset[]= the header carries NO valid `sensitivity:` token (risk-sensitivity-taxonomy);
|
|
5122
6048
|
refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
|
|
5123
6049
|
refute-record, M4) — the earned-green verdict the AI must record under
|
|
5124
6050
|
`auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
|
|
5125
6051
|
never auto-blocks a gate, only surfaced here for review + a human spot-audit.
|
|
5126
6052
|
Honest visibility for three verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
|
|
5127
6053
|
TASK.md + state only, writes nothing."""
|
|
5128
|
-
shallow, risk_unset, refute_unrecorded = [], [], []
|
|
6054
|
+
shallow, risk_unset, refute_unrecorded, sensitivity_unset = [], [], [], []
|
|
6055
|
+
advisor_verdict_unrecorded = []
|
|
6056
|
+
advisor_reviewer_is_author = []
|
|
6057
|
+
advisor_residue_on_mechanical_mis_tier = []
|
|
5129
6058
|
for slug in sorted(state.get("tasks") or {}):
|
|
5130
6059
|
if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
|
|
5131
6060
|
continue
|
|
5132
6061
|
body6 = _raw_phase_bodies(root, slug).get(6, "")
|
|
6062
|
+
hdr = _task_header(root, slug)
|
|
5133
6063
|
if _section_unfilled(body6, "### Deep checks"):
|
|
5134
6064
|
shallow.append(slug)
|
|
5135
6065
|
if _section_unfilled(body6, "### Refute-read verdict"):
|
|
5136
6066
|
refute_unrecorded.append(slug)
|
|
5137
|
-
if
|
|
6067
|
+
if _section_unfilled(body6, "### Advisor 3-lens verdict"):
|
|
6068
|
+
advisor_verdict_unrecorded.append(slug)
|
|
6069
|
+
if not _RISK_ANY_RE.search(hdr):
|
|
5138
6070
|
risk_unset.append(slug)
|
|
5139
|
-
|
|
6071
|
+
# sensitivity_unset (risk-sensitivity-taxonomy): a verify-reached task with no
|
|
6072
|
+
# human-declared sensitivity — MEASURE-NOT-BLOCK, same class as risk_unset.
|
|
6073
|
+
if _task_sensitivity(hdr) is None:
|
|
6074
|
+
sensitivity_unset.append(slug)
|
|
6075
|
+
# advisor_reviewer_is_author / advisor_residue_on_mechanical_mis_tier
|
|
6076
|
+
# (advisor-verdict-audit): MEASURE-NOT-BLOCK lints on the filled advisor block.
|
|
6077
|
+
# Both require the block to be PRESENT AND FILLED (not just unfilled).
|
|
6078
|
+
# Extract ONLY the advisor sub-section to avoid false matches from
|
|
6079
|
+
# the Refute-read verdict block (which also carries Verdict:/Residue: lines).
|
|
6080
|
+
_advisor_body = ""
|
|
6081
|
+
_adv_idx = body6.find("### Advisor 3-lens verdict")
|
|
6082
|
+
if _adv_idx != -1:
|
|
6083
|
+
_adv_rest = body6[_adv_idx + len("### Advisor 3-lens verdict"):]
|
|
6084
|
+
_next_hdr = re.search(r"\n###", _adv_rest)
|
|
6085
|
+
_advisor_body = _adv_rest[:_next_hdr.start()] if _next_hdr else _adv_rest
|
|
6086
|
+
_advisor_filled = (_adv_idx != -1
|
|
6087
|
+
and not _section_unfilled(body6, "### Advisor 3-lens verdict"))
|
|
6088
|
+
if _advisor_filled:
|
|
6089
|
+
_ma = re.search(r"(?m)^Advisor:(.*)$", _advisor_body)
|
|
6090
|
+
_advisor_name = _ma.group(1).strip() if _ma else ""
|
|
6091
|
+
_gate_name = (state["tasks"][slug].get("gate_actor") or {}).get("name", "").strip()
|
|
6092
|
+
if (_advisor_name and _gate_name
|
|
6093
|
+
and _advisor_name.lower() == _gate_name.lower()):
|
|
6094
|
+
advisor_reviewer_is_author.append(slug)
|
|
6095
|
+
_sens = _task_sensitivity(hdr)
|
|
6096
|
+
if _sens == "mechanical":
|
|
6097
|
+
_mr = re.search(r"(?m)^Residue:(.*)$", _advisor_body)
|
|
6098
|
+
_mv = re.search(r"(?m)^Verdict:(.*)$", _advisor_body)
|
|
6099
|
+
_residue = _mr.group(1).strip() if _mr else ""
|
|
6100
|
+
_verdict = _mv.group(1).strip() if _mv else ""
|
|
6101
|
+
if (_residue and _residue.lower() != "none"
|
|
6102
|
+
and _verdict.upper().startswith("PASS")):
|
|
6103
|
+
advisor_residue_on_mechanical_mis_tier.append(slug)
|
|
6104
|
+
return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded,
|
|
6105
|
+
"sensitivity_unset": sensitivity_unset,
|
|
6106
|
+
"advisor_verdict_unrecorded": advisor_verdict_unrecorded,
|
|
6107
|
+
"advisor_reviewer_is_author": advisor_reviewer_is_author,
|
|
6108
|
+
"advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier}
|
|
5140
6109
|
|
|
5141
6110
|
|
|
5142
6111
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
@@ -5169,8 +6138,28 @@ def cmd_audit(args: argparse.Namespace) -> None:
|
|
|
5169
6138
|
ru = glints["refute_unrecorded"]
|
|
5170
6139
|
print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
|
|
5171
6140
|
f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
|
|
6141
|
+
if glints["advisor_verdict_unrecorded"]:
|
|
6142
|
+
av = glints["advisor_verdict_unrecorded"]
|
|
6143
|
+
print(f"audit: advisor_verdict_unrecorded — {len(av)} task(s): {', '.join(av)} "
|
|
6144
|
+
f"— record the 3-lens advisor verdict (§6); a spot-audit is the backstop")
|
|
6145
|
+
if glints["sensitivity_unset"]:
|
|
6146
|
+
su = glints["sensitivity_unset"]
|
|
6147
|
+
print(f"audit: sensitivity_unset — {len(su)} task(s) reached verify with no "
|
|
6148
|
+
f"sensitivity: declaration: {', '.join(su)}")
|
|
6149
|
+
if glints["advisor_reviewer_is_author"]:
|
|
6150
|
+
ar = glints["advisor_reviewer_is_author"]
|
|
6151
|
+
print(f"audit: advisor_reviewer_is_author — {len(ar)} task(s): {', '.join(ar)} "
|
|
6152
|
+
f"— advisor and gate actor are the same identity")
|
|
6153
|
+
if glints["advisor_residue_on_mechanical_mis_tier"]:
|
|
6154
|
+
am = glints["advisor_residue_on_mechanical_mis_tier"]
|
|
6155
|
+
print(f"audit: advisor_residue_on_mechanical_mis_tier — {len(am)} task(s): "
|
|
6156
|
+
f"{', '.join(am)} — mechanical tier with non-none residue and PASS verdict "
|
|
6157
|
+
f"is incoherent; consider re-tiering")
|
|
5172
6158
|
if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
|
|
5173
|
-
and not glints["refute_unrecorded"]
|
|
6159
|
+
and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
|
|
6160
|
+
and not glints["sensitivity_unset"] \
|
|
6161
|
+
and not glints["advisor_reviewer_is_author"] \
|
|
6162
|
+
and not glints["advisor_residue_on_mechanical_mis_tier"]:
|
|
5174
6163
|
print(f"audit: clean ({checked} tasks checked)")
|
|
5175
6164
|
# MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
|
|
5176
6165
|
if findings:
|
|
@@ -5570,8 +6559,23 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5570
6559
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
5571
6560
|
getattr(args, "evidence", None),
|
|
5572
6561
|
identity._render_actor_line(state), loose_bundle))
|
|
5573
|
-
|
|
5574
|
-
|
|
6562
|
+
# milestone-release-backlink: STAMP each bundled milestone's MILESTONE.md `release:` line to
|
|
6563
|
+
# the cut version. Built in memory NOW and appended to the SAME atomic batch as the ledgers,
|
|
6564
|
+
# so the stamp commits all-or-nothing with them (a failed write rolls back everything). Only
|
|
6565
|
+
# `bundle` (closed-and-unreleased) milestones are stamped; a missing/unreadable file is skipped
|
|
6566
|
+
# (degrade-safe — the ledger attribution stays the source of truth).
|
|
6567
|
+
stamp_writes: list[tuple[Path, str]] = []
|
|
6568
|
+
for m in bundle:
|
|
6569
|
+
mfile = root / "milestones" / m["slug"] / "MILESTONE.md"
|
|
6570
|
+
try:
|
|
6571
|
+
_mtxt = mfile.read_text(encoding="utf-8")
|
|
6572
|
+
except OSError:
|
|
6573
|
+
continue
|
|
6574
|
+
_stamped = _set_release_line(_mtxt, args.version)
|
|
6575
|
+
if _stamped != _mtxt:
|
|
6576
|
+
stamp_writes.append((mfile, _stamped))
|
|
6577
|
+
try: # CHANGELOG + RELEASES + milestone stamps: one all-or-nothing commit
|
|
6578
|
+
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)] + stamp_writes)
|
|
5575
6579
|
except OSError as e:
|
|
5576
6580
|
_die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
|
|
5577
6581
|
"files were rolled back to their prior content. Retry the release.")
|
|
@@ -5764,6 +6768,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5764
6768
|
pi.add_argument("--rule-file", dest="rule_file", action="store_true",
|
|
5765
6769
|
help="write the ADD block to .claude/rules/add-workflows.md and reference it "
|
|
5766
6770
|
"from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
|
|
6771
|
+
pi.add_argument("--run-mode", dest="run_mode", default=None,
|
|
6772
|
+
choices=["auto", "conservative"],
|
|
6773
|
+
help="seed autonomy+streams posture: auto→parallel, conservative→sequential "
|
|
6774
|
+
"(absent: PROJECT.md is byte-identical to a plain init)")
|
|
5767
6775
|
pi.set_defaults(func=cmd_init)
|
|
5768
6776
|
|
|
5769
6777
|
pl = sub.add_parser("lock",
|
|
@@ -5892,6 +6900,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5892
6900
|
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
5893
6901
|
pwa.set_defaults(func=cmd_waves)
|
|
5894
6902
|
|
|
6903
|
+
pdp = sub.add_parser("dag-plan", help="record a committed snapshot of the milestone's computed "
|
|
6904
|
+
"DAG schedule (waves + critical path + tiers) + a freshness "
|
|
6905
|
+
"check vs the live depends_on edges")
|
|
6906
|
+
pdp.add_argument("--milestone", default=None,
|
|
6907
|
+
help="milestone slug to snapshot (default: the active milestone)")
|
|
6908
|
+
pdp.set_defaults(func=cmd_dag_plan)
|
|
6909
|
+
|
|
5895
6910
|
pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
|
|
5896
6911
|
pmd.add_argument("slug")
|
|
5897
6912
|
pmd.set_defaults(func=cmd_milestone_done)
|
|
@@ -5962,6 +6977,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5962
6977
|
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
5963
6978
|
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
5964
6979
|
|
|
6980
|
+
pst = sub.add_parser("streams", help="show or set the project streams posture (parallel|sequential)")
|
|
6981
|
+
pst.add_argument("action", nargs="?", choices=("show", "set"), default="show")
|
|
6982
|
+
pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
|
|
6983
|
+
pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
|
|
6984
|
+
|
|
5965
6985
|
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
5966
6986
|
pto.add_argument("text", nargs="?", default=None,
|
|
5967
6987
|
help="todo text to capture; omit to LIST open todos")
|
|
@@ -5992,12 +7012,29 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5992
7012
|
|
|
5993
7013
|
pst = sub.add_parser("status", help="print where the project is (resume point)")
|
|
5994
7014
|
pst.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
7015
|
+
pst.add_argument("--task", metavar="SLUG", help="with --json, filter to one task's "
|
|
7016
|
+
"{slug, phase, gate, milestone, owner, assignee} object")
|
|
7017
|
+
pst.add_argument("--all", action="store_true", help="show every milestone/task "
|
|
7018
|
+
"(default: top 10 by most-recently-updated)")
|
|
5995
7019
|
pst.set_defaults(func=cmd_status)
|
|
5996
7020
|
|
|
5997
7021
|
pck = sub.add_parser("check", help="read-only integrity check of the .add project")
|
|
5998
7022
|
pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
5999
7023
|
pck.set_defaults(func=cmd_check)
|
|
6000
7024
|
|
|
7025
|
+
pcomp = sub.add_parser("components",
|
|
7026
|
+
help="read-only: print + validate the component registry "
|
|
7027
|
+
"(.add/components.toml) — RED integrity errors + schema-lint typo warnings")
|
|
7028
|
+
pcomp.set_defaults(func=cmd_components)
|
|
7029
|
+
|
|
7030
|
+
psrch = sub.add_parser("search", help="keyword/substring search over the "
|
|
7031
|
+
"milestone/task corpus (active + archived) — "
|
|
7032
|
+
"title/goal/rationale lines only, never the full body")
|
|
7033
|
+
psrch.add_argument("keywords", nargs="+", metavar="KEYWORD",
|
|
7034
|
+
help="one or more keywords (case-insensitive substring, OR-combined)")
|
|
7035
|
+
psrch.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
7036
|
+
psrch.set_defaults(func=cmd_search)
|
|
7037
|
+
|
|
6001
7038
|
pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
|
|
6002
7039
|
"contract snapshot into this repo (fail-loud)")
|
|
6003
7040
|
pfedsub = pfed.add_subparsers(dest="action", required=True)
|