@pilotspace/add 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/CHANGELOG.md +195 -1
  2. package/README.md +70 -3
  3. package/THIRD_PARTY_NOTICES.md +41 -0
  4. package/agents/add-advisor.md +31 -0
  5. package/agents/add-build.md +29 -0
  6. package/agents/add-design.md +32 -0
  7. package/agents/add-persona.md +31 -0
  8. package/agents/add-verify.md +34 -0
  9. package/bin/cli.js +615 -54
  10. package/docs/02-the-flow.md +1 -1
  11. package/docs/03-step-1-specify.md +4 -0
  12. package/docs/04-step-2-scenarios.md +4 -0
  13. package/docs/06-step-4-tests.md +4 -0
  14. package/docs/08-step-6-verify.md +7 -0
  15. package/docs/18-personas.md +80 -0
  16. package/docs/add-milestone-task-lifecycle.png +0 -0
  17. package/docs/add-task-growth-wheel.png +0 -0
  18. package/docs/appendix-c-glossary.md +5 -1
  19. package/package.json +4 -1
  20. package/personas-teacher/LICENSE +21 -0
  21. package/personas-teacher/README.md +1067 -0
  22. package/personas-teacher/VENDOR.md +13 -0
  23. package/personas-teacher/academic/academic-anthropologist.md +125 -0
  24. package/personas-teacher/academic/academic-geographer.md +127 -0
  25. package/personas-teacher/academic/academic-historian.md +123 -0
  26. package/personas-teacher/academic/academic-narratologist.md +118 -0
  27. package/personas-teacher/academic/academic-psychologist.md +118 -0
  28. package/personas-teacher/design/design-brand-guardian.md +322 -0
  29. package/personas-teacher/design/design-image-prompt-engineer.md +236 -0
  30. package/personas-teacher/design/design-inclusive-visuals-specialist.md +71 -0
  31. package/personas-teacher/design/design-persona-walkthrough.md +272 -0
  32. package/personas-teacher/design/design-ui-designer.md +383 -0
  33. package/personas-teacher/design/design-ux-architect.md +469 -0
  34. package/personas-teacher/design/design-ux-researcher.md +329 -0
  35. package/personas-teacher/design/design-visual-storyteller.md +149 -0
  36. package/personas-teacher/design/design-whimsy-injector.md +438 -0
  37. package/personas-teacher/divisions.json +21 -0
  38. package/personas-teacher/engineering/engineering-ai-data-remediation-engineer.md +211 -0
  39. package/personas-teacher/engineering/engineering-ai-engineer.md +146 -0
  40. package/personas-teacher/engineering/engineering-autonomous-optimization-architect.md +107 -0
  41. package/personas-teacher/engineering/engineering-backend-architect.md +236 -0
  42. package/personas-teacher/engineering/engineering-cms-developer.md +536 -0
  43. package/personas-teacher/engineering/engineering-code-reviewer.md +76 -0
  44. package/personas-teacher/engineering/engineering-codebase-onboarding-engineer.md +173 -0
  45. package/personas-teacher/engineering/engineering-data-engineer.md +306 -0
  46. package/personas-teacher/engineering/engineering-database-optimizer.md +176 -0
  47. package/personas-teacher/engineering/engineering-devops-automator.md +376 -0
  48. package/personas-teacher/engineering/engineering-drupal-shopping-cart.md +360 -0
  49. package/personas-teacher/engineering/engineering-email-intelligence-engineer.md +353 -0
  50. package/personas-teacher/engineering/engineering-embedded-firmware-engineer.md +173 -0
  51. package/personas-teacher/engineering/engineering-feishu-integration-developer.md +598 -0
  52. package/personas-teacher/engineering/engineering-filament-optimization-specialist.md +283 -0
  53. package/personas-teacher/engineering/engineering-frontend-developer.md +225 -0
  54. package/personas-teacher/engineering/engineering-git-workflow-master.md +84 -0
  55. package/personas-teacher/engineering/engineering-incident-response-commander.md +444 -0
  56. package/personas-teacher/engineering/engineering-it-service-manager.md +561 -0
  57. package/personas-teacher/engineering/engineering-minimal-change-engineer.md +207 -0
  58. package/personas-teacher/engineering/engineering-mobile-app-builder.md +493 -0
  59. package/personas-teacher/engineering/engineering-multi-agent-systems-architect.md +600 -0
  60. package/personas-teacher/engineering/engineering-orgscript-engineer.md +113 -0
  61. package/personas-teacher/engineering/engineering-prompt-engineer.md +202 -0
  62. package/personas-teacher/engineering/engineering-rapid-prototyper.md +462 -0
  63. package/personas-teacher/engineering/engineering-senior-developer.md +176 -0
  64. package/personas-teacher/engineering/engineering-software-architect.md +112 -0
  65. package/personas-teacher/engineering/engineering-solidity-smart-contract-engineer.md +522 -0
  66. package/personas-teacher/engineering/engineering-sre.md +90 -0
  67. package/personas-teacher/engineering/engineering-technical-writer.md +393 -0
  68. package/personas-teacher/engineering/engineering-voice-ai-integration-engineer.md +561 -0
  69. package/personas-teacher/engineering/engineering-wechat-mini-program-developer.md +350 -0
  70. package/personas-teacher/engineering/engineering-wordpress-shopping-cart.md +346 -0
  71. package/personas-teacher/examples/README.md +48 -0
  72. package/personas-teacher/examples/nexus-spatial-discovery.md +852 -0
  73. package/personas-teacher/examples/workflow-book-chapter.md +55 -0
  74. package/personas-teacher/examples/workflow-landing-page.md +119 -0
  75. package/personas-teacher/examples/workflow-startup-mvp.md +155 -0
  76. package/personas-teacher/examples/workflow-with-memory.md +238 -0
  77. package/personas-teacher/finance/finance-bookkeeper-controller.md +260 -0
  78. package/personas-teacher/finance/finance-financial-analyst.md +234 -0
  79. package/personas-teacher/finance/finance-fpa-analyst.md +263 -0
  80. package/personas-teacher/finance/finance-investment-researcher.md +272 -0
  81. package/personas-teacher/finance/finance-tax-strategist.md +239 -0
  82. package/personas-teacher/game-development/blender/blender-addon-engineer.md +234 -0
  83. package/personas-teacher/game-development/game-audio-engineer.md +264 -0
  84. package/personas-teacher/game-development/game-designer.md +167 -0
  85. package/personas-teacher/game-development/godot/godot-gameplay-scripter.md +334 -0
  86. package/personas-teacher/game-development/godot/godot-multiplayer-engineer.md +297 -0
  87. package/personas-teacher/game-development/godot/godot-shader-developer.md +266 -0
  88. package/personas-teacher/game-development/level-designer.md +208 -0
  89. package/personas-teacher/game-development/narrative-designer.md +243 -0
  90. package/personas-teacher/game-development/roblox-studio/roblox-avatar-creator.md +297 -0
  91. package/personas-teacher/game-development/roblox-studio/roblox-experience-designer.md +305 -0
  92. package/personas-teacher/game-development/roblox-studio/roblox-systems-scripter.md +325 -0
  93. package/personas-teacher/game-development/technical-artist.md +229 -0
  94. package/personas-teacher/game-development/unity/unity-architect.md +271 -0
  95. package/personas-teacher/game-development/unity/unity-editor-tool-developer.md +310 -0
  96. package/personas-teacher/game-development/unity/unity-multiplayer-engineer.md +321 -0
  97. package/personas-teacher/game-development/unity/unity-shader-graph-artist.md +269 -0
  98. package/personas-teacher/game-development/unreal-engine/unreal-multiplayer-architect.md +313 -0
  99. package/personas-teacher/game-development/unreal-engine/unreal-systems-engineer.md +310 -0
  100. package/personas-teacher/game-development/unreal-engine/unreal-technical-artist.md +256 -0
  101. package/personas-teacher/game-development/unreal-engine/unreal-world-builder.md +273 -0
  102. package/personas-teacher/gis/gis-3d-scene-developer.md +111 -0
  103. package/personas-teacher/gis/gis-analyst.md +91 -0
  104. package/personas-teacher/gis/gis-bim-specialist.md +108 -0
  105. package/personas-teacher/gis/gis-cartography-designer.md +150 -0
  106. package/personas-teacher/gis/gis-drone-reality-mapping.md +120 -0
  107. package/personas-teacher/gis/gis-geoai-ml-engineer.md +105 -0
  108. package/personas-teacher/gis/gis-geoprocessing-specialist.md +97 -0
  109. package/personas-teacher/gis/gis-qa-engineer.md +133 -0
  110. package/personas-teacher/gis/gis-solution-engineer.md +101 -0
  111. package/personas-teacher/gis/gis-spatial-data-engineer.md +97 -0
  112. package/personas-teacher/gis/gis-spatial-data-scientist.md +111 -0
  113. package/personas-teacher/gis/gis-technical-consultant.md +86 -0
  114. package/personas-teacher/gis/gis-web-gis-developer.md +108 -0
  115. package/personas-teacher/marketing/marketing-aeo-foundations.md +264 -0
  116. package/personas-teacher/marketing/marketing-agentic-search-optimizer.md +311 -0
  117. package/personas-teacher/marketing/marketing-ai-citation-strategist.md +170 -0
  118. package/personas-teacher/marketing/marketing-app-store-optimizer.md +321 -0
  119. package/personas-teacher/marketing/marketing-baidu-seo-specialist.md +226 -0
  120. package/personas-teacher/marketing/marketing-bilibili-content-strategist.md +199 -0
  121. package/personas-teacher/marketing/marketing-book-co-author.md +110 -0
  122. package/personas-teacher/marketing/marketing-carousel-growth-engine.md +199 -0
  123. package/personas-teacher/marketing/marketing-china-ecommerce-operator.md +283 -0
  124. package/personas-teacher/marketing/marketing-china-market-localization-strategist.md +283 -0
  125. package/personas-teacher/marketing/marketing-content-creator.md +54 -0
  126. package/personas-teacher/marketing/marketing-cross-border-ecommerce.md +259 -0
  127. package/personas-teacher/marketing/marketing-douyin-strategist.md +149 -0
  128. package/personas-teacher/marketing/marketing-email-strategist.md +249 -0
  129. package/personas-teacher/marketing/marketing-global-podcast-strategist.md +206 -0
  130. package/personas-teacher/marketing/marketing-growth-hacker.md +54 -0
  131. package/personas-teacher/marketing/marketing-instagram-curator.md +113 -0
  132. package/personas-teacher/marketing/marketing-kuaishou-strategist.md +223 -0
  133. package/personas-teacher/marketing/marketing-linkedin-content-creator.md +214 -0
  134. package/personas-teacher/marketing/marketing-livestream-commerce-coach.md +305 -0
  135. package/personas-teacher/marketing/marketing-multi-platform-publisher.md +217 -0
  136. package/personas-teacher/marketing/marketing-podcast-strategist.md +277 -0
  137. package/personas-teacher/marketing/marketing-pr-communications-manager.md +473 -0
  138. package/personas-teacher/marketing/marketing-private-domain-operator.md +308 -0
  139. package/personas-teacher/marketing/marketing-reddit-community-builder.md +123 -0
  140. package/personas-teacher/marketing/marketing-seo-specialist.md +321 -0
  141. package/personas-teacher/marketing/marketing-short-video-editing-coach.md +412 -0
  142. package/personas-teacher/marketing/marketing-social-media-strategist.md +125 -0
  143. package/personas-teacher/marketing/marketing-tiktok-strategist.md +125 -0
  144. package/personas-teacher/marketing/marketing-twitter-engager.md +126 -0
  145. package/personas-teacher/marketing/marketing-video-optimization-specialist.md +119 -0
  146. package/personas-teacher/marketing/marketing-wechat-official-account.md +145 -0
  147. package/personas-teacher/marketing/marketing-weibo-strategist.md +240 -0
  148. package/personas-teacher/marketing/marketing-x-twitter-intelligence-analyst.md +161 -0
  149. package/personas-teacher/marketing/marketing-xiaohongshu-specialist.md +138 -0
  150. package/personas-teacher/marketing/marketing-zhihu-strategist.md +162 -0
  151. package/personas-teacher/paid-media/paid-media-auditor.md +71 -0
  152. package/personas-teacher/paid-media/paid-media-creative-strategist.md +71 -0
  153. package/personas-teacher/paid-media/paid-media-paid-social-strategist.md +71 -0
  154. package/personas-teacher/paid-media/paid-media-ppc-strategist.md +71 -0
  155. package/personas-teacher/paid-media/paid-media-programmatic-buyer.md +71 -0
  156. package/personas-teacher/paid-media/paid-media-search-query-analyst.md +71 -0
  157. package/personas-teacher/paid-media/paid-media-tracking-specialist.md +71 -0
  158. package/personas-teacher/product/product-behavioral-nudge-engine.md +80 -0
  159. package/personas-teacher/product/product-feedback-synthesizer.md +119 -0
  160. package/personas-teacher/product/product-manager.md +469 -0
  161. package/personas-teacher/product/product-sprint-prioritizer.md +154 -0
  162. package/personas-teacher/product/product-trend-researcher.md +159 -0
  163. package/personas-teacher/project-management/project-management-experiment-tracker.md +198 -0
  164. package/personas-teacher/project-management/project-management-jira-workflow-steward.md +230 -0
  165. package/personas-teacher/project-management/project-management-meeting-notes-specialist.md +95 -0
  166. package/personas-teacher/project-management/project-management-project-shepherd.md +194 -0
  167. package/personas-teacher/project-management/project-management-studio-operations.md +200 -0
  168. package/personas-teacher/project-management/project-management-studio-producer.md +203 -0
  169. package/personas-teacher/project-management/project-manager-senior.md +135 -0
  170. package/personas-teacher/sales/sales-account-strategist.md +227 -0
  171. package/personas-teacher/sales/sales-coach.md +271 -0
  172. package/personas-teacher/sales/sales-deal-strategist.md +180 -0
  173. package/personas-teacher/sales/sales-discovery-coach.md +225 -0
  174. package/personas-teacher/sales/sales-engineer.md +182 -0
  175. package/personas-teacher/sales/sales-offer-lead-gen-strategist.md +257 -0
  176. package/personas-teacher/sales/sales-outbound-strategist.md +201 -0
  177. package/personas-teacher/sales/sales-pipeline-analyst.md +267 -0
  178. package/personas-teacher/sales/sales-proposal-strategist.md +217 -0
  179. package/personas-teacher/security/security-appsec-engineer.md +491 -0
  180. package/personas-teacher/security/security-architect.md +304 -0
  181. package/personas-teacher/security/security-blockchain-security-auditor.md +463 -0
  182. package/personas-teacher/security/security-cloud-security-architect.md +523 -0
  183. package/personas-teacher/security/security-compliance-auditor.md +158 -0
  184. package/personas-teacher/security/security-incident-responder.md +437 -0
  185. package/personas-teacher/security/security-penetration-tester.md +399 -0
  186. package/personas-teacher/security/security-senior-secops.md +750 -0
  187. package/personas-teacher/security/security-threat-detection-engineer.md +534 -0
  188. package/personas-teacher/security/security-threat-intelligence-analyst.md +644 -0
  189. package/personas-teacher/spatial-computing/macos-spatial-metal-engineer.md +337 -0
  190. package/personas-teacher/spatial-computing/terminal-integration-specialist.md +70 -0
  191. package/personas-teacher/spatial-computing/visionos-spatial-engineer.md +54 -0
  192. package/personas-teacher/spatial-computing/xr-cockpit-interaction-specialist.md +32 -0
  193. package/personas-teacher/spatial-computing/xr-immersive-developer.md +32 -0
  194. package/personas-teacher/spatial-computing/xr-interface-architect.md +32 -0
  195. package/personas-teacher/specialized/accounts-payable-agent.md +185 -0
  196. package/personas-teacher/specialized/agentic-identity-trust.md +387 -0
  197. package/personas-teacher/specialized/agents-orchestrator.md +367 -0
  198. package/personas-teacher/specialized/automation-governance-architect.md +216 -0
  199. package/personas-teacher/specialized/business-strategist.md +488 -0
  200. package/personas-teacher/specialized/change-management-consultant.md +497 -0
  201. package/personas-teacher/specialized/chief-financial-officer.md +388 -0
  202. package/personas-teacher/specialized/corporate-training-designer.md +192 -0
  203. package/personas-teacher/specialized/customer-service.md +398 -0
  204. package/personas-teacher/specialized/customer-success-manager.md +460 -0
  205. package/personas-teacher/specialized/data-consolidation-agent.md +60 -0
  206. package/personas-teacher/specialized/data-privacy-officer.md +412 -0
  207. package/personas-teacher/specialized/esg-sustainability-officer.md +396 -0
  208. package/personas-teacher/specialized/government-digital-presales-consultant.md +363 -0
  209. package/personas-teacher/specialized/grant-writer.md +511 -0
  210. package/personas-teacher/specialized/healthcare-customer-service.md +389 -0
  211. package/personas-teacher/specialized/healthcare-marketing-compliance.md +395 -0
  212. package/personas-teacher/specialized/hospitality-guest-services.md +603 -0
  213. package/personas-teacher/specialized/hr-onboarding.md +451 -0
  214. package/personas-teacher/specialized/identity-graph-operator.md +260 -0
  215. package/personas-teacher/specialized/language-translator.md +264 -0
  216. package/personas-teacher/specialized/legal-billing-time-tracking.md +569 -0
  217. package/personas-teacher/specialized/legal-client-intake.md +492 -0
  218. package/personas-teacher/specialized/legal-document-review.md +454 -0
  219. package/personas-teacher/specialized/loan-officer-assistant.md +555 -0
  220. package/personas-teacher/specialized/lsp-index-engineer.md +314 -0
  221. package/personas-teacher/specialized/ma-integration-manager.md +427 -0
  222. package/personas-teacher/specialized/medical-billing-coding-specialist.md +491 -0
  223. package/personas-teacher/specialized/operations-manager.md +399 -0
  224. package/personas-teacher/specialized/organizational-psychologist.md +391 -0
  225. package/personas-teacher/specialized/personal-growth-mentor.md +159 -0
  226. package/personas-teacher/specialized/real-estate-buyer-seller.md +596 -0
  227. package/personas-teacher/specialized/recruitment-specialist.md +509 -0
  228. package/personas-teacher/specialized/report-distribution-agent.md +65 -0
  229. package/personas-teacher/specialized/retail-customer-returns.md +566 -0
  230. package/personas-teacher/specialized/sales-data-extraction-agent.md +67 -0
  231. package/personas-teacher/specialized/sales-outreach.md +425 -0
  232. package/personas-teacher/specialized/specialized-chief-of-staff.md +279 -0
  233. package/personas-teacher/specialized/specialized-civil-engineer.md +356 -0
  234. package/personas-teacher/specialized/specialized-cultural-intelligence-strategist.md +88 -0
  235. package/personas-teacher/specialized/specialized-developer-advocate.md +317 -0
  236. package/personas-teacher/specialized/specialized-document-generator.md +55 -0
  237. package/personas-teacher/specialized/specialized-french-consulting-market.md +192 -0
  238. package/personas-teacher/specialized/specialized-korean-business-navigator.md +216 -0
  239. package/personas-teacher/specialized/specialized-mcp-builder.md +248 -0
  240. package/personas-teacher/specialized/specialized-model-qa.md +488 -0
  241. package/personas-teacher/specialized/specialized-pricing-analyst.md +243 -0
  242. package/personas-teacher/specialized/specialized-salesforce-architect.md +180 -0
  243. package/personas-teacher/specialized/specialized-strategy-duel-agent.md +130 -0
  244. package/personas-teacher/specialized/specialized-workflow-architect.md +597 -0
  245. package/personas-teacher/specialized/study-abroad-advisor.md +282 -0
  246. package/personas-teacher/specialized/supply-chain-strategist.md +582 -0
  247. package/personas-teacher/specialized/zk-steward.md +211 -0
  248. package/personas-teacher/strategy/EXECUTIVE-BRIEF.md +95 -0
  249. package/personas-teacher/strategy/QUICKSTART.md +194 -0
  250. package/personas-teacher/strategy/coordination/agent-activation-prompts.md +401 -0
  251. package/personas-teacher/strategy/coordination/handoff-templates.md +357 -0
  252. package/personas-teacher/strategy/nexus-strategy.md +1110 -0
  253. package/personas-teacher/strategy/playbooks/phase-0-discovery.md +178 -0
  254. package/personas-teacher/strategy/playbooks/phase-1-strategy.md +238 -0
  255. package/personas-teacher/strategy/playbooks/phase-2-foundation.md +278 -0
  256. package/personas-teacher/strategy/playbooks/phase-3-build.md +286 -0
  257. package/personas-teacher/strategy/playbooks/phase-4-hardening.md +332 -0
  258. package/personas-teacher/strategy/playbooks/phase-5-launch.md +277 -0
  259. package/personas-teacher/strategy/playbooks/phase-6-operate.md +318 -0
  260. package/personas-teacher/strategy/runbooks/scenario-enterprise-feature.md +157 -0
  261. package/personas-teacher/strategy/runbooks/scenario-incident-response.md +217 -0
  262. package/personas-teacher/strategy/runbooks/scenario-marketing-campaign.md +187 -0
  263. package/personas-teacher/strategy/runbooks/scenario-startup-mvp.md +154 -0
  264. package/personas-teacher/support/support-analytics-reporter.md +365 -0
  265. package/personas-teacher/support/support-executive-summary-generator.md +212 -0
  266. package/personas-teacher/support/support-finance-tracker.md +442 -0
  267. package/personas-teacher/support/support-infrastructure-maintainer.md +618 -0
  268. package/personas-teacher/support/support-legal-compliance-checker.md +588 -0
  269. package/personas-teacher/support/support-support-responder.md +585 -0
  270. package/personas-teacher/testing/testing-accessibility-auditor.md +316 -0
  271. package/personas-teacher/testing/testing-api-tester.md +306 -0
  272. package/personas-teacher/testing/testing-evidence-collector.md +210 -0
  273. package/personas-teacher/testing/testing-performance-benchmarker.md +268 -0
  274. package/personas-teacher/testing/testing-reality-checker.md +236 -0
  275. package/personas-teacher/testing/testing-test-results-analyzer.md +305 -0
  276. package/personas-teacher/testing/testing-tool-evaluator.md +394 -0
  277. package/personas-teacher/testing/testing-workflow-optimizer.md +450 -0
  278. package/personas-teacher/tools.json +19 -0
  279. package/skill/add/SKILL.md +20 -14
  280. package/skill/add/advisor.md +33 -34
  281. package/skill/add/confidence.md +1 -1
  282. package/skill/add/deltas.md +12 -13
  283. package/skill/add/design.md +52 -49
  284. package/skill/add/fold.md +18 -16
  285. package/skill/add/intake.md +4 -3
  286. package/skill/add/loop.md +3 -4
  287. package/skill/add/phases/0-ground.md +32 -34
  288. package/skill/add/phases/0-setup.md +22 -21
  289. package/skill/add/phases/1-specify.md +8 -6
  290. package/skill/add/phases/3-contract.md +1 -1
  291. package/skill/add/phases/4-tests.md +9 -10
  292. package/skill/add/phases/5-build.md +17 -13
  293. package/skill/add/phases/6-verify.md +9 -4
  294. package/skill/add/phases/7-observe.md +2 -1
  295. package/skill/add/report-template.md +62 -18
  296. package/skill/add/run.md +5 -3
  297. package/skill/add/scope.md +2 -2
  298. package/skill/add/sensitivity.md +51 -0
  299. package/skill/add/streams.md +31 -23
  300. package/tooling/add.py +906 -28
  301. package/tooling/add_engine/autonomy.py +36 -1
  302. package/tooling/add_engine/constants.py +56 -3
  303. package/tooling/add_engine/guidelines.py +22 -20
  304. package/tooling/add_engine/predicates.py +56 -3
  305. package/tooling/add_engine/search.py +165 -0
  306. package/tooling/templates/GLOSSARY.md.tmpl +16 -0
  307. package/tooling/templates/MILESTONE.md.tmpl +9 -0
  308. package/tooling/templates/PROMPT.persona.md.tmpl +44 -0
  309. package/tooling/templates/TASK.fast.md.tmpl +1 -0
  310. package/tooling/templates/TASK.md.tmpl +39 -6
  311. package/tooling/templates/gitignore.tmpl +12 -0
  312. package/tooling/templates/personas/_template.md.tmpl +60 -0
