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

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 (128) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +36 -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 +108 -6
  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 +65 -21
  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/workflow/enforce-stage.js +44 -0
  107. package/lib/workflow/plan-authority.js +225 -0
  108. package/package.json +8 -4
  109. package/scripts/commitlint.js +13 -15
  110. package/scripts/generate-risk-manifest.js +91 -0
  111. package/scripts/github-context-bridge.sh +10 -0
  112. package/scripts/legacy-claim-repair.js +145 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  114. package/scripts/process-tree.js +14 -2
  115. package/scripts/protected-state-check.js +440 -17
  116. package/scripts/sync-agent-skills.js +333 -34
  117. package/scripts/test-full-suite.js +704 -18
  118. package/scripts/test-profile.js +13 -3
  119. package/scripts/test.js +95 -14
  120. package/skills/coverage.json +1 -0
  121. package/skills/review/SKILL.md +2 -0
  122. package/skills/review/evals/scorecard.json +2 -2
  123. package/skills/setup/SKILL.md +18 -0
  124. package/skills/setup/evals/scorecard.json +3 -3
  125. package/skills/shepherd/SKILL.md +19 -2
  126. package/skills/shepherd/evals/scorecard.json +3 -3
  127. package/skills/validate/SKILL.md +3 -0
  128. package/skills/validate/evals/scorecard.json +1 -1
@@ -49,8 +49,13 @@ const GATE_EVENT_ORIGIN = 'cli';
49
49
  * SAME-actor re-approval mints no duplicate, while a later reject (different
50
50
  * decision) or a different actor is recorded as its own event.
51
51
  */
52
- function gateIdempotencyKey(eventType, issueId, gateId, actor) {
53
- return `${eventType}:${issueId}:${gateId}:${actor}`;
52
+ function gateIdempotencyKey(eventType, issueId, gateId, actor, expiresAt) {
53
+ const base = `${eventType}:${issueId}:${gateId}:${actor}`;
54
+ return expiresAt ? `${base}:${expiresAt}` : base;
55
+ }
56
+
57
+ function gateControlId(issueId, gateId) {
58
+ return `gate:${issueId}:${gateId}`;
54
59
  }
55
60
 
56
61
  /** Shape a stored kernel_events row into the gate-event view callers consume. */
@@ -67,6 +72,18 @@ function parseGateEvent(row) {
67
72
  actor: row.actor,
68
73
  created_at: row.created_at,
69
74
  };
75
+ // Older direct callers supplied only the legacy row subset and consumed its
76
+ // exact four-field view. Stored Kernel rows always carry entity_id, so enrich
77
+ // those audit/status views without changing that legacy parse contract.
78
+ if (row.entity_id) {
79
+ view.issue_id = row.entity_id;
80
+ view.control_id = payload.control_id || gateControlId(row.entity_id, payload.gate);
81
+ view.decision = row.event_type === GATE_APPROVED_EVENT ? 'approved' : 'rejected';
82
+ view.issued_at = row.created_at;
83
+ view.expires_at = payload.expires_at ?? null;
84
+ view.request_identity = payload.request_identity ?? null;
85
+ view.generation = Number.isInteger(payload.generation) ? payload.generation : 0;
86
+ }
70
87
  if (payload.reason !== undefined) view.reason = payload.reason;
71
88
  return view;
72
89
  }
@@ -76,10 +93,43 @@ function isIdempotencyRace(error) {
76
93
  return /UNIQUE constraint failed/i.test(message) && /idempotency_key/i.test(message);
77
94
  }
78
95
 
