forge-workflow 0.1.0-beta.5 → 0.1.0-beta.7

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
@@ -21,12 +21,13 @@
21
21
  */
22
22
 
23
23
  const path = require('node:path');
24
+ const { stripGlobalFlags } = require('../global-flags');
24
25
  const { setConfigOverride } = require('../config-writer');
25
26
  const { getDefaultRuntimeGraph, getResolvedRuntimeGraph } = require('../core/runtime-graph');
26
27
  const {
27
28
  recordGateEvent,
28
29
  listGateEvents,
29
- isGateApproved,
30
+ evaluateGateApproval,
30
31
  } = require('../gate-events');
31
32
 
32
33
  // The doc-update gate folds under this noun as `gate doc` (P2, kernel issue
@@ -38,14 +39,138 @@ const docGate = require('./doc-gate');
38
39
 
39
40
  const TOGGLE_ACTIONS = new Set(['enable', 'disable']);
40
41
  const EVENT_ACTIONS = new Set(['approve', 'reject', 'status', 'check']);
42
+ const MAX_GATE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
43
+ const GATE_JSON_SCHEMA = 'forge.gate.v1';
41
44
 
42
45
  function usage() {
43
46
  return [
44
- 'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--reason <text>] [--json]',
47
+ 'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--ttl <duration>] [--reason <text>] [--json]',
45
48
  ' forge gate doc <detect|check|init|okf|...> [args] (doc-update gate; = forge doc-gate, run `forge doc-gate --help`)',
46
49
  ].join('\n');
47
50
  }
48
51
 