package/tooling/add.py CHANGED
@@ -36,8 +36,11 @@ from add_engine.constants import ( # the _-prefixed names (import * skips them)
36
36
  _GITIGNORE_BODY, _GUIDE_BEGIN, _GUIDE_END,
37
37
  _RULE_REF_LINE, _FALLBACK_TASK, _FALLBACK_TASK_FAST,
38
38
  _DEFAULT_WIDTH,
39
- _DELTA_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
39
+ _DELTA_RE, _PERSONA_TAG_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
40
+ _SEED_POINTER_RE, # shared (delta-task-backlink) — reads the `[→ slug]` seed stamp back
40
41
  _AUTONOMY_LEVELS, # shared (autonomy resolvers + _AUTONOMY_ORDER/cmd_autonomy)
42
+ _STREAMS_POSTURES, # shared (streams resolvers + cmd_streams) — run-mode streams half
43
+ _SENSITIVITY_VALUES, # shared (_task_sensitivity + cmd_freeze/status/audit) — risk-class taxonomy
41
44
  )
42
45
 
43
46
  # --- terminal-render primitives (moved to add_engine/render.py) -------------
@@ -78,8 +81,12 @@ from add_engine.taskdoc import (
78
81
  # --- autonomy-level resolvers (moved to add_engine/autonomy.py) -------------
79
82
  from add_engine.autonomy import (
80
83
  _autonomy_level, _effective_autonomy, _project_autonomy, _project_autonomy_token,
84
+ _project_streams, # run-mode streams half (persist-run-mode) — read live from PROJECT.md
81
85
  )
82
86
 
87
+ # --- keyword/substring corpus search (NEW — add_engine/search.py) -----------
88
+ from add_engine.search import _search_corpus
89
+
83
90
 
84
91
  def _phase_index(name: str) -> int:
85
92
  """Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
@@ -150,10 +157,151 @@ def _render_template(name: str, **subs: str) -> str:
150
157
  text = text.replace("{{" + key + "}}", val)
151
158
  return text
152
159
 
160
+
161
+ # --- TASK.md milestone backlink (task-milestone-backlink) --------------------
162
+ # The task↔milestone link is mirrored into the TASK.md header so the file names its
163
+ # own parent. The engine WRITES it (new-task) and MAINTAINS it (set-milestone); a
164
+ # milestone-free task reads the "(none)" sentinel, never blank. Keeping it engine-owned
165
+ # is what makes it drift-proof — `check` flags a hand-edited line that disagrees.
166
+ _MILESTONE_BACKLINK = "(none)"
167
+ _MILESTONE_LINE_RE = re.compile(r"(?m)^milestone:[^\n]*$")
168
+ _SLUG_LINE_RE = re.compile(r"(?m)^slug:[^\n]*$")
169
+
170
+
171
+ def _milestone_backlink_value(milestone) -> str:
172
+ """The header value for a milestone slug (or the sentinel when milestone-free)."""
173
+ return milestone if milestone else _MILESTONE_BACKLINK
174
+
175
+
176
+ def _set_milestone_line(text: str, value: str) -> str:
177
+ """Rewrite (or insert) the TASK.md header `milestone:` backlink — idempotent.
178
+
179
+ A grandfathered file lacking the line gets it inserted right after `slug:`; with no
180
+ slug line either, the text is returned unchanged (degrade-safe — never corrupts a doc).
181
+ """
182
+ line = f"milestone: {value}"
183
+ if _MILESTONE_LINE_RE.search(text):
184
+ return _MILESTONE_LINE_RE.sub(lambda _m: line, text, count=1)
185
+ m = _SLUG_LINE_RE.search(text)
186
+ if not m:
187
+ return text
188
+ return text[:m.end()] + "\n" + line + text[m.end():]
189
+
190
+
191
+ def _read_milestone_line(text: str):
192
+ """The current `milestone:` backlink value in a TASK.md header, or None if absent."""
193
+ m = _MILESTONE_LINE_RE.search(text)
194
+ return m.group(0)[len("milestone:"):].strip() if m else None
195
+
196
+
197
+ # --- MILESTONE.md release backlink (milestone-release-backlink) --------------
198
+ # The milestone↔release link is mirrored into the MILESTONE.md header: the template seeds
199
+ # `release: pending`; cmd_release STAMPS it to the cut version (the stamp rides the same
200
+ # all-or-nothing batch as CHANGELOG + RELEASES). The mirror of _set_milestone_line, one
201
+ # scope level up — keying on `^release:`, inserting after the `stage:` line if absent.
202
+ _RELEASE_LINE_RE = re.compile(r"(?m)^release:[^\n]*$")
203
+ _STAGE_LINE_RE = re.compile(r"(?m)^stage:[^\n]*$")
204
+
205
+
206
+ def _set_release_line(text: str, value: str) -> str:
207
+ """Rewrite (or insert) the MILESTONE.md header `release:` backlink — idempotent.
208
+
209
+ A grandfathered file lacking the line gets it inserted right after the `stage:` line;
210
+ with no stage line either, the text is returned unchanged (degrade-safe)."""
211
+ line = f"release: {value}"
212
+ if _RELEASE_LINE_RE.search(text):
213
+ return _RELEASE_LINE_RE.sub(lambda _m: line, text, count=1)
214
+ m = _STAGE_LINE_RE.search(text)
215
+ if not m:
216
+ return text
217
+ return text[:m.end()] + "\n" + line + text[m.end():]
218
+
219
+
220
+ # --- §0 GROUND drift anchor (ground-anchor-sha) -----------------------------
221
+ # §0 line numbers rot during BUILD while symbols survive (PR40 audit). The engine SEEDS a
222
+ # `Ground SHA:` field (the AI fills it via git — NO-EXEC: add.py never shells out) and `check`
223
+ # WARNs when a §0 cites bare line numbers without one, so drift is detectable not silent.
224
+ _GROUND_SHA_RE = re.compile(r"(?m)^Ground SHA:[ \t]*(.*?)[ \t]*$")
225
+ _LINE_REF_RE = re.compile(r"l\.\d+")
226
+
227
+
228
+ def _ground_section(text: str) -> str:
229
+ """The §0 GROUND block of a TASK.md — from the `## 0` heading to the next `## ` heading."""
230
+ m = re.search(r"(?m)^## 0\b", text)
231
+ if not m:
232
+ return ""
233
+ rest = text[m.end():]
234
+ nxt = re.search(r"(?m)^## ", rest)
235
+ return rest[:nxt.start()] if nxt else rest
236
+
237
+
238
+ def _read_ground_sha(text: str):
239
+ """The §0 `Ground SHA:` value, or None if absent or still a `<…>` placeholder."""
240
+ m = _GROUND_SHA_RE.search(_ground_section(text))
241
+ if not m:
242
+ return None
243
+ val = m.group(1).strip()
244
+ return None if (not val or val.startswith("<")) else val
245
+
246
+
247
+ def _ground_cites_line_ref(text: str) -> bool:
248
+ """True iff the §0 GROUND block cites a bare line number (the `l.NNN` idiom)."""
249
+ return bool(_LINE_REF_RE.search(_ground_section(text)))
250
+
251
+
252
+ def _seeded_delta_pointers(text: str) -> list[str]:
253
+ """The task slugs `[SPEC · seeded] … [→ <slug>]` lines point at (delta-task-backlink). PURE.
254
+
255
+ Walks the delta→task lineage backward: each seeded SPEC delta carries the slug it was seeded
256
+ into (the `[→ <slug>]` stamp `_resolve_spec_delta` appends). `check` flags a pointer that no
257
+ longer resolves to a live or archived task. Order-preserving; open/dropped deltas are ignored."""
258
+ out: list[str] = []
259
+ for ln in text.splitlines():
260
+ m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
261
+ if not m or m.group(2) != "seeded":
262
+ continue
263
+ p = _SEED_POINTER_RE.search(m.group(3))
264
+ if p:
265
+ out.append(p.group(1))
266
+ return out
267
+
268
+
269
+ # --- tidy a closed TASK.md (strip-scaffold-at-done) --------------------------
270
+ # A live TASK.md carries `<!-- … -->` instruction comments that guide the active phase; once the
271
+ # task is `done` they are dead weight (PR40 audit). cmd_gate strips them on a COMPLETING gate.
272
+ # Content-safe: fenced code blocks (```…```, incl. the frozen §3) pass through BYTE-EXACT — only
273
+ # comments OUTSIDE a fence are removed; idempotent.
274
+ _HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
275
+ _TRAILING_WS_RE = re.compile(r"(?m)[ \t]+$")
276
+ _BLANK_RUN_RE = re.compile(r"\n{3,}")
277
+
278
+
279
+ def _strip_live_scaffold(text: str) -> str:
280
+ """Remove `<!-- … -->` instruction comments from a TASK.md — fences untouched, idempotent.
281
+
282
+ Splits on fenced code blocks so a comment inside a ``` fence (e.g. the frozen §3) is never
283
+ touched; in the non-fence segments it drops comment spans, trims the trailing whitespace a
284
+ removal leaves on a line, and collapses 3+ consecutive newlines to one blank line."""
285
+ segs = re.split(r"(```.*?```)", text, flags=re.DOTALL)
286
+ for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
287
+ s = _HTML_COMMENT_RE.sub("", segs[i])
288
+ s = _TRAILING_WS_RE.sub("", s)
289
+ segs[i] = _BLANK_RUN_RE.sub("\n\n", s)
290
+ return "".join(segs)
291
+
292
+
293
+ def _contract_fingerprint(raw3: str) -> str:
294
+ """md5 of the §3 CONTRACT CONTENT — comment-normalized + outer-whitespace-canonical (the
295
+ instruction comment is scaffolding, not contract). Used on BOTH tamper-guard sides
296
+ (_tripwire_snapshot + _tripwire_divergence) so the at-done strip — which removes the §3
297
+ comment and shifts the section's boundary whitespace — never reads as `contract_tampered`,
298
+ while a real fenced-shape edit still does."""
299
+ return _md5_text(_strip_live_scaffold(raw3).strip())
300
+
153
301
  # --- state/markdown predicates (moved to add_engine/predicates.py) -----------
154
302
  from add_engine.predicates import (
155
303
  _phase_owner, _setup_locked, _milestone_confirmed, _section_unfilled,
156
- _task_done,
304
+ _task_done, _persona_missing, _persona_slug_valid, _rule_coverage_gaps,
157
305
  )
158
306
 
159
307
  # --- git-native identity/actor seam (moved to add_engine/identity.py) --------
@@ -390,6 +538,19 @@ def cmd_init(args: argparse.Namespace) -> None:
390
538
  continue
391
539
  _atomic_write(dest, rendered)
392
540
 
541
+ # --run-mode: apply the paired autonomy + streams posture into PROJECT.md.
542
+ # ONLY when the flag is explicitly set — absent flag leaves PROJECT.md byte-identical.
543
+ run_mode = getattr(args, "run_mode", None)
544
+ if run_mode is not None:
545
+ _level = run_mode # "auto" | "conservative"
546
+ _posture = "parallel" if run_mode == "auto" else "sequential"
547
+ proj_md = root / "PROJECT.md"
548
+ if proj_md.exists():
549
+ _text = proj_md.read_text(encoding="utf-8")
550
+ _text = _autonomy_decl_line(_text, _level)
551
+ _text = _streams_decl_line(_text, _posture)
552
+ _atomic_write(proj_md, _text)
553
+
393
554
  state = {
394
555
  "project": proj_name,
395
556
  "stage": args.stage,
@@ -496,10 +657,18 @@ def cmd_new_task(args: argparse.Namespace) -> None:
496
657
  rendered = _render_template(
497
658
  "TASK.fast.md" if fast else "TASK.md",
498
659
  title=title, slug=slug, date=date.today().isoformat(),
499
- stage=state["stage"], autonomy=autonomy)
660
+ stage=state["stage"], autonomy=autonomy,
661
+ milestone=_milestone_backlink_value(milestone))
500
662
  if feature_override: # pre-fill §1 from the seeded delta
501
663
  rendered = re.sub(r"(?m)^Feature:.*$",
502
664
  lambda _m: f"Feature: {feature_override}", rendered, count=1)
665
+ if from_delta: # delta-task-backlink: §0 reverse link
666
+ # pre-fill the §0 Related-intent PLACEHOLDER only (the `<…>` line a fresh full template
667
+ # carries) — mirrors the §1 Feature pre-fill, gated by from_delta, count=1. The fast
668
+ # template has no §0 Related-intent line, so the sub is a silent no-op there.
669
+ _bl = f"Related intent: seeded from {prior} spec-delta — \"{delta_text}\" [← {prior}]"
670
+ rendered = re.sub(r"(?m)^Related intent:\s*<.*>\s*$",
671
+ lambda _m: _bl, rendered, count=1)
503
672
  seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
504
673
  if flipped_prior is not None: # consume the source delta -> seeded
505
674
  seed_writes.append((prior_md, flipped_prior))
@@ -697,6 +866,15 @@ def cmd_freeze(args: argparse.Namespace) -> None:
697
866
  if not _flag_well_formed(raw3):
698
867
  _die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
699
868
  f"('Least-sure flag surfaced at freeze:' + a [part] tag) before it freezes")
869
+ # the human declares the risk-CLASS at freeze (risk-sensitivity-taxonomy): a present-but-
870
+ # unknown sensitivity token is refused here (validate-then-write — nothing is written);
871
+ # an absent token is grandfathered (allowed), a valid member proceeds. The engine never
872
+ # classifies — it only validates the human's declaration.
873
+ _valid_sens = _project_sensitivity_values(root) # base ∪ project GLOSSARY classes (sensitivity-glossary)
874
+ if _task_sensitivity(_task_header(root, slug), valid=_valid_sens) == "?":
875
+ _die(f"sensitivity_invalid: {slug} declares an unknown sensitivity — use one of "
876
+ f"{', '.join(_valid_sens)} (or add the class to GLOSSARY.md's '## Sensitivity classes', "
877
+ "or omit the line)")
700
878
  # --- write ---
701
879
  ver = _next_freeze_version(state, slug)
702
880
  who = args.by or identity._actor_stamp(state)["name"]
@@ -956,6 +1134,71 @@ def cmd_advance(args: argparse.Namespace) -> None:
956
1134
  # not be able to fool the guard either way).
957
1135
  _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
958
1136
 
1137
+ # sensitivity taxonomy (risk-sensitivity-taxonomy): the risk-CLASS the human declares in the
1138
+ # TASK header at freeze — same anchored declaration grammar as risk:/autonomy: (line-start or
1139
+ # `·`, value stops at whitespace/`<`/`#`/`|`), so a title/prose substring is never a declaration.
1140
+ _SENSITIVITY_RE = re.compile(r"(?:^|·)[ \t]*sensitivity:[ \t]*([^\s<#|]+)", re.MULTILINE)
1141
+
1142
+ def _task_sensitivity(hdr: str, valid=None):
1143
+ """The declared sensitivity from a TASK.md header region (HTML comments already stripped by
1144
+ _task_header). A member of `valid`, None when no `sensitivity:` line is present, or "?" when a
1145
+ REAL token outside `valid` was written. `valid` defaults to _SENSITIVITY_VALUES (the universal
1146
+ base — back-compat); callers that honor a project's GLOSSARY domain classes pass
1147
+ valid=_project_sensitivity_values(root) (sensitivity-glossary). PURE — the engine validates a
1148
+ human-declared token against the project's vocabulary, it never infers it (mirrors _autonomy_level)."""
1149
+ if valid is None:
1150
+ valid = _SENSITIVITY_VALUES
1151
+ m = _SENSITIVITY_RE.search(hdr)
1152
+ if not m:
1153
+ return None
1154
+ tok = m.group(1).strip().lower()
1155
+ return tok if tok in valid else "?"
1156
+
1157
+
1158
+ # sensitivity-glossary: a project EXTENDS the universal base with domain risk-classes declared in
1159
+ # GLOSSARY.md's "## Sensitivity classes" section (the AI keeps it current per the skill guide). The
1160
+ # base four stay method-universal (advisor-gate-relax keys off `mechanical`) — a project never
1161
+ # REMOVES them. Read live like _project_autonomy/_project_streams (no state.json field). The first
1162
+ # GLOSSARY reader in the engine; degrade-safe by construction (design-for-failure).
1163
+ _SENS_CLASSES_HEADING_RE = re.compile(r"(?im)^##[ \t]+sensitivity[ \t]+classes\b.*$")
1164
+ # a domain line is "- <token>: …" or "- <token> — …"; the token must START with a letter, so a
1165
+ # placeholder ("- <token>: …") begins with "<" and never matches, and the ": "/"—" separator keeps
1166
+ # a prose bullet from being read as a class. HTML comments are stripped FIRST (mirrors _project_
1167
+ # autonomy/_project_streams) so a commented-out template example is never a declaration.
1168
+ _SENS_CLASS_LINE_RE = re.compile(r"(?m)^[ \t]*-[ \t]+([A-Za-z][\w-]*)[ \t]*(?::|—)")
1169
+
1170
+ def _project_sensitivity_domain(root: Path) -> tuple:
1171
+ """Domain sensitivity tokens declared in GLOSSARY.md's "## Sensitivity classes" section,
1172
+ lowercased and deduped in document order. FAIL-SAFE: no GLOSSARY.md / no section / unreadable
1173
+ -> () (the caller always unions the base in, so the vocabulary is never empty)."""
1174
+ try:
1175
+ text = (root / "GLOSSARY.md").read_text(encoding="utf-8")
1176
+ except OSError:
1177
+ return ()
1178
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.S) # a commented example is never a declaration
1179
+ m = _SENS_CLASSES_HEADING_RE.search(text)
1180
+ if not m:
1181
+ return ()
1182
+ body = text[m.end():]
1183
+ nxt = re.search(r"(?m)^##[ \t]", body) # stop at the next section heading
1184
+ if nxt:
1185
+ body = body[:nxt.start()]
1186
+ seen, out = set(), []
1187
+ for tok in (t.strip().lower() for t in _SENS_CLASS_LINE_RE.findall(body)):
1188
+ if tok and tok not in seen:
1189
+ seen.add(tok)
1190
+ out.append(tok)
1191
+ return tuple(out)
1192
+
1193
+ def _project_sensitivity_values(root: Path) -> tuple:
1194
+ """The full sensitivity vocabulary for a project: the universal base _SENSITIVITY_VALUES
1195
+ (always present, listed first) ∪ the project's GLOSSARY.md domain classes. Base-first, deduped."""
1196
+ out = list(_SENSITIVITY_VALUES)
1197
+ for tok in _project_sensitivity_domain(root):
1198
+ if tok not in out:
1199
+ out.append(tok)
1200
+ return tuple(out)
1201
+
959
1202
  # the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
