rcf-lite 0.27.0 → 0.28.1

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 (601) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +19 -0
  3. package/bin/rcf.js +2 -0
  4. package/blueprints/application-account-settings/CHANGELOG.md +35 -6
  5. package/blueprints/application-account-settings/README.md +1 -1
  6. package/blueprints/application-account-settings/blueprint.json +4 -2
  7. package/blueprints/application-account-settings/contributions/adrs/adr-2602-application-account-settings-security-surface-shape.json +1 -1
  8. package/blueprints/application-account-settings/contributions/probes/probe-utils.mjs +177 -0
  9. package/blueprints/application-account-settings/contributions/probes/profile-form-autocomplete.mjs +67 -0
  10. package/blueprints/application-account-settings/contributions/probes/run-profile-form-autocomplete.mjs +5 -0
  11. package/blueprints/application-account-settings/contributions/probes/run-sessions-adapter-uniform.mjs +5 -0
  12. package/blueprints/application-account-settings/contributions/probes/run-sessions-surface-shape.mjs +5 -0
  13. package/blueprints/application-account-settings/contributions/probes/run-shell-tablist-per-capability.mjs +5 -0
  14. package/blueprints/application-account-settings/contributions/probes/run-theme-radiogroup.mjs +5 -0
  15. package/blueprints/application-account-settings/contributions/probes/sessions-adapter-uniform.mjs +130 -0
  16. package/blueprints/application-account-settings/contributions/probes/sessions-surface-shape.mjs +70 -0
  17. package/blueprints/application-account-settings/contributions/probes/shell-tablist-per-capability.mjs +60 -0
  18. package/blueprints/application-account-settings/contributions/probes/theme-radiogroup.mjs +126 -0
  19. package/blueprints/application-admin-console/CHANGELOG.md +32 -6
  20. package/blueprints/application-admin-console/README.md +1 -1
  21. package/blueprints/application-admin-console/blueprint.json +4 -2
  22. package/blueprints/application-admin-console/contributions/adrs/adr-2201-application-admin-console-capability-vocabulary.json +1 -1
  23. package/blueprints/application-admin-console/contributions/probes/audit-log-surface.mjs +79 -0
  24. package/blueprints/application-admin-console/contributions/probes/org-switcher-surface.mjs +83 -0
  25. package/blueprints/application-admin-console/contributions/probes/permission-matrix-grid.mjs +67 -0
  26. package/blueprints/application-admin-console/contributions/probes/probe-utils.mjs +177 -0
  27. package/blueprints/application-admin-console/contributions/probes/run-audit-log-surface.mjs +5 -0
  28. package/blueprints/application-admin-console/contributions/probes/run-org-switcher-surface.mjs +5 -0
  29. package/blueprints/application-admin-console/contributions/probes/run-permission-matrix-grid.mjs +5 -0
  30. package/blueprints/application-admin-console/contributions/probes/run-sign-in-access-gated-surface.mjs +5 -0
  31. package/blueprints/application-admin-console/contributions/probes/run-users-directory-surface.mjs +5 -0
  32. package/blueprints/application-admin-console/contributions/probes/sign-in-access-gated-surface.mjs +117 -0
  33. package/blueprints/application-admin-console/contributions/probes/users-directory-surface.mjs +145 -0
  34. package/blueprints/application-api-rest/CHANGELOG.md +16 -8
  35. package/blueprints/application-api-rest/README.md +3 -3
  36. package/blueprints/application-api-rest/blueprint.json +3 -2
  37. package/blueprints/application-api-rest/contributions/adrs/adr-302-application-api-rest-auth-model.json +1 -1
  38. package/blueprints/application-api-rest/contributions/probes/cursor-pagination-round-trip.mjs +154 -0
  39. package/blueprints/application-api-rest/contributions/probes/health-probes-distinct.mjs +124 -0
  40. package/blueprints/application-api-rest/contributions/probes/probe-utils.mjs +177 -0
  41. package/blueprints/application-api-rest/contributions/probes/problem-details-on-error.mjs +77 -0
  42. package/blueprints/application-api-rest/contributions/probes/request-id-echoed.mjs +76 -0
  43. package/blueprints/application-api-rest/contributions/probes/run-cursor-pagination-round-trip.mjs +5 -0
  44. package/blueprints/application-api-rest/contributions/probes/run-health-probes-distinct.mjs +5 -0
  45. package/blueprints/application-api-rest/contributions/probes/run-problem-details-on-error.mjs +5 -0
  46. package/blueprints/application-api-rest/contributions/probes/run-request-id-echoed.mjs +5 -0
  47. package/blueprints/application-api-rest/docs/topics.md +1 -1
  48. package/blueprints/application-charts/CHANGELOG.md +35 -9
  49. package/blueprints/application-charts/README.md +6 -6
  50. package/blueprints/application-charts/blueprint.json +5 -3
  51. package/blueprints/application-charts/contributions/adrs/adr-1901-application-charts-engine-choice.json +1 -1
  52. package/blueprints/application-charts/contributions/probes/keyboard-traversal.mjs +151 -0
  53. package/blueprints/application-charts/contributions/probes/non-colour-distinction.mjs +67 -0
  54. package/blueprints/application-charts/contributions/probes/probe-utils.mjs +177 -0
  55. package/blueprints/application-charts/contributions/probes/run-keyboard-traversal.mjs +5 -0
  56. package/blueprints/application-charts/contributions/probes/run-non-colour-distinction.mjs +5 -0
  57. package/blueprints/application-charts/contributions/probes/run-text-alternative-table.mjs +5 -0
  58. package/blueprints/application-charts/contributions/probes/text-alternative-table.mjs +139 -0
  59. package/blueprints/application-charts/contributions/requirements/application-charts-req-001.json +1 -1
  60. package/blueprints/application-charts/contributions/requirements/application-charts-req-002.json +1 -1
  61. package/blueprints/application-charts/contributions/user-stories/application-charts-us-18101.json +5 -5
  62. package/blueprints/application-charts/contributions/user-stories/application-charts-us-18106.json +1 -1
  63. package/blueprints/application-charts/guide/application-charts.md +1 -1
  64. package/blueprints/application-dashboard/CHANGELOG.md +20 -10
  65. package/blueprints/application-dashboard/README.md +6 -6
  66. package/blueprints/application-dashboard/assets/guidance/dashboard-design.md +2 -2
  67. package/blueprints/application-dashboard/blueprint.json +3 -2
  68. package/blueprints/application-dashboard/contributions/adrs/adr-2001-application-dashboard-primary-kpi-enum.json +1 -1
  69. package/blueprints/application-dashboard/contributions/adrs/adr-2002-application-dashboard-timeframe-presets.json +1 -1
  70. package/blueprints/application-dashboard/contributions/probes/export-handle-formats.mjs +75 -0
  71. package/blueprints/application-dashboard/contributions/probes/four-tile-states.mjs +75 -0
  72. package/blueprints/application-dashboard/contributions/probes/primary-kpi-top-left.mjs +58 -0
  73. package/blueprints/application-dashboard/contributions/probes/probe-utils.mjs +177 -0
  74. package/blueprints/application-dashboard/contributions/probes/run-export-handle-formats.mjs +4 -0
  75. package/blueprints/application-dashboard/contributions/probes/run-four-tile-states.mjs +4 -0
  76. package/blueprints/application-dashboard/contributions/probes/run-primary-kpi-top-left.mjs +4 -0
  77. package/blueprints/application-dashboard/contributions/probes/run-shell-five-regions.mjs +4 -0
  78. package/blueprints/application-dashboard/contributions/probes/shell-five-regions.mjs +93 -0
  79. package/blueprints/application-dashboard/contributions/requirements/application-dashboard-req-002.json +1 -1
  80. package/blueprints/application-dashboard/contributions/requirements/application-dashboard-req-003.json +1 -1
  81. package/blueprints/application-dashboard/contributions/tacs/tac-2002-application-dashboard-filter-chrome.json +1 -1
  82. package/blueprints/application-dashboard/contributions/tacs/tac-2003-application-dashboard-export-handle.json +2 -2
  83. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19101.json +2 -2
  84. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19102.json +2 -2
  85. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19103.json +1 -1
  86. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19104.json +1 -1
  87. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19105.json +1 -1
  88. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19106.json +2 -2
  89. package/blueprints/application-dashboard/contributions/user-stories/application-dashboard-us-19107.json +5 -5
  90. package/blueprints/application-dashboard/docs/topics.md +2 -2
  91. package/blueprints/application-dashboard/guide/application-dashboard.md +2 -2
  92. package/blueprints/application-datatable/CHANGELOG.md +19 -9
  93. package/blueprints/application-datatable/README.md +2 -2
  94. package/blueprints/application-datatable/blueprint.json +3 -2
  95. package/blueprints/application-datatable/contributions/probes/apg-table-shape.mjs +57 -0
  96. package/blueprints/application-datatable/contributions/probes/four-states-regions.mjs +80 -0
  97. package/blueprints/application-datatable/contributions/probes/probe-utils.mjs +177 -0
  98. package/blueprints/application-datatable/contributions/probes/run-apg-table-shape.mjs +4 -0
  99. package/blueprints/application-datatable/contributions/probes/run-four-states-regions.mjs +4 -0
  100. package/blueprints/application-datatable/contributions/probes/run-search-adapter-round-trip.mjs +4 -0
  101. package/blueprints/application-datatable/contributions/probes/run-sort-adapter-round-trip.mjs +4 -0
  102. package/blueprints/application-datatable/contributions/probes/search-adapter-round-trip.mjs +58 -0
  103. package/blueprints/application-datatable/contributions/probes/sort-adapter-round-trip.mjs +60 -0
  104. package/blueprints/application-datatable/contributions/requirements/application-datatable-req-006.json +1 -1
  105. package/blueprints/application-datatable/docs/topics.md +1 -1
  106. package/blueprints/application-empty-error-states/CHANGELOG.md +33 -3
  107. package/blueprints/application-empty-error-states/blueprint.json +4 -2
  108. package/blueprints/application-empty-error-states/contributions/probes/empty-list-and-no-search.mjs +66 -0
  109. package/blueprints/application-empty-error-states/contributions/probes/error-boundary-alert.mjs +65 -0
  110. package/blueprints/application-empty-error-states/contributions/probes/forbidden-and-server-error.mjs +167 -0
  111. package/blueprints/application-empty-error-states/contributions/probes/not-found-and-recovery.mjs +61 -0
  112. package/blueprints/application-empty-error-states/contributions/probes/permission-denied-and-offline.mjs +155 -0
  113. package/blueprints/application-empty-error-states/contributions/probes/probe-utils.mjs +177 -0
  114. package/blueprints/application-empty-error-states/contributions/probes/run-empty-list-and-no-search.mjs +5 -0
  115. package/blueprints/application-empty-error-states/contributions/probes/run-error-boundary-alert.mjs +5 -0
  116. package/blueprints/application-empty-error-states/contributions/probes/run-forbidden-and-server-error.mjs +5 -0
  117. package/blueprints/application-empty-error-states/contributions/probes/run-not-found-and-recovery.mjs +5 -0
  118. package/blueprints/application-empty-error-states/contributions/probes/run-permission-denied-and-offline.mjs +5 -0
  119. package/blueprints/application-error-handling/CHANGELOG.md +45 -6
  120. package/blueprints/application-error-handling/blueprint.json +3 -2
  121. package/blueprints/application-error-handling/contributions/probes/category-vocabulary.mjs +90 -0
  122. package/blueprints/application-error-handling/contributions/probes/probe-utils.mjs +177 -0
  123. package/blueprints/application-error-handling/contributions/probes/record-shape-adr-1701.mjs +78 -0
  124. package/blueprints/application-error-handling/contributions/probes/run-category-vocabulary.mjs +5 -0
  125. package/blueprints/application-error-handling/contributions/probes/run-record-shape-adr-1701.mjs +5 -0
  126. package/blueprints/application-error-handling/contributions/probes/run-two-boundaries-registered.mjs +5 -0
  127. package/blueprints/application-error-handling/contributions/probes/two-boundaries-registered.mjs +343 -0
  128. package/blueprints/application-file-upload/CHANGELOG.md +28 -6
  129. package/blueprints/application-file-upload/README.md +1 -1
  130. package/blueprints/application-file-upload/blueprint.json +3 -2
  131. package/blueprints/application-file-upload/contributions/probes/assertive-completion-slot.mjs +78 -0
  132. package/blueprints/application-file-upload/contributions/probes/chunked-transport-endpoints.mjs +173 -0
  133. package/blueprints/application-file-upload/contributions/probes/per-file-progressbar.mjs +124 -0
  134. package/blueprints/application-file-upload/contributions/probes/probe-utils.mjs +177 -0
  135. package/blueprints/application-file-upload/contributions/probes/run-assertive-completion-slot.mjs +4 -0
  136. package/blueprints/application-file-upload/contributions/probes/run-chunked-transport-endpoints.mjs +4 -0
  137. package/blueprints/application-file-upload/contributions/probes/run-per-file-progressbar.mjs +4 -0
  138. package/blueprints/application-file-upload/contributions/probes/run-upload-surface-shape.mjs +4 -0
  139. package/blueprints/application-file-upload/contributions/probes/upload-surface-shape.mjs +55 -0
  140. package/blueprints/application-file-upload/contributions/requirements/application-file-upload-req-001.json +1 -1
  141. package/blueprints/application-file-upload/contributions/requirements/application-file-upload-req-002.json +1 -1
  142. package/blueprints/application-file-upload/contributions/requirements/application-file-upload-req-003.json +1 -1
  143. package/blueprints/application-file-upload/contributions/requirements/application-file-upload-req-004.json +1 -1
  144. package/blueprints/application-file-upload/contributions/requirements/application-file-upload-req-005.json +1 -1
  145. package/blueprints/application-file-upload/probe-packs/application-file-upload.pack.mjs +1 -1
  146. package/blueprints/application-forms-wizard/CHANGELOG.md +20 -6
  147. package/blueprints/application-forms-wizard/README.md +2 -2
  148. package/blueprints/application-forms-wizard/blueprint.json +3 -2
  149. package/blueprints/application-forms-wizard/contributions/probes/draft-persistence-round-trip.mjs +110 -0
  150. package/blueprints/application-forms-wizard/contributions/probes/probe-utils.mjs +177 -0
  151. package/blueprints/application-forms-wizard/contributions/probes/run-draft-persistence-round-trip.mjs +4 -0
  152. package/blueprints/application-forms-wizard/contributions/probes/run-step-page-shape.mjs +4 -0
  153. package/blueprints/application-forms-wizard/contributions/probes/run-task-list-surface.mjs +4 -0
  154. package/blueprints/application-forms-wizard/contributions/probes/step-page-shape.mjs +61 -0
  155. package/blueprints/application-forms-wizard/contributions/probes/task-list-surface.mjs +109 -0
  156. package/blueprints/application-forms-wizard/contributions/requirements/application-forms-wizard-req-001.json +1 -1
  157. package/blueprints/application-forms-wizard/contributions/requirements/application-forms-wizard-req-002.json +1 -1
  158. package/blueprints/application-forms-wizard/contributions/requirements/application-forms-wizard-req-003.json +1 -1
  159. package/blueprints/application-forms-wizard/contributions/requirements/application-forms-wizard-req-004.json +1 -1
  160. package/blueprints/application-forms-wizard/contributions/requirements/application-forms-wizard-req-005.json +1 -1
  161. package/blueprints/application-forms-wizard/probe-packs/application-forms-wizard.pack.mjs +1 -1
  162. package/blueprints/application-notifications-in-app/CHANGELOG.md +38 -5
  163. package/blueprints/application-notifications-in-app/README.md +4 -4
  164. package/blueprints/application-notifications-in-app/blueprint.json +4 -2
  165. package/blueprints/application-notifications-in-app/contributions/adrs/adr-2101-application-notifications-in-app-live-region-role-mapping.json +1 -1
  166. package/blueprints/application-notifications-in-app/contributions/probes/centre-acknowledge-round-trip.mjs +210 -0
  167. package/blueprints/application-notifications-in-app/contributions/probes/live-region-preseeding.mjs +93 -0
  168. package/blueprints/application-notifications-in-app/contributions/probes/probe-utils.mjs +177 -0
  169. package/blueprints/application-notifications-in-app/contributions/probes/run-centre-acknowledge-round-trip.mjs +5 -0
  170. package/blueprints/application-notifications-in-app/contributions/probes/run-live-region-preseeding.mjs +5 -0
  171. package/blueprints/application-notifications-in-app/contributions/probes/run-toast-contract.mjs +5 -0
  172. package/blueprints/application-notifications-in-app/contributions/probes/toast-contract.mjs +92 -0
  173. package/blueprints/application-notifications-in-app/contributions/tacs/tac-2101-application-notifications-in-app-live-region.json +3 -3
  174. package/blueprints/application-notifications-in-app/contributions/tacs/tac-2102-application-notifications-in-app-centre.json +1 -1
  175. package/blueprints/application-notifications-in-app/contributions/user-stories/application-notifications-in-app-us-20101.json +1 -1
  176. package/blueprints/application-notifications-in-app/contributions/user-stories/application-notifications-in-app-us-20102.json +1 -1
  177. package/blueprints/application-notifications-in-app/contributions/user-stories/application-notifications-in-app-us-20103.json +1 -1
  178. package/blueprints/application-notifications-in-app/contributions/user-stories/application-notifications-in-app-us-20105.json +1 -1
  179. package/blueprints/application-notifications-in-app/guide/application-notifications-in-app.md +3 -3
  180. package/blueprints/application-onboarding-tour/CHANGELOG.md +31 -5
  181. package/blueprints/application-onboarding-tour/README.md +1 -1
  182. package/blueprints/application-onboarding-tour/blueprint.json +5 -3
  183. package/blueprints/application-onboarding-tour/contributions/probes/checklist-anchor-open.mjs +81 -0
  184. package/blueprints/application-onboarding-tour/contributions/probes/completion-persistence.mjs +159 -0
  185. package/blueprints/application-onboarding-tour/contributions/probes/first-run-detection.mjs +67 -0
  186. package/blueprints/application-onboarding-tour/contributions/probes/probe-utils.mjs +177 -0
  187. package/blueprints/application-onboarding-tour/contributions/probes/run-checklist-anchor-open.mjs +5 -0
  188. package/blueprints/application-onboarding-tour/contributions/probes/run-completion-persistence.mjs +5 -0
  189. package/blueprints/application-onboarding-tour/contributions/probes/run-first-run-detection.mjs +5 -0
  190. package/blueprints/application-onboarding-tour/contributions/probes/run-stepper-role-dialog.mjs +5 -0
  191. package/blueprints/application-onboarding-tour/contributions/probes/stepper-role-dialog.mjs +45 -0
  192. package/blueprints/application-spa/CHANGELOG.md +20 -11
  193. package/blueprints/application-spa/README.md +9 -10
  194. package/blueprints/application-spa/blueprint.json +3 -2
  195. package/blueprints/application-spa/contributions/adrs/adr-202-application-spa-theming.json +1 -1
  196. package/blueprints/application-spa/contributions/adrs/adr-206-application-spa-iconography.json +1 -1
  197. package/blueprints/application-spa/contributions/probes/designed-empty-state.mjs +72 -0
  198. package/blueprints/application-spa/contributions/probes/probe-utils.mjs +177 -0
  199. package/blueprints/application-spa/contributions/probes/route-inventory-published.mjs +185 -0
  200. package/blueprints/application-spa/contributions/probes/run-designed-empty-state.mjs +5 -0
  201. package/blueprints/application-spa/contributions/probes/run-route-inventory-published.mjs +5 -0
  202. package/blueprints/application-spa/contributions/probes/run-shell-nav-present.mjs +5 -0
  203. package/blueprints/application-spa/contributions/probes/shell-nav-present.mjs +69 -0
  204. package/blueprints/application-spa/contributions/tacs/tac-207-application-spa-token-adherence-probe.json +1 -1
  205. package/blueprints/application-spa/contributions/tacs/tac-208-application-spa-icon-adherence-probe.json +2 -2
  206. package/blueprints/application-spa/contributions/tacs/tac-209-application-spa-csp-styled-adherence-probe.json +3 -3
  207. package/blueprints/application-spa/contributions/tacs/tac-210-application-spa-external-dependency-provisioning-probe.json +2 -2
  208. package/blueprints/application-spa/contributions/tacs/tac-211-application-spa-core-flow-e2e-probe.json +2 -2
  209. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1131.json +1 -1
  210. package/blueprints/application-spa/docs/topics.md +1 -1
  211. package/blueprints/delivery-ci-workflows/CHANGELOG.md +31 -11
  212. package/blueprints/delivery-ci-workflows/README.md +3 -3
  213. package/blueprints/delivery-ci-workflows/blueprint.json +3 -2
  214. package/blueprints/delivery-ci-workflows/contributions/adrs/adr-702-delivery-ci-workflows-strict-coverage-gate.json +2 -2
  215. package/blueprints/delivery-ci-workflows/contributions/probes/node-gate-entrypoint.mjs +76 -0
  216. package/blueprints/delivery-ci-workflows/contributions/probes/probe-utils.mjs +56 -0
  217. package/blueprints/delivery-ci-workflows/contributions/probes/real-account-github-actions-run-record.mjs +139 -0
  218. package/blueprints/delivery-ci-workflows/contributions/probes/run-node-gate-entrypoint.mjs +3 -0
  219. package/blueprints/delivery-ci-workflows/contributions/probes/run-real-account-github-actions-run-record.mjs +3 -0
  220. package/blueprints/delivery-ci-workflows/contributions/probes/run-workflow-template-shape.mjs +3 -0
  221. package/blueprints/delivery-ci-workflows/contributions/probes/workflow-template-shape.mjs +94 -0
  222. package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-004.json +1 -1
  223. package/blueprints/delivery-ci-workflows/docs/topics.md +2 -3
  224. package/blueprints/deploy-cloudflare-workers/CHANGELOG.md +4 -4
  225. package/blueprints/deploy-cloudflare-workers/README.md +3 -3
  226. package/blueprints/deploy-cloudflare-workers/contributions/adrs/adr-1306-deploy-cloudflare-workers-spa-shape.json +2 -2
  227. package/blueprints/deploy-cloudflare-workers/docs/topics.md +2 -2
  228. package/blueprints/deploy-hetzner-server/CHANGELOG.md +98 -7
  229. package/blueprints/deploy-hetzner-server/blueprint.json +7 -6
  230. package/blueprints/deploy-hetzner-server/contributions/probes/cloud-init-render-lint.mjs +46 -21
  231. package/blueprints/deploy-hetzner-server/contributions/probes/hcloud-dry-run-mock.mjs +344 -83
  232. package/blueprints/deploy-hetzner-server/contributions/probes/manifest-schema-validate.mjs +211 -42
  233. package/blueprints/deploy-hetzner-server/contributions/probes/probe-utils.mjs +140 -22
  234. package/blueprints/deploy-hetzner-server/contributions/probes/real-account-cloud-init-hardened.mjs +111 -30
  235. package/blueprints/deploy-hetzner-server/contributions/probes/real-account-snapshot-on-demand.mjs +158 -30
  236. package/blueprints/deploy-hetzner-server/contributions/probes/real-account-throwaway-server-provision.mjs +154 -32
  237. package/blueprints/edge-cloudflare-access/CHANGELOG.md +6 -5
  238. package/blueprints/edge-cloudflare-access/blueprint.json +1 -1
  239. package/blueprints/edge-cloudflare-access/contributions/probes/admin-console-gate-surface.mjs +9 -1
  240. package/blueprints/edge-cloudflare-access/docs/topics.md +1 -1
  241. package/blueprints/edge-cloudflare-rate-limiting/CHANGELOG.md +4 -7
  242. package/blueprints/edge-cloudflare-rate-limiting/docs/topics.md +1 -1
  243. package/blueprints/edge-cloudflare-tunnel/CHANGELOG.md +4 -6
  244. package/blueprints/edge-cloudflare-tunnel/README.md +1 -2
  245. package/blueprints/edge-cloudflare-tunnel/docs/topics.md +1 -1
  246. package/blueprints/edge-cloudflare-turnstile/CHANGELOG.md +2 -3
  247. package/blueprints/edge-cloudflare-turnstile/docs/topics.md +1 -1
  248. package/blueprints/email-smtp-resend/CHANGELOG.md +24 -8
  249. package/blueprints/email-smtp-resend/README.md +2 -2
  250. package/blueprints/email-smtp-resend/blueprint.json +3 -2
  251. package/blueprints/email-smtp-resend/contributions/probes/probe-utils.mjs +50 -0
  252. package/blueprints/email-smtp-resend/contributions/probes/real-account-resend-send.mjs +50 -0
  253. package/blueprints/email-smtp-resend/contributions/probes/run-real-account-resend-send.mjs +3 -0
  254. package/blueprints/email-smtp-resend/contributions/probes/run-smtp-round-trip.mjs +3 -0
  255. package/blueprints/email-smtp-resend/contributions/probes/run-unverified-sender-refusal.mjs +3 -0
  256. package/blueprints/email-smtp-resend/contributions/probes/smtp-round-trip.mjs +43 -0
  257. package/blueprints/email-smtp-resend/contributions/probes/unverified-sender-refusal.mjs +99 -0
  258. package/blueprints/email-smtp-resend/docs/topics.md +2 -3
  259. package/blueprints/jobs-background/CHANGELOG.md +66 -5
  260. package/blueprints/jobs-background/README.md +2 -2
  261. package/blueprints/jobs-background/blueprint.json +3 -2
  262. package/blueprints/jobs-background/contributions/probes/apply-time-override.mjs +38 -10
  263. package/blueprints/jobs-background/contributions/probes/apply-time-refusal.mjs +28 -6
  264. package/blueprints/jobs-background/contributions/probes/event-secrecy.mjs +16 -2
  265. package/blueprints/jobs-background/contributions/probes/fake-clock-cron.mjs +102 -21
  266. package/blueprints/jobs-background/contributions/probes/probe-utils.mjs +10 -2
  267. package/blueprints/jobs-background/contributions/probes/retry-and-fail-real-account.mjs +355 -0
  268. package/blueprints/jobs-background/contributions/probes/retry-and-fail.mjs +33 -2
  269. package/blueprints/jobs-background/contributions/probes/run-retry-and-fail-real-account.mjs +12 -0
  270. package/blueprints/jobs-background/docs/topics.md +1 -1
  271. package/blueprints/jobs-background/guide/jobs-background.md +1 -1
  272. package/blueprints/messaging-queue-cloudflare/CHANGELOG.md +25 -19
  273. package/blueprints/messaging-queue-cloudflare/README.md +12 -12
  274. package/blueprints/messaging-queue-cloudflare/blueprint.json +1 -1
  275. package/blueprints/messaging-queue-cloudflare/contributions/adrs/adr-3001-messaging-queue-cloudflare-adapter.json +2 -2
  276. package/blueprints/messaging-queue-cloudflare/contributions/adrs/adr-3002-messaging-queue-cloudflare-delivery-semantics.json +1 -1
  277. package/blueprints/messaging-queue-cloudflare/contributions/probes/real-account-concurrency-smoke.mjs +180 -12
  278. package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-006.json +1 -1
  279. package/blueprints/messaging-queue-cloudflare/docs/topics.md +2 -2
  280. package/blueprints/messaging-queue-cloudflare/guide/messaging-queue-cloudflare.md +2 -2
  281. package/blueprints/object-storage-s3/CHANGELOG.md +75 -4
  282. package/blueprints/object-storage-s3/blueprint.json +3 -2
  283. package/blueprints/object-storage-s3/contributions/probes/event-secrecy.mjs +73 -12
  284. package/blueprints/object-storage-s3/contributions/probes/facade-round-trip.mjs +55 -7
  285. package/blueprints/object-storage-s3/contributions/probes/hetzner-object-storage-round-trip.mjs +102 -18
  286. package/blueprints/object-storage-s3/contributions/probes/multipart-upload.mjs +105 -36
  287. package/blueprints/object-storage-s3/contributions/probes/presigned-url.mjs +104 -55
  288. package/blueprints/object-storage-s3/contributions/probes/probe-utils.mjs +129 -7
  289. package/blueprints/object-storage-s3/contributions/probes/put-get-round-trip.mjs +80 -18
  290. package/blueprints/object-storage-s3/contributions/probes/r2-real-account-smoke.mjs +291 -37
  291. package/blueprints/object-storage-s3/contributions/probes/run-event-secrecy.mjs +8 -2
  292. package/blueprints/object-storage-s3/contributions/probes/run-facade-round-trip.mjs +5 -5
  293. package/blueprints/object-storage-s3/contributions/probes/run-hetzner-object-storage-round-trip.mjs +8 -5
  294. package/blueprints/object-storage-s3/contributions/probes/run-multipart-upload.mjs +8 -2
  295. package/blueprints/object-storage-s3/contributions/probes/run-presigned-url.mjs +8 -2
  296. package/blueprints/object-storage-s3/contributions/probes/run-put-get-round-trip.mjs +6 -3
  297. package/blueprints/object-storage-s3/contributions/probes/run-r2-real-account-smoke.mjs +10 -2
  298. package/blueprints/object-storage-s3/contributions/requirements/object-storage-s3-req-006.json +1 -1
  299. package/blueprints/object-storage-s3/docs/topics.md +1 -1
  300. package/blueprints/object-storage-s3/guide/object-storage-s3.md +1 -1
  301. package/blueprints/observability-essentials/CHANGELOG.md +23 -7
  302. package/blueprints/observability-essentials/README.md +2 -2
  303. package/blueprints/observability-essentials/assets/probe-samples/probe-response-baseline.v2.json +1 -1
  304. package/blueprints/observability-essentials/blueprint.json +3 -2
  305. package/blueprints/observability-essentials/contributions/probes/liveness-probe.mjs +84 -0
  306. package/blueprints/observability-essentials/contributions/probes/metrics-endpoint.mjs +96 -0
  307. package/blueprints/observability-essentials/contributions/probes/probe-utils.mjs +52 -0
  308. package/blueprints/observability-essentials/contributions/probes/readiness-probe.mjs +171 -0
  309. package/blueprints/observability-essentials/contributions/probes/run-liveness-probe.mjs +3 -0
  310. package/blueprints/observability-essentials/contributions/probes/run-metrics-endpoint.mjs +3 -0
  311. package/blueprints/observability-essentials/contributions/probes/run-readiness-probe.mjs +3 -0
  312. package/blueprints/observability-essentials/docs/topics.md +2 -3
  313. package/blueprints/observability-logging/CHANGELOG.md +29 -4
  314. package/blueprints/observability-logging/README.md +2 -2
  315. package/blueprints/observability-logging/blueprint.json +3 -2
  316. package/blueprints/observability-logging/contributions/probes/correlation-id-flow.mjs +173 -0
  317. package/blueprints/observability-logging/contributions/probes/line-shape-and-fields.mjs +82 -0
  318. package/blueprints/observability-logging/contributions/probes/probe-utils.mjs +47 -0
  319. package/blueprints/observability-logging/contributions/probes/redaction-boundary.mjs +130 -0
  320. package/blueprints/observability-logging/contributions/probes/run-correlation-id-flow.mjs +3 -0
  321. package/blueprints/observability-logging/contributions/probes/run-line-shape-and-fields.mjs +3 -0
  322. package/blueprints/observability-logging/contributions/probes/run-redaction-boundary.mjs +3 -0
  323. package/blueprints/observability-probe-endpoints/CHANGELOG.md +23 -8
  324. package/blueprints/observability-probe-endpoints/README.md +3 -3
  325. package/blueprints/observability-probe-endpoints/blueprint.json +3 -2
  326. package/blueprints/observability-probe-endpoints/contributions/probes/kubernetes-startup-enabled.mjs +80 -0
  327. package/blueprints/observability-probe-endpoints/contributions/probes/partial-profile-refusal.mjs +101 -0
  328. package/blueprints/observability-probe-endpoints/contributions/probes/probe-utils.mjs +49 -0
  329. package/blueprints/observability-probe-endpoints/contributions/probes/profile-boot-materialisation.mjs +69 -0
  330. package/blueprints/observability-probe-endpoints/contributions/probes/run-kubernetes-startup-enabled.mjs +3 -0
  331. package/blueprints/observability-probe-endpoints/contributions/probes/run-partial-profile-refusal.mjs +3 -0
  332. package/blueprints/observability-probe-endpoints/contributions/probes/run-profile-boot-materialisation.mjs +3 -0
  333. package/blueprints/observability-probe-endpoints/docs/topics.md +2 -2
  334. package/blueprints/persistence-data-d1/CHANGELOG.md +16 -3
  335. package/blueprints/persistence-data-d1/README.md +2 -2
  336. package/blueprints/persistence-data-d1/blueprint.json +3 -2
  337. package/blueprints/persistence-data-d1/contributions/adrs/adr-1404-persistence-data-d1-store-boundary.json +1 -1
  338. package/blueprints/persistence-data-d1/contributions/probes/facade-round-trip.mjs +140 -0
  339. package/blueprints/persistence-data-d1/contributions/probes/migrations-forward-only.mjs +66 -0
  340. package/blueprints/persistence-data-d1/contributions/probes/probe-utils.mjs +51 -0
  341. package/blueprints/persistence-data-d1/contributions/probes/real-account-d1-round-trip.mjs +133 -0
  342. package/blueprints/persistence-data-d1/contributions/probes/run-facade-round-trip.mjs +3 -0
  343. package/blueprints/persistence-data-d1/contributions/probes/run-migrations-forward-only.mjs +3 -0
  344. package/blueprints/persistence-data-d1/contributions/probes/run-real-account-d1-round-trip.mjs +3 -0
  345. package/blueprints/persistence-data-d1/docs/topics.md +2 -3
  346. package/blueprints/persistence-data-postgres/CHANGELOG.md +84 -4
  347. package/blueprints/persistence-data-postgres/README.md +2 -2
  348. package/blueprints/persistence-data-postgres/blueprint.json +3 -2
  349. package/blueprints/persistence-data-postgres/contributions/adrs/adr-2801-persistence-data-postgres-driver.json +1 -1
  350. package/blueprints/persistence-data-postgres/contributions/probes/facade-round-trip.mjs +87 -12
  351. package/blueprints/persistence-data-postgres/contributions/probes/migration-apply.mjs +245 -19
  352. package/blueprints/persistence-data-postgres/contributions/probes/pool-posture-smoke.mjs +109 -26
  353. package/blueprints/persistence-data-postgres/contributions/probes/prepared-statement-scan.mjs +151 -38
  354. package/blueprints/persistence-data-postgres/contributions/probes/probe-utils.mjs +24 -6
  355. package/blueprints/persistence-data-postgres/contributions/probes/recovery-restore-round-trip.mjs +261 -48
  356. package/blueprints/persistence-data-postgres/contributions/probes/transaction-atomicity.mjs +76 -20
  357. package/blueprints/persistence-data-postgres/contributions/requirements/persistence-data-postgres-req-003.json +1 -1
  358. package/blueprints/persistence-data-postgres/docs/topics.md +1 -1
  359. package/blueprints/persistence-data-sqlite/CHANGELOG.md +16 -2
  360. package/blueprints/persistence-data-sqlite/README.md +2 -2
  361. package/blueprints/persistence-data-sqlite/blueprint.json +3 -2
  362. package/blueprints/persistence-data-sqlite/contributions/probes/boot-open-migrate.mjs +90 -0
  363. package/blueprints/persistence-data-sqlite/contributions/probes/facade-round-trip.mjs +82 -0
  364. package/blueprints/persistence-data-sqlite/contributions/probes/probe-utils.mjs +83 -0
  365. package/blueprints/persistence-data-sqlite/contributions/probes/run-boot-open-migrate.mjs +5 -0
  366. package/blueprints/persistence-data-sqlite/contributions/probes/run-facade-round-trip.mjs +5 -0
  367. package/blueprints/persistence-data-sqlite/contributions/probes/run-wal-checkpoint.mjs +5 -0
  368. package/blueprints/persistence-data-sqlite/contributions/probes/wal-checkpoint.mjs +137 -0
  369. package/blueprints/persistence-data-sqlite/docs/topics.md +2 -3
  370. package/blueprints/platform-cloudflare-cron-triggers/CHANGELOG.md +5 -5
  371. package/blueprints/platform-cloudflare-cron-triggers/README.md +2 -3
  372. package/blueprints/platform-cloudflare-cron-triggers/docs/topics.md +1 -1
  373. package/blueprints/platform-cloudflare-durable-objects/CHANGELOG.md +6 -10
  374. package/blueprints/platform-cloudflare-durable-objects/docs/topics.md +1 -1
  375. package/blueprints/platform-cloudflare-kv/CHANGELOG.md +16 -12
  376. package/blueprints/platform-cloudflare-kv/README.md +2 -4
  377. package/blueprints/platform-cloudflare-kv/blueprint.json +2 -2
  378. package/blueprints/platform-cloudflare-kv/docs/topics.md +1 -1
  379. package/blueprints/platform-docker-compose-host/CHANGELOG.md +92 -5
  380. package/blueprints/platform-docker-compose-host/blueprint.json +2 -2
  381. package/blueprints/platform-docker-compose-host/contributions/probes/caddyfile-validate.mjs +116 -29
  382. package/blueprints/platform-docker-compose-host/contributions/probes/compose-config-lint.mjs +181 -109
  383. package/blueprints/platform-docker-compose-host/contributions/probes/probe-utils.mjs +146 -26
  384. package/blueprints/platform-docker-compose-host/contributions/probes/real-account-minimal-stack-up.mjs +238 -24
  385. package/blueprints/platform-docker-compose-host/contributions/probes/real-account-reload-burst.mjs +207 -21
  386. package/blueprints/platform-docker-compose-host/contributions/probes/secrets-as-files-scan.mjs +246 -42
  387. package/blueprints/platform-docker-compose-host/docs/topics.md +1 -1
  388. package/blueprints/security-auth-clerk/CHANGELOG.md +18 -6
  389. package/blueprints/security-auth-clerk/README.md +3 -3
  390. package/blueprints/security-auth-clerk/assets/wiring/workers-wrangler-toml-shape.md +1 -1
  391. package/blueprints/security-auth-clerk/blueprint.json +3 -2
  392. package/blueprints/security-auth-clerk/contributions/probes/hosted-identity-ui-config.mjs +61 -0
  393. package/blueprints/security-auth-clerk/contributions/probes/probe-utils.mjs +188 -0
  394. package/blueprints/security-auth-clerk/contributions/probes/real-account-principal-directory-round-trip.mjs +216 -0
  395. package/blueprints/security-auth-clerk/contributions/probes/real-account-session-inventory.mjs +260 -0
  396. package/blueprints/security-auth-clerk/contributions/probes/role-model-adapter.mjs +53 -0
  397. package/blueprints/security-auth-clerk/contributions/probes/run-hosted-identity-ui-config.mjs +4 -0
  398. package/blueprints/security-auth-clerk/contributions/probes/run-real-account-principal-directory-round-trip.mjs +4 -0
  399. package/blueprints/security-auth-clerk/contributions/probes/run-real-account-session-inventory.mjs +4 -0
  400. package/blueprints/security-auth-clerk/contributions/probes/run-role-model-adapter.mjs +4 -0
  401. package/blueprints/security-auth-clerk/docs/topics.md +2 -3
  402. package/blueprints/security-auth-keycloak/CHANGELOG.md +18 -7
  403. package/blueprints/security-auth-keycloak/README.md +2 -2
  404. package/blueprints/security-auth-keycloak/blueprint.json +3 -2
  405. package/blueprints/security-auth-keycloak/contributions/probes/discovery-shape.mjs +68 -0
  406. package/blueprints/security-auth-keycloak/contributions/probes/introspection-shape.mjs +88 -0
  407. package/blueprints/security-auth-keycloak/contributions/probes/jwt-verifier-shape.mjs +86 -0
  408. package/blueprints/security-auth-keycloak/contributions/probes/probe-utils.mjs +180 -0
  409. package/blueprints/security-auth-keycloak/contributions/probes/real-account-realm-round-trip.mjs +91 -0
  410. package/blueprints/security-auth-keycloak/contributions/probes/role-adapter-shape.mjs +69 -0
  411. package/blueprints/security-auth-keycloak/contributions/probes/run-discovery-shape.mjs +4 -0
  412. package/blueprints/security-auth-keycloak/contributions/probes/run-introspection-shape.mjs +4 -0
  413. package/blueprints/security-auth-keycloak/contributions/probes/run-jwt-verifier-shape.mjs +4 -0
  414. package/blueprints/security-auth-keycloak/contributions/probes/run-real-account-realm-round-trip.mjs +4 -0
  415. package/blueprints/security-auth-keycloak/contributions/probes/run-role-adapter-shape.mjs +4 -0
  416. package/blueprints/security-auth-keycloak/docs/topics.md +1 -2
  417. package/blueprints/security-auth-magic-link/CHANGELOG.md +15 -4
  418. package/blueprints/security-auth-magic-link/README.md +2 -2
  419. package/blueprints/security-auth-magic-link/assets/principal-registry-samples/single-address.md +1 -1
  420. package/blueprints/security-auth-magic-link/blueprint.json +3 -2
  421. package/blueprints/security-auth-magic-link/contributions/probes/probe-utils.mjs +174 -0
  422. package/blueprints/security-auth-magic-link/contributions/probes/real-account-magic-link-send.mjs +139 -0
  423. package/blueprints/security-auth-magic-link/contributions/probes/run-real-account-magic-link-send.mjs +4 -0
  424. package/blueprints/security-auth-magic-link/contributions/probes/run-token-entropy-shape.mjs +4 -0
  425. package/blueprints/security-auth-magic-link/contributions/probes/run-token-issue-verify.mjs +4 -0
  426. package/blueprints/security-auth-magic-link/contributions/probes/token-entropy-shape.mjs +60 -0
  427. package/blueprints/security-auth-magic-link/contributions/probes/token-issue-verify.mjs +90 -0
  428. package/blueprints/security-auth-magic-link/docs/topics.md +1 -1
  429. package/blueprints/security-auth-oauth2/CHANGELOG.md +16 -5
  430. package/blueprints/security-auth-oauth2/README.md +2 -2
  431. package/blueprints/security-auth-oauth2/blueprint.json +3 -2
  432. package/blueprints/security-auth-oauth2/contributions/probes/authorisation-code-flow-shape.mjs +259 -0
  433. package/blueprints/security-auth-oauth2/contributions/probes/pkce-challenge-shape.mjs +58 -0
  434. package/blueprints/security-auth-oauth2/contributions/probes/probe-utils.mjs +190 -0
  435. package/blueprints/security-auth-oauth2/contributions/probes/provider-adapter-shape.mjs +64 -0
  436. package/blueprints/security-auth-oauth2/contributions/probes/real-account-authorisation-code-flow.mjs +93 -0
  437. package/blueprints/security-auth-oauth2/contributions/probes/run-authorisation-code-flow-shape.mjs +4 -0
  438. package/blueprints/security-auth-oauth2/contributions/probes/run-pkce-challenge-shape.mjs +4 -0
  439. package/blueprints/security-auth-oauth2/contributions/probes/run-provider-adapter-shape.mjs +4 -0
  440. package/blueprints/security-auth-oauth2/contributions/probes/run-real-account-authorisation-code-flow.mjs +4 -0
  441. package/blueprints/security-auth-oauth2/contributions/probes/run-session-bridge-shape.mjs +4 -0
  442. package/blueprints/security-auth-oauth2/contributions/probes/session-bridge-shape.mjs +78 -0
  443. package/blueprints/security-auth-oauth2/docs/topics.md +1 -2
  444. package/blueprints/security-secrets-management/CHANGELOG.md +15 -4
  445. package/blueprints/security-secrets-management/README.md +2 -2
  446. package/blueprints/security-secrets-management/blueprint.json +3 -2
  447. package/blueprints/security-secrets-management/contributions/probes/add-recipient-rotation.mjs +107 -0
  448. package/blueprints/security-secrets-management/contributions/probes/encrypt-decrypt-round-trip.mjs +122 -0
  449. package/blueprints/security-secrets-management/contributions/probes/key-rotation.mjs +93 -0
  450. package/blueprints/security-secrets-management/contributions/probes/mismatched-key-refusal.mjs +74 -0
  451. package/blueprints/security-secrets-management/contributions/probes/probe-utils.mjs +214 -0
  452. package/blueprints/security-secrets-management/contributions/probes/run-add-recipient-rotation.mjs +4 -0
  453. package/blueprints/security-secrets-management/contributions/probes/run-encrypt-decrypt-round-trip.mjs +4 -0
  454. package/blueprints/security-secrets-management/contributions/probes/run-key-rotation.mjs +4 -0
  455. package/blueprints/security-secrets-management/contributions/probes/run-mismatched-key-refusal.mjs +4 -0
  456. package/blueprints/security-secrets-management/docs/topics.md +2 -3
  457. package/fixtures/canary-manifest.json +9 -9
  458. package/guidance/harness-template.md +25 -0
  459. package/guidance/managed/agent-instructions-block.hash +1 -1
  460. package/guidance/managed/agent-instructions-block.md +25 -0
  461. package/package.json +2 -2
  462. package/rcf/adrs/adr-4101-feedback-store-gitignored.json +20 -0
  463. package/rcf/adrs/adr-4102-fingerprint-and-labels.json +25 -0
  464. package/rcf/adrs/adr-4103-redaction-rule-based.json +20 -0
  465. package/rcf/adrs/adr-4104-destination-on-library.json +25 -0
  466. package/rcf/adrs/adr-4105-gh-ambient-auth.json +20 -0
  467. package/rcf/adrs/adr-4106-bundled-entries-terminal.json +20 -0
  468. package/rcf/adrs/adr-4107-stop-is-ask-event.json +30 -0
  469. package/rcf/adrs/adr-4108-hooks-in-committed-config.json +25 -0
  470. package/rcf/adrs/adr-4109-referee-validate-adr-crosslinks.json +25 -0
  471. package/rcf/adrs/adr-4110-referee-coverage-strict-by-default.json +25 -0
  472. package/rcf/adrs/adr-4111-referee-finalise-refusals.json +20 -0
  473. package/rcf/adrs/adr-4112-referee-finalise-verified-invariant.json +30 -0
  474. package/rcf/code-nodes/cn-600.json +46 -0
  475. package/rcf/code-nodes/cn-601.json +18 -0
  476. package/rcf/code-nodes/cn-602.json +16 -0
  477. package/rcf/code-nodes/cn-603.json +12 -0
  478. package/rcf/code-nodes/cn-604.json +14 -0
  479. package/rcf/code-nodes/cn-605.json +18 -0
  480. package/rcf/code-nodes/cn-606.json +12 -0
  481. package/rcf/code-nodes/cn-607.json +12 -0
  482. package/rcf/code-nodes/cn-608.json +14 -0
  483. package/rcf/code-nodes/cn-609.json +17 -0
  484. package/rcf/code-nodes/cn-620.json +18 -0
  485. package/rcf/code-nodes/cn-621.json +14 -0
  486. package/rcf/code-nodes/cn-622.json +15 -0
  487. package/rcf/code-nodes/cn-623.json +16 -0
  488. package/rcf/code-nodes/cn-624.json +14 -0
  489. package/rcf/code-nodes/cn-625.json +15 -0
  490. package/rcf/code-nodes/cn-626.json +13 -0
  491. package/rcf/code-nodes/cn-627.json +13 -0
  492. package/rcf/code-nodes/cn-628.json +14 -0
  493. package/rcf/code-nodes/cn-629.json +12 -0
  494. package/rcf/code-nodes/cn-630.json +12 -0
  495. package/rcf/code-nodes/cn-631.json +13 -0
  496. package/rcf/code-nodes/cn-632.json +13 -0
  497. package/rcf/fbs/fbs-180.json +45 -0
  498. package/rcf/fbs/fbs-181.json +46 -0
  499. package/rcf/fbs/fbs-182.json +43 -0
  500. package/rcf/fbs/fbs-183.json +50 -0
  501. package/rcf/fbs/fbs-184.json +49 -0
  502. package/rcf/fbs/fbs-185.json +34 -0
  503. package/rcf/fbs/fbs-186.json +16 -0
  504. package/rcf/fbs/fbs-187.json +17 -0
  505. package/rcf/fbs/fbs-188.json +17 -0
  506. package/rcf/fbs/fbs-189.json +17 -0
  507. package/rcf/requirements/req-155.json +13 -0
  508. package/rcf/requirements/req-156.json +13 -0
  509. package/rcf/requirements/req-157.json +13 -0
  510. package/rcf/requirements/req-158.json +13 -0
  511. package/rcf/requirements/req-159.json +13 -0
  512. package/rcf/requirements/req-160.json +13 -0
  513. package/rcf/requirements/req-161.json +13 -0
  514. package/rcf/requirements/req-162.json +13 -0
  515. package/rcf/requirements/req-163.json +13 -0
  516. package/rcf/requirements/req-164.json +13 -0
  517. package/rcf/requirements/req-165.json +13 -0
  518. package/rcf/tacs/tac-4101-feedback-cli.json +77 -0
  519. package/rcf/tacs/tac-4102-feedback-store.json +50 -0
  520. package/rcf/tacs/tac-4103-feedback-redact.json +40 -0
  521. package/rcf/tacs/tac-4104-feedback-fingerprint.json +33 -0
  522. package/rcf/tacs/tac-4105-feedback-render.json +45 -0
  523. package/rcf/tacs/tac-4106-feedback-destination.json +40 -0
  524. package/rcf/tacs/tac-4107-feedback-gh.json +43 -0
  525. package/rcf/tacs/tac-4108-feedback-hook.json +46 -0
  526. package/rcf/tacs/tac-4109-referee-validate-adr-crosslinks.json +20 -0
  527. package/rcf/tacs/tac-4110-referee-coverage-mode-selector.json +21 -0
  528. package/rcf/tacs/tac-4111-referee-finalise-per-ac-refusals.json +21 -0
  529. package/rcf/tacs/tac-4112-referee-finalise-verified-invariant.json +22 -0
  530. package/rcf/test-suites/ts-140.json +9 -9
  531. package/rcf/test-suites/ts-150.json +9 -9
  532. package/rcf/test-suites/ts-185.json +54 -0
  533. package/rcf/test-suites/ts-186.json +58 -0
  534. package/rcf/test-suites/ts-187.json +44 -0
  535. package/rcf/test-suites/ts-188.json +174 -0
  536. package/rcf/test-suites/ts-189.json +60 -0
  537. package/rcf/test-suites/ts-190.json +30 -0
  538. package/rcf/test-suites/ts-191.json +51 -0
  539. package/rcf/test-suites/ts-192.json +37 -0
  540. package/rcf/test-suites/ts-193.json +60 -0
  541. package/rcf/test-suites/ts-194.json +67 -0
  542. package/rcf/test-suites/ts-195.json +52 -0
  543. package/rcf/test-suites/ts-196.json +58 -0
  544. package/rcf/test-suites/ts-197.json +66 -0
  545. package/rcf/test-suites/ts-198.json +87 -0
  546. package/rcf/test-suites/ts-199.json +51 -0
  547. package/rcf/test-suites/ts-200.json +30 -0
  548. package/rcf/test-suites/ts-201.json +38 -0
  549. package/rcf/test-suites/ts-202.json +38 -0
  550. package/rcf/test-suites/ts-203.json +38 -0
  551. package/rcf/user-stories/us-15501.json +60 -0
  552. package/rcf/user-stories/us-15502.json +33 -0
  553. package/rcf/user-stories/us-15503.json +33 -0
  554. package/rcf/user-stories/us-15601.json +60 -0
  555. package/rcf/user-stories/us-15701.json +42 -0
  556. package/rcf/user-stories/us-15801.json +51 -0
  557. package/rcf/user-stories/us-15802.json +33 -0
  558. package/rcf/user-stories/us-15901.json +33 -0
  559. package/rcf/user-stories/us-15902.json +51 -0
  560. package/rcf/user-stories/us-16001.json +51 -0
  561. package/rcf/user-stories/us-16101.json +42 -0
  562. package/rcf/user-stories/us-16102.json +33 -0
  563. package/rcf/user-stories/us-16103.json +42 -0
  564. package/rcf/user-stories/us-16104.json +42 -0
  565. package/rcf/user-stories/us-16201.json +33 -0
  566. package/rcf/user-stories/us-16301.json +36 -0
  567. package/rcf/user-stories/us-16401.json +45 -0
  568. package/rcf/user-stories/us-16501.json +45 -0
  569. package/rcf/user-stories/us-16502.json +45 -0
  570. package/releases/releases.yaml +21 -1
  571. package/src/blueprint/library-loader.js +48 -0
  572. package/src/blueprint/library-registry.js +13 -0
  573. package/src/browser-verify/pack-runner.js +7 -12
  574. package/src/build/mark.js +1 -1
  575. package/src/cli/blueprint-library.js +149 -0
  576. package/src/cli/coverage.js +49 -24
  577. package/src/cli/doctor.js +195 -7
  578. package/src/cli/feedback.js +2213 -0
  579. package/src/cli/finalise.js +158 -1
  580. package/src/cli/help.js +6 -0
  581. package/src/cli/init.js +63 -0
  582. package/src/cli/update.js +21 -1
  583. package/src/core/store/walker.js +33 -0
  584. package/src/core/store/writer.js +23 -0
  585. package/src/feedback/destination.js +455 -0
  586. package/src/feedback/fingerprint.js +132 -0
  587. package/src/feedback/gh.js +376 -0
  588. package/src/feedback/hook.js +183 -0
  589. package/src/feedback/labels.js +87 -0
  590. package/src/feedback/redact-allowlist.json +14 -0
  591. package/src/feedback/redact.js +1148 -0
  592. package/src/feedback/render.js +267 -0
  593. package/src/feedback/store.js +604 -0
  594. package/src/finalise/index.js +4 -0
  595. package/src/finalise/ingest.js +61 -0
  596. package/src/finalise/ship-without-eval.js +19 -7
  597. package/src/mcp/tools.js +8 -2
  598. package/src/setup/feedback-hooks.js +397 -0
  599. package/src/setup/managed-gitignore.js +3 -0
  600. package/src/view/html-page.js +5 -5
  601. package/src/view/style.css +2 -2