52
+ function parseGateTtl(raw, issuedAt) {
53
+ if (raw === undefined) return { ok: true, expiresAt: null, requestIdentity: null };
54
+ const match = /^(\d+)(s|m|h|d)$/.exec(String(raw));
55
+ if (!match) return { ok: false, error: 'TTL must be a positive duration such as 30s, 5m, 2h, or 1d.' };
56
+ const multipliers = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
57
+ const ttlMs = Number(match[1]) * multipliers[match[2]];
58
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_GATE_TTL_MS) {
59
+ return { ok: false, error: 'TTL must be greater than zero and no more than 30d.' };
60
+ }
61
+ const issuedMillis = Date.parse(issuedAt);
62
+ if (!Number.isFinite(issuedMillis)) return { ok: false, error: 'Cannot issue a TTL approval without a valid clock.' };
63
+ const expiresMillis = issuedMillis + ttlMs;
64
+ if (!Number.isFinite(expiresMillis)) return { ok: false, error: 'TTL expiry is outside the supported date range.' };
65
+ const expiryDate = new Date(expiresMillis);
66
+ if (!Number.isFinite(expiryDate.getTime())) {
67
+ return { ok: false, error: 'TTL expiry is outside the supported date range.' };
68
+ }
69
+ return {
70
+ ok: true,
71
+ expiresAt: expiryDate.toISOString(),
72
+ requestIdentity: `ttl-ms=${ttlMs}`,
73
+ };
74
+ }
75
+
76
+ function parseDecisionInput(action, rawArgs, flags) {
77
+ const issueId = rawArgs[0];
78
+ const gateId = rawArgs[1];
79
+ if (!issueId || !gateId || issueId.startsWith('--') || gateId.startsWith('--')) {
80
+ return { ok: false, error: `Missing issue id or gate id.\n${usage()}` };
81
+ }
82
+ if (flags.project !== undefined || flags['--project'] !== undefined) {
83
+ return { ok: false, error: 'Gate approvals are issue-scoped; --project is not supported.' };
84
+ }
85
+
86
+ let ttl = flags.ttl ?? flags['--ttl'];
87
+ let reason = flags.reason ?? flags['--reason'];
88
+ const seen = new Set();
89
+ for (let index = 2; index < rawArgs.length; index += 1) {
90
+ const token = rawArgs[index];
91
+ if (token === '--json') {
92
+ if (seen.has('json')) return { ok: false, error: 'Duplicate --json flag.' };
93
+ seen.add('json');
94
+ continue;
95
+ }
96
+ const match = /^(--ttl|--reason)(?:=(.*))?$/.exec(token);
97
+ if (!match) {
98
+ const kind = token.startsWith('--') ? 'Unknown flag' : 'Unexpected argument';
99
+ return { ok: false, error: `${kind} '${token}'.\n${usage()}` };
100
+ }
101
+ const name = match[1].slice(2);
102
+ if (seen.has(name)) return { ok: false, error: `Duplicate --${name} flag.` };
103
+ seen.add(name);
104
+ let value = match[2];
105
+ if (value === undefined) {
106
+ value = rawArgs[index + 1];
107
+ if (value === undefined || value.startsWith('--')) {
108
+ return { ok: false, error: `--${name} requires a value.` };
109
+ }
110
+ index += 1;
111
+ }
112
+ if (name === 'ttl') ttl = value;
113
+ if (name === 'reason') reason = value;
114
+ }
115
+ if (action !== 'approve' && ttl !== undefined) {
116
+ return { ok: false, error: '--ttl is valid only for gate approval.' };
117
+ }
118
+ return { ok: true, issueId, gateId, ttl, reason };
119
+ }
120
+
121
+ function parseReadInput(action, rawArgs) {
122
+ const operandCount = action === 'status' ? 1 : 2;
123
+ const issueId = rawArgs[0];
124
+ const gateId = operandCount === 2 ? rawArgs[1] : undefined;
125
+ if (!issueId || issueId.startsWith('--') || (operandCount === 2 && (!gateId || gateId.startsWith('--')))) {
126
+ const missing = action === 'status' ? 'issue id' : 'issue id or gate id';
127
+ return { ok: false, error: `Missing ${missing}.\n${usage()}` };
128
+ }
129
+
130
+ let jsonSeen = false;
131
+ for (const token of rawArgs.slice(operandCount)) {
132
+ if (token === '--json') {
133
+ if (jsonSeen) return { ok: false, error: 'Duplicate --json flag.' };
134
+ jsonSeen = true;
135
+ continue;
136
+ }
137
+ const kind = token.startsWith('--') ? 'Unknown flag' : 'Unexpected argument';
138
+ return { ok: false, error: `${kind} '${token}'.\n${usage()}` };
139
+ }
140
+ return { ok: true, issueId, gateId };
141
+ }
142
+
143
+ function gateJsonResult(action, result, context = {}) {
144
+ const event = result.event ?? null;
145
+ const envelope = {
146
+ schema_version: GATE_JSON_SCHEMA,
147
+ command: `gate.${action}`,
148
+ ok: result.success === true,
149
+ issue_id: event?.issue_id ?? result.issueId ?? context.issueId ?? null,
150
+ };
151
+ const gateId = event?.gate ?? context.gateId;
152
+ if (action !== 'status' && gateId !== undefined && gateId !== null) {
153
+ envelope.gate_id = gateId;
154
+ }
155
+ if (action === 'approve' || action === 'reject') {
156
+ envelope.duplicate = result.duplicate === true;
157
+ envelope.decision = event?.decision ?? (action === 'approve' ? 'approved' : 'rejected');
158
+ envelope.event = event;
159
+ }
160
+ if (action === 'status') {
161
+ envelope.issue = envelope.issue_id;
162
+ envelope.events = result.events ?? [];
163
+ }
164
+ if (action === 'check') {
165
+ envelope.approved = result.approved === true;
166
+ envelope.satisfied = result.success === true;
167
+ envelope.disabled = result.disabled === true;
168
+ envelope.event = event;
169
+ }
170
+ if (result.error) envelope.error = result.error;
171
+ return { ...result, output: JSON.stringify(envelope, null, 2) };
172
+ }
173
+
49
174
  // The known-toggle set is gates PLUS unlocked toggleable rails (e.g.
50
175
  // rail.kernel_tracking): both are governed through the same `forge gate
51
176
  // enable|disable` surface and the resolver's rail-aware workflow.gates loop.
@@ -96,22 +221,27 @@ function handleToggle(action, gateId, projectRoot) {
96
221
  };
97
222
  }
98
223
 