960
1203
  # manual < conservative < auto, declared as a per-task `autonomy:` header token.
961
1204
  # anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
@@ -978,6 +1221,66 @@ def _autonomy_lowered(hdr: str) -> bool:
978
1221
  return _autonomy_level(hdr) in ("manual", "conservative")
979
1222
 
980
1223
 
1224
+ # advisor-gate-relax helpers: read the "### Advisor 3-lens verdict" SUB-SECTION
1225
+ # of body6, not the whole §6, so the refute-read's Verdict/Residue lines are
1226
+ # never mistaken for the advisor's fields. Fail-safe: both return False when the
1227
+ # advisor block is absent → the guard fires (conservative by design).
1228
+
1229
+ def _advisor_slice(body6: str) -> str:
1230
+ """Return the '### Advisor 3-lens verdict' sub-section text from §6 body.
1231
+ Returns '' when the block is absent (fail-safe)."""
1232
+ m = re.search(r"(?m)^### Advisor 3-lens verdict\b", body6)
1233
+ if not m:
1234
+ return ""
1235
+ nxt = re.search(r"(?m)^### ", body6[m.end():])
1236
+ end = m.end() + nxt.start() if nxt else len(body6)
1237
+ return body6[m.start():end]
1238
+
1239
+
1240
+ def _advisor_verdict_is_pass(body6: str) -> bool:
1241
+ """True iff the Advisor 3-lens verdict sub-section declares Verdict: PASS…
1242
+ Scoped to the advisor block only — the §6 Refute-read Verdict line is excluded.
1243
+ Fail-safe: False when the advisor block is absent (guard fires)."""
1244
+ slc = _advisor_slice(body6)
1245
+ m = re.search(r"(?m)^Verdict:[ \t]*(\S+)", slc)
1246
+ return bool(m) and m.group(1).upper().startswith("PASS")
1247
+
1248
+
1249
+ def _advisor_no_residue(body6: str) -> bool:
1250
+ """True iff the Advisor 3-lens verdict sub-section declares Residue: none.
1251
+ Fail-safe: False when the advisor block is absent (guard fires)."""
1252
+ slc = _advisor_slice(body6)
1253
+ m = re.search(r"(?m)^Residue:[ \t]*(\S+)", slc)
1254
+ return bool(m) and m.group(1).strip().lower() == "none"
1255
+
1256
+
1257
+ # step-spawn-hint (advisor-gated-autonomy): the engine's pinned terse copy of each phase
1258
+ # guide's Advisor hook — one spawn idiom per phase. contract/done are ABSENT (no batch to
1259
+ # fan out: a freeze is one human decision; done is closed). Advisory ONLY — the engine never
1260
+ # spawns; the line just NAMES the agent shape a parallel run would use at this step.
1261
+ _SPAWN_HINTS = {
1262
+ "ground": "broad sweep",
1263
+ "specify": "domain researcher",
1264
+ "scenarios": "wide scenario sweep",
1265
+ "tests": "red-suite test-author",
1266
+ "build": "independent well-scoped batch",
1267
+ "verify": "earned-green refute-read",
1268
+ "observe": "lessons-mining reviewer",
1269
+ }
1270
+
1271
+
1272
+ def _spawn_hint_line(task: dict, autonomy: str) -> str | None:
1273
+ """PURE: the one-line per-step spawn hint for the ACTIVE task, or None. Suppressed when
1274
+ the autonomy level is `manual` (the human drives every step) or the phase has no idiom
1275
+ (contract/done). The tier reflects declared risk: a `risk: high` task earns `top`
1276
+ (your strongest agent), everything else `mid`. Never spawns, never mutates state."""
1277
+ phase = task.get("phase")
1278
+ if autonomy == "manual" or phase not in _SPAWN_HINTS:
1279
+ return None
1280
+ tier = "top" if task.get("risk") == "high" else "mid"
1281
+ return f"spawn hint: {phase} → {_SPAWN_HINTS[phase]} (tier: {tier})"
1282
+
1283
+
981
1284
  def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
