@soleur/opencode 0.0.0-stage → 1.0.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 (812) hide show
  1. package/.claude-plugin/agents.manifest.json +476 -0
  2. package/.claude-plugin/plugin.json +42 -0
  3. package/README.md +44 -2
  4. package/agents/community/.gitkeep +0 -0
  5. package/agents/engineering/cto.md +87 -0
  6. package/agents/engineering/design/ddd-architect.md +95 -0
  7. package/agents/engineering/discovery/agent-finder.md +214 -0
  8. package/agents/engineering/discovery/functional-discovery.md +182 -0
  9. package/agents/engineering/infra/infra-security.md +151 -0
  10. package/agents/engineering/infra/platform-strategist.md +109 -0
  11. package/agents/engineering/infra/terraform-architect.md +171 -0
  12. package/agents/engineering/prompt-engineer.md +37 -0
  13. package/agents/engineering/research/best-practices-researcher.md +115 -0
  14. package/agents/engineering/research/framework-docs-researcher.md +93 -0
  15. package/agents/engineering/research/git-history-analyzer.md +53 -0
  16. package/agents/engineering/research/learnings-researcher.md +285 -0
  17. package/agents/engineering/research/repo-research-analyst.md +118 -0
  18. package/agents/engineering/review/agent-native-reviewer.md +279 -0
  19. package/agents/engineering/review/architecture-strategist.md +61 -0
  20. package/agents/engineering/review/code-quality-analyst.md +92 -0
  21. package/agents/engineering/review/code-simplicity-reviewer.md +109 -0
  22. package/agents/engineering/review/data-integrity-guardian.md +82 -0
  23. package/agents/engineering/review/data-migration-expert.md +98 -0
  24. package/agents/engineering/review/deployment-verification-agent.md +183 -0
  25. package/agents/engineering/review/dhh-rails-reviewer.md +46 -0
  26. package/agents/engineering/review/kieran-rails-reviewer.md +87 -0
  27. package/agents/engineering/review/legacy-code-expert.md +55 -0
  28. package/agents/engineering/review/observability-coverage-reviewer.md +147 -0
  29. package/agents/engineering/review/pattern-recognition-specialist.md +59 -0
  30. package/agents/engineering/review/performance-oracle.md +130 -0
  31. package/agents/engineering/review/security-sentinel.md +167 -0
  32. package/agents/engineering/review/semgrep-sast.md +36 -0
  33. package/agents/engineering/review/test-design-reviewer.md +150 -0
  34. package/agents/engineering/review/user-impact-reviewer.md +70 -0
  35. package/agents/engineering/workflow/pr-comment-resolver.md +69 -0
  36. package/agents/finance/budget-analyst.md +29 -0
  37. package/agents/finance/cfo.md +44 -0
  38. package/agents/finance/financial-reporter.md +30 -0
  39. package/agents/finance/revenue-analyst.md +30 -0
  40. package/agents/legal/clo.md +115 -0
  41. package/agents/legal/legal-compliance-auditor.md +110 -0
  42. package/agents/legal/legal-document-generator.md +134 -0
  43. package/agents/marketing/analytics-analyst.md +25 -0
  44. package/agents/marketing/brand-architect.md +181 -0
  45. package/agents/marketing/cmo.md +76 -0
  46. package/agents/marketing/conversion-optimizer.md +19 -0
  47. package/agents/marketing/copywriter.md +20 -0
  48. package/agents/marketing/fact-checker.md +70 -0
  49. package/agents/marketing/growth-strategist.md +145 -0
  50. package/agents/marketing/paid-media-strategist.md +28 -0
  51. package/agents/marketing/pricing-strategist.md +17 -0
  52. package/agents/marketing/programmatic-seo-specialist.md +25 -0
  53. package/agents/marketing/retention-strategist.md +23 -0
  54. package/agents/marketing/seo-aeo-analyst.md +164 -0
  55. package/agents/operations/coo.md +50 -0
  56. package/agents/operations/ops-advisor.md +78 -0
  57. package/agents/operations/ops-provisioner.md +101 -0
  58. package/agents/operations/ops-research.md +52 -0
  59. package/agents/operations/service-automator.md +278 -0
  60. package/agents/product/business-validator.md +206 -0
  61. package/agents/product/competitive-intelligence.md +89 -0
  62. package/agents/product/cpo.md +63 -0
  63. package/agents/product/design/ux-design-lead.md +209 -0
  64. package/agents/product/spec-flow-analyzer.md +122 -0
  65. package/agents/sales/cro.md +44 -0
  66. package/agents/sales/deal-architect.md +29 -0
  67. package/agents/sales/outbound-strategist.md +29 -0
  68. package/agents/sales/pipeline-analyst.md +29 -0
  69. package/agents/support/cco.md +48 -0
  70. package/agents/support/community-manager.md +570 -0
  71. package/agents/support/ticket-triage.md +70 -0
  72. package/bin/soleur-opencode.js +29 -0
  73. package/commands/go.md +659 -0
  74. package/commands/help.md +273 -0
  75. package/commands/sync.md +977 -0
  76. package/commands/version.md +103 -0
  77. package/hooks/antigravity-hooks.json +84 -0
  78. package/hooks/antigravity-session-start.sh +15 -0
  79. package/hooks/bare-root-cwd-guard.sh +131 -0
  80. package/hooks/browser-snapshot-credential-guard.sh +200 -0
  81. package/hooks/codex-session-start.sh +9 -0
  82. package/hooks/compaction-state.sh +525 -0
  83. package/hooks/destructive-command-guard.sh +1387 -0
  84. package/hooks/devin-session-start.sh +90 -0
  85. package/hooks/ext-filter-detect.sh +171 -0
  86. package/hooks/hooks.json +133 -0
  87. package/hooks/lib/hook-tool-kind.sh +24 -0
  88. package/hooks/lib/log-rotation.sh +176 -0
  89. package/hooks/lib/shell-argv.pl +576 -0
  90. package/hooks/memory-backstop.sh +1114 -0
  91. package/hooks/operator-stage-approval.sh +391 -0
  92. package/hooks/stop-hook.sh +367 -0
  93. package/hooks/unkept-promise-hook.sh +383 -0
  94. package/hooks/version-banner.sh +127 -0
  95. package/hooks/welcome-hook.sh +50 -0
  96. package/install.js +108 -0
  97. package/lib/agent-registry.ts +154 -0
  98. package/lib/c4-canonical-cli.mjs +34 -0
  99. package/lib/c4-canonical.mjs +46 -0
  100. package/lib/c4-from-components.ts +554 -0
  101. package/lib/go-routing.ts +81 -0
  102. package/lib/grok-inspect-contract.ts +311 -0
  103. package/lib/harness-model-map.ts +101 -0
  104. package/lib/harness-parity.ts +726 -0
  105. package/lib/harness-tool-map.ts +180 -0
  106. package/lib/harness.ts +716 -0
  107. package/lib/heartbeat-live-reconcile.ts +1153 -0
  108. package/lib/heartbeat-manifest.ts +342 -0
  109. package/lib/kb-coverage.ts +252 -0
  110. package/lib/pr-merge-poll.ts +103 -0
  111. package/lib/workflow-fidelity.ts +388 -0
  112. package/opencode/INSTRUCTIONS.md +152 -0
  113. package/opencode/agents/engineering/cto.md +10 -0
  114. package/opencode/agents/engineering/design/ddd-architect.md +10 -0
  115. package/opencode/agents/engineering/discovery/agent-finder.md +10 -0
  116. package/opencode/agents/engineering/discovery/functional-discovery.md +10 -0
  117. package/opencode/agents/engineering/infra/infra-security.md +10 -0
  118. package/opencode/agents/engineering/infra/platform-strategist.md +10 -0
  119. package/opencode/agents/engineering/infra/terraform-architect.md +10 -0
  120. package/opencode/agents/engineering/prompt-engineer.md +10 -0
  121. package/opencode/agents/engineering/research/best-practices-researcher.md +10 -0
  122. package/opencode/agents/engineering/research/framework-docs-researcher.md +10 -0
  123. package/opencode/agents/engineering/research/git-history-analyzer.md +10 -0
  124. package/opencode/agents/engineering/research/learnings-researcher.md +10 -0
  125. package/opencode/agents/engineering/research/repo-research-analyst.md +10 -0
  126. package/opencode/agents/engineering/review/agent-native-reviewer.md +10 -0
  127. package/opencode/agents/engineering/review/architecture-strategist.md +10 -0
  128. package/opencode/agents/engineering/review/code-quality-analyst.md +10 -0
  129. package/opencode/agents/engineering/review/code-simplicity-reviewer.md +10 -0
  130. package/opencode/agents/engineering/review/data-integrity-guardian.md +10 -0
  131. package/opencode/agents/engineering/review/data-migration-expert.md +10 -0
  132. package/opencode/agents/engineering/review/deployment-verification-agent.md +10 -0
  133. package/opencode/agents/engineering/review/dhh-rails-reviewer.md +10 -0
  134. package/opencode/agents/engineering/review/kieran-rails-reviewer.md +10 -0
  135. package/opencode/agents/engineering/review/legacy-code-expert.md +10 -0
  136. package/opencode/agents/engineering/review/observability-coverage-reviewer.md +10 -0
  137. package/opencode/agents/engineering/review/pattern-recognition-specialist.md +10 -0
  138. package/opencode/agents/engineering/review/performance-oracle.md +10 -0
  139. package/opencode/agents/engineering/review/security-sentinel.md +10 -0
  140. package/opencode/agents/engineering/review/semgrep-sast.md +10 -0
  141. package/opencode/agents/engineering/review/test-design-reviewer.md +10 -0
  142. package/opencode/agents/engineering/review/user-impact-reviewer.md +10 -0
  143. package/opencode/agents/engineering/workflow/pr-comment-resolver.md +10 -0
  144. package/opencode/agents/finance/budget-analyst.md +10 -0
  145. package/opencode/agents/finance/cfo.md +10 -0
  146. package/opencode/agents/finance/financial-reporter.md +10 -0
  147. package/opencode/agents/finance/revenue-analyst.md +10 -0
  148. package/opencode/agents/legal/clo.md +10 -0
  149. package/opencode/agents/legal/legal-compliance-auditor.md +10 -0
  150. package/opencode/agents/legal/legal-document-generator.md +10 -0
  151. package/opencode/agents/marketing/analytics-analyst.md +10 -0
  152. package/opencode/agents/marketing/brand-architect.md +10 -0
  153. package/opencode/agents/marketing/cmo.md +10 -0
  154. package/opencode/agents/marketing/conversion-optimizer.md +10 -0
  155. package/opencode/agents/marketing/copywriter.md +10 -0
  156. package/opencode/agents/marketing/fact-checker.md +10 -0
  157. package/opencode/agents/marketing/growth-strategist.md +10 -0
  158. package/opencode/agents/marketing/paid-media-strategist.md +10 -0
  159. package/opencode/agents/marketing/pricing-strategist.md +10 -0
  160. package/opencode/agents/marketing/programmatic-seo-specialist.md +10 -0
  161. package/opencode/agents/marketing/retention-strategist.md +10 -0
  162. package/opencode/agents/marketing/seo-aeo-analyst.md +10 -0
  163. package/opencode/agents/operations/coo.md +10 -0
  164. package/opencode/agents/operations/ops-advisor.md +10 -0
  165. package/opencode/agents/operations/ops-provisioner.md +10 -0
  166. package/opencode/agents/operations/ops-research.md +10 -0
  167. package/opencode/agents/operations/service-automator.md +10 -0
  168. package/opencode/agents/product/business-validator.md +10 -0
  169. package/opencode/agents/product/competitive-intelligence.md +10 -0
  170. package/opencode/agents/product/cpo.md +10 -0
  171. package/opencode/agents/product/design/ux-design-lead.md +10 -0
  172. package/opencode/agents/product/spec-flow-analyzer.md +10 -0
  173. package/opencode/agents/sales/cro.md +10 -0
  174. package/opencode/agents/sales/deal-architect.md +10 -0
  175. package/opencode/agents/sales/outbound-strategist.md +10 -0
  176. package/opencode/agents/sales/pipeline-analyst.md +10 -0
  177. package/opencode/agents/support/cco.md +10 -0
  178. package/opencode/agents/support/community-manager.md +10 -0
  179. package/opencode/agents/support/ticket-triage.md +10 -0
  180. package/opencode/commands/go.md +10 -0
  181. package/opencode/commands/help.md +10 -0
  182. package/opencode/commands/sync.md +10 -0
  183. package/opencode/commands/version.md +11 -0
  184. package/opencode/opencode.json +8 -0
  185. package/opencode/plugin.ts +493 -0
  186. package/package.json +36 -4
  187. package/scripts/admin-merge-ready.sh +545 -0
  188. package/scripts/admin-merge-ready.test.sh +753 -0
  189. package/scripts/affected-tests.sh +908 -0
  190. package/scripts/affected-tests.test.sh +1797 -0
  191. package/scripts/alpha-metrics.sh +78 -0
  192. package/scripts/alpha-metrics.test.sh +115 -0
  193. package/scripts/ancestor-signal-helpers.baseline.txt +16 -0
  194. package/scripts/audit-flag-flip.sh +107 -0
  195. package/scripts/check-red-on-main.sh +316 -0
  196. package/scripts/ci-head-verdict.sh +363 -0
  197. package/scripts/cloud-detect.sh +193 -0
  198. package/scripts/delegation-verify.sh +657 -0
  199. package/scripts/delegation-verify.test.sh +1424 -0
  200. package/scripts/deploy-arm.sh +475 -0
  201. package/scripts/domain-model-drift.sh +419 -0
  202. package/scripts/emit-decision.sh +144 -0
  203. package/scripts/emit-decision.test.sh +144 -0
  204. package/scripts/ext-filter-divergence-corpus.txt +15 -0
  205. package/scripts/ext-filter-divergence.sh +189 -0
  206. package/scripts/generate-c4-from-components.ts +442 -0
  207. package/scripts/grok-fidelity-gate.sh +68 -0
  208. package/scripts/grok-pre-push-gate.sh +232 -0
  209. package/scripts/harness-discovery-smoke.ts +906 -0
  210. package/scripts/harness-parity-census.ts +92 -0
  211. package/scripts/inngest-luks-alert-armed-probe.ts +29 -0
  212. package/scripts/install-opencode-adapter.ts +520 -0
  213. package/scripts/lib/affected-tests/common.sh +365 -0
  214. package/scripts/lib/affected-tests/go.sh +157 -0
  215. package/scripts/lib/affected-tests/js.sh +188 -0
  216. package/scripts/lib/affected-tests/nx-turbo.sh +98 -0
  217. package/scripts/lib/affected-tests/python.sh +185 -0
  218. package/scripts/lib/context-sanitize.sh +49 -0
  219. package/scripts/lib/domain-model-lib.sh +180 -0
  220. package/scripts/lib/ext-filter-grammar.tsv +34 -0
  221. package/scripts/lib/operator-script.sh +1236 -0
  222. package/scripts/lib/proc.sh +580 -0
  223. package/scripts/lib/session-state.sh +748 -0
  224. package/scripts/lib/tmp-classify.sh +843 -0
  225. package/scripts/lib/wrapper-boundary.sh +47 -0
  226. package/scripts/lint-ratchet.py +1547 -0
  227. package/scripts/monitor-pr-checks.sh +441 -0
  228. package/scripts/pipeline-tally.sh +756 -0
  229. package/scripts/pipeline-tally.test.sh +740 -0
  230. package/scripts/plugin-version.sh +528 -0
  231. package/scripts/precommit-guard.sh +113 -0
  232. package/scripts/precommit-guard.test.sh +254 -0
  233. package/scripts/reconcile-live-heartbeats.ts +705 -0
  234. package/scripts/render-c4-model.sh +217 -0
  235. package/scripts/resolve-git-root.sh +55 -0
  236. package/scripts/resolve-regenerable-conflicts.sh +363 -0
  237. package/scripts/resolve-regenerable-conflicts.test.sh +780 -0
  238. package/scripts/ruff-requirements.txt +22 -0
  239. package/scripts/run-in-pid-namespace.sh +98 -0
  240. package/scripts/run-in-pid-namespace.test.sh +290 -0
  241. package/scripts/scan-ancestor-signal-helpers.py +556 -0
  242. package/scripts/scan-ancestor-signal-helpers.test.sh +670 -0
  243. package/scripts/setup-ralph-loop.sh +238 -0
  244. package/scripts/sync-cursor-name-map.ts +316 -0
  245. package/scripts/sync-grok-agent-compat.ts +188 -0
  246. package/scripts/sync-npm-package.ts +178 -0
  247. package/scripts/sync-opencode-adapter.ts +479 -0
  248. package/scripts/sync-pr-behind.sh +534 -0
  249. package/scripts/sync-pr-behind.test.sh +252 -0
  250. package/scripts/taste-profile-update.sh +180 -0
  251. package/scripts/taste-profile-update.test.sh +167 -0
  252. package/scripts/write-budget-marker.sh +23 -0
  253. package/scripts/write-kb-coverage.ts +113 -0
  254. package/skills/admin-ip-refresh/SKILL.md +81 -0
  255. package/skills/admin-ip-refresh/references/admin-ip-refresh-procedure.md +201 -0
  256. package/skills/agent-browser/SKILL.md +698 -0
  257. package/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh +304 -0
  258. package/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py +964 -0
  259. package/skills/agent-browser/scripts/redact-a11y-snapshot.py +443 -0
  260. package/skills/agent-browser/test/fixtures/capture-playwright-mcp-fixtures.py +217 -0
  261. package/skills/agent-browser/test/fixtures/fake-passthrough-proxy.py +24 -0
  262. package/skills/agent-browser/test/fixtures/fake-playwright-mcp.py +131 -0
  263. package/skills/agent-browser/test/fixtures/make-proxy-mutant.py +48 -0
  264. package/skills/agent-browser/test/fixtures/odd-shapes/dialog-link-injection.json +10 -0
  265. package/skills/agent-browser/test/fixtures/odd-shapes/empty-content.json +5 -0
  266. package/skills/agent-browser/test/fixtures/odd-shapes/error-and-result.json +4 -0
  267. package/skills/agent-browser/test/fixtures/odd-shapes/error-code-bool.json +6 -0
  268. package/skills/agent-browser/test/fixtures/odd-shapes/error-code-string.json +6 -0
  269. package/skills/agent-browser/test/fixtures/odd-shapes/error-message-tree.json +6 -0
  270. package/skills/agent-browser/test/fixtures/odd-shapes/error-no-data.json +6 -0
  271. package/skills/agent-browser/test/fixtures/odd-shapes/error-string.json +4 -0
  272. package/skills/agent-browser/test/fixtures/odd-shapes/error-with-data.json +9 -0
  273. package/skills/agent-browser/test/fixtures/odd-shapes/evaluate-escaped-object.json +10 -0
  274. package/skills/agent-browser/test/fixtures/odd-shapes/invalid-utf8.json +4 -0
  275. package/skills/agent-browser/test/fixtures/odd-shapes/link-result.json +10 -0
  276. package/skills/agent-browser/test/fixtures/odd-shapes/list-two-results.json +6 -0
  277. package/skills/agent-browser/test/fixtures/odd-shapes/meta-in-result.json +13 -0
  278. package/skills/agent-browser/test/fixtures/odd-shapes/no-content.json +5 -0
  279. package/skills/agent-browser/test/fixtures/odd-shapes/quoted-key.json +10 -0
  280. package/skills/agent-browser/test/fixtures/odd-shapes/resource-block.json +18 -0
  281. package/skills/agent-browser/test/fixtures/odd-shapes/result-and-method.json +11 -0
  282. package/skills/agent-browser/test/fixtures/odd-shapes/result-string.json +3 -0
  283. package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped-one-row.json +10 -0
  284. package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped.json +10 -0
  285. package/skills/agent-browser/test/fixtures/odd-shapes/structured-content.json +13 -0
  286. package/skills/agent-browser/test/fixtures/odd-shapes/tools-list-no-tools.json +5 -0
  287. package/skills/agent-browser/test/fixtures/odd-shapes/tree-no-credential.json +10 -0
  288. package/skills/agent-browser/test/fixtures/odd-shapes/two-text-blocks.json +14 -0
  289. package/skills/agent-browser/test/fixtures/odd-shapes/unparsable-line.json +3 -0
  290. package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-brace.json +10 -0
  291. package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-link-substring.json +10 -0
  292. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/close.json +12 -0
  293. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/evaluate.json +12 -0
  294. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/find.json +12 -0
  295. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/framing.json +6 -0
  296. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/initialize.json +14 -0
  297. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate-link.json +12 -0
  298. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate.json +12 -0
  299. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/roots-list-request.json +5 -0
  300. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/screenshot.json +17 -0
  301. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-filename.json +12 -0
  302. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-json.json +12 -0
  303. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-raw.json +12 -0
  304. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot.json +12 -0
  305. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/tools-list.json +827 -0
  306. package/skills/agent-browser/test/fixtures/proxy-session-driver.py +138 -0
  307. package/skills/agent-browser/test/fixtures/session-facts.py +102 -0
  308. package/skills/agent-browser/test/playwright-mcp-lifetime.test.sh +1620 -0
  309. package/skills/agent-browser/test/playwright-mcp-redact-proxy.test.sh +1599 -0
  310. package/skills/agent-browser/test/redact-a11y-snapshot.test.sh +527 -0
  311. package/skills/agent-native-architecture/SKILL.md +479 -0
  312. package/skills/agent-native-architecture/references/action-parity-discipline.md +430 -0
  313. package/skills/agent-native-architecture/references/agent-execution-patterns.md +483 -0
  314. package/skills/agent-native-architecture/references/agent-native-testing.md +605 -0
  315. package/skills/agent-native-architecture/references/architecture-patterns.md +493 -0
  316. package/skills/agent-native-architecture/references/dynamic-context-injection.md +358 -0
  317. package/skills/agent-native-architecture/references/files-universal-interface.md +314 -0
  318. package/skills/agent-native-architecture/references/from-primitives-to-domain-tools.md +381 -0
  319. package/skills/agent-native-architecture/references/mcp-tool-design.md +524 -0
  320. package/skills/agent-native-architecture/references/mobile-patterns.md +897 -0
  321. package/skills/agent-native-architecture/references/product-implications.md +478 -0
  322. package/skills/agent-native-architecture/references/refactoring-to-prompt-native.md +342 -0
  323. package/skills/agent-native-architecture/references/self-modification.md +294 -0
  324. package/skills/agent-native-architecture/references/shared-workspace-architecture.md +700 -0
  325. package/skills/agent-native-architecture/references/system-prompt-design.md +263 -0
  326. package/skills/agent-native-audit/SKILL.md +300 -0
  327. package/skills/agent-native-audit/workflows/agent-native-audit.workflow.js +423 -0
  328. package/skills/andrew-kane-gem-writer/SKILL.md +189 -0
  329. package/skills/andrew-kane-gem-writer/references/database-adapters.md +231 -0
  330. package/skills/andrew-kane-gem-writer/references/module-organization.md +121 -0
  331. package/skills/andrew-kane-gem-writer/references/rails-integration.md +183 -0
  332. package/skills/andrew-kane-gem-writer/references/resources.md +126 -0
  333. package/skills/andrew-kane-gem-writer/references/testing-patterns.md +261 -0
  334. package/skills/architecture/SKILL.md +436 -0
  335. package/skills/architecture/references/adr-template.md +154 -0
  336. package/skills/architecture/references/likec4-reference.md +136 -0
  337. package/skills/architecture/references/nfr-reference.md +126 -0
  338. package/skills/archive-kb/SKILL.md +86 -0
  339. package/skills/archive-kb/scripts/archive-kb.sh +300 -0
  340. package/skills/archive-kb/test/archive-kb-partial-run.test.sh +276 -0
  341. package/skills/atdd-developer/SKILL.md +65 -0
  342. package/skills/brainstorm/SKILL.md +701 -0
  343. package/skills/brainstorm/references/brainstorm-brand-workshop.md +153 -0
  344. package/skills/brainstorm/references/brainstorm-domain-config.md +63 -0
  345. package/skills/brainstorm/references/brainstorm-validation-workshop.md +65 -0
  346. package/skills/brainstorm/references/ui-surface-terms.md +42 -0
  347. package/skills/brainstorm-techniques/SKILL.md +231 -0
  348. package/skills/brainstorm-techniques/references/decision-principles.md +150 -0
  349. package/skills/brainstorm-techniques/references/phase-boundaries.md +103 -0
  350. package/skills/brainstorm-techniques/references/standing-delegation.md +407 -0
  351. package/skills/brainstorm-techniques/references/technical-ruling-delegation.md +174 -0
  352. package/skills/campaign-calendar/SKILL.md +145 -0
  353. package/skills/cf-token-scope/SKILL.md +164 -0
  354. package/skills/cf-token-scope/references/widen-playbook.md +114 -0
  355. package/skills/cf-token-scope/scripts/cf-token-scope.sh +253 -0
  356. package/skills/changelog/SKILL.md +142 -0
  357. package/skills/code-to-prd/SKILL.md +66 -0
  358. package/skills/code-to-prd/references/banner-template.md +12 -0
  359. package/skills/code-to-prd/references/prd-template.md +41 -0
  360. package/skills/code-to-prd/scripts/code-to-prd.sh +634 -0
  361. package/skills/code-to-prd/test/code-to-prd.test.sh +447 -0
  362. package/skills/code-to-prd/test/fixture/.env.example +7 -0
  363. package/skills/code-to-prd/test/fixture/app/about/page.tsx +11 -0
  364. package/skills/code-to-prd/test/fixture/app/api/health/route.ts +10 -0
  365. package/skills/code-to-prd/test/fixture/app/page.tsx +14 -0
  366. package/skills/code-to-prd/test/fixture/next.config.mjs +3 -0
  367. package/skills/code-to-prd/test/fixture/package.json +6 -0
  368. package/skills/cohort-status/SKILL.md +62 -0
  369. package/skills/community/SKILL.md +210 -0
  370. package/skills/community/scripts/bsky-community.sh +644 -0
  371. package/skills/community/scripts/bsky-setup.sh +631 -0
  372. package/skills/community/scripts/community-router.sh +93 -0
  373. package/skills/community/scripts/discord-community.sh +487 -0
  374. package/skills/community/scripts/discord-setup.sh +784 -0
  375. package/skills/community/scripts/github-community.sh +706 -0
  376. package/skills/community/scripts/hn-community.sh +233 -0
  377. package/skills/community/scripts/lib/hmac-sha1-b64.sh +126 -0
  378. package/skills/community/scripts/linkedin-community.sh +750 -0
  379. package/skills/community/scripts/linkedin-setup.sh +758 -0
  380. package/skills/community/scripts/x-community.sh +890 -0
  381. package/skills/community/scripts/x-setup.sh +713 -0
  382. package/skills/community/test/community-argv.test.sh +2922 -0
  383. package/skills/community/test/github-community.test.sh +981 -0
  384. package/skills/competitive-analysis/SKILL.md +69 -0
  385. package/skills/competitive-analysis/references/peer-plugin-audit.md +218 -0
  386. package/skills/compound/SKILL.md +673 -0
  387. package/skills/compound/scripts/token-efficiency-report.sh +334 -0
  388. package/skills/compound/test/phase-16.test.sh +379 -0
  389. package/skills/compound-capture/SKILL.md +791 -0
  390. package/skills/compound-capture/assets/critical-pattern-template.md +37 -0
  391. package/skills/compound-capture/assets/resolution-template.md +116 -0
  392. package/skills/compound-capture/references/yaml-schema.md +67 -0
  393. package/skills/compound-capture/schema.yaml +181 -0
  394. package/skills/constraint-scaffold/SKILL.md +175 -0
  395. package/skills/constraint-scaffold/references/boundary-readme.template +35 -0
  396. package/skills/constraint-scaffold/references/constraint-gates-workflow.template +69 -0
  397. package/skills/constraint-scaffold/references/depcruise-config.template +230 -0
  398. package/skills/constraint-scaffold/references/fix-constraints-stage-a.template +240 -0
  399. package/skills/constraint-scaffold/references/fix-constraints-stage-b.template +313 -0
  400. package/skills/constraint-scaffold/references/shared-runner.template +135 -0
  401. package/skills/constraint-scaffold/scripts/constraint-scaffold.sh +589 -0
  402. package/skills/constraint-scaffold/test/bite-proof.test.sh +1338 -0
  403. package/skills/constraint-scaffold/test/boundary.test.sh +734 -0
  404. package/skills/constraint-scaffold/test/emit-fix-constraints.test.sh +193 -0
  405. package/skills/constraint-scaffold/test/generator.test.sh +93 -0
  406. package/skills/constraint-scaffold/test/parity.test.sh +285 -0
  407. package/skills/content-writer/SKILL.md +242 -0
  408. package/skills/content-writer/scripts/blog-jargon-scan.sh +42 -0
  409. package/skills/cron-delete/SKILL.md +56 -0
  410. package/skills/cron-list/SKILL.md +54 -0
  411. package/skills/deepen-plan/SKILL.md +937 -0
  412. package/skills/deepen-plan/workflows/deepen-plan.workflow.js +488 -0
  413. package/skills/deploy/SKILL.md +115 -0
  414. package/skills/deploy/references/hetzner-setup.md +65 -0
  415. package/skills/deploy/scripts/deploy.sh +37 -0
  416. package/skills/deploy-docs/SKILL.md +95 -0
  417. package/skills/dhh-rails-style/SKILL.md +203 -0
  418. package/skills/dhh-rails-style/references/architecture.md +717 -0
  419. package/skills/dhh-rails-style/references/controllers.md +323 -0
  420. package/skills/dhh-rails-style/references/frontend.md +556 -0
  421. package/skills/dhh-rails-style/references/gems.md +316 -0
  422. package/skills/dhh-rails-style/references/models.md +388 -0
  423. package/skills/dhh-rails-style/references/testing.md +354 -0
  424. package/skills/discord-content/SKILL.md +126 -0
  425. package/skills/docs-site/SKILL.md +214 -0
  426. package/skills/drain-labeled-backlog/SKILL.md +203 -0
  427. package/skills/drain-labeled-backlog/scripts/group-by-area.sh +208 -0
  428. package/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js +433 -0
  429. package/skills/drain-prs/SKILL.md +142 -0
  430. package/skills/drain-prs/scripts/triage-prs.sh +192 -0
  431. package/skills/drain-prs/test/argv-ceiling.test.sh +183 -0
  432. package/skills/dspy-ruby/SKILL.md +634 -0
  433. package/skills/dspy-ruby/assets/config-template.rb +359 -0
  434. package/skills/dspy-ruby/assets/module-template.rb +326 -0
  435. package/skills/dspy-ruby/assets/signature-template.rb +143 -0
  436. package/skills/dspy-ruby/references/core-concepts.md +268 -0
  437. package/skills/dspy-ruby/references/optimization.md +623 -0
  438. package/skills/dspy-ruby/references/providers.md +335 -0
  439. package/skills/eval-harness/README.md +194 -0
  440. package/skills/eval-harness/SKILL.md +157 -0
  441. package/skills/eval-harness/enums/go-routes.json +13 -0
  442. package/skills/eval-harness/enums/incident-threshold.json +5 -0
  443. package/skills/eval-harness/enums/lane.json +5 -0
  444. package/skills/eval-harness/enums/tool-selection.json +20 -0
  445. package/skills/eval-harness/enums/triage-levels.json +5 -0
  446. package/skills/eval-harness/gated-skills.json +34 -0
  447. package/skills/eval-harness/models.generated.json +5 -0
  448. package/skills/eval-harness/promptfooconfig-go-routing.yaml +35 -0
  449. package/skills/eval-harness/promptfooconfig-incident-threshold.yaml +32 -0
  450. package/skills/eval-harness/promptfooconfig-lane-inference.yaml +33 -0
  451. package/skills/eval-harness/promptfooconfig-ticket-triage.yaml +34 -0
  452. package/skills/eval-harness/promptfooconfig-tool-selection.yaml +33 -0
  453. package/skills/eval-harness/prompts/go-baseline.txt +6 -0
  454. package/skills/eval-harness/prompts/go-skill.txt +21 -0
  455. package/skills/eval-harness/prompts/incident-threshold-baseline.txt +6 -0
  456. package/skills/eval-harness/prompts/incident-threshold-skill.txt +12 -0
  457. package/skills/eval-harness/prompts/lane-inference-baseline.txt +6 -0
  458. package/skills/eval-harness/prompts/lane-inference-skill.txt +15 -0
  459. package/skills/eval-harness/prompts/tool-selection-baseline.txt +19 -0
  460. package/skills/eval-harness/prompts/tool-selection-skill.txt +22 -0
  461. package/skills/eval-harness/prompts/triage-baseline.txt +7 -0
  462. package/skills/eval-harness/prompts/triage-skill.txt +10 -0
  463. package/skills/eval-harness/scripts/eval-gate.cjs +318 -0
  464. package/skills/eval-harness/scripts/extract-block.cjs +87 -0
  465. package/skills/eval-harness/scripts/gate-classification.cjs +28 -0
  466. package/skills/eval-harness/scripts/gen-models.sh +41 -0
  467. package/skills/eval-harness/scripts/gen-skill-prompt.cjs +177 -0
  468. package/skills/eval-harness/scripts/measure-classification.cjs +30 -0
  469. package/skills/eval-harness/scripts/parse-label.cjs +93 -0
  470. package/skills/eval-harness/scripts/verdict.cjs +132 -0
  471. package/skills/eval-harness/tasks/go-routing.jsonl +8 -0
  472. package/skills/eval-harness/tasks/incident-threshold.jsonl +7 -0
  473. package/skills/eval-harness/tasks/lane-inference.jsonl +7 -0
  474. package/skills/eval-harness/tasks/ticket-triage.jsonl +6 -0
  475. package/skills/eval-harness/tasks/tool-selection.jsonl +15 -0
  476. package/skills/eval-harness/test/eval-gate.test.sh +132 -0
  477. package/skills/eval-harness/test/extract-block.test.sh +87 -0
  478. package/skills/eval-harness/test/gate-classification.test.sh +77 -0
  479. package/skills/eval-harness/test/gen-models.test.sh +75 -0
  480. package/skills/eval-harness/test/measure-classification.test.sh +74 -0
  481. package/skills/eval-harness/test/parse-label.test.sh +129 -0
  482. package/skills/eval-harness/test/registry-completeness.test.sh +109 -0
  483. package/skills/eval-harness/test/verdict.test.sh +123 -0
  484. package/skills/every-style-editor/SKILL.md +142 -0
  485. package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +529 -0
  486. package/skills/feature-tweet/SKILL.md +199 -0
  487. package/skills/feature-video/SKILL.md +412 -0
  488. package/skills/feature-video/scripts/check_deps.sh +281 -0
  489. package/skills/file-todos/SKILL.md +267 -0
  490. package/skills/file-todos/assets/todo-template.md +166 -0
  491. package/skills/fix-issue/SKILL.md +278 -0
  492. package/skills/fix-issue/references/agent-authored-exclusion.md +118 -0
  493. package/skills/fix-issue/references/exclude-label-jq-snippet.md +96 -0
  494. package/skills/flag-bootstrap/SETUP.md +142 -0
  495. package/skills/flag-create/SKILL.md +145 -0
  496. package/skills/flag-create/scripts/create.sh +273 -0
  497. package/skills/flag-delete/SKILL.md +167 -0
  498. package/skills/flag-delete/scripts/delete.sh +288 -0
  499. package/skills/flag-list/SKILL.md +99 -0
  500. package/skills/flag-list/scripts/list.sh +275 -0
  501. package/skills/flag-set-role/SKILL.md +229 -0
  502. package/skills/flag-set-role/scripts/flip.sh +774 -0
  503. package/skills/frontend-anti-slop/SKILL.md +106 -0
  504. package/skills/frontend-anti-slop/references/anti-patterns.md +199 -0
  505. package/skills/frontend-anti-slop/references/slop-rules.md +65 -0
  506. package/skills/frontend-anti-slop/scripts/tier1-scan.ts +525 -0
  507. package/skills/frontend-design/SKILL.md +77 -0
  508. package/skills/gdpr-gate/NOTICE +136 -0
  509. package/skills/gdpr-gate/SKILL.md +341 -0
  510. package/skills/gdpr-gate/references/fields.md +160 -0
  511. package/skills/gdpr-gate/references/layers/api-layer.md +355 -0
  512. package/skills/gdpr-gate/references/layers/auth-sessions.md +290 -0
  513. package/skills/gdpr-gate/references/layers/data-in-transit.md +331 -0
  514. package/skills/gdpr-gate/references/layers/data-lifecycle.md +341 -0
  515. package/skills/gdpr-gate/references/layers/frontend.md +249 -0
  516. package/skills/gdpr-gate/references/layers/testing-seeding.md +256 -0
  517. package/skills/gdpr-gate/references/leakage-vectors.md +248 -0
  518. package/skills/gdpr-gate/references/legacy/legal-consent-v1-prose.md +48 -0
  519. package/skills/gdpr-gate/references/legal-consent.md +140 -0
  520. package/skills/gdpr-gate/references/non-negotiables.md +60 -0
  521. package/skills/gdpr-gate/scripts/gdpr-gate.sh +271 -0
  522. package/skills/gdpr-gate/scripts/notice-frontmatter.sh +337 -0
  523. package/skills/gdpr-gate/scripts/path-denylist.txt +39 -0
  524. package/skills/gdpr-gate/scripts/repo-scan.sh +158 -0
  525. package/skills/gdpr-gate/scripts/vendor-drift-classify.sh +120 -0
  526. package/skills/gdpr-gate/scripts/vendor-pin-integrity.sh +237 -0
  527. package/skills/gemini-imagegen/SKILL.md +283 -0
  528. package/skills/gemini-imagegen/requirements.txt +5 -0
  529. package/skills/gemini-imagegen/scripts/_error_handling.py +146 -0
  530. package/skills/gemini-imagegen/scripts/compose_images.py +150 -0
  531. package/skills/gemini-imagegen/scripts/edit_image.py +137 -0
  532. package/skills/gemini-imagegen/scripts/gemini_images.py +256 -0
  533. package/skills/gemini-imagegen/scripts/generate_image.py +147 -0
  534. package/skills/gemini-imagegen/scripts/multi_turn_chat.py +223 -0
  535. package/skills/gemini-imagegen/scripts/test_error_handling.py +179 -0
  536. package/skills/git-worktree/SKILL.md +534 -0
  537. package/skills/git-worktree/scripts/git-repo-readiness-diag.sh +98 -0
  538. package/skills/git-worktree/scripts/worktree-manager.sh +4773 -0
  539. package/skills/git-worktree/test/create-from-origin-main.test.sh +116 -0
  540. package/skills/git-worktree/test/lease-protects-active.test.sh +1056 -0
  541. package/skills/git-worktree/test/no-repo-fail-loud.test.sh +88 -0
  542. package/skills/git-worktree/test/orphan-reaper-honest-count.test.sh +379 -0
  543. package/skills/git-worktree/test/reap-archive-persistence.test.sh +725 -0
  544. package/skills/git-worktree/test/stale-lock-sweep.test.sh +176 -0
  545. package/skills/go/SKILL.md +54 -0
  546. package/skills/growth/SKILL.md +164 -0
  547. package/skills/harvest-debt/SKILL.md +78 -0
  548. package/skills/harvest-debt/scripts/harvest-debt.sh +114 -0
  549. package/skills/heal-skill/SKILL.md +173 -0
  550. package/skills/help/SKILL.md +46 -0
  551. package/skills/incident/NOTICE +39 -0
  552. package/skills/incident/SKILL.md +395 -0
  553. package/skills/incident/scripts/dry-run.sh +448 -0
  554. package/skills/incident/scripts/redact-engine.py +412 -0
  555. package/skills/incident/scripts/redact-sentinel.sh +44 -0
  556. package/skills/incident/templates/pir.md +139 -0
  557. package/skills/incident/test/fixtures/dry-run-incident.json +26 -0
  558. package/skills/incident/test/fixtures/dry-run-secret-leak.json +26 -0
  559. package/skills/incident/test/fixtures/legacy-bash-scanner.sh +82 -0
  560. package/skills/incident/test/fixtures/positive-corpus.md +87 -0
  561. package/skills/incident/test/redact-sentinel.test.sh +1420 -0
  562. package/skills/invoice/SKILL.md +206 -0
  563. package/skills/kb-glossary/SKILL.md +71 -0
  564. package/skills/kb-glossary/references/glossary-format.md +111 -0
  565. package/skills/kb-glossary/references/rejected-request-register.md +115 -0
  566. package/skills/kb-search/SKILL.md +276 -0
  567. package/skills/kb-search/scripts/kb-search-cache.sh +123 -0
  568. package/skills/legal-audit/SKILL.md +161 -0
  569. package/skills/legal-generate/NOTICE +242 -0
  570. package/skills/legal-generate/SKILL.md +244 -0
  571. package/skills/legal-generate/references/templates/advisor-agreement/template.md +12 -0
  572. package/skills/legal-generate/references/templates/business-associate-agreement/template.md +112 -0
  573. package/skills/legal-generate/references/templates/cookie-notice/template.md +82 -0
  574. package/skills/legal-generate/references/templates/dpa-global/template.md +439 -0
  575. package/skills/legal-generate/references/templates/dpa-us/template.md +222 -0
  576. package/skills/legal-generate/references/templates/employee-offer-letter/template.md +75 -0
  577. package/skills/legal-generate/references/templates/master-services-agreement/template.md +252 -0
  578. package/skills/legal-generate/references/templates/mutual-nda/template.md +66 -0
  579. package/skills/legal-generate/references/templates/one-way-nda/template.md +38 -0
  580. package/skills/legal-generate/references/templates/privacy-policy-gdpr/template.md +427 -0
  581. package/skills/legal-generate/references/templates/privacy-policy-us/template.md +313 -0
  582. package/skills/legal-generate/references/templates/terms-of-use/template.md +75 -0
  583. package/skills/legal-generate/scripts/strip-vendor-credit.sh +96 -0
  584. package/skills/linear-fetch/SKILL.md +247 -0
  585. package/skills/linear-fetch/scripts/assert-no-linear-telemetry.sh +66 -0
  586. package/skills/linear-fetch/scripts/redact-linear-urls.sh +51 -0
  587. package/skills/linear-fetch/scripts/render-caller-template.sh +36 -0
  588. package/skills/linear-fetch/test/assert-no-linear-telemetry.test.sh +113 -0
  589. package/skills/linear-fetch/test/parity.test.sh +112 -0
  590. package/skills/linear-fetch/test/persist-safe-integration.test.sh +191 -0
  591. package/skills/linear-fetch/test/redact-linear-urls.test.sh +226 -0
  592. package/skills/merge-pr/SKILL.md +668 -0
  593. package/skills/model-launch-review/SKILL.md +255 -0
  594. package/skills/model-launch-review/scripts/audit-models.sh +501 -0
  595. package/skills/one-shot/SKILL.md +316 -0
  596. package/skills/one-shot/references/token-discipline.md +74 -0
  597. package/skills/operator-bootstrap/SKILL.md +412 -0
  598. package/skills/operator-bootstrap/template.sh +439 -0
  599. package/skills/operator-digest/SKILL.md +287 -0
  600. package/skills/operator-digest/assets/operator-digest.workflow.yml +175 -0
  601. package/skills/operator-digest/scripts/digest-scrub.sh +119 -0
  602. package/skills/operator-digest/scripts/provision-operator-digest-repo.sh +147 -0
  603. package/skills/operator-rephrase/SKILL.md +98 -0
  604. package/skills/pencil-setup/SKILL.md +201 -0
  605. package/skills/pencil-setup/scripts/check_deps.sh +556 -0
  606. package/skills/pencil-setup/scripts/copy_adapter.sh +80 -0
  607. package/skills/pencil-setup/scripts/package-lock.json +1172 -0
  608. package/skills/pencil-setup/scripts/package.json +14 -0
  609. package/skills/pencil-setup/scripts/pencil-error-enrichment.mjs +21 -0
  610. package/skills/pencil-setup/scripts/pencil-mcp-adapter.mjs +733 -0
  611. package/skills/pencil-setup/scripts/pencil-response-classify.mjs +38 -0
  612. package/skills/pencil-setup/scripts/pencil-save-gate.mjs +26 -0
  613. package/skills/pencil-setup/scripts/sanitize-filename.mjs +25 -0
  614. package/skills/plan/SKILL.md +1140 -0
  615. package/skills/plan/references/differential-migration-testing.md +185 -0
  616. package/skills/plan/references/plan-community-discovery.md +42 -0
  617. package/skills/plan/references/plan-founder-check.md +137 -0
  618. package/skills/plan/references/plan-functional-overlap.md +25 -0
  619. package/skills/plan/references/plan-host-fanout.md +51 -0
  620. package/skills/plan/references/plan-issue-templates.md +779 -0
  621. package/skills/plan/references/plan-network-outage-checklist.md +131 -0
  622. package/skills/plan/references/plan-scope-check.md +126 -0
  623. package/skills/plan/references/plan-sharp-edges.md +325 -0
  624. package/skills/plan/references/plan-signoff-lifecycle.md +12 -0
  625. package/skills/plan/references/webapp-cwv-observability.md +143 -0
  626. package/skills/plan-review/SKILL.md +91 -0
  627. package/skills/plan-review/lib/named-panel.mjs +58 -0
  628. package/skills/plan-review/workflows/plan-review.workflow.js +477 -0
  629. package/skills/postmerge/SKILL.md +642 -0
  630. package/skills/postmerge/references/deploy-status-debugging.md +138 -0
  631. package/skills/postmerge/references/graceful-degradation.md +20 -0
  632. package/skills/postmerge/references/sentry-error-count-delta.md +137 -0
  633. package/skills/preflight/SKILL.md +1553 -0
  634. package/skills/preflight/references/check-13-founder-check.md +286 -0
  635. package/skills/preflight/scripts/founder-check.py +1640 -0
  636. package/skills/preflight/scripts/parse-form-a.awk +107 -0
  637. package/skills/preflight/scripts/probe-verb-gate.sh +108 -0
  638. package/skills/product-roadmap/SKILL.md +357 -0
  639. package/skills/product-roadmap/scripts/roadmap-reconcile.sh +327 -0
  640. package/skills/provision-cloudflare/SKILL.md +67 -0
  641. package/skills/provision-cloudflare/scripts/provision-cloudflare.sh +271 -0
  642. package/skills/provision-doppler/SKILL.md +58 -0
  643. package/skills/provision-doppler/scripts/provision-doppler.sh +358 -0
  644. package/skills/provision-github/SKILL.md +59 -0
  645. package/skills/provision-github/scripts/provision-github.sh +300 -0
  646. package/skills/provision-hetzner/SKILL.md +76 -0
  647. package/skills/provision-hetzner/scripts/provision-hetzner.sh +262 -0
  648. package/skills/provision-hetzner/test/fixture/knowledge-base/legal/tenant-dpa-register.md +26 -0
  649. package/skills/provision-hetzner/test/fixture/provision-hetzner-dry-run.golden +34 -0
  650. package/skills/provision-hetzner/test/provision-hetzner-characterization.test.sh +227 -0
  651. package/skills/qa/SKILL.md +263 -0
  652. package/skills/qa/references/browser-preflight.md +22 -0
  653. package/skills/questionnaire-generate/SKILL.md +328 -0
  654. package/skills/questionnaire-generate/references/questionnaire.template +60 -0
  655. package/skills/rclone/SKILL.md +162 -0
  656. package/skills/rclone/scripts/check_setup.sh +63 -0
  657. package/skills/release-announce/SKILL.md +65 -0
  658. package/skills/release-docs/SKILL.md +92 -0
  659. package/skills/reproduce-bug/SKILL.md +358 -0
  660. package/skills/resolve-debt/SKILL.md +85 -0
  661. package/skills/resolve-debt/scripts/resolve-debt.py +584 -0
  662. package/skills/resolve-parallel/SKILL.md +45 -0
  663. package/skills/resolve-parallel/workflows/resolve-parallel.workflow.js +415 -0
  664. package/skills/resolve-pr-parallel/SKILL.md +68 -0
  665. package/skills/resolve-pr-parallel/workflows/resolve-pr-parallel.workflow.js +419 -0
  666. package/skills/resolve-todo-parallel/SKILL.md +57 -0
  667. package/skills/resolve-todo-parallel/workflows/resolve-todo-parallel.workflow.js +398 -0
  668. package/skills/review/SKILL.md +1621 -0
  669. package/skills/review/references/anti-slop-scanner-hook.md +40 -0
  670. package/skills/review/references/defect-classes-migrations.md +9 -0
  671. package/skills/review/references/generated-input-oracle-surfaces.md +15 -0
  672. package/skills/review/references/review-e2e-testing.md +116 -0
  673. package/skills/review/references/review-todo-structure.md +165 -0
  674. package/skills/review/references/risk-tier-and-fix-rounds.md +95 -0
  675. package/skills/review/references/semgrep-custom-rules.yaml +174 -0
  676. package/skills/review/references/ultrathink-phases.md +98 -0
  677. package/skills/review/references/wfs.md +6 -0
  678. package/skills/review/scripts/emit-review-trailer.sh +499 -0
  679. package/skills/review/scripts/ensure-semgrep.sh +99 -0
  680. package/skills/review/scripts/fix-round-seats.sh +197 -0
  681. package/skills/review/test/emit-review-trailer.test.sh +387 -0
  682. package/skills/review/test/ensure-semgrep.test.sh +111 -0
  683. package/skills/review/test/fix-round-seats.test.sh +237 -0
  684. package/skills/review/workflows/README.md +147 -0
  685. package/skills/review/workflows/review.workflow.js +667 -0
  686. package/skills/schedule/SKILL.md +796 -0
  687. package/skills/seo-aeo/SKILL.md +139 -0
  688. package/skills/seo-aeo/scripts/validate-csp.sh +233 -0
  689. package/skills/seo-aeo/scripts/validate-seo.sh +262 -0
  690. package/skills/ship/SKILL.md +3022 -0
  691. package/skills/ship/references/adr-ordinal-collision.md +47 -0
  692. package/skills/ship/references/affected-gate-contract.md +115 -0
  693. package/skills/ship/references/ci-run-diagnosis.md +29 -0
  694. package/skills/ship/references/ci-workflow-authoring.md +68 -0
  695. package/skills/ship/references/counsel-review-clo-attestation.md +38 -0
  696. package/skills/ship/references/cto-ruling-armed-push.md +5 -0
  697. package/skills/ship/references/decision-challenges-render.md +71 -0
  698. package/skills/ship/references/detached-battery-verdict.md +13 -0
  699. package/skills/ship/references/feature-tweet-draft.md +42 -0
  700. package/skills/ship/references/followthrough-issue-body-template.md +47 -0
  701. package/skills/ship/references/followthrough-stub-template.sh +92 -0
  702. package/skills/ship/references/merge-queue-dequeue.md +39 -0
  703. package/skills/ship/references/operator-holds.md +149 -0
  704. package/skills/ship/references/pipeline-tally-render.md +43 -0
  705. package/skills/ship/references/pr-overlap.md +153 -0
  706. package/skills/ship/references/queue-mode.md +7 -0
  707. package/skills/ship/references/ready-run-wait.md +36 -0
  708. package/skills/ship/references/red-on-main-quarantine.md +110 -0
  709. package/skills/ship/references/register-update-pr-pattern.md +88 -0
  710. package/skills/ship/references/settle-then-admin-merge.md +130 -0
  711. package/skills/ship/references/supabase-column-probe.md +13 -0
  712. package/skills/ship/scripts/auto-close-scan.sh +76 -0
  713. package/skills/ship/scripts/battery-owed.sh +403 -0
  714. package/skills/ship/scripts/hold-gate-eval.sh +410 -0
  715. package/skills/ship/scripts/net-issue-flow.sh +665 -0
  716. package/skills/ship/scripts/pr-overlap-preflight.sh +483 -0
  717. package/skills/ship/scripts/ship-pir-action-items-gate.sh +246 -0
  718. package/skills/ship/scripts/technical-ruling-gate.sh +444 -0
  719. package/skills/skill-creator/SKILL.md +299 -0
  720. package/skills/skill-creator/references/api-security.md +248 -0
  721. package/skills/skill-creator/references/authoring-levers.md +110 -0
  722. package/skills/skill-creator/references/be-clear-and-direct.md +587 -0
  723. package/skills/skill-creator/references/best-practices.md +431 -0
  724. package/skills/skill-creator/references/common-patterns.md +639 -0
  725. package/skills/skill-creator/references/core-principles.md +193 -0
  726. package/skills/skill-creator/references/executable-code.md +188 -0
  727. package/skills/skill-creator/references/iteration-and-testing.md +509 -0
  728. package/skills/skill-creator/references/official-spec.md +193 -0
  729. package/skills/skill-creator/references/recommended-structure.md +180 -0
  730. package/skills/skill-creator/references/skill-structure.md +396 -0
  731. package/skills/skill-creator/references/using-scripts.md +122 -0
  732. package/skills/skill-creator/references/using-templates.md +120 -0
  733. package/skills/skill-creator/references/workflows-and-validation.md +541 -0
  734. package/skills/skill-creator/scripts/init_skill.py +322 -0
  735. package/skills/skill-creator/scripts/package_skill.py +110 -0
  736. package/skills/skill-creator/scripts/quick_validate.py +64 -0
  737. package/skills/skill-creator/templates/router-skill.md +73 -0
  738. package/skills/skill-creator/templates/simple-skill.md +34 -0
  739. package/skills/skill-creator/workflows/add-reference.md +96 -0
  740. package/skills/skill-creator/workflows/add-script.md +104 -0
  741. package/skills/skill-creator/workflows/add-template.md +83 -0
  742. package/skills/skill-creator/workflows/add-workflow.md +128 -0
  743. package/skills/skill-creator/workflows/audit-skill.md +159 -0
  744. package/skills/skill-creator/workflows/create-domain-expertise-skill.md +623 -0
  745. package/skills/skill-creator/workflows/create-new-skill.md +207 -0
  746. package/skills/skill-creator/workflows/get-guidance.md +134 -0
  747. package/skills/skill-creator/workflows/upgrade-to-router.md +170 -0
  748. package/skills/skill-creator/workflows/verify-skill.md +226 -0
  749. package/skills/skill-security-scan/SKILL.md +145 -0
  750. package/skills/skill-security-scan/references/disclaimer.md +21 -0
  751. package/skills/skill-security-scan/references/first-party-allowlist.yaml +38 -0
  752. package/skills/skill-security-scan/references/override-artifact-schema.json +68 -0
  753. package/skills/skill-security-scan/references/override-mechanism.md +171 -0
  754. package/skills/skill-security-scan/references/redirect-domains.yaml +27 -0
  755. package/skills/skill-security-scan/references/regex-patterns.md +136 -0
  756. package/skills/skill-security-scan/references/rules/code-exec.yaml +70 -0
  757. package/skills/skill-security-scan/references/rules/filesystem-boundary.yaml +29 -0
  758. package/skills/skill-security-scan/references/rules/manifest.yaml +44 -0
  759. package/skills/skill-security-scan/references/rules/supply-chain.yaml +36 -0
  760. package/skills/skill-security-scan/references/scan-meta-schema.json +63 -0
  761. package/skills/skill-security-scan/references/test-fixtures/clean-soleur-style.skill.md +23 -0
  762. package/skills/skill-security-scan/references/test-fixtures/clean-third-party.skill.md +26 -0
  763. package/skills/skill-security-scan/references/test-fixtures/malicious-codeexec.skill.md +23 -0
  764. package/skills/skill-security-scan/references/test-fixtures/malicious-curl-pipe-bash.skill.md +69 -0
  765. package/skills/skill-security-scan/references/test-fixtures/malicious-prompt-injection.skill.md +17 -0
  766. package/skills/skill-security-scan/references/test-fixtures/malicious-telemetry-beacon.skill.md +23 -0
  767. package/skills/skill-security-scan/references/typosquat-targets.yaml +65 -0
  768. package/skills/skill-security-scan/scripts/check-codeexec.sh +41 -0
  769. package/skills/skill-security-scan/scripts/check-filesystem-boundary.sh +19 -0
  770. package/skills/skill-security-scan/scripts/check-prompt-injection.sh +95 -0
  771. package/skills/skill-security-scan/scripts/check-supply-chain.sh +219 -0
  772. package/skills/skill-security-scan/scripts/check-telemetry-surface.sh +126 -0
  773. package/skills/skill-security-scan/scripts/lib.sh +197 -0
  774. package/skills/skill-security-scan/scripts/parse-override.sh +231 -0
  775. package/skills/skill-security-scan/scripts/run-scan.sh +342 -0
  776. package/skills/skill-security-scan/scripts/run-self-test.sh +95 -0
  777. package/skills/skill-security-scan/test/argv-ceiling.test.sh +180 -0
  778. package/skills/skill-security-scan/test/meta-dir-reap.test.sh +411 -0
  779. package/skills/skill-security-scan/test/parse-override.test.sh +607 -0
  780. package/skills/skill-security-scan/test/redact-pii-ghs-shape.test.sh +251 -0
  781. package/skills/social-distribute/SKILL.md +485 -0
  782. package/skills/spec-templates/SKILL.md +274 -0
  783. package/skills/sync/SKILL.md +49 -0
  784. package/skills/test-browser/SKILL.md +464 -0
  785. package/skills/test-browser/references/feature-map.md +161 -0
  786. package/skills/test-fix-loop/SKILL.md +159 -0
  787. package/skills/test-fix-loop/references/determinism-checklist.md +154 -0
  788. package/skills/triage/SKILL.md +402 -0
  789. package/skills/trigger-cron/SKILL.md +136 -0
  790. package/skills/trigger-cron/scripts/trigger.sh +198 -0
  791. package/skills/user-set-role/SKILL.md +147 -0
  792. package/skills/user-set-role/scripts/set-role.sh +218 -0
  793. package/skills/user-story-writer/SKILL.md +77 -0
  794. package/skills/ux-audit/SKILL.md +251 -0
  795. package/skills/ux-audit/references/finding.schema.json +62 -0
  796. package/skills/ux-audit/references/route-list.yaml +95 -0
  797. package/skills/ux-audit/scripts/bot-fixture.ts +270 -0
  798. package/skills/ux-audit/scripts/bot-signin.ts +135 -0
  799. package/skills/ux-audit/scripts/dedup-hash.ts +46 -0
  800. package/skills/work/SKILL.md +1479 -0
  801. package/skills/work/references/end-of-work-resume.md +7 -0
  802. package/skills/work/references/hook-test-main-cwd.md +24 -0
  803. package/skills/work/references/key-principles.md +83 -0
  804. package/skills/work/references/property-testing.md +122 -0
  805. package/skills/work/references/sql-security-definer-rpc-scaffold.sql +78 -0
  806. package/skills/work/references/work-agent-teams.md +97 -0
  807. package/skills/work/references/work-lifecycle-parallel.md +147 -0
  808. package/skills/work/references/work-lockfile-bumps.md +97 -0
  809. package/skills/work/references/work-scratch-sandboxes.md +212 -0
  810. package/skills/work/references/work-subagent-fanout.md +61 -0
  811. package/skills/work/references/work-test-harness-sharp-edges.md +73 -0
  812. package/skills/xcode-test/SKILL.md +360 -0