96
+ function isActiveApproval(event, now) {
97
+ if (!event || event.event_type !== GATE_APPROVED_EVENT) return false;
98
+ if (!event.expires_at) return true;
99
+ const nowMillis = Date.parse(now);
100
+ const expiryMillis = Date.parse(event.expires_at);
101
+ return Number.isFinite(nowMillis) && Number.isFinite(expiryMillis) && nowMillis < expiryMillis;
102
+ }
103
+
104
+ function planGateGeneration(events, params) {
105
+ const { gateId, decision, actor, requestIdentity, now } = params;
106
+ const gateEvents = events.filter(event => event.gate === gateId);
107
+ const latest = gateEvents.at(-1);
108
+ const maxGeneration = gateEvents.reduce(
109
+ (max, event) => Math.max(max, Number.isInteger(event.generation) ? event.generation : 0),
110
+ -1,
111
+ );
112
+
113
+ if (decision === 'approved') {
114
+ const identicalActive = latest
115
+ && latest.actor === actor
116
+ && latest.request_identity === requestIdentity
117
+ && isActiveApproval(latest, now);
118
+ if (identicalActive) return { duplicateEvent: latest, generation: latest.generation };
119
+ return { duplicateEvent: null, generation: maxGeneration + 1 };
120
+ }
121
+
122
+ if (latest?.event_type === GATE_REJECTED_EVENT && latest.actor === actor) {
123
+ return { duplicateEvent: latest, generation: latest.generation };
124
+ }
125
+ return { duplicateEvent: null, generation: Math.max(maxGeneration, 0) };
126
+ }
127
+
79
128
  /**
80
- * Record a gate decision as a kernel event on the issue. Idempotent per
81
- * issue+gate+actor+decision. Validates the issue exists (the caller validates the
82
- * gate id against the known-gate set before calling).
129
+ * Record a gate decision as a kernel event on the issue. Active identical
130
+ * requests are idempotent; expiry/rejection advances the durable generation so
131
+ * a later approval can become authoritative. Validates the issue exists (the
132
+ * caller validates the gate id against the known-gate set before calling).
83
133
  *
84
134
  * @param {string} projectRoot
85
135
  * @param {Object} params
@@ -93,7 +143,7 @@ function isIdempotencyRace(error) {
93
143
  * @returns {Promise<{ ok: boolean, duplicate?: boolean, issueMissing?: boolean, event?: Object, actor?: string }>}
94
144
  */