99
- async function handleDecision(action, issueId, gateId, flags, projectRoot, opts) {
100
- if (!issueId || !gateId) {
101
- return { success: false, error: `Missing issue id or gate id.\n${usage()}` };
102
- }
224
+ async function handleDecision(action, input, projectRoot, opts) {
225
+ if (!input.ok) return { success: false, error: input.error };
226
+ const { issueId, gateId } = input;
103
227
  const known = validateKnownGate(gateId);
104
228
  if (!known.ok) return { success: false, error: known.error };
105
229
 
106
230
  const decision = action === 'approve' ? 'approved' : 'rejected';
107
- const reason = typeof flags.reason === 'string' ? flags.reason : undefined;
231
+ const reason = typeof input.reason === 'string' ? input.reason : undefined;
232
+ const issuedAt = opts.now || new Date().toISOString();
233
+ const expiry = parseGateTtl(input.ttl, issuedAt);
234
+ if (!expiry.ok) return { success: false, error: expiry.error };
108
235
  const result = await recordGateEvent(projectRoot, {
109
236
  issueId,
110
237
  gateId,
111
238
  decision,
112
239
  reason,
240
+ expiresAt: expiry.expiresAt,
241
+ requestIdentity: expiry.requestIdentity,
113
242
  env: opts.env,
114
243
  deps: kernelDeps(opts),
244
+ now: issuedAt,
115
245
  });
116
246
 
117
247
  if (result.issueMissing) {
@@ -125,29 +255,26 @@ async function handleDecision(action, issueId, gateId, flags, projectRoot, opts)
125
255
  success: true,
126
256
  duplicate: result.duplicate === true,
127
257
  actor: result.actor,
128
- output: `${verb} gate '${gateId}' for ${issueId} (actor ${result.actor})${reasonNote}${suffix}`,
258
+ event: result.event,
259
+ output: `${verb} gate '${gateId}' for ${issueId} (control ${result.event.control_id}; actor ${result.actor}; issued ${result.event.issued_at}; expires ${result.event.expires_at || 'never'})${reasonNote}${suffix}`,
129
260
  };
130
261
  }
131
262
 
132
- async function handleStatus(issueId, flags, projectRoot, opts) {
263
+ async function handleStatus(issueId, _flags, projectRoot, opts) {
133
264
  if (!issueId) {
134
265
  return { success: false, error: `Missing issue id.\n${usage()}` };
135
266
  }
136
267
  const events = await listGateEvents(projectRoot, issueId, { deps: kernelDeps(opts) });
137
268
 
138
- if (flags.json) {
139
- return { success: true, output: JSON.stringify({ issue: issueId, events }, null, 2) };
140
- }
141
-
142
269
  if (events.length === 0) {
143
- return { success: true, output: `No gate events for ${issueId}.` };
270
+ return { success: true, issueId, events, output: `No gate events for ${issueId}.` };
144
271
  }
145
272
  const lines = events.map(event => {
146
273
  const label = event.event_type === 'gate.approved' ? 'APPROVED' : 'REJECTED';
147
274
  const reason = event.reason ? ` — ${event.reason}` : '';
148
- return `${label} ${event.gate} by ${event.actor} at ${event.created_at}${reason}`;
275
+ return `${label} ${event.gate} for ${event.issue_id} (${event.control_id}) by ${event.actor} at ${event.issued_at}; expires ${event.expires_at || 'never'}${reason}`;
149
276
  });
150
- return { success: true, output: `Gate events for ${issueId}:\n${lines.join('\n')}` };
277
+ return { success: true, issueId, events, output: `Gate events for ${issueId}:\n${lines.join('\n')}` };
151
278
  }
152
279
 
153
280
  async function handleCheck(issueId, gateId, projectRoot, opts) {
@@ -162,18 +289,42 @@ async function handleCheck(issueId, gateId, projectRoot, opts) {
162
289
  const resolved = getResolvedRuntimeGraph({ projectRoot });
163
290
  const resolvedGate = [...resolved.gates, ...resolved.rails].find(gate => gate.id === gateId);
164
291
  if (resolvedGate && resolvedGate.enabled === false) {
165
- return { success: true, output: `gate ${gateId} is disabled — satisfied for ${issueId}` };
292
+ return {
293
+ success: true,
294
+ issueId,
295
+ approved: false,
296
+ disabled: true,
297
+ output: `gate ${gateId} is disabled — satisfied for ${issueId}`,
298
+ };
166
299
  }
167
300
 
168
- const approved = await isGateApproved(projectRoot, issueId, gateId, { deps: kernelDeps(opts) });
169
- if (approved) {
170
- return { success: true, output: `gate ${gateId} approved for ${issueId}` };
301
+ const events = await listGateEvents(projectRoot, issueId, { deps: kernelDeps(opts) });
302
+ const decision = evaluateGateApproval(events, gateId, opts.now);
303
+ const event = decision.event;
304
+ if (decision.approved) {
305
+ return {
306
+ success: true,
307
+ event,
308
+ approved: true,
309
+ output: `gate ${gateId} approved for ${issueId} (control ${event.control_id}; actor ${event.actor}; decision ${event.decision}; issued ${event.issued_at}; expires ${event.expires_at || 'never'})`,
310
+ };
171
311
  }
172
- return { success: false, error: `gate ${gateId} not approved for ${issueId}` };
312
+ const evidence = event
313
+ ? ` (control ${event.control_id}; actor ${event.actor}; decision ${event.decision}; issued ${event.issued_at}; expires ${event.expires_at || 'never'})`
314
+ : '';
315
+ return {
316
+ success: false,
317
+ event,
318
+ issueId,
319
+ approved: false,
320
+ error: `gate ${gateId} not approved for ${issueId}${evidence}`,
321
+ };
173
322
  }
174
323
 
175
324
  async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {}) {
176
- const [action, ...rest] = args;
325
+ const normalizedArgs = stripGlobalFlags(args);
326
+ const [action, ...rest] = normalizedArgs;
327
+ const wantsJson = flags.json === true || flags['--json'] === true || normalizedArgs.includes('--json');
177
328
 
178
329
  // `gate doc [<doc-gate sub> ...]` → the standalone doc-gate handler, with the
179
330
  // consumed `doc` token dropped so its own arg shape (detect/check/init/okf …)
@@ -186,13 +337,28 @@ async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {})
186
337
  return handleToggle(action, rest[0], projectRoot);
187
338
  }
188
339
  if (EVENT_ACTIONS.has(action)) {
340
+ let result;
341
+ let input;
189
342
  if (action === 'status') {
190
- return handleStatus(rest[0], flags, projectRoot, opts);
191
- }
192
- if (action === 'check') {
193
- return handleCheck(rest[0], rest[1], projectRoot, opts);
343
+ input = parseReadInput(action, rest);
344
+ result = input.ok
345
+ ? await handleStatus(input.issueId, flags, projectRoot, opts)
346
+ : { success: false, issueId: rest[0], error: input.error };
347
+ } else if (action === 'check') {
348
+ input = parseReadInput(action, rest);
349
+ result = input.ok
350
+ ? await handleCheck(input.issueId, input.gateId, projectRoot, opts)
351
+ : { success: false, issueId: rest[0], approved: false, error: input.error };
352
+ } else {
353
+ input = parseDecisionInput(action, rest, flags);
354
+ result = await handleDecision(action, input, projectRoot, opts);
194
355
  }
195
- return handleDecision(action, rest[0], rest[1], flags, projectRoot, opts);
356
+ const context = input.ok
357
+ ? { issueId: input.issueId, ...(input.gateId ? { gateId: input.gateId } : {}) }
358
+ : { issueId: rest[0] };
359
+ return wantsJson
360
+ ? gateJsonResult(action, result, context)
361
+ : result;
196
362
  }
197
363
 
198
364
  return {
@@ -206,4 +372,8 @@ module.exports = {
206
372
  description: 'Toggle a workflow gate, or record/query human-gate approval events',
207
373
  usage: usage(),
208
374
  handler,
375
+ parseGateTtl,
376
+ parseDecisionInput,
377
+ parseReadInput,
378
+ gateJsonResult,
209
379
  };
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+ const { constants } = require('node:os');
5
+ const spawn = require('cross-spawn');
6
+ const {
7
+ createGithubContext, prepareGithubAccount, readGithubAccount,
8
+ writeGithubAccount, unsetGithubAccount, validateGithubLogin,
9
+ } = require('../github-context');
10
+ const { stripGlobalFlags } = require('../global-flags');
11
+ const { defaultResolveLocalRepository } = require('./merge');
12
+
13
+ const usage = 'forge github use <login> | status [--json] | unset | run -- <program> [args...]';
14
+ const ERROR_MESSAGES = {
15
+ INVALID_GITHUB_LOGIN: 'GitHub login must be 1-39 letters, numbers, or internal hyphens.',
16
+ GIT_CONFIG_FAILED: 'Unable to access this clone-local GitHub account binding.',
17
+ GITHUB_NOT_AUTHENTICATED: 'Account is not authenticated. Run gh auth login --hostname github.com for the account, then retry.',
18
+ GITHUB_AUTH_FAILED: 'Unable to verify the account. Run gh auth login --hostname github.com, then retry.',
19
+ GITHUB_CLI_UNSUPPORTED: 'Upgrade GitHub CLI to support gh auth token --hostname github.com --user <login>.',
20
+ GITHUB_ACCOUNT_MISMATCH: 'The authenticated GitHub account does not match this clone binding.',
21
+ GITHUB_COMMAND_FAILED: 'The selected account cannot view this repository. Check repository permissions and organization SSO.',
22
+ GITHUB_REPOSITORY_INVALID: 'Origin must identify a GitHub.com repository. Check its URL and SSH alias configuration.',
23
+ };
24
+
25
+ function safeFailure(error) {
26
+ const code = Object.hasOwn(ERROR_MESSAGES, error?.code) ? error.code : 'GITHUB_OPERATION_FAILED';
27
+ return { success: false, code, error: ERROR_MESSAGES[code] || 'GitHub account operation failed. Run forge github status for diagnostics.' };
28
+ }
29
+
30
+ function isGithubOrigin(remote, root, options) {
31
+ try {
32
+ const scpHost = /^[A-Za-z]:/.test(remote) || /^(?:git|https?|ssh|file):/i.test(remote) ? null
33
+ : /^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9][A-Za-z0-9._-]*):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.exec(remote);
34
+ const parsed = scpHost ? null : new URL(remote);
35
+ if (parsed && !['https:', 'ssh:'].includes(parsed.protocol)) return false;
36
+ const host = scpHost ? scpHost[1] : parsed.hostname;
37
+ if (host.toLowerCase() === 'github.com') return true;
38
+ if ((!scpHost && parsed.protocol !== 'ssh:') || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host)) return false;
39
+ // Resolve configured SSH aliases locally; -G does not open a connection.
40
+ const run = options.runner || execFileSync;
41
+ const config = String(run('ssh', ['-G', host], {
42
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000,
43
+ }));
44
+ return /^hostname\s+github\.com\s*$/im.test(config);
45
+ } catch { return false; }
46
+ }
47
+
48
+ function repositoryAccess(context, root, options) {
49
+ const remote = readGit(root, ['remote', 'get-url', 'origin'], options);
50
+ const repository = defaultResolveLocalRepository({ projectRoot: root, git: () => remote });
51
+ if (!repository || !isGithubOrigin(remote, root, options)) {
52
+ throw Object.assign(new Error(ERROR_MESSAGES.GITHUB_REPOSITORY_INVALID), { code: 'GITHUB_REPOSITORY_INVALID' });
53
+ }
54
+ context.runGh(['repo', 'view', `github.com/${repository}`, '--json', 'nameWithOwner'], { cwd: root, timeout: 30000 });
55
+ }
56
+
57
+ function readGit(root, args, options) {
58
+ try {
59
+ const run = options.runner || execFileSync;
60
+ return String(run('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000 }) || '').trim();
61
+ } catch { return ''; }
62
+ }
63
+
64
+ function boundedText(value) {
65
+ // Author fields are display data, never terminal control sequences.
66
+ return value.replace(/\p{Cc}/gu, '').slice(0, 160) || null;
67
+ }
68
+
69
+ function transportOf(remote) {
70
+ if (!remote) return 'missing';
71
+ if (/^https:\/\//i.test(remote)) return 'https';
72
+ if (/^http:\/\//i.test(remote)) return 'http';
73
+ if (/^ssh:\/\//i.test(remote)
74
+ || (!/^[A-Za-z]:/.test(remote) && !/^(?:git|https?|ssh|file):/i.test(remote)
75
+ && /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*:[^\\/\s][^\s]*$/.test(remote))) return 'ssh';
76
+ return 'other';
77
+ }
78
+
79
+ function helperOf(helper) {
80
+ if (!helper) return 'none';
81
+ if (/\bgh(?:\.exe)?\s+auth\s+git-credential\b/i.test(helper)) return 'github-cli';
82
+ if (/\bmanager(?:-core)?\b/i.test(helper)) return 'credential-manager';
83
+ return 'other';
84
+ }
85
+
86
+ function localDiagnostics(root, options) {
87
+ return {
88
+ author: {
89
+ name: boundedText(readGit(root, ['config', '--get', 'user.name'], options)),
90
+ email: boundedText(readGit(root, ['config', '--get', 'user.email'], options)),
91
+ },
92
+ transport: transportOf(readGit(root, ['remote', 'get-url', 'origin'], options)),
93
+ credentialHelper: helperOf(readGit(root, ['config', '--get-all', 'credential.helper'], options)),
94
+ };
95
+ }
96
+
97
+ function renderStatus(status) {
98
+ const lines = [
99
+ `GitHub: ${status.state}`,
100
+ `Clone account: ${status.account || 'not selected'}`,
101
+ `Verified login: ${status.login || 'unavailable'}`,
102
+ `Commit author: ${status.author.name || 'unset'} <${status.author.email || 'unset'}>`,
103
+ `Origin transport: ${status.transport}; credential helper: ${status.credentialHelper}`,
104
+ ];
105
+ if (status.error) lines.push(status.error);
106
+ if (status.state === 'unbound') lines.push('Optional: authenticate with gh auth login, then select an account with forge github use <login>.');
107
+ if (status.transport === 'ssh') lines.push('SSH key selection is controlled by your SSH configuration.');
108
+ if (status.transport === 'https' && status.credentialHelper === 'github-cli') {
109
+ lines.push('HTTPS uses the GitHub CLI helper; a launched session may also use its selected token for Git transport.');
110
+ } else if (status.transport === 'https') {
111
+ lines.push('HTTPS Git credentials are selected separately by your credential helper.');
112
+ }
113
+ return lines.join('\n');
114
+ }
115
+
116
+ function accountStatus(root, options) {
117
+ const status = { state: 'unbound', account: null, source: null, login: null, repositoryAccess: null, ...localDiagnostics(root, options) };
118
+ try {
119
+ const account = readGithubAccount(root, options);
120
+ if (!account) return status;
121
+ status.source = 'clone-local';
122
+ status.account = validateGithubLogin(account) ? account : null;
123
+ const context = prepareGithubAccount(root, account, options);
124
+ status.login = context.login;
125
+ status.repositoryAccess = false;
126
+ repositoryAccess(context, root, options);
127
+ status.repositoryAccess = true;
128
+ status.state = 'ready';
129
+ } catch (error) {
130
+ const failure = safeFailure(error);
131
+ status.state = status.login ? 'no_repository_access' : 'unauthenticated';
132
+ if (failure.code === 'GITHUB_ACCOUNT_MISMATCH') {
133
+ status.state = 'mismatch';
134
+ const match = /^GitHub account mismatch: expected [A-Za-z0-9-]+, received ([A-Za-z0-9-]+)\.$/.exec(error.message);
135
+ status.login = match && validateGithubLogin(match[1]) ? match[1] : null;
136
+ }
137
+ status.code = failure.code;
138
+ status.error = failure.error;
139
+ }
140
+ return status;
141
+ }
142
+
143
+ function launchFailure(error) {
144
+ if (error?.code === 'ENOENT') return { success: false, code: 'ENOENT', exitCode: 127, error: 'Child program was not found. Check its installation and PATH.' };
145
+ if (error?.code === 'EACCES') return { success: false, code: 'EACCES', exitCode: 126, error: 'Child program is not executable.' };
146
+ return { success: false, code: 'CHILD_LAUNCH_FAILED', exitCode: 1, error: 'Unable to launch the child program.' };
147
+ }
148
+
149
+ function launch(context, program, args, root, options) {
150
+ return new Promise(resolve => {
151
+ let child;
152
+ try {
153
+ child = context.runChild(program, args, { cwd: root, stdio: 'inherit', shell: false });
154
+ } catch (error) { resolve(launchFailure(error)); return; }
155
+ const signals = options.signalSource || process;
156
+ const forwardInt = () => child.kill('SIGINT');
157
+ const forwardTerm = () => child.kill('SIGTERM');
158
+ signals.on('SIGINT', forwardInt);
159
+ signals.on('SIGTERM', forwardTerm);
160
+ const finish = result => {
161
+ signals.removeListener('SIGINT', forwardInt);
162
+ signals.removeListener('SIGTERM', forwardTerm);
163
+ resolve(result);
164
+ };
165
+ child.once('error', error => finish(launchFailure(error)));
166
+ child.once('close', (code, signal) => {
167
+ const exitCode = Number.isInteger(code) ? code : 128 + (constants.signals[signal] || 1);
168
+ finish({ success: exitCode === 0, exitCode, signal, ...(exitCode ? { error: `Child program exited with status ${exitCode}.` } : {}) });
169
+ });
170
+ });
171
+ }
172
+
173
+ function githubStatusResult(projectRoot, flags, args, options) {
174
+ const status = accountStatus(projectRoot, options);
175
+ const json = flags.json || flags['--json'] || args.includes('--json');
176
+ return { success: ['ready', 'unbound'].includes(status.state), status,
177
+ output: json ? JSON.stringify(status, null, 2) : renderStatus(status),
178
+ ...(status.error ? { error: status.error } : {}) };
179
+ }
180
+
181
+ async function handler(args = [], flags = {}, projectRoot = process.cwd(), options = {}) {
182
+ const delimiter = args.indexOf('--');
183
+ const ownArgs = stripGlobalFlags(delimiter < 0 ? args : args.slice(0, delimiter));
184
+ const verb = ownArgs[0];
185
+ if (!verb || verb === 'help' || (delimiter < 0 && (args.includes('--help') || args.includes('-h')))) {
186
+ return { success: true, output: usage };
187
+ }
188
+ try {
189
+ if (verb === 'use' && ownArgs.length === 2 && delimiter < 0) {
190
+ const context = prepareGithubAccount(projectRoot, ownArgs[1], options);
191
+ repositoryAccess(context, projectRoot, options);
192
+ writeGithubAccount(projectRoot, ownArgs[1], options);
193
+ return { success: true, account: context.account, output: `This clone uses GitHub account ${context.account}.` };
194
+ }
195
+ if (verb === 'status' && ownArgs.slice(1).every(arg => arg === '--json') && delimiter < 0) {
196
+ return githubStatusResult(projectRoot, flags, args, options);
197
+ }
198
+ if (verb === 'unset' && ownArgs.length === 1 && delimiter < 0) {
199
+ unsetGithubAccount(projectRoot, options);
200
+ return { success: true, output: 'Clone GitHub account binding removed; native account selection applies.' };
201
+ }
202
+ if (verb === 'run' && ownArgs.length === 1 && delimiter >= 0 && args[delimiter + 1]) {
203
+ const context = createGithubContext(projectRoot, { ...options, childRunner: options.childRunner || spawn });
204
+ return await launch(context, args[delimiter + 1], args.slice(delimiter + 2), projectRoot, options);
205
+ }
206
+ return { success: false, error: `Usage: ${usage}` };
207
+ } catch (error) { return safeFailure(error); }
208
+ }
209
+
210
+ module.exports = {
211
+ name: 'github',
212
+ description: 'Optional clone-local GitHub account selection and isolated session launch',
213
+ usage,
214
+ handler,
215
+ };
@@ -29,8 +29,8 @@ const {
29
29
  } = require('../hook-global-installer');
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
- const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
33
- const { buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
32
+ const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability, readAttentionCapability } = require('../hook-renderer');
33
+ const { buildMemoryDigest, buildReadAttentionDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
34
34
  const { collectInbox, buildInboxNudge } = require('../inbox');
35
35
  const { collectDigest } = require('../pr-monitor/digest');
36
36
  const { loadDispatchText } = require('../using-forge');
@@ -172,6 +172,7 @@ function usage() {
172
172
  + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
173
173
  + ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
174
174
  + ' forge hooks memory-recall --harness <claude> (machine-facing; emits UserPromptSubmit query-relevant memory)\n'
175
+ + ' forge hooks read-attention --harness <claude> (machine-facing; emits path-matched PreToolUse:Read context)\n'
175
176
  + ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
176
177
  }
177
178
 
@@ -212,12 +213,23 @@ const CAPTURE_TAGS = ['type:session-summary', CAPTURE_AUTO_TAG];
212
213
  * @returns {string}
213
214
  */
214
215
  function buildCaptureNote(trigger, issues) {
215
- const capped = issues.slice(0, CAPTURE_ISSUE_CAP);
216
- const lines = capped.map(issue => {
216
+ const stableIssues = [...new Map(issues.map(issue => {
217
+ const id = String((issue && issue.id) || '');
217
218
  const title = String((issue && (issue.title || issue.id)) || 'untitled').replace(/\s+/g, ' ').trim();
219
+ return [`${id}\0${title}`, { id, title }];
220
+ })).values()].sort((left, right) => {
221
+ const leftKey = `${left.id}\0${left.title}`;
222
+ const rightKey = `${right.id}\0${right.title}`;
223
+ if (leftKey < rightKey) return -1;
224
+ if (leftKey > rightKey) return 1;
225
+ return 0;
226
+ });
227
+ const capped = stableIssues.slice(0, CAPTURE_ISSUE_CAP);
228
+ const lines = capped.map(issue => {
229
+ const title = issue.title;
218
230
  return `- ${title.length > CAPTURE_TITLE_CAP ? `${title.slice(0, CAPTURE_TITLE_CAP)}…` : title}`;
219
231
  });
220
- const more = issues.length > capped.length ? `\n- …and ${issues.length - capped.length} more` : '';
232
+ const more = stableIssues.length > capped.length ? `\n- …and ${stableIssues.length - capped.length} more` : '';
221
233
  const body = lines.length
222
234
  ? `Session boundary (${trigger}) — in-progress:\n${lines.join('\n')}${more}`
223
235
  : `Session boundary (${trigger}) — no in-progress issues.`;
@@ -299,6 +311,41 @@ function formatUserPromptSubmit(harness, text) {
299
311
  return '';
300
312
  }
301
313
 
314
+ function formatPreToolUse(harness, text) {
315
+ if (harness !== 'claude') return '';
316
+ return JSON.stringify({
317
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text },
318
+ });
319
+ }
320
+
321
+ function fetchReadAttentionNotes(projectRoot) {
322
+ return projectMemory.recent(projectRoot, 100).map(entry => ({
323
+ note: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value),
324
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
325
+ sourceAgent: entry.sourceAgent,
326
+ timestamp: entry.timestamp,
327
+ }));
328
+ }
329
+
330
+ async function handleReadAttention(rest, projectRoot, opts = {}) {
331
+ try {
332
+ const harness = parseHarness(rest);
333
+ const capability = readAttentionCapability(harness);
334
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
335
+ const readInput = opts.readInput || readHookStdin;
336
+ const payload = JSON.parse(readInput() || '{}');
337
+ const targetPath = payload?.tool_input?.file_path || payload?.tool_input?.path;
338
+ if (!targetPath) return { success: true, output: '' };
339
+ const fetchNotes = opts.fetchNotes || fetchReadAttentionNotes;
340
+ const notes = await fetchNotes(projectRoot, { ...opts, noteLimit: 100 });
341
+ const digest = buildReadAttentionDigest(targetPath, notes, opts);
342
+ if (digest.empty) return { success: true, output: '' };
343
+ return { success: true, output: formatPreToolUse(harness, digest.text) };
344
+ } catch {
345
+ return { success: true, output: '' };
346
+ }
347
+ }
348
+
302
349
  /**
303
350
  * `forge hooks inbox-pickup --harness <h>` — the COMPLIANT comment-back CONTEXT hook. On each
304
351
  * prompt it emits harness-native UserPromptSubmit JSON carrying a COMPACT count+pointer nudge
@@ -700,11 +747,12 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
700
747
  if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
701
748
  if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
702
749
  if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
750
+ if (action === 'read-attention') return handleReadAttention(args.slice(1), projectRoot, opts);
703
751
  if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
704
752
  if (action === 'install') return handleInstall(args, flags, opts);
705
753
  return {
706
754
  success: false,
707
- error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, capture.\n${usage()}`,
755
+ error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, memory-recall, read-attention, capture.\n${usage()}`,
708
756
  };
709
757
  }
710
758