@pilotspace/add 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -1
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/bin/cli.js +37 -11
- 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/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 +3 -1
- package/personas-teacher/LICENSE +21 -0
- package/personas-teacher/README.md +1067 -0
- package/personas-teacher/VENDOR.md +13 -0
- package/personas-teacher/academic/academic-anthropologist.md +125 -0
- package/personas-teacher/academic/academic-geographer.md +127 -0
- package/personas-teacher/academic/academic-historian.md +123 -0
- package/personas-teacher/academic/academic-narratologist.md +118 -0
- package/personas-teacher/academic/academic-psychologist.md +118 -0
- package/personas-teacher/design/design-brand-guardian.md +322 -0
- package/personas-teacher/design/design-image-prompt-engineer.md +236 -0
- package/personas-teacher/design/design-inclusive-visuals-specialist.md +71 -0
- package/personas-teacher/design/design-persona-walkthrough.md +272 -0
- package/personas-teacher/design/design-ui-designer.md +383 -0
- package/personas-teacher/design/design-ux-architect.md +469 -0
- package/personas-teacher/design/design-ux-researcher.md +329 -0
- package/personas-teacher/design/design-visual-storyteller.md +149 -0
- package/personas-teacher/design/design-whimsy-injector.md +438 -0
- package/personas-teacher/divisions.json +21 -0
- package/personas-teacher/engineering/engineering-ai-data-remediation-engineer.md +211 -0
- package/personas-teacher/engineering/engineering-ai-engineer.md +146 -0
- package/personas-teacher/engineering/engineering-autonomous-optimization-architect.md +107 -0
- package/personas-teacher/engineering/engineering-backend-architect.md +236 -0
- package/personas-teacher/engineering/engineering-cms-developer.md +536 -0
- package/personas-teacher/engineering/engineering-code-reviewer.md +76 -0
- package/personas-teacher/engineering/engineering-codebase-onboarding-engineer.md +173 -0
- package/personas-teacher/engineering/engineering-data-engineer.md +306 -0
- package/personas-teacher/engineering/engineering-database-optimizer.md +176 -0
- package/personas-teacher/engineering/engineering-devops-automator.md +376 -0
- package/personas-teacher/engineering/engineering-drupal-shopping-cart.md +360 -0
- package/personas-teacher/engineering/engineering-email-intelligence-engineer.md +353 -0
- package/personas-teacher/engineering/engineering-embedded-firmware-engineer.md +173 -0
- package/personas-teacher/engineering/engineering-feishu-integration-developer.md +598 -0
- package/personas-teacher/engineering/engineering-filament-optimization-specialist.md +283 -0
- package/personas-teacher/engineering/engineering-frontend-developer.md +225 -0
- package/personas-teacher/engineering/engineering-git-workflow-master.md +84 -0
- package/personas-teacher/engineering/engineering-incident-response-commander.md +444 -0
- package/personas-teacher/engineering/engineering-it-service-manager.md +561 -0
- package/personas-teacher/engineering/engineering-minimal-change-engineer.md +207 -0
- package/personas-teacher/engineering/engineering-mobile-app-builder.md +493 -0
- package/personas-teacher/engineering/engineering-multi-agent-systems-architect.md +600 -0
- package/personas-teacher/engineering/engineering-orgscript-engineer.md +113 -0
- package/personas-teacher/engineering/engineering-prompt-engineer.md +202 -0
- package/personas-teacher/engineering/engineering-rapid-prototyper.md +462 -0
- package/personas-teacher/engineering/engineering-senior-developer.md +176 -0
- package/personas-teacher/engineering/engineering-software-architect.md +112 -0
- package/personas-teacher/engineering/engineering-solidity-smart-contract-engineer.md +522 -0
- package/personas-teacher/engineering/engineering-sre.md +90 -0
- package/personas-teacher/engineering/engineering-technical-writer.md +393 -0
- package/personas-teacher/engineering/engineering-voice-ai-integration-engineer.md +561 -0
- package/personas-teacher/engineering/engineering-wechat-mini-program-developer.md +350 -0
- package/personas-teacher/engineering/engineering-wordpress-shopping-cart.md +346 -0
- package/personas-teacher/examples/README.md +48 -0
- package/personas-teacher/examples/nexus-spatial-discovery.md +852 -0
- package/personas-teacher/examples/workflow-book-chapter.md +55 -0
- package/personas-teacher/examples/workflow-landing-page.md +119 -0
- package/personas-teacher/examples/workflow-startup-mvp.md +155 -0
- package/personas-teacher/examples/workflow-with-memory.md +238 -0
- package/personas-teacher/finance/finance-bookkeeper-controller.md +260 -0
- package/personas-teacher/finance/finance-financial-analyst.md +234 -0
- package/personas-teacher/finance/finance-fpa-analyst.md +263 -0
- package/personas-teacher/finance/finance-investment-researcher.md +272 -0
- package/personas-teacher/finance/finance-tax-strategist.md +239 -0
- package/personas-teacher/game-development/blender/blender-addon-engineer.md +234 -0
- package/personas-teacher/game-development/game-audio-engineer.md +264 -0
- package/personas-teacher/game-development/game-designer.md +167 -0
- package/personas-teacher/game-development/godot/godot-gameplay-scripter.md +334 -0
- package/personas-teacher/game-development/godot/godot-multiplayer-engineer.md +297 -0
- package/personas-teacher/game-development/godot/godot-shader-developer.md +266 -0
- package/personas-teacher/game-development/level-designer.md +208 -0
- package/personas-teacher/game-development/narrative-designer.md +243 -0
- package/personas-teacher/game-development/roblox-studio/roblox-avatar-creator.md +297 -0
- package/personas-teacher/game-development/roblox-studio/roblox-experience-designer.md +305 -0
- package/personas-teacher/game-development/roblox-studio/roblox-systems-scripter.md +325 -0
- package/personas-teacher/game-development/technical-artist.md +229 -0
- package/personas-teacher/game-development/unity/unity-architect.md +271 -0
- package/personas-teacher/game-development/unity/unity-editor-tool-developer.md +310 -0
- package/personas-teacher/game-development/unity/unity-multiplayer-engineer.md +321 -0
- package/personas-teacher/game-development/unity/unity-shader-graph-artist.md +269 -0
- package/personas-teacher/game-development/unreal-engine/unreal-multiplayer-architect.md +313 -0
- package/personas-teacher/game-development/unreal-engine/unreal-systems-engineer.md +310 -0
- package/personas-teacher/game-development/unreal-engine/unreal-technical-artist.md +256 -0
- package/personas-teacher/game-development/unreal-engine/unreal-world-builder.md +273 -0
- package/personas-teacher/gis/gis-3d-scene-developer.md +111 -0
- package/personas-teacher/gis/gis-analyst.md +91 -0
- package/personas-teacher/gis/gis-bim-specialist.md +108 -0
- package/personas-teacher/gis/gis-cartography-designer.md +150 -0
- package/personas-teacher/gis/gis-drone-reality-mapping.md +120 -0
- package/personas-teacher/gis/gis-geoai-ml-engineer.md +105 -0
- package/personas-teacher/gis/gis-geoprocessing-specialist.md +97 -0
- package/personas-teacher/gis/gis-qa-engineer.md +133 -0
- package/personas-teacher/gis/gis-solution-engineer.md +101 -0
- package/personas-teacher/gis/gis-spatial-data-engineer.md +97 -0
- package/personas-teacher/gis/gis-spatial-data-scientist.md +111 -0
- package/personas-teacher/gis/gis-technical-consultant.md +86 -0
- package/personas-teacher/gis/gis-web-gis-developer.md +108 -0
- package/personas-teacher/marketing/marketing-aeo-foundations.md +264 -0
- package/personas-teacher/marketing/marketing-agentic-search-optimizer.md +311 -0
- package/personas-teacher/marketing/marketing-ai-citation-strategist.md +170 -0
- package/personas-teacher/marketing/marketing-app-store-optimizer.md +321 -0
- package/personas-teacher/marketing/marketing-baidu-seo-specialist.md +226 -0
- package/personas-teacher/marketing/marketing-bilibili-content-strategist.md +199 -0
- package/personas-teacher/marketing/marketing-book-co-author.md +110 -0
- package/personas-teacher/marketing/marketing-carousel-growth-engine.md +199 -0
- package/personas-teacher/marketing/marketing-china-ecommerce-operator.md +283 -0
- package/personas-teacher/marketing/marketing-china-market-localization-strategist.md +283 -0
- package/personas-teacher/marketing/marketing-content-creator.md +54 -0
- package/personas-teacher/marketing/marketing-cross-border-ecommerce.md +259 -0
- package/personas-teacher/marketing/marketing-douyin-strategist.md +149 -0
- package/personas-teacher/marketing/marketing-email-strategist.md +249 -0
- package/personas-teacher/marketing/marketing-global-podcast-strategist.md +206 -0
- package/personas-teacher/marketing/marketing-growth-hacker.md +54 -0
- package/personas-teacher/marketing/marketing-instagram-curator.md +113 -0
- package/personas-teacher/marketing/marketing-kuaishou-strategist.md +223 -0
- package/personas-teacher/marketing/marketing-linkedin-content-creator.md +214 -0
- package/personas-teacher/marketing/marketing-livestream-commerce-coach.md +305 -0
- package/personas-teacher/marketing/marketing-multi-platform-publisher.md +217 -0
- package/personas-teacher/marketing/marketing-podcast-strategist.md +277 -0
- package/personas-teacher/marketing/marketing-pr-communications-manager.md +473 -0
- package/personas-teacher/marketing/marketing-private-domain-operator.md +308 -0
- package/personas-teacher/marketing/marketing-reddit-community-builder.md +123 -0
- package/personas-teacher/marketing/marketing-seo-specialist.md +321 -0
- package/personas-teacher/marketing/marketing-short-video-editing-coach.md +412 -0
- package/personas-teacher/marketing/marketing-social-media-strategist.md +125 -0
- package/personas-teacher/marketing/marketing-tiktok-strategist.md +125 -0
- package/personas-teacher/marketing/marketing-twitter-engager.md +126 -0
- package/personas-teacher/marketing/marketing-video-optimization-specialist.md +119 -0
- package/personas-teacher/marketing/marketing-wechat-official-account.md +145 -0
- package/personas-teacher/marketing/marketing-weibo-strategist.md +240 -0
- package/personas-teacher/marketing/marketing-x-twitter-intelligence-analyst.md +161 -0
- package/personas-teacher/marketing/marketing-xiaohongshu-specialist.md +138 -0
- package/personas-teacher/marketing/marketing-zhihu-strategist.md +162 -0
- package/personas-teacher/paid-media/paid-media-auditor.md +71 -0
- package/personas-teacher/paid-media/paid-media-creative-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-paid-social-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-ppc-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-programmatic-buyer.md +71 -0
- package/personas-teacher/paid-media/paid-media-search-query-analyst.md +71 -0
- package/personas-teacher/paid-media/paid-media-tracking-specialist.md +71 -0
- package/personas-teacher/product/product-behavioral-nudge-engine.md +80 -0
- package/personas-teacher/product/product-feedback-synthesizer.md +119 -0
- package/personas-teacher/product/product-manager.md +469 -0
- package/personas-teacher/product/product-sprint-prioritizer.md +154 -0
- package/personas-teacher/product/product-trend-researcher.md +159 -0
- package/personas-teacher/project-management/project-management-experiment-tracker.md +198 -0
- package/personas-teacher/project-management/project-management-jira-workflow-steward.md +230 -0
- package/personas-teacher/project-management/project-management-meeting-notes-specialist.md +95 -0
- package/personas-teacher/project-management/project-management-project-shepherd.md +194 -0
- package/personas-teacher/project-management/project-management-studio-operations.md +200 -0
- package/personas-teacher/project-management/project-management-studio-producer.md +203 -0
- package/personas-teacher/project-management/project-manager-senior.md +135 -0
- package/personas-teacher/sales/sales-account-strategist.md +227 -0
- package/personas-teacher/sales/sales-coach.md +271 -0
- package/personas-teacher/sales/sales-deal-strategist.md +180 -0
- package/personas-teacher/sales/sales-discovery-coach.md +225 -0
- package/personas-teacher/sales/sales-engineer.md +182 -0
- package/personas-teacher/sales/sales-offer-lead-gen-strategist.md +257 -0
- package/personas-teacher/sales/sales-outbound-strategist.md +201 -0
- package/personas-teacher/sales/sales-pipeline-analyst.md +267 -0
- package/personas-teacher/sales/sales-proposal-strategist.md +217 -0
- package/personas-teacher/security/security-appsec-engineer.md +491 -0
- package/personas-teacher/security/security-architect.md +304 -0
- package/personas-teacher/security/security-blockchain-security-auditor.md +463 -0
- package/personas-teacher/security/security-cloud-security-architect.md +523 -0
- package/personas-teacher/security/security-compliance-auditor.md +158 -0
- package/personas-teacher/security/security-incident-responder.md +437 -0
- package/personas-teacher/security/security-penetration-tester.md +399 -0
- package/personas-teacher/security/security-senior-secops.md +750 -0
- package/personas-teacher/security/security-threat-detection-engineer.md +534 -0
- package/personas-teacher/security/security-threat-intelligence-analyst.md +644 -0
- package/personas-teacher/spatial-computing/macos-spatial-metal-engineer.md +337 -0
- package/personas-teacher/spatial-computing/terminal-integration-specialist.md +70 -0
- package/personas-teacher/spatial-computing/visionos-spatial-engineer.md +54 -0
- package/personas-teacher/spatial-computing/xr-cockpit-interaction-specialist.md +32 -0
- package/personas-teacher/spatial-computing/xr-immersive-developer.md +32 -0
- package/personas-teacher/spatial-computing/xr-interface-architect.md +32 -0
- package/personas-teacher/specialized/accounts-payable-agent.md +185 -0
- package/personas-teacher/specialized/agentic-identity-trust.md +387 -0
- package/personas-teacher/specialized/agents-orchestrator.md +367 -0
- package/personas-teacher/specialized/automation-governance-architect.md +216 -0
- package/personas-teacher/specialized/business-strategist.md +488 -0
- package/personas-teacher/specialized/change-management-consultant.md +497 -0
- package/personas-teacher/specialized/chief-financial-officer.md +388 -0
- package/personas-teacher/specialized/corporate-training-designer.md +192 -0
- package/personas-teacher/specialized/customer-service.md +398 -0
- package/personas-teacher/specialized/customer-success-manager.md +460 -0
- package/personas-teacher/specialized/data-consolidation-agent.md +60 -0
- package/personas-teacher/specialized/data-privacy-officer.md +412 -0
- package/personas-teacher/specialized/esg-sustainability-officer.md +396 -0
- package/personas-teacher/specialized/government-digital-presales-consultant.md +363 -0
- package/personas-teacher/specialized/grant-writer.md +511 -0
- package/personas-teacher/specialized/healthcare-customer-service.md +389 -0
- package/personas-teacher/specialized/healthcare-marketing-compliance.md +395 -0
- package/personas-teacher/specialized/hospitality-guest-services.md +603 -0
- package/personas-teacher/specialized/hr-onboarding.md +451 -0
- package/personas-teacher/specialized/identity-graph-operator.md +260 -0
- package/personas-teacher/specialized/language-translator.md +264 -0
- package/personas-teacher/specialized/legal-billing-time-tracking.md +569 -0
- package/personas-teacher/specialized/legal-client-intake.md +492 -0
- package/personas-teacher/specialized/legal-document-review.md +454 -0
- package/personas-teacher/specialized/loan-officer-assistant.md +555 -0
- package/personas-teacher/specialized/lsp-index-engineer.md +314 -0
- package/personas-teacher/specialized/ma-integration-manager.md +427 -0
- package/personas-teacher/specialized/medical-billing-coding-specialist.md +491 -0
- package/personas-teacher/specialized/operations-manager.md +399 -0
- package/personas-teacher/specialized/organizational-psychologist.md +391 -0
- package/personas-teacher/specialized/personal-growth-mentor.md +159 -0
- package/personas-teacher/specialized/real-estate-buyer-seller.md +596 -0
- package/personas-teacher/specialized/recruitment-specialist.md +509 -0
- package/personas-teacher/specialized/report-distribution-agent.md +65 -0
- package/personas-teacher/specialized/retail-customer-returns.md +566 -0
- package/personas-teacher/specialized/sales-data-extraction-agent.md +67 -0
- package/personas-teacher/specialized/sales-outreach.md +425 -0
- package/personas-teacher/specialized/specialized-chief-of-staff.md +279 -0
- package/personas-teacher/specialized/specialized-civil-engineer.md +356 -0
- package/personas-teacher/specialized/specialized-cultural-intelligence-strategist.md +88 -0
- package/personas-teacher/specialized/specialized-developer-advocate.md +317 -0
- package/personas-teacher/specialized/specialized-document-generator.md +55 -0
- package/personas-teacher/specialized/specialized-french-consulting-market.md +192 -0
- package/personas-teacher/specialized/specialized-korean-business-navigator.md +216 -0
- package/personas-teacher/specialized/specialized-mcp-builder.md +248 -0
- package/personas-teacher/specialized/specialized-model-qa.md +488 -0
- package/personas-teacher/specialized/specialized-pricing-analyst.md +243 -0
- package/personas-teacher/specialized/specialized-salesforce-architect.md +180 -0
- package/personas-teacher/specialized/specialized-strategy-duel-agent.md +130 -0
- package/personas-teacher/specialized/specialized-workflow-architect.md +597 -0
- package/personas-teacher/specialized/study-abroad-advisor.md +282 -0
- package/personas-teacher/specialized/supply-chain-strategist.md +582 -0
- package/personas-teacher/specialized/zk-steward.md +211 -0
- package/personas-teacher/strategy/EXECUTIVE-BRIEF.md +95 -0
- package/personas-teacher/strategy/QUICKSTART.md +194 -0
- package/personas-teacher/strategy/coordination/agent-activation-prompts.md +401 -0
- package/personas-teacher/strategy/coordination/handoff-templates.md +357 -0
- package/personas-teacher/strategy/nexus-strategy.md +1110 -0
- package/personas-teacher/strategy/playbooks/phase-0-discovery.md +178 -0
- package/personas-teacher/strategy/playbooks/phase-1-strategy.md +238 -0
- package/personas-teacher/strategy/playbooks/phase-2-foundation.md +278 -0
- package/personas-teacher/strategy/playbooks/phase-3-build.md +286 -0
- package/personas-teacher/strategy/playbooks/phase-4-hardening.md +332 -0
- package/personas-teacher/strategy/playbooks/phase-5-launch.md +277 -0
- package/personas-teacher/strategy/playbooks/phase-6-operate.md +318 -0
- package/personas-teacher/strategy/runbooks/scenario-enterprise-feature.md +157 -0
- package/personas-teacher/strategy/runbooks/scenario-incident-response.md +217 -0
- package/personas-teacher/strategy/runbooks/scenario-marketing-campaign.md +187 -0
- package/personas-teacher/strategy/runbooks/scenario-startup-mvp.md +154 -0
- package/personas-teacher/support/support-analytics-reporter.md +365 -0
- package/personas-teacher/support/support-executive-summary-generator.md +212 -0
- package/personas-teacher/support/support-finance-tracker.md +442 -0
- package/personas-teacher/support/support-infrastructure-maintainer.md +618 -0
- package/personas-teacher/support/support-legal-compliance-checker.md +588 -0
- package/personas-teacher/support/support-support-responder.md +585 -0
- package/personas-teacher/testing/testing-accessibility-auditor.md +316 -0
- package/personas-teacher/testing/testing-api-tester.md +306 -0
- package/personas-teacher/testing/testing-evidence-collector.md +210 -0
- package/personas-teacher/testing/testing-performance-benchmarker.md +268 -0
- package/personas-teacher/testing/testing-reality-checker.md +236 -0
- package/personas-teacher/testing/testing-test-results-analyzer.md +305 -0
- package/personas-teacher/testing/testing-tool-evaluator.md +394 -0
- package/personas-teacher/testing/testing-workflow-optimizer.md +450 -0
- package/personas-teacher/tools.json +19 -0
- package/skill/add/SKILL.md +17 -12
- package/skill/add/advisor.md +31 -34
- package/skill/add/confidence.md +1 -1
- package/skill/add/deltas.md +12 -13
- package/skill/add/design.md +52 -49
- package/skill/add/fold.md +18 -16
- package/skill/add/intake.md +4 -3
- package/skill/add/loop.md +1 -2
- package/skill/add/phases/0-ground.md +32 -34
- package/skill/add/phases/0-setup.md +22 -21
- package/skill/add/phases/1-specify.md +8 -6
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/4-tests.md +9 -10
- package/skill/add/phases/5-build.md +17 -13
- package/skill/add/phases/6-verify.md +7 -4
- package/skill/add/phases/7-observe.md +2 -1
- package/skill/add/report-template.md +61 -18
- package/skill/add/run.md +4 -3
- package/skill/add/scope.md +2 -2
- package/skill/add/sensitivity.md +51 -0
- package/skill/add/streams.md +24 -23
- package/tooling/add.py +822 -26
- 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 +36 -6
- package/tooling/templates/gitignore.tmpl +12 -0
- package/tooling/templates/personas/_template.md.tmpl +60 -0
package/tooling/add.py
CHANGED
|
@@ -36,8 +36,11 @@ from add_engine.constants import ( # the _-prefixed names (import * skips them)
|
|
|
36
36
|
_GITIGNORE_BODY, _GUIDE_BEGIN, _GUIDE_END,
|
|
37
37
|
_RULE_REF_LINE, _FALLBACK_TASK, _FALLBACK_TASK_FAST,
|
|
38
38
|
_DEFAULT_WIDTH,
|
|
39
|
-
_DELTA_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
|
|
39
|
+
_DELTA_RE, _PERSONA_TAG_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
|
|
40
|
+
_SEED_POINTER_RE, # shared (delta-task-backlink) — reads the `[→ slug]` seed stamp back
|
|
40
41
|
_AUTONOMY_LEVELS, # shared (autonomy resolvers + _AUTONOMY_ORDER/cmd_autonomy)
|
|
42
|
+
_STREAMS_POSTURES, # shared (streams resolvers + cmd_streams) — run-mode streams half
|
|
43
|
+
_SENSITIVITY_VALUES, # shared (_task_sensitivity + cmd_freeze/status/audit) — risk-class taxonomy
|
|
41
44
|
)
|
|
42
45
|
|
|
43
46
|
# --- terminal-render primitives (moved to add_engine/render.py) -------------
|
|
@@ -78,8 +81,12 @@ from add_engine.taskdoc import (
|
|
|
78
81
|
# --- autonomy-level resolvers (moved to add_engine/autonomy.py) -------------
|
|
79
82
|
from add_engine.autonomy import (
|
|
80
83
|
_autonomy_level, _effective_autonomy, _project_autonomy, _project_autonomy_token,
|
|
84
|
+
_project_streams, # run-mode streams half (persist-run-mode) — read live from PROJECT.md
|
|
81
85
|
)
|
|
82
86
|
|
|
87
|
+
# --- keyword/substring corpus search (NEW — add_engine/search.py) -----------
|
|
88
|
+
from add_engine.search import _search_corpus
|
|
89
|
+
|
|
83
90
|
|
|
84
91
|
def _phase_index(name: str) -> int:
|
|
85
92
|
"""Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
|
|
@@ -150,10 +157,151 @@ def _render_template(name: str, **subs: str) -> str:
|
|
|
150
157
|
text = text.replace("{{" + key + "}}", val)
|
|
151
158
|
return text
|
|
152
159
|
|
|
160
|
+
|
|
161
|
+
# --- TASK.md milestone backlink (task-milestone-backlink) --------------------
|
|
162
|
+
# The task↔milestone link is mirrored into the TASK.md header so the file names its
|
|
163
|
+
# own parent. The engine WRITES it (new-task) and MAINTAINS it (set-milestone); a
|
|
164
|
+
# milestone-free task reads the "(none)" sentinel, never blank. Keeping it engine-owned
|
|
165
|
+
# is what makes it drift-proof — `check` flags a hand-edited line that disagrees.
|
|
166
|
+
_MILESTONE_BACKLINK = "(none)"
|
|
167
|
+
_MILESTONE_LINE_RE = re.compile(r"(?m)^milestone:[^\n]*$")
|
|
168
|
+
_SLUG_LINE_RE = re.compile(r"(?m)^slug:[^\n]*$")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _milestone_backlink_value(milestone) -> str:
|
|
172
|
+
"""The header value for a milestone slug (or the sentinel when milestone-free)."""
|
|
173
|
+
return milestone if milestone else _MILESTONE_BACKLINK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _set_milestone_line(text: str, value: str) -> str:
|
|
177
|
+
"""Rewrite (or insert) the TASK.md header `milestone:` backlink — idempotent.
|
|
178
|
+
|
|
179
|
+
A grandfathered file lacking the line gets it inserted right after `slug:`; with no
|
|
180
|
+
slug line either, the text is returned unchanged (degrade-safe — never corrupts a doc).
|
|
181
|
+
"""
|
|
182
|
+
line = f"milestone: {value}"
|
|
183
|
+
if _MILESTONE_LINE_RE.search(text):
|
|
184
|
+
return _MILESTONE_LINE_RE.sub(lambda _m: line, text, count=1)
|
|
185
|
+
m = _SLUG_LINE_RE.search(text)
|
|
186
|
+
if not m:
|
|
187
|
+
return text
|
|
188
|
+
return text[:m.end()] + "\n" + line + text[m.end():]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read_milestone_line(text: str):
|
|
192
|
+
"""The current `milestone:` backlink value in a TASK.md header, or None if absent."""
|
|
193
|
+
m = _MILESTONE_LINE_RE.search(text)
|
|
194
|
+
return m.group(0)[len("milestone:"):].strip() if m else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- MILESTONE.md release backlink (milestone-release-backlink) --------------
|
|
198
|
+
# The milestone↔release link is mirrored into the MILESTONE.md header: the template seeds
|
|
199
|
+
# `release: pending`; cmd_release STAMPS it to the cut version (the stamp rides the same
|
|
200
|
+
# all-or-nothing batch as CHANGELOG + RELEASES). The mirror of _set_milestone_line, one
|
|
201
|
+
# scope level up — keying on `^release:`, inserting after the `stage:` line if absent.
|
|
202
|
+
_RELEASE_LINE_RE = re.compile(r"(?m)^release:[^\n]*$")
|
|
203
|
+
_STAGE_LINE_RE = re.compile(r"(?m)^stage:[^\n]*$")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _set_release_line(text: str, value: str) -> str:
|
|
207
|
+
"""Rewrite (or insert) the MILESTONE.md header `release:` backlink — idempotent.
|
|
208
|
+
|
|
209
|
+
A grandfathered file lacking the line gets it inserted right after the `stage:` line;
|
|
210
|
+
with no stage line either, the text is returned unchanged (degrade-safe)."""
|
|
211
|
+
line = f"release: {value}"
|
|
212
|
+
if _RELEASE_LINE_RE.search(text):
|
|
213
|
+
return _RELEASE_LINE_RE.sub(lambda _m: line, text, count=1)
|
|
214
|
+
m = _STAGE_LINE_RE.search(text)
|
|
215
|
+
if not m:
|
|
216
|
+
return text
|
|
217
|
+
return text[:m.end()] + "\n" + line + text[m.end():]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- §0 GROUND drift anchor (ground-anchor-sha) -----------------------------
|
|
221
|
+
# §0 line numbers rot during BUILD while symbols survive (PR40 audit). The engine SEEDS a
|
|
222
|
+
# `Ground SHA:` field (the AI fills it via git — NO-EXEC: add.py never shells out) and `check`
|
|
223
|
+
# WARNs when a §0 cites bare line numbers without one, so drift is detectable not silent.
|
|
224
|
+
_GROUND_SHA_RE = re.compile(r"(?m)^Ground SHA:[ \t]*(.*?)[ \t]*$")
|
|
225
|
+
_LINE_REF_RE = re.compile(r"l\.\d+")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _ground_section(text: str) -> str:
|
|
229
|
+
"""The §0 GROUND block of a TASK.md — from the `## 0` heading to the next `## ` heading."""
|
|
230
|
+
m = re.search(r"(?m)^## 0\b", text)
|
|
231
|
+
if not m:
|
|
232
|
+
return ""
|
|
233
|
+
rest = text[m.end():]
|
|
234
|
+
nxt = re.search(r"(?m)^## ", rest)
|
|
235
|
+
return rest[:nxt.start()] if nxt else rest
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_ground_sha(text: str):
|
|
239
|
+
"""The §0 `Ground SHA:` value, or None if absent or still a `<…>` placeholder."""
|
|
240
|
+
m = _GROUND_SHA_RE.search(_ground_section(text))
|
|
241
|
+
if not m:
|
|
242
|
+
return None
|
|
243
|
+
val = m.group(1).strip()
|
|
244
|
+
return None if (not val or val.startswith("<")) else val
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _ground_cites_line_ref(text: str) -> bool:
|
|
248
|
+
"""True iff the §0 GROUND block cites a bare line number (the `l.NNN` idiom)."""
|
|
249
|
+
return bool(_LINE_REF_RE.search(_ground_section(text)))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _seeded_delta_pointers(text: str) -> list[str]:
|
|
253
|
+
"""The task slugs `[SPEC · seeded] … [→ <slug>]` lines point at (delta-task-backlink). PURE.
|
|
254
|
+
|
|
255
|
+
Walks the delta→task lineage backward: each seeded SPEC delta carries the slug it was seeded
|
|
256
|
+
into (the `[→ <slug>]` stamp `_resolve_spec_delta` appends). `check` flags a pointer that no
|
|
257
|
+
longer resolves to a live or archived task. Order-preserving; open/dropped deltas are ignored."""
|
|
258
|
+
out: list[str] = []
|
|
259
|
+
for ln in text.splitlines():
|
|
260
|
+
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
261
|
+
if not m or m.group(2) != "seeded":
|
|
262
|
+
continue
|
|
263
|
+
p = _SEED_POINTER_RE.search(m.group(3))
|
|
264
|
+
if p:
|
|
265
|
+
out.append(p.group(1))
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --- tidy a closed TASK.md (strip-scaffold-at-done) --------------------------
|
|
270
|
+
# A live TASK.md carries `<!-- … -->` instruction comments that guide the active phase; once the
|
|
271
|
+
# task is `done` they are dead weight (PR40 audit). cmd_gate strips them on a COMPLETING gate.
|
|
272
|
+
# Content-safe: fenced code blocks (```…```, incl. the frozen §3) pass through BYTE-EXACT — only
|
|
273
|
+
# comments OUTSIDE a fence are removed; idempotent.
|
|
274
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
275
|
+
_TRAILING_WS_RE = re.compile(r"(?m)[ \t]+$")
|
|
276
|
+
_BLANK_RUN_RE = re.compile(r"\n{3,}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _strip_live_scaffold(text: str) -> str:
|
|
280
|
+
"""Remove `<!-- … -->` instruction comments from a TASK.md — fences untouched, idempotent.
|
|
281
|
+
|
|
282
|
+
Splits on fenced code blocks so a comment inside a ``` fence (e.g. the frozen §3) is never
|
|
283
|
+
touched; in the non-fence segments it drops comment spans, trims the trailing whitespace a
|
|
284
|
+
removal leaves on a line, and collapses 3+ consecutive newlines to one blank line."""
|
|
285
|
+
segs = re.split(r"(```.*?```)", text, flags=re.DOTALL)
|
|
286
|
+
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
287
|
+
s = _HTML_COMMENT_RE.sub("", segs[i])
|
|
288
|
+
s = _TRAILING_WS_RE.sub("", s)
|
|
289
|
+
segs[i] = _BLANK_RUN_RE.sub("\n\n", s)
|
|
290
|
+
return "".join(segs)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _contract_fingerprint(raw3: str) -> str:
|
|
294
|
+
"""md5 of the §3 CONTRACT CONTENT — comment-normalized + outer-whitespace-canonical (the
|
|
295
|
+
instruction comment is scaffolding, not contract). Used on BOTH tamper-guard sides
|
|
296
|
+
(_tripwire_snapshot + _tripwire_divergence) so the at-done strip — which removes the §3
|
|
297
|
+
comment and shifts the section's boundary whitespace — never reads as `contract_tampered`,
|
|
298
|
+
while a real fenced-shape edit still does."""
|
|
299
|
+
return _md5_text(_strip_live_scaffold(raw3).strip())
|
|
300
|
+
|
|
153
301
|
# --- state/markdown predicates (moved to add_engine/predicates.py) -----------
|
|
154
302
|
from add_engine.predicates import (
|
|
155
303
|
_phase_owner, _setup_locked, _milestone_confirmed, _section_unfilled,
|
|
156
|
-
_task_done,
|
|
304
|
+
_task_done, _persona_missing, _persona_slug_valid, _rule_coverage_gaps,
|
|
157
305
|
)
|
|
158
306
|
|
|
159
307
|
# --- git-native identity/actor seam (moved to add_engine/identity.py) --------
|
|
@@ -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
|
|
@@ -3215,6 +3725,88 @@ def cmd_waves(args: argparse.Namespace) -> None:
|
|
|
3215
3725
|
print("\n".join(_wave_block_lines(state, t, s)))
|
|
3216
3726
|
|
|
3217
3727
|
|
|
3728
|
+
# --- persisted DAG-plan snapshot (persist-dag-plan) --------------------------------
|
|
3729
|
+
# `waves` recomputes the schedule live and is the authority; `dag-plan` MATERIALIZES that
|
|
3730
|
+
# computed schedule into a committed, auditable per-milestone snapshot + a freshness check
|
|
3731
|
+
# against the live edges. The snapshot is NEVER the authority — only a checkable projection.
|
|
3732
|
+
def _dag_plan_path(root: Path, mslug: str) -> Path:
|
|
3733
|
+
return root / "milestones" / mslug / "dag-plan.json"
|
|
3734
|
+
|
|
3735
|
+
|
|
3736
|
+
def _edges_fingerprint(state: dict, mslug: str) -> str:
|
|
3737
|
+
"""md5 over the EDGE STRUCTURE: every member's (done OR open) sorted depends_on. PURE.
|
|
3738
|
+
Invariant under a task COMPLETING (phase/gate change, not edges) — so completion is NOT
|
|
3739
|
+
drift — and changes only when a dep is added/removed/redirected or a member added/removed."""
|
|
3740
|
+
tasks = state.get("tasks") or {}
|
|
3741
|
+
edges = {s: sorted(t.get("depends_on") or [])
|
|
3742
|
+
for s, t in tasks.items() if t.get("milestone") == mslug}
|
|
3743
|
+
return _md5_text(json.dumps(edges, sort_keys=True))
|
|
3744
|
+
|
|
3745
|
+
|
|
3746
|
+
def _dag_plan_freshness(root: Path, state: dict, mslug: str) -> tuple[str, dict | None]:
|
|
3747
|
+
"""Read the snapshot and compare its stored fingerprint to the live one. Fail-safe:
|
|
3748
|
+
absent -> ("none", None) · unreadable/garbled -> ("unreadable", None) · match -> "fresh"
|
|
3749
|
+
· mismatch -> "stale". Never raises on a missing/corrupt file (no traceback to the user)."""
|
|
3750
|
+
sp = _dag_plan_path(root, mslug)
|
|
3751
|
+
if not sp.exists():
|
|
3752
|
+
return ("none", None)
|
|
3753
|
+
try:
|
|
3754
|
+
snap = json.loads(sp.read_text(encoding="utf-8"))
|
|
3755
|
+
except (OSError, ValueError):
|
|
3756
|
+
return ("unreadable", None)
|
|
3757
|
+
if not isinstance(snap, dict) or "edges_fingerprint" not in snap:
|
|
3758
|
+
return ("unreadable", None)
|
|
3759
|
+
live = _edges_fingerprint(state, mslug)
|
|
3760
|
+
return (("fresh" if snap.get("edges_fingerprint") == live else "stale"), snap)
|
|
3761
|
+
|
|
3762
|
+
|
|
3763
|
+
def _dag_plan_status_line(root: Path, state: dict, mslug: str) -> str:
|
|
3764
|
+
"""The single `dag-plan:` line cmd_status prints for the active milestone."""
|
|
3765
|
+
fresh, snap = _dag_plan_freshness(root, state, mslug)
|
|
3766
|
+
if fresh == "fresh":
|
|
3767
|
+
return "dag-plan: fresh ✓"
|
|
3768
|
+
if fresh == "stale":
|
|
3769
|
+
return f"dag-plan: stale (edges changed since {(snap or {}).get('generated', '?')})"
|
|
3770
|
+
if fresh == "unreadable":
|
|
3771
|
+
return "dag-plan: unreadable — run add.py dag-plan"
|
|
3772
|
+
return "dag-plan: none — run add.py dag-plan"
|
|
3773
|
+
|
|
3774
|
+
|
|
3775
|
+
def cmd_dag_plan(args: argparse.Namespace) -> None:
|
|
3776
|
+
"""RECORD-ONLY: materialize the active (or --milestone) milestone's computed DAG schedule
|
|
3777
|
+
into a committed snapshot + an edge fingerprint. Reuses cmd_waves' rejects; writes NOTHING
|
|
3778
|
+
on any reject. Idempotent: an unchanged fingerprint leaves the file byte-identical."""
|
|
3779
|
+
root = _require_root()
|
|
3780
|
+
state = load_state(root)
|
|
3781
|
+
mslug = getattr(args, "milestone", None) or _active_milestone(state)
|
|
3782
|
+
if not mslug:
|
|
3783
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
3784
|
+
if mslug not in (state.get("milestones") or {}):
|
|
3785
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
|
|
3786
|
+
sched = _wave_schedule(state, mslug)
|
|
3787
|
+
if "cycle" in sched:
|
|
3788
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
3789
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
3790
|
+
live_fp = _edges_fingerprint(state, mslug)
|
|
3791
|
+
sp = _dag_plan_path(root, mslug)
|
|
3792
|
+
# idempotent: an unchanged fingerprint leaves the snapshot (and its `generated` date) untouched
|
|
3793
|
+
if sp.exists():
|
|
3794
|
+
try:
|
|
3795
|
+
cur = json.loads(sp.read_text(encoding="utf-8"))
|
|
3796
|
+
except (OSError, ValueError):
|
|
3797
|
+
cur = None
|
|
3798
|
+
if isinstance(cur, dict) and cur.get("edges_fingerprint") == live_fp:
|
|
3799
|
+
print(f"dag-plan: {mslug} already fresh ✓ (generated {cur.get('generated', '?')})")
|
|
3800
|
+
return
|
|
3801
|
+
snap = {"milestone": mslug, "generated": date.today().isoformat(),
|
|
3802
|
+
"edges_fingerprint": live_fp, "schedule": sched}
|
|
3803
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
3804
|
+
_atomic_write(sp, json.dumps(snap, indent=2, sort_keys=True) + "\n")
|
|
3805
|
+
nwaves = len(sched.get("waves") or [])
|
|
3806
|
+
ntasks = sum(len(w) for w in (sched.get("waves") or []))
|
|
3807
|
+
print(f"dag-plan: wrote {mslug} — {nwaves} wave(s), {ntasks} task(s) (fresh ✓)")
|
|
3808
|
+
|
|
3809
|
+
|
|
3218
3810
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
3219
3811
|
root = _require_root()
|
|
3220
3812
|
state = load_state(root)
|
|
@@ -3431,6 +4023,17 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
3431
4023
|
state["tasks"][task]["milestone"] = new
|
|
3432
4024
|
state["tasks"][task]["updated"] = _now()
|
|
3433
4025
|
save_state(root, state)
|
|
4026
|
+
# keep the TASK.md `milestone:` backlink in lockstep with state (task-milestone-backlink):
|
|
4027
|
+
# rewrite the header line (insert it if a grandfathered file lacks it). Degrade-safe — a
|
|
4028
|
+
# missing/unreadable TASK.md never blocks the move (state is already the source of truth).
|
|
4029
|
+
task_md = root / "tasks" / task / "TASK.md"
|
|
4030
|
+
try:
|
|
4031
|
+
_txt = task_md.read_text(encoding="utf-8")
|
|
4032
|
+
_new_txt = _set_milestone_line(_txt, _milestone_backlink_value(new))
|
|
4033
|
+
if _new_txt != _txt:
|
|
4034
|
+
_atomic_write(task_md, _new_txt)
|
|
4035
|
+
except OSError:
|
|
4036
|
+
pass
|
|
3434
4037
|
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
3435
4038
|
print(_next_footer(root, state))
|
|
3436
4039
|
|
|
@@ -3605,7 +4208,10 @@ def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
|
3605
4208
|
except (ValueError, OSError):
|
|
3606
4209
|
rel = str(f)
|
|
3607
4210
|
tests[rel] = h
|
|
3608
|
-
|
|
4211
|
+
# strip-scaffold-at-done: fingerprint the contract CONTENT (comment-normalized, see
|
|
4212
|
+
# _contract_fingerprint) so the at-done comment strip is invisible to the tamper guard; a real
|
|
4213
|
+
# fenced-shape edit still changes it. Mirrored byte-for-byte in _tripwire_divergence.
|
|
4214
|
+
return {"contract_md5": _contract_fingerprint(raw3), "tests": tests}
|
|
3609
4215
|
|
|
3610
4216
|
|
|
3611
4217
|
def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
@@ -3613,7 +4219,9 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
|
3613
4219
|
path directly (never re-globs), so a weakened, deleted, or unreadable test file
|
|
3614
4220
|
and an edited frozen §3 all surface. Fail-closed: an unreadable file -> diverged."""
|
|
3615
4221
|
diffs: list[str] = []
|
|
3616
|
-
|
|
4222
|
+
# compare the contract CONTENT fingerprint (strip-scaffold-at-done) — same normalization as the
|
|
4223
|
+
# snapshot, so the at-done comment strip never reads as tampering; a real shape edit still does.
|
|
4224
|
+
if _contract_fingerprint(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
|
|
3617
4225
|
diffs.append("contract_tampered")
|
|
3618
4226
|
rootp = root.parent.resolve()
|
|
3619
4227
|
for rel, snap in (tw.get("tests") or {}).items():
|
|
@@ -3632,7 +4240,10 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
|
3632
4240
|
# likewise NOT a source touch — counting one produced repeated false `scope_violation`s in
|
|
3633
4241
|
# consuming projects (`.next/`, `coverage/`, `tsconfig.tsbuildinfo`, whose `incremental`
|
|
3634
4242
|
# rewrite even races a clean re-snapshot), so they are pruned here too.
|
|
3635
|
-
|
|
4243
|
+
# `.claude` is an agent-tool internal dir (config/skills/worktrees) like `.serena` — never a
|
|
4244
|
+
# task's declared source; without it, the walk descends into `.claude/worktrees/<wt>/` (linked
|
|
4245
|
+
# git worktrees: full branch checkouts) and their churn produces false `scope_violation`s.
|
|
4246
|
+
_SCOPE_EXCLUDE_DIRS = (".git", ".add", ".claude", "__pycache__", "node_modules", ".serena",
|
|
3636
4247
|
".next", "coverage", "test-results")
|
|
3637
4248
|
_SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildinfo by suffix
|
|
3638
4249
|
_SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
|
|
@@ -4509,8 +5120,15 @@ def _flag_well_formed(raw3: str) -> bool:
|
|
|
4509
5120
|
refused unless it takes the honest escape 'none material — biggest risk: X'.
|
|
4510
5121
|
why/cost stay a human-read convention, never machine keywords (evidence: the
|
|
4511
5122
|
lived flags use em-dash/prose, never literal because/if-wrong). HTML comments
|
|
4512
|
-
(template hints) never count.
|
|
4513
|
-
|
|
5123
|
+
(template hints) never count. Fence-aware (mirrors _strip_live_scaffold): a
|
|
5124
|
+
frozen §3 may legitimately quote a bare `<!--` inside its own fenced code
|
|
5125
|
+
block (documenting an HTML-comment invariant) — that must never merge with
|
|
5126
|
+
an unrelated `-->` found later in the raw text. PURE — fail-closed on a
|
|
5127
|
+
missing label."""
|
|
5128
|
+
segs = re.split(r"(```.*?```)", raw3, flags=re.DOTALL)
|
|
5129
|
+
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
5130
|
+
segs[i] = re.sub(r"<!--.*?-->", "", segs[i], flags=re.S)
|
|
5131
|
+
body = "".join(segs)
|
|
4514
5132
|
m = _FLAG_LABEL_RE.search(body)
|
|
4515
5133
|
if not m:
|
|
4516
5134
|
return False
|
|
@@ -4936,7 +5554,12 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
4936
5554
|
delta_text, evidence = em.group(1).strip(), em.group(2).strip()
|
|
4937
5555
|
else:
|
|
4938
5556
|
delta_text, evidence = tail, ""
|
|
4939
|
-
|
|
5557
|
+
# OPTIONAL persona target + section hint (persona-self-improve) — None for a plain lesson.
|
|
5558
|
+
pm = _PERSONA_TAG_RE.search(unit[0])
|
|
5559
|
+
persona = pm.group(1).strip() if pm else None
|
|
5560
|
+
hint = pm.group(2).strip() if pm else None
|
|
5561
|
+
by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence,
|
|
5562
|
+
"persona": persona, "hint": hint})
|
|
4940
5563
|
return by_comp
|
|
4941
5564
|
|
|
4942
5565
|
|
|
@@ -5062,7 +5685,11 @@ def _select_spec_delta(text: str, match: str | None = None,
|
|
|
5062
5685
|
# literal, both exempt via MACHINE_CONSTANTS.
|
|
5063
5686
|
_FOLD_VERB = "fold" # the subcommand / decision-record verb
|
|
5064
5687
|
_FOLDED = "folded" # the resolved status value
|
|
5065
|
-
|
|
5688
|
+
# group(2) carries any OPTIONAL persona annotation (`· persona:<slug> · <hint>`) so flipping
|
|
5689
|
+
# open->folded preserves it byte-for-byte (persona-self-improve).
|
|
5690
|
+
_COMP_OPEN_TOKEN_RE = re.compile(
|
|
5691
|
+
r"(\[\s*(?:DDD|SDD|UDD|TDD|ADD)\s*·\s*)open"
|
|
5692
|
+
r"(\s*(?:·\s*persona:[^\s·\]]+\s*·\s*[^·\]]+?\s*)?\])")
|
|
5066
5693
|
|
|
5067
5694
|
# competency -> (foundation file, section-heading PREFIX) — fold.md's routing table. DDD/SDD/UDD
|
|
5068
5695
|
# land in PROJECT.md sections; TDD/ADD in CONVENTIONS.md (they ARE the engine). Total over the five.
|
|
@@ -5076,6 +5703,13 @@ _FOLD_ROUTES = {
|
|
|
5076
5703
|
_KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail section (every session adds one row)
|
|
5077
5704
|
_TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
|
|
5078
5705
|
|
|
5706
|
+
# persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
|
|
5707
|
+
# foundation file. The section HINT picks the growable section; only these two are routable.
|
|
5708
|
+
_PERSONA_FOLD_SECTIONS = {
|
|
5709
|
+
"critical-rule": "## Critical Rules",
|
|
5710
|
+
"success-metric": "## Success Metrics",
|
|
5711
|
+
}
|
|
5712
|
+
|
|
5079
5713
|
|
|
5080
5714
|
def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
|
|
5081
5715
|
"""Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
|
|
@@ -5170,11 +5804,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5170
5804
|
prev_v = int(vm.group(1))
|
|
5171
5805
|
new_v = prev_v + 1
|
|
5172
5806
|
|
|
5173
|
-
#
|
|
5807
|
+
# A lesson with a `persona:<slug>` target routes to a persona living doc, NOT a foundation file
|
|
5808
|
+
# (persona-self-improve). Partition first so the foundation-route checks skip persona lessons.
|
|
5809
|
+
persona_sel = [it for it in selected if it.get("persona")]
|
|
5810
|
+
found_sel = [it for it in selected if not it.get("persona")]
|
|
5811
|
+
|
|
5812
|
+
# routing — every FOUNDATION lesson's destination section (and the audit-trail section) must exist.
|
|
5174
5813
|
conventions_md = root / "CONVENTIONS.md"
|
|
5175
5814
|
conventions_text = conventions_md.read_text(encoding="utf-8") if conventions_md.exists() else ""
|
|
5176
5815
|
file_text = {"PROJECT.md": project_text, "CONVENTIONS.md": conventions_text}
|
|
5177
|
-
for it in
|
|
5816
|
+
for it in found_sel:
|
|
5178
5817
|
fname, heading = _FOLD_ROUTES[it["comp"]]
|
|
5179
5818
|
if not _section_present(file_text[fname], heading):
|
|
5180
5819
|
_die(f"missing_route_section: {fname} has no '{heading}' section for a "
|
|
@@ -5183,6 +5822,21 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5183
5822
|
_die(f"missing_route_section: PROJECT.md has no '{_KEY_DECISIONS_HEADING}' "
|
|
5184
5823
|
"section for the audit-trail row — add the section header and re-run")
|
|
5185
5824
|
|
|
5825
|
+
# persona routing — validate fail-closed BEFORE any write (design-for-failure): the section hint
|
|
5826
|
+
# must be routable and the target persona file must exist. No network, no child launch — pure IO.
|
|
5827
|
+
persona_paths: dict[str, Path] = {}
|
|
5828
|
+
for it in persona_sel:
|
|
5829
|
+
hint = (it.get("hint") or "").strip()
|
|
5830
|
+
if hint not in _PERSONA_FOLD_SECTIONS:
|
|
5831
|
+
_die(f"persona_section_unroutable: '{hint or '(none)'}' is not a growable persona "
|
|
5832
|
+
f"section — use one of {', '.join(_PERSONA_FOLD_SECTIONS)}")
|
|
5833
|
+
slug = it["persona"]
|
|
5834
|
+
ppath = root / "personas" / f"{slug}.md"
|
|
5835
|
+
if not _persona_slug_valid(slug) or not ppath.is_file():
|
|
5836
|
+
_die(f"missing_persona_target: no .add/personas/{slug}.md for a persona lesson — "
|
|
5837
|
+
"seed the persona first (setup) or fix the slug")
|
|
5838
|
+
persona_paths[slug] = ppath
|
|
5839
|
+
|
|
5186
5840
|
# ── build EVERY edit in memory before writing anything ──────────────────────────────────────
|
|
5187
5841
|
comps_filter = {want_comp} if want_comp else None
|
|
5188
5842
|
task_new: dict[str, str] = {}
|
|
@@ -5198,15 +5852,40 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5198
5852
|
return (f"- ({it['comp']}) {it['text']}{ev} "
|
|
5199
5853
|
f"[{_FOLDED} foundation-version {new_v} · from {it['task']}]")
|
|
5200
5854
|
|
|
5201
|
-
|
|
5855
|
+
def _persona_bullet(it):
|
|
5856
|
+
ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
|
|
5857
|
+
return (f"- {it['text']}{ev} "
|
|
5858
|
+
f"[{_FOLDED} {date.today().isoformat()} · from {it['task']}]")
|
|
5859
|
+
|
|
5860
|
+
# transcribe FOUNDATION lessons verbatim (reverse so canonical-order first lands on top, newest-first).
|
|
5202
5861
|
proj_text, conv_text = project_text, conventions_text
|
|
5203
|
-
for it in reversed(
|
|
5862
|
+
for it in reversed(found_sel):
|
|
5204
5863
|
fname, heading = _FOLD_ROUTES[it["comp"]]
|
|
5205
5864
|
if fname == "PROJECT.md":
|
|
5206
5865
|
proj_text = _prepend_to_section(proj_text, heading, _bullet(it))
|
|
5207
5866
|
else:
|
|
5208
5867
|
conv_text = _prepend_to_section(conv_text, heading, _bullet(it))
|
|
5209
5868
|
|
|
5869
|
+
# transcribe PERSONA lessons into their living docs — PREPEND newest-first under the hinted
|
|
5870
|
+
# section, NEVER clobbering (every prior persona line survives; the schema stays conformant).
|
|
5871
|
+
from collections import Counter
|
|
5872
|
+
persona_new: dict[str, str] = {}
|
|
5873
|
+
for it in reversed(persona_sel):
|
|
5874
|
+
slug = it["persona"]
|
|
5875
|
+
before = persona_new.get(slug) or persona_paths[slug].read_text(encoding="utf-8")
|
|
5876
|
+
heading = _PERSONA_FOLD_SECTIONS[it["hint"].strip()]
|
|
5877
|
+
if not _section_present(before, heading): # defensive: a conformant persona has it
|
|
5878
|
+
_die(f"persona_section_unroutable: .add/personas/{slug}.md has no '{heading}' section")
|
|
5879
|
+
after = _prepend_to_section(before, heading, _persona_bullet(it))
|
|
5880
|
+
# never-clobber INVARIANT: every pre-existing line must survive the merge.
|
|
5881
|
+
if Counter(before.splitlines()) - Counter(after.splitlines()):
|
|
5882
|
+
_die(f"persona_clobber_forbidden: consolidating into {slug}.md would drop existing content")
|
|
5883
|
+
# post-merge INVARIANT: the four required persona sections survive.
|
|
5884
|
+
if _persona_missing(after):
|
|
5885
|
+
_die(f"persona_clobber_forbidden: consolidating into {slug}.md broke the persona schema "
|
|
5886
|
+
f"(missing {', '.join(_persona_missing(after))})")
|
|
5887
|
+
persona_new[slug] = after
|
|
5888
|
+
|
|
5210
5889
|
counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
|
|
5211
5890
|
count_str = " · ".join(f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c])
|
|
5212
5891
|
scope = "all" if not (want_task or want_comp) else " ".join(
|
|
@@ -5229,6 +5908,10 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5229
5908
|
for slug, body in task_new.items():
|
|
5230
5909
|
writes.append((root / "tasks" / slug / "TASK.md", body))
|
|
5231
5910
|
touched.append(f"{len(task_new)} TASK.md")
|
|
5911
|
+
for slug, body in persona_new.items():
|
|
5912
|
+
writes.append((persona_paths[slug], body))
|
|
5913
|
+
if persona_new:
|
|
5914
|
+
touched.append(f"{len(persona_new)} persona")
|
|
5232
5915
|
_atomic_write_many(writes)
|
|
5233
5916
|
|
|
5234
5917
|
print(f"{_FOLDED} {len(selected)} lessons -> foundation-version {new_v}")
|
|
@@ -5303,8 +5986,14 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
5303
5986
|
# F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
|
|
5304
5987
|
# high-risk record must show a guarded dial AND a human at the gate —
|
|
5305
5988
|
# catches post-gate header tampering and auto-resolved high-risk gates.
|
|
5989
|
+
# advisor-gate-relax: a mechanical task whose Advisor 3-lens verdict shows
|
|
5990
|
+
# PASS + Residue: none is exempt — the outer condition is False so both
|
|
5991
|
+
# sub-branches are skipped. Non-mechanical (security/data/architecture)
|
|
5992
|
+
# never relax; an absent advisor block is fail-safe False → guard fires.
|
|
5306
5993
|
hdr = _task_header(root, slug)
|
|
5307
|
-
if _RISK_HIGH_RE.search(hdr)
|
|
5994
|
+
if _RISK_HIGH_RE.search(hdr) and not (_task_sensitivity(hdr) == "mechanical"
|
|
5995
|
+
and _advisor_verdict_is_pass(s6)
|
|
5996
|
+
and _advisor_no_residue(s6)):
|
|
5308
5997
|
if not _autonomy_lowered(hdr):
|
|
5309
5998
|
f(slug, "unguarded_high_risk_auto",
|
|
5310
5999
|
"risk: high declared but autonomy is not lowered (manual or conservative)")
|
|
@@ -5355,24 +6044,68 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
|
5355
6044
|
shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
|
|
5356
6045
|
ABSENT block grandfathers a legacy task — never retro-flagged);
|
|
5357
6046
|
risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
|
|
6047
|
+
sensitivity_unset[]= the header carries NO valid `sensitivity:` token (risk-sensitivity-taxonomy);
|
|
5358
6048
|
refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
|
|
5359
6049
|
refute-record, M4) — the earned-green verdict the AI must record under
|
|
5360
6050
|
`auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
|
|
5361
6051
|
never auto-blocks a gate, only surfaced here for review + a human spot-audit.
|
|
5362
6052
|
Honest visibility for three verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
|
|
5363
6053
|
TASK.md + state only, writes nothing."""
|
|
5364
|
-
shallow, risk_unset, refute_unrecorded = [], [], []
|
|
6054
|
+
shallow, risk_unset, refute_unrecorded, sensitivity_unset = [], [], [], []
|
|
6055
|
+
advisor_verdict_unrecorded = []
|
|
6056
|
+
advisor_reviewer_is_author = []
|
|
6057
|
+
advisor_residue_on_mechanical_mis_tier = []
|
|
5365
6058
|
for slug in sorted(state.get("tasks") or {}):
|
|
5366
6059
|
if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
|
|
5367
6060
|
continue
|
|
5368
6061
|
body6 = _raw_phase_bodies(root, slug).get(6, "")
|
|
6062
|
+
hdr = _task_header(root, slug)
|
|
5369
6063
|
if _section_unfilled(body6, "### Deep checks"):
|
|
5370
6064
|
shallow.append(slug)
|
|
5371
6065
|
if _section_unfilled(body6, "### Refute-read verdict"):
|
|
5372
6066
|
refute_unrecorded.append(slug)
|
|
5373
|
-
if
|
|
6067
|
+
if _section_unfilled(body6, "### Advisor 3-lens verdict"):
|
|
6068
|
+
advisor_verdict_unrecorded.append(slug)
|
|
6069
|
+
if not _RISK_ANY_RE.search(hdr):
|
|
5374
6070
|
risk_unset.append(slug)
|
|
5375
|
-
|
|
6071
|
+
# sensitivity_unset (risk-sensitivity-taxonomy): a verify-reached task with no
|
|
6072
|
+
# human-declared sensitivity — MEASURE-NOT-BLOCK, same class as risk_unset.
|
|
6073
|
+
if _task_sensitivity(hdr) is None:
|
|
6074
|
+
sensitivity_unset.append(slug)
|
|
6075
|
+
# advisor_reviewer_is_author / advisor_residue_on_mechanical_mis_tier
|
|
6076
|
+
# (advisor-verdict-audit): MEASURE-NOT-BLOCK lints on the filled advisor block.
|
|
6077
|
+
# Both require the block to be PRESENT AND FILLED (not just unfilled).
|
|
6078
|
+
# Extract ONLY the advisor sub-section to avoid false matches from
|
|
6079
|
+
# the Refute-read verdict block (which also carries Verdict:/Residue: lines).
|
|
6080
|
+
_advisor_body = ""
|
|
6081
|
+
_adv_idx = body6.find("### Advisor 3-lens verdict")
|
|
6082
|
+
if _adv_idx != -1:
|
|
6083
|
+
_adv_rest = body6[_adv_idx + len("### Advisor 3-lens verdict"):]
|
|
6084
|
+
_next_hdr = re.search(r"\n###", _adv_rest)
|
|
6085
|
+
_advisor_body = _adv_rest[:_next_hdr.start()] if _next_hdr else _adv_rest
|
|
6086
|
+
_advisor_filled = (_adv_idx != -1
|
|
6087
|
+
and not _section_unfilled(body6, "### Advisor 3-lens verdict"))
|
|
6088
|
+
if _advisor_filled:
|
|
6089
|
+
_ma = re.search(r"(?m)^Advisor:(.*)$", _advisor_body)
|
|
6090
|
+
_advisor_name = _ma.group(1).strip() if _ma else ""
|
|
6091
|
+
_gate_name = (state["tasks"][slug].get("gate_actor") or {}).get("name", "").strip()
|
|
6092
|
+
if (_advisor_name and _gate_name
|
|
6093
|
+
and _advisor_name.lower() == _gate_name.lower()):
|
|
6094
|
+
advisor_reviewer_is_author.append(slug)
|
|
6095
|
+
_sens = _task_sensitivity(hdr)
|
|
6096
|
+
if _sens == "mechanical":
|
|
6097
|
+
_mr = re.search(r"(?m)^Residue:(.*)$", _advisor_body)
|
|
6098
|
+
_mv = re.search(r"(?m)^Verdict:(.*)$", _advisor_body)
|
|
6099
|
+
_residue = _mr.group(1).strip() if _mr else ""
|
|
6100
|
+
_verdict = _mv.group(1).strip() if _mv else ""
|
|
6101
|
+
if (_residue and _residue.lower() != "none"
|
|
6102
|
+
and _verdict.upper().startswith("PASS")):
|
|
6103
|
+
advisor_residue_on_mechanical_mis_tier.append(slug)
|
|
6104
|
+
return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded,
|
|
6105
|
+
"sensitivity_unset": sensitivity_unset,
|
|
6106
|
+
"advisor_verdict_unrecorded": advisor_verdict_unrecorded,
|
|
6107
|
+
"advisor_reviewer_is_author": advisor_reviewer_is_author,
|
|
6108
|
+
"advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier}
|
|
5376
6109
|
|
|
5377
6110
|
|
|
5378
6111
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
@@ -5405,8 +6138,28 @@ def cmd_audit(args: argparse.Namespace) -> None:
|
|
|
5405
6138
|
ru = glints["refute_unrecorded"]
|
|
5406
6139
|
print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
|
|
5407
6140
|
f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
|
|
6141
|
+
if glints["advisor_verdict_unrecorded"]:
|
|
6142
|
+
av = glints["advisor_verdict_unrecorded"]
|
|
6143
|
+
print(f"audit: advisor_verdict_unrecorded — {len(av)} task(s): {', '.join(av)} "
|
|
6144
|
+
f"— record the 3-lens advisor verdict (§6); a spot-audit is the backstop")
|
|
6145
|
+
if glints["sensitivity_unset"]:
|
|
6146
|
+
su = glints["sensitivity_unset"]
|
|
6147
|
+
print(f"audit: sensitivity_unset — {len(su)} task(s) reached verify with no "
|
|
6148
|
+
f"sensitivity: declaration: {', '.join(su)}")
|
|
6149
|
+
if glints["advisor_reviewer_is_author"]:
|
|
6150
|
+
ar = glints["advisor_reviewer_is_author"]
|
|
6151
|
+
print(f"audit: advisor_reviewer_is_author — {len(ar)} task(s): {', '.join(ar)} "
|
|
6152
|
+
f"— advisor and gate actor are the same identity")
|
|
6153
|
+
if glints["advisor_residue_on_mechanical_mis_tier"]:
|
|
6154
|
+
am = glints["advisor_residue_on_mechanical_mis_tier"]
|
|
6155
|
+
print(f"audit: advisor_residue_on_mechanical_mis_tier — {len(am)} task(s): "
|
|
6156
|
+
f"{', '.join(am)} — mechanical tier with non-none residue and PASS verdict "
|
|
6157
|
+
f"is incoherent; consider re-tiering")
|
|
5408
6158
|
if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
|
|
5409
|
-
and not glints["refute_unrecorded"]
|
|
6159
|
+
and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
|
|
6160
|
+
and not glints["sensitivity_unset"] \
|
|
6161
|
+
and not glints["advisor_reviewer_is_author"] \
|
|
6162
|
+
and not glints["advisor_residue_on_mechanical_mis_tier"]:
|
|
5410
6163
|
print(f"audit: clean ({checked} tasks checked)")
|
|
5411
6164
|
# MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
|
|
5412
6165
|
if findings:
|
|
@@ -5806,8 +6559,23 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5806
6559
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
5807
6560
|
getattr(args, "evidence", None),
|
|
5808
6561
|
identity._render_actor_line(state), loose_bundle))
|
|
5809
|
-
|
|
5810
|
-
|
|
6562
|
+
# milestone-release-backlink: STAMP each bundled milestone's MILESTONE.md `release:` line to
|
|
6563
|
+
# the cut version. Built in memory NOW and appended to the SAME atomic batch as the ledgers,
|
|
6564
|
+
# so the stamp commits all-or-nothing with them (a failed write rolls back everything). Only
|
|
6565
|
+
# `bundle` (closed-and-unreleased) milestones are stamped; a missing/unreadable file is skipped
|
|
6566
|
+
# (degrade-safe — the ledger attribution stays the source of truth).
|
|
6567
|
+
stamp_writes: list[tuple[Path, str]] = []
|
|
6568
|
+
for m in bundle:
|
|
6569
|
+
mfile = root / "milestones" / m["slug"] / "MILESTONE.md"
|
|
6570
|
+
try:
|
|
6571
|
+
_mtxt = mfile.read_text(encoding="utf-8")
|
|
6572
|
+
except OSError:
|
|
6573
|
+
continue
|
|
6574
|
+
_stamped = _set_release_line(_mtxt, args.version)
|
|
6575
|
+
if _stamped != _mtxt:
|
|
6576
|
+
stamp_writes.append((mfile, _stamped))
|
|
6577
|
+
try: # CHANGELOG + RELEASES + milestone stamps: one all-or-nothing commit
|
|
6578
|
+
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)] + stamp_writes)
|
|
5811
6579
|
except OSError as e:
|
|
5812
6580
|
_die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
|
|
5813
6581
|
"files were rolled back to their prior content. Retry the release.")
|
|
@@ -6000,6 +6768,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6000
6768
|
pi.add_argument("--rule-file", dest="rule_file", action="store_true",
|
|
6001
6769
|
help="write the ADD block to .claude/rules/add-workflows.md and reference it "
|
|
6002
6770
|
"from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
|
|
6771
|
+
pi.add_argument("--run-mode", dest="run_mode", default=None,
|
|
6772
|
+
choices=["auto", "conservative"],
|
|
6773
|
+
help="seed autonomy+streams posture: auto→parallel, conservative→sequential "
|
|
6774
|
+
"(absent: PROJECT.md is byte-identical to a plain init)")
|
|
6003
6775
|
pi.set_defaults(func=cmd_init)
|
|
6004
6776
|
|
|
6005
6777
|
pl = sub.add_parser("lock",
|
|
@@ -6128,6 +6900,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6128
6900
|
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
6129
6901
|
pwa.set_defaults(func=cmd_waves)
|
|
6130
6902
|
|
|
6903
|
+
pdp = sub.add_parser("dag-plan", help="record a committed snapshot of the milestone's computed "
|
|
6904
|
+
"DAG schedule (waves + critical path + tiers) + a freshness "
|
|
6905
|
+
"check vs the live depends_on edges")
|
|
6906
|
+
pdp.add_argument("--milestone", default=None,
|
|
6907
|
+
help="milestone slug to snapshot (default: the active milestone)")
|
|
6908
|
+
pdp.set_defaults(func=cmd_dag_plan)
|
|
6909
|
+
|
|
6131
6910
|
pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
|
|
6132
6911
|
pmd.add_argument("slug")
|
|
6133
6912
|
pmd.set_defaults(func=cmd_milestone_done)
|
|
@@ -6198,6 +6977,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6198
6977
|
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
6199
6978
|
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
6200
6979
|
|
|
6980
|
+
pst = sub.add_parser("streams", help="show or set the project streams posture (parallel|sequential)")
|
|
6981
|
+
pst.add_argument("action", nargs="?", choices=("show", "set"), default="show")
|
|
6982
|
+
pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
|
|
6983
|
+
pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
|
|
6984
|
+
|
|
6201
6985
|
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
6202
6986
|
pto.add_argument("text", nargs="?", default=None,
|
|
6203
6987
|
help="todo text to capture; omit to LIST open todos")
|
|
@@ -6228,6 +7012,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6228
7012
|
|
|
6229
7013
|
pst = sub.add_parser("status", help="print where the project is (resume point)")
|
|
6230
7014
|
pst.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
7015
|
+
pst.add_argument("--task", metavar="SLUG", help="with --json, filter to one task's "
|
|
7016
|
+
"{slug, phase, gate, milestone, owner, assignee} object")
|
|
7017
|
+
pst.add_argument("--all", action="store_true", help="show every milestone/task "
|
|
7018
|
+
"(default: top 10 by most-recently-updated)")
|
|
6231
7019
|
pst.set_defaults(func=cmd_status)
|
|
6232
7020
|
|
|
6233
7021
|
pck = sub.add_parser("check", help="read-only integrity check of the .add project")
|
|
@@ -6239,6 +7027,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6239
7027
|
"(.add/components.toml) — RED integrity errors + schema-lint typo warnings")
|
|
6240
7028
|
pcomp.set_defaults(func=cmd_components)
|
|
6241
7029
|
|
|
7030
|
+
psrch = sub.add_parser("search", help="keyword/substring search over the "
|
|
7031
|
+
"milestone/task corpus (active + archived) — "
|
|
7032
|
+
"title/goal/rationale lines only, never the full body")
|
|
7033
|
+
psrch.add_argument("keywords", nargs="+", metavar="KEYWORD",
|
|
7034
|
+
help="one or more keywords (case-insensitive substring, OR-combined)")
|
|
7035
|
+
psrch.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
7036
|
+
psrch.set_defaults(func=cmd_search)
|
|
7037
|
+
|
|
6242
7038
|
pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
|
|
6243
7039
|
"contract snapshot into this repo (fail-loud)")
|
|
6244
7040
|
pfedsub = pfed.add_subparsers(dest="action", required=True)
|