95
145
  async function recordGateEvent(projectRoot, params = {}) {
96
- const { issueId, gateId, decision, reason, env, deps, now } = params;
146
+ const { issueId, gateId, decision, reason, expiresAt, requestIdentity, env, deps, now } = params;
97
147
  const eventType = GATE_EVENT_TYPES[decision];
98
148
  if (!eventType) {
99
149
  throw new Error(`Unknown gate decision '${decision}' (expected 'approved' or 'rejected').`);
@@ -109,14 +159,42 @@ async function recordGateEvent(projectRoot, params = {}) {
109
159
  return { ok: false, issueMissing: true, actor };
110
160
  }
111
161
 
112
- const idempotencyKey = gateIdempotencyKey(eventType, issueId, gateId, actor);
162
+ const createdAt = now || new Date().toISOString();
163
+ const stableRequestIdentity = requestIdentity ?? expiresAt ?? null;
164
+ const rows = await driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, config);
165
+ const generationPlan = planGateGeneration((rows || []).map(parseGateEvent), {
166
+ gateId,
167
+ decision,
168
+ actor,
169
+ requestIdentity: stableRequestIdentity,
170
+ now: createdAt,
171
+ });
172
+ if (generationPlan.duplicateEvent) {
173
+ return { ok: true, duplicate: true, event: generationPlan.duplicateEvent, actor };
174
+ }
175
+ const keyParts = [stableRequestIdentity];
176
+ if (generationPlan.generation > 0) keyParts.push(`generation=${generationPlan.generation}`);
177
+ const idempotencyKey = gateIdempotencyKey(
178
+ eventType,
179
+ issueId,
180
+ gateId,
181
+ actor,
182
+ keyParts.filter(Boolean).join(':') || undefined,
183
+ );
113
184
 
114
185
  const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
115
186
  if (existing) {
116
187
  return { ok: true, duplicate: true, event: parseGateEvent(existing), actor };
117
188
  }
118
189
 
119
- const payload = { gate: gateId, actor };
190
+ const payload = {
191
+ gate: gateId,
192
+ actor,
193
+ control_id: gateControlId(issueId, gateId),
194
+ expires_at: expiresAt ?? null,
195
+ request_identity: stableRequestIdentity,
196
+ generation: generationPlan.generation,
197
+ };
120
198
  if (typeof reason === 'string' && reason.length > 0) payload.reason = reason;
121
199
 
122
200
  const event = {
@@ -128,7 +206,7 @@ async function recordGateEvent(projectRoot, params = {}) {
128
206
  actor,
129
207
  origin: GATE_EVENT_ORIGIN,
130
208
  payload,
131
- created_at: now || new Date().toISOString(),
209
+ created_at: createdAt,
132
210
  };
133
211
 
134
212
  try {
@@ -166,6 +244,12 @@ async function listGateEvents(projectRoot, issueId, options = {}) {
166
244
  }
167
245
  }
168
246
 
247
+ function evaluateGateApproval(events, gateId, now = new Date().toISOString()) {
248
+ const decisions = events.filter(event => event.gate === gateId);
249
+ const event = decisions.at(-1);
250
+ return { approved: isActiveApproval(event, now), event };
251
+ }
252
+
169
253
  /**
170
254
  * True iff a `gate.approved` event exists for this issue + gate.
171
255
  *
@@ -177,7 +261,7 @@ async function listGateEvents(projectRoot, issueId, options = {}) {
177
261
  */
178
262
  async function isGateApproved(projectRoot, issueId, gateId, options = {}) {
179
263
  const events = await listGateEvents(projectRoot, issueId, options);
180
- return events.some(event => event.event_type === GATE_APPROVED_EVENT && event.gate === gateId);
264
+ return evaluateGateApproval(events, gateId, options.now).approved;
181
265
  }
182
266
 
183
267
  module.exports = {
@@ -185,8 +269,12 @@ module.exports = {
185
269
  GATE_REJECTED_EVENT,
186
270
  GATE_EVENT_TYPES,
187
271
  gateIdempotencyKey,
272
+ gateControlId,
273
+ isActiveApproval,
274
+ planGateGeneration,
188
275
  parseGateEvent,
189
276
  recordGateEvent,
190
277
  listGateEvents,
278
+ evaluateGateApproval,
191
279
  isGateApproved,
192
280
  };
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ const ACCOUNT_KEY = 'github.account';
6
+ const HOST = 'github.com';
7
+ const LOGIN_RE = /^(?=.{1,39}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
8
+ const GITHUB_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_host', 'gh_repo']);
9
+ const GITHUB_SECRET_ENV_NAMES = new Set(['gh_token', 'github_token']);
10
+
11
+ class GithubContextError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = 'GithubContextError';
15
+ this.code = code;
16
+ }
17
+
18
+ toJSON() {
19
+ return { code: this.code, message: this.message };
20
+ }
21
+ }
22
+
23
+ function validateGithubLogin(login) {
24
+ return typeof login === 'string' && LOGIN_RE.test(login);
25
+ }
26
+
27
+ function safeLogin(login) {
28
+ return validateGithubLogin(login) ? login : null;
29
+ }
30
+
31
+ function runnerFor(options) {
32
+ return options.runner || options._runner || options._exec || options.execFileSync || defaultRunner;
33
+ }
34
+
35
+ function defaultRunner(command, args, options = {}) {
36
+ return execFileSync(command, args, {
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ windowsHide: true,
40
+ ...options,
41
+ });
42
+ }
43
+
44
+ function errorText(error) {
45
+ return [error?.message, error?.stderr?.toString?.(), error?.stdout?.toString?.()]
46
+ .filter(Boolean)
47
+ .join(' ')
48
+ .toLowerCase();
49
+ }
50
+
51
+ function isUnsupportedTokenCapability(error) {
52
+ const text = errorText(error);
53
+ return /unknown (?:flag|option)|invalid (?:flag|option)|unexpected argument|does not support/.test(text)
54
+ && /user|hostname/.test(text);
55
+ }
56
+
57
+ function safeCommandFailure(code, message) {
58
+ return new GithubContextError(code, message);
59
+ }
60
+
61
+ function safeGithubCommandFailure(error) {
62
+ const text = errorText(error);
63
+ const failure = safeCommandFailure(
64
+ 'GITHUB_COMMAND_FAILED',
65
+ /rate limit|secondary rate/.test(text)
66
+ ? 'The GitHub CLI command was rate limited.'
67
+ : 'The GitHub CLI command failed without exposing its output.',
68
+ );
69
+ const matchedStatus = /\bhttp\s+(401|403|429)\b/.exec(text)?.[1];
70
+ let httpStatus;
71
+ if ([401, 403, 429].includes(error?.httpStatus)) httpStatus = error.httpStatus;
72
+ else if ([401, 403, 429].includes(error?.status)) httpStatus = error.status;
73
+ else httpStatus = Number(matchedStatus) || undefined;
74
+ const retryAfter = Number(error?.retryAfter);
75
+ if (httpStatus) failure.httpStatus = httpStatus;
76
+ if (Number.isFinite(retryAfter) && retryAfter > 0) failure.retryAfter = retryAfter;
77
+ return failure;
78
+ }
79
+
80
+ function gitConfig(projectRoot, args, options, absentIsOkay = false) {
81
+ try {
82
+ return runnerFor(options)('git', ['config', '--local', ...args], {
83
+ cwd: projectRoot,
84
+ encoding: 'utf8',
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ windowsHide: true,
87
+ });
88
+ } catch (error) {
89
+ if (absentIsOkay && (error?.status === 1 || /key does not contain|not found|no such key/i.test(errorText(error)))) {
90
+ return '';
91
+ }
92
+ throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to read this clone-local GitHub account binding.');
93
+ }
94
+ }
95
+
96
+ function readGithubAccount(projectRoot, options = {}) {
97
+ const output = gitConfig(projectRoot, ['--get', ACCOUNT_KEY], options, true);
98
+ const value = output == null ? '' : output.toString().trim();
99
+ return value || null;
100
+ }
101
+
102
+ function writeGithubAccount(projectRoot, login, options = {}) {
103
+ if (!validateGithubLogin(login)) {
104
+ throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
105
+ }
106
+ try {
107
+ runnerFor(options)('git', ['config', '--local', '--replace-all', ACCOUNT_KEY, login], {
108
+ cwd: projectRoot,
109
+ encoding: 'utf8',
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ windowsHide: true,
112
+ });
113
+ } catch (_error) {
114
+ throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to write the clone-local GitHub account binding.');
115
+ }
116
+ }
117
+
118
+ function unsetGithubAccount(projectRoot, options = {}) {
119
+ try {
120
+ runnerFor(options)('git', ['config', '--local', '--unset-all', ACCOUNT_KEY], {
121
+ cwd: projectRoot,
122
+ encoding: 'utf8',
123
+ stdio: ['ignore', 'pipe', 'pipe'],
124
+ windowsHide: true,
125
+ });
126
+ } catch (error) {
127
+ if (error?.status === 5 || /key does not contain|not found|no such key/i.test(errorText(error))) return;
128
+ throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to remove the clone-local GitHub account binding.');
129
+ }
130
+ }
131
+
132
+ function withoutGithubEnvironment(environment) {
133
+ const result = {};
134
+ for (const [key, value] of Object.entries(environment || {})) {
135
+ if (!GITHUB_ENV_NAMES.has(key.toLowerCase())) result[key] = value;
136
+ }
137
+ return result;
138
+ }
139
+
140
+ function selectedEnvironment(baseEnv, token, additions = {}) {
141
+ const result = withoutGithubEnvironment(baseEnv);
142
+ Object.assign(result, withoutGithubEnvironment(additions));
143
+ result.GH_TOKEN = token;
144
+ result.GITHUB_TOKEN = token;
145
+ result.GH_HOST = HOST;
146
+ return result;
147
+ }
148
+
149
+ function passthroughEnvironment(baseEnv, additions = {}) {
150
+ return { ...baseEnv, ...additions };
151
+ }
152
+
153
+ function retrieveGithubToken(login, options) {
154
+ if (!validateGithubLogin(login)) {
155
+ throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
156
+ }
157
+ const baseEnv = options.baseEnv || options.env || process.env;
158
+ let output;
159
+ try {
160
+ output = runnerFor(options)('gh', ['auth', 'token', '--hostname', HOST, '--user', login], {
161
+ env: withoutGithubEnvironment(baseEnv),
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'pipe'],
164
+ windowsHide: true,
165
+ });
166
+ } catch (error) {
167
+ if (isUnsupportedTokenCapability(error)) {
168
+ throw safeCommandFailure('GITHUB_CLI_UNSUPPORTED', 'Upgrade GitHub CLI, then authenticate the account with gh auth login.');
169
+ }
170
+ throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'GitHub account is not authenticated; run gh auth login for this account.');
171
+ }
172
+
173
+ const token = output == null ? '' : output.toString().trim();
174
+ if (!token) {
175
+ throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'GitHub account is not authenticated; run gh auth login for this account.');
176
+ }
177
+ return token;
178
+ }
179
+
180
+ function loginFromOutput(output) {
181
+ const text = output == null ? '' : output.toString().trim();
182
+ if (!text) return null;
183
+ try {
184
+ const parsed = JSON.parse(text);
185
+ return safeLogin(parsed?.login);
186
+ } catch {
187
+ // `gh --jq` normally returns plain text; JSON is only a compatibility path.
188
+ return safeLogin(text.split(/\r?\n/, 1)[0].trim());
189
+ }
190
+ }
191
+
192
+ function resolveGithubLogin(token, baseEnv, options) {
193
+ try {
194
+ const output = runnerFor(options)('gh', ['api', '--hostname', HOST, 'user', '--jq', '.login'], {
195
+ env: selectedEnvironment(baseEnv, token),
196
+ encoding: 'utf8',
197
+ stdio: ['ignore', 'pipe', 'pipe'],
198
+ windowsHide: true,
199
+ });
200
+ const login = loginFromOutput(output);
201
+ if (!login) throw new Error('missing login');
202
+ return login;
203
+ } catch {
204
+ // Provider errors may contain response bodies or credentials; expose only a safe diagnosis.
205
+ throw safeCommandFailure('GITHUB_AUTH_FAILED', 'Unable to verify the selected GitHub account; run gh auth login and try again.');
206
+ }
207
+ }
208
+
209
+ function prepareGithubAccount(_projectRoot, account, options = {}) {
210
+ if (!validateGithubLogin(account)) {
211
+ throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
212
+ }
213
+ const baseEnv = { ...(options.baseEnv || options.env || process.env) };
214
+ const token = retrieveGithubToken(account, { ...options, baseEnv });
215
+ const login = resolveGithubLogin(token, baseEnv, options);
216
+ if (login.toLowerCase() !== account.toLowerCase()) {
217
+ throw safeCommandFailure('GITHUB_ACCOUNT_MISMATCH', `GitHub account mismatch: expected ${account}, received ${login}.`);
218
+ }
219
+ return createPublicContext(publicStatus('ready', account, login), token, baseEnv, options);
220
+ }
221
+
222
+ function publicStatus(state, account, login) {
223
+ return Object.freeze({ state, account: account || null, login: login || null });
224
+ }
225
+
226
+ function createPublicContext(status, token, baseEnv, options) {
227
+ const context = {
228
+ status,
229
+ bound: status.state !== 'unbound',
230
+ account: status.account,
231
+ login: status.login,
232
+ };
233
+ const secrets = (status.state === 'unbound'
234
+ ? Object.entries(baseEnv)
235
+ .filter(([key]) => GITHUB_SECRET_ENV_NAMES.has(key.toLowerCase()))
236
+ .map(([, value]) => value)
237
+ : [token])
238
+ .filter((value) => typeof value === 'string' && value);
239
+ const environmentForChild = (additions = {}) => status.state === 'unbound'
240
+ ? passthroughEnvironment(baseEnv, additions)
241
+ : selectedEnvironment(baseEnv, token, additions);
242
+ const childRunner = options.childRunner || options._childRunner || runnerFor(options);
243
+ const runChild = (command, args, runOptions = {}) => {
244
+ if (typeof command !== 'string' || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
245
+ throw safeCommandFailure('INVALID_GH_ARGUMENTS', 'GitHub CLI arguments must be an array of strings.');
246
+ }
247
+ try {
248
+ return childRunner(command, args, {
249
+ ...runOptions,
250
+ env: environmentForChild(runOptions.env),
251
+ });
252
+ } catch (_error) {
253
+ throw safeCommandFailure('GITHUB_COMMAND_FAILED', 'The GitHub CLI command failed without exposing its output.');
254
+ }
255
+ };
256
+ const runGh = (args, runOptions = {}) => {
257
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
258
+ throw safeCommandFailure('INVALID_GH_ARGUMENTS', 'GitHub CLI arguments must be an array of strings.');
259
+ }
260
+ try {
261
+ const output = runnerFor(options)('gh', args, {
262
+ ...runOptions,
263
+ env: environmentForChild(runOptions.env),
264
+ encoding: 'utf8',
265
+ stdio: ['ignore', 'pipe', 'pipe'],
266
+ windowsHide: true,
267
+ });
268
+ return redactTokens(output, secrets);
269
+ } catch (error) {
270
+ throw safeGithubCommandFailure(error);
271
+ }
272
+ };
273
+ Object.defineProperties(context, {
274
+ runChild: { value: runChild, enumerable: false },
275
+ runGh: { value: runGh, enumerable: false },
276
+ getStatus: { value: () => ({ ...status }), enumerable: false },
277
+ });
278
+ return context;
279
+ }
280
+
281
+ function redactTokens(value, tokens) {
282
+ if (value == null) return value;
283
+ return tokens.slice().sort((left, right) => right.length - left.length)
284
+ .reduce((redacted, token) => redacted.split(token).join('[REDACTED]'), value.toString());
285
+ }
286
+
287
+ function createGithubContext(projectRoot, options = {}) {
288
+ const baseEnv = { ...(options.baseEnv || options.env || process.env) };
289
+ const account = readGithubAccount(projectRoot, options);
290
+ if (!account) return createPublicContext(publicStatus('unbound', null, null), null, baseEnv, options);
291
+ if (!validateGithubLogin(account)) {
292
+ throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'The clone-local github.account value is not a valid GitHub login.');
293
+ }
294
+ return prepareGithubAccount(projectRoot, account, { ...options, baseEnv });
295
+ }
296
+
297
+ module.exports = {
298
+ ACCOUNT_KEY,
299
+ GithubContextError,
300
+ createGithubContext,
301
+ prepareGithubAccount,
302
+ prepareGithubContext: createGithubContext,
303
+ readGithubAccount,
304
+ resolveGithubLogin,
305
+ unsetGithubAccount,
306
+ validateGithubLogin,
307
+ writeGithubAccount,
308
+ };
@@ -37,6 +37,7 @@ const GLOBAL_BOOLEAN_FLAGS = new Set([
37
37
  '--verbose',
38
38
  '--dry-run',
39
39
  '--symlink',
40
+ '--skills-only', '--no-hooks',
40
41
  '--sync',
41
42
  '--interview',
42
43
  ]);