982
1285
  """True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
983
1286
  SINGLE source the footer marker and the guide TEXT marker both render (task
@@ -1023,8 +1326,16 @@ def cmd_gate(args: argparse.Namespace) -> None:
1023
1326
  # the mechanized high-risk guard: an unguarded high-risk header refuses
1024
1327
  # COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
1025
1328
  # owns the gate. HARD-STOP is never blocked — stopping is always allowed.
1329
+ # advisor-gate-relax: a mechanical task whose Advisor 3-lens verdict shows
1330
+ # PASS + Residue: none is the one exception — it may auto-complete even
1331
+ # without a lowered dial. Only sensitivity:"mechanical" qualifies; security/
1332
+ # data/architecture never relax; an absent advisor block is fail-safe False.
1026
1333
  hdr = _task_header(root, slug)
1027
- 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:
1028
1339
  _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
1029
1340
  "without a lowered autonomy level — run `add.py autonomy set conservative` "
1030
1341
  "(or manual); a human must own a high-risk gate (run.md guard)")
@@ -1072,6 +1383,15 @@ def cmd_gate(args: argparse.Namespace) -> None:
1072
1383
  _sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
1073
1384
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1074
1385
  _stamp_adr_record(root, state, slug) # adr-at-observe: harvest §7 Decisions (ADR) — AFTER §6 is mirrored
1386
+ if completing: # strip-scaffold-at-done: tidy the now-closed
1387
+ _tf = root / "tasks" / slug / "TASK.md" # TASK.md LAST (after the stampers) — drop the
1388
+ try: # live-phase `<!-- -->` comments; fences untouched.
1389
+ _tt = _tf.read_text(encoding="utf-8")
1390
+ _st = _strip_live_scaffold(_tt)
1391
+ if _st != _tt:
1392
+ _atomic_write(_tf, _st)
1393
+ except OSError:
1394
+ pass # degrade-safe — the verdict is already in state
1075
1395
  print(f"task '{slug}' gate -> {args.outcome}")
1076
1396
  _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
1077
1397
  if _gbar:
@@ -1109,6 +1429,20 @@ def _autonomy_decl_line(text: str, level: str) -> str:
1109
1429
  return f"autonomy: {level}\n" + text
1110
1430
 
1111
1431
 
1432
+ def _streams_decl_line(text: str, posture: str) -> str:
1433
+ """Rewrite the SINGLE `streams:` declaration line to `posture`, PRESERVING its trailing comment,
1434
+ idempotently (replace in place, count=1 — never a second line). If absent, insert it after a
1435
+ leading `#` heading (PROJECT.md), else prepend. PURE on the text; the caller does the atomic
1436
+ write. Mirrors _autonomy_decl_line — streams is project-scoped, so there is no slug-line branch."""
1437
+ pat = re.compile(r"(?m)^(streams:[ \t]*)[^\s<#|]+(.*)$")
1438
+ if pat.search(text):
1439
+ return pat.sub(lambda m: f"{m.group(1)}{posture}{m.group(2)}", text, count=1)
1440
+ lines = text.splitlines(keepends=True)
1441
+ if lines and lines[0].lstrip().startswith("#"):
1442
+ return lines[0] + f"streams: {posture}\n" + "".join(lines[1:])
1443
+ return f"streams: {posture}\n" + text
1444
+
1445
+
1112
1446
  def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
1113
1447
  """RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
1114
1448
  freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
@@ -1158,6 +1492,25 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
1158
1492
  _print_autonomy(root, state, slug)
1159
1493
 
1160
1494
 
1495
+ def cmd_streams(args: argparse.Namespace) -> None:
1496
+ """show / set the project STREAMS posture — the parallel-vs-sequential half of the run mode
1497
+ (persist-run-mode). Project-scoped (parallelism is ACROSS tasks, so there is no per-task posture)
1498
+ and unguarded by a raise (parallel drops no human gate — it only overlaps builds). The setter
1499
+ mirrors cmd_autonomy's --project branch: a PURE _streams_decl_line + atomic write into PROJECT.md.
1500
+ state.json is UNTOUCHED — the posture lives in PROJECT.md beside autonomy."""
1501
+ root = _require_root() # reused -> "no .add/ project found …"
1502
+ if (getattr(args, "action", None) or "show") == "show":
1503
+ print(f"streams: {_project_streams(root)}")
1504
+ return
1505
+ posture = args.posture
1506
+ if posture not in _STREAMS_POSTURES:
1507
+ _die("streams_posture_invalid: posture must be one of "
1508
+ f"{', '.join(_STREAMS_POSTURES)} (got {posture!r})")
1509
+ target = root / "PROJECT.md"
1510
+ _atomic_write(target, _streams_decl_line(target.read_text(encoding="utf-8"), posture))
1511
+ print(f"project streams -> {posture}")
1512
+
1513
+
1161
1514
  def cmd_todo(args: argparse.Namespace) -> None:
1162
1515
  """Capture / list / close a lightweight backlog todo (task: todo-capture).
1163
1516
 
@@ -1437,18 +1790,41 @@ def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
1437
1790
  return PLAIN
1438
1791
 
1439
1792
 
1793
+ _STATUS_PAGE_SIZE = 10 # status-pagination: default cap on milestones:/tasks: lists; --all lifts it
1794
+
1795
+
1796
+ def _sorted_by_updated(items: dict) -> list:
1797
+ """Return `items.items()` sorted by each record's `updated` timestamp, newest first.
1798
+ Read/serialization-time only — never mutates the caller's dict or state.json order."""
1799
+ return sorted(items.items(), key=lambda kv: kv[1].get("updated") or "", reverse=True)
1800
+
1801
+
1440
1802
  def cmd_status(args: argparse.Namespace) -> None:
1803
+ show_all = getattr(args, "all", False)
1441
1804
  if getattr(args, "json", False):
1442
1805
  root, state = _load_state_for_json()
1443
1806
  tasks = state.get("tasks") or {}
1807
+ task_slug = getattr(args, "task", None)
1808
+ if task_slug:
1809
+ t = tasks.get(task_slug)
1810
+ if t is None:
1811
+ _die("unknown_task")
1812
+ print(json.dumps({"slug": task_slug, "phase": t.get("phase"), "gate": t.get("gate"),
1813
+ "milestone": t.get("milestone"),
1814
+ "owner": t.get("owner"), "assignee": t.get("assignee")}))
1815
+ return
1444
1816
  milestones = state.get("milestones") or {}
1817
+ sorted_ms = _sorted_by_updated(milestones)
1818
+ page_ms = sorted_ms if show_all else sorted_ms[:_STATUS_PAGE_SIZE]
1445
1819
  ms_list = []
1446
- for mslug, m in milestones.items():
1820
+ for mslug, m in page_ms:
1447
1821
  members = [t for t in tasks.values() if t.get("milestone") == mslug]
1448
1822
  ms_list.append({"slug": mslug, "status": m.get("status", "active"),
1449
1823
  "done": sum(1 for t in members if _task_done(t)),
1450
1824
  "total": len(members),
1451
1825
  "owner": m.get("owner"), "assignee": m.get("assignee")})
