@pilotspace/add 1.14.0 → 1.16.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 +195 -1
- package/README.md +70 -3
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/agents/add-advisor.md +31 -0
- package/agents/add-build.md +29 -0
- package/agents/add-design.md +32 -0
- package/agents/add-persona.md +31 -0
- package/agents/add-verify.md +34 -0
- package/bin/cli.js +615 -54
- 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/08-step-6-verify.md +7 -0
- 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 +5 -1
- package/package.json +4 -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 +20 -14
- package/skill/add/advisor.md +33 -34
- 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 +3 -4
- 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 +9 -4
- package/skill/add/phases/7-observe.md +2 -1
- package/skill/add/report-template.md +62 -18
- package/skill/add/run.md +5 -3
- package/skill/add/scope.md +2 -2
- package/skill/add/sensitivity.md +51 -0
- package/skill/add/streams.md +31 -23
- package/tooling/add.py +906 -28
- 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 +1 -0
- package/tooling/templates/TASK.md.tmpl +39 -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) --------
|
|
@@ -390,6 +538,19 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
390
538
|
continue
|
|
391
539
|
_atomic_write(dest, rendered)
|
|
392
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
|
+
|
|
393
554
|
state = {
|
|
394
555
|
"project": proj_name,
|
|
395
556
|
"stage": args.stage,
|
|
@@ -496,10 +657,18 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
496
657
|
rendered = _render_template(
|
|
497
658
|
"TASK.fast.md" if fast else "TASK.md",
|
|
498
659
|
title=title, slug=slug, date=date.today().isoformat(),
|
|
499
|
-
stage=state["stage"], autonomy=autonomy
|
|
660
|
+
stage=state["stage"], autonomy=autonomy,
|
|
661
|
+
milestone=_milestone_backlink_value(milestone))
|
|
500
662
|
if feature_override: # pre-fill §1 from the seeded delta
|
|
501
663
|
rendered = re.sub(r"(?m)^Feature:.*$",
|
|
502
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)
|
|
503
672
|
seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
|
|
504
673
|
if flipped_prior is not None: # consume the source delta -> seeded
|
|
505
674
|
seed_writes.append((prior_md, flipped_prior))
|
|
@@ -697,6 +866,15 @@ def cmd_freeze(args: argparse.Namespace) -> None:
|
|
|
697
866
|
if not _flag_well_formed(raw3):
|
|
698
867
|
_die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
|
|
699
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)")
|
|
700
878
|
# --- write ---
|
|
701
879
|
ver = _next_freeze_version(state, slug)
|
|
702
880
|
who = args.by or identity._actor_stamp(state)["name"]
|
|
@@ -956,6 +1134,71 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
956
1134
|
# not be able to fool the guard either way).
|
|
957
1135
|
_RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
|
|
958
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
|
+
|
|
959
1202
|
# the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
|
|
960
1203
|
# manual < conservative < auto, declared as a per-task `autonomy:` header token.
|
|
961
1204
|
# anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
|
|
@@ -978,6 +1221,66 @@ def _autonomy_lowered(hdr: str) -> bool:
|
|
|
978
1221
|
return _autonomy_level(hdr) in ("manual", "conservative")
|
|
979
1222
|
|
|
980
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
|
+
|
|
981
1284
|
def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
|
|
982
1285
|
"""True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
|
|
983
1286
|
SINGLE source the footer marker and the guide TEXT marker both render (task
|
|
@@ -1023,8 +1326,16 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1023
1326
|
# the mechanized high-risk guard: an unguarded high-risk header refuses
|
|
1024
1327
|
# COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
|
|
1025
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.
|
|
1026
1333
|
hdr = _task_header(root, slug)
|
|
1027
|
-
|
|
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:
|
|
1028
1339
|
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
1029
1340
|
"without a lowered autonomy level — run `add.py autonomy set conservative` "
|
|
1030
1341
|
"(or manual); a human must own a high-risk gate (run.md guard)")
|
|
@@ -1072,6 +1383,15 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1072
1383
|
_sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
|
|
1073
1384
|
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
1074
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
|
|
1075
1395
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
1076
1396
|
_gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
|
|
1077
1397
|
if _gbar:
|
|
@@ -1109,6 +1429,20 @@ def _autonomy_decl_line(text: str, level: str) -> str:
|
|
|
1109
1429
|
return f"autonomy: {level}\n" + text
|
|
1110
1430
|
|
|
1111
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
|
+
|
|
1112
1446
|
def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
|
|
1113
1447
|
"""RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
|
|
1114
1448
|
freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
|
|
@@ -1158,6 +1492,25 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1158
1492
|
_print_autonomy(root, state, slug)
|
|
1159
1493
|
|
|
1160
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
|
+
|
|
1161
1514
|
def cmd_todo(args: argparse.Namespace) -> None:
|
|
1162
1515
|
"""Capture / list / close a lightweight backlog todo (task: todo-capture).
|
|
1163
1516
|
|
|
@@ -1437,18 +1790,41 @@ def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
|
|
|
1437
1790
|
return PLAIN
|
|
1438
1791
|
|
|
1439
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
|
+
|
|
1440
1802
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
1803
|
+
show_all = getattr(args, "all", False)
|
|
1441
1804
|
if getattr(args, "json", False):
|
|
1442
1805
|
root, state = _load_state_for_json()
|
|
1443
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
|
|
1444
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]
|
|
1445
1819
|
ms_list = []
|
|
1446
|
-
for mslug, m in
|
|
1820
|
+
for mslug, m in page_ms:
|
|
1447
1821
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1448
1822
|
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
1449
1823
|
"done": sum(1 for t in members if _task_done(t)),
|
|
1450
1824
|
"total": len(members),
|
|
1451
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]
|
|
1452
1828
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1453
1829
|
print(json.dumps({
|
|
1454
1830
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
@@ -1457,10 +1833,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1457
1833
|
"active_milestones": list(state.get("active_milestones") or []),
|
|
1458
1834
|
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
1459
1835
|
"milestones": ms_list,
|
|
1836
|
+
"milestones_total": len(milestones),
|
|
1460
1837
|
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
1461
1838
|
"milestone": t.get("milestone"),
|
|
1462
1839
|
"owner": t.get("owner"), "assignee": t.get("assignee")}
|
|
1463
|
-
for s, t in
|
|
1840
|
+
for s, t in page_tasks],
|
|
1841
|
+
"tasks_total": len(tasks),
|
|
1464
1842
|
"graduation_ready": grad_ready,
|
|
1465
1843
|
"stage_criteria": {"met": grad_met, "total": grad_total}}))
|
|
1466
1844
|
return
|
|
@@ -1475,6 +1853,9 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1475
1853
|
# project autonomy default (task init-auto-default): the posture new tasks INHERIT,
|
|
1476
1854
|
# read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
|
|
1477
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)}")
|
|
1478
1859
|
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1479
1860
|
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1480
1861
|
_who = identity._whoami(state)
|
|
@@ -1495,6 +1876,9 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1495
1876
|
_gr_cited, _gr_total = _exit_criteria_cited(root, _active_ms)
|
|
1496
1877
|
_gr_state = "auto-ready ✓" if _goal_auto_ready(root, _active_ms) else "NOT auto-ready"
|
|
1497
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))
|
|
1498
1882
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
1499
1883
|
if (root / "PROJECT.md").exists():
|
|
1500
1884
|
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
@@ -1516,12 +1900,16 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1516
1900
|
active_ms = _active_milestone(state)
|
|
1517
1901
|
if milestones:
|
|
1518
1902
|
print("milestones:")
|
|
1519
|
-
|
|
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:
|
|
1520
1906
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1521
1907
|
done = sum(1 for t in members if _task_done(t))
|
|
1522
1908
|
mark = "*" if mslug in (state.get("active_milestones") or []) else " "
|
|
1523
1909
|
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
1524
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)")
|
|
1525
1913
|
# graduation cue (v22): project-global + read-only. Fires only when every milestone
|
|
1526
1914
|
# is done AND the human's PROJECT.md stage-goal-criteria are all checked — additive
|
|
1527
1915
|
# (a new line solely when ready; the non-ready output is byte-identical to before).
|
|
@@ -1590,6 +1978,19 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1590
1978
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1591
1979
|
if active and active in tasks:
|
|
1592
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)
|
|
1593
1994
|
# owner/assignee of the active task (ownership-assignment) — present-only, never a
|
|
1594
1995
|
# placeholder; an unassigned active task adds no line (additive-cue convention).
|
|
1595
1996
|
_own = _fmt_ownership(tasks[active])
|
|
@@ -1619,12 +2020,16 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1619
2020
|
print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
|
|
1620
2021
|
return
|
|
1621
2022
|
print("tasks :")
|
|
1622
|
-
|
|
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:
|
|
1623
2026
|
mark = "*" if slug == active else " "
|
|
1624
2027
|
deps = t.get("depends_on") or []
|
|
1625
2028
|
dep_s = f" deps={','.join(deps)}" if deps else ""
|
|
1626
2029
|
ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
|
|
1627
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)")
|
|
1628
2033
|
# fold-pressure nudge: surface unfolded competency deltas so emission can't
|
|
1629
2034
|
# silently outrun the human fold (read-only; v11). Silent when none are open.
|
|
1630
2035
|
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
@@ -1749,6 +2154,14 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1749
2154
|
print(f"guide : {gp}")
|
|
1750
2155
|
elif phase in _PHASE_GUIDE_FILES:
|
|
1751
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)
|
|
1752
2165
|
if phase == "verify":
|
|
1753
2166
|
print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
|
|
1754
2167
|
elif phase == "done":
|
|
@@ -2287,6 +2700,31 @@ def cmd_components(args: argparse.Namespace) -> None:
|
|
|
2287
2700
|
raise SystemExit(1)
|
|
2288
2701
|
|
|
2289
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
|
+
|
|
2290
2728
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
2291
2729
|
"""Read-only integrity check of the .add project. Exit 1 if anything fails."""
|
|
2292
2730
|
as_json = getattr(args, "json", False)
|
|
@@ -2330,6 +2768,46 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2330
2768
|
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
2331
2769
|
warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add "
|
|
2332
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"))
|
|
2333
2811
|
# autonomy level (task explicit-autonomy-dial): a REAL out-of-set token is a hard
|
|
2334
2812
|
# unknown_autonomy_level; a LIVE task (phase before done/observe) with no `autonomy:`
|
|
2335
2813
|
# line is implicit_autonomy — a WARN, never red. Done/observe predecessors are SKIPPED
|
|
@@ -2432,6 +2910,27 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2432
2910
|
"(scope_violation pending) — the verify gate "
|
|
2433
2911
|
"will refuse it"))
|
|
2434
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
|
+
|
|
2435
2934
|
# drift: a done milestone must have no unfinished tasks
|
|
2436
2935
|
for mslug, m in milestones.items():
|
|
2437
2936
|
if m.get("status") == "done":
|
|
@@ -2470,6 +2969,17 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2470
2969
|
f"task '{_at}' froze its contract without grounding — fill the "
|
|
2471
2970
|
"§0 GROUND anchors the contract cites (add.py guide)"))
|
|
2472
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
|
+
|
|
2473
2983
|
# wave-ledger fork-base (engine-merge-base-enforcement): the engine EXECUTES the
|
|
2474
2984
|
# streams.md rule — every roster echo must match `base:`. A FILLED mismatch is red at
|
|
2475
2985
|
# ANY status; a pending row is red at `status: merging` (merge-time strictness) but only
|
|
@@ -2545,6 +3055,30 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2545
3055
|
f".add/design/captures/{_pname}.<png|svg|…> — render + confirm it "
|
|
2546
3056
|
"before build (design.md beat 4)"))
|
|
2547
3057
|
|
|
3058
|
+
# roster-uninstalled (roster-install-drift): the ADD-managed guideline block cites the agent
|
|
3059
|
+
# roster ("agents/*.md" tail — matches both the shipped `add-method/agents/*.md` attribution
|
|
3060
|
+
# citation and any older phrasing) but the project may have no roster installed at all — never
|
|
3061
|
+
# shipped in the package, synced before this fix, or from a build that regressed the agents/
|
|
3062
|
+
# tree — a dead reference with no signal anywhere. WARN, never red (measure-not-block);
|
|
3063
|
+
# presence-gated on the citation itself — a project whose guideline files don't cite a roster
|
|
3064
|
+
# at all is silently exempt, never retro-flagged.
|
|
3065
|
+
_project_root = root.parent
|
|
3066
|
+
_cites_roster = False
|
|
3067
|
+
for _gname in GUIDELINE_FILES:
|
|
3068
|
+
try:
|
|
3069
|
+
if "agents/*.md" in (_project_root / _gname).read_text(encoding="utf-8"):
|
|
3070
|
+
_cites_roster = True
|
|
3071
|
+
break
|
|
3072
|
+
except OSError:
|
|
3073
|
+
pass
|
|
3074
|
+
if _cites_roster:
|
|
3075
|
+
_agents_dir = _project_root / ".claude" / "agents"
|
|
3076
|
+
if not (_agents_dir.is_dir() and any(_agents_dir.glob("add-*.md"))):
|
|
3077
|
+
warnings.append(("roster_uninstalled",
|
|
3078
|
+
"guideline file(s) cite the agent roster but no `.claude/agents/"
|
|
3079
|
+
"add-*.md` files are installed — run `add.py update` (or re-run the "
|
|
3080
|
+
"CLI installer) to materialize them"))
|
|
3081
|
+
|
|
2548
3082
|
passed = sum(1 for ok, _, _ in checks if ok)
|
|
2549
3083
|
failed = len(checks) - passed
|
|
2550
3084
|
if as_json:
|
|
@@ -3215,6 +3749,88 @@ def cmd_waves(args: argparse.Namespace) -> None:
|
|
|
3215
3749
|
print("\n".join(_wave_block_lines(state, t, s)))
|
|
3216
3750
|
|
|
3217
3751
|
|
|
3752
|
+
# --- persisted DAG-plan snapshot (persist-dag-plan) --------------------------------
|
|
3753
|
+
# `waves` recomputes the schedule live and is the authority; `dag-plan` MATERIALIZES that
|
|
3754
|
+
# computed schedule into a committed, auditable per-milestone snapshot + a freshness check
|
|
3755
|
+
# against the live edges. The snapshot is NEVER the authority — only a checkable projection.
|
|
3756
|
+
def _dag_plan_path(root: Path, mslug: str) -> Path:
|
|
3757
|
+
return root / "milestones" / mslug / "dag-plan.json"
|
|
3758
|
+
|
|
3759
|
+
|
|
3760
|
+
def _edges_fingerprint(state: dict, mslug: str) -> str:
|
|
3761
|
+
"""md5 over the EDGE STRUCTURE: every member's (done OR open) sorted depends_on. PURE.
|
|
3762
|
+
Invariant under a task COMPLETING (phase/gate change, not edges) — so completion is NOT
|
|
3763
|
+
drift — and changes only when a dep is added/removed/redirected or a member added/removed."""
|
|
3764
|
+
tasks = state.get("tasks") or {}
|
|
3765
|
+
edges = {s: sorted(t.get("depends_on") or [])
|
|
3766
|
+
for s, t in tasks.items() if t.get("milestone") == mslug}
|
|
3767
|
+
return _md5_text(json.dumps(edges, sort_keys=True))
|
|
3768
|
+
|
|
3769
|
+
|
|
3770
|
+
def _dag_plan_freshness(root: Path, state: dict, mslug: str) -> tuple[str, dict | None]:
|
|
3771
|
+
"""Read the snapshot and compare its stored fingerprint to the live one. Fail-safe:
|
|
3772
|
+
absent -> ("none", None) · unreadable/garbled -> ("unreadable", None) · match -> "fresh"
|
|
3773
|
+
· mismatch -> "stale". Never raises on a missing/corrupt file (no traceback to the user)."""
|
|
3774
|
+
sp = _dag_plan_path(root, mslug)
|
|
3775
|
+
if not sp.exists():
|
|
3776
|
+
return ("none", None)
|
|
3777
|
+
try:
|
|
3778
|
+
snap = json.loads(sp.read_text(encoding="utf-8"))
|
|
3779
|
+
except (OSError, ValueError):
|
|
3780
|
+
return ("unreadable", None)
|
|
3781
|
+
if not isinstance(snap, dict) or "edges_fingerprint" not in snap:
|
|
3782
|
+
return ("unreadable", None)
|
|
3783
|
+
live = _edges_fingerprint(state, mslug)
|
|
3784
|
+
return (("fresh" if snap.get("edges_fingerprint") == live else "stale"), snap)
|
|
3785
|
+
|
|
3786
|
+
|
|
3787
|
+
def _dag_plan_status_line(root: Path, state: dict, mslug: str) -> str:
|
|
3788
|
+
"""The single `dag-plan:` line cmd_status prints for the active milestone."""
|
|
3789
|
+
fresh, snap = _dag_plan_freshness(root, state, mslug)
|
|
3790
|
+
if fresh == "fresh":
|
|
3791
|
+
return "dag-plan: fresh ✓"
|
|
3792
|
+
if fresh == "stale":
|
|
3793
|
+
return f"dag-plan: stale (edges changed since {(snap or {}).get('generated', '?')})"
|
|
3794
|
+
if fresh == "unreadable":
|
|
3795
|
+
return "dag-plan: unreadable — run add.py dag-plan"
|
|
3796
|
+
return "dag-plan: none — run add.py dag-plan"
|
|
3797
|
+
|
|
3798
|
+
|
|
3799
|
+
def cmd_dag_plan(args: argparse.Namespace) -> None:
|
|
3800
|
+
"""RECORD-ONLY: materialize the active (or --milestone) milestone's computed DAG schedule
|
|
3801
|
+
into a committed snapshot + an edge fingerprint. Reuses cmd_waves' rejects; writes NOTHING
|
|
3802
|
+
on any reject. Idempotent: an unchanged fingerprint leaves the file byte-identical."""
|
|
3803
|
+
root = _require_root()
|
|
3804
|
+
state = load_state(root)
|
|
3805
|
+
mslug = getattr(args, "milestone", None) or _active_milestone(state)
|
|
3806
|
+
if not mslug:
|
|
3807
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
3808
|
+
if mslug not in (state.get("milestones") or {}):
|
|
3809
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
|
|
3810
|
+
sched = _wave_schedule(state, mslug)
|
|
3811
|
+
if "cycle" in sched:
|
|
3812
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
3813
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
3814
|
+
live_fp = _edges_fingerprint(state, mslug)
|
|
3815
|
+
sp = _dag_plan_path(root, mslug)
|
|
3816
|
+
# idempotent: an unchanged fingerprint leaves the snapshot (and its `generated` date) untouched
|
|
3817
|
+
if sp.exists():
|
|
3818
|
+
try:
|
|
3819
|
+
cur = json.loads(sp.read_text(encoding="utf-8"))
|
|
3820
|
+
except (OSError, ValueError):
|
|
3821
|
+
cur = None
|
|
3822
|
+
if isinstance(cur, dict) and cur.get("edges_fingerprint") == live_fp:
|
|
3823
|
+
print(f"dag-plan: {mslug} already fresh ✓ (generated {cur.get('generated', '?')})")
|
|
3824
|
+
return
|
|
3825
|
+
snap = {"milestone": mslug, "generated": date.today().isoformat(),
|
|
3826
|
+
"edges_fingerprint": live_fp, "schedule": sched}
|
|
3827
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
3828
|
+
_atomic_write(sp, json.dumps(snap, indent=2, sort_keys=True) + "\n")
|
|
3829
|
+
nwaves = len(sched.get("waves") or [])
|
|
3830
|
+
ntasks = sum(len(w) for w in (sched.get("waves") or []))
|
|
3831
|
+
print(f"dag-plan: wrote {mslug} — {nwaves} wave(s), {ntasks} task(s) (fresh ✓)")
|
|
3832
|
+
|
|
3833
|
+
|
|
3218
3834
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
3219
3835
|
root = _require_root()
|
|
3220
3836
|
state = load_state(root)
|
|
@@ -3431,6 +4047,17 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
3431
4047
|
state["tasks"][task]["milestone"] = new
|
|
3432
4048
|
state["tasks"][task]["updated"] = _now()
|
|
3433
4049
|
save_state(root, state)
|
|
4050
|
+
# keep the TASK.md `milestone:` backlink in lockstep with state (task-milestone-backlink):
|
|
4051
|
+
# rewrite the header line (insert it if a grandfathered file lacks it). Degrade-safe — a
|
|
4052
|
+
# missing/unreadable TASK.md never blocks the move (state is already the source of truth).
|
|
4053
|
+
task_md = root / "tasks" / task / "TASK.md"
|
|
4054
|
+
try:
|
|
4055
|
+
_txt = task_md.read_text(encoding="utf-8")
|
|
4056
|
+
_new_txt = _set_milestone_line(_txt, _milestone_backlink_value(new))
|
|
4057
|
+
if _new_txt != _txt:
|
|
4058
|
+
_atomic_write(task_md, _new_txt)
|
|
4059
|
+
except OSError:
|
|
4060
|
+
pass
|
|
3434
4061
|
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
3435
4062
|
print(_next_footer(root, state))
|
|
3436
4063
|
|
|
@@ -3605,7 +4232,10 @@ def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
|
3605
4232
|
except (ValueError, OSError):
|
|
3606
4233
|
rel = str(f)
|
|
3607
4234
|
tests[rel] = h
|
|
3608
|
-
|
|
4235
|
+
# strip-scaffold-at-done: fingerprint the contract CONTENT (comment-normalized, see
|
|
4236
|
+
# _contract_fingerprint) so the at-done comment strip is invisible to the tamper guard; a real
|
|
4237
|
+
# fenced-shape edit still changes it. Mirrored byte-for-byte in _tripwire_divergence.
|
|
4238
|
+
return {"contract_md5": _contract_fingerprint(raw3), "tests": tests}
|
|
3609
4239
|
|
|
3610
4240
|
|
|
3611
4241
|
def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
@@ -3613,7 +4243,9 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
|
3613
4243
|
path directly (never re-globs), so a weakened, deleted, or unreadable test file
|
|
3614
4244
|
and an edited frozen §3 all surface. Fail-closed: an unreadable file -> diverged."""
|
|
3615
4245
|
diffs: list[str] = []
|
|
3616
|
-
|
|
4246
|
+
# compare the contract CONTENT fingerprint (strip-scaffold-at-done) — same normalization as the
|
|
4247
|
+
# snapshot, so the at-done comment strip never reads as tampering; a real shape edit still does.
|
|
4248
|
+
if _contract_fingerprint(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
|
|
3617
4249
|
diffs.append("contract_tampered")
|
|
3618
4250
|
rootp = root.parent.resolve()
|
|
3619
4251
|
for rel, snap in (tw.get("tests") or {}).items():
|
|
@@ -3632,7 +4264,10 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
|
3632
4264
|
# likewise NOT a source touch — counting one produced repeated false `scope_violation`s in
|
|
3633
4265
|
# consuming projects (`.next/`, `coverage/`, `tsconfig.tsbuildinfo`, whose `incremental`
|
|
3634
4266
|
# rewrite even races a clean re-snapshot), so they are pruned here too.
|
|
3635
|
-
|
|
4267
|
+
# `.claude` is an agent-tool internal dir (config/skills/worktrees) like `.serena` — never a
|
|
4268
|
+
# task's declared source; without it, the walk descends into `.claude/worktrees/<wt>/` (linked
|
|
4269
|
+
# git worktrees: full branch checkouts) and their churn produces false `scope_violation`s.
|
|
4270
|
+
_SCOPE_EXCLUDE_DIRS = (".git", ".add", ".claude", "__pycache__", "node_modules", ".serena",
|
|
3636
4271
|
".next", "coverage", "test-results")
|
|
3637
4272
|
_SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildinfo by suffix
|
|
3638
4273
|
_SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
|
|
@@ -4509,8 +5144,15 @@ def _flag_well_formed(raw3: str) -> bool:
|
|
|
4509
5144
|
refused unless it takes the honest escape 'none material — biggest risk: X'.
|
|
4510
5145
|
why/cost stay a human-read convention, never machine keywords (evidence: the
|
|
4511
5146
|
lived flags use em-dash/prose, never literal because/if-wrong). HTML comments
|
|
4512
|
-
(template hints) never count.
|
|
4513
|
-
|
|
5147
|
+
(template hints) never count. Fence-aware (mirrors _strip_live_scaffold): a
|
|
5148
|
+
frozen §3 may legitimately quote a bare `<!--` inside its own fenced code
|
|
5149
|
+
block (documenting an HTML-comment invariant) — that must never merge with
|
|
5150
|
+
an unrelated `-->` found later in the raw text. PURE — fail-closed on a
|
|
5151
|
+
missing label."""
|
|
5152
|
+
segs = re.split(r"(```.*?```)", raw3, flags=re.DOTALL)
|
|
5153
|
+
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
5154
|
+
segs[i] = re.sub(r"<!--.*?-->", "", segs[i], flags=re.S)
|
|
5155
|
+
body = "".join(segs)
|
|
4514
5156
|
m = _FLAG_LABEL_RE.search(body)
|
|
4515
5157
|
if not m:
|
|
4516
5158
|
return False
|
|
@@ -4936,7 +5578,12 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
4936
5578
|
delta_text, evidence = em.group(1).strip(), em.group(2).strip()
|
|
4937
5579
|
else:
|
|
4938
5580
|
delta_text, evidence = tail, ""
|
|
4939
|
-
|
|
5581
|
+
# OPTIONAL persona target + section hint (persona-self-improve) — None for a plain lesson.
|
|
5582
|
+
pm = _PERSONA_TAG_RE.search(unit[0])
|
|
5583
|
+
persona = pm.group(1).strip() if pm else None
|
|
5584
|
+
hint = pm.group(2).strip() if pm else None
|
|
5585
|
+
by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence,
|
|
5586
|
+
"persona": persona, "hint": hint})
|
|
4940
5587
|
return by_comp
|
|
4941
5588
|
|
|
4942
5589
|
|
|
@@ -5062,7 +5709,11 @@ def _select_spec_delta(text: str, match: str | None = None,
|
|
|
5062
5709
|
# literal, both exempt via MACHINE_CONSTANTS.
|
|
5063
5710
|
_FOLD_VERB = "fold" # the subcommand / decision-record verb
|
|
5064
5711
|
_FOLDED = "folded" # the resolved status value
|
|
5065
|
-
|
|
5712
|
+
# group(2) carries any OPTIONAL persona annotation (`· persona:<slug> · <hint>`) so flipping
|
|
5713
|
+
# open->folded preserves it byte-for-byte (persona-self-improve).
|
|
5714
|
+
_COMP_OPEN_TOKEN_RE = re.compile(
|
|
5715
|
+
r"(\[\s*(?:DDD|SDD|UDD|TDD|ADD)\s*·\s*)open"
|
|
5716
|
+
r"(\s*(?:·\s*persona:[^\s·\]]+\s*·\s*[^·\]]+?\s*)?\])")
|
|
5066
5717
|
|
|
5067
5718
|
# competency -> (foundation file, section-heading PREFIX) — fold.md's routing table. DDD/SDD/UDD
|
|
5068
5719
|
# land in PROJECT.md sections; TDD/ADD in CONVENTIONS.md (they ARE the engine). Total over the five.
|
|
@@ -5076,6 +5727,13 @@ _FOLD_ROUTES = {
|
|
|
5076
5727
|
_KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail section (every session adds one row)
|
|
5077
5728
|
_TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
|
|
5078
5729
|
|
|
5730
|
+
# persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
|
|
5731
|
+
# foundation file. The section HINT picks the growable section; only these two are routable.
|
|
5732
|
+
_PERSONA_FOLD_SECTIONS = {
|
|
5733
|
+
"critical-rule": "## Critical Rules",
|
|
5734
|
+
"success-metric": "## Success Metrics",
|
|
5735
|
+
}
|
|
5736
|
+
|
|
5079
5737
|
|
|
5080
5738
|
def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
|
|
5081
5739
|
"""Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
|
|
@@ -5170,11 +5828,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5170
5828
|
prev_v = int(vm.group(1))
|
|
5171
5829
|
new_v = prev_v + 1
|
|
5172
5830
|
|
|
5173
|
-
#
|
|
5831
|
+
# A lesson with a `persona:<slug>` target routes to a persona living doc, NOT a foundation file
|
|
5832
|
+
# (persona-self-improve). Partition first so the foundation-route checks skip persona lessons.
|
|
5833
|
+
persona_sel = [it for it in selected if it.get("persona")]
|
|
5834
|
+
found_sel = [it for it in selected if not it.get("persona")]
|
|
5835
|
+
|
|
5836
|
+
# routing — every FOUNDATION lesson's destination section (and the audit-trail section) must exist.
|
|
5174
5837
|
conventions_md = root / "CONVENTIONS.md"
|
|
5175
5838
|
conventions_text = conventions_md.read_text(encoding="utf-8") if conventions_md.exists() else ""
|
|
5176
5839
|
file_text = {"PROJECT.md": project_text, "CONVENTIONS.md": conventions_text}
|
|
5177
|
-
for it in
|
|
5840
|
+
for it in found_sel:
|
|
5178
5841
|
fname, heading = _FOLD_ROUTES[it["comp"]]
|
|
5179
5842
|
if not _section_present(file_text[fname], heading):
|
|
5180
5843
|
_die(f"missing_route_section: {fname} has no '{heading}' section for a "
|
|
@@ -5183,6 +5846,21 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5183
5846
|
_die(f"missing_route_section: PROJECT.md has no '{_KEY_DECISIONS_HEADING}' "
|
|
5184
5847
|
"section for the audit-trail row — add the section header and re-run")
|
|
5185
5848
|
|
|
5849
|
+
# persona routing — validate fail-closed BEFORE any write (design-for-failure): the section hint
|
|
5850
|
+
# must be routable and the target persona file must exist. No network, no child launch — pure IO.
|
|
5851
|
+
persona_paths: dict[str, Path] = {}
|
|
5852
|
+
for it in persona_sel:
|
|
5853
|
+
hint = (it.get("hint") or "").strip()
|
|
5854
|
+
if hint not in _PERSONA_FOLD_SECTIONS:
|
|
5855
|
+
_die(f"persona_section_unroutable: '{hint or '(none)'}' is not a growable persona "
|
|
5856
|
+
f"section — use one of {', '.join(_PERSONA_FOLD_SECTIONS)}")
|
|
5857
|
+
slug = it["persona"]
|
|
5858
|
+
ppath = root / "personas" / f"{slug}.md"
|
|
5859
|
+
if not _persona_slug_valid(slug) or not ppath.is_file():
|
|
5860
|
+
_die(f"missing_persona_target: no .add/personas/{slug}.md for a persona lesson — "
|
|
5861
|
+
"seed the persona first (setup) or fix the slug")
|
|
5862
|
+
persona_paths[slug] = ppath
|
|
5863
|
+
|
|
5186
5864
|
# ── build EVERY edit in memory before writing anything ──────────────────────────────────────
|
|
5187
5865
|
comps_filter = {want_comp} if want_comp else None
|
|
5188
5866
|
task_new: dict[str, str] = {}
|
|
@@ -5198,15 +5876,40 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5198
5876
|
return (f"- ({it['comp']}) {it['text']}{ev} "
|
|
5199
5877
|
f"[{_FOLDED} foundation-version {new_v} · from {it['task']}]")
|
|
5200
5878
|
|
|
5201
|
-
|
|
5879
|
+
def _persona_bullet(it):
|
|
5880
|
+
ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
|
|
5881
|
+
return (f"- {it['text']}{ev} "
|
|
5882
|
+
f"[{_FOLDED} {date.today().isoformat()} · from {it['task']}]")
|
|
5883
|
+
|
|
5884
|
+
# transcribe FOUNDATION lessons verbatim (reverse so canonical-order first lands on top, newest-first).
|
|
5202
5885
|
proj_text, conv_text = project_text, conventions_text
|
|
5203
|
-
for it in reversed(
|
|
5886
|
+
for it in reversed(found_sel):
|
|
5204
5887
|
fname, heading = _FOLD_ROUTES[it["comp"]]
|
|
5205
5888
|
if fname == "PROJECT.md":
|
|
5206
5889
|
proj_text = _prepend_to_section(proj_text, heading, _bullet(it))
|
|
5207
5890
|
else:
|
|
5208
5891
|
conv_text = _prepend_to_section(conv_text, heading, _bullet(it))
|
|
5209
5892
|
|
|
5893
|
+
# transcribe PERSONA lessons into their living docs — PREPEND newest-first under the hinted
|
|
5894
|
+
# section, NEVER clobbering (every prior persona line survives; the schema stays conformant).
|
|
5895
|
+
from collections import Counter
|
|
5896
|
+
persona_new: dict[str, str] = {}
|
|
5897
|
+
for it in reversed(persona_sel):
|
|
5898
|
+
slug = it["persona"]
|
|
5899
|
+
before = persona_new.get(slug) or persona_paths[slug].read_text(encoding="utf-8")
|
|
5900
|
+
heading = _PERSONA_FOLD_SECTIONS[it["hint"].strip()]
|
|
5901
|
+
if not _section_present(before, heading): # defensive: a conformant persona has it
|
|
5902
|
+
_die(f"persona_section_unroutable: .add/personas/{slug}.md has no '{heading}' section")
|
|
5903
|
+
after = _prepend_to_section(before, heading, _persona_bullet(it))
|
|
5904
|
+
# never-clobber INVARIANT: every pre-existing line must survive the merge.
|
|
5905
|
+
if Counter(before.splitlines()) - Counter(after.splitlines()):
|
|
5906
|
+
_die(f"persona_clobber_forbidden: consolidating into {slug}.md would drop existing content")
|
|
5907
|
+
# post-merge INVARIANT: the four required persona sections survive.
|
|
5908
|
+
if _persona_missing(after):
|
|
5909
|
+
_die(f"persona_clobber_forbidden: consolidating into {slug}.md broke the persona schema "
|
|
5910
|
+
f"(missing {', '.join(_persona_missing(after))})")
|
|
5911
|
+
persona_new[slug] = after
|
|
5912
|
+
|
|
5210
5913
|
counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
|
|
5211
5914
|
count_str = " · ".join(f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c])
|
|
5212
5915
|
scope = "all" if not (want_task or want_comp) else " ".join(
|
|
@@ -5229,6 +5932,10 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5229
5932
|
for slug, body in task_new.items():
|
|
5230
5933
|
writes.append((root / "tasks" / slug / "TASK.md", body))
|
|
5231
5934
|
touched.append(f"{len(task_new)} TASK.md")
|
|
5935
|
+
for slug, body in persona_new.items():
|
|
5936
|
+
writes.append((persona_paths[slug], body))
|
|
5937
|
+
if persona_new:
|
|
5938
|
+
touched.append(f"{len(persona_new)} persona")
|
|
5232
5939
|
_atomic_write_many(writes)
|
|
5233
5940
|
|
|
5234
5941
|
print(f"{_FOLDED} {len(selected)} lessons -> foundation-version {new_v}")
|
|
@@ -5303,8 +6010,14 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
5303
6010
|
# F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
|
|
5304
6011
|
# high-risk record must show a guarded dial AND a human at the gate —
|
|
5305
6012
|
# catches post-gate header tampering and auto-resolved high-risk gates.
|
|
6013
|
+
# advisor-gate-relax: a mechanical task whose Advisor 3-lens verdict shows
|
|
6014
|
+
# PASS + Residue: none is exempt — the outer condition is False so both
|
|
6015
|
+
# sub-branches are skipped. Non-mechanical (security/data/architecture)
|
|
6016
|
+
# never relax; an absent advisor block is fail-safe False → guard fires.
|
|
5306
6017
|
hdr = _task_header(root, slug)
|
|
5307
|
-
if _RISK_HIGH_RE.search(hdr)
|
|
6018
|
+
if _RISK_HIGH_RE.search(hdr) and not (_task_sensitivity(hdr) == "mechanical"
|
|
6019
|
+
and _advisor_verdict_is_pass(s6)
|
|
6020
|
+
and _advisor_no_residue(s6)):
|
|
5308
6021
|
if not _autonomy_lowered(hdr):
|
|
5309
6022
|
f(slug, "unguarded_high_risk_auto",
|
|
5310
6023
|
"risk: high declared but autonomy is not lowered (manual or conservative)")
|
|
@@ -5348,6 +6061,21 @@ def _freeze_skip_notices(state: dict) -> list[dict]:
|
|
|
5348
6061
|
# region only (mirrors _RISK_HIGH_RE's anchoring so a title substring can never look like one).
|
|
5349
6062
|
_RISK_ANY_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*\S", re.MULTILINE)
|
|
5350
6063
|
|
|
6064
|
+
# A single `Reported:` line (report-rendered-trace) — scoped to ONE phase body at a time by the
|
|
6065
|
+
# caller (bodies.get(3, "")/bodies.get(6, "")), so §3 and §6 never cross-contaminate each other.
|
|
6066
|
+
_REPORTED_LINE_RE = re.compile(r"(?m)^Reported:[ \t]*(.*)$")
|
|
6067
|
+
|
|
6068
|
+
|
|
6069
|
+
def _reported_unrecorded(body_text: str) -> bool:
|
|
6070
|
+
"""True iff a 'Reported:' line is PRESENT but still an unfilled `<...>` placeholder or blank.
|
|
6071
|
+
ABSENT line -> False (grandfathered — a pre-existing task's TASK.md predates this template
|
|
6072
|
+
field), mirroring _section_unfilled's absent-block convention."""
|
|
6073
|
+
m = _REPORTED_LINE_RE.search(body_text)
|
|
6074
|
+
if m is None:
|
|
6075
|
+
return False
|
|
6076
|
+
val = m.group(1).strip()
|
|
6077
|
+
return (not val) or bool(re.fullmatch(r"<[^>\n]*>", val))
|
|
6078
|
+
|
|
5351
6079
|
|
|
5352
6080
|
def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
5353
6081
|
"""PRESENCE-ONLY, MEASURE-NOT-BLOCK lints SURFACED (never failed-on) by `add.py audit`
|
|
@@ -5355,24 +6083,95 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
|
5355
6083
|
shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
|
|
5356
6084
|
ABSENT block grandfathers a legacy task — never retro-flagged);
|
|
5357
6085
|
risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
|
|
6086
|
+
sensitivity_unset[]= the header carries NO valid `sensitivity:` token (risk-sensitivity-taxonomy);
|
|
5358
6087
|
refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
|
|
5359
6088
|
refute-record, M4) — the earned-green verdict the AI must record under
|
|
5360
6089
|
`auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
|
|
5361
6090
|
never auto-blocks a gate, only surfaced here for review + a human spot-audit.
|
|
5362
|
-
|
|
6091
|
+
rule_coverage_gap[]= this task's OWN §1 Must/Reject IDs have >=1 rule with no §2 scenario tag
|
|
6092
|
+
and no §4 `covers:` line (_rule_coverage_gaps — same opt-in-by-tag-presence
|
|
6093
|
+
grandfather as `add.py check`'s whole-project sweep) — surfaced the moment
|
|
6094
|
+
THIS task reaches verify, not only via a separate `check` invocation someone
|
|
6095
|
+
has to remember to run; `check` still owns the per-rule detail.
|
|
6096
|
+
contract_report_unrecorded[] = §3's `Reported:` line present-but-unfilled (report-rendered-
|
|
6097
|
+
trace) — the freeze report (banner/ARC/SHAPE) was cited by the guide but
|
|
6098
|
+
never recorded as rendered; ABSENT line grandfathers a pre-field task.
|
|
6099
|
+
verify_report_unrecorded[] = §6's `Reported:` line present-but-unfilled — the gate report
|
|
6100
|
+
(banner/ARC) was never recorded as rendered; same grandfather rule.
|
|
6101
|
+
Honest visibility for six verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
|
|
5363
6102
|
TASK.md + state only, writes nothing."""
|
|
5364
|
-
shallow, risk_unset, refute_unrecorded = [], [], []
|
|
6103
|
+
shallow, risk_unset, refute_unrecorded, sensitivity_unset = [], [], [], []
|
|
6104
|
+
advisor_verdict_unrecorded = []
|
|
6105
|
+
advisor_reviewer_is_author = []
|
|
6106
|
+
advisor_residue_on_mechanical_mis_tier = []
|
|
6107
|
+
rule_coverage_gap = []
|
|
6108
|
+
contract_report_unrecorded = []
|
|
6109
|
+
verify_report_unrecorded = []
|
|
5365
6110
|
for slug in sorted(state.get("tasks") or {}):
|
|
5366
6111
|
if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
|
|
5367
6112
|
continue
|
|
5368
|
-
|
|
6113
|
+
bodies = _raw_phase_bodies(root, slug)
|
|
6114
|
+
body3 = bodies.get(3, "")
|
|
6115
|
+
body6 = bodies.get(6, "")
|
|
6116
|
+
hdr = _task_header(root, slug)
|
|
5369
6117
|
if _section_unfilled(body6, "### Deep checks"):
|
|
5370
6118
|
shallow.append(slug)
|
|
5371
6119
|
if _section_unfilled(body6, "### Refute-read verdict"):
|
|
5372
6120
|
refute_unrecorded.append(slug)
|
|
5373
|
-
if
|
|
6121
|
+
if _section_unfilled(body6, "### Advisor 3-lens verdict"):
|
|
6122
|
+
advisor_verdict_unrecorded.append(slug)
|
|
6123
|
+
if _reported_unrecorded(body3):
|
|
6124
|
+
contract_report_unrecorded.append(slug)
|
|
6125
|
+
if _reported_unrecorded(body6):
|
|
6126
|
+
verify_report_unrecorded.append(slug)
|
|
6127
|
+
if not _RISK_ANY_RE.search(hdr):
|
|
5374
6128
|
risk_unset.append(slug)
|
|
5375
|
-
|
|
6129
|
+
# sensitivity_unset (risk-sensitivity-taxonomy): a verify-reached task with no
|
|
6130
|
+
# human-declared sensitivity — MEASURE-NOT-BLOCK, same class as risk_unset.
|
|
6131
|
+
if _task_sensitivity(hdr) is None:
|
|
6132
|
+
sensitivity_unset.append(slug)
|
|
6133
|
+
# rule_coverage_gap (verify-traceability-glint): this task's own §1 Must/Reject vs
|
|
6134
|
+
# §2/§4 tags — the SAME predicate `check` uses project-wide, scoped to just this task
|
|
6135
|
+
# and surfaced right at verify, not only via a separate whole-project sweep.
|
|
6136
|
+
if _rule_coverage_gaps(bodies.get(1, ""), bodies.get(2, ""), bodies.get(4, "")):
|
|
6137
|
+
rule_coverage_gap.append(slug)
|
|
6138
|
+
# advisor_reviewer_is_author / advisor_residue_on_mechanical_mis_tier
|
|
6139
|
+
# (advisor-verdict-audit): MEASURE-NOT-BLOCK lints on the filled advisor block.
|
|
6140
|
+
# Both require the block to be PRESENT AND FILLED (not just unfilled).
|
|
6141
|
+
# Extract ONLY the advisor sub-section to avoid false matches from
|
|
6142
|
+
# the Refute-read verdict block (which also carries Verdict:/Residue: lines).
|
|
6143
|
+
_advisor_body = ""
|
|
6144
|
+
_adv_idx = body6.find("### Advisor 3-lens verdict")
|
|
6145
|
+
if _adv_idx != -1:
|
|
6146
|
+
_adv_rest = body6[_adv_idx + len("### Advisor 3-lens verdict"):]
|
|
6147
|
+
_next_hdr = re.search(r"\n###", _adv_rest)
|
|
6148
|
+
_advisor_body = _adv_rest[:_next_hdr.start()] if _next_hdr else _adv_rest
|
|
6149
|
+
_advisor_filled = (_adv_idx != -1
|
|
6150
|
+
and not _section_unfilled(body6, "### Advisor 3-lens verdict"))
|
|
6151
|
+
if _advisor_filled:
|
|
6152
|
+
_ma = re.search(r"(?m)^Advisor:(.*)$", _advisor_body)
|
|
6153
|
+
_advisor_name = _ma.group(1).strip() if _ma else ""
|
|
6154
|
+
_gate_name = (state["tasks"][slug].get("gate_actor") or {}).get("name", "").strip()
|
|
6155
|
+
if (_advisor_name and _gate_name
|
|
6156
|
+
and _advisor_name.lower() == _gate_name.lower()):
|
|
6157
|
+
advisor_reviewer_is_author.append(slug)
|
|
6158
|
+
_sens = _task_sensitivity(hdr)
|
|
6159
|
+
if _sens == "mechanical":
|
|
6160
|
+
_mr = re.search(r"(?m)^Residue:(.*)$", _advisor_body)
|
|
6161
|
+
_mv = re.search(r"(?m)^Verdict:(.*)$", _advisor_body)
|
|
6162
|
+
_residue = _mr.group(1).strip() if _mr else ""
|
|
6163
|
+
_verdict = _mv.group(1).strip() if _mv else ""
|
|
6164
|
+
if (_residue and _residue.lower() != "none"
|
|
6165
|
+
and _verdict.upper().startswith("PASS")):
|
|
6166
|
+
advisor_residue_on_mechanical_mis_tier.append(slug)
|
|
6167
|
+
return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded,
|
|
6168
|
+
"sensitivity_unset": sensitivity_unset,
|
|
6169
|
+
"advisor_verdict_unrecorded": advisor_verdict_unrecorded,
|
|
6170
|
+
"advisor_reviewer_is_author": advisor_reviewer_is_author,
|
|
6171
|
+
"advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier,
|
|
6172
|
+
"rule_coverage_gap": rule_coverage_gap,
|
|
6173
|
+
"contract_report_unrecorded": contract_report_unrecorded,
|
|
6174
|
+
"verify_report_unrecorded": verify_report_unrecorded}
|
|
5376
6175
|
|
|
5377
6176
|
|
|
5378
6177
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
@@ -5405,8 +6204,44 @@ def cmd_audit(args: argparse.Namespace) -> None:
|
|
|
5405
6204
|
ru = glints["refute_unrecorded"]
|
|
5406
6205
|
print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
|
|
5407
6206
|
f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
|
|
6207
|
+
if glints["advisor_verdict_unrecorded"]:
|
|
6208
|
+
av = glints["advisor_verdict_unrecorded"]
|
|
6209
|
+
print(f"audit: advisor_verdict_unrecorded — {len(av)} task(s): {', '.join(av)} "
|
|
6210
|
+
f"— record the 3-lens advisor verdict (§6); a spot-audit is the backstop")
|
|
6211
|
+
if glints["rule_coverage_gap"]:
|
|
6212
|
+
rc = glints["rule_coverage_gap"]
|
|
6213
|
+
print(f"audit: rule_coverage_gap — {len(rc)} task(s): {', '.join(rc)} "
|
|
6214
|
+
f"— a §1 Must/Reject has no §2 scenario tag or §4 covers: line "
|
|
6215
|
+
f"(run `add.py check` for the per-rule detail)")
|
|
6216
|
+
if glints["sensitivity_unset"]:
|
|
6217
|
+
su = glints["sensitivity_unset"]
|
|
6218
|
+
print(f"audit: sensitivity_unset — {len(su)} task(s) reached verify with no "
|
|
6219
|
+
f"sensitivity: declaration: {', '.join(su)}")
|
|
6220
|
+
if glints["advisor_reviewer_is_author"]:
|
|
6221
|
+
ar = glints["advisor_reviewer_is_author"]
|
|
6222
|
+
print(f"audit: advisor_reviewer_is_author — {len(ar)} task(s): {', '.join(ar)} "
|
|
6223
|
+
f"— advisor and gate actor are the same identity")
|
|
6224
|
+
if glints["advisor_residue_on_mechanical_mis_tier"]:
|
|
6225
|
+
am = glints["advisor_residue_on_mechanical_mis_tier"]
|
|
6226
|
+
print(f"audit: advisor_residue_on_mechanical_mis_tier — {len(am)} task(s): "
|
|
6227
|
+
f"{', '.join(am)} — mechanical tier with non-none residue and PASS verdict "
|
|
6228
|
+
f"is incoherent; consider re-tiering")
|
|
6229
|
+
if glints["contract_report_unrecorded"]:
|
|
6230
|
+
cr = glints["contract_report_unrecorded"]
|
|
6231
|
+
print(f"audit: contract_report_unrecorded — {len(cr)} task(s): {', '.join(cr)} "
|
|
6232
|
+
f"— record the rendered freeze report (§3 `Reported: yes`); a spot-audit is the backstop")
|
|
6233
|
+
if glints["verify_report_unrecorded"]:
|
|
6234
|
+
vr = glints["verify_report_unrecorded"]
|
|
6235
|
+
print(f"audit: verify_report_unrecorded — {len(vr)} task(s): {', '.join(vr)} "
|
|
6236
|
+
f"— record the rendered gate report (§6 `Reported: yes`); a spot-audit is the backstop")
|
|
5408
6237
|
if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
|
|
5409
|
-
and not glints["refute_unrecorded"]
|
|
6238
|
+
and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
|
|
6239
|
+
and not glints["sensitivity_unset"] \
|
|
6240
|
+
and not glints["advisor_reviewer_is_author"] \
|
|
6241
|
+
and not glints["advisor_residue_on_mechanical_mis_tier"] \
|
|
6242
|
+
and not glints["rule_coverage_gap"] \
|
|
6243
|
+
and not glints["contract_report_unrecorded"] \
|
|
6244
|
+
and not glints["verify_report_unrecorded"]:
|
|
5410
6245
|
print(f"audit: clean ({checked} tasks checked)")
|
|
5411
6246
|
# MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
|
|
5412
6247
|
if findings:
|
|
@@ -5806,8 +6641,23 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5806
6641
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
5807
6642
|
getattr(args, "evidence", None),
|
|
5808
6643
|
identity._render_actor_line(state), loose_bundle))
|
|
5809
|
-
|
|
5810
|
-
|
|
6644
|
+
# milestone-release-backlink: STAMP each bundled milestone's MILESTONE.md `release:` line to
|
|
6645
|
+
# the cut version. Built in memory NOW and appended to the SAME atomic batch as the ledgers,
|
|
6646
|
+
# so the stamp commits all-or-nothing with them (a failed write rolls back everything). Only
|
|
6647
|
+
# `bundle` (closed-and-unreleased) milestones are stamped; a missing/unreadable file is skipped
|
|
6648
|
+
# (degrade-safe — the ledger attribution stays the source of truth).
|
|
6649
|
+
stamp_writes: list[tuple[Path, str]] = []
|
|
6650
|
+
for m in bundle:
|
|
6651
|
+
mfile = root / "milestones" / m["slug"] / "MILESTONE.md"
|
|
6652
|
+
try:
|
|
6653
|
+
_mtxt = mfile.read_text(encoding="utf-8")
|
|
6654
|
+
except OSError:
|
|
6655
|
+
continue
|
|
6656
|
+
_stamped = _set_release_line(_mtxt, args.version)
|
|
6657
|
+
if _stamped != _mtxt:
|
|
6658
|
+
stamp_writes.append((mfile, _stamped))
|
|
6659
|
+
try: # CHANGELOG + RELEASES + milestone stamps: one all-or-nothing commit
|
|
6660
|
+
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)] + stamp_writes)
|
|
5811
6661
|
except OSError as e:
|
|
5812
6662
|
_die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
|
|
5813
6663
|
"files were rolled back to their prior content. Retry the release.")
|
|
@@ -6000,6 +6850,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6000
6850
|
pi.add_argument("--rule-file", dest="rule_file", action="store_true",
|
|
6001
6851
|
help="write the ADD block to .claude/rules/add-workflows.md and reference it "
|
|
6002
6852
|
"from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
|
|
6853
|
+
pi.add_argument("--run-mode", dest="run_mode", default=None,
|
|
6854
|
+
choices=["auto", "conservative"],
|
|
6855
|
+
help="seed autonomy+streams posture: auto→parallel, conservative→sequential "
|
|
6856
|
+
"(absent: PROJECT.md is byte-identical to a plain init)")
|
|
6003
6857
|
pi.set_defaults(func=cmd_init)
|
|
6004
6858
|
|
|
6005
6859
|
pl = sub.add_parser("lock",
|
|
@@ -6128,6 +6982,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6128
6982
|
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
6129
6983
|
pwa.set_defaults(func=cmd_waves)
|
|
6130
6984
|
|
|
6985
|
+
pdp = sub.add_parser("dag-plan", help="record a committed snapshot of the milestone's computed "
|
|
6986
|
+
"DAG schedule (waves + critical path + tiers) + a freshness "
|
|
6987
|
+
"check vs the live depends_on edges")
|
|
6988
|
+
pdp.add_argument("--milestone", default=None,
|
|
6989
|
+
help="milestone slug to snapshot (default: the active milestone)")
|
|
6990
|
+
pdp.set_defaults(func=cmd_dag_plan)
|
|
6991
|
+
|
|
6131
6992
|
pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
|
|
6132
6993
|
pmd.add_argument("slug")
|
|
6133
6994
|
pmd.set_defaults(func=cmd_milestone_done)
|
|
@@ -6198,6 +7059,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6198
7059
|
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
6199
7060
|
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
6200
7061
|
|
|
7062
|
+
pst = sub.add_parser("streams", help="show or set the project streams posture (parallel|sequential)")
|
|
7063
|
+
pst.add_argument("action", nargs="?", choices=("show", "set"), default="show")
|
|
7064
|
+
pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
|
|
7065
|
+
pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
|
|
7066
|
+
|
|
6201
7067
|
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
6202
7068
|
pto.add_argument("text", nargs="?", default=None,
|
|
6203
7069
|
help="todo text to capture; omit to LIST open todos")
|
|
@@ -6228,6 +7094,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6228
7094
|
|
|
6229
7095
|
pst = sub.add_parser("status", help="print where the project is (resume point)")
|
|
6230
7096
|
pst.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
7097
|
+
pst.add_argument("--task", metavar="SLUG", help="with --json, filter to one task's "
|
|
7098
|
+
"{slug, phase, gate, milestone, owner, assignee} object")
|
|
7099
|
+
pst.add_argument("--all", action="store_true", help="show every milestone/task "
|
|
7100
|
+
"(default: top 10 by most-recently-updated)")
|
|
6231
7101
|
pst.set_defaults(func=cmd_status)
|
|
6232
7102
|
|
|
6233
7103
|
pck = sub.add_parser("check", help="read-only integrity check of the .add project")
|
|
@@ -6239,6 +7109,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6239
7109
|
"(.add/components.toml) — RED integrity errors + schema-lint typo warnings")
|
|
6240
7110
|
pcomp.set_defaults(func=cmd_components)
|
|
6241
7111
|
|
|
7112
|
+
psrch = sub.add_parser("search", help="keyword/substring search over the "
|
|
7113
|
+
"milestone/task corpus (active + archived) — "
|
|
7114
|
+
"title/goal/rationale lines only, never the full body")
|
|
7115
|
+
psrch.add_argument("keywords", nargs="+", metavar="KEYWORD",
|
|
7116
|
+
help="one or more keywords (case-insensitive substring, OR-combined)")
|
|
7117
|
+
psrch.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
7118
|
+
psrch.set_defaults(func=cmd_search)
|
|
7119
|
+
|
|
6242
7120
|
pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
|
|
6243
7121
|
"contract snapshot into this repo (fail-loud)")
|
|
6244
7122
|
pfedsub = pfed.add_subparsers(dest="action", required=True)
|