@@ -83,6 +83,7 @@ const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
83
83
  // The memory-recall context hook (UserPromptSubmit tier). Its own idempotency marker so a
84
84
  // re-render recognizes and REPLACES the Forge-owned entry in place rather than duplicating it.
85
85
  const FORGE_MEMORY_RECALL_MARKER = 'hooks memory-recall';
86
+ const FORGE_READ_ATTENTION_MARKER = 'hooks read-attention';
86
87
  // The capture-on-exit context hook (PreCompact + Stop tier). A THIRD context marker so a
87
88
  // re-merge recognizes + replaces the Forge-owned PreCompact/Stop entries in place. Both
88
89
  // events share this one marker (they differ only by a --trigger suffix on the command).
@@ -134,6 +135,13 @@ const USER_PROMPT_SUBMIT_SUPPORT = Object.freeze({
134
135
  hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
135
136
  });
136
137
 
138
+ const READ_ATTENTION_SUPPORT = Object.freeze({
139
+ claude: Object.freeze({ rendered: true }),
140
+ cursor: Object.freeze({ rendered: false, reason: 'no-read-context-surface' }),
141
+ codex: Object.freeze({ rendered: false, reason: 'global-config' }),
142
+ hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
143
+ });
144
+
137
145
  // Per-harness session-END (capture-on-exit) capability. Only Claude exposes native
