@pilotspace/add 1.13.0 → 1.15.0

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