1826
+ sorted_tasks = _sorted_by_updated(tasks)
1827
+ page_tasks = sorted_tasks if show_all else sorted_tasks[:_STATUS_PAGE_SIZE]
1452
1828
  grad_ready, grad_met, grad_total = _graduation_ready(root, state)
1453
1829
  print(json.dumps({
1454
1830
  "project": state.get("project"), "stage": state.get("stage"),
@@ -1457,10 +1833,12 @@ def cmd_status(args: argparse.Namespace) -> None:
1457
1833
  "active_milestones": list(state.get("active_milestones") or []),
1458
1834
  "active_tasks": dict(state.get("active_tasks") or {}),
1459
1835
  "milestones": ms_list,
1836
+ "milestones_total": len(milestones),
1460
1837
  "tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
1461
1838
  "milestone": t.get("milestone"),
1462
1839
  "owner": t.get("owner"), "assignee": t.get("assignee")}
1463
- for s, t in tasks.items()],
1840
+ for s, t in page_tasks],
1841
+ "tasks_total": len(tasks),
1464
1842
  "graduation_ready": grad_ready,
1465
1843
  "stage_criteria": {"met": grad_met, "total": grad_total}}))
1466
1844
  return
@@ -1475,6 +1853,9 @@ def cmd_status(args: argparse.Namespace) -> None:
1475
1853
  # project autonomy default (task init-auto-default): the posture new tasks INHERIT,
1476
1854
  # read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
1477
1855
  print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
1856
+ # run mode (persist-run-mode): the combined streams + autonomy posture, both read LIVE from
1857
+ # PROJECT.md so the human sees the whole run-mode throttle every session. Advisory; engine never spawns.
1858
+ print(f"run mode: {_project_streams(root)} + {_project_autonomy(root)}")
1478
1859
  # git-native actor (user-identity): who ADD sees you as this session — the identity every
1479
1860
  # human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
1480
1861
  _who = identity._whoami(state)
@@ -1495,6 +1876,9 @@ def cmd_status(args: argparse.Namespace) -> None:
1495
1876
  _gr_cited, _gr_total = _exit_criteria_cited(root, _active_ms)
1496
1877
  _gr_state = "auto-ready ✓" if _goal_auto_ready(root, _active_ms) else "NOT auto-ready"
1497
1878
  print(f"goal-ready: {_gr_state} ({_gr_cited}/{_gr_total} exit criteria cite a verifier)")
1879
+ # dag-plan snapshot freshness (persist-dag-plan): per-active-milestone, read-only —
1880
+ # fresh ✓ / stale / none / unreadable vs the LIVE depends_on edges. Advisory; never writes.
1881
+ print(_dag_plan_status_line(root, state, _active_ms))
1498
1882
  # foundation pointer — read the cross-milestone context first (anti-rot)
1499
1883
  if (root / "PROJECT.md").exists():
1500
1884
  print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
@@ -1516,12 +1900,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1516
1900
  active_ms = _active_milestone(state)
1517
1901
  if milestones:
1518
1902
  print("milestones:")
1519
- 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:
1520
1906
  members = [t for t in tasks.values() if t.get("milestone") == mslug]
1521
1907
  done = sum(1 for t in members if _task_done(t))
1522
1908
  mark = "*" if mslug in (state.get("active_milestones") or []) else " "
1523
1909
  print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
1524
1910
  f" status={m.get('status', 'active')}")
1911
+ if not show_all and len(_sorted_ms) > _STATUS_PAGE_SIZE:
1912
+ print(f" … {len(_sorted_ms) - _STATUS_PAGE_SIZE} more (see status --all)")
1525
1913
  # graduation cue (v22): project-global + read-only. Fires only when every milestone
1526
1914
  # is done AND the human's PROJECT.md stage-goal-criteria are all checked — additive
1527
1915
  # (a new line solely when ready; the non-ready output is byte-identical to before).
@@ -1590,6 +1978,19 @@ def cmd_status(args: argparse.Namespace) -> None:
1590
1978
  # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
1591
1979
  if active and active in tasks:
1592
1980
  print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
1981
+ # the human-declared risk-CLASS (risk-sensitivity-taxonomy): present-only when a valid
1982
+ # sensitivity is declared; "unset" cue when absent; "?" surfaces a typo to fix at freeze.
1983
+ _sens = _task_sensitivity(_task_header(root, active), valid=_project_sensitivity_values(root))
1984
+ print(f"sensitivity: {('unset' if _sens is None else _sens)}")
1985
+ # step-spawn-hint (advisor-gated-autonomy): one advisory line naming the agent shape a
1986
+ # parallel run would fan out at THIS step. Present-only (None → no line): suppressed under
1987
+ # the `manual` dial and at contract/done. The tier reflects declared `risk: high`.
1988
+ _hint = _spawn_hint_line(
1989
+ {"phase": tasks[active].get("phase"),
1990
+ "risk": "high" if _RISK_HIGH_RE.search(_task_header(root, active)) else None},
1991
+ _project_autonomy(root))
1992
+ if _hint:
1993
+ print(_hint)
1593
1994
  # owner/assignee of the active task (ownership-assignment) — present-only, never a
1594
1995
  # placeholder; an unassigned active task adds no line (additive-cue convention).
1595
1996
  _own = _fmt_ownership(tasks[active])
@@ -1619,12 +2020,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1619
2020
  print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
1620
2021
  return
1621
2022
  print("tasks :")
1622
- 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:
1623
2026
  mark = "*" if slug == active else " "
1624
2027
  deps = t.get("depends_on") or []
1625
2028
  dep_s = f" deps={','.join(deps)}" if deps else ""
1626
2029
  ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
1627
2030
  print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
2031
+ if not show_all and len(_sorted_tasks) > _STATUS_PAGE_SIZE:
2032
+ print(f" … {len(_sorted_tasks) - _STATUS_PAGE_SIZE} more (see status --all)")
1628
2033
  # fold-pressure nudge: surface unfolded competency deltas so emission can't
1629
2034
  # silently outrun the human fold (read-only; v11). Silent when none are open.
1630
2035
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
@@ -1749,6 +2154,14 @@ def cmd_guide(args: argparse.Namespace) -> None:
1749
2154
  print(f"guide : {gp}")
1750
2155
  elif phase in _PHASE_GUIDE_FILES:
1751
2156
  print("guide : (phase guides not installed — npx @pilotspace/add init)")
2157
+ # step-spawn-hint (advisor-gated-autonomy): one advisory line naming the agent shape a parallel
2158
+ # run would fan out at THIS step. Present-only: suppressed under `manual` and at contract/done.
2159
+ _hint = _spawn_hint_line(
2160
+ {"phase": phase,
2161
+ "risk": "high" if _RISK_HIGH_RE.search(_task_header(root, slug)) else None},
2162
+ _project_autonomy(root))
2163
+ if _hint:
2164
+ print(_hint)
1752
2165
  if phase == "verify":
1753
2166
  print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
1754
2167
  elif phase == "done":
@@ -2287,6 +2700,31 @@ def cmd_components(args: argparse.Namespace) -> None:
2287
2700
  raise SystemExit(1)
2288
2701
 
2289
2702
 
2703
+ def cmd_search(args: argparse.Namespace) -> None:
2704
+ """Read-only keyword/substring search over the milestone/task corpus (active
2705
+ + archived) — title/goal/rationale (milestone) or title/Feature (task) lines
2706
+ only, never full body, never graph traversal (context-search, search-index).
2707
+ Fresh per-call scan via add_engine.search._search_corpus — no persisted
2708
+ index/cache. Exit 0 always, including zero matches; --json mirrors
2709
+ check/ready's own machine-readable convention."""
2710
+ root = find_root()
2711
+ if root is None:
2712
+ _die("no_project")
2713
+ hits = _search_corpus(root, args.keywords)
2714
+ if getattr(args, "json", False):
2715
+ print(json.dumps([{k: h[k] for k in ("slug", "kind", "status", "snippet")}
2716
+ for h in hits], ensure_ascii=False, indent=2))
2717
+ return
2718
+ query = " ".join(args.keywords)
2719
+ if not hits:
2720
+ print(f"no matches for: {query}")
2721
+ return
2722
+ print(f"{len(hits)} match(es) for: {query}")
2723
+ for h in hits:
2724
+ print(f"{h['slug']} [{h['kind']}, {h['status']}] ({h['count']} match(es))")
2725
+ print(f" {h['snippet']}")
2726
+
2727
+
2290
2728
  def cmd_check(args: argparse.Namespace) -> None:
2291
2729
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
2292
2730
  as_json = getattr(args, "json", False)
@@ -2330,6 +2768,46 @@ def cmd_check(args: argparse.Namespace) -> None:
2330
2768
  # the intake flow — NOT a failure. Names structure, never the act of intake.
2331
2769
  warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add "
2332
2770
  "intake flow (or attach with --milestone)"))
2771
+ # backlink-drift (task-milestone-backlink): the TASK.md `milestone:` header mirrors state.
2772
+ # WARN (never red, warn-never-block) when a PRESENT line disagrees; an ABSENT line is a
2773
+ # grandfathered task — silent, never retro-red. Degrade-safe: an unreadable file skips here.
2774
+ try:
2775
+ _task_text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
2776
+ except OSError:
2777
+ _task_text = None
2778
+ _bl = _read_milestone_line(_task_text) if _task_text is not None else None
2779
+ if _bl is not None and _bl != _milestone_backlink_value(ms):
2780
+ warnings.append((f"task '{slug}'", f"milestone backlink '{_bl}' disagrees with state "
2781
+ f"'{_milestone_backlink_value(ms)}' — re-run `add.py set-milestone "
2782
+ f"{slug} {ms or 'none'}` to re-sync"))
2783
+ # §0 drift anchor (ground-anchor-sha): a §0 that cites bare line numbers (l.NNN) with no
2784
+ # `Ground SHA:` has undetectable drift. WARN (never red, warn-never-block); a §0 with a SHA,
2785
+ # with no line refs, or an unreadable file is silent. Reuses the read above (one read).
2786
+ if _task_text is not None and _ground_cites_line_ref(_task_text) and \
2787
+ _read_ground_sha(_task_text) is None:
2788
+ warnings.append((f"task '{slug}'", "§0 cites line numbers (l.NNN) with no `Ground SHA:` — "
2789
+ "record `git rev-parse --short HEAD` so drift is detectable"))
2790
+ # dangling lineage (delta-task-backlink): a `[SPEC · seeded] … [→ ptr]` whose pointer task
2791
+ # is neither live nor archived. WARN (never red); reuses the read above. `_archived_task_slugs`
2792
+ # is the same resolver `cmd_ready` trusts (archived ⇒ was PASS-done), so a healthy
2793
+ # completed-then-archived seed stays silent.
2794
+ if _task_text is not None:
2795
+ _arch = _archived_task_slugs(state)
2796
+ for _ptr in _seeded_delta_pointers(_task_text):
2797
+ if _ptr not in tasks and _ptr not in _arch:
2798
+ warnings.append((f"task '{slug}'", f"seeded SPEC delta points at '{_ptr}' which no "
2799
+ "longer exists (dangling lineage) — re-point or drop the delta"))
2800
+ # rule-id-coverage: a §1 Must/Reject ID with no §2 scenario tag and no §4 `covers:`
2801
+ # reference is a coverage gap. WARN only, runs in ANY phase (a gap in already-shipped
2802
+ # work must still surface — the whole point of the check); opt-in per task via
2803
+ # `_rule_coverage_gaps`'s own tag-presence gate, so a task that never adopted the M#/
2804
+ # R:code convention is silently grandfathered — never retro-flagged.
2805
+ if _task_text is not None:
2806
+ _spans = _phase_spans(_task_text)
2807
+ for _rid, _kind in _rule_coverage_gaps(_spans.get(1, ""), _spans.get(2, ""), _spans.get(4, "")):
2808
+ warnings.append((f"task '{slug}'", f"rule '{_rid}' ({_kind}) has no §2 scenario tag "
2809
+ "and no §4 test covering it (coverage gap) — add a scenario tag "
2810
+ "or a covers: line"))
2333
2811
  # autonomy level (task explicit-autonomy-dial): a REAL out-of-set token is a hard
2334
2812
  # unknown_autonomy_level; a LIVE task (phase before done/observe) with no `autonomy:`
2335
2813
  # line is implicit_autonomy — a WARN, never red. Done/observe predecessors are SKIPPED
@@ -2432,6 +2910,27 @@ def cmd_check(args: argparse.Namespace) -> None:
2432
2910
  "(scope_violation pending) — the verify gate "
2433
2911
  "will refuse it"))
2434
2912
 
2913
+ # persona-setup: validate each persona living doc (.add/personas/*.md) presence-based
2914
+ # (measure-not-block) — a missing required key/section is a WARN naming the slug, never a
2915
+ # hard failure; a conformant persona is an INFO affirmation. NO-EXEC: pure read + predicate.
2916
+ personas_dir = root / "personas"
2917
+ if personas_dir.is_dir():
2918
+ for pf in sorted(personas_dir.glob("*.md")):
2919
+ slug = pf.stem
2920
+ if not _persona_slug_valid(slug):
2921
+ warnings.append((f"persona '{slug}'",
2922
+ "persona_slug_invalid — rename to alphanumeric with - or _ only"))
2923
+ continue
2924
+ try:
2925
+ missing = _persona_missing(pf.read_text(encoding="utf-8"))
2926
+ except OSError:
2927
+ missing = ["(unreadable)"]
2928
+ if missing:
2929
+ warnings.append((f"persona '{slug}'",
2930
+ "persona_schema_incomplete: missing " + ", ".join(missing)))
2931
+ else:
2932
+ infos.append((f"persona '{slug}'", "schema-conformant"))
2933
+
2435
2934
  # drift: a done milestone must have no unfinished tasks