@@ -0,0 +1,1640 @@
1
+ #!/usr/bin/env python3
2
+ """preflight Check 13 -- founder-stated check helper (#9578, ADR-275).
3
+
4
+ A founder states, in plain words, what proves a piece of work is done. The plan stores it as a
5
+ `founder_check:` block under `## Acceptance Criteria`; a freeze commit pins it; a step other than
6
+ `work` runs it. This file holds every DECISION in that flow so the decisions are testable and
7
+ exist in exactly one place. It never executes the founder's command and carries no sandbox:
8
+ the command runs only inside preflight Step 10.5, driven by the Check 13 wrapper.
9
+
10
+ File-based interface. Text a plan author controls (the command, the expected text, the override
11
+ reason) is never typed into a host shell word: `verify` writes its decision record to a file
12
+ (`--out`) and the command to another (`--command-out`); `classify` and `log` read the record back
13
+ (`--verify-json`) and take only MEASURED inputs (an rc, a stdout file, the health-control rc).
14
+ The one free-text value a founder types, an override reason, arrives on stdin through a quoted
15
+ heredoc (`--reason-stdin`).
16
+
17
+ Subcommands
18
+ verify resolve the plan, parse the block, compare it with its freeze copy, anchor authorship
19
+ classify pure verdict over (verify record, rc, stdout file, health-control rc, polarity)
20
+ log append one row to the founder-check log (the only writer of outcomes)
21
+ commit-log stage and commit the log in one commit (`founder-check: log`)
22
+ summary print `founder-check: <N> rows` (the layer-7 probe)
23
+ text print one pinned wording constant (`text --list` names them all)
24
+
25
+ Honest limits, restated where they matter and in ADR-275: this is tamper-EVIDENT, not
26
+ tamper-proof. The authorship anchor compares identity strings the operator's own agent can also
27
+ write, `--mode headless` is declared by the caller, and history rewriting is not stopped. A pin
28
+ fixes the bytes of the script a command names, never what that script loads. `hash:` is an
29
+ identity shown in the log; the freeze-commit comparison is the integrity control.
30
+ """
31
+ import argparse
32
+ import contextlib
33
+ import datetime
34
+ import hashlib
35
+ import json
36
+ import os
37
+ import re
38
+ import shlex
39
+ import stat
40
+ import subprocess
41
+ import sys
42
+ from typing import NamedTuple
43
+
44
+ PLANS_DIR = "knowledge-base/project/plans"
45
+ SPECS_DIR = "knowledge-base/project/specs"
46
+ LOG_NAME = "founder-check-log.md"
47
+ MAX_PLAN_BYTES = 1 << 20
48
+ MAX_COMMITS = 5000 # a branch longer than this is not scanned, it is refused
49
+ MAX_TEXT, MAX_COMMAND, MAX_EXPECTED, MAX_APPROVED = 2000, 1000, 500, 500
50
+ REFREEZE_PREFIX = "plan: re-freeze founder-stated check"
51
+ LOG_COMMIT_MESSAGE = "founder-check: log"
52
+
53
+ CANONICAL_FIELDS = ("kind", "text", "command", "expected", "pins", "approved_by", "approved_at")
54
+ ALLOWED_KEYS = frozenset(CANONICAL_FIELDS) | {"hash"}
55
+ KINDS = ("command", "judgement")
56
+ INTERPRETERS = ("bash", "python3", "node", "bun")
57
+ SCRIPT_EXTS = (".sh", ".py", ".js", ".mjs", ".cjs", ".ts", ".awk")
58
+ # Options that make a non-interpreter verb run a program the work could have written. Long options
59
+ # are matched by unique prefix too (git and getopt-style parsers accept `--ext-d` for `--ext-diff`).
60
+ DENIED_OPTIONS = {
61
+ "git": ("-c", "--config-env", "--exec-path", "--upload-pack", "--receive-pack", "--exec", "--ext-diff",
62
+ "--textconv", "--open-files-in-pager", "-O", "--output"),
63
+ "rg": ("--pre", "--hostname-bin"),
64
+ "curl": ("-K", "--config"),
65
+ }
66
+ # git is a read-only allowlist: every other subcommand can run a program (rebase -x, difftool,
67
+ # bisect run, submodule foreach, ls-remote --upload-pack, any alias) and the list cannot be a denylist.
68
+ GIT_READ_ONLY = frozenset({
69
+ "status", "log", "show", "diff", "rev-parse", "rev-list", "ls-files", "ls-tree", "cat-file", "grep",
70
+ "describe", "blame", "shortlog", "name-rev", "merge-base", "show-ref", "for-each-ref", "diff-tree",
71
+ "diff-files", "diff-index", "count-objects", "var",
72
+ })
73
+
74
+ # Outcomes only an interactive founder can produce. The headless log refuses them.
75
+ HEADLESS_REFUSED = frozenset({"OVERRIDDEN", "FOUNDER-CONFIRMED"})
76
+ # Outcomes that stop a headless run: nobody is there to ask, and an agent never decides.
77
+ HEADLESS_STOPS = frozenset(
78
+ {"FAILED", "INVALID", "CHANGED-SINCE-APPROVAL", "UNTRUSTED", "NEEDS-YOUR-EYES", "BLOCK-REJECTED"}
79
+ )
80
+ # What an override can be overriding. Strict, so a row always names its cause.
81
+ OVERRIDE_CAUSES = ("FAILED", "INVALID", "CHANGED-SINCE-APPROVAL", "BLOCK-REJECTED")
82
+
83
+ OUTCOMES = (
84
+ "PASSED", "FAILED", "FAILED-AS-EXPECTED", "VACUOUS", "INVALID", "SKIP-NOSANDBOX",
85
+ "NEEDS-YOUR-EYES", "FOUNDER-CONFIRMED", "OVERRIDDEN", "UNTRUSTED", "CHANGED-SINCE-APPROVAL",
86
+ "BLOCK-REJECTED", "STOPPED-AWAITING-FOUNDER",
87
+ )
88
+ STOP_CAUSES = {
89
+ "FAILED": ("it did not pass", "Run this step again with you present to retry, change the check or continue anyway."),
90
+ "INVALID": ("it could not run properly", "Run this step again with you present to retry, change the check or continue anyway."),
91
+ "CHANGED-SINCE-APPROVAL": (
92
+ "it does not match what you approved, or was not approved before the work began",
93
+ "Run this step again with you present. Depending on what changed, you will be offered to confirm "
94
+ "the changed check, restore the approved one, change the check or continue anyway.",
95
+ ),
96
+ "UNTRUSTED": (
97
+ "it could not be matched to you as its author",
98
+ "Run this step again with you present and state your own check.",
99
+ ),
100
+ "NEEDS-YOUR-EYES": (
101
+ "it needs your own eyes on the result",
102
+ "Run this step again with you present so you can look and answer.",
103
+ ),
104
+ "BLOCK-REJECTED": (
105
+ "it could not be used as written",
106
+ "Run this step again with you present. You will be offered to change the check or continue anyway "
107
+ "or, if the problem is this computer or repository rather than the check, to try again or "
108
+ "continue anyway.",
109
+ ),
110
+ "SKIP-NOSANDBOX": (
111
+ "it could not run on this computer",
112
+ "This check cannot run on this computer. Fix the cause shown, or change the check to one you "
113
+ "confirm by looking, then run this step again.",
114
+ ),
115
+ }
116
+ STOP_DEFAULT = ("it could not be used", "Run this step again with you present.")
117
+ # What a verify FAIL says in plain words. Anything missing falls back to the record's own detail.
118
+ REJECT_REASONS = {
119
+ "secret-shape": "it looks like it holds a password, key or token, which would be saved in this repository",
120
+ "shell-active-token": "the command uses a pipe, redirect, variable or substitution, which is not allowed",
121
+ "verb-gate": "the command starts with a program that is not on the allowed list",
122
+ "unpinned-script": "the command runs a script that was not recorded with the check",
123
+ "no-freeze": "the check was not saved as approved before the work began",
124
+ "freeze-without-block": "a check was saved as approved earlier on this branch and it is no longer in the plan",
125
+ "credentials-required": "the check needs a password or key, so it cannot run on its own",
126
+ "unknown-field": "the check has a field that is not part of the check format",
127
+ "invalid-kind": "the check must be either a command or something you confirm by looking",
128
+ "unparseable": "the check could not be read as written",
129
+ "missing-field": "a required part of the check is empty",
130
+ "control-character": "the check contains a hidden control character",
131
+ "verb-gate-unavailable": "the tool that checks the command is not available on this computer",
132
+ "script-operand-required": "the command must name a script file in this project",
133
+ "interpreter-option": "the command passes an option to its interpreter, which is not allowed",
134
+ "absolute-script-path": "the command names a script outside this project",
135
+ "script-path-traversal": "the command names a script outside this project",
136
+ "dangerous-option": "the command uses an option that makes a program run other programs",
137
+ "hash-mismatch": "the saved fingerprint does not match the check as written",
138
+ "pin-not-at-freeze": "a script the check runs was not recorded with it as approved",
139
+ "multiple-blocks": "more than one founder check was found in the plan",
140
+ "multiple-plans": "more than one plan carries a founder check",
141
+ "no-block-candidate": "no founder check was found in the plan",
142
+ "candidate-refused-frozen": "this check was already approved, so changing it needs a re-freeze",
143
+ "base-not-default-branch": "the branch it is compared with is not this repository's main branch",
144
+ "base-unresolvable": "this repository's main branch could not be found",
145
+ "not-a-repository": "this is not a git repository",
146
+ "symlinked-plan": "the plan file is a link, so it was not read",
147
+ "plan-not-regular": "the plan file is not an ordinary file, so it was not read",
148
+ "plan-outside-plans-dir": "the plan file is outside the plans folder, so it was not read",
149
+ "plan-unreadable": "the plan file could not be read",
150
+ "internal-error": "the check could not be examined because of an internal error",
151
+ "too-long": "the check, its description, its expected text or the approval answer is longer than a check should be",
152
+ "unlisted-git-subcommand": "the command uses a git command that is not on the read-only list",
153
+ "history-too-long": "this branch has too many commits to scan for the approved check",
154
+ }
155
+ # A FAIL that says nothing about the check itself: the computer or the repository is the problem,
156
+ # so "change the check" is not an answer and the founder is offered a retry instead.
157
+ ENVIRONMENTAL_REASONS = frozenset(
158
+ {"verb-gate-unavailable", "base-not-default-branch", "base-unresolvable", "not-a-repository",
159
+ "plan-unreadable", "internal-error", "history-too-long"}
160
+ )
161
+
162
+ # Wording is a contract (CLO-reviewed, pinned by tests). Never claim more than "ran against the
163
+ # sha, finished without an error and printed the expected text". No string uses the words
164
+ # "verified", "proven" or "safe" (the CLO ruled the old negation exemption out, #9578). Every
165
+ # founder-facing sentence the references print lives here; the references only name the key.
166
+ WORDING = {
167
+ "pass": (
168
+ "Your check passed. This shows only that the check you wrote ran against {sha}, "
169
+ "finished without an error and, if you set an expected result, printed it. It does not "
170
+ "show that the work is correct or complete, or free of problems this check does not look "
171
+ "for. Review the result before relying on it."
172
+ ),
173
+ "judgement": "You confirmed this by looking. No command ran for it.",
174
+ "first-use": (
175
+ "A vague, wrong or risky check can pass broken work or run actions you did not intend. Read what "
176
+ "will run before it runs. The check runs on this computer in a limited environment that can still"
177
+ " use your network connection, reach this computer's own services, read every file in this "
178
+ "project folder and its history, including files you have not committed, and send what it reads "
179
+ "to any address on the internet. One check does not cover everything. The text and command you "
180
+ "approve are committed to this repository, which may be public, so do not put passwords, keys or "
181
+ "other people's personal details in them."
182
+ ),
183
+ "capture-question": "What would you check to know this is done?",
184
+ "approval-ask": (
185
+ "Approve exactly this check as written? What will run is the command shown, not the description "
186
+ "beside it. If you say yes, the check is saved in this repository, which may be public, and runs "
187
+ "once now against the project as it stands, where it should fail. It runs again before you ship. "
188
+ "Say yes to approve it, or tell me what to change (no passwords or keys)."
189
+ ),
190
+ "no-block": "No founder-stated check was found for this ship, so none was run.",
191
+ "no-sandbox": "Your check did not run on this computer, so nothing was checked.",
192
+ "failed-ask": "Your check did not pass. How should this proceed?",
193
+ "invalid-ask": (
194
+ "Your check could not run properly, so it says nothing about your work. How should this "
195
+ "proceed?"
196
+ ),
197
+ "changed-ask": (
198
+ "The check that would run now does not match the one you approved, or it was not approved before "
199
+ "the work began. The reason is shown above. How should this proceed?"
200
+ ),
201
+ "rejected-ask": "Your check could not be used as written.\nReason: {detail}\nHow should this proceed?",
202
+ "untrusted-fail": (
203
+ "This check could not be matched to you as its author, so it was not run. The command and the "
204
+ "name on the commit that saved it are shown above. To use a check here, state your own. Do not "
205
+ "run the one above yourself unless you know and trust who wrote it."
206
+ ),
207
+ "eyes-ask": (
208
+ "Looking at what is shown above, does the work meet what you stated? Yes: this is recorded in the"
209
+ " repository log as your confirmation, no command ran for it, and this check no longer stops the "
210
+ "ship. No: this counts as a failed check, and you will be asked how to proceed."
211
+ ),
212
+ "reason-prompt": (
213
+ "In one line, why are you continuing? Your answer is saved in the repository log, marked as an "
214
+ "override. The log may be public, so do not put passwords, keys or other people's personal "
215
+ "details in it."
216
+ ),
217
+ "overridden-failed": "Founder check did not pass and you chose to continue: {reason}",
218
+ "overridden-invalid": (
219
+ "Founder check could not run properly, so it checked nothing, and you chose to continue: {reason}"
220
+ ),
221
+ "overridden-changed": (
222
+ "Founder check did not match what you approved, or was not approved before the work began, so it "
223
+ "was not run, and you chose to continue: {reason}"
224
+ ),
225
+ "overridden-rejected": (
226
+ "Founder check could not be used as written, so it was not run, and you chose to continue: {reason}"
227
+ ),
228
+ "headless-stop": "Your check was stopped because {cause}, and an unattended run cannot decide that for you. {next}",
229
+ "baseline-ok": (
230
+ "Your check fails today, as it should before the work. This shows only that the check can fail. "
231
+ "It does not show that it can pass, or that it checks what you care about."
232
+ ),
233
+ "baseline-vacuous": (
234
+ "Your check already passes before any work is done, so it cannot tell you whether the new work is"
235
+ " done."
236
+ ),
237
+ "untrusted-unmeasured": (
238
+ "We could not read the GitHub account details needed to compare this check's author with you, so "
239
+ "it was not run. Sign in to GitHub on this computer and run this step again, or state your own "
240
+ "check."
241
+ ),
242
+ "no-sandbox-stop": (
243
+ "Because your check could not run, this stops the ship. Fix the cause shown above, or change "
244
+ "the check to one you confirm by looking, then run this step again."
245
+ ),
246
+ "opt-retry": "Run the check again.",
247
+ "opt-restore": (
248
+ "Put the approved check back as it was. This undoes later edits to the check or to a script it runs."
249
+ ),
250
+ "opt-change": (
251
+ "Approve a different check. You will be shown the earlier version and the new one. The new check "
252
+ "runs once now against the work as it stands. The work may already exist, so a pass is allowed "
253
+ "here."
254
+ ),
255
+ "opt-continue": (
256
+ "Let the ship go ahead anyway. This is recorded in the repository log as an override, with "
257
+ "your reason. The check is not marked as passed."
258
+ ),
259
+ "approval-ask-eyes": (
260
+ "Approve exactly this check as written? It is a check you confirm by looking, so no command will "
261
+ "run. If you say yes, it is saved in this repository, which may be public. Before you ship, you "
262
+ "will be shown what the work produced and asked whether it meets what you stated. Say yes to "
263
+ "approve it, or tell me what to change (no passwords or keys)."
264
+ ),
265
+ "approval-ask-change": (
266
+ "Approve this changed check exactly as written? What will run is the command shown, not the "
267
+ "description beside it, and the earlier version is shown for comparison. If you say yes, the new "
268
+ "version is saved in this repository, which may be public, and runs once now against the work as "
269
+ "it stands. The work may already exist, so it passing is expected. It runs again before you ship."
270
+ " Say yes to approve it, or tell me what to change (no passwords or keys)."
271
+ ),
272
+ "refrozen-ship-ask": (
273
+ "This check was changed after it was first approved, and the change was saved in this repository,"
274
+ " which may be public. The earlier version and the current one are shown above. What will run is "
275
+ "the command shown, not the description beside it. Run the current version now? Yes: it runs once"
276
+ " now. No: this ship stops on a check that changed since it was first approved, and you will be "
277
+ "asked how to proceed."
278
+ ),
279
+ "opt-change-new": (
280
+ "Approve a different check. It must fail on the work as it stands today, or be one you confirm by"
281
+ " looking."
282
+ ),
283
+ "opt-retry-fixed": (
284
+ "Run the check again once you have fixed the cause shown above. If nothing has changed, it stops "
285
+ "the same way."
286
+ ),
287
+ "opt-strengthen": "Write a stronger check. It runs once now and must fail on the work as it stands today.",
288
+ "opt-eyes": (
289
+ "Make this a check you confirm by looking. Nothing will run for it. Before you ship, you will be "
290
+ "shown the work and asked whether it meets what you stated."
291
+ ),
292
+ "opt-drop": (
293
+ "Record that this already holds and drop the check. No founder check will run at ship, and the "
294
+ "ship will say so."
295
+ ),
296
+ "refrozen-note": (
297
+ "This check was changed after it was first approved. The new text was saved with an approval "
298
+ "recorded under your name, and the earlier text is shown above."
299
+ ),
300
+ "no-pr-note": (
301
+ "No pull request was checked, so who wrote this check was not compared with a GitHub account."
302
+ ),
303
+ "aggregate-judgement": "Founder check: you confirmed this by looking. No command ran.",
304
+ "aggregate-pass": "Founder check: ran, returned success against {sha}",
305
+ }
306
+
307
+ LOG_COLUMNS = (
308
+ "kind", "polarity", "command", "rc", "outcome", "underlying", "attempt_n", "tested_sha",
309
+ "block_hash", "time_utc", "expected_matched", "reason", "freeze_source", "no_pr",
310
+ )
311
+
312
+ # Step 10.5's shell-active reject set, applied at verify so a block carrying one is refused before
313
+ # anything is shown or run. The sandbox is the control; this is the early, legible refusal.
314
+ _SHELL_ACTIVE = re.compile(r"\$\(|`|<\(|>\(|;|&&|\|\||\||>|<|&|\$\{?[A-Za-z_]")
315
+ _SUBSTITUTION = re.compile(r"\$\(|`|\$\{|<\(|>\(")
316
+ _CONTROL = re.compile("[\x00-\x1f\x7f\u2028\u2029]")
317
+ # text may keep \t and \n. Listed one code point at a time: a hand-written \x0b-\x1f range reads to CodeQL as a suspicious one.
318
+ _HARD_CONTROL = re.compile(
319
+ "[" + "".join(f"\\x{c:02x}" for c in (*range(0x00, 0x09), *range(0x0B, 0x20), 0x7F)) + "\\u2028\\u2029]"
320
+ )
321
+ _KW = r"(?:password|passwd|passphrase|secret|api[_-]?key|apikey|token|credential|auth)"
322
+ _SECRET_SHAPES = (
323
+ re.compile(r"(?i)(?<![A-Za-z0-9])bearer[ \t]+[A-Za-z0-9._~+/=-]{8,}"),
324
+ re.compile(r"(?i)(?<![A-Za-z0-9])authorization[ \t]*:"),
325
+ re.compile(r"(?<![A-Za-z0-9])(?:ghp|gho|ghs|ghu|ghr|github_pat|glpat|sk|pk|rk|xox[abpr])[_-][A-Za-z0-9._~+/=-]{16,}"),
326
+ re.compile(r"(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![A-Za-z0-9])"),
327
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
328
+ re.compile(r"://[^/\s:@]+:[^/\s@]+@"),
329
+ re.compile(r"(?:^|\s)-u[ \t]*\S+:\S+"),
330
+ # a name that merely CONTAINS the keyword counts: GITHUB_TOKEN, DB_PASSWORD, client_secret, access_token
331
+ re.compile(r"(?i)(?<![A-Za-z0-9])[A-Za-z0-9_.-]*" + _KW + r"[A-Za-z0-9_.-]*[ \t]*[=:][ \t]*\S{6,}"),
332
+ re.compile(r"(?i)(?<![A-Za-z0-9-])--(?:password|passwd|oauth2-bearer|proxy-password)(?:=|[ \t]+)\S{3,}"),
333
+ re.compile(r"(?i)(?<![A-Za-z0-9-])--(?:user|proxy-user)(?:=|[ \t]+)\S+:\S+"),
334
+ re.compile(r"(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}"),
335
+ )
336
+ _HEX = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
337
+
338
+
339
+ class ParseError(Exception):
340
+ pass
341
+
342
+
343
+ # ---------------------------------------------------------------------------------------------
344
+ # git. The two subprocess call sites in this file are _git and _verb_gate; Guard 4 pins them by
345
+ # walking the AST, not by grepping spellings.
346
+ # ---------------------------------------------------------------------------------------------
347
+ _LAST_ERR = ""
348
+
349
+
350
+ def _git(args, cwd):
351
+ global _LAST_ERR
352
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, errors="replace")
353
+ _LAST_ERR = (r.stderr or "").strip()
354
+ return r.returncode, r.stdout
355
+
356
+
357
+ def _verb_gate(command):
358
+ """The Step 10.4 verb gate, handed the command as DATA. It is a validator and does not run it."""
359
+ gate = os.path.join(os.path.dirname(os.path.abspath(__file__)), "probe-verb-gate.sh")
360
+ r = subprocess.run(["bash", gate, command], capture_output=True, text=True, errors="replace")
361
+ return r.returncode, (r.stdout or "").strip()
362
+
363
+
364
+ def _nul_list(args, cwd):
365
+ """A path list read with -z: a path that git would quote ('a"b.md', a newline in a name) stays one exact path."""
366
+ rc, out = _git([args[0], "-z", *args[1:]], cwd)
367
+ return [p for p in out.split("\0") if p] if rc == 0 else []
368
+
369
+
370
+ def _out(args, cwd):
371
+ rc, out = _git(args, cwd)
372
+ return out if rc == 0 else None
373
+
374
+
375
+ # ---------------------------------------------------------------------------------------------
376
+ # Block extraction and parsing (stdlib only; the block shape is fixed and small).
377
+ # ---------------------------------------------------------------------------------------------
378
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
379
+ _AC_HEADING = re.compile(r"^##[ \t]+Acceptance Criteria(?:[ \t].*)?$", re.I)
380
+
381
+
382
+ def extract_blocks(text):
383
+ """Return the `founder_check:` fences found INSIDE an `## Acceptance Criteria` section.
384
+
385
+ A heading that merely STARTS with "Acceptance Criteria" counts (`## Acceptance Criteria
386
+ (testable)`), so a suffixed heading cannot hide a block. A block quoted under any other
387
+ heading (a Design section, an ADR excerpt) is ignored, and so is any heading that sits inside a
388
+ fence: only structure outside fences decides the section.
389
+ """
390
+ blocks = []
391
+ in_fence = None # (char, length)
392
+ in_ac = False
393
+ cur = None
394
+ for line in text.splitlines():
395
+ m = _FENCE.match(line)
396
+ if in_fence is None:
397
+ if m:
398
+ in_fence = (m.group(1)[0], len(m.group(1)))
399
+ cur = []
400
+ continue
401
+ if re.match(r"^##(?!#)[ \t]+", line):
402
+ in_ac = bool(_AC_HEADING.match(line))
403
+ else:
404
+ if m and m.group(1)[0] == in_fence[0] and len(m.group(1)) >= in_fence[1] and not m.group(2).strip():
405
+ body = cur or []
406
+ first = next((x for x in body if x.strip()), "")
407
+ if in_ac and re.match(r"^founder_check:[ \t]*$", first):
408
+ blocks.append(body)
409
+ in_fence = None
410
+ cur = None
411
+ else:
412
+ cur.append(line)
413
+ return blocks
414
+
415
+
416
+ def _strip_comment(v):
417
+ quote = None
418
+ for i, ch in enumerate(v):
419
+ if quote:
420
+ if ch == quote:
421
+ quote = None
422
+ elif ch in "\"'":
423
+ quote = ch
424
+ elif ch == "#" and (i == 0 or v[i - 1] in " \t"):
425
+ return v[:i].rstrip()
426
+ return v.rstrip()
427
+
428
+
429
+ def _scalar(v):
430
+ v = v.strip()
431
+ if v.startswith('"'):
432
+ if len(v) < 2 or not v.endswith('"'):
433
+ raise ParseError("unterminated double-quoted scalar")
434
+ try:
435
+ return json.loads(v)
436
+ except ValueError as e:
437
+ raise ParseError(f"bad double-quoted scalar: {e}")
438
+ if v.startswith("'"):
439
+ if len(v) < 2 or not v.endswith("'"):
440
+ raise ParseError("unterminated single-quoted scalar")
441
+ return v[1:-1].replace("''", "'")
442
+ if v.startswith("[") or v.startswith("{"):
443
+ raise ParseError("nested collection where a scalar is required")
444
+ return v
445
+
446
+
447
+ def _indent(line):
448
+ return len(line) - len(line.lstrip(" "))
449
+
450
+
451
+ def parse_block(lines):
452
+ """Parse a `founder_check:` block into a dict of its fields. Raises ParseError.
453
+
454
+ Collections are accepted in block form only; the literal `[]` and `{}` stand for "none".
455
+ """
456
+ body = [ln.rstrip() for ln in lines]
457
+ first = next((i for i, x in enumerate(body) if x.strip()), None)
458
+ if first is None or not re.match(r"^founder_check:[ \t]*$", body[first]):
459
+ raise ParseError("block does not start with founder_check:")
460
+ rest = [x for x in body[first + 1:] if x.strip() and not x.lstrip().startswith("#")]
461
+ if not rest:
462
+ raise ParseError("empty block")
463
+ base = _indent(rest[0])
464
+ if base == 0:
465
+ raise ParseError("block fields must be indented")
466
+ fields, i = {}, 0
467
+ while i < len(rest):
468
+ ln = rest[i]
469
+ if _indent(ln) != base:
470
+ raise ParseError(f"unexpected indentation: {ln.strip()[:40]}")
471
+ m = re.match(r"^ *([A-Za-z_][A-Za-z0-9_]*):(?:[ \t]+(.*))?$", ln)
472
+ if not m:
473
+ raise ParseError(f"not a key: value line: {ln.strip()[:40]}")
474
+ key, val = m.group(1), _strip_comment(m.group(2) or "")
475
+ if key in fields:
476
+ raise ParseError(f"duplicate key {key}")
477
+ i += 1
478
+ if val == "":
479
+ kids = []
480
+ while i < len(rest) and _indent(rest[i]) > base:
481
+ kids.append(rest[i])
482
+ i += 1
483
+ if not kids:
484
+ fields[key] = ""
485
+ else:
486
+ mp = {}
487
+ for k in kids:
488
+ km = re.match(r"^ *([^\s:][^:]*?):[ \t]+(.*)$", k)
489
+ if not km:
490
+ raise ParseError(f"not a mapping entry: {k.strip()[:40]}")
491
+ mp[km.group(1).strip()] = _scalar(_strip_comment(km.group(2)))
492
+ fields[key] = mp
493
+ elif val == "[]":
494
+ fields[key] = []
495
+ elif val == "{}":
496
+ fields[key] = {}
497
+ elif val.startswith("[") or val.startswith("{"):
498
+ raise ParseError("only the literal [] and {} are accepted; write collections in block form")
499
+ else:
500
+ fields[key] = _scalar(val)
501
+ return fields
502
+
503
+
504
+ def canonical(block):
505
+ out = {}
506
+ for k in CANONICAL_FIELDS:
507
+ v = block.get(k)
508
+ if k == "pins":
509
+ v = dict(v) if isinstance(v, dict) else ({} if v in (None, "", []) else v)
510
+ else:
511
+ v = "" if v is None else v
512
+ out[k] = v
513
+ return out
514
+
515
+
516
+ def canonical_hash(canon):
517
+ pins = canon["pins"]
518
+ obj = dict(canon)
519
+ if isinstance(pins, dict):
520
+ obj["pins"] = {k: pins[k] for k in sorted(pins)}
521
+ raw = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
522
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
523
+
524
+
525
+ def _tokens(command):
526
+ try:
527
+ return shlex.split(command)
528
+ except ValueError:
529
+ return None
530
+
531
+
532
+ def first_token(command):
533
+ """The verb as bash resolves it: quotes and backslashes removed, as probe-verb-gate.sh does."""
534
+ toks = _tokens(command)
535
+ return toks[0] if toks else ""
536
+
537
+
538
+ def _secret_shaped(*values):
539
+ return any(rx.search(v) for v in values if isinstance(v, str) for rx in _SECRET_SHAPES)
540
+
541
+
542
+ def _bash_differs(cmd):
543
+ """True when bash would read `cmd` differently from shlex (the tokenizer every rule below uses).
544
+
545
+ Step 10.5 runs the command under bash, the rules judge shlex's tokens, and the two disagree on
546
+ ANSI-C quoting (`$'a'`), globs, brace lists, tilde and history words: `bash $'a.sh'` names a file
547
+ shlex calls `$a.sh`. Anything outside single quotes that bash would expand is refused, so what
548
+ shlex saw is what bash runs.
549
+ """
550
+ quote, i, n = None, 0, len(cmd)
551
+ while i < n:
552
+ ch = cmd[i]
553
+ if quote == "'":
554
+ if ch == "'":
555
+ quote = None
556
+ i += 1
557
+ continue
558
+ if ch == "\\":
559
+ i += 2
560
+ continue
561
+ if quote == '"':
562
+ if ch == '"':
563
+ quote = None
564
+ elif ch == "$" and re.match(r"[A-Za-z0-9_{(@*#?$!-]", cmd[i + 1:i + 2] or " "):
565
+ return True
566
+ i += 1
567
+ continue
568
+ if ch in "'\"":
569
+ quote = ch
570
+ elif ch in "$*?[]~!":
571
+ return True
572
+ elif ch == "{":
573
+ end = cmd.find("}", i)
574
+ body = cmd[i + 1:end] if end != -1 else cmd[i + 1:]
575
+ if "," in body or ".." in body or end == -1:
576
+ return True
577
+ elif ch in " \t":
578
+ i += 1
579
+ continue
580
+ i += 1
581
+ return quote is not None
582
+
583
+
584
+ def _option_denied(verb, toks):
585
+ """The denied option a token names, spelled any way a parser accepts (cluster, prefix, `=`)."""
586
+ for t in toks:
587
+ if not t.startswith("-") or t == "-":
588
+ continue
589
+ for opt in DENIED_OPTIONS.get(verb, ()):
590
+ if opt.startswith("--"):
591
+ name = t[2:].split("=", 1)[0] if t.startswith("--") else None
592
+ if name and (t == opt or t.startswith(opt + "=") or (len(name) >= 2 and opt[2:].startswith(name))):
593
+ return opt
594
+ elif not t.startswith("--"):
595
+ # a short option: alone, glued to its value, or inside a cluster (`-sSK f`, `-calias.x=y`)
596
+ letter = opt[1:]
597
+ cluster = t[1:]
598
+ if verb == "git":
599
+ if cluster.startswith(letter):
600
+ return opt
601
+ elif letter in cluster:
602
+ return opt
603
+ return None
604
+
605
+
606
+ def static_problem(block):
607
+ """A reason code when the block is not acceptable as written, else None. Never runs anything."""
608
+ if "credentials_required" in block:
609
+ return "credentials-required"
610
+ for k in block:
611
+ if k not in ALLOWED_KEYS:
612
+ return "unknown-field"
613
+ kind = block.get("kind", "")
614
+ if kind not in KINDS:
615
+ return "invalid-kind"
616
+ for k in ("kind", "text", "command", "expected", "approved_by", "approved_at"):
617
+ if not isinstance(block.get(k, ""), str):
618
+ return "unparseable"
619
+ pins = block.get("pins", {})
620
+ if not isinstance(pins, dict):
621
+ return "unparseable"
622
+ for k, v in pins.items():
623
+ if not isinstance(k, str) or not isinstance(v, str) or not _HEX.fullmatch(v):
624
+ return "unparseable"
625
+ key = k[2:] if k.startswith("./") else k
626
+ if key.startswith("/") or ".." in key.split("/") or not key:
627
+ return "unparseable"
628
+ text, cmd, expected = block.get("text", ""), block.get("command", ""), block.get("expected", "")
629
+ if not text.strip() or not block.get("approved_by", "").strip() or not block.get("approved_at", "").strip():
630
+ return "missing-field"
631
+ if _HARD_CONTROL.search(text) or _CONTROL.search(expected) or _CONTROL.search(block.get("approved_by", "")):
632
+ return "control-character"
633
+ if (len(text) > MAX_TEXT or len(cmd) > MAX_COMMAND or len(expected) > MAX_EXPECTED
634
+ or len(block.get("approved_by", "")) > MAX_APPROVED):
635
+ return "too-long"
636
+ if _secret_shaped(text, cmd, expected, block.get("approved_by", "")):
637
+ return "secret-shape"
638
+ if kind == "judgement":
639
+ return None
640
+ if not cmd.strip():
641
+ return "missing-field"
642
+ if _CONTROL.search(cmd):
643
+ return "control-character"
644
+ if _SHELL_ACTIVE.search(cmd) or _SUBSTITUTION.search(expected) or _bash_differs(cmd):
645
+ return "shell-active-token"
646
+ rc, _msg = _verb_gate(cmd)
647
+ if rc == 1:
648
+ return "verb-gate"
649
+ if rc != 0:
650
+ return "verb-gate-unavailable"
651
+ toks = _tokens(cmd)
652
+ if not toks:
653
+ return "unparseable"
654
+ verb = toks[0]
655
+ if verb in INTERPRETERS:
656
+ operands = toks[1:]
657
+ if not operands:
658
+ return "script-operand-required"
659
+ first = operands[0]
660
+ if first.startswith("-"):
661
+ return "interpreter-option"
662
+ if first.startswith("/"):
663
+ return "absolute-script-path"
664
+ if ".." in first.split("/"):
665
+ return "script-path-traversal"
666
+ if "/" not in first and not first.endswith(SCRIPT_EXTS):
667
+ return "script-operand-required"
668
+ key = first[2:] if first.startswith("./") else first
669
+ normalised = {(k[2:] if k.startswith("./") else k) for k in pins}
670
+ if key not in normalised:
671
+ return "unpinned-script"
672
+ else:
673
+ if _option_denied(verb, toks[1:]):
674
+ return "dangerous-option"
675
+ if verb == "git" and (len(toks) < 2 or toks[1] not in GIT_READ_ONLY):
676
+ return "unlisted-git-subcommand"
677
+ return None
678
+
679
+
680
+ # ---------------------------------------------------------------------------------------------
681
+ # verify
682
+ # ---------------------------------------------------------------------------------------------
683
+ def _escape(text):
684
+ """A copy that is safe to print: control characters and line separators become visible escapes."""
685
+ out = []
686
+ for ch in str(text):
687
+ o = ord(ch)
688
+ if o < 0x20 or o == 0x7F or ch in "\u2028\u2029":
689
+ out.append("\\x%02x" % o if o < 0x100 else "\\u%04x" % o)
690
+ else:
691
+ out.append(ch)
692
+ return "".join(out)
693
+
694
+
695
+ class _Emitter:
696
+ def __init__(self, a):
697
+ self.command_out = a.command_out
698
+ self.display_out = a.display_out
699
+ self.out = a.out
700
+ self.extra = {}
701
+
702
+ def __call__(self, outcome, **kw):
703
+ doc = {"outcome": outcome}
704
+ doc.update(self.extra)
705
+ doc.update(kw)
706
+ if outcome == "NO-BLOCK":
707
+ doc["banner"] = WORDING["no-block"]
708
+ if outcome == "FAIL":
709
+ doc["environmental"] = doc.get("reason") in ENVIRONMENTAL_REASONS
710
+ block = doc.get("block")
711
+ if isinstance(block, dict):
712
+ doc["kind"] = block.get("kind", "")
713
+ doc["first_token"] = first_token(block.get("command", "")) if block.get("kind") == "command" else ""
714
+ if self.command_out and outcome == "OK":
715
+ # The raw command goes to a FILE so the Check 13 wrapper never has to quote it into
716
+ # a shell word. Written ONLY for an OK verdict, so the file's existence is a go
717
+ # signal and no other outcome can leave a command behind for the wrapper to read.
718
+ with open(self.command_out, "w", encoding="utf-8") as fh:
719
+ fh.write(block.get("command", ""))
720
+ if self.display_out:
721
+ # What an UNTRUSTED, CHANGED or rejected block is SHOWN as: escaped, never executable.
722
+ with open(self.display_out, "w", encoding="utf-8") as fh:
723
+ fh.write(_escape(block.get("command", "")) + "\n")
724
+ line = json.dumps(doc, sort_keys=True)
725
+ if self.out:
726
+ with open(self.out, "w", encoding="utf-8") as fh:
727
+ fh.write(line + "\n")
728
+ print(line)
729
+ return 0 if outcome in ("OK", "NO-BLOCK") else 1
730
+
731
+
732
+ def _blocks_at(repo, rev, path):
733
+ size = (_out(["cat-file", "-s", f"{rev}:{path}"], repo) or "").strip()
734
+ if not size.isdigit() or int(size) > MAX_PLAN_BYTES:
735
+ return [] # absent, or too large to read: never pulled into memory
736
+ text = _out(["show", f"{rev}:{path}"], repo)
737
+ if text is None or len(text) > MAX_PLAN_BYTES:
738
+ return []
739
+ return extract_blocks(text)
740
+
741
+
742
+ def _operator_email(repo):
743
+ out = _out(["var", "GIT_AUTHOR_IDENT"], repo)
744
+ m = re.search(r"<([^>]*)>", out or "")
745
+ return m.group(1).strip().lower() if m else ""
746
+
747
+
748
+ def _read_plan(top, rel):
749
+ """Read a plan the safe way: lstat first, never follow a link, never open a non-regular file,
750
+ stay inside the plans directory, and cap the read. Returns (status, text)."""
751
+ full = os.path.join(top, rel)
752
+ plans_root = os.path.realpath(os.path.join(top, PLANS_DIR))
753
+ try:
754
+ st = os.lstat(full)
755
+ except FileNotFoundError:
756
+ return "missing", ""
757
+ except OSError:
758
+ return "unreadable", ""
759
+ if stat.S_ISLNK(st.st_mode):
760
+ return "symlink", ""
761
+ if not stat.S_ISREG(st.st_mode):
762
+ return "not-regular", ""
763
+ if not os.path.realpath(full).startswith(plans_root + os.sep):
764
+ return "outside", ""
765
+ if st.st_size > MAX_PLAN_BYTES:
766
+ return "too-large", ""
767
+ try:
768
+ fd = os.open(full, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0))
769
+ with os.fdopen(fd, "rb") as fh:
770
+ data = fh.read(MAX_PLAN_BYTES + 1)
771
+ except OSError:
772
+ return "unreadable", ""
773
+ if len(data) > MAX_PLAN_BYTES:
774
+ return "too-large", ""
775
+ return "ok", data.decode("utf-8", errors="replace")
776
+
777
+
778
+ _ARCHIVE_PREFIX = re.compile(r"^\d{8}-\d{6}-")
779
+
780
+
781
+ def _plan_key(path):
782
+ """A plan archived by compound (`plans/archive/<ts>-<name>.md`) is the SAME plan as `plans/<name>.md`."""
783
+ arch = PLANS_DIR + "/archive/"
784
+ if path.startswith(arch):
785
+ return PLANS_DIR + "/" + _ARCHIVE_PREFIX.sub("", path[len(arch):])
786
+ return path
787
+
788
+
789
+ def _branch(repo):
790
+ b = (_out(["rev-parse", "--abbrev-ref", "HEAD"], repo) or "").strip()
791
+ return b if b and b != "HEAD" else ""
792
+
793
+
794
+ def _safe_branch(b):
795
+ return bool(b) and all(re.fullmatch(r"[A-Za-z0-9._-]+", s) and s not in (".", "..") for s in b.split("/"))
796
+
797
+
798
+ def _spec_dirs(top, branch):
799
+ """The live spec directory, then any archived copy (compound moves it after the feature)."""
800
+ live = os.path.join(top, SPECS_DIR, branch)
801
+ arch_root = os.path.join(top, SPECS_DIR, "archive")
802
+ archived = []
803
+ if os.path.isdir(arch_root):
804
+ archived = sorted(
805
+ (os.path.join(arch_root, d) for d in os.listdir(arch_root) if _ARCHIVE_PREFIX.sub("", d) == branch),
806
+ reverse=True,
807
+ )
808
+ return [live, *archived]
809
+
810
+
811
+ def _log_path(top, branch):
812
+ if not _safe_branch(branch):
813
+ return None
814
+ dirs = _spec_dirs(top, branch)
815
+ for d in dirs:
816
+ if os.path.isfile(os.path.join(d, LOG_NAME)):
817
+ return os.path.join(d, LOG_NAME)
818
+ for d in dirs[1:]:
819
+ if os.path.isdir(d):
820
+ return os.path.join(d, LOG_NAME)
821
+ return os.path.join(dirs[0], LOG_NAME)
822
+
823
+
824
+ def _tasks_plan(top, branch):
825
+ """The plan a branch's tasks.md names (`Plan: <path>`), so a plan already merged to main is found."""
826
+ if not _safe_branch(branch):
827
+ return None
828
+ for d in _spec_dirs(top, branch):
829
+ p = os.path.join(d, "tasks.md")
830
+ if os.path.isfile(p) and not os.path.islink(p) and os.path.getsize(p) <= MAX_PLAN_BYTES:
831
+ with open(p, encoding="utf-8", errors="replace") as fh:
832
+ text = fh.read()
833
+ m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*{0,2}Plan:\*{0,2}[ \t]*`?(knowledge-base/project/plans/[^\s`]+\.md)`?", text, re.M)
834
+ if m:
835
+ return m.group(1)
836
+ return None
837
+
838
+
839
+ def _history(repo, merge_base):
840
+ """One `git log -z` pass: every branch commit with its author, subject and name-status changes.
841
+
842
+ Returns the commits, or None when the branch is longer than MAX_COMMITS (it is refused, not
843
+ sampled: a freeze hidden past the cap must not read as no freeze).
844
+ """
845
+ rc, out = _git(
846
+ ["log", "-z", "--reverse", "--topo-order", "--name-status", "-M", f"--max-count={MAX_COMMITS + 1}",
847
+ "--format=%x01%H%x01%ae%x01%s", f"{merge_base}..HEAD"],
848
+ repo,
849
+ )
850
+ commits = []
851
+ if rc != 0:
852
+ return commits
853
+ toks = out.split("\0")
854
+ i = 0
855
+ while i < len(toks):
856
+ t = toks[i].lstrip("\n")
857
+ if toks[i].startswith("\x01"):
858
+ _, sha, email, subj = (toks[i].split("\x01") + ["", "", "", ""])[:4]
859
+ commits.append({"sha": sha, "email": email.strip().lower(), "subject": subj, "changes": []})
860
+ i += 1
861
+ elif re.fullmatch(r"[A-Z][0-9]*", t) and commits:
862
+ if t[0] in ("R", "C") and i + 2 < len(toks) + 1:
863
+ commits[-1]["changes"].append((t[0], toks[i + 1], toks[i + 2] if i + 2 < len(toks) else ""))
864
+ i += 3
865
+ else:
866
+ commits[-1]["changes"].append((t[0], "", toks[i + 1] if i + 1 < len(toks) else ""))
867
+ i += 2
868
+ else:
869
+ i += 1
870
+ return None if len(commits) > MAX_COMMITS else commits
871
+
872
+
873
+ def _plans_at(repo, rev):
874
+ """Every plan path at `rev`, grouped by plan identity: an archived plan and its live name are one plan."""
875
+ by_key = {}
876
+ for pth in _nul_list(["ls-tree", "-r", "--name-only", rev, "--", PLANS_DIR], repo):
877
+ if pth.endswith(".md"):
878
+ by_key.setdefault(_plan_key(pth), []).append(pth)
879
+ return by_key
880
+
881
+
882
+ def _canon_at(repo, sha, path):
883
+ """The canonical block a commit holds for `path`, or None when it holds none or cannot parse."""
884
+ blocks = _blocks_at(repo, sha, path)
885
+ if not blocks:
886
+ return None
887
+ try:
888
+ return canonical(parse_block(blocks[0]))
889
+ except ParseError:
890
+ return None
891
+
892
+
893
+ def _pin_blob(repo, rev, path):
894
+ """(mode, blob) of `path` at `rev`, or ("", "") when absent."""
895
+ out = _out(["ls-tree", rev, "--", path], repo) or ""
896
+ m = re.match(r"^(\d+) \w+ ([0-9a-f]+)\t", out)
897
+ return (m.group(1), m.group(2)) if m else ("", "")
898
+
899
+
900
+ def _head_dirty(repo):
901
+ out = _out(["status", "--porcelain", "--untracked-files=all"], repo) or "" # "normal" collapses a new directory, hiding the log inside it
902
+ return any(not ln[3:].strip('"').endswith(LOG_NAME) for ln in out.splitlines() if ln.strip())
903
+
904
+
905
+ def _default_bases(repo):
906
+ ok = {"origin/main", "origin/master"}
907
+ head = (_out(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repo) or "").strip()
908
+ if head:
909
+ ok.add(head)
910
+ return ok
911
+
912
+
913
+ def _verify_candidate(emit, repo, path, lines, frozen):
914
+ """Baseline mode: the block is not frozen yet, so there is no freeze to compare with.
915
+
916
+ Everything that can be decided from the block and the CURRENT tree still is: parse, the static
917
+ rules, every pin against HEAD's blob. The freeze commit follows a valid baseline run, never
918
+ precedes it.
919
+ """
920
+ try:
921
+ block = parse_block(lines)
922
+ except ParseError as e:
923
+ return emit("FAIL", reason="unparseable", plan=path, detail=str(e)[:120])
924
+ canon = canonical(block)
925
+ info = {"plan": path, "freeze_sha": "", "freeze_source": "candidate", "hash": canonical_hash(canon),
926
+ "block": canon, "refreeze": bool(frozen)}
927
+ problem = static_problem(block)
928
+ if problem:
929
+ return emit("FAIL", reason=problem, **info, detail="the candidate block is not acceptable as written")
930
+ declared = block.get("hash")
931
+ if declared and declared != info["hash"]:
932
+ return emit("FAIL", reason="hash-mismatch", **info, detail="hash: does not match the block's own fields")
933
+ for pth, sha in canon["pins"].items():
934
+ pth = pth[2:] if pth.startswith("./") else pth
935
+ mode, blob = _pin_blob(repo, "HEAD", pth)
936
+ if mode not in ("100644", "100755") or blob != sha:
937
+ return emit("FAIL", reason="pin-not-at-freeze", **info, detail=f"{pth} is not pinned to its blob in HEAD")
938
+ return emit("OK", reason="candidate", **info, changed_fields=[], reasons=[], flags=[])
939
+
940
+
941
+ def cmd_verify(a):
942
+ emit = _Emitter(a)
943
+ repo = a.repo or os.getcwd()
944
+ top = (_out(["rev-parse", "--show-toplevel"], repo) or "").strip()
945
+ if not top:
946
+ return emit("FAIL", reason="not-a-repository", detail="verify must run inside the repository")
947
+ repo = top
948
+ head_sha = (_out(["rev-parse", "HEAD"], repo) or "").strip()
949
+ emit.extra.update({"head_sha": head_sha, "dirty": _head_dirty(repo) if head_sha else False,
950
+ "pr_author_checked": False, "no_pr": bool(a.no_pr)})
951
+ branch = _branch(repo)
952
+
953
+ base = a.base[len("refs/remotes/"):] if a.base.startswith("refs/remotes/") else a.base
954
+ if base not in _default_bases(repo):
955
+ return emit("FAIL", reason="base-not-default-branch",
956
+ detail=f"--base {a.base} is not the remote default branch, so it cannot anchor a freeze")
957
+ merge_base = (_out(["merge-base", base, "HEAD"], repo) or "").strip()
958
+
959
+ cands = set()
960
+ for p in (a.plan or []):
961
+ full = p if os.path.isabs(p) else os.path.join(repo, p)
962
+ rel = os.path.relpath(full, repo) if os.path.commonpath([os.path.realpath(repo), os.path.dirname(os.path.realpath(full)) or "/"]) == os.path.realpath(repo) else full
963
+ cands.add(rel)
964
+ tp = _tasks_plan(repo, branch) if branch else None
965
+ if tp:
966
+ cands.add(tp)
967
+
968
+ commits, evidence, refreeze, back, prior, refrozen_from = [], {}, {}, {}, {}, {}
969
+ operator = _operator_email(repo)
970
+ if merge_base:
971
+ for args in (
972
+ ["diff", "--name-only", merge_base, "HEAD", "--", PLANS_DIR],
973
+ ["diff", "--name-only", "HEAD", "--", PLANS_DIR],
974
+ ["ls-files", "--others", "--exclude-standard", "--", PLANS_DIR],
975
+ ):
976
+ cands.update(p for p in _nul_list(args, repo) if p.endswith(".md"))
977
+
978
+ def resolve(p):
979
+ for _ in range(1000):
980
+ if p not in back:
981
+ break
982
+ p = back[p]
983
+ return _plan_key(p)
984
+
985
+ commits = _history(repo, merge_base)
986
+ if commits is None:
987
+ return emit("FAIL", reason="history-too-long",
988
+ detail=f"the branch has more than {MAX_COMMITS} commits since {a.base}")
989
+ for c in commits:
990
+ for st, old, new in c["changes"]:
991
+ if st == "R" and old:
992
+ back[new] = old
993
+ for st, old, p in c["changes"]:
994
+ if st == "D" or not (p.startswith(PLANS_DIR + "/") and p.endswith(".md")):
995
+ continue
996
+ ident = resolve(p)
997
+ if ident not in evidence and _blocks_at(repo, c["sha"], p):
998
+ evidence[ident] = {"sha": c["sha"], "path": p}
999
+ prior[ident] = _canon_at(repo, c["sha"], p)
1000
+ # A re-freeze is a deliberate CHANGE of a check that was already frozen earlier on
1001
+ # this branch. A commit that restates the same block changes nothing and must not
1002
+ # launder the earlier freeze's authorship; a first freeze is just a freeze.
1003
+ elif c["subject"].startswith(REFREEZE_PREFIX) and c["email"] == operator:
1004
+ # (reached only after the plan's first block commit: that commit takes the branch above)
1005
+ cur = _canon_at(repo, c["sha"], p)
1006
+ if cur is not None and cur != prior.get(ident):
1007
+ refrozen_from[ident] = prior.get(ident)
1008
+ refreeze[ident] = {"sha": c["sha"], "path": p}
1009
+ prior[ident] = cur
1010
+ cands.update(v["path"] for v in evidence.values() if os.path.lexists(os.path.join(repo, v["path"])))
1011
+ else:
1012
+ resolve = _plan_key # noqa: F811 -- no history to follow
1013
+ # No merge-base: nothing names the plan, so look at every plan on disk. A block found this
1014
+ # way is FAIL base-unresolvable below; finding none is NO-BLOCK, so a repo that never used
1015
+ # the feature still ships.
1016
+ for args in (["ls-files", "--", PLANS_DIR], ["ls-files", "--others", "--exclude-standard", "--", PLANS_DIR]):
1017
+ cands.update(p for p in _nul_list(args, repo) if p.endswith(".md"))
1018
+
1019
+ freeze_of = {}
1020
+ on_main = _plans_at(repo, merge_base) if merge_base else {}
1021
+ for p in cands:
1022
+ ident = resolve(p)
1023
+ # Look the plan up at the merge base under EVERY name it had there: an unrelated edit to a
1024
+ # plan that main already archived must compare against main's frozen block, not become
1025
+ # its own freeze.
1026
+ main_path = next((q for q in sorted(on_main.get(ident, [])) if _blocks_at(repo, merge_base, q)), None)
1027
+ if main_path:
1028
+ freeze_of[ident] = {"sha": merge_base, "path": main_path, "source": "merge-base"}
1029
+ elif ident in refreeze:
1030
+ freeze_of[ident] = {**refreeze[ident], "source": "refreeze"}
1031
+ elif ident in evidence:
1032
+ freeze_of[ident] = {**evidence[ident], "source": "branch"}
1033
+ for ident, ev in evidence.items():
1034
+ if ident not in freeze_of:
1035
+ freeze_of[ident] = {**(refreeze.get(ident) or ev), "source": "refreeze" if ident in refreeze else "branch"}
1036
+
1037
+ heads = {}
1038
+ for p in sorted(cands):
1039
+ status, text = _read_plan(repo, p)
1040
+ if status == "missing":
1041
+ continue
1042
+ if status != "ok":
1043
+ reason = {
1044
+ "symlink": "symlinked-plan", "not-regular": "plan-not-regular", "outside": "plan-outside-plans-dir",
1045
+ "too-large": "unparseable", "unreadable": "plan-unreadable",
1046
+ }[status]
1047
+ return emit("FAIL", reason=reason, plan=p, detail=f"the plan is not read: {status.replace('-', ' ')}")
1048
+ blocks = extract_blocks(text)
1049
+ if len(blocks) > 1:
1050
+ return emit("FAIL", reason="multiple-blocks", plan=p, detail="more than one founder_check block under Acceptance Criteria")
1051
+ if blocks:
1052
+ heads[p] = blocks[0]
1053
+
1054
+ if len(heads) > 1:
1055
+ return emit("FAIL", reason="multiple-plans", detail="more than one plan carries a founder_check block: " + ", ".join(sorted(heads)))
1056
+
1057
+ if not merge_base:
1058
+ if heads:
1059
+ return emit("FAIL", reason="base-unresolvable",
1060
+ detail=f"cannot resolve {a.base}, so a freeze cannot be located for the block in " + ", ".join(sorted(heads)))
1061
+ if a.candidate:
1062
+ return emit("FAIL", reason="no-block-candidate", detail="no founder_check block was found")
1063
+ return emit("NO-BLOCK", reason="no-block", base_note="base-unresolvable")
1064
+
1065
+ if not heads:
1066
+ if a.candidate:
1067
+ return emit("FAIL", reason="no-block-candidate",
1068
+ detail="no founder_check block under an Acceptance Criteria heading; check the heading and the fence")
1069
+ if freeze_of:
1070
+ return emit(
1071
+ "FAIL", reason="freeze-without-block",
1072
+ detail="a freeze exists in history for " + ", ".join(sorted(freeze_of)) + " but no block resolves at HEAD",
1073
+ )
1074
+ return emit("NO-BLOCK", reason="no-block")
1075
+
1076
+ (path, lines), = heads.items()
1077
+ ident = resolve(path)
1078
+ if a.candidate:
1079
+ if ident in freeze_of and not a.refreeze:
1080
+ return emit("FAIL", reason="candidate-refused-frozen", plan=path,
1081
+ detail="a freeze exists for this plan; a candidate run is only for a block that is not frozen (use --refreeze to re-freeze a changed check)")
1082
+ return _verify_candidate(emit, repo, path, lines, ident in freeze_of)
1083
+ stale = sorted(p for p in freeze_of if p != ident)
1084
+ if stale:
1085
+ return emit("FAIL", reason="freeze-without-block", plan=path, detail="a block was frozen at " + ", ".join(stale) + " and is not there at HEAD")
1086
+ if ident not in freeze_of:
1087
+ return emit("FAIL", reason="no-freeze", plan=path, detail="the block has no freeze commit; commit it before any code")
1088
+ fz = freeze_of[ident]
1089
+ freeze_sha, source = fz["sha"], fz["source"]
1090
+
1091
+ try:
1092
+ head_block = parse_block(lines)
1093
+ except ParseError as e:
1094
+ return emit("FAIL", reason="unparseable", plan=path, detail=str(e)[:120])
1095
+ flines = (_blocks_at(repo, freeze_sha, fz["path"]) or [[]])[0]
1096
+ try:
1097
+ frozen_block = parse_block(flines)
1098
+ except ParseError as e:
1099
+ return emit("FAIL", reason="unparseable", plan=path, detail="freeze copy: " + str(e)[:120])
1100
+
1101
+ head_c, frozen_c = canonical(head_block), canonical(frozen_block)
1102
+ head_hash, frozen_hash = canonical_hash(head_c), canonical_hash(frozen_c)
1103
+ freeze_author = (_out(["log", "-1", "--format=%ae", freeze_sha], repo) or "").strip().lower()
1104
+ base_info = {"plan": path, "freeze_sha": freeze_sha, "freeze_source": source, "hash": head_hash,
1105
+ "block": head_c, "frozen": {k: frozen_c[k] for k in ("kind", "text", "command", "expected", "approved_by", "approved_at")},
1106
+ "freeze_author": freeze_author, "refreeze": source == "refreeze",
1107
+ "refrozen_from": ({k: refrozen_from[ident][k] for k in ("kind", "text", "command", "expected", "approved_by", "approved_at")}
1108
+ if source == "refreeze" and refrozen_from.get(ident) else None)}
1109
+
1110
+ problem = static_problem(frozen_block)
1111
+ if problem:
1112
+ return emit("FAIL", reason=problem, **base_info, detail="the frozen block is not acceptable as written")
1113
+ for blk, h in ((head_block, head_hash), (frozen_block, frozen_hash)):
1114
+ declared = blk.get("hash")
1115
+ if declared and declared != h:
1116
+ return emit("FAIL", reason="hash-mismatch", **base_info, detail="hash: does not match the block's own fields")
1117
+
1118
+ # Pins are facts about the FREEZE tree: a regular file at exactly the pinned blob, never a link.
1119
+ for pth, sha in frozen_c["pins"].items():
1120
+ pth = pth[2:] if pth.startswith("./") else pth
1121
+ mode, blob = _pin_blob(repo, freeze_sha, pth)
1122
+ if mode not in ("100644", "100755") or blob != sha:
1123
+ return emit("FAIL", reason="pin-not-at-freeze", **base_info, detail=f"{pth} is not pinned to a regular file at its blob at the freeze")
1124
+
1125
+ reasons, flags = [], []
1126
+
1127
+ # Authorship anchor (Guard 3). A freeze reviewed on main needs none.
1128
+ if source in ("branch", "refreeze"):
1129
+ if not operator or freeze_author != operator:
1130
+ flags.append("freeze-author")
1131
+ if a.pr_author and a.operator_login:
1132
+ emit.extra["pr_author_checked"] = True
1133
+ if a.pr_author.lower() != a.operator_login.lower():
1134
+ flags.append("pr-author")
1135
+ elif not a.no_pr:
1136
+ flags.append("pr-author-unmeasurable")
1137
+
1138
+ changed = [k for k in CANONICAL_FIELDS if head_c[k] != frozen_c[k]]
1139
+ if changed:
1140
+ reasons.append("field-changed")
1141
+ for pth, sha in frozen_c["pins"].items():
1142
+ pth = pth[2:] if pth.startswith("./") else pth
1143
+ full = os.path.join(repo, pth)
1144
+ if os.path.islink(full):
1145
+ reasons.append("pinned-script-changed")
1146
+ break
1147
+ rc, h = _git(["hash-object", "--no-filters", "--", pth], repo)
1148
+ if rc != 0 or h.strip() != sha:
1149
+ reasons.append("pinned-script-changed")
1150
+ break
1151
+ if source == "branch":
1152
+ idx = {c["sha"]: n for n, c in enumerate(commits)}
1153
+ if freeze_sha in idx:
1154
+ for n, c in enumerate(commits):
1155
+ paths = [x for ch in c["changes"] for x in (ch[1], ch[2]) if x]
1156
+ if any(not x.startswith("knowledge-base/") for x in paths):
1157
+ if idx[freeze_sha] >= n:
1158
+ reasons.append("ordering")
1159
+ break
1160
+
1161
+ info = {**base_info, "changed_fields": changed, "reasons": reasons, "flags": flags}
1162
+ if flags:
1163
+ return emit("UNTRUSTED", reason="authorship", **info, detail="the freeze does not anchor to the local operator; the command is shown, never run")
1164
+ if reasons:
1165
+ return emit("CHANGED-SINCE-APPROVAL", reason=reasons[0], **info, detail="the approved text, a pinned script or the freeze ordering changed")
1166
+ if source == "refreeze" and a.mode != "interactive":
1167
+ # A re-freeze replaces what the founder first approved. Only a present founder can say yes
1168
+ # to that, so an unattended run stops on it instead of running the replacement.
1169
+ return emit("CHANGED-SINCE-APPROVAL", reason="refreeze-needs-founder", **info,
1170
+ detail="the approved check was replaced by a re-freeze; an unattended run does not decide that")
1171
+ return emit("OK", reason="ok", **info)
1172
+
1173
+
1174
+ # ---------------------------------------------------------------------------------------------
1175
+ # classify
1176
+ # ---------------------------------------------------------------------------------------------
1177
+ class Result(NamedTuple):
1178
+ outcome: str
1179
+ expected_matched: bool
1180
+ reason: str
1181
+
1182
+
1183
+ def classify(rc, stdout, expected, polarity, first="", sandbox_healthy=True, refreeze=False):
1184
+ """The one decision chokepoint, shared by baseline and acceptance polarity."""
1185
+ matched = (expected == "") or (expected in stdout)
1186
+ if not sandbox_healthy:
1187
+ return Result("INVALID", matched, "sandbox-unhealthy")
1188
+ if rc in (124, 126, 127):
1189
+ return Result("INVALID", matched, f"tooling-rc-{rc}")
1190
+ if first == "curl" and rc in (6, 7, 28):
1191
+ return Result("INVALID", matched, f"curl-rc-{rc}")
1192
+ if polarity == "baseline" and refreeze:
1193
+ # A deliberate change made after the work exists: a pass is the normal case, not a vacuous
1194
+ # check, so this run reports what happened and the founder decides.
1195
+ if rc == 0 and matched:
1196
+ return Result("PASSED", matched, "refreeze-baseline-passes")
1197
+ return Result("FAILED", matched, "refreeze-baseline-fails")
1198
+ if polarity == "baseline":
1199
+ if rc == 0 and matched:
1200
+ return Result("VACUOUS", matched, "baseline-passes")
1201
+ return Result("FAILED-AS-EXPECTED", matched, "baseline-fails")
1202
+ if rc == 0 and matched:
1203
+ return Result("PASSED", matched, "ran-returned-success")
1204
+ return Result("FAILED", matched, "non-zero-or-expected-absent")
1205
+
1206
+
1207
+ def _read_capped(path):
1208
+ with open(path, "rb") as fh:
1209
+ data = fh.read(MAX_PLAN_BYTES + 1)
1210
+ if len(data) > MAX_PLAN_BYTES:
1211
+ raise ValueError("file larger than 1 MiB")
1212
+ return data.decode("utf-8", errors="replace")
1213
+
1214
+
1215
+ def _load_json(path, what, digest=None):
1216
+ """Read a record file. `digest`, a list, receives the sha256 of exactly the text that was parsed."""
1217
+ try:
1218
+ text = _read_capped(path)
1219
+ doc = json.loads(text)
1220
+ except (OSError, ValueError) as e:
1221
+ print(f"refused: cannot read {what} {path}: {e}", file=sys.stderr)
1222
+ return None
1223
+ if not isinstance(doc, dict):
1224
+ print(f"refused: {what} {path} is not a JSON object", file=sys.stderr)
1225
+ return None
1226
+ if digest is not None:
1227
+ digest.append(hashlib.sha256(text.encode("utf-8")).hexdigest())
1228
+ return doc
1229
+
1230
+
1231
+ def cmd_classify(a):
1232
+ dg = []
1233
+ v = _load_json(a.verify_json, "the verify record", dg)
1234
+ if v is None:
1235
+ return 2
1236
+ block = v.get("block") if isinstance(v.get("block"), dict) else {}
1237
+ if v.get("outcome") != "OK" or block.get("kind") != "command":
1238
+ print(f"refused: classify only runs on an OK verify of a command check (outcome={v.get('outcome')!s:.40}, kind={block.get('kind')!s:.20})", file=sys.stderr)
1239
+ return 2
1240
+ candidate = v.get("reason") == "candidate"
1241
+ if (a.polarity == "baseline") != candidate:
1242
+ print("refused: baseline polarity needs a --candidate verify record, acceptance needs a frozen one", file=sys.stderr)
1243
+ return 2
1244
+ approved = block.get("command", "")
1245
+ if not approved:
1246
+ print("refused: the verify record carries no command to classify a run of", file=sys.stderr)
1247
+ return 2
1248
+ try:
1249
+ ran = _read_capped(a.command_file)
1250
+ except (OSError, ValueError) as e:
1251
+ print(f"refused: cannot read --command-file: {e}", file=sys.stderr)
1252
+ return 2
1253
+ if ran != approved:
1254
+ print("refused: the command that ran is not the approved command in the verify record", file=sys.stderr)
1255
+ return 2
1256
+ stdout = ""
1257
+ if a.stdout_file:
1258
+ try:
1259
+ stdout = _read_capped(a.stdout_file)
1260
+ except (OSError, ValueError) as e:
1261
+ print(f"refused: cannot read --stdout-file: {e}", file=sys.stderr)
1262
+ return 2
1263
+ r = classify(a.rc, stdout, block.get("expected", ""), a.polarity, str(v.get("first_token", "")), a.control_rc == 0, bool(v.get("refreeze")))
1264
+ doc = {"outcome": r.outcome, "expected_matched": r.expected_matched, "reason": r.reason,
1265
+ "rc": a.rc, "polarity": a.polarity, "hash": str(v.get("hash", "")),
1266
+ "head_sha": str(v.get("head_sha", "")), "verify_sha256": dg[0]}
1267
+ line = json.dumps(doc, sort_keys=True)
1268
+ if a.out:
1269
+ with open(a.out, "w", encoding="utf-8") as fh:
1270
+ fh.write(line + "\n")
1271
+ print(line)
1272
+ return 0
1273
+
1274
+
1275
+ # ---------------------------------------------------------------------------------------------
1276
+ # log / commit-log / summary / text
1277
+ # ---------------------------------------------------------------------------------------------
1278
+ def _cell(v):
1279
+ s = _CONTROL.sub(" ", str(v))
1280
+ return re.sub(r"\s+", " ", s).replace("\\", "\\\\").replace("|", "\\|").strip()
1281
+
1282
+
1283
+ def _resolve_log(a):
1284
+ if a.log:
1285
+ return os.path.abspath(a.log)
1286
+ top = (_out(["rev-parse", "--show-toplevel"], os.getcwd()) or "").strip()
1287
+ return _log_path(top, _branch(top)) if top else None
1288
+
1289
+
1290
+ CLASSIFY_OUTCOMES = frozenset({"PASSED", "FAILED", "INVALID", "FAILED-AS-EXPECTED", "VACUOUS"})
1291
+
1292
+
1293
+ def _derivable(v, cl, polarity):
1294
+ """The outcomes the records allow a row to carry. `log` records a measurement, it does not
1295
+ choose one: an outcome no record supports is refused, whoever asks for it."""
1296
+ block = v.get("block") if isinstance(v.get("block"), dict) else {}
1297
+ vo = v.get("outcome")
1298
+ if vo == "UNTRUSTED":
1299
+ return {"UNTRUSTED"}
1300
+ if vo == "CHANGED-SINCE-APPROVAL":
1301
+ return {"CHANGED-SINCE-APPROVAL"}
1302
+ if vo == "FAIL":
1303
+ return {"BLOCK-REJECTED"}
1304
+ if vo != "OK":
1305
+ return set()
1306
+ if (polarity == "baseline") != (v.get("reason") == "candidate"):
1307
+ return set()
1308
+ if cl:
1309
+ return {cl["outcome"]}
1310
+ if block.get("kind") == "judgement":
1311
+ return {"NEEDS-YOUR-EYES", "FOUNDER-CONFIRMED", "FAILED"} if polarity == "acceptance" else {"NEEDS-YOUR-EYES"}
1312
+ # a command that never produced an rc: no sandbox, a refused token, or an unreadable wrapper result
1313
+ return {"SKIP-NOSANDBOX", "INVALID", "BLOCK-REJECTED"}
1314
+
1315
+
1316
+ def _unsafe_log_path(path):
1317
+ """Why `path` may not be written, or None. A link anywhere below the repository root (the spec
1318
+ directory, an ancestor, the log itself) would let a committed symlink send the append outside
1319
+ the repository, or make `commit-log` commit the link."""
1320
+ top = (_out(["rev-parse", "--show-toplevel"], os.path.dirname(path) if os.path.isdir(os.path.dirname(path)) else os.getcwd()) or "").strip()
1321
+ real_top = os.path.realpath(top) if top else ""
1322
+ cur = os.path.abspath(path)
1323
+ chain = [cur]
1324
+ if not top: # outside any repository: the file and its own directory are all this can vouch for
1325
+ chain.append(os.path.dirname(cur))
1326
+ while top:
1327
+ parent = os.path.dirname(cur)
1328
+ if parent == cur or (real_top and os.path.realpath(parent) == real_top) or (top and parent == top):
1329
+ break
1330
+ chain.append(parent)
1331
+ cur = parent
1332
+ for part in chain:
1333
+ if os.path.islink(part):
1334
+ return f"{os.path.relpath(part)} is a symbolic link; the log is never written through a link"
1335
+ if os.path.lexists(path) and not os.path.isfile(path):
1336
+ return "the log path exists and is not a regular file"
1337
+ return None
1338
+
1339
+
1340
+ def cmd_log(a):
1341
+ dg = []
1342
+ v = _load_json(a.verify_json, "the verify record", dg)
1343
+ if v is None:
1344
+ return 2
1345
+ cl = {}
1346
+ if a.classify_json:
1347
+ cl = _load_json(a.classify_json, "the classify record")
1348
+ if cl is None:
1349
+ return 2
1350
+ if (cl.get("outcome") not in CLASSIFY_OUTCOMES or cl.get("polarity") != a.polarity
1351
+ or cl.get("verify_sha256") != dg[0] or cl.get("hash") != str(v.get("hash", ""))
1352
+ or cl.get("head_sha") != str(v.get("head_sha", ""))):
1353
+ print("refused: the classify record does not belong to this verify record and polarity", file=sys.stderr)
1354
+ return 3
1355
+ block = v.get("block") if isinstance(v.get("block"), dict) else {}
1356
+ h = str(v.get("hash", ""))
1357
+ sha = str(v.get("head_sha", ""))
1358
+ if (h and not re.fullmatch(r"[0-9a-f]{64}", h)) or (sha and not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha)):
1359
+ print("refused: the verify record carries a hash or sha that is not hex", file=sys.stderr)
1360
+ return 3
1361
+ outcome, underlying = a.outcome, a.underlying or ""
1362
+ reason = ""
1363
+ if a.reason_stdin:
1364
+ reason = sys.stdin.read(600).strip()
1365
+ if a.mode == "headless":
1366
+ if outcome in HEADLESS_REFUSED:
1367
+ print(f"refused: {outcome} is an interactive-only outcome; a headless run cannot record it", file=sys.stderr)
1368
+ return 3
1369
+ if outcome in HEADLESS_STOPS or outcome == "SKIP-NOSANDBOX":
1370
+ underlying, outcome = outcome, "STOPPED-AWAITING-FOUNDER"
1371
+ allowed = _derivable(v, cl, a.polarity)
1372
+ measured = underlying if outcome in ("OVERRIDDEN", "STOPPED-AWAITING-FOUNDER") else outcome
1373
+ if measured not in allowed:
1374
+ print(f"refused: the records do not support recording {measured or 'this outcome'} here", file=sys.stderr)
1375
+ return 3
1376
+ if outcome == "OVERRIDDEN":
1377
+ if not reason:
1378
+ print("refused: an override needs a one-line reason", file=sys.stderr)
1379
+ return 3
1380
+ if underlying not in OVERRIDE_CAUSES:
1381
+ print(f"refused: an override names what it overrides: --underlying {'|'.join(OVERRIDE_CAUSES)}", file=sys.stderr)
1382
+ return 3
1383
+ if reason and _secret_shaped(reason):
1384
+ print("refused: the reason looks like it holds a secret; the log is committed and may be public", file=sys.stderr)
1385
+ return 3
1386
+ frozen = v.get("frozen") if isinstance(v.get("frozen"), dict) else {}
1387
+ cmd = str(block.get("command", ""))
1388
+ if frozen.get("command") and frozen.get("command") != cmd:
1389
+ reason = (reason + " | frozen command: " + str(frozen["command"])).strip(" |")
1390
+ if isinstance(v.get("reasons"), list) and v["reasons"]:
1391
+ # CHANGED-SINCE-APPROVAL is three different things (a field, a pinned script, the ordering);
1392
+ # the row names which, so an override is never read as one cause when it was another.
1393
+ reason = (reason + " | reasons: " + ",".join(str(x)[:40] for x in v["reasons"][:5])).strip(" |")
1394
+ tested = sha[:12] + ("+uncommitted" if v.get("dirty") else "") if sha else ""
1395
+ row = {
1396
+ "kind": block.get("kind", ""), "polarity": a.polarity, "command": cmd,
1397
+ "rc": "" if cl.get("rc") is None else cl.get("rc"), "outcome": outcome, "underlying": underlying,
1398
+ "attempt_n": a.attempt_n if a.attempt_n is not None else "", "tested_sha": tested, "block_hash": h,
1399
+ "time_utc": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1400
+ "expected_matched": "" if cl.get("expected_matched") is None else str(bool(cl.get("expected_matched"))).lower(),
1401
+ "reason": reason,
1402
+ "freeze_source": str(v.get("freeze_source", ""))[:20],
1403
+ "no_pr": "true" if v.get("no_pr") else "false",
1404
+ }
1405
+ path = _resolve_log(a)
1406
+ if not path:
1407
+ print("refused: no log path (detached HEAD or an unsafe branch name); pass --log", file=sys.stderr)
1408
+ return 3
1409
+ problem = _unsafe_log_path(path)
1410
+ if problem:
1411
+ print(f"refused: {problem}", file=sys.stderr)
1412
+ return 3
1413
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1414
+ problem = _unsafe_log_path(path) # again, now that the directories exist
1415
+ if problem:
1416
+ print(f"refused: {problem}", file=sys.stderr)
1417
+ return 3
1418
+ try:
1419
+ # O_NOFOLLOW: a link planted at the log path (dangling or not) is refused, never written through
1420
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
1421
+ except OSError as e:
1422
+ print(f"refused: cannot open the log without following a link: {e.strerror}", file=sys.stderr)
1423
+ return 3
1424
+ with os.fdopen(fd, "a", encoding="utf-8") as fh:
1425
+ if fh.tell() == 0:
1426
+ fh.write("| " + " | ".join(LOG_COLUMNS) + " |\n")
1427
+ fh.write("| " + " | ".join("---" for _ in LOG_COLUMNS) + " |\n")
1428
+ fh.write("| " + " | ".join(_cell(row[c]) for c in LOG_COLUMNS) + " |\n")
1429
+ print(f"SOLEUR_FOUNDER_CHECK_RESULT outcome={outcome} hash={h} tested_sha={sha[:12]}")
1430
+ return 0
1431
+
1432
+
1433
+ def cmd_commit_log(a):
1434
+ top = (_out(["rev-parse", "--show-toplevel"], os.getcwd()) or "").strip()
1435
+ path = _resolve_log(a) if (a.log or top) else None
1436
+ if not top or not path or not os.path.isfile(path):
1437
+ print("founder-check: no log to commit")
1438
+ return 0
1439
+ rel = os.path.relpath(path, top)
1440
+ problem = _unsafe_log_path(path)
1441
+ if problem:
1442
+ print(f"founder-check: {problem}", file=sys.stderr)
1443
+ return 1
1444
+ if _branch(top) in {b.rsplit("/", 1)[-1] for b in _default_bases(top)}:
1445
+ # Hooks are kept (they are the repository's own gates and the log may be public), so the one
1446
+ # thing refused is committing the log straight onto the default branch.
1447
+ print("founder-check: refusing to commit the log on the default branch", file=sys.stderr)
1448
+ return 1
1449
+ rc, status = _git(["status", "--porcelain", "--", rel], top)
1450
+ if rc == 0 and not status.strip():
1451
+ print("founder-check: log already committed")
1452
+ return 0
1453
+ rc, _o = _git(["add", "--", rel], top)
1454
+ if rc == 0:
1455
+ rc, _o = _git(["commit", "-q", "-m", LOG_COMMIT_MESSAGE, "--", rel], top)
1456
+ if rc != 0:
1457
+ print(f"founder-check: could not commit the log: {_LAST_ERR[:200]}", file=sys.stderr)
1458
+ return 1
1459
+ print(f"founder-check: committed {rel}")
1460
+ return 0
1461
+
1462
+
1463
+ def _log_rows(path):
1464
+ with open(path, encoding="utf-8", errors="replace") as fh:
1465
+ lines = [ln.rstrip("\n") for ln in fh if ln.startswith("|")]
1466
+ sep = re.compile(r"^\|[\s:|-]*$")
1467
+ rows = []
1468
+ for i, ln in enumerate(lines):
1469
+ if sep.match(ln):
1470
+ continue
1471
+ if i + 1 < len(lines) and sep.match(lines[i + 1]):
1472
+ continue
1473
+ rows.append(ln)
1474
+ return rows
1475
+
1476
+
1477
+ def cmd_summary(a):
1478
+ path = a.log if a.log else _resolve_log(a)
1479
+ if not path or not os.path.isfile(path):
1480
+ if a.log:
1481
+ print(f"founder-check: log not found: {_cell(a.log)}", file=sys.stderr)
1482
+ return 1
1483
+ print("founder-check: no log")
1484
+ return 0
1485
+ print(f"founder-check: {len(_log_rows(path))} rows")
1486
+ return 0
1487
+
1488
+
1489
+ def cmd_text(a):
1490
+ if a.list:
1491
+ print("\n".join(sorted(WORDING)))
1492
+ return 0
1493
+ if not a.name:
1494
+ print("text: a name or --list is required", file=sys.stderr)
1495
+ return 2
1496
+ v = {}
1497
+ if a.verify_json:
1498
+ v = _load_json(a.verify_json, "the verify record") or {}
1499
+ sha = a.sha
1500
+ if v.get("head_sha"):
1501
+ sha = str(v["head_sha"])[:12] + (" plus uncommitted changes" if v.get("dirty") else "")
1502
+ detail = REJECT_REASONS.get(str(v.get("reason", ""))) or _CONTROL.sub(" ", str(v.get("detail", "")))
1503
+ cause, nxt = STOP_CAUSES.get(a.underlying, STOP_DEFAULT)
1504
+ print(WORDING[a.name].format(sha=sha, reason=a.reason, cause=cause, detail=detail, next=nxt))
1505
+ return 0
1506
+
1507
+
1508
+ # ---------------------------------------------------------------------------------------------
1509
+ def build_parser():
1510
+ p = argparse.ArgumentParser(prog="founder-check.py", description=__doc__.split("\n")[0], allow_abbrev=False)
1511
+ sub = p.add_subparsers(dest="cmd", required=True)
1512
+
1513
+ v = sub.add_parser("verify", allow_abbrev=False)
1514
+ v.add_argument("--base", default="origin/main")
1515
+ v.add_argument("--repo")
1516
+ v.add_argument("--plan", action="append")
1517
+ v.add_argument("--pr-author")
1518
+ v.add_argument("--operator-login")
1519
+ v.add_argument("--no-pr", action="store_true", help="no pull request exists yet, so there is no PR author to compare")
1520
+ v.add_argument("--out", help="write the decision record (one JSON line) to this file")
1521
+ v.add_argument("--command-out", help="write the block's raw command text to this file, only for an OK verdict")
1522
+ v.add_argument("--display-out", help="write an escaped, display-only copy of the command here, for every outcome that parsed a block")
1523
+ v.add_argument("--mode", choices=("interactive", "headless"), default="headless",
1524
+ help="only an interactive run may run a re-frozen check; the default is headless")
1525
+ v.add_argument("--candidate", action="store_true", help="baseline mode: validate a block that has no freeze commit yet")
1526
+ v.add_argument("--refreeze", action="store_true", help="with --candidate: baseline a deliberately changed check")
1527
+ v.set_defaults(fn=cmd_verify)
1528
+
1529
+ c = sub.add_parser("classify", allow_abbrev=False)
1530
+ c.add_argument("--verify-json", required=True)
1531
+ c.add_argument("--polarity", choices=("baseline", "acceptance"), required=True)
1532
+ c.add_argument("--rc", type=int, required=True)
1533
+ c.add_argument("--control-rc", type=int, required=True, help="rc of the `true` health-control run through the same wrapper")
1534
+ c.add_argument("--command-file", required=True, help="the command text the wrapper actually ran; it must equal the approved command")
1535
+ c.add_argument("--stdout-file")
1536
+ c.add_argument("--out")
1537
+ c.set_defaults(fn=cmd_classify)
1538
+
1539
+ g = sub.add_parser("log", allow_abbrev=False)
1540
+ g.add_argument("--log")
1541
+ g.add_argument("--mode", choices=("interactive", "headless"), required=True)
1542
+ g.add_argument("--outcome", choices=OUTCOMES, required=True)
1543
+ g.add_argument("--polarity", choices=("baseline", "acceptance"), required=True)
1544
+ g.add_argument("--verify-json", required=True)
1545
+ g.add_argument("--classify-json")
1546
+ g.add_argument("--attempt-n", type=int)
1547
+ g.add_argument("--underlying", choices=OVERRIDE_CAUSES)
1548
+ g.add_argument("--reason-stdin", action="store_true", help="read the founder's one-line reason from stdin")
1549
+ g.set_defaults(fn=cmd_log)
1550
+
1551
+ k = sub.add_parser("commit-log", allow_abbrev=False)
1552
+ k.add_argument("--log")
1553
+ k.set_defaults(fn=cmd_commit_log)
1554
+
1555
+ s = sub.add_parser("summary", allow_abbrev=False)
1556
+ s.add_argument("--log")
1557
+ s.set_defaults(fn=cmd_summary)
1558
+
1559
+ t = sub.add_parser("text", allow_abbrev=False)
1560
+ t.add_argument("name", nargs="?", choices=sorted(WORDING))
1561
+ t.add_argument("--list", action="store_true")
1562
+ t.add_argument("--verify-json")
1563
+ t.add_argument("--sha", default="<sha>")
1564
+ t.add_argument("--reason", default="<reason>")
1565
+ t.add_argument("--underlying", choices=tuple(STOP_CAUSES))
1566
+ t.set_defaults(fn=cmd_text)
1567
+ return p
1568
+
1569
+
1570
+ _OUTPUT_FLAGS = ("--out", "--command-out", "--display-out")
1571
+
1572
+
1573
+ def _note(exc, site):
1574
+ # Class name and a static site label only. Never str(exc)/repr(exc): exception text can carry a path or
1575
+ # the founder's command. `site` is a string literal at every call site.
1576
+ # A closed, full or missing stderr (None when fd 2 is closed) must not turn a swallowed error into a crash.
1577
+ with contextlib.suppress(OSError, ValueError, AttributeError):
1578
+ sys.stderr.write("founder-check: swallowed %s at %s\n" % (type(exc).__name__, site))
1579
+ sys.stderr.flush()
1580
+
1581
+
1582
+ def _locator(exc):
1583
+ # Exception class plus the innermost frame IN THIS FILE (function name and line), e.g. "KeyError at cmd_verify:1034";
1584
+ # the innermost frame overall is usually a stdlib one an agent cannot locate. Never str(exc): that text can carry a
1585
+ # path or the founder's command. Needs no import. Mirrored inline in incident/scripts/redact-engine.py main().
1586
+ tb, own, last = exc.__traceback__, None, None
1587
+ while tb is not None:
1588
+ last = tb
1589
+ if tb.tb_frame.f_code.co_filename == __file__:
1590
+ own = tb
1591
+ tb = tb.tb_next
1592
+ tb = own or last
1593
+ if tb is None:
1594
+ return type(exc).__name__
1595
+ return "%s at %s:%s" % (type(exc).__name__, tb.tb_frame.f_code.co_name, tb.tb_lineno)
1596
+
1597
+
1598
+ def _clear_outputs(argv):
1599
+ """Remove the files a run is about to write, BEFORE argument parsing. A stale decision record or
1600
+ command file from an earlier run must never survive a run that dies early (a usage error, a
1601
+ crash): an absent file is a refusal downstream, a stale one is somebody else's verdict."""
1602
+ if not argv or argv[0] not in ("verify", "classify"):
1603
+ return
1604
+ paths = []
1605
+ for i, tok in enumerate(argv[1:], 1):
1606
+ for flag in _OUTPUT_FLAGS:
1607
+ if tok == flag and i + 1 < len(argv):
1608
+ paths.append(argv[i + 1])
1609
+ elif tok.startswith(flag + "="):
1610
+ paths.append(tok[len(flag) + 1:])
1611
+ for p in paths:
1612
+ try:
1613
+ if os.path.islink(p) or os.path.isfile(p):
1614
+ os.unlink(p)
1615
+ except OSError as exc:
1616
+ _note(exc, "clear-output")
1617
+
1618
+
1619
+ def main(argv=None):
1620
+ argv = list(sys.argv[1:] if argv is None else argv)
1621
+ _clear_outputs(argv)
1622
+ args = build_parser().parse_args(argv)
1623
+ try:
1624
+ return args.fn(args)
1625
+ except Exception as e: # a traceback is an unreadable verdict: print a one-line FAIL and a distinct rc
1626
+ line = json.dumps({"outcome": "FAIL", "reason": "internal-error", "environmental": True,
1627
+ "detail": _locator(e)}) # class name and innermost frame only: exception text can carry a path or the command
1628
+ out = getattr(args, "out", None)
1629
+ if out:
1630
+ try:
1631
+ with open(out, "w", encoding="utf-8") as fh:
1632
+ fh.write(line + "\n")
1633
+ except OSError as exc:
1634
+ _note(exc, "out-file-write")
1635
+ print(line)
1636
+ return 4
1637
+
1638
+
1639
+ if __name__ == "__main__":
1640
+ sys.exit(main())