@@ -0,0 +1,2213 @@
1
+ // `rcf feedback` core verb (TAC-4101). Slice 2 (FBS-181) adds the
2
+ // `preview` sub-verb on top of slice 1's capture and store:
3
+ // add record one finding (silent, local; no network)
4
+ // list print pending entries (--all includes every state; --json)
5
+ // status counts + gh availability (gh probing lands in slice 4;
6
+ // slice 2 still reports the local-only summary)
7
+ // defer mark all pending entries deferredUntilSession
8
+ // discard mark named (or --all) entries discarded
9
+ // preview render the exact issue title, body and redaction ledger
10
+ // for every pending entry; read-only, no network
11
+ //
12
+ // Sub-verbs from later slices are registered here as "not yet available"
13
+ // stubs so `rcf feedback submit|opt-in|opt-out|hook` return the
14
+ // documented usage-plus-outcome exit code (3) with a one-line note
15
+ // naming the slice that ships them. This keeps the RULE 17 block
16
+ // referable at slice-2 time without inventing verb behaviour ahead of
17
+ // the ACs. Sub-verb argv parsing lives in each handler so each verb's
18
+ // flag surface is local.
19
+ //
20
+ // The verb is CORE (not grouped), because it spans discover-to-audit
21
+ // scope and is invoked from harness hooks (design §3.1). See
22
+ // `bin/rcf.js:CORE`.
23
+
24
+ import { readFile, realpath } from 'node:fs/promises';
25
+ import { dirname, resolve } from 'node:path';
26
+ import { platform as osPlatform } from 'node:os';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { parseArgs } from 'node:util';
29
+ import { execFile } from 'node:child_process';
30
+ import { promisify } from 'node:util';
31
+ import process from 'node:process';
32
+
33
+ const execFileP = promisify(execFile);
34
+
35
+ import { findProjectRoot } from '../view/index.js';
36
+ import {
37
+ appendEntry,
38
+ appendState,
39
+ ensureGitignore,
40
+ mintUniqueEntryId,
41
+ newEntryId,
42
+ readAskLedger,
43
+ readDiscardTimestamps,
44
+ readEntries,
45
+ readFeedbackSettings,
46
+ storeExists,
47
+ writeAskLedger,
48
+ writeFeedbackSettings,
49
+ } from '../feedback/store.js';
50
+ import {
51
+ buildAskReason,
52
+ buildCarryOverLine,
53
+ emit as emitHook,
54
+ emitSessionStart,
55
+ shouldAsk,
56
+ } from '../feedback/hook.js';
57
+ import { redact, allowedHosts, findResidualSecrets } from '../feedback/redact.js';
58
+ import { fingerprint } from '../feedback/fingerprint.js';
59
+ import { renderIssue, renderComment, renderBundle } from '../feedback/render.js';
60
+ import { resolve as resolveDestination, listUnresolvedLibraries, findBlueprintRecord } from '../feedback/destination.js';
61
+ import { readLibraryRegistry } from '../blueprint/library-registry.js';
62
+ import { loadGhAdapter } from '../feedback/gh.js';
63
+ import { labelsForEntry } from '../feedback/labels.js';
64
+ import { outboxDir } from '../feedback/store.js';
65
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
66
+
67
+ const here = dirname(fileURLToPath(import.meta.url));
68
+ const PACKAGE_ROOT = resolve(here, '..', '..');
69
+
70
+ /** Closed enum from design §6. Any other value on --class exits 2. */
71
+ export const SYMPTOM_CLASSES = Object.freeze([
72
+ 'docs-mismatch',
73
+ 'validate-fails',
74
+ 'apply-fails',
75
+ 'internal-contradiction',
76
+ 'stale-citation',
77
+ 'missing-scenario',
78
+ 'verb-error',
79
+ 'verb-hang',
80
+ 'wrong-output',
81
+ 'other',
82
+ ]);
83
+
84
+ /** Severity enum (design §3.1). */
85
+ export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor']);
86
+
87
+ /** Kind enum. */
88
+ export const KINDS = Object.freeze(['blueprint', 'core']);
89
+
90
+ /** Body size cap (bytes, design §5 rule 7). */
91
+ export const BODY_CAP_BYTES = 8 * 1024;
92
+
93
+ /** Title char cap (design §3.1). */
94
+ export const TITLE_CAP_CHARS = 120;
95
+
96
+ export const HELP = `Usage: rcf feedback <sub-verb> [options]
97
+
98
+ Sub-verbs (available):
99
+ add Record one finding to the local log (no network, no ask).
100
+ list Print pending entries (--all includes every state, --json).
101
+ status Counts, opt-out state, gh availability summary.
102
+ defer Mark all pending entries deferred for the current session.
103
+ discard Mark named entries discarded (or --all).
104
+ preview Render the exact issue text and redaction ledger for each
105
+ pending entry; read-only, no network.
106
+ submit File each pending entry under the reporter's ambient gh
107
+ login; --yes is required. Runs preflight (gh, auth, repo
108
+ access, issues-enabled), a fingerprint dedupe search, then
109
+ either creates the issue, comments on a match, or writes
110
+ a bundle to .rcf/feedback/outbox/ and prints the paste
111
+ URL. See docs/feedback.md for the full walk.
112
+ opt-out Silence the per-session ask for this project by writing
113
+ rcf/feedback-settings.json:ask false. add / preview /
114
+ submit keep working (hand-driven flow).
115
+ opt-in Restore the ask (writes rcf/feedback-settings.json:ask true).
116
+ hook Harness hook handler (Stop / SessionEnd / SessionStart).
117
+ Reads harness JSON from stdin; applies the quiet rule
118
+ (design 3.5); on Claude Code, Stop exits 2 with the ask
119
+ on stderr; on Codex, stdout carries the block JSON.
120
+ Sub-shape: rcf feedback hook <stop|session-end|session-start>
121
+ [--harness claude-code|codex].
122
+
123
+ Common options:
124
+ --help Print this help.
125
+ --json Machine-readable output (list, status).
126
+
127
+ 'rcf feedback add' options (all required unless noted):
128
+ --kind blueprint|core What the finding is about.
129
+ --target <ref> Blueprint slug, prefix:slug or verb path.
130
+ --anchor <id> AC / REQ / TAC / ADR id, or verb name.
131
+ Optional but strongly recommended for
132
+ dedupe folding.
133
+ --class <symptom-class> One of ${SYMPTOM_CLASSES.join(', ')}.
134
+ --severity blocker|major|minor
135
+ --title <text> <= ${TITLE_CAP_CHARS} chars.
136
+ --body <text> | --body-file <path>
137
+ Body markdown, <= ${BODY_CAP_BYTES / 1024} KB
138
+ after UTF-8 encoding.
139
+ --evidence <pointer> Required, repeatable: a command, a
140
+ path:line inside the project, or an id.
141
+ At least one --evidence is required
142
+ (AC-15501-1); absence exits 2.
143
+ --harness claude-code|codex|other
144
+ Optional; inferred from env when absent.
145
+ --ask-now Blocker shortcut; flags the batch so the
146
+ next Stop hook asks regardless of the
147
+ 15-minute quiet rule (slice 5).
148
+ --force Bypass the .gitignore pre-write refusal.
149
+
150
+ Exit codes:
151
+ 0 success
152
+ 2 usage error (bad flags, unknown enum, oversize body, no ignore
153
+ coverage without --force)
154
+ 3 add: --kind blueprint AND the target is absent from
155
+ rcf/manifest.json:blueprints[] (unknown target). A known target
156
+ whose registered library has no resolvable feedback destination
157
+ is exit 0 with the entry stamped destination:unresolved; submit
158
+ writes a bundle at that entry's slot.
159
+ submit / preview: at least one entry stayed pending because of a
160
+ residual-secret refusal after redaction.
161
+ `;
162
+
163
+ /**
164
+ * Core-verb entry point. Signature matches the other CORE handlers
165
+ * dispatched from `bin/rcf.js:CORE`.
166
+ *
167
+ * @param {string[]} argv - argv slice after `feedback`
168
+ * @param {object} [deps]
169
+ * @param {NodeJS.WritableStream} [deps.stdout]
170
+ * @param {NodeJS.WritableStream} [deps.stderr]
171
+ * @param {string} [deps.cwd]
172
+ * @param {NodeJS.ProcessEnv} [deps.env]
173
+ * @param {() => Date} [deps.now]
174
+ * @param {() => number} [deps.rng]
175
+ * @param {string} [deps.sessionId]
176
+ * @returns {Promise<number>}
177
+ */
178
+ export async function main(argv, deps = {}) {
179
+ const stdout = deps.stdout ?? process.stdout;
180
+ const stderr = deps.stderr ?? process.stderr;
181
+ const cwd = deps.cwd ?? process.cwd();
182
+ const env = deps.env ?? process.env;
183
+ const now = deps.now ?? (() => new Date());
184
+ const rng = deps.rng ?? Math.random;
185
+ const sessionId = deps.sessionId ?? env.RCF_FEEDBACK_SESSION_ID ?? 'unknown';
186
+
187
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
188
+ stdout.write(HELP);
189
+ return argv.length === 0 ? 2 : 0;
190
+ }
191
+
192
+ const sub = argv[0];
193
+ const rest = argv.slice(1);
194
+ const ctx = { stdout, stderr, cwd, env, now, rng, sessionId };
195
+ switch (sub) {
196
+ case 'add': return handleAdd(rest, ctx);
197
+ case 'list': return handleList(rest, ctx);
198
+ case 'status': return handleStatus(rest, ctx);
199
+ case 'defer': return handleDefer(rest, ctx);
200
+ case 'discard': return handleDiscard(rest, ctx);
201
+ case 'preview': return handlePreview(rest, ctx);
202
+ case 'submit': return handleSubmit(rest, ctx);
203
+ case 'opt-in': return handleOptToggle('opt-in', rest, ctx);
204
+ case 'opt-out': return handleOptToggle('opt-out', rest, ctx);
205
+ case 'hook': return handleHook(rest, ctx);
206
+ default:
207
+ stderr.write(`[error] usage unknown feedback sub-verb '${sub}'\n`);
208
+ stdout.write(HELP);
209
+ return 2;
210
+ }
211
+ }
212
+
213
+ // -- add -------------------------------------------------------------------
214
+
215
+ const ADD_OPTIONS = /** @type {const} */ ({
216
+ kind: { type: 'string' },
217
+ target: { type: 'string' },
218
+ anchor: { type: 'string' },
219
+ class: { type: 'string' },
220
+ severity: { type: 'string' },
221
+ title: { type: 'string' },
222
+ body: { type: 'string' },
223
+ 'body-file': { type: 'string' },
224
+ evidence: { type: 'string', multiple: true },
225
+ harness: { type: 'string' },
226
+ 'ask-now': { type: 'boolean' },
227
+ force: { type: 'boolean' },
228
+ help: { type: 'boolean' },
229
+ });
230
+
231
+ async function handleAdd(argv, ctx) {
232
+ const { stdout, stderr, cwd, env } = ctx;
233
+ // F-slice-1-02: RCF_FEEDBACK_DISABLE=1 makes `add` a no-op that
234
+ // prints the design-8 line and exits 0 without touching the tree.
235
+ // Locked-down environments where even a local log is unwanted rely
236
+ // on this; the previous build honoured only the opt-out semantics
237
+ // in `status`, so `add` would still persist raw entries.
238
+ if (env.RCF_FEEDBACK_DISABLE === '1') {
239
+ stdout.write('feedback disabled by env\n');
240
+ return 0;
241
+ }
242
+ let parsed;
243
+ try {
244
+ parsed = parseArgs({ args: argv, options: ADD_OPTIONS, allowPositionals: false, strict: true });
245
+ } catch (err) {
246
+ stderr.write(`[error] usage ${err.message}\n`);
247
+ return 2;
248
+ }
249
+ const flags = parsed.values;
250
+ if (flags.help) { stdout.write(HELP); return 0; }
251
+
252
+ // Enum + presence checks BEFORE any project-root probing so a usage
253
+ // error never touches the tree.
254
+ const kind = flags.kind;
255
+ if (!kind || !KINDS.includes(kind)) {
256
+ stderr.write(`[error] usage --kind must be one of ${KINDS.join(', ')}\n`);
257
+ return 2;
258
+ }
259
+ const symptomClass = flags.class;
260
+ if (!symptomClass || !SYMPTOM_CLASSES.includes(symptomClass)) {
261
+ stderr.write(`[error] usage --class must be one of ${SYMPTOM_CLASSES.join(', ')}\n`);
262
+ return 2;
263
+ }
264
+ const severity = flags.severity;
265
+ if (!severity || !SEVERITIES.includes(severity)) {
266
+ stderr.write(`[error] usage --severity must be one of ${SEVERITIES.join(', ')}\n`);
267
+ return 2;
268
+ }
269
+ const target = flags.target;
270
+ if (!target) { stderr.write('[error] usage --target is required\n'); return 2; }
271
+ const title = flags.title;
272
+ if (!title) { stderr.write('[error] usage --title is required\n'); return 2; }
273
+ if (title.length > TITLE_CAP_CHARS) {
274
+ stderr.write(`[error] usage --title exceeds ${TITLE_CAP_CHARS} character cap (got ${title.length})\n`);
275
+ return 2;
276
+ }
277
+ const evidence = flags.evidence ?? [];
278
+ if (evidence.length === 0) {
279
+ stderr.write('[error] usage --evidence is required (repeatable)\n');
280
+ return 2;
281
+ }
282
+ const harness = flags.harness ?? inferHarness(ctx.env);
283
+ if (flags.harness && !['claude-code', 'codex', 'other'].includes(flags.harness)) {
284
+ stderr.write('[error] usage --harness must be one of claude-code, codex, other\n');
285
+ return 2;
286
+ }
287
+
288
+ // Body: either --body or --body-file, exactly one, capped at 8 KB.
289
+ if (flags.body != null && flags['body-file'] != null) {
290
+ stderr.write('[error] usage pass either --body or --body-file, not both\n');
291
+ return 2;
292
+ }
293
+ let body;
294
+ if (flags.body != null) {
295
+ body = flags.body;
296
+ } else if (flags['body-file'] != null) {
297
+ try {
298
+ body = await readFile(resolve(cwd, flags['body-file']), 'utf8');
299
+ } catch (err) {
300
+ stderr.write(`[error] usage --body-file: ${err.message}\n`);
301
+ return 2;
302
+ }
303
+ } else {
304
+ stderr.write('[error] usage one of --body or --body-file is required\n');
305
+ return 2;
306
+ }
307
+ const bodyBytes = Buffer.byteLength(body, 'utf8');
308
+ if (bodyBytes > BODY_CAP_BYTES) {
309
+ stderr.write(`[error] usage --body exceeds ${BODY_CAP_BYTES} byte cap (got ${bodyBytes})\n`);
310
+ return 2;
311
+ }
312
+
313
+ // Find the project root; without a manifest we cannot honour the
314
+ // gitignore refusal or the destination resolution.
315
+ const projectRoot = await findProjectRoot(cwd);
316
+ if (!projectRoot) {
317
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor). Run `npx rcf init` to scaffold and wire a project.\n');
318
+ return 2;
319
+ }
320
+
321
+ // Gitignore pre-write refusal. --force bypasses.
322
+ if (!flags.force) {
323
+ const check = await ensureGitignore(projectRoot);
324
+ if (!check.ok) {
325
+ stderr.write(`[error] usage ${check.reason}. Run \`rcf doctor --fix\` to add the managed .gitignore entry (or pass --force to write anyway).\n`);
326
+ return 2;
327
+ }
328
+ }
329
+
330
+ // Destination resolution (design 3.3, slice 3 landed the resolver).
331
+ // Runs at add-time so the entry carries its declared destination
332
+ // through preview and submit; preview re-resolves defensively so a
333
+ // library that later declared an issues field lifts a previously
334
+ // stamped `unresolved` entry to a real destination.
335
+ const resolverInputs = await loadResolverInputs(projectRoot);
336
+ const record = kind === 'blueprint'
337
+ ? await lookupBlueprintRecord(projectRoot, target)
338
+ : null;
339
+ // Build a resolver-shaped entry for the pre-write resolve call: the
340
+ // real feedback entry does not exist yet, but the resolver only reads
341
+ // { kind, target.ref } and the manifest for lookup.
342
+ const preResolveEntry = { kind, target: { ref: target } };
343
+ const resolvedDestination = await resolveDestination(preResolveEntry, resolverInputs);
344
+ // Design 3.1 exit-3 branch: the blueprint TARGET could not be found
345
+ // on the manifest at all (unknown-target). A library whose registry
346
+ // entry lacks an issuesRepo (or whose sourceRef is not a github URL)
347
+ // stamps the entry as unresolved but stays exit 0 (design 7 case 5:
348
+ // slice 4 writes the bundle at submit time).
349
+ const destinationUnresolved = resolvedDestination.visibility === 'unresolved';
350
+ const targetUnknown = destinationUnresolved && resolvedDestination.reason === 'unknown-target';
351
+
352
+ // Environment stamp (design §3.6, F-2 extended).
353
+ const environment = {
354
+ harness,
355
+ rcfLiteVersion: await readOwnVersion(),
356
+ nodeVersion: process.versions.node,
357
+ platform: osPlatform(),
358
+ };
359
+
360
+ // Blueprint-specific stamps (AC-15501-5, F-slice-1-07: also merge
361
+ // registry-sourced libraryRef and pin.tarballSha256 which live on
362
+ // rcf/blueprint-libraries.json, not on the manifest record).
363
+ let blueprintStamp = {};
364
+ if (kind === 'blueprint' && record) {
365
+ const registryEntry = record.libraryPrefix && resolverInputs.registry
366
+ ? (Array.isArray(resolverInputs.registry.libraries)
367
+ ? resolverInputs.registry.libraries.find((l) => l?.libraryPrefix === record.libraryPrefix)
368
+ : null)
369
+ : null;
370
+ const libraryRef = record.libraryRef ?? registryEntry?.libraryRef ?? null;
371
+ const resolvedSha = record.pin?.resolvedSha ?? registryEntry?.resolvedSha ?? null;
372
+ const tarballSha256 = record.pin?.tarballSha256 ?? registryEntry?.provenance?.tarballSha256 ?? null;
373
+ blueprintStamp = {
374
+ blueprintVersion: record.version ?? null,
375
+ libraryPrefix: record.libraryPrefix ?? null,
376
+ libraryRef,
377
+ ...(resolvedSha ? { resolvedSha } : {}),
378
+ ...(tarballSha256 ? { tarballSha256 } : {}),
379
+ };
380
+ }
381
+
382
+ const recordedAt = ctx.now().toISOString().replace(/\.\d{3}Z$/, 'Z');
383
+ // F-slice-1-03 / ruling R2: 12-hex id with a uniqueness probe on
384
+ // the append. mintUniqueEntryId THROWS FEEDBACK_ID_EXHAUSTED if
385
+ // every attempt collides (fail-closed, fix round 2): a colliding
386
+ // id would violate R2's "no two rows share an id" shape, so we
387
+ // refuse the write and surface the fault instead of appending a
388
+ // duplicate row the reader would fold into a state transition.
389
+ let id;
390
+ try {
391
+ id = await mintUniqueEntryId(projectRoot, ctx.now(), ctx.rng);
392
+ } catch (err) {
393
+ if (err && err.code === 'FEEDBACK_ID_EXHAUSTED') {
394
+ stderr.write(`[error] usage ${err.message}\n`);
395
+ return 2;
396
+ }
397
+ throw err;
398
+ }
399
+
400
+ const entryPreFingerprint = {
401
+ id,
402
+ recordedAt,
403
+ sessionId: ctx.sessionId,
404
+ kind,
405
+ target: {
406
+ ref: target,
407
+ ...(kind === 'blueprint' ? {
408
+ effectiveSlug: record?.slug ?? target,
409
+ ...blueprintStamp,
410
+ } : {}),
411
+ },
412
+ anchor: flags.anchor ?? null,
413
+ symptomClass,
414
+ severity,
415
+ title,
416
+ body,
417
+ evidence: evidence.map((v) => classifyEvidence(v)),
418
+ environment,
419
+ askNow: Boolean(flags['ask-now']),
420
+ };
421
+ // F-slice-1-12: persist the fingerprint so `list --json`, `status`
422
+ // and any triage-side tooling can key off it without re-computing.
423
+ // The persisted value MUST equal what preview and submit compute
424
+ // from the same entry.
425
+ const fp = fingerprint(entryPreFingerprint);
426
+ const entry = {
427
+ ...entryPreFingerprint,
428
+ fingerprint: fp,
429
+ status: 'pending',
430
+ destination: destinationUnresolved
431
+ // Preserve the slice-1 shape ({ reason: 'unresolved' }) that
432
+ // existing tests and the preview-time re-resolve rely on; the
433
+ // resolver's granular reason (unknown-target vs no-issues-field
434
+ // vs no-github-source vs no-libraryprefix-record) is recomputed
435
+ // at preview and submit time.
436
+ ? { reason: 'unresolved', resolverReason: resolvedDestination.reason ?? 'unresolved' }
437
+ : {
438
+ repo: resolvedDestination.repo,
439
+ visibility: resolvedDestination.visibility,
440
+ kind: resolvedDestination.kind,
441
+ derived: resolvedDestination.derived,
442
+ ...(resolvedDestination.source ? { source: resolvedDestination.source } : {}),
443
+ ...(resolvedDestination.publisherContact ? { publisherContact: resolvedDestination.publisherContact } : {}),
444
+ },
445
+ };
446
+
447
+ await appendEntry(projectRoot, entry);
448
+
449
+ // F-slice-1-09: prune discarded entries older than 30 days so the
450
+ // log does not grow unbounded (design 3.1 L137). The prune is a
451
+ // set of state transitions, not a rewrite of the JSONL; a discarded
452
+ // entry older than the window gains a new `pruned` state line so
453
+ // the reader's fold-by-id drops it from `list --all` counts too.
454
+ await pruneOldDiscarded(projectRoot, ctx.now());
455
+
456
+ // Count of pending entries after this write (fold-by-id read).
457
+ const all = await readEntries(projectRoot);
458
+ const pending = all.filter((e) => e.status === 'pending').length;
459
+
460
+ stdout.write(`recorded ${id} (${pending} pending). Nothing sent.\n`);
461
+
462
+ if (targetUnknown) {
463
+ // Design 3.1 exit-3: --kind blueprint but the target does not
464
+ // appear on rcf/manifest.json:blueprints[]. The entry is still
465
+ // recorded so the operator can rewrite the target and re-preview.
466
+ stderr.write(`destination unresolved: ${target}\n`);
467
+ return 3;
468
+ }
469
+ if (destinationUnresolved) {
470
+ // Design 7 case 5: the target resolved to a library whose registry
471
+ // entry has no issuesRepo and no derivable github source. Entry is
472
+ // stamped unresolved and stays pending; slice 4 writes the bundle
473
+ // at submit time and prints the publisher contact.
474
+ stderr.write(`destination unresolved for library '${record?.libraryPrefix ?? '<unknown>'}': ${resolvedDestination.reason ?? 'unresolved'}. The entry is kept; \`rcf feedback submit\` will write a bundle instead of filing an issue.\n`);
475
+ }
476
+ return 0;
477
+ }
478
+
479
+ /**
480
+ * Classify an evidence pointer into `{ kind, value }`. A colon-separated
481
+ * `path:line` pattern is 'file'; a bare identifier that looks like an
482
+ * upper-cased id is 'id'; anything else is 'command'. The classification
483
+ * is best-effort and does not gate the write.
484
+ *
485
+ * @param {string} value
486
+ * @returns {{ kind: 'file' | 'id' | 'command', value: string }}
487
+ */
488
+ export function classifyEvidence(value) {
489
+ if (/^[A-Z]+-\d[\w-]*$/.test(value)) return { kind: 'id', value };
490
+ if (/^[^\s].*:\d+$/.test(value)) return { kind: 'file', value };
491
+ return { kind: 'command', value };
492
+ }
493
+
494
+ // -- list ------------------------------------------------------------------
495
+
496
+ const LIST_OPTIONS = /** @type {const} */ ({
497
+ all: { type: 'boolean' },
498
+ json: { type: 'boolean' },
499
+ help: { type: 'boolean' },
500
+ });
501
+
502
+ async function handleList(argv, ctx) {
503
+ const { stdout, stderr, cwd } = ctx;
504
+ let parsed;
505
+ try {
506
+ parsed = parseArgs({ args: argv, options: LIST_OPTIONS, allowPositionals: false, strict: true });
507
+ } catch (err) {
508
+ stderr.write(`[error] usage ${err.message}\n`);
509
+ return 2;
510
+ }
511
+ const flags = parsed.values;
512
+ if (flags.help) { stdout.write(HELP); return 0; }
513
+
514
+ const projectRoot = await findProjectRoot(cwd);
515
+ if (!projectRoot) {
516
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
517
+ return 2;
518
+ }
519
+
520
+ const all = await readEntries(projectRoot);
521
+ const shown = flags.all ? all : all.filter((e) => e.status === 'pending');
522
+ // Round 3 (F-slice-1-09 regression): a pruned entry's body and
523
+ // title stayed visible through `list --all --json` because the
524
+ // fold-by-id spread merged the pruned state row over the original
525
+ // entry without clearing the free-form content. The prune sweep's
526
+ // point is to age content out; scrub it here so `list` on either
527
+ // surface never re-exposes a pruned body.
528
+ const sanitised = shown.map((e) => (e.status === 'pruned'
529
+ ? { ...e, title: null, body: null, evidence: [] }
530
+ : e));
531
+
532
+ if (flags.json) {
533
+ stdout.write(`${JSON.stringify(sanitised, null, 2)}\n`);
534
+ return 0;
535
+ }
536
+ if (sanitised.length === 0) {
537
+ stdout.write(flags.all
538
+ ? 'no feedback entries.\n'
539
+ : 'no pending feedback entries.\n');
540
+ return 0;
541
+ }
542
+ const rows = sanitised.slice().sort((a, b) => (a.recordedAt ?? '').localeCompare(b.recordedAt ?? ''));
543
+ for (const e of rows) {
544
+ const statusCol = flags.all ? `[${e.status}] ` : '';
545
+ const titleText = e.status === 'pruned' ? '(pruned; body scrubbed)' : e.title;
546
+ stdout.write(`${statusCol}${e.id} ${e.target?.ref ?? '?'} ${e.symptomClass} ${e.severity} ${titleText}\n`);
547
+ }
548
+ return 0;
549
+ }
550
+
551
+ // -- status ----------------------------------------------------------------
552
+
553
+ const STATUS_OPTIONS = /** @type {const} */ ({
554
+ json: { type: 'boolean' },
555
+ help: { type: 'boolean' },
556
+ });
557
+
558
+ async function handleStatus(argv, ctx) {
559
+ const { stdout, stderr, cwd, env } = ctx;
560
+ let parsed;
561
+ try {
562
+ parsed = parseArgs({ args: argv, options: STATUS_OPTIONS, allowPositionals: false, strict: true });
563
+ } catch (err) {
564
+ stderr.write(`[error] usage ${err.message}\n`);
565
+ return 2;
566
+ }
567
+ const flags = parsed.values;
568
+ if (flags.help) { stdout.write(HELP); return 0; }
569
+
570
+ const projectRoot = await findProjectRoot(cwd);
571
+ if (!projectRoot) {
572
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
573
+ return 2;
574
+ }
575
+
576
+ const exists = await storeExists(projectRoot);
577
+ const all = exists ? await readEntries(projectRoot) : [];
578
+ const counts = countByStatus(all);
579
+ // Precedence (design section 8): env DISABLE > env ASK=0 > file
580
+ // ask false > file ask true (the default). The status view names
581
+ // the resolved source so the operator can tell whether an ask
582
+ // will fire without inspecting the file.
583
+ const settingsRead = await readFeedbackSettings(projectRoot);
584
+ const fileAskOff = settingsRead.settings.ask === false;
585
+ const optOut = env.RCF_FEEDBACK_DISABLE === '1'
586
+ || env.RCF_FEEDBACK_ASK === '0'
587
+ || fileAskOff;
588
+ // Destination coverage across the library registry (design 3.1;
589
+ // slice 3 landed the resolver). A parseable-but-empty registry means
590
+ // no libraries are declared, so nothing to warn about. A broken
591
+ // registry is treated as "no libraries" here; the doctor
592
+ // feedback-destinations check surfaces the parse failure instead.
593
+ const registryRead = await readLibraryRegistry(projectRoot);
594
+ const registry = (registryRead && typeof registryRead === 'object' && 'kind' in registryRead)
595
+ ? { libraries: [] }
596
+ : registryRead;
597
+ const unresolvedLibraries = listUnresolvedLibraries(registry);
598
+ // Slice 4 (FBS-183): probe gh availability once so `rcf feedback
599
+ // status` warns the operator when submit would fall through to a
600
+ // bundle. A failed probe never fails status; the summary just
601
+ // records what the probe saw.
602
+ const ghSummary = await probeGhSummary(env);
603
+ // Per-blueprint destination table (design 3.1 L140): for every
604
+ // applied blueprint on the manifest, show the destination the
605
+ // resolver would return today. Read-only, no network. Collected
606
+ // BEFORE the JSON early-return so --json carries the same table
607
+ // the text path prints (F-slice-1-10 fix round 2).
608
+ const resolverInputsStatus = await loadResolverInputs(projectRoot);
609
+ const appliedBlueprints = Array.isArray(resolverInputsStatus.manifest?.blueprints)
610
+ ? resolverInputsStatus.manifest.blueprints
611
+ : [];
612
+ const destinationTable = [];
613
+ for (const r of appliedBlueprints) {
614
+ const ref = r.libraryPrefix
615
+ ? `${r.libraryPrefix}:${r.slug}`
616
+ : (r.slug ?? r.name ?? '(unnamed)');
617
+ const dest = await resolveDestination(
618
+ { kind: 'blueprint', target: { ref } },
619
+ resolverInputsStatus,
620
+ );
621
+ destinationTable.push({
622
+ ref,
623
+ repo: dest.repo ?? null,
624
+ visibility: dest.visibility ?? 'unresolved',
625
+ derived: !!dest.derived,
626
+ reason: dest.reason ?? null,
627
+ });
628
+ }
629
+
630
+ const summary = {
631
+ counts,
632
+ optOut,
633
+ optOutSource: optOut
634
+ ? (env.RCF_FEEDBACK_DISABLE === '1'
635
+ ? 'env:RCF_FEEDBACK_DISABLE'
636
+ : (env.RCF_FEEDBACK_ASK === '0'
637
+ ? 'env:RCF_FEEDBACK_ASK=0'
638
+ : 'file:rcf/feedback-settings.json'))
639
+ : null,
640
+ quietMinutes: settingsRead.settings.quietMinutes,
641
+ storePath: '.rcf/feedback/entries.jsonl',
642
+ gh: ghSummary,
643
+ destinations: {
644
+ registeredLibraries: Array.isArray(registry?.libraries) ? registry.libraries.length : 0,
645
+ unresolvedLibraries,
646
+ table: destinationTable,
647
+ },
648
+ };
649
+
650
+ if (flags.json) {
651
+ stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
652
+ return 0;
653
+ }
654
+ stdout.write(`feedback: ${counts.pending} pending, ${counts.submitted} submitted, ${counts.bundled} bundled, ${counts.deferredUntilSession} deferred, ${counts.discarded} discarded, ${counts.pruned} pruned\n`);
655
+ stdout.write(`opt-out: ${optOut ? `yes (${summary.optOutSource})` : 'no'}\n`);
656
+ stdout.write(`gh: ${ghSummary.present ? (ghSummary.authed ? 'installed, authed on github.com' : 'installed, not logged in (submit will bundle)') : 'not installed (submit will bundle)'}\n`);
657
+ if (summary.destinations.unresolvedLibraries.length > 0) {
658
+ stdout.write(`destinations: ${summary.destinations.unresolvedLibraries.length} of ${summary.destinations.registeredLibraries} registered librar${summary.destinations.registeredLibraries === 1 ? 'y has' : 'ies have'} no resolvable destination:\n`);
659
+ // F-slice-3-04: text output must LIST every unresolved library
660
+ // (design 3.3 L163); JSON already emits the array.
661
+ for (const u of summary.destinations.unresolvedLibraries) {
662
+ const contact = u.publisherContact ? ` (contact: ${u.publisherContact})` : '';
663
+ stdout.write(` - ${u.libraryPrefix}: ${u.reason}${contact}\n`);
664
+ }
665
+ } else if (summary.destinations.registeredLibraries > 0) {
666
+ stdout.write(`destinations: all ${summary.destinations.registeredLibraries} registered librar${summary.destinations.registeredLibraries === 1 ? 'y resolves' : 'ies resolve'} to a feedback destination.\n`);
667
+ }
668
+ if (destinationTable.length > 0) {
669
+ stdout.write(`applied blueprints (${destinationTable.length}):\n`);
670
+ for (const row of destinationTable) {
671
+ const cell = row.repo
672
+ ? `${row.repo} (${row.visibility}${row.derived ? ', derived' : ''})`
673
+ : `(unresolved${row.reason ? `: ${row.reason}` : ''})`;
674
+ stdout.write(` - ${row.ref} -> ${cell}\n`);
675
+ }
676
+ }
677
+ return 0;
678
+ }
679
+
680
+ /**
681
+ * Probe the gh adapter for install + auth state. Slice 4 wires this;
682
+ * the returned shape is what `feedback status --json` emits under
683
+ * `gh`. A failed probe never throws.
684
+ *
685
+ * @param {NodeJS.ProcessEnv} env
686
+ */
687
+ async function probeGhSummary(env) {
688
+ try {
689
+ const gh = await loadGhAdapter(env);
690
+ const path = await gh.ghOnPath();
691
+ const present = !!(path.ok && path.value?.present !== false);
692
+ if (!present) {
693
+ return { present: false, authed: false, host: 'github.com', note: 'gh not on PATH' };
694
+ }
695
+ const auth = await gh.ghAuthStatus({ host: 'github.com' });
696
+ return {
697
+ present: true,
698
+ authed: !!auth.ok,
699
+ host: 'github.com',
700
+ note: auth.ok ? null : 'run `gh auth login` to enable submit',
701
+ };
702
+ } catch (err) {
703
+ return { present: null, authed: null, host: 'github.com', note: `probe failed: ${err.message}` };
704
+ }
705
+ }
706
+
707
+ function countByStatus(entries) {
708
+ // F-slice-1-09 fix: `pruned` is a real state now that retention
709
+ // sweeps stamp it, so status counts it too. Without this the count
710
+ // line silently under-reported the store.
711
+ const c = {
712
+ pending: 0, submitted: 0, bundled: 0, deferredUntilSession: 0, discarded: 0, pruned: 0,
713
+ };
714
+ for (const e of entries) {
715
+ if (Object.prototype.hasOwnProperty.call(c, e.status)) c[e.status] += 1;
716
+ }
717
+ return c;
718
+ }
719
+
720
+ // -- defer -----------------------------------------------------------------
721
+
722
+ const DEFER_OPTIONS = /** @type {const} */ ({
723
+ help: { type: 'boolean' },
724
+ });
725
+
726
+ async function handleDefer(argv, ctx) {
727
+ const { stdout, stderr, cwd } = ctx;
728
+ let parsed;
729
+ try {
730
+ parsed = parseArgs({ args: argv, options: DEFER_OPTIONS, allowPositionals: false, strict: true });
731
+ } catch (err) {
732
+ stderr.write(`[error] usage ${err.message}\n`);
733
+ return 2;
734
+ }
735
+ const flags = parsed.values;
736
+ if (flags.help) { stdout.write(HELP); return 0; }
737
+
738
+ const projectRoot = await findProjectRoot(cwd);
739
+ if (!projectRoot) {
740
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
741
+ return 2;
742
+ }
743
+ const all = await readEntries(projectRoot);
744
+ const pending = all.filter((e) => e.status === 'pending');
745
+ if (pending.length === 0) {
746
+ stdout.write('no pending feedback entries to defer.\n');
747
+ return 0;
748
+ }
749
+ const at = ctx.now().toISOString().replace(/\.\d{3}Z$/, 'Z');
750
+ for (const e of pending) {
751
+ await appendState(projectRoot, {
752
+ id: e.id, at, status: 'deferredUntilSession', sessionId: ctx.sessionId,
753
+ });
754
+ }
755
+ stdout.write(`deferred ${pending.length} entr${pending.length === 1 ? 'y' : 'ies'} for session ${ctx.sessionId}.\n`);
756
+ return 0;
757
+ }
758
+
759
+ /**
760
+ * Requeue entries that were `deferredUntilSession` under a prior
761
+ * `sessionId` back to `pending` for the current session. Called from
762
+ * both `runSessionStartHook` and `runStopHook` so a defer taken in
763
+ * session A returns to the ask queue in session B whether the
764
+ * SessionStart hook is installed or not (design 3.1: defer = "not
765
+ * now", ask again next session; AC-16103-3).
766
+ *
767
+ * The transition line writes `{ id, at, status: 'pending' }` with
768
+ * NO `sessionId` field, so fold-by-id preserves the entry's prior
769
+ * `sessionId` (the session it was last deferred in). That keeps the
770
+ * `anyCarriedOver` gate in both hooks naturally true in the new
771
+ * session: `pending.sessionId !== currentSessionId`.
772
+ *
773
+ * Idempotent per session: entries whose folded `sessionId` already
774
+ * equals the current session (a fresh add this session, or a
775
+ * previous requeue for this same session) are skipped.
776
+ *
777
+ * @param {string} projectRoot
778
+ * @param {string | undefined} sessionId
779
+ * @param {() => Date} now
780
+ * @returns {Promise<number>} number of entries requeued
781
+ */
782
+ async function requeueDeferredForSession(projectRoot, sessionId, now) {
783
+ if (!sessionId) return 0;
784
+ const all = await readEntries(projectRoot);
785
+ const toRequeue = all.filter((e) => {
786
+ if (e.status !== 'deferredUntilSession') return false;
787
+ const s = typeof e.sessionId === 'string' ? e.sessionId : '';
788
+ return s !== '' && s !== sessionId;
789
+ });
790
+ if (toRequeue.length === 0) return 0;
791
+ const at = now().toISOString().replace(/\.\d{3}Z$/, 'Z');
792
+ for (const e of toRequeue) {
793
+ await appendState(projectRoot, {
794
+ id: e.id, at, status: 'pending',
795
+ });
796
+ }
797
+ return toRequeue.length;
798
+ }
799
+
800
+ // -- discard ---------------------------------------------------------------
801
+
802
+ const DISCARD_OPTIONS = /** @type {const} */ ({
803
+ all: { type: 'boolean' },
804
+ help: { type: 'boolean' },
805
+ });
806
+
807
+ async function handleDiscard(argv, ctx) {
808
+ const { stdout, stderr, cwd } = ctx;
809
+ let parsed;
810
+ try {
811
+ parsed = parseArgs({ args: argv, options: DISCARD_OPTIONS, allowPositionals: true, strict: true });
812
+ } catch (err) {
813
+ stderr.write(`[error] usage ${err.message}\n`);
814
+ return 2;
815
+ }
816
+ const flags = parsed.values;
817
+ if (flags.help) { stdout.write(HELP); return 0; }
818
+
819
+ const projectRoot = await findProjectRoot(cwd);
820
+ if (!projectRoot) {
821
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
822
+ return 2;
823
+ }
824
+ const all = await readEntries(projectRoot);
825
+ // F-slice-1-11: design 3.1 L137 allows discard against any id, not
826
+ // only pending. A user changing their mind about a deferred or a
827
+ // bundled entry must be able to say so. Only already-discarded /
828
+ // pruned entries are refused (idempotency).
829
+ const active = all.filter((e) => e.status !== 'discarded' && e.status !== 'pruned');
830
+ const pending = all.filter((e) => e.status === 'pending');
831
+ let targets;
832
+ if (flags.all) {
833
+ if (parsed.positionals.length > 0) {
834
+ stderr.write('[error] usage --all cannot be combined with entry ids\n');
835
+ return 2;
836
+ }
837
+ // `--all` (kept scope: pending only) matches slice-1 semantics
838
+ // so a hook-triggered mass discard does not accidentally nuke
839
+ // submitted issue links.
840
+ targets = pending;
841
+ } else {
842
+ if (parsed.positionals.length === 0) {
843
+ stderr.write('[error] usage discard: at least one entry id, or --all\n');
844
+ return 2;
845
+ }
846
+ const wanted = new Set(parsed.positionals);
847
+ const byId = new Map(active.map((e) => [e.id, e]));
848
+ targets = [];
849
+ const missing = [];
850
+ for (const id of wanted) {
851
+ const e = byId.get(id);
852
+ if (!e) { missing.push(id); continue; }
853
+ targets.push(e);
854
+ }
855
+ if (missing.length > 0) {
856
+ stderr.write(`[error] usage unknown entry id(s): ${missing.join(', ')}\n`);
857
+ return 2;
858
+ }
859
+ }
860
+ if (targets.length === 0) {
861
+ stdout.write('no feedback entries to discard.\n');
862
+ return 0;
863
+ }
864
+ const at = ctx.now().toISOString().replace(/\.\d{3}Z$/, 'Z');
865
+ for (const e of targets) {
866
+ await appendState(projectRoot, { id: e.id, at, status: 'discarded' });
867
+ }
868
+ stdout.write(`discarded ${targets.length} entr${targets.length === 1 ? 'y' : 'ies'}.\n`);
869
+ return 0;
870
+ }
871
+
872
+ // -- preview (slice 2, FBS-181) -------------------------------------------
873
+
874
+ const PREVIEW_OPTIONS = /** @type {const} */ ({
875
+ json: { type: 'boolean' },
876
+ help: { type: 'boolean' },
877
+ });
878
+
879
+ /**
880
+ * Render each named (or every pending) entry as the title and body it
881
+ * would take on the GitHub issue tracker, plus a redaction disclosure
882
+ * ledger and any residual-secret markers rule 8b turned up. This is
883
+ * what the operator sees before consenting to submit; it is read-only
884
+ * and never touches the network.
885
+ *
886
+ * Text output: one framed block per entry with the destination line,
887
+ * the rendered title, the rendered body inside a `---` fence, and the
888
+ * ledger printed as diff-style `- before / + after (rule, count)`
889
+ * rows. `--json` emits the same as one object per entry.
890
+ *
891
+ * @param {string[]} argv
892
+ * @param {object} ctx
893
+ * @returns {Promise<number>}
894
+ */
895
+ async function handlePreview(argv, ctx) {
896
+ const { stdout, stderr, cwd } = ctx;
897
+ let parsed;
898
+ try {
899
+ parsed = parseArgs({ args: argv, options: PREVIEW_OPTIONS, allowPositionals: true, strict: true });
900
+ } catch (err) {
901
+ stderr.write(`[error] usage ${err.message}\n`);
902
+ return 2;
903
+ }
904
+ const flags = parsed.values;
905
+ if (flags.help) { stdout.write(HELP); return 0; }
906
+
907
+ const projectRoot = await findProjectRoot(cwd);
908
+ if (!projectRoot) {
909
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
910
+ return 2;
911
+ }
912
+
913
+ const all = await readEntries(projectRoot);
914
+ const pending = all.filter((e) => e.status === 'pending');
915
+ const wantedIds = new Set(parsed.positionals);
916
+ const entries = wantedIds.size > 0
917
+ ? pending.filter((e) => wantedIds.has(e.id))
918
+ : pending;
919
+
920
+ if (wantedIds.size > 0) {
921
+ const missing = [...wantedIds].filter((id) => !pending.some((e) => e.id === id));
922
+ if (missing.length > 0) {
923
+ stderr.write(`[error] usage unknown pending entry id(s): ${missing.join(', ')}\n`);
924
+ return 2;
925
+ }
926
+ }
927
+
928
+ const context = await buildRedactionContext(projectRoot);
929
+ const resolverInputs = await loadResolverInputs(projectRoot);
930
+
931
+ const previews = await Promise.all(
932
+ entries.map((e) => buildPreview(e, context, resolverInputs)),
933
+ );
934
+
935
+ if (flags.json) {
936
+ stdout.write(`${JSON.stringify(previews, null, 2)}\n`);
937
+ return 0;
938
+ }
939
+
940
+ if (previews.length === 0) {
941
+ stdout.write('no pending feedback entries to preview.\n');
942
+ return 0;
943
+ }
944
+
945
+ for (const p of previews) {
946
+ stdout.write(`--- ${p.id} ---\n`);
947
+ // F-slice-3-03: preview must show derived-source disclosure and
948
+ // the unresolved-library bundle message, not just repo + visibility.
949
+ stdout.write(`destination: ${p.destination.repo ?? '(unresolved)'} (${p.destination.visibility})\n`);
950
+ if (p.destination.derived === true) {
951
+ stdout.write(" source: derived from the library's git source (no explicit issues field on the library)\n");
952
+ } else if (p.destination.source) {
953
+ stdout.write(` source: ${p.destination.source}\n`);
954
+ }
955
+ if (p.destination.visibility === 'unresolved') {
956
+ const reason = p.destination.reason ?? 'unresolved';
957
+ const contact = p.destination.publisherContact ? ` (library contact: ${p.destination.publisherContact})` : '';
958
+ stdout.write(` no issue destination declared${p.destination.reason ? ` (${reason})` : ''}; bundle will be written${contact}\n`);
959
+ // AC-15801-4 / design amendment R5b: for ambiguous-library-slug
960
+ // surface the candidate library prefixes so the operator sees
961
+ // exactly which qualified refs would resolve it.
962
+ if (Array.isArray(p.destination.candidates) && p.destination.candidates.length > 0) {
963
+ const qualified = p.destination.candidates.map((prefix) => `${prefix}:<slug>`).join(', ');
964
+ stdout.write(` candidate libraries owning this slug: ${p.destination.candidates.join(', ')} (re-apply with a qualified ref, e.g. ${qualified})\n`);
965
+ }
966
+ }
967
+ stdout.write(`fingerprint: ${p.fingerprint}\n`);
968
+ if (p.fingerprintFallback) {
969
+ stdout.write('warning: no anchor on this entry; the fingerprint falls back to normalised-title tokens and duplicates may not fold.\n');
970
+ }
971
+ stdout.write(`title: ${p.titleRendered}\n`);
972
+ stdout.write('body:\n');
973
+ stdout.write('---\n');
974
+ stdout.write(`${p.bodyRendered}\n`);
975
+ stdout.write('---\n');
976
+ if (p.ledger.length === 0) {
977
+ stdout.write('redaction ledger: nothing replaced.\n');
978
+ } else {
979
+ stdout.write('redaction ledger:\n');
980
+ for (const row of p.ledger) {
981
+ stdout.write(` - ${row.before}\n`);
982
+ stdout.write(` + ${row.after} (${row.rule}, x${row.count})\n`);
983
+ }
984
+ }
985
+ if (p.residual.length > 0) {
986
+ stdout.write('residual secret markers (submit will refuse):\n');
987
+ for (const r of p.residual) {
988
+ stdout.write(` residual secret at line ${r.line}: ${r.snippet} (${r.pattern})\n`);
989
+ }
990
+ }
991
+ stdout.write('\n');
992
+ }
993
+ return 0;
994
+ }
995
+
996
+ /**
997
+ * Assemble the redaction context from the project's identity seed and
998
+ * manifest. Best-effort: a missing file leaves that field undefined
999
+ * rather than failing the preview.
1000
+ *
1001
+ * @param {string} projectRoot
1002
+ * @returns {Promise<import('../feedback/redact.js').RedactionContext>}
1003
+ */
1004
+ async function buildRedactionContext(projectRoot) {
1005
+ let operatorName;
1006
+ try {
1007
+ const profile = await readFile(resolve(projectRoot, 'rcf', '.identity', 'profile.md'), 'utf8');
1008
+ const m = profile.match(/^##\s+Name\s*\n([^\n]+)/m);
1009
+ if (m) {
1010
+ const n = m[1].trim();
1011
+ if (n && !/placeholder|todo|your name/i.test(n)) operatorName = n;
1012
+ }
1013
+ } catch { /* absent is fine */ }
1014
+
1015
+ let projectName;
1016
+ const remotes = new Set();
1017
+ try {
1018
+ const manifest = JSON.parse(await readFile(resolve(projectRoot, 'rcf', 'manifest.json'), 'utf8'));
1019
+ if (typeof manifest.projectName === 'string') projectName = manifest.projectName;
1020
+ if (typeof manifest.gitRemote === 'string') remotes.add(manifest.gitRemote);
1021
+ } catch { /* absent is fine */ }
1022
+
1023
+ // F-slice-2-07: also walk `git remote -v` so ordinary projects
1024
+ // without a manifest.gitRemote still have their HTTPS remote
1025
+ // redacted. Best-effort; a missing git or a non-repo directory
1026
+ // just leaves the set unchanged.
1027
+ try {
1028
+ const { stdout } = await execFileP('git', ['-C', projectRoot, 'remote', '-v'], { encoding: 'utf8' });
1029
+ for (const line of stdout.split('\n')) {
1030
+ const parts = line.split(/\s+/);
1031
+ if (parts.length >= 2 && parts[1]) remotes.add(parts[1]);
1032
+ }
1033
+ } catch { /* no git or no remotes is fine */ }
1034
+
1035
+ let allowHostsExt = [];
1036
+ try {
1037
+ const settings = JSON.parse(await readFile(resolve(projectRoot, 'rcf', 'feedback-settings.json'), 'utf8'));
1038
+ if (Array.isArray(settings?.redaction?.allowHosts)) {
1039
+ allowHostsExt = settings.redaction.allowHosts;
1040
+ }
1041
+ } catch { /* absent is fine */ }
1042
+ // Assemble the extended allow-list once so downstream callers can
1043
+ // print it if they want to; the redactor itself accepts the raw
1044
+ // extension array.
1045
+ void allowedHosts;
1046
+
1047
+ // F-slice-2-06: pass BOTH the typed spelling and the realpath
1048
+ // spelling of the project root so a path expressed either way
1049
+ // (e.g. /tmp/foo vs /private/tmp/foo on macOS) is stripped.
1050
+ const rootSpellings = [projectRoot];
1051
+ try {
1052
+ const real = await realpath(projectRoot);
1053
+ if (real && real !== projectRoot) rootSpellings.push(real);
1054
+ } catch { /* absent is fine */ }
1055
+
1056
+ return {
1057
+ projectRoot: rootSpellings,
1058
+ projectName,
1059
+ operatorName,
1060
+ gitRemote: [...remotes].filter((r) => typeof r === 'string' && r.length > 0),
1061
+ allowHosts: allowHostsExt,
1062
+ };
1063
+ }
1064
+
1065
+ /**
1066
+ * Load the resolver's read-only inputs (manifest, registry) once per
1067
+ * preview call so a batch of entries all consult the same on-disk
1068
+ * state. Best-effort: a missing manifest or registry file falls back
1069
+ * to empty shapes, which the resolver treats as "unresolved" for
1070
+ * blueprint entries and "core" for core entries.
1071
+ *
1072
+ * @param {string} projectRoot
1073
+ * @returns {Promise<{ manifest: object | null, registry: object | null }>}
1074
+ */
1075
+ async function loadResolverInputs(projectRoot) {
1076
+ let manifest = null;
1077
+ try {
1078
+ const raw = await readFile(resolve(projectRoot, 'rcf', 'manifest.json'), 'utf8');
1079
+ manifest = JSON.parse(raw);
1080
+ } catch { /* absent is fine */ }
1081
+ const registry = await readLibraryRegistry(projectRoot);
1082
+ // readLibraryRegistry returns an rcfError object on parse failure;
1083
+ // preview should not blow up on a broken registry. Treat that shape
1084
+ // as "no registry", so blueprint entries fall through to unresolved
1085
+ // rather than crashing.
1086
+ const usableRegistry = (registry && typeof registry === 'object' && 'kind' in registry)
1087
+ ? { libraries: [] }
1088
+ : registry;
1089
+ return { manifest, registry: usableRegistry };
1090
+ }
1091
+
1092
+ /**
1093
+ * Build one preview record for `--json` output and for the text
1094
+ * renderer. Uses the destination resolver (slice 3) so the destination
1095
+ * shape now matches the design 3.3 walk.
1096
+ *
1097
+ * @param {object} entry
1098
+ * @param {import('../feedback/redact.js').RedactionContext} context
1099
+ * @param {{ manifest: object | null, registry: object | null }} resolverInputs
1100
+ * @returns {Promise<object>}
1101
+ */
1102
+ async function buildPreview(entry, context, resolverInputs) {
1103
+ const titleRes = redact(entry.title ?? '', context);
1104
+ const bodyRes = redact(entry.body ?? '', context);
1105
+ const evidenceRedacted = (entry.evidence ?? []).map((ev) => {
1106
+ const r = redact(ev.value ?? '', context);
1107
+ return { kind: ev.kind, value: r.text, ledger: r.ledger };
1108
+ });
1109
+ const combinedLedger = mergeLedger([titleRes.ledger, bodyRes.ledger, ...evidenceRedacted.map((e) => e.ledger)]);
1110
+ const fp = fingerprint(entry);
1111
+ const destination = await resolveDestination(entry, resolverInputs);
1112
+ const rendered = renderIssue(entry, {
1113
+ title: titleRes.text,
1114
+ body: bodyRes.text,
1115
+ evidence: evidenceRedacted.map((e) => ({ kind: e.kind, value: e.value })),
1116
+ ledger: combinedLedger,
1117
+ }, { fingerprint: fp, destination });
1118
+ // F-slice-2-08: preview must run the residual pass over the
1119
+ // RENDERED body (which inlines evidence values) so an evidence
1120
+ // pointer carrying a residual secret is called out at preview
1121
+ // time, not silently landed in the issue. Also merge evidence
1122
+ // residuals from the per-field pass so the operator sees where
1123
+ // each hit came from.
1124
+ const evidenceResidual = evidenceRedacted.flatMap((e) => findResidualSecrets(String(e.value ?? '')));
1125
+ const renderedResidual = findResidualSecrets(rendered.body);
1126
+ const residual = dedupeResidual([
1127
+ ...titleRes.residual,
1128
+ ...bodyRes.residual,
1129
+ ...evidenceResidual,
1130
+ ...renderedResidual,
1131
+ ]);
1132
+ return {
1133
+ id: entry.id,
1134
+ fingerprint: fp,
1135
+ fingerprintFallback: entry.anchor == null || String(entry.anchor).trim() === '',
1136
+ destination,
1137
+ titleRendered: rendered.title,
1138
+ bodyRendered: rendered.body,
1139
+ labels: rendered.labels,
1140
+ ledger: combinedLedger,
1141
+ residual,
1142
+ };
1143
+ }
1144
+
1145
+ /**
1146
+ * Dedupe residual-hit rows on (pattern, line, snippet) so a hit found
1147
+ * both in an evidence pass and again in the rendered body does not
1148
+ * double-count in the preview surface.
1149
+ *
1150
+ * @param {Array<{line: number, snippet: string, pattern: string}>} hits
1151
+ */
1152
+ function dedupeResidual(hits) {
1153
+ const seen = new Set();
1154
+ const out = [];
1155
+ for (const h of hits) {
1156
+ const key = `${h.pattern}|${h.line}|${h.snippet}`;
1157
+ if (seen.has(key)) continue;
1158
+ seen.add(key);
1159
+ out.push(h);
1160
+ }
1161
+ return out;
1162
+ }
1163
+
1164
+ /**
1165
+ * Fold ledgers from several redact() calls into one. Rows are grouped
1166
+ * by (rule, before) so distinct samples of the same rule survive the
1167
+ * merge into the preview surface (F-slice-2-09): the redactor already
1168
+ * keeps one row per distinct before-sample; if this fold then collapsed
1169
+ * everything under a rule name to a single row, the operator would see
1170
+ * only one out of many stripped values. Counts sum within a (rule,
1171
+ * before) group. Ordering is first-seen so the preview reads in the
1172
+ * order rules fired.
1173
+ *
1174
+ * @param {Array<Array<{ rule: string, before: string, after: string, count: number }>>} ledgers
1175
+ * @returns {Array<{ rule: string, before: string, after: string, count: number }>}
1176
+ */
1177
+ function mergeLedger(ledgers) {
1178
+ const byKey = new Map();
1179
+ const order = [];
1180
+ for (const ledger of ledgers) {
1181
+ for (const row of ledger) {
1182
+ const key = `${row.rule}|${row.before}`;
1183
+ if (!byKey.has(key)) {
1184
+ order.push(key);
1185
+ byKey.set(key, { rule: row.rule, before: row.before, after: row.after, count: row.count });
1186
+ } else {
1187
+ byKey.get(key).count += row.count;
1188
+ }
1189
+ }
1190
+ }
1191
+ return order.map((k) => byKey.get(k));
1192
+ }
1193
+
1194
+ // -- submit (slice 4, FBS-183) --------------------------------------------
1195
+
1196
+ const SUBMIT_OPTIONS = /** @type {const} */ ({
1197
+ yes: { type: 'boolean' },
1198
+ 'dry-run': { type: 'boolean' },
1199
+ 'no-preview': { type: 'boolean' },
1200
+ help: { type: 'boolean' },
1201
+ });
1202
+
1203
+ /**
1204
+ * `rcf feedback submit`: file each pending entry (or those named by
1205
+ * positional id) under the reporter's ambient gh login. Refuses
1206
+ * without `--yes` on a non-TTY (design section 9's consent contract);
1207
+ * on a TTY the sub-verb prompts once. `--dry-run` runs the preflight
1208
+ * and dedupe search and prints the plan without any create/comment.
1209
+ *
1210
+ * Preflight order (per destination): gh on PATH -> gh auth status ->
1211
+ * gh repo view (visibility + viewerPermission + hasIssuesEnabled).
1212
+ * First failure routes that destination's entries to a bundle. The
1213
+ * unresolved-destination case (destination.resolve returned unresolved)
1214
+ * bundles directly with no gh calls for those entries (AC-16001-4).
1215
+ *
1216
+ * Dedupe (per entry passing preflight): gh search issues on the
1217
+ * destination for `rcf-feedback-fingerprint: <fp>` (open, in:body,
1218
+ * limit 5). Zero hits -> create. One hit -> +1 comment. Many hits ->
1219
+ * comment on the lowest number and mention the others. Search failure
1220
+ * -> create with dedupe:unchecked. Closed search runs only after a
1221
+ * zero-hit open search so the create body can reference the prior
1222
+ * number.
1223
+ *
1224
+ * Create/comment failure (network/4xx/5xx/ratelimit or unexpected
1225
+ * label rejection): retry once WITHOUT labels; still failing, bundle
1226
+ * that entry only. Exit 0 when every entry ended submitted or
1227
+ * bundled; exit 3 when a residual secret leaves one pending.
1228
+ *
1229
+ * @param {string[]} argv
1230
+ * @param {object} ctx
1231
+ * @returns {Promise<number>}
1232
+ */
1233
+ async function handleSubmit(argv, ctx) {
1234
+ const { stdout, stderr, cwd, env, now, sessionId } = ctx;
1235
+ let parsed;
1236
+ try {
1237
+ parsed = parseArgs({ args: argv, options: SUBMIT_OPTIONS, allowPositionals: true, strict: true });
1238
+ } catch (err) {
1239
+ stderr.write(`[error] usage ${err.message}\n`);
1240
+ return 2;
1241
+ }
1242
+ const flags = parsed.values;
1243
+ if (flags.help) { stdout.write(HELP); return 0; }
1244
+
1245
+ const projectRoot = await findProjectRoot(cwd);
1246
+ if (!projectRoot) {
1247
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
1248
+ return 2;
1249
+ }
1250
+
1251
+ // Consent gate: --yes is required unless stdin is a TTY and the
1252
+ // user answers y. In practice the agent always passes --yes after
1253
+ // the operator said yes in conversation; the flag is the contract
1254
+ // that a human answered. AC-15901-2 asserts the non-TTY branch.
1255
+ if (!flags.yes) {
1256
+ const stdin = process.stdin;
1257
+ const isTty = typeof stdin?.isTTY === 'boolean' ? stdin.isTTY : false;
1258
+ if (!isTty) {
1259
+ stderr.write('[error] usage rcf feedback submit requires --yes (or an interactive TTY where you can confirm the send).\n');
1260
+ return 2;
1261
+ }
1262
+ const ok = await promptForYes(stdin, stdout);
1263
+ if (!ok) {
1264
+ stdout.write('submit cancelled.\n');
1265
+ return 0;
1266
+ }
1267
+ }
1268
+
1269
+ const all = await readEntries(projectRoot);
1270
+ const pending = all.filter((e) => e.status === 'pending');
1271
+ const wantedIds = new Set(parsed.positionals);
1272
+ const entries = wantedIds.size > 0
1273
+ ? pending.filter((e) => wantedIds.has(e.id))
1274
+ : pending;
1275
+
1276
+ if (wantedIds.size > 0) {
1277
+ const missing = [...wantedIds].filter((id) => !pending.some((e) => e.id === id));
1278
+ if (missing.length > 0) {
1279
+ stderr.write(`[error] usage unknown pending entry id(s): ${missing.join(', ')}\n`);
1280
+ return 2;
1281
+ }
1282
+ }
1283
+
1284
+ if (entries.length === 0) {
1285
+ stdout.write('no pending feedback entries to submit.\n');
1286
+ return 0;
1287
+ }
1288
+
1289
+ const context = await buildRedactionContext(projectRoot);
1290
+ const resolverInputs = await loadResolverInputs(projectRoot);
1291
+ const gh = await loadGhAdapter(env);
1292
+ const rcfLiteVersion = await readOwnVersion();
1293
+
1294
+ // Group entries by destination repo so the preflight (auth,
1295
+ // repo-view, label-list) runs once per repo, not once per entry.
1296
+ // Unresolved destinations get their own bucket so the case-5 path
1297
+ // (AC-16001-4) is a first-class fold, not a special-case.
1298
+ const buckets = new Map();
1299
+ for (const e of entries) {
1300
+ const destination = await resolveDestination(e, resolverInputs);
1301
+ const key = destination.repo ?? `__unresolved__:${destination.reason ?? 'unknown'}`;
1302
+ if (!buckets.has(key)) buckets.set(key, { destination, entries: [] });
1303
+ buckets.get(key).entries.push(e);
1304
+ }
1305
+
1306
+ // Preflight once per bucket; per-destination results feed the
1307
+ // per-entry submit loop.
1308
+ const preflightCache = new Map();
1309
+ for (const [key, bucket] of buckets) {
1310
+ if (bucket.destination.visibility === 'unresolved') {
1311
+ preflightCache.set(key, { ok: false, reason: 'unresolved-destination', bucket: true });
1312
+ } else {
1313
+ preflightCache.set(key, await runPreflight(bucket.destination, gh));
1314
+ }
1315
+ }
1316
+
1317
+ const summary = {
1318
+ submitted: 0,
1319
+ bundled: 0,
1320
+ pending: 0,
1321
+ };
1322
+ const bundleRowsByKey = new Map();
1323
+
1324
+ for (const [key, bucket] of buckets) {
1325
+ const destination = bucket.destination;
1326
+ const pre = preflightCache.get(key);
1327
+
1328
+ if (!pre.ok && pre.bucket === true) {
1329
+ // Whole-destination fallback: build one bundle file for every
1330
+ // entry in this bucket; the message is the preflight reason.
1331
+ const rows = [];
1332
+ for (const e of bucket.entries) {
1333
+ const built = await buildEntryPayload(e, context, rcfLiteVersion);
1334
+ if (built.residual.length > 0) {
1335
+ await markResidual(projectRoot, e.id, at(now), sessionId);
1336
+ stdout.write(`${e.id} -> pending (residual secret in body; run \`rcf feedback preview\` and rewrite)\n`);
1337
+ summary.pending += 1;
1338
+ continue;
1339
+ }
1340
+ rows.push({ entry: e, redacted: built.redacted, fingerprint: built.fingerprint });
1341
+ }
1342
+ if (rows.length > 0) {
1343
+ bundleRowsByKey.set(key, { destination, rows, reason: pre.message });
1344
+ }
1345
+ continue;
1346
+ }
1347
+
1348
+ for (const e of bucket.entries) {
1349
+ const built = await buildEntryPayload(e, context, rcfLiteVersion);
1350
+ if (built.residual.length > 0) {
1351
+ await markResidual(projectRoot, e.id, at(now), sessionId);
1352
+ stdout.write(`${e.id} -> pending (residual secret in body; run \`rcf feedback preview\` and rewrite)\n`);
1353
+ summary.pending += 1;
1354
+ continue;
1355
+ }
1356
+
1357
+ // Label pre-check per entry-repo pair. Cached on the preflight
1358
+ // record so the second entry against the same repo does not
1359
+ // re-list.
1360
+ if (!pre.labelsChecked) {
1361
+ pre.labels = await runLabelPrecheck(destination.repo, gh);
1362
+ pre.labelsChecked = true;
1363
+ }
1364
+ const requestedLabels = labelsForEntry(e.severity, e.kind);
1365
+ const availableLabels = pre.labels?.available ?? null;
1366
+ const usedLabels = availableLabels
1367
+ ? requestedLabels.filter((l) => availableLabels.includes(l))
1368
+ : requestedLabels;
1369
+ const droppedLabels = availableLabels
1370
+ ? requestedLabels.filter((l) => !availableLabels.includes(l))
1371
+ : [];
1372
+
1373
+ // Dedupe search on the destination (open first).
1374
+ const searchOpen = await gh.ghIssueSearch({
1375
+ repo: destination.repo,
1376
+ query: `rcf-feedback-fingerprint: ${built.fingerprint}`,
1377
+ state: 'open',
1378
+ limit: 5,
1379
+ });
1380
+
1381
+ let dedupe = 'new';
1382
+ let closedRef = null;
1383
+ let matches = [];
1384
+ if (!searchOpen.ok) {
1385
+ dedupe = 'unchecked';
1386
+ } else {
1387
+ matches = searchOpen.value?.matches ?? [];
1388
+ }
1389
+
1390
+ if (flags['dry-run']) {
1391
+ const plan = matches.length === 1
1392
+ ? `comment on #${matches[0].number}`
1393
+ : matches.length > 1
1394
+ ? `comment on #${matches.sort((a, b) => a.number - b.number)[0].number} (${matches.length} matches)`
1395
+ : 'create';
1396
+ stdout.write(`${e.id} -> dry-run ${plan} (${dedupe === 'unchecked' ? 'unchecked' : matches.length === 0 ? 'new' : 'comment'})\n`);
1397
+ continue;
1398
+ }
1399
+
1400
+ // Zero-open-hit case: check closed-hit so the create body can
1401
+ // reference the previously reported number.
1402
+ if (matches.length === 0 && dedupe !== 'unchecked') {
1403
+ const searchClosed = await gh.ghIssueSearch({
1404
+ repo: destination.repo,
1405
+ query: `rcf-feedback-fingerprint: ${built.fingerprint}`,
1406
+ state: 'closed',
1407
+ limit: 1,
1408
+ });
1409
+ if (searchClosed.ok && (searchClosed.value?.matches ?? []).length > 0) {
1410
+ closedRef = searchClosed.value.matches[0].number;
1411
+ }
1412
+ }
1413
+
1414
+ let result;
1415
+ if (matches.length === 0) {
1416
+ // Create.
1417
+ let createBody = built.rendered.body;
1418
+ if (closedRef) {
1419
+ createBody = `${createBody}\nPreviously reported and closed as #${closedRef}.\n`;
1420
+ }
1421
+ result = await createWithRetry(gh, {
1422
+ repo: destination.repo,
1423
+ title: built.rendered.title,
1424
+ body: createBody,
1425
+ labels: usedLabels,
1426
+ });
1427
+ } else if (matches.length === 1) {
1428
+ // Comment.
1429
+ dedupe = 'comment';
1430
+ const commentBody = renderComment(e, {
1431
+ body: built.redacted.body,
1432
+ ledger: built.redacted.ledger,
1433
+ }, { fingerprint: built.fingerprint, includeBody: false }).body;
1434
+ result = await commentWithRetry(gh, {
1435
+ repo: destination.repo,
1436
+ number: matches[0].number,
1437
+ body: commentBody,
1438
+ });
1439
+ } else {
1440
+ // Many: comment on lowest, mention the others.
1441
+ dedupe = 'comment';
1442
+ const sorted = matches.slice().sort((a, b) => a.number - b.number);
1443
+ const others = sorted.slice(1).map((m) => `#${m.number}`).join(', ');
1444
+ const base = renderComment(e, {
1445
+ body: built.redacted.body,
1446
+ ledger: built.redacted.ledger,
1447
+ }, { fingerprint: built.fingerprint, includeBody: false }).body;
1448
+ const commentBody = `${base}\nalso see ${others}\n`;
1449
+ result = await commentWithRetry(gh, {
1450
+ repo: destination.repo,
1451
+ number: sorted[0].number,
1452
+ body: commentBody,
1453
+ });
1454
+ }
1455
+
1456
+ if (result.ok) {
1457
+ await appendState(projectRoot, {
1458
+ id: e.id,
1459
+ at: at(now),
1460
+ status: 'submitted',
1461
+ issueUrl: result.url,
1462
+ dedupe,
1463
+ droppedLabels,
1464
+ sessionId,
1465
+ });
1466
+ stdout.write(`${e.id} -> ${result.url} (${dedupe})\n`);
1467
+ summary.submitted += 1;
1468
+ } else {
1469
+ // Retry-without-labels failed too: bundle this one entry.
1470
+ const rows = bundleRowsByKey.get(`__perentry__:${e.id}`)?.rows ?? [];
1471
+ rows.push({ entry: e, redacted: built.redacted, fingerprint: built.fingerprint });
1472
+ bundleRowsByKey.set(`__perentry__:${e.id}`, {
1473
+ destination,
1474
+ rows,
1475
+ reason: `create/comment failed: ${result.reason}`,
1476
+ });
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ // Write bundle files (whole-destination and per-entry).
1482
+ const generatedAt = now().toISOString().replace(/\.\d{3}Z$/, 'Z');
1483
+ for (const [key, bundle] of bundleRowsByKey) {
1484
+ // Round 4 (F-01, design R3c): `submit --dry-run` writes nothing
1485
+ // under `.rcf/feedback/`, including outbox bundles for the
1486
+ // unresolved-destination and whole-destination-fallback branches.
1487
+ // Previously the dry-run gate on the create/comment path (L1340)
1488
+ // let the bundle write path run unconditionally, so an
1489
+ // `unresolved` entry produced a real outbox file on a dry-run.
1490
+ if (flags['dry-run']) {
1491
+ const issuesUrl = bundle.destination.repo
1492
+ ? `https://github.com/${bundle.destination.repo}/issues/new`
1493
+ : '(no repo; paste to the library owner)';
1494
+ const contactLine = bundle.destination.publisherContact
1495
+ ? ` (contact: ${bundle.destination.publisherContact})`
1496
+ : '';
1497
+ stdout.write(`${bundle.rows.map((r) => r.entry.id).join(', ')} -> dry-run bundle ${issuesUrl}${contactLine}\n`);
1498
+ continue;
1499
+ }
1500
+ let outboxPath;
1501
+ try {
1502
+ outboxPath = await writeBundle(projectRoot, bundle.destination, bundle.rows, {
1503
+ generatedAt,
1504
+ rcfLiteVersion,
1505
+ reasonNotFiled: bundle.reason,
1506
+ });
1507
+ } catch (err) {
1508
+ // Round 3 (AC-15601-4 regression): writeBundle throws
1509
+ // FEEDBACK_BUNDLE_RESIDUAL when the safeguard's whole-bundle
1510
+ // residual scan bites. The uncaught throw propagated all the
1511
+ // way to the dispatcher and turned the exit code into 1; the
1512
+ // documented shape is exit 3 (at least one entry pending),
1513
+ // matching the per-entry residual path. Catch it here, keep
1514
+ // every bundle row PENDING with residual:true, and let
1515
+ // `summary.pending > 0 ? 3 : 0` return the 3.
1516
+ if (/** @type {any} */ (err)?.code === 'FEEDBACK_BUNDLE_RESIDUAL') {
1517
+ stdout.write(`${bundle.rows.map((r) => r.entry.id).join(', ')} -> pending (bundle-level residual secret; run \`rcf feedback preview\` and rewrite before submit)\n`);
1518
+ stderr.write(`[safeguard] ${err.message}\n`);
1519
+ for (const row of bundle.rows) {
1520
+ await markResidual(projectRoot, row.entry.id, at(now), sessionId);
1521
+ summary.pending += 1;
1522
+ }
1523
+ continue;
1524
+ }
1525
+ throw err;
1526
+ }
1527
+ const issuesUrl = bundle.destination.repo
1528
+ ? `https://github.com/${bundle.destination.repo}/issues/new`
1529
+ : '(no repo; paste to the library owner)';
1530
+ const contactLine = bundle.destination.publisherContact
1531
+ ? ` (contact: ${bundle.destination.publisherContact})`
1532
+ : '';
1533
+ stdout.write(`${bundle.rows.map((r) => r.entry.id).join(', ')} -> bundle ${outboxPath} | ${issuesUrl}${contactLine}\n`);
1534
+ for (const row of bundle.rows) {
1535
+ await appendState(projectRoot, {
1536
+ id: row.entry.id,
1537
+ at: at(now),
1538
+ status: 'bundled',
1539
+ outboxPath,
1540
+ reason: bundle.reason,
1541
+ sessionId,
1542
+ });
1543
+ summary.bundled += 1;
1544
+ }
1545
+ }
1546
+
1547
+ return summary.pending > 0 ? 3 : 0;
1548
+ }
1549
+
1550
+ /**
1551
+ * Read up to a single line of yes/no from stdin (blocking one-shot).
1552
+ * Returns true only for `y` or `yes` (case-insensitive).
1553
+ *
1554
+ * @param {NodeJS.ReadStream} stdin
1555
+ * @param {NodeJS.WritableStream} stdout
1556
+ * @returns {Promise<boolean>}
1557
+ */
1558
+ async function promptForYes(stdin, stdout) {
1559
+ stdout.write('submit each pending entry under your ambient gh login? [y/N] ');
1560
+ return new Promise((resolvePromise) => {
1561
+ let buf = '';
1562
+ const onData = (chunk) => {
1563
+ buf += chunk.toString('utf8');
1564
+ const nl = buf.indexOf('\n');
1565
+ if (nl >= 0) {
1566
+ stdin.off('data', onData);
1567
+ try { stdin.pause(); } catch { /* ignore */ }
1568
+ const answer = buf.slice(0, nl).trim().toLowerCase();
1569
+ resolvePromise(answer === 'y' || answer === 'yes');
1570
+ }
1571
+ };
1572
+ stdin.on('data', onData);
1573
+ try { stdin.resume(); } catch { /* ignore */ }
1574
+ });
1575
+ }
1576
+
1577
+ /**
1578
+ * Build the redacted body/title and the rendered issue payload for
1579
+ * one entry. Records residual-secret markers on the returned object
1580
+ * so the caller can refuse the entry.
1581
+ *
1582
+ * @param {object} entry
1583
+ * @param {import('../feedback/redact.js').RedactionContext} context
1584
+ * @param {string} rcfLiteVersion
1585
+ */
1586
+ async function buildEntryPayload(entry, context, rcfLiteVersion) {
1587
+ const titleRes = redact(entry.title ?? '', context);
1588
+ const bodyRes = redact(entry.body ?? '', context);
1589
+ const evidenceRedacted = (entry.evidence ?? []).map((ev) => {
1590
+ const r = redact(ev.value ?? '', context);
1591
+ return { kind: ev.kind, value: r.text, ledger: r.ledger };
1592
+ });
1593
+ const ledger = mergeLedger([titleRes.ledger, bodyRes.ledger, ...evidenceRedacted.map((e) => e.ledger)]);
1594
+ const fp = fingerprint(entry);
1595
+ // A destination shape the render helper accepts; the submit path
1596
+ // does not use bundle-specific fields on rendered issues.
1597
+ const rendered = renderIssue(entry, {
1598
+ title: titleRes.text,
1599
+ body: bodyRes.text,
1600
+ evidence: evidenceRedacted.map((e) => ({ kind: e.kind, value: e.value })),
1601
+ ledger,
1602
+ }, { fingerprint: fp, destination: { repo: null, visibility: 'unresolved' } });
1603
+ const residual = [
1604
+ ...titleRes.residual,
1605
+ ...bodyRes.residual,
1606
+ ...findResidualSecrets(rendered.body),
1607
+ ];
1608
+ void rcfLiteVersion;
1609
+ return {
1610
+ fingerprint: fp,
1611
+ redacted: {
1612
+ title: titleRes.text,
1613
+ body: bodyRes.text,
1614
+ evidence: evidenceRedacted.map((e) => ({ kind: e.kind, value: e.value })),
1615
+ ledger,
1616
+ },
1617
+ rendered,
1618
+ residual,
1619
+ };
1620
+ }
1621
+
1622
+ /**
1623
+ * Preflight one destination repo. Records the classified reason on
1624
+ * failure so the bundle message can name it.
1625
+ *
1626
+ * @param {object} destination
1627
+ * @param {import('../feedback/gh.js').GhAdapter} gh
1628
+ */
1629
+ async function runPreflight(destination, gh) {
1630
+ // (1) gh on PATH.
1631
+ const path = await gh.ghOnPath();
1632
+ if (!path.ok || (path.ok && path.value?.present === false)) {
1633
+ return {
1634
+ ok: false,
1635
+ bucket: true,
1636
+ reason: 'gh-missing',
1637
+ message: 'gh not on PATH; install from https://cli.github.com',
1638
+ };
1639
+ }
1640
+ // (2) gh auth status.
1641
+ const auth = await gh.ghAuthStatus({ host: 'github.com' });
1642
+ if (!auth.ok) {
1643
+ return {
1644
+ ok: false,
1645
+ bucket: true,
1646
+ reason: 'gh-auth',
1647
+ message: 'gh is not logged in; run `gh auth login`',
1648
+ };
1649
+ }
1650
+ // (3) repo access, visibility, hasIssuesEnabled.
1651
+ const view = await gh.ghRepoView({ repo: destination.repo });
1652
+ if (!view.ok) {
1653
+ return {
1654
+ ok: false,
1655
+ bucket: true,
1656
+ reason: 'repo-unreachable',
1657
+ message: `cannot reach ${destination.repo}: ${view.message}`,
1658
+ };
1659
+ }
1660
+ const v = view.value;
1661
+ const isPrivate = (v?.visibility && String(v.visibility).toLowerCase() === 'private')
1662
+ || destination.visibility === 'private';
1663
+ if (isPrivate && (v?.viewerPermission === null || v?.viewerPermission === undefined || String(v.viewerPermission).toUpperCase() === 'NONE')) {
1664
+ const contact = destination.publisherContact ? ` (library contact: ${destination.publisherContact})` : '';
1665
+ return {
1666
+ ok: false,
1667
+ bucket: true,
1668
+ reason: 'private-no-access',
1669
+ message: `no access to private repo ${destination.repo}${contact}`,
1670
+ };
1671
+ }
1672
+ if (v?.hasIssuesEnabled === false) {
1673
+ return {
1674
+ ok: false,
1675
+ bucket: true,
1676
+ reason: 'issues-disabled',
1677
+ message: `issues are disabled on ${destination.repo}`,
1678
+ };
1679
+ }
1680
+ return { ok: true, view: v };
1681
+ }
1682
+
1683
+ /**
1684
+ * Label pre-check: list labels on the destination once and cache the
1685
+ * `available` set of the requested six (any not-present label is
1686
+ * dropped). A gh label list failure returns null available so the
1687
+ * submit path proceeds with the requested labels (best-effort per
1688
+ * design section 4.3 note).
1689
+ *
1690
+ * @param {string} repo
1691
+ * @param {import('../feedback/gh.js').GhAdapter} gh
1692
+ */
1693
+ async function runLabelPrecheck(repo, gh) {
1694
+ const r = await gh.ghLabelList({ repo });
1695
+ if (!r.ok) return { available: null };
1696
+ const names = r.value?.names ?? [];
1697
+ return { available: names };
1698
+ }
1699
+
1700
+ async function createWithRetry(gh, opts) {
1701
+ const first = await gh.ghIssueCreate(opts);
1702
+ if (first.ok) return { ok: true, url: first.value.url, number: first.value.number };
1703
+ // Retry once without labels.
1704
+ const second = await gh.ghIssueCreate({ ...opts, labels: [] });
1705
+ if (second.ok) return { ok: true, url: second.value.url, number: second.value.number };
1706
+ return { ok: false, reason: second.message };
1707
+ }
1708
+
1709
+ async function commentWithRetry(gh, opts) {
1710
+ const first = await gh.ghIssueComment(opts);
1711
+ if (first.ok) return { ok: true, url: first.value.url };
1712
+ const second = await gh.ghIssueComment(opts);
1713
+ if (second.ok) return { ok: true, url: second.value.url };
1714
+ return { ok: false, reason: second.message };
1715
+ }
1716
+
1717
+ /**
1718
+ * Write one bundle file under `.rcf/feedback/outbox/`. Filename shape:
1719
+ * `<ISO-timestamp>-<repo-slug>.md`; unresolved destinations use
1720
+ * `<ISO-timestamp>-unresolved.md`. Returns the absolute path.
1721
+ *
1722
+ * @param {string} projectRoot
1723
+ * @param {object} destination
1724
+ * @param {Array<{ entry: object, redacted: object, fingerprint: string }>} rows
1725
+ * @param {{ generatedAt: string, rcfLiteVersion: string, reasonNotFiled?: string }} meta
1726
+ * @returns {Promise<string>}
1727
+ */
1728
+ async function writeBundle(projectRoot, destination, rows, meta) {
1729
+ const dir = outboxDir(projectRoot);
1730
+ await mkdir(dir, { recursive: true });
1731
+ const stampSafe = meta.generatedAt.replace(/[:]/g, '-');
1732
+ const slug = destination.repo
1733
+ ? destination.repo.replace(/[^A-Za-z0-9._-]+/g, '-').toLowerCase()
1734
+ : 'unresolved';
1735
+ // A per-entry fallback bundle (retry-then-fail) uses the entry id as
1736
+ // a disambiguator so two fallbacks in the same second do not
1737
+ // overwrite each other. A whole-destination bundle keeps the base
1738
+ // filename shape.
1739
+ const tail = rows.length === 1 ? `-${rows[0].entry.id}` : '';
1740
+ const filename = `${stampSafe}-${slug}${tail}.md`;
1741
+ const path = resolve(dir, filename);
1742
+ const bundleDestination = {
1743
+ repo: destination.repo,
1744
+ visibility: destination.visibility === 'private' ? 'private' : (destination.repo ? 'public' : 'unresolved'),
1745
+ reasonNotFiled: meta.reasonNotFiled,
1746
+ libraryContact: destination.publisherContact,
1747
+ };
1748
+ const text = renderBundle(bundleDestination, rows, {
1749
+ generatedAt: meta.generatedAt,
1750
+ rcfLiteVersion: meta.rcfLiteVersion,
1751
+ });
1752
+ // Design safeguard (fix round 2, AC-15601-4): a final whole-text
1753
+ // residual-secret pass over the fully assembled and normalised
1754
+ // bundle text. Each row's rendered body already passed the
1755
+ // per-entry check in buildEntryPayload, but the bundle is the
1756
+ // last surface before bytes hit disk; if renderBundle's header
1757
+ // or section framing somehow ever reintroduces a residual shape,
1758
+ // the write is refused with a fail-closed error rather than a
1759
+ // silent leak.
1760
+ const bundleResiduals = findResidualSecrets(text);
1761
+ if (bundleResiduals.length > 0) {
1762
+ const first = bundleResiduals[0];
1763
+ const err = new Error(
1764
+ `[safeguard] refusing to write bundle ${filename}: residual secret pattern ${first.pattern} at line ${first.line} (${bundleResiduals.length} hit${bundleResiduals.length === 1 ? '' : 's'}). `
1765
+ + 'This is a defence-in-depth refusal - the per-entry residual check already runs against each rendered body; a bundle-level hit means the bundle assembly re-introduced a shape and MUST be investigated before submit is re-run.',
1766
+ );
1767
+ /** @type {any} */ (err).code = 'FEEDBACK_BUNDLE_RESIDUAL';
1768
+ throw err;
1769
+ }
1770
+ await writeFile(path, text, 'utf8');
1771
+ return path;
1772
+ }
1773
+
1774
+ /**
1775
+ * Stamp a residual-secret refusal on the entry state (kept pending).
1776
+ *
1777
+ * @param {string} projectRoot
1778
+ * @param {string} id
1779
+ * @param {string} atStamp
1780
+ * @param {string} sessionId
1781
+ */
1782
+ async function markResidual(projectRoot, id, atStamp, sessionId) {
1783
+ await appendState(projectRoot, {
1784
+ id,
1785
+ at: atStamp,
1786
+ status: 'pending',
1787
+ residual: true,
1788
+ sessionId,
1789
+ });
1790
+ }
1791
+
1792
+ function at(now) {
1793
+ return now().toISOString().replace(/\.\d{3}Z$/, 'Z');
1794
+ }
1795
+
1796
+ // -- opt-in / opt-out (slice 5, FBS-184) ----------------------------------
1797
+
1798
+ const OPT_OPTIONS = /** @type {const} */ ({
1799
+ help: { type: 'boolean' },
1800
+ json: { type: 'boolean' },
1801
+ });
1802
+
1803
+ /**
1804
+ * Toggle the per-project ask via `rcf/feedback-settings.json`.
1805
+ * `opt-out` writes `ask: false`; `opt-in` writes `ask: true`. Other
1806
+ * fields (settingsVersion, quietMinutes, redaction) survive the merge
1807
+ * (design section 8). The env-driven silences (RCF_FEEDBACK_ASK,
1808
+ * RCF_FEEDBACK_DISABLE) are complementary and never toggled here.
1809
+ *
1810
+ * @param {'opt-in' | 'opt-out'} which
1811
+ * @param {string[]} argv
1812
+ * @param {object} ctx
1813
+ */
1814
+ async function handleOptToggle(which, argv, ctx) {
1815
+ const { stdout, stderr, cwd } = ctx;
1816
+ let parsed;
1817
+ try {
1818
+ parsed = parseArgs({ args: argv, options: OPT_OPTIONS, allowPositionals: false, strict: true });
1819
+ } catch (err) {
1820
+ stderr.write(`[error] usage ${err.message}\n`);
1821
+ return 2;
1822
+ }
1823
+ const flags = parsed.values;
1824
+ if (flags.help) { stdout.write(HELP); return 0; }
1825
+
1826
+ const projectRoot = await findProjectRoot(cwd);
1827
+ if (!projectRoot) {
1828
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor).\n');
1829
+ return 2;
1830
+ }
1831
+
1832
+ const ask = which === 'opt-in';
1833
+ const result = await writeFeedbackSettings(projectRoot, { ask });
1834
+ if (flags.json) {
1835
+ stdout.write(`${JSON.stringify({ action: result.action, path: 'rcf/feedback-settings.json', settings: result.settings }, null, 2)}\n`);
1836
+ return 0;
1837
+ }
1838
+ if (result.action === 'noop') {
1839
+ stdout.write(`feedback ${which}: no change (rcf/feedback-settings.json already has ask: ${ask}).\n`);
1840
+ return 0;
1841
+ }
1842
+ const verb = result.action === 'created' ? 'wrote' : 'updated';
1843
+ const posture = ask
1844
+ ? 'the harness will ask once per session at a natural pause (design 3.5).'
1845
+ : 'the harness will not ask on this project; add / preview / submit still work by hand.';
1846
+ stdout.write(`feedback ${which}: ${verb} rcf/feedback-settings.json (ask: ${ask}); ${posture}\n`);
1847
+ return 0;
1848
+ }
1849
+
1850
+ // -- hook (slice 5, FBS-184) ---------------------------------------------
1851
+
1852
+ const HOOK_OPTIONS = /** @type {const} */ ({
1853
+ harness: { type: 'string' },
1854
+ help: { type: 'boolean' },
1855
+ });
1856
+
1857
+ /**
1858
+ * Harness hook handler (design 3.5). Sub-shape:
1859
+ * rcf feedback hook <stop|session-end|session-start> [--harness <h>]
1860
+ *
1861
+ * Reads harness JSON from stdin (session_id, cwd, hook_event_name,
1862
+ * stop_hook_active, last_assistant_message on Stop) and never blocks
1863
+ * longer than 1 second (the 1.5s SessionEnd shared budget on Claude
1864
+ * Code). The store IO is small; no network is ever touched.
1865
+ *
1866
+ * Behaviour by sub-verb:
1867
+ * - stop: apply the quiet rule, and on 'ask' append the ledger
1868
+ * record BEFORE emitting so a crash between emit and reply
1869
+ * cannot cause a second ask; then emit per the harness.
1870
+ * - session-end: write a bundle of every pending entry into
1871
+ * `.rcf/feedback/outbox/`. Byte-idempotent: a re-run whose
1872
+ * rendered payload matches the last-written bundle does not
1873
+ * rewrite the file.
1874
+ * - session-start: with pending entries carried over from a
1875
+ * prior sessionId, print (or emit in the Codex shape) one
1876
+ * context line so the harness re-surfaces the ask at the next
1877
+ * natural pause (RULE 17). Never mutates state.json.
1878
+ *
1879
+ * @param {string[]} argv
1880
+ * @param {object} ctx
1881
+ */
1882
+ async function handleHook(argv, ctx) {
1883
+ const { stdout, stderr, cwd, env, now, sessionId: fallbackSession } = ctx;
1884
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
1885
+ stdout.write(HELP);
1886
+ return argv.length === 0 ? 2 : 0;
1887
+ }
1888
+ const sub = argv[0];
1889
+ const rest = argv.slice(1);
1890
+ let parsed;
1891
+ try {
1892
+ parsed = parseArgs({ args: rest, options: HOOK_OPTIONS, allowPositionals: false, strict: true });
1893
+ } catch (err) {
1894
+ stderr.write(`[error] usage ${err.message}\n`);
1895
+ return 2;
1896
+ }
1897
+ const flags = parsed.values;
1898
+ if (flags.help) { stdout.write(HELP); return 0; }
1899
+
1900
+ const harness = normaliseHarness(flags.harness, env);
1901
+ if (harness === 'unknown') {
1902
+ // The hook must always know which shape to emit; refuse rather
1903
+ // than guess. A missing --harness on a machine without either
1904
+ // env signature is a configuration bug in the installer.
1905
+ stderr.write("[error] usage --harness required (one of 'claude-code', 'codex') when neither CLAUDECODE nor CODEX_* env is set.\n");
1906
+ return 2;
1907
+ }
1908
+ if (!['stop', 'session-end', 'session-start'].includes(sub)) {
1909
+ stderr.write(`[error] usage unknown feedback hook sub-verb '${sub}' (expected stop, session-end, session-start).\n`);
1910
+ return 2;
1911
+ }
1912
+
1913
+ const payload = await readHarnessPayload(ctx);
1914
+ const projectRootHint = typeof payload?.cwd === 'string' && payload.cwd.length > 0
1915
+ ? payload.cwd
1916
+ : cwd;
1917
+ const projectRoot = await findProjectRoot(projectRootHint);
1918
+ if (!projectRoot) {
1919
+ // No project root: silently exit 0 (a hook on a non-rcf tree
1920
+ // must never block or shout at the user; design 3.5 says the
1921
+ // hook is quiet by default).
1922
+ return 0;
1923
+ }
1924
+
1925
+ const sessionIdIncoming = typeof payload?.session_id === 'string' && payload.session_id.length > 0
1926
+ ? payload.session_id
1927
+ : fallbackSession;
1928
+
1929
+ if (sub === 'session-end') {
1930
+ return runSessionEndHook(projectRoot, { now });
1931
+ }
1932
+ if (sub === 'session-start') {
1933
+ return runSessionStartHook(projectRoot, harness, sessionIdIncoming, now, { stdout });
1934
+ }
1935
+ return runStopHook(projectRoot, harness, sessionIdIncoming, payload, env, now, { stdout, stderr });
1936
+ }
1937
+
1938
+ function normaliseHarness(flag, env) {
1939
+ if (flag === 'claude-code' || flag === 'codex') return flag;
1940
+ if (flag && flag !== '') return 'unknown';
1941
+ if (env.CLAUDECODE === '1') return 'claude-code';
1942
+ for (const k of Object.keys(env)) {
1943
+ if (k.startsWith('CODEX_')) return 'codex';
1944
+ }
1945
+ return 'unknown';
1946
+ }
1947
+
1948
+ async function readHarnessPayload(ctx) {
1949
+ // The wrapper trusts the caller only to supply the payload; when
1950
+ // driven by the harness the JSON arrives on stdin. Tests inject
1951
+ // via ctx.hookStdin (a string) to avoid piping through process.
1952
+ if (typeof ctx.hookStdin === 'string' && ctx.hookStdin.length > 0) {
1953
+ try { return JSON.parse(ctx.hookStdin); } catch { return null; }
1954
+ }
1955
+ const stdin = process.stdin;
1956
+ if (!stdin || (typeof stdin.isTTY === 'boolean' && stdin.isTTY)) return null;
1957
+ const buf = await new Promise((resolvePromise) => {
1958
+ let acc = '';
1959
+ let done = false;
1960
+ const finish = () => { if (!done) { done = true; resolvePromise(acc); } };
1961
+ stdin.setEncoding('utf8');
1962
+ stdin.on('data', (chunk) => { acc += chunk; });
1963
+ stdin.on('end', finish);
1964
+ stdin.on('error', finish);
1965
+ // A 1-second cap; hooks share a small budget and must never
1966
+ // block the harness on a stalled parent.
1967
+ setTimeout(finish, 1000).unref?.();
1968
+ });
1969
+ if (!buf) return null;
1970
+ try { return JSON.parse(buf); } catch { return null; }
1971
+ }
1972
+
1973
+ async function runStopHook(projectRoot, harness, sessionId, payload, env, now, streams) {
1974
+ const settingsRead = await readFeedbackSettings(projectRoot);
1975
+ const fileOptOut = settingsRead.settings.ask === false;
1976
+ const envOptOut = env.RCF_FEEDBACK_ASK === '0' || env.RCF_FEEDBACK_DISABLE === '1';
1977
+ const optedOut = fileOptOut || envOptOut;
1978
+
1979
+ // AC-16103-3: deferred entries from an earlier sessionId return to
1980
+ // pending here so a defer taken in session A is asked again in
1981
+ // session B whether or not the SessionStart hook fired first.
1982
+ await requeueDeferredForSession(projectRoot, sessionId, now);
1983
+
1984
+ const all = await readEntries(projectRoot);
1985
+ const pending = all.filter((e) => e.status === 'pending');
1986
+ const pendingCount = pending.length;
1987
+
1988
+ const ledger = await readAskLedger(projectRoot);
1989
+ const askedThisSession = Boolean(sessionId)
1990
+ && Array.isArray(ledger.asked)
1991
+ && ledger.asked.some((r) => r?.sessionId === sessionId);
1992
+
1993
+ const stopHookActive = payload && (payload.stop_hook_active === true
1994
+ || payload.stopHookActive === true);
1995
+
1996
+ const nowMs = now().getTime();
1997
+ const ages = pending
1998
+ .map((e) => Date.parse(e.recordedAt ?? ''))
1999
+ .filter((t) => Number.isFinite(t))
2000
+ .map((t) => nowMs - t);
2001
+ const newestPendingAgeMs = ages.length === 0 ? null : Math.min(...ages);
2002
+ const anyAskNow = pending.some((e) => e.askNow === true);
2003
+ const anyCarriedOver = Boolean(sessionId) && pending.some((e) => {
2004
+ const s = typeof e.sessionId === 'string' ? e.sessionId : '';
2005
+ return s !== '' && s !== sessionId;
2006
+ });
2007
+ const queueComplete = typeof ledger.queueStateAt === 'string' && ledger.queueStateAt.length > 0;
2008
+
2009
+ const decision = shouldAsk({
2010
+ optedOut,
2011
+ pendingCount,
2012
+ askedThisSession,
2013
+ stopHookActive,
2014
+ anyAskNow,
2015
+ newestPendingAgeMs,
2016
+ quietMinutes: settingsRead.settings.quietMinutes,
2017
+ queueComplete,
2018
+ anyCarriedOver,
2019
+ });
2020
+
2021
+ if (decision === 'ask' && sessionId) {
2022
+ // Ledger first, then emit (design 3.5 crash-safety clause).
2023
+ const askedAt = now().toISOString().replace(/\.\d{3}Z$/, 'Z');
2024
+ const next = {
2025
+ ...ledger,
2026
+ asked: [...(Array.isArray(ledger.asked) ? ledger.asked : []), { sessionId, askedAt }],
2027
+ };
2028
+ await writeAskLedger(projectRoot, next);
2029
+ }
2030
+ const reason = buildAskReason(pendingCount);
2031
+ return emitHook(harness, decision, reason, streams);
2032
+ }
2033
+
2034
+ async function runSessionStartHook(projectRoot, harness, sessionId, now, streams) {
2035
+ // AC-16103-3: deferred entries from an earlier sessionId return to
2036
+ // pending on SessionStart so the same session's Stop offers the
2037
+ // ask (design 3.1: defer = "not now", ask again next session).
2038
+ // The transition preserves the entry's prior sessionId so the
2039
+ // carry-over gate below still fires for the requeued entries.
2040
+ await requeueDeferredForSession(projectRoot, sessionId, now);
2041
+
2042
+ const all = await readEntries(projectRoot);
2043
+ const pending = all.filter((e) => e.status === 'pending');
2044
+ const anyCarriedOver = pending.some((e) => {
2045
+ const s = typeof e.sessionId === 'string' ? e.sessionId : '';
2046
+ // Treat every pending entry as carried-over when we do not yet
2047
+ // have a sessionId to compare against (a first SessionStart on
2048
+ // a new session; the recorded sessionId is by definition prior).
2049
+ return s !== '' && (!sessionId || s !== sessionId);
2050
+ });
2051
+ if (!anyCarriedOver) return 0;
2052
+ const line = buildCarryOverLine(pending.length);
2053
+ return emitSessionStart(harness, line, streams);
2054
+ }
2055
+
2056
+ async function runSessionEndHook(projectRoot, { now }) {
2057
+ const all = await readEntries(projectRoot);
2058
+ const pending = all.filter((e) => e.status === 'pending');
2059
+ if (pending.length === 0) return 0;
2060
+ // Compose a fingerprint-only bundle text (idempotency check reads
2061
+ // the last-written file and compares by content hash). We do not
2062
+ // change entry state on session-end; slice 4's `submit` remains
2063
+ // the terminal step. This is a safety flush of the exact set of
2064
+ // pending titles so a crashed session's findings are pastable.
2065
+ const stampSafe = now().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[:]/g, '-');
2066
+ const dir = outboxDir(projectRoot);
2067
+ await mkdir(dir, { recursive: true });
2068
+ const filename = `${stampSafe}-session-end.md`;
2069
+ const path = resolve(dir, filename);
2070
+ const body = renderSessionEndBundle(pending, now());
2071
+ // Idempotency: two consecutive runs with the same pending set MUST
2072
+ // produce a single file. The only field that legitimately differs
2073
+ // between the two bodies is the `Generated:` timestamp; a naive
2074
+ // byte-compare therefore flaked whenever the two calls straddled a
2075
+ // second boundary (AC-16103-1 intermittent failure). Compare on a
2076
+ // canonical form that stamps the timestamp with a fixed placeholder,
2077
+ // so the byte-idempotency check honours the design intent (same
2078
+ // pending set = same bundle) regardless of wall-clock drift.
2079
+ const canon = (s) => s.replace(/^Generated: [^\n]*$/m, 'Generated: <stamp>');
2080
+ const bodyCanon = canon(body);
2081
+ try {
2082
+ const entries = await readdir(dir);
2083
+ for (const f of entries) {
2084
+ if (!f.endsWith('-session-end.md')) continue;
2085
+ try {
2086
+ const existing = await readFile(resolve(dir, f), 'utf8');
2087
+ if (canon(existing) === bodyCanon) return 0;
2088
+ } catch { /* ignore */ }
2089
+ }
2090
+ } catch { /* ignore */ }
2091
+ await writeFile(path, body, 'utf8');
2092
+ return 0;
2093
+ }
2094
+
2095
+ function renderSessionEndBundle(pending, when) {
2096
+ const stamp = when.toISOString().replace(/\.\d{3}Z$/, 'Z');
2097
+ const lines = [];
2098
+ lines.push('# rcf feedback: session-end pending bundle');
2099
+ lines.push('');
2100
+ lines.push(`Generated: ${stamp}`);
2101
+ lines.push(`Entries pending on this project at session end: ${pending.length}`);
2102
+ lines.push('');
2103
+ lines.push('The next session offers to send these on Stop (design 3.5).');
2104
+ lines.push('This bundle is a local reminder; no issue was filed.');
2105
+ lines.push('');
2106
+ for (const e of pending) {
2107
+ lines.push(`## ${e.id}: ${e.title ?? '(no title)'}`);
2108
+ lines.push('');
2109
+ lines.push(`- kind: ${e.kind ?? '?'}`);
2110
+ lines.push(`- target: ${e?.target?.ref ?? '?'}`);
2111
+ lines.push(`- anchor: ${e.anchor ?? '-'}`);
2112
+ lines.push(`- class: ${e.symptomClass ?? '?'}`);
2113
+ lines.push(`- severity: ${e.severity ?? '?'}`);
2114
+ if (e.fingerprint) lines.push(`- fingerprint: ${e.fingerprint}`);
2115
+ lines.push('');
2116
+ }
2117
+ return lines.join('\n');
2118
+ }
2119
+
2120
+
2121
+ // -- helpers ---------------------------------------------------------------
2122
+
2123
+ function inferHarness(env) {
2124
+ // F-slice-1-05: design 3.6 pins CLAUDECODE=1 and returns `unknown`
2125
+ // when neither harness leaves a signature. `other` is a legitimate
2126
+ // OPERATOR-declared value on --harness; the auto-fallback must not
2127
+ // conflate "unidentified" with a declared choice.
2128
+ if (env.CLAUDECODE === '1') return 'claude-code';
2129
+ for (const k of Object.keys(env)) {
2130
+ if (k.startsWith('CODEX_')) return 'codex';
2131
+ }
2132
+ return 'unknown';
2133
+ }
2134
+
2135
+ /**
2136
+ * F-slice-1-09: sweep discarded entries older than the design 3.1
2137
+ * 30-day window. A `pruned` state line is appended for each expiring
2138
+ * entry so the reader's fold-by-id drops it from list/count surfaces
2139
+ * on the next read; the historical JSONL rows stay untouched (append-
2140
+ * only invariant). Best-effort: failures never break the caller.
2141
+ *
2142
+ * @param {string} projectRoot
2143
+ * @param {() => Date} nowFn
2144
+ * @param {number} [windowDays]
2145
+ */
2146
+ async function pruneOldDiscarded(projectRoot, nowFn, windowDays = 30) {
2147
+ try {
2148
+ const all = await readEntries(projectRoot);
2149
+ const discardedAt = await readDiscardTimestamps(projectRoot);
2150
+ const now = typeof nowFn === 'function' ? nowFn() : new Date();
2151
+ const cutoff = now.getTime() - windowDays * 24 * 60 * 60 * 1000;
2152
+ for (const e of all) {
2153
+ if (e.status !== 'discarded') continue;
2154
+ // F-slice-1-09 fix: the 30-day clock runs from the discard
2155
+ // transition, not `recordedAt`. Design 3.1 L137 says "30 days
2156
+ // after discard"; the earlier reading would prune an entry
2157
+ // discarded today just because it was recorded 60 days ago.
2158
+ // If we cannot find a discard timestamp (a hand-authored
2159
+ // entry seeded with status:discarded, for instance), we err
2160
+ // on the safe side and keep the entry.
2161
+ const discardTs = discardedAt.get(e.id);
2162
+ if (typeof discardTs !== 'string') continue;
2163
+ const t = Date.parse(discardTs);
2164
+ if (!Number.isFinite(t) || t > cutoff) continue;
2165
+ // The state line is the only trace after prune; the raw body
2166
+ // stays visible in the fold-by-id read so `list --all` (below)
2167
+ // can still cite what was there and why it went. Never a silent
2168
+ // removal.
2169
+ await appendState(projectRoot, {
2170
+ id: e.id,
2171
+ at: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
2172
+ status: 'pruned',
2173
+ });
2174
+ }
2175
+ } catch (err) {
2176
+ if (process.env.RCF_FEEDBACK_TRACE_PRUNE === '1') {
2177
+ process.stderr.write(`[prune] failed: ${err.message}\n`);
2178
+ }
2179
+ }
2180
+ }
2181
+
2182
+ async function readOwnVersion() {
2183
+ try {
2184
+ const pkg = JSON.parse(await readFile(resolve(PACKAGE_ROOT, 'package.json'), 'utf8'));
2185
+ return pkg.version ?? '0.0.0';
2186
+ } catch {
2187
+ return '0.0.0';
2188
+ }
2189
+ }
2190
+
2191
+ /**
2192
+ * Lookup a blueprint entry on `rcf/manifest.json:blueprints[]` by ref.
2193
+ * The lookup accepts a shelf slug (`security-auth-magic-link`), a
2194
+ * prefix:slug shape (`wsd:std-error-envelope`) and an effective slug
2195
+ * (`wsd-std-error-envelope`). Slice 3 lands the full resolver; slice 1
2196
+ * just needs the yes/no answer for the exit-3 branch.
2197
+ *
2198
+ * @param {string} projectRoot
2199
+ * @param {string} ref
2200
+ * @returns {Promise<null | { slug: string, version?: string | null, libraryPrefix?: string | null, libraryRef?: string | null, pin?: object }>}
2201
+ */
2202
+ async function lookupBlueprintRecord(projectRoot, ref) {
2203
+ try {
2204
+ const manifest = JSON.parse(await readFile(resolve(projectRoot, 'rcf', 'manifest.json'), 'utf8'));
2205
+ // Delegate to the resolver's own findBlueprintRecord so the
2206
+ // qualified-ref safety (F-slice-3-01) applies here too; slice-1
2207
+ // stamping and slice-3 destination resolution walk the same
2208
+ // matcher, so a qualified WSD ref never picks up a shelf record
2209
+ // by slug collision.
2210
+ return findBlueprintRecord(manifest, ref);
2211
+ } catch { /* fall through to null */ }
2212
+ return null;
2213
+ }