2436
2935
  for mslug, m in milestones.items():
2437
2936
  if m.get("status") == "done":
@@ -2470,6 +2969,17 @@ def cmd_check(args: argparse.Namespace) -> None:
2470
2969
  f"task '{_at}' froze its contract without grounding — fill the "
2471
2970
  "§0 GROUND anchors the contract cites (add.py guide)"))
2472
2971
 
2972
+ # sensitivity-glossary: nudge a project to declare its DOMAIN sensitivity classes. WARN, NEVER
2973
+ # red (measure-not-block, mirrors goal_not_auto_ready) — the base four always apply; this only
2974
+ # invites the project's own risk-class vocabulary into GLOSSARY.md (the AI maintains it per the
2975
+ # skill guide). Fires IFF the "## Sensitivity classes" section declares no domain class.
2976
+ if not _project_sensitivity_domain(root):
2977
+ warnings.append(("sensitivity_classes_unset",
2978
+ "no domain sensitivity classes declared — add the project's risk-class "
2979
+ "vocabulary to GLOSSARY.md's '## Sensitivity classes' section (the base "
2980
+ "security|data|architecture|mechanical always apply; the AI keeps the "
2981
+ "domain classes current — see the sensitivity skill guide)"))
2982
+
2473
2983
  # wave-ledger fork-base (engine-merge-base-enforcement): the engine EXECUTES the
2474
2984
  # streams.md rule — every roster echo must match `base:`. A FILLED mismatch is red at
2475
2985
  # ANY status; a pending row is red at `status: merging` (merge-time strictness) but only
@@ -2545,6 +3055,30 @@ def cmd_check(args: argparse.Namespace) -> None:
2545
3055
  f".add/design/captures/{_pname}.<png|svg|…> — render + confirm it "
2546
3056
  "before build (design.md beat 4)"))
2547
3057
 
3058
+ # roster-uninstalled (roster-install-drift): the ADD-managed guideline block cites the agent
3059
+ # roster ("agents/*.md" tail — matches both the shipped `add-method/agents/*.md` attribution
3060
+ # citation and any older phrasing) but the project may have no roster installed at all — never
3061
+ # shipped in the package, synced before this fix, or from a build that regressed the agents/
3062
+ # tree — a dead reference with no signal anywhere. WARN, never red (measure-not-block);
3063
+ # presence-gated on the citation itself — a project whose guideline files don't cite a roster
3064
+ # at all is silently exempt, never retro-flagged.
3065
+ _project_root = root.parent
3066
+ _cites_roster = False
3067
+ for _gname in GUIDELINE_FILES:
3068
+ try:
3069
+ if "agents/*.md" in (_project_root / _gname).read_text(encoding="utf-8"):
3070
+ _cites_roster = True
3071
+ break
3072
+ except OSError:
3073
+ pass
3074
+ if _cites_roster:
3075
+ _agents_dir = _project_root / ".claude" / "agents"
3076
+ if not (_agents_dir.is_dir() and any(_agents_dir.glob("add-*.md"))):
3077
+ warnings.append(("roster_uninstalled",
3078
+ "guideline file(s) cite the agent roster but no `.claude/agents/"
3079
+ "add-*.md` files are installed — run `add.py update` (or re-run the "
3080
+ "CLI installer) to materialize them"))
3081
+
2548
3082
  passed = sum(1 for ok, _, _ in checks if ok)
2549
3083
  failed = len(checks) - passed
2550
3084
  if as_json:
@@ -3215,6 +3749,88 @@ def cmd_waves(args: argparse.Namespace) -> None:
3215
3749
  print("\n".join(_wave_block_lines(state, t, s)))
3216
3750
 
3217
3751
 