138
146
  // PreCompact + Stop hook surfaces where Forge can snapshot session learnings BEFORE
139
147
  // context is compacted or the session ends. Cursor 1.7 hooks are deny-oriented with no
@@ -161,7 +169,7 @@ function adapterInvocation(harness) {
161
169
  * (per-commit) so the rendered Claude PreToolUse groups read write-guard first.
162
170
  */
163
171
  const FORGE_HOOK_CONTRACT = Object.freeze({
164
- schemaVersion: '1.2.0',
172
+ schemaVersion: '1.3.0',
165
173
  kind: 'forge.hookContract',
166
174
  adapter: FORGE_HOOK_ADAPTER,
167
175
  intents: Object.freeze([
@@ -215,6 +223,14 @@ const FORGE_HOOK_CONTRACT = Object.freeze({
215
223
  lifecycle: 'user-prompt-submit',
216
224
  command: `${FORGE_CLI} hooks memory-recall`,
217
225
  }),
226
+ Object.freeze({
227
+ id: 'read-attention',
228
+ kind: 'context',
229
+ cliAction: 'read-attention',
230
+ enforces: 'Read attention: on supported PreToolUse:Read surfaces, inject only memory explicitly scoped to the path being opened. Content is fenced as untrusted, capped, additive, fail-open, and never mutates authority.',
231
+ lifecycle: 'pre-tool-read',
232
+ command: `${FORGE_CLI} hooks read-attention`,
233
+ }),
218
234
  Object.freeze({
219
235
  id: 'shepherd-events',
220
236
  kind: 'context',
@@ -294,6 +310,10 @@ function userPromptSubmitCapability(harness) {
294
310
  return USER_PROMPT_SUBMIT_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
295
311
  }
296
312
 
313
+ function readAttentionCapability(harness) {
314
+ return READ_ATTENTION_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
315
+ }
316
+
297
317
  /**
298
318
  * Report the per-harness session-END (capture-on-exit) capability (the honest matrix for
299
319
  * the PreCompact + Stop capture tier).
@@ -321,6 +341,10 @@ function renderClaudeHooks(contract) {
321
341
  matcher: 'Bash',
322
342
  hooks: [{ type: 'command', command: harnessCommand(contract, 'tdd-gate', 'claude') }],
323
343
  },
344
+ {
345
+ matcher: 'Read',
346
+ hooks: [{ type: 'command', command: harnessCommand(contract, 'read-attention', 'claude') }],
347
+ },
324
348
  ],
325
349
  // SessionStart context injection (memory push). No matcher → applies to every
326
350
  // session source; the command emits { hookSpecificOutput.additionalContext }.
@@ -438,6 +462,7 @@ function isForgeCommand(command) {
438
462
  || command.includes(FORGE_INBOX_CONTEXT_MARKER)
439
463
  || command.includes(FORGE_SHEPHERD_EVENTS_MARKER)
440
464
  || command.includes(FORGE_MEMORY_RECALL_MARKER)
465
+ || command.includes(FORGE_READ_ATTENTION_MARKER)
441
466
  || command.includes(FORGE_CAPTURE_CONTEXT_MARKER));
442
467
  }
443
468
 
@@ -651,13 +676,16 @@ module.exports = {
651
676
  FORGE_CONTEXT_MARKER,
652
677
  FORGE_INBOX_CONTEXT_MARKER,
653
678
  FORGE_MEMORY_RECALL_MARKER,
679
+ FORGE_READ_ATTENTION_MARKER,
654
680
  FORGE_CAPTURE_CONTEXT_MARKER,
655
681
  SESSION_START_SUPPORT,
656
682
  BOOTSTRAP_DELIVERY,
657
683
  USER_PROMPT_SUBMIT_SUPPORT,
684
+ READ_ATTENTION_SUPPORT,
658
685
  SESSION_END_SUPPORT,
659
686
  sessionStartCapability,
660
687
  userPromptSubmitCapability,
688
+ readAttentionCapability,
661
689
  sessionEndCapability,
662
690
  HookConfigParseError,
663
691
  renderClaudeHooks,
@@ -114,6 +114,19 @@ function pushSection(lines, label, text) {
114
114
  lines.push('', `${label}:`, text);
115
115
  }
116
116
 
117
+ function renderReadinessReason(reason) {
118
+ if (!reason || typeof reason !== 'object') return String(reason ?? 'unknown');
119
+ const detail = reason.field || reason.issue_id || reason.gate || reason.actor || reason.status;
120
+ const parts = [];
121
+ if (detail) parts.push(String(detail));
122
+ if (reason.expected !== undefined) parts.push(`expected ${JSON.stringify(reason.expected)}`);
123
+ if (reason.actual !== undefined) parts.push(`actual ${JSON.stringify(reason.actual)}`);
124
+ if (reason.declared !== undefined) parts.push(`declared ${JSON.stringify(reason.declared)}`);
125
+ return parts.length > 0
126
+ ? `${reason.code} (${parts.join(', ')})`
127
+ : String(reason.code || 'unknown');
128
+ }
129
+
117
130
  // forge show: the full detail view. This is the surface that prints the FULL id
118
131
  // (list/ready show only the 8-char prefix), so every field a script or human
119
132
  // needs to act on the issue is reachable without --json.
@@ -135,6 +148,12 @@ function renderIssueShow(envelope) {
135
148
  pushField(lines, 'Claimed by', issue.claimed_by);
136
149
  pushField(lines, 'Parent', shortId(issue.parent_id ?? null) || null);
137
150
  pushField(lines, 'Blocked by', Array.isArray(issue.blocked_by) ? issue.blocked_by.map(shortId) : null);
151
+ pushField(lines, 'Readiness', issue.readiness_state);
152
+ pushField(
153
+ lines,
154
+ 'Readiness reasons',
155
+ Array.isArray(issue.readiness_reasons) ? issue.readiness_reasons.map(renderReadinessReason) : null,
156
+ );
138
157
  pushField(lines, 'Dependencies', Array.isArray(issue.dependencies) ? issue.dependencies.map(shortId) : null);
139
158
  pushField(lines, 'Dependents', Array.isArray(issue.dependents) ? issue.dependents.map(shortId) : null);
140
159
  pushField(lines, 'Created', issue.created_at);