3752
+ # --- persisted DAG-plan snapshot (persist-dag-plan) --------------------------------
3753
+ # `waves` recomputes the schedule live and is the authority; `dag-plan` MATERIALIZES that
3754
+ # computed schedule into a committed, auditable per-milestone snapshot + a freshness check
3755
+ # against the live edges. The snapshot is NEVER the authority — only a checkable projection.
3756
+ def _dag_plan_path(root: Path, mslug: str) -> Path:
3757
+ return root / "milestones" / mslug / "dag-plan.json"
3758
+
3759
+
3760
+ def _edges_fingerprint(state: dict, mslug: str) -> str:
3761
+ """md5 over the EDGE STRUCTURE: every member's (done OR open) sorted depends_on. PURE.
3762
+ Invariant under a task COMPLETING (phase/gate change, not edges) — so completion is NOT
3763
+ drift — and changes only when a dep is added/removed/redirected or a member added/removed."""
3764
+ tasks = state.get("tasks") or {}
3765
+ edges = {s: sorted(t.get("depends_on") or [])
3766
+ for s, t in tasks.items() if t.get("milestone") == mslug}
3767
+ return _md5_text(json.dumps(edges, sort_keys=True))
3768
+
3769
+
3770
+ def _dag_plan_freshness(root: Path, state: dict, mslug: str) -> tuple[str, dict | None]:
3771
+ """Read the snapshot and compare its stored fingerprint to the live one. Fail-safe:
3772
+ absent -> ("none", None) · unreadable/garbled -> ("unreadable", None) · match -> "fresh"
3773
+ · mismatch -> "stale". Never raises on a missing/corrupt file (no traceback to the user)."""
3774
+ sp = _dag_plan_path(root, mslug)
3775
+ if not sp.exists():
3776
+ return ("none", None)
3777
+ try:
3778
+ snap = json.loads(sp.read_text(encoding="utf-8"))
3779
+ except (OSError, ValueError):
3780
+ return ("unreadable", None)
3781
+ if not isinstance(snap, dict) or "edges_fingerprint" not in snap:
3782
+ return ("unreadable", None)
3783
+ live = _edges_fingerprint(state, mslug)
3784
+ return (("fresh" if snap.get("edges_fingerprint") == live else "stale"), snap)
3785
+
3786
+
3787
+ def _dag_plan_status_line(root: Path, state: dict, mslug: str) -> str:
3788
+ """The single `dag-plan:` line cmd_status prints for the active milestone."""
3789
+ fresh, snap = _dag_plan_freshness(root, state, mslug)
3790
+ if fresh == "fresh":
3791
+ return "dag-plan: fresh ✓"
3792
+ if fresh == "stale":
3793
+ return f"dag-plan: stale (edges changed since {(snap or {}).get('generated', '?')})"
3794
+ if fresh == "unreadable":
3795
+ return "dag-plan: unreadable — run add.py dag-plan"
3796
+ return "dag-plan: none — run add.py dag-plan"
3797
+
3798
+
3799
+ def cmd_dag_plan(args: argparse.Namespace) -> None:
3800
+ """RECORD-ONLY: materialize the active (or --milestone) milestone's computed DAG schedule
3801
+ into a committed snapshot + an edge fingerprint. Reuses cmd_waves' rejects; writes NOTHING
3802
+ on any reject. Idempotent: an unchanged fingerprint leaves the file byte-identical."""
3803
+ root = _require_root()
3804
+ state = load_state(root)
3805
+ mslug = getattr(args, "milestone", None) or _active_milestone(state)
3806
+ if not mslug:
3807
+ _die("no_active_milestone: no active milestone and no --milestone given")
3808
+ if mslug not in (state.get("milestones") or {}):
3809
+ _die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
3810
+ sched = _wave_schedule(state, mslug)
3811
+ if "cycle" in sched:
3812
+ _die(f"dependency_cycle: not-done deps form a cycle "
3813
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
3814
+ live_fp = _edges_fingerprint(state, mslug)
3815
+ sp = _dag_plan_path(root, mslug)
3816
+ # idempotent: an unchanged fingerprint leaves the snapshot (and its `generated` date) untouched
3817
+ if sp.exists():
3818
+ try:
3819
+ cur = json.loads(sp.read_text(encoding="utf-8"))
3820
+ except (OSError, ValueError):
3821
+ cur = None
3822
+ if isinstance(cur, dict) and cur.get("edges_fingerprint") == live_fp:
3823
+ print(f"dag-plan: {mslug} already fresh ✓ (generated {cur.get('generated', '?')})")
3824
+ return
3825
+ snap = {"milestone": mslug, "generated": date.today().isoformat(),
3826
+ "edges_fingerprint": live_fp, "schedule": sched}
3827
+ sp.parent.mkdir(parents=True, exist_ok=True)
3828
+ _atomic_write(sp, json.dumps(snap, indent=2, sort_keys=True) + "\n")
3829
+ nwaves = len(sched.get("waves") or [])
3830
+ ntasks = sum(len(w) for w in (sched.get("waves") or []))
3831
+ print(f"dag-plan: wrote {mslug} — {nwaves} wave(s), {ntasks} task(s) (fresh ✓)")
3832
+
3833
+
3218
3834
  def cmd_milestone_done(args: argparse.Namespace) -> None:
3219
3835
  root = _require_root()
3220
3836
  state = load_state(root)
@@ -3431,6 +4047,17 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
3431
4047
  state["tasks"][task]["milestone"] = new
3432
4048
  state["tasks"][task]["updated"] = _now()
3433
4049
  save_state(root, state)
4050
+ # keep the TASK.md `milestone:` backlink in lockstep with state (task-milestone-backlink):
4051
+ # rewrite the header line (insert it if a grandfathered file lacks it). Degrade-safe — a
4052
+ # missing/unreadable TASK.md never blocks the move (state is already the source of truth).
4053
+ task_md = root / "tasks" / task / "TASK.md"
4054
+ try:
4055
+ _txt = task_md.read_text(encoding="utf-8")
4056
+ _new_txt = _set_milestone_line(_txt, _milestone_backlink_value(new))
4057
+ if _new_txt != _txt:
4058
+ _atomic_write(task_md, _new_txt)
4059
+ except OSError:
4060
+ pass
3434
4061
  print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
3435
4062
  print(_next_footer(root, state))
3436
4063
 
@@ -3605,7 +4232,10 @@ def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
3605
4232
  except (ValueError, OSError):
3606
4233
  rel = str(f)
3607
4234
  tests[rel] = h
3608
- return {"contract_md5": _md5_text(raw3), "tests": tests}
4235
+ # strip-scaffold-at-done: fingerprint the contract CONTENT (comment-normalized, see
4236
+ # _contract_fingerprint) so the at-done comment strip is invisible to the tamper guard; a real
4237
+ # fenced-shape edit still changes it. Mirrored byte-for-byte in _tripwire_divergence.
4238
+ return {"contract_md5": _contract_fingerprint(raw3), "tests": tests}
3609
4239
 
3610
4240
 
3611
4241
  def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
@@ -3613,7 +4243,9 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
3613
4243
  path directly (never re-globs), so a weakened, deleted, or unreadable test file
3614
4244
  and an edited frozen §3 all surface. Fail-closed: an unreadable file -> diverged."""
3615
4245
  diffs: list[str] = []
3616
- if _md5_text(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
4246
+ # compare the contract CONTENT fingerprint (strip-scaffold-at-done) — same normalization as the
4247
+ # snapshot, so the at-done comment strip never reads as tampering; a real shape edit still does.
4248
+ if _contract_fingerprint(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
3617
4249
  diffs.append("contract_tampered")
3618
4250
  rootp = root.parent.resolve()
3619
4251
  for rel, snap in (tw.get("tests") or {}).items():
@@ -3632,7 +4264,10 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
3632
4264
  # likewise NOT a source touch — counting one produced repeated false `scope_violation`s in
3633
4265
  # consuming projects (`.next/`, `coverage/`, `tsconfig.tsbuildinfo`, whose `incremental`
3634
4266
  # rewrite even races a clean re-snapshot), so they are pruned here too.
3635
- _SCOPE_EXCLUDE_DIRS = (".git", ".add", "__pycache__", "node_modules", ".serena",
4267
+ # `.claude` is an agent-tool internal dir (config/skills/worktrees) like `.serena` never a
4268
+ # task's declared source; without it, the walk descends into `.claude/worktrees/<wt>/` (linked
4269
+ # git worktrees: full branch checkouts) and their churn produces false `scope_violation`s.
4270
+ _SCOPE_EXCLUDE_DIRS = (".git", ".add", ".claude", "__pycache__", "node_modules", ".serena",
3636
4271
  ".next", "coverage", "test-results")
3637
4272
  _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildinfo by suffix
3638
4273
  _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
@@ -4509,8 +5144,15 @@ def _flag_well_formed(raw3: str) -> bool:
4509
5144
  refused unless it takes the honest escape 'none material — biggest risk: X'.
4510
5145
  why/cost stay a human-read convention, never machine keywords (evidence: the
4511
5146
  lived flags use em-dash/prose, never literal because/if-wrong). HTML comments
4512
- (template hints) never count. PURE fail-closed on a missing label."""
4513
- body = re.sub(r"<!--.*?-->", "", raw3, flags=re.S)
5147
+ (template hints) never count. Fence-aware (mirrors _strip_live_scaffold): a
5148
+ frozen §3 may legitimately quote a bare `<!--` inside its own fenced code
5149
+ block (documenting an HTML-comment invariant) — that must never merge with
5150
+ an unrelated `-->` found later in the raw text. PURE — fail-closed on a
5151
+ missing label."""
5152
+ segs = re.split(r"(```.*?```)", raw3, flags=re.DOTALL)
5153
+ for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
5154
+ segs[i] = re.sub(r"<!--.*?-->", "", segs[i], flags=re.S)
5155
+ body = "".join(segs)
4514
5156
  m = _FLAG_LABEL_RE.search(body)
4515
5157
  if not m:
4516
5158
  return False
@@ -4936,7 +5578,12 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
4936
5578
  delta_text, evidence = em.group(1).strip(), em.group(2).strip()
4937
5579
  else:
4938
5580
  delta_text, evidence = tail, ""
4939
- by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence})
5581
+ # OPTIONAL persona target + section hint (persona-self-improve) None for a plain lesson.
5582
+ pm = _PERSONA_TAG_RE.search(unit[0])
5583
+ persona = pm.group(1).strip() if pm else None
5584
+ hint = pm.group(2).strip() if pm else None
5585
+ by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence,
5586
+ "persona": persona, "hint": hint})
4940
5587
  return by_comp
4941
5588
 
4942
5589
 
@@ -5062,7 +5709,11 @@ def _select_spec_delta(text: str, match: str | None = None,
5062
5709
  # literal, both exempt via MACHINE_CONSTANTS.
5063
5710
  _FOLD_VERB = "fold" # the subcommand / decision-record verb
5064
5711
  _FOLDED = "folded" # the resolved status value
5065
- _COMP_OPEN_TOKEN_RE = re.compile(r"(\[\s*(?:DDD|SDD|UDD|TDD|ADD)\s*·\s*)open(\s*\])")
5712
+ # group(2) carries any OPTIONAL persona annotation (`· persona:<slug> · <hint>`) so flipping
5713
+ # open->folded preserves it byte-for-byte (persona-self-improve).
5714
+ _COMP_OPEN_TOKEN_RE = re.compile(
5715
+ r"(\[\s*(?:DDD|SDD|UDD|TDD|ADD)\s*·\s*)open"
5716
+ r"(\s*(?:·\s*persona:[^\s·\]]+\s*·\s*[^·\]]+?\s*)?\])")
5066
5717
 
5067
5718
  # competency -> (foundation file, section-heading PREFIX) — fold.md's routing table. DDD/SDD/UDD
5068
5719
  # land in PROJECT.md sections; TDD/ADD in CONVENTIONS.md (they ARE the engine). Total over the five.
@@ -5076,6 +5727,13 @@ _FOLD_ROUTES = {
5076
5727
  _KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail section (every session adds one row)
5077
5728
  _TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
5078
5729
 
5730
+ # persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
5731
+ # foundation file. The section HINT picks the growable section; only these two are routable.
5732
+ _PERSONA_FOLD_SECTIONS = {
5733
+ "critical-rule": "## Critical Rules",
5734
+ "success-metric": "## Success Metrics",
5735
+ }
5736
+
5079
5737
 
5080
5738
  def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
5081
5739
  """Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
@@ -5170,11 +5828,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
5170
5828
  prev_v = int(vm.group(1))
5171
5829
  new_v = prev_v + 1
5172
5830
 
5173
- # routing every selected lesson's destination section (and the audit-trail section) must exist.
5831
+ # A lesson with a `persona:<slug>` target routes to a persona living doc, NOT a foundation file
5832
+ # (persona-self-improve). Partition first so the foundation-route checks skip persona lessons.
5833
+ persona_sel = [it for it in selected if it.get("persona")]
5834
+ found_sel = [it for it in selected if not it.get("persona")]
5835
+
5836
+ # routing — every FOUNDATION lesson's destination section (and the audit-trail section) must exist.
5174
5837
  conventions_md = root / "CONVENTIONS.md"
5175
5838
  conventions_text = conventions_md.read_text(encoding="utf-8") if conventions_md.exists() else ""
5176
5839
  file_text = {"PROJECT.md": project_text, "CONVENTIONS.md": conventions_text}
5177
- for it in selected:
5840
+ for it in found_sel:
5178
5841
  fname, heading = _FOLD_ROUTES[it["comp"]]
5179
5842
  if not _section_present(file_text[fname], heading):
5180
5843
  _die(f"missing_route_section: {fname} has no '{heading}' section for a "
@@ -5183,6 +5846,21 @@ def cmd_fold(args: argparse.Namespace) -> None:
5183
5846
  _die(f"missing_route_section: PROJECT.md has no '{_KEY_DECISIONS_HEADING}' "
5184
5847
  "section for the audit-trail row — add the section header and re-run")
5185
5848
 
5849
+ # persona routing — validate fail-closed BEFORE any write (design-for-failure): the section hint
5850
+ # must be routable and the target persona file must exist. No network, no child launch — pure IO.
5851
+ persona_paths: dict[str, Path] = {}
5852
+ for it in persona_sel:
5853
+ hint = (it.get("hint") or "").strip()
5854
+ if hint not in _PERSONA_FOLD_SECTIONS:
5855
+ _die(f"persona_section_unroutable: '{hint or '(none)'}' is not a growable persona "
5856
+ f"section — use one of {', '.join(_PERSONA_FOLD_SECTIONS)}")
5857
+ slug = it["persona"]
5858
+ ppath = root / "personas" / f"{slug}.md"
5859
+ if not _persona_slug_valid(slug) or not ppath.is_file():
5860
+ _die(f"missing_persona_target: no .add/personas/{slug}.md for a persona lesson — "
5861
+ "seed the persona first (setup) or fix the slug")
5862
+ persona_paths[slug] = ppath
5863
+
5186
5864
  # ── build EVERY edit in memory before writing anything ──────────────────────────────────────
5187
5865
  comps_filter = {want_comp} if want_comp else None
5188
5866
  task_new: dict[str, str] = {}
@@ -5198,15 +5876,40 @@ def cmd_fold(args: argparse.Namespace) -> None:
5198
5876
  return (f"- ({it['comp']}) {it['text']}{ev} "
5199
5877
  f"[{_FOLDED} foundation-version {new_v} · from {it['task']}]")
5200
5878
 
5201
- # transcribe verbatim (reverse so canonical-order first lands on top, newest-first).
5879
+ def _persona_bullet(it):
5880
+ ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
5881
+ return (f"- {it['text']}{ev} "
5882
+ f"[{_FOLDED} {date.today().isoformat()} · from {it['task']}]")
5883
+
5884
+ # transcribe FOUNDATION lessons verbatim (reverse so canonical-order first lands on top, newest-first).
5202
5885
  proj_text, conv_text = project_text, conventions_text
5203
- for it in reversed(selected):
5886
+ for it in reversed(found_sel):
5204
5887
  fname, heading = _FOLD_ROUTES[it["comp"]]
5205
5888
  if fname == "PROJECT.md":
5206
5889
  proj_text = _prepend_to_section(proj_text, heading, _bullet(it))
5207
5890
  else:
5208
5891
  conv_text = _prepend_to_section(conv_text, heading, _bullet(it))
5209
5892
 
5893
+ # transcribe PERSONA lessons into their living docs — PREPEND newest-first under the hinted
5894
+ # section, NEVER clobbering (every prior persona line survives; the schema stays conformant).
5895
+ from collections import Counter
5896
+ persona_new: dict[str, str] = {}
5897
+ for it in reversed(persona_sel):
5898
+ slug = it["persona"]
5899
+ before = persona_new.get(slug) or persona_paths[slug].read_text(encoding="utf-8")
5900
+ heading = _PERSONA_FOLD_SECTIONS[it["hint"].strip()]
5901
+ if not _section_present(before, heading): # defensive: a conformant persona has it
5902
+ _die(f"persona_section_unroutable: .add/personas/{slug}.md has no '{heading}' section")
5903
+ after = _prepend_to_section(before, heading, _persona_bullet(it))
5904
+ # never-clobber INVARIANT: every pre-existing line must survive the merge.
5905
+ if Counter(before.splitlines()) - Counter(after.splitlines()):
5906
+ _die(f"persona_clobber_forbidden: consolidating into {slug}.md would drop existing content")
5907
+ # post-merge INVARIANT: the four required persona sections survive.
5908
+ if _persona_missing(after):
5909
+ _die(f"persona_clobber_forbidden: consolidating into {slug}.md broke the persona schema "
5910
+ f"(missing {', '.join(_persona_missing(after))})")
5911
+ persona_new[slug] = after
5912
+
5210
5913
  counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
5211
5914
  count_str = " · ".join(f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c])
5212
5915
  scope = "all" if not (want_task or want_comp) else " ".join(
@@ -5229,6 +5932,10 @@ def cmd_fold(args: argparse.Namespace) -> None:
5229
5932
  for slug, body in task_new.items():
5230
5933
  writes.append((root / "tasks" / slug / "TASK.md", body))
5231
5934
  touched.append(f"{len(task_new)} TASK.md")
5935
+ for slug, body in persona_new.items():
5936
+ writes.append((persona_paths[slug], body))
5937
+ if persona_new:
5938
+ touched.append(f"{len(persona_new)} persona")
5232
5939
  _atomic_write_many(writes)
5233
5940
 
5234
5941
  print(f"{_FOLDED} {len(selected)} lessons -> foundation-version {new_v}")
@@ -5303,8 +6010,14 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
5303
6010
  # F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
5304
6011
  # high-risk record must show a guarded dial AND a human at the gate —
5305
6012
  # catches post-gate header tampering and auto-resolved high-risk gates.
6013
+ # advisor-gate-relax: a mechanical task whose Advisor 3-lens verdict shows
6014
+ # PASS + Residue: none is exempt — the outer condition is False so both
6015
+ # sub-branches are skipped. Non-mechanical (security/data/architecture)
6016
+ # never relax; an absent advisor block is fail-safe False → guard fires.
5306
6017
  hdr = _task_header(root, slug)
5307
- if _RISK_HIGH_RE.search(hdr):
6018
+ if _RISK_HIGH_RE.search(hdr) and not (_task_sensitivity(hdr) == "mechanical"
6019
+ and _advisor_verdict_is_pass(s6)
6020
+ and _advisor_no_residue(s6)):
5308
6021
  if not _autonomy_lowered(hdr):
5309
6022
  f(slug, "unguarded_high_risk_auto",
5310
6023
  "risk: high declared but autonomy is not lowered (manual or conservative)")
@@ -5348,6 +6061,21 @@ def _freeze_skip_notices(state: dict) -> list[dict]:
5348
6061
  # region only (mirrors _RISK_HIGH_RE's anchoring so a title substring can never look like one).
5349
6062
  _RISK_ANY_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*\S", re.MULTILINE)
5350
6063
 
6064
+ # A single `Reported:` line (report-rendered-trace) — scoped to ONE phase body at a time by the
6065
+ # caller (bodies.get(3, "")/bodies.get(6, "")), so §3 and §6 never cross-contaminate each other.
6066
+ _REPORTED_LINE_RE = re.compile(r"(?m)^Reported:[ \t]*(.*)$")
6067
+
6068
+
6069
+ def _reported_unrecorded(body_text: str) -> bool:
6070
+ """True iff a 'Reported:' line is PRESENT but still an unfilled `<...>` placeholder or blank.
6071
+ ABSENT line -> False (grandfathered — a pre-existing task's TASK.md predates this template
6072
+ field), mirroring _section_unfilled's absent-block convention."""
6073
+ m = _REPORTED_LINE_RE.search(body_text)
6074
+ if m is None:
6075
+ return False
6076
+ val = m.group(1).strip()
6077
+ return (not val) or bool(re.fullmatch(r"<[^>\n]*>", val))
6078
+
5351
6079
 
5352
6080
  def _guarantee_lint_notices(root: Path, state: dict) -> dict:
5353
6081
  """PRESENCE-ONLY, MEASURE-NOT-BLOCK lints SURFACED (never failed-on) by `add.py audit`
@@ -5355,24 +6083,95 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
5355
6083
  shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
5356
6084
  ABSENT block grandfathers a legacy task — never retro-flagged);
5357
6085
  risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
6086
+ sensitivity_unset[]= the header carries NO valid `sensitivity:` token (risk-sensitivity-taxonomy);
5358
6087
  refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
5359
6088
  refute-record, M4) — the earned-green verdict the AI must record under
5360
6089
  `auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
5361
6090
  never auto-blocks a gate, only surfaced here for review + a human spot-audit.
5362
- Honest visibility for three verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
6091
+ rule_coverage_gap[]= this task's OWN §1 Must/Reject IDs have >=1 rule with no §2 scenario tag
6092
+ and no §4 `covers:` line (_rule_coverage_gaps — same opt-in-by-tag-presence
6093
+ grandfather as `add.py check`'s whole-project sweep) — surfaced the moment
6094
+ THIS task reaches verify, not only via a separate `check` invocation someone
6095
+ has to remember to run; `check` still owns the per-rule detail.
6096
+ contract_report_unrecorded[] = §3's `Reported:` line present-but-unfilled (report-rendered-
6097
+ trace) — the freeze report (banner/ARC/SHAPE) was cited by the guide but
6098
+ never recorded as rendered; ABSENT line grandfathers a pre-field task.
6099
+ verify_report_unrecorded[] = §6's `Reported:` line present-but-unfilled — the gate report
6100
+ (banner/ARC) was never recorded as rendered; same grandfather rule.
6101
+ Honest visibility for six verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
5363
6102
  TASK.md + state only, writes nothing."""
5364
- shallow, risk_unset, refute_unrecorded = [], [], []
6103
+ shallow, risk_unset, refute_unrecorded, sensitivity_unset = [], [], [], []
6104
+ advisor_verdict_unrecorded = []
6105
+ advisor_reviewer_is_author = []
6106
+ advisor_residue_on_mechanical_mis_tier = []
6107
+ rule_coverage_gap = []
6108
+ contract_report_unrecorded = []
6109
+ verify_report_unrecorded = []
5365
6110
  for slug in sorted(state.get("tasks") or {}):
5366
6111
  if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
5367
6112
  continue
5368
- body6 = _raw_phase_bodies(root, slug).get(6, "")
6113
+ bodies = _raw_phase_bodies(root, slug)
6114
+ body3 = bodies.get(3, "")
6115
+ body6 = bodies.get(6, "")
6116
+ hdr = _task_header(root, slug)
5369
6117
  if _section_unfilled(body6, "### Deep checks"):
5370
6118
  shallow.append(slug)
5371
6119
  if _section_unfilled(body6, "### Refute-read verdict"):
5372
6120
  refute_unrecorded.append(slug)
5373
- if not _RISK_ANY_RE.search(_task_header(root, slug)):
6121
+ if _section_unfilled(body6, "### Advisor 3-lens verdict"):
6122
+ advisor_verdict_unrecorded.append(slug)
6123
+ if _reported_unrecorded(body3):
6124
+ contract_report_unrecorded.append(slug)
6125
+ if _reported_unrecorded(body6):
6126
+ verify_report_unrecorded.append(slug)
6127
+ if not _RISK_ANY_RE.search(hdr):
5374
6128
  risk_unset.append(slug)
5375
- return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded}
6129
+ # sensitivity_unset (risk-sensitivity-taxonomy): a verify-reached task with no
6130
+ # human-declared sensitivity — MEASURE-NOT-BLOCK, same class as risk_unset.
6131
+ if _task_sensitivity(hdr) is None:
6132
+ sensitivity_unset.append(slug)
6133
+ # rule_coverage_gap (verify-traceability-glint): this task's own §1 Must/Reject vs
6134
+ # §2/§4 tags — the SAME predicate `check` uses project-wide, scoped to just this task
6135
+ # and surfaced right at verify, not only via a separate whole-project sweep.
6136
+ if _rule_coverage_gaps(bodies.get(1, ""), bodies.get(2, ""), bodies.get(4, "")):
6137
+ rule_coverage_gap.append(slug)
6138
+ # advisor_reviewer_is_author / advisor_residue_on_mechanical_mis_tier
6139
+ # (advisor-verdict-audit): MEASURE-NOT-BLOCK lints on the filled advisor block.
6140
+ # Both require the block to be PRESENT AND FILLED (not just unfilled).
6141
+ # Extract ONLY the advisor sub-section to avoid false matches from
6142
+ # the Refute-read verdict block (which also carries Verdict:/Residue: lines).
6143
+ _advisor_body = ""
6144
+ _adv_idx = body6.find("### Advisor 3-lens verdict")
6145
+ if _adv_idx != -1:
6146
+ _adv_rest = body6[_adv_idx + len("### Advisor 3-lens verdict"):]
6147
+ _next_hdr = re.search(r"\n###", _adv_rest)
6148
+ _advisor_body = _adv_rest[:_next_hdr.start()] if _next_hdr else _adv_rest
6149
+ _advisor_filled = (_adv_idx != -1
6150
+ and not _section_unfilled(body6, "### Advisor 3-lens verdict"))
6151
+ if _advisor_filled:
6152
+ _ma = re.search(r"(?m)^Advisor:(.*)$", _advisor_body)
6153
+ _advisor_name = _ma.group(1).strip() if _ma else ""
6154
+ _gate_name = (state["tasks"][slug].get("gate_actor") or {}).get("name", "").strip()
6155
+ if (_advisor_name and _gate_name
6156
+ and _advisor_name.lower() == _gate_name.lower()):
6157
+ advisor_reviewer_is_author.append(slug)
6158
+ _sens = _task_sensitivity(hdr)
6159
+ if _sens == "mechanical":
6160
+ _mr = re.search(r"(?m)^Residue:(.*)$", _advisor_body)
6161
+ _mv = re.search(r"(?m)^Verdict:(.*)$", _advisor_body)
6162
+ _residue = _mr.group(1).strip() if _mr else ""
6163
+ _verdict = _mv.group(1).strip() if _mv else ""
6164
+ if (_residue and _residue.lower() != "none"
6165
+ and _verdict.upper().startswith("PASS")):
6166
+ advisor_residue_on_mechanical_mis_tier.append(slug)
6167
+ return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded,
6168
+ "sensitivity_unset": sensitivity_unset,
6169
+ "advisor_verdict_unrecorded": advisor_verdict_unrecorded,
6170
+ "advisor_reviewer_is_author": advisor_reviewer_is_author,
6171
+ "advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier,
6172
+ "rule_coverage_gap": rule_coverage_gap,
6173
+ "contract_report_unrecorded": contract_report_unrecorded,
6174
+ "verify_report_unrecorded": verify_report_unrecorded}
5376
6175
 
5377
6176
 
5378
6177
  def cmd_audit(args: argparse.Namespace) -> None:
@@ -5405,8 +6204,44 @@ def cmd_audit(args: argparse.Namespace) -> None:
5405
6204
  ru = glints["refute_unrecorded"]
5406
6205
  print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
5407
6206
  f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
6207
+ if glints["advisor_verdict_unrecorded"]:
6208
+ av = glints["advisor_verdict_unrecorded"]
6209
+ print(f"audit: advisor_verdict_unrecorded — {len(av)} task(s): {', '.join(av)} "
6210
+ f"— record the 3-lens advisor verdict (§6); a spot-audit is the backstop")
6211
+ if glints["rule_coverage_gap"]:
6212
+ rc = glints["rule_coverage_gap"]
6213
+ print(f"audit: rule_coverage_gap — {len(rc)} task(s): {', '.join(rc)} "
6214
+ f"— a §1 Must/Reject has no §2 scenario tag or §4 covers: line "
6215
+ f"(run `add.py check` for the per-rule detail)")
6216
+ if glints["sensitivity_unset"]:
6217
+ su = glints["sensitivity_unset"]
6218
+ print(f"audit: sensitivity_unset — {len(su)} task(s) reached verify with no "
6219
+ f"sensitivity: declaration: {', '.join(su)}")
6220
+ if glints["advisor_reviewer_is_author"]:
6221
+ ar = glints["advisor_reviewer_is_author"]
6222
+ print(f"audit: advisor_reviewer_is_author — {len(ar)} task(s): {', '.join(ar)} "
6223
+ f"— advisor and gate actor are the same identity")
6224
+ if glints["advisor_residue_on_mechanical_mis_tier"]:
6225
+ am = glints["advisor_residue_on_mechanical_mis_tier"]
6226
+ print(f"audit: advisor_residue_on_mechanical_mis_tier — {len(am)} task(s): "
6227
+ f"{', '.join(am)} — mechanical tier with non-none residue and PASS verdict "
6228
+ f"is incoherent; consider re-tiering")
6229
+ if glints["contract_report_unrecorded"]:
6230
+ cr = glints["contract_report_unrecorded"]
6231
+ print(f"audit: contract_report_unrecorded — {len(cr)} task(s): {', '.join(cr)} "
6232
+ f"— record the rendered freeze report (§3 `Reported: yes`); a spot-audit is the backstop")
6233
+ if glints["verify_report_unrecorded"]:
6234
+ vr = glints["verify_report_unrecorded"]
6235
+ print(f"audit: verify_report_unrecorded — {len(vr)} task(s): {', '.join(vr)} "
6236
+ f"— record the rendered gate report (§6 `Reported: yes`); a spot-audit is the backstop")
5408
6237
  if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
5409
- and not glints["refute_unrecorded"]:
6238
+ and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
6239
+ and not glints["sensitivity_unset"] \
6240
+ and not glints["advisor_reviewer_is_author"] \
6241
+ and not glints["advisor_residue_on_mechanical_mis_tier"] \
6242
+ and not glints["rule_coverage_gap"] \
6243
+ and not glints["contract_report_unrecorded"] \
6244
+ and not glints["verify_report_unrecorded"]:
5410
6245
  print(f"audit: clean ({checked} tasks checked)")
5411
6246
  # MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
5412
6247
  if findings:
@@ -5806,8 +6641,23 @@ def cmd_release(args: argparse.Namespace) -> None:
5806
6641
  _render_releases_row(args.version, day, bundle, waiver_slugs,
5807
6642
  getattr(args, "evidence", None),
5808
6643
  identity._render_actor_line(state), loose_bundle))
5809
- try: # CHANGELOG + RELEASES as one all-or-nothing commit
5810
- _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
6644
+ # milestone-release-backlink: STAMP each bundled milestone's MILESTONE.md `release:` line to
6645
+ # the cut version. Built in memory NOW and appended to the SAME atomic batch as the ledgers,
6646
+ # so the stamp commits all-or-nothing with them (a failed write rolls back everything). Only
6647
+ # `bundle` (closed-and-unreleased) milestones are stamped; a missing/unreadable file is skipped
6648
+ # (degrade-safe — the ledger attribution stays the source of truth).
6649
+ stamp_writes: list[tuple[Path, str]] = []
6650
+ for m in bundle:
6651
+ mfile = root / "milestones" / m["slug"] / "MILESTONE.md"
6652
+ try:
6653
+ _mtxt = mfile.read_text(encoding="utf-8")
6654
+ except OSError:
6655
+ continue
6656
+ _stamped = _set_release_line(_mtxt, args.version)
6657
+ if _stamped != _mtxt:
6658
+ stamp_writes.append((mfile, _stamped))
6659
+ try: # CHANGELOG + RELEASES + milestone stamps: one all-or-nothing commit
6660
+ _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)] + stamp_writes)
5811
6661
  except OSError as e:
5812
6662
  _die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
5813
6663
  "files were rolled back to their prior content. Retry the release.")
@@ -6000,6 +6850,10 @@ def build_parser() -> argparse.ArgumentParser:
6000
6850
  pi.add_argument("--rule-file", dest="rule_file", action="store_true",
6001
6851
  help="write the ADD block to .claude/rules/add-workflows.md and reference it "
6002
6852
  "from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
6853
+ pi.add_argument("--run-mode", dest="run_mode", default=None,
6854
+ choices=["auto", "conservative"],
6855
+ help="seed autonomy+streams posture: auto→parallel, conservative→sequential "
6856
+ "(absent: PROJECT.md is byte-identical to a plain init)")
6003
6857
  pi.set_defaults(func=cmd_init)
6004
6858
 
6005
6859
  pl = sub.add_parser("lock",
@@ -6128,6 +6982,13 @@ def build_parser() -> argparse.ArgumentParser:
6128
6982
  pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
6129
6983
  pwa.set_defaults(func=cmd_waves)
6130
6984
 
6985
+ pdp = sub.add_parser("dag-plan", help="record a committed snapshot of the milestone's computed "
6986
+ "DAG schedule (waves + critical path + tiers) + a freshness "
6987
+ "check vs the live depends_on edges")
6988
+ pdp.add_argument("--milestone", default=None,
6989
+ help="milestone slug to snapshot (default: the active milestone)")
6990
+ pdp.set_defaults(func=cmd_dag_plan)
6991
+
6131
6992
  pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
6132
6993
  pmd.add_argument("slug")
6133
6994
  pmd.set_defaults(func=cmd_milestone_done)
@@ -6198,6 +7059,11 @@ def build_parser() -> argparse.ArgumentParser:
6198
7059
  help="confirm a RAISE toward auto (a human-owned trust escalation)")
6199
7060
  pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
6200
7061
 
7062
+ pst = sub.add_parser("streams", help="show or set the project streams posture (parallel|sequential)")
7063
+ pst.add_argument("action", nargs="?", choices=("show", "set"), default="show")
7064
+ pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
7065
+ pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
7066
+
6201
7067
  pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
6202
7068
  pto.add_argument("text", nargs="?", default=None,
6203
7069
  help="todo text to capture; omit to LIST open todos")
@@ -6228,6 +7094,10 @@ def build_parser() -> argparse.ArgumentParser:
6228
7094
 
6229
7095
  pst = sub.add_parser("status", help="print where the project is (resume point)")
6230
7096
  pst.add_argument("--json", action="store_true", help="machine-readable JSON output")
7097
+ pst.add_argument("--task", metavar="SLUG", help="with --json, filter to one task's "
7098
+ "{slug, phase, gate, milestone, owner, assignee} object")
7099
+ pst.add_argument("--all", action="store_true", help="show every milestone/task "
7100
+ "(default: top 10 by most-recently-updated)")
6231
7101
  pst.set_defaults(func=cmd_status)
6232
7102
 
6233
7103
  pck = sub.add_parser("check", help="read-only integrity check of the .add project")
@@ -6239,6 +7109,14 @@ def build_parser() -> argparse.ArgumentParser:
6239
7109
  "(.add/components.toml) — RED integrity errors + schema-lint typo warnings")
6240
7110
  pcomp.set_defaults(func=cmd_components)
6241
7111
 
7112
+ psrch = sub.add_parser("search", help="keyword/substring search over the "
7113
+ "milestone/task corpus (active + archived) — "
7114
+ "title/goal/rationale lines only, never the full body")
7115
+ psrch.add_argument("keywords", nargs="+", metavar="KEYWORD",
7116
+ help="one or more keywords (case-insensitive substring, OR-combined)")
7117
+ psrch.add_argument("--json", action="store_true", help="machine-readable JSON output")
7118
+ psrch.set_defaults(func=cmd_search)
7119
+
6242
7120
  pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
6243
7121
  "contract snapshot into this repo (fail-loud)")
6244
7122
  pfedsub = pfed.add_subparsers(dest="action", required=True)