forge-workflow 0.1.0-beta.4 → 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 (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -0,0 +1,347 @@
1
+ 'use strict';
2
+
3
+ const { createProbeResult } = require('./model');
4
+
5
+ const PROBE_REVISION = 'forge.harness-capability-probe.v1';
6
+ const DEFAULT_TIMEOUT_MS = 1500;
7
+ const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024;
8
+ const MAX_TIMEOUT_MS = 30 * 1000;
9
+ const MAX_OUTPUT_BYTES = 64 * 1024;
10
+ const EXECUTABLE_IDENTITY_PATTERN = /^[0-9a-f]{64}$/;
11
+ const SECRET_PATTERNS = Object.freeze([
12
+ /gh[pousr]_[a-z0-9]{20,}/i,
13
+ /sk-(?:live-|test-)?[a-z0-9]{16,}/i,
14
+ /(?:api[_ -]?key|authorization|credential|password|private[_ -]?key|secret|token)\s*[:=]\s*\S{8,}/i,
15
+ ]);
16
+ const PRIVATE_PATH_PATTERN = /(?:[a-z]:\\Users\\[^\\\s]+|\/(?:Users|home)\/[^/\s]+\/)/i;
17
+
18
+ const HARNESS_IDS = Object.freeze(['claude', 'codex', 'cursor', 'hermes']);
19
+ const CAPABILITY_IDS = Object.freeze([
20
+ 'native_streaming_monitor',
21
+ 'bounded_background_tasks',
22
+ 'wake_resume',
23
+ 'cancellation',
24
+ 'session_scoped_cleanup',
25
+ 'initiating_agent_delivery',
26
+ ]);
27
+
28
+ function capability(id, probeId, args, requiredTokens) {
29
+ if (args.length === 0 || args.at(-1) !== '--help') {
30
+ throw new TypeError(`Capability probe ${probeId} must use a side-effect-free help command`);
31
+ }
32
+ return Object.freeze({
33
+ id,
34
+ probeId,
35
+ args: Object.freeze(args),
36
+ requiredTokens: Object.freeze(requiredTokens),
37
+ });
38
+ }
39
+
40
+ function harnessSpec(command, versionPattern, capabilities) {
41
+ return Object.freeze({
42
+ command,
43
+ versionArgs: Object.freeze(['--version']),
44
+ versionPattern,
45
+ capabilities: Object.freeze(capabilities),
46
+ });
47
+ }
48
+
49
+ const HARNESS_PROBE_SPECS = Object.freeze({
50
+ claude: harnessSpec('claude', /^(?:claude(?:\s+code)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)?)$/i, [
51
+ capability('native_streaming_monitor', 'claude.stream-json-help.v1', ['--help'], ['--output-format', 'stream-json']),
52
+ capability('bounded_background_tasks', 'claude.remote-help.v1', ['--help'], ['--remote']),
53
+ capability('wake_resume', 'claude.resume-help.v1', ['--help'], ['--resume']),
54
+ capability('cancellation', 'claude.cancel-help.v1', ['--help'], ['--cancel']),
55
+ capability('session_scoped_cleanup', 'claude.session-cleanup-help.v1', ['--help'], ['session', 'delete']),
56
+ capability('initiating_agent_delivery', 'claude.stream-delivery-help.v1', ['--help'], ['--output-format', 'stream-json']),
57
+ ]),
58
+ codex: harnessSpec('codex', /^(?:codex(?:-cli)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)?)$/i, [
59
+ capability('native_streaming_monitor', 'codex.json-help.v1', ['--help'], ['--json', 'output-schema']),
60
+ capability('bounded_background_tasks', 'codex.cloud-help.v1', ['cloud', '--help'], ['task']),
61
+ capability('wake_resume', 'codex.resume-help.v1', ['resume', '--help'], ['resume']),
62
+ capability('cancellation', 'codex.cloud-cancel-help.v1', ['cloud', '--help'], ['cancel']),
63
+ capability('session_scoped_cleanup', 'codex.cloud-cleanup-help.v1', ['cloud', '--help'], ['delete']),
64
+ capability('initiating_agent_delivery', 'codex.delivery-help.v1', ['--help'], ['event', 'initiating', 'agent']),
65
+ ]),
66
+ cursor: harnessSpec('cursor-agent', /^(?:cursor(?:-agent)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)?)$/i, [
67
+ capability('native_streaming_monitor', 'cursor.json-help.v1', ['--help'], ['--json']),
68
+ capability('bounded_background_tasks', 'cursor.background-help.v1', ['--help'], ['background']),
69
+ capability('wake_resume', 'cursor.resume-help.v1', ['resume', '--help'], ['resume']),
70
+ capability('cancellation', 'cursor.cancel-help.v1', ['cancel', '--help'], ['cancel']),
71
+ capability('session_scoped_cleanup', 'cursor.session-cleanup-help.v1', ['sessions', '--help'], ['sessions', 'delete']),
72
+ capability('initiating_agent_delivery', 'cursor.follow-up-help.v1', ['--help'], ['follow-up', 'initiating', 'agent']),
73
+ ]),
74
+ hermes: harnessSpec('hermes', /^(?:hermes(?:-agent)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)?)$/i, [
75
+ capability('native_streaming_monitor', 'hermes.monitor-help.v1', ['monitor', '--help'], ['monitor', 'stream']),
76
+ capability('bounded_background_tasks', 'hermes.cron-help.v1', ['cron', '--help'], ['cron', 'task']),
77
+ capability('wake_resume', 'hermes.resume-help.v1', ['resume', '--help'], ['resume']),
78
+ capability('cancellation', 'hermes.cron-cancel-help.v1', ['cron', '--help'], ['pause', 'remove']),
79
+ capability('session_scoped_cleanup', 'hermes.session-cleanup-help.v1', ['sessions', '--help'], ['sessions', 'delete']),
80
+ capability('initiating_agent_delivery', 'hermes.origin-delivery-help.v1', ['cron', '--help'], ['origin', 'deliver']),
81
+ ]),
82
+ });
83
+
84
+ function getHarnessProbeSpec(harness) {
85
+ const spec = HARNESS_PROBE_SPECS[harness];
86
+ if (!spec) throw new TypeError(`Unsupported harness: ${harness}`);
87
+ return spec;
88
+ }
89
+
90
+ function assertBoundedPositiveInteger(value, name, maximum) {
91
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
92
+ throw new TypeError(`${name} must be an integer between 1 and ${maximum}`);
93
+ }
94
+ }
95
+
96
+ function isHostileOutput(output) {
97
+ return SECRET_PATTERNS.some((pattern) => pattern.test(output))
98
+ || PRIVATE_PATH_PATTERN.test(output)
99
+ || [...output].some((character) => {
100
+ const code = character.codePointAt(0);
101
+ return (code >= 0 && code <= 8)
102
+ || code === 11
103
+ || code === 12
104
+ || (code >= 14 && code <= 31)
105
+ || code === 127;
106
+ });
107
+ }
108
+
109
+ function normalizeExecution(response, maxOutputBytes) {
110
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
111
+ return { complete: false, reason: 'MALFORMED_RESULT' };
112
+ }
113
+ if (response.timedOut === true) return { complete: false, reason: 'PROBE_TIMEOUT' };
114
+ if (response.errorCode === 'ENOENT') return { complete: false, reason: 'EXECUTABLE_UNAVAILABLE' };
115
+ if (typeof response.stdout !== 'string' || typeof response.stderr !== 'string') {
116
+ return { complete: false, reason: 'MALFORMED_RESULT' };
117
+ }
118
+ const outputBytes = Buffer.byteLength(response.stdout, 'utf8') + Buffer.byteLength(response.stderr, 'utf8');
119
+ if (outputBytes > maxOutputBytes) return { complete: false, reason: 'OUTPUT_LIMIT' };
120
+ const output = `${response.stdout}\n${response.stderr}`;
121
+ if (isHostileOutput(output)) return { complete: false, reason: 'HOSTILE_OUTPUT' };
122
+ if (response.exitCode !== 0) return { complete: false, reason: 'PROBE_FAILED' };
123
+ return {
124
+ complete: true,
125
+ stdout: response.stdout,
126
+ stderr: response.stderr,
127
+ executableIdentity: response.executableIdentity,
128
+ };
129
+ }
130
+
131
+ async function executeBounded(execute, requestBase, timeoutMs) {
132
+ const controller = new AbortController();
133
+ let timer;
134
+ const timeout = new Promise((resolve) => {
135
+ timer = setTimeout(() => {
136
+ controller.abort();
137
+ resolve({ timedOut: true, stdout: '', stderr: '' });
138
+ }, timeoutMs);
139
+ });
140
+ const request = Object.freeze({ ...requestBase, signal: controller.signal });
141
+ try {
142
+ return await Promise.race([
143
+ Promise.resolve().then(() => execute(request)),
144
+ timeout,
145
+ ]);
146
+ } catch (_error) {
147
+ return { exitCode: null, stdout: '', stderr: '', errorCode: 'EXECUTION_ERROR' };
148
+ } finally {
149
+ clearTimeout(timer);
150
+ }
151
+ }
152
+
153
+ function requestFor({ harness, kind, probeId, command, args, timeoutMs, maxOutputBytes }) {
154
+ return {
155
+ harness,
156
+ kind,
157
+ probeId,
158
+ command,
159
+ args: Object.freeze([...args]),
160
+ timeoutMs,
161
+ maxOutputBytes,
162
+ sideEffectFree: true,
163
+ };
164
+ }
165
+
166
+ function incompleteCapabilities(spec, reason) {
167
+ return spec.capabilities.map((item) => ({
168
+ id: item.id,
169
+ available: false,
170
+ status: 'INCOMPLETE',
171
+ reason,
172
+ probe_id: item.probeId,
173
+ }));
174
+ }
175
+
176
+ function buildResult({ harness, spec, observedAt, status, availability, identity, version, capabilities }) {
177
+ return createProbeResult({
178
+ probeRevision: PROBE_REVISION,
179
+ harnessId: harness,
180
+ status,
181
+ availability,
182
+ observedAt,
183
+ executable: { command: spec.command, identity, version },
184
+ capabilities,
185
+ });
186
+ }
187
+
188
+ function parseVersion(stdout, pattern) {
189
+ const normalized = stdout.trim();
190
+ if (normalized.includes('\n') || normalized.includes('\r')) return null;
191
+ const match = pattern.exec(normalized);
192
+ return match ? match[1] : null;
193
+ }
194
+
195
+ function capabilityFromExecution(item, execution, identity) {
196
+ let status = 'INCOMPLETE';
197
+ let reason = execution.reason;
198
+ let available = false;
199
+ if (execution.complete && execution.executableIdentity !== identity) {
200
+ reason = 'EXECUTABLE_IDENTITY_CHANGED';
201
+ } else if (execution.complete) {
202
+ const output = execution.stdout.toLowerCase();
203
+ available = item.requiredTokens.every((token) => output.includes(token.toLowerCase()));
204
+ status = available ? 'AVAILABLE' : 'UNAVAILABLE';
205
+ reason = available ? 'PROBED_SUPPORTED' : 'PROBED_UNSUPPORTED';
206
+ }
207
+ return {
208
+ id: item.id,
209
+ available,
210
+ status,
211
+ reason,
212
+ probe_id: item.probeId,
213
+ };
214
+ }
215
+
216
+ async function probeCapability(item, context, cache) {
217
+ const { execute, harness, spec, timeoutMs, maxOutputBytes, identity } = context;
218
+ const cacheKey = JSON.stringify(item.args);
219
+ let response = cache.get(cacheKey);
220
+ if (!response) {
221
+ response = executeBounded(execute, requestFor({
222
+ harness,
223
+ kind: 'behavior',
224
+ probeId: item.probeId,
225
+ command: spec.command,
226
+ args: item.args,
227
+ timeoutMs,
228
+ maxOutputBytes,
229
+ }), timeoutMs);
230
+ cache.set(cacheKey, response);
231
+ }
232
+ const execution = normalizeExecution(await response, maxOutputBytes);
233
+ return capabilityFromExecution(item, execution, identity);
234
+ }
235
+
236
+ async function probeCapabilities(context) {
237
+ const cache = new Map();
238
+ const capabilities = [];
239
+ for (const item of context.spec.capabilities) {
240
+ capabilities.push(await probeCapability(item, context, cache));
241
+ }
242
+ return capabilities;
243
+ }
244
+
245
+ async function probeHarness(options) {
246
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
247
+ throw new TypeError('probe options must be an object');
248
+ }
249
+ const { harness, execute } = options;
250
+ const spec = getHarnessProbeSpec(harness);
251
+ if (typeof execute !== 'function') throw new TypeError('execute must be a function');
252
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
253
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
254
+ assertBoundedPositiveInteger(timeoutMs, 'timeoutMs', MAX_TIMEOUT_MS);
255
+ assertBoundedPositiveInteger(maxOutputBytes, 'maxOutputBytes', MAX_OUTPUT_BYTES);
256
+ const observedAt = options.observedAt ?? new Date().toISOString();
257
+
258
+ const versionResponse = await executeBounded(execute, requestFor({
259
+ harness,
260
+ kind: 'version',
261
+ probeId: `${harness}.version.v1`,
262
+ command: spec.command,
263
+ args: spec.versionArgs,
264
+ timeoutMs,
265
+ maxOutputBytes,
266
+ }), timeoutMs);
267
+ const versionExecution = normalizeExecution(versionResponse, maxOutputBytes);
268
+ if (!versionExecution.complete) {
269
+ return buildResult({
270
+ harness,
271
+ spec,
272
+ observedAt,
273
+ status: 'INCOMPLETE',
274
+ availability: 'UNAVAILABLE',
275
+ identity: null,
276
+ version: null,
277
+ capabilities: incompleteCapabilities(spec, versionExecution.reason),
278
+ });
279
+ }
280
+ const identity = versionExecution.executableIdentity;
281
+ const version = parseVersion(versionExecution.stdout || versionExecution.stderr, spec.versionPattern);
282
+ if (!EXECUTABLE_IDENTITY_PATTERN.test(identity || '')) {
283
+ return buildResult({
284
+ harness,
285
+ spec,
286
+ observedAt,
287
+ status: 'INCOMPLETE',
288
+ availability: 'UNAVAILABLE',
289
+ identity: null,
290
+ version,
291
+ capabilities: incompleteCapabilities(spec, 'EXECUTABLE_IDENTITY_UNAVAILABLE'),
292
+ });
293
+ }
294
+ if (!version) {
295
+ return buildResult({
296
+ harness,
297
+ spec,
298
+ observedAt,
299
+ status: 'INCOMPLETE',
300
+ availability: 'UNAVAILABLE',
301
+ identity,
302
+ version: null,
303
+ capabilities: incompleteCapabilities(spec, 'MALFORMED_VERSION'),
304
+ });
305
+ }
306
+
307
+ const capabilities = await probeCapabilities({
308
+ execute,
309
+ harness,
310
+ spec,
311
+ timeoutMs,
312
+ maxOutputBytes,
313
+ identity,
314
+ });
315
+
316
+ return buildResult({
317
+ harness,
318
+ spec,
319
+ observedAt,
320
+ status: capabilities.some((item) => item.status === 'INCOMPLETE') ? 'INCOMPLETE' : 'PASS',
321
+ availability: 'AVAILABLE',
322
+ identity,
323
+ version,
324
+ capabilities,
325
+ });
326
+ }
327
+
328
+ async function probeAllHarnesses(options) {
329
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
330
+ throw new TypeError('probe options must be an object');
331
+ }
332
+ return Promise.all(HARNESS_IDS.map((harness) => probeHarness({ ...options, harness })));
333
+ }
334
+
335
+ module.exports = {
336
+ CAPABILITY_IDS,
337
+ DEFAULT_MAX_OUTPUT_BYTES,
338
+ DEFAULT_TIMEOUT_MS,
339
+ MAX_OUTPUT_BYTES,
340
+ MAX_TIMEOUT_MS,
341
+ HARNESS_IDS,
342
+ HARNESS_PROBE_SPECS,
343
+ PROBE_REVISION,
344
+ getHarnessProbeSpec,
345
+ probeAllHarnesses,
346
+ probeHarness,
347
+ };
@@ -0,0 +1,236 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ /**
5
+ * The local append-only JSONL sink shared by every Forge log that must survive
6
+ * without a database handle: the protected-state audit log and the subagent
7
+ * audit-evidence log. Kept in its own module so both writers get the same
8
+ * concurrency and cap behaviour instead of a second copy drifting from the first.
9
+ */
10
+
11
+ const TRIM_LOCK_ATTEMPTS = 5;
12
+ const TRIM_LOCK_RETRY_MS = 20;
13
+ /**
14
+ * How many times the trim re-reads the log for records appended since its
15
+ * snapshot. Each pass copies less than the last, so a small bound is enough to
16
+ * drain a burst without letting a busy writer keep the rename waiting forever.
17
+ */
18
+ const TRIM_DELTA_PASSES = 3;
19
+ /**
20
+ * How many times the trim rewrites the log to get it under the cap. A rewrite
21
+ * that drained concurrent appends can still sit above it, and one more quiet
22
+ * pass settles that; the bound stops a relentless writer from pinning the lock.
23
+ */
24
+ const NEWLINE_BYTE = 0x0a;
25
+ /** The trim rewrite takes milliseconds, so an older lock outlived its process. */
26
+ const TRIM_LOCK_STALE_MS = 5000;
27
+ /**
28
+ * Another process is using the file. POSIX reports only EEXIST/EBUSY, but
29
+ * Windows answers EPERM/EACCES both for a lock whose delete is still pending and
30
+ * for a replace of a log some other writer holds open.
31
+ */
32
+ const FILE_CONTENDED_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
33
+
34
+ function sleepSync(ms) {
35
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
36
+ }
37
+
38
+ function lockHeldSinceMs(lockPath) {
39
+ try {
40
+ return fs.lstatSync(lockPath).mtimeMs;
41
+ } catch (error) {
42
+ if (error.code === 'ENOENT') return null;
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ function acquireTrimLock(lockPath) {
48
+ for (let attempt = 0; attempt < TRIM_LOCK_ATTEMPTS; attempt += 1) {
49
+ try {
50
+ return fs.openSync(lockPath, 'wx');
51
+ } catch (error) {
52
+ if (!FILE_CONTENDED_CODES.has(error.code)) throw error;
53
+
54
+ const heldSince = lockHeldSinceMs(lockPath);
55
+ if (heldSince !== null && Date.now() - heldSince > TRIM_LOCK_STALE_MS) {
56
+ try {
57
+ fs.unlinkSync(lockPath);
58
+ } catch {
59
+ // Another writer reclaimed it first; retry against theirs.
60
+ }
61
+ continue;
62
+ }
63
+
64
+ sleepSync(TRIM_LOCK_RETRY_MS);
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ function releaseTrimLock(handle, lockPath) {
72
+ try {
73
+ fs.closeSync(handle);
74
+ } catch {
75
+ // Already closed.
76
+ }
77
+ try {
78
+ fs.unlinkSync(lockPath);
79
+ } catch {
80
+ // Already reclaimed as stale.
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Reads the log as whole records plus the byte offset they end at, so a later
86
+ * read can resume exactly where this one stopped. A trailing partial line is
87
+ * excluded from both: it belongs to an append still in flight, and cutting the
88
+ * offset at the last newline keeps the resume point on a record boundary.
89
+ */
90
+ function snapshotJsonl(logPath) {
91
+ let raw;
92
+ try {
93
+ raw = fs.readFileSync(logPath);
94
+ } catch (error) {
95
+ if (error.code !== 'ENOENT') throw error;
96
+ return { lines: [], size: 0 };
97
+ }
98
+
99
+ const size = raw.lastIndexOf(NEWLINE_BYTE) + 1;
100
+ return { lines: raw.subarray(0, size).toString('utf8').split('\n').filter(Boolean), size };
101
+ }
102
+
103
+ /** The whole records appended past `offset`, or null when there are none yet. */
104
+ function readAppendsSince(logPath, offset) {
105
+ let size;
106
+ try {
107
+ // Almost every trim runs with nobody appending, so answer from a stat alone
108
+ // and keep the extra work off the lock in the common case.
109
+ size = fs.statSync(logPath).size;
110
+ } catch (error) {
111
+ if (error.code === 'ENOENT') return null;
112
+ throw error;
113
+ }
114
+ if (size <= offset) return null;
115
+
116
+ let handle;
117
+ try {
118
+ handle = fs.openSync(logPath, 'r');
119
+ } catch (error) {
120
+ if (error.code === 'ENOENT') return null;
121
+ throw error;
122
+ }
123
+
124
+ try {
125
+ const buffer = Buffer.alloc(size - offset);
126
+ const read = fs.readSync(handle, buffer, 0, buffer.length, offset);
127
+ const end = buffer.subarray(0, read).lastIndexOf(NEWLINE_BYTE) + 1;
128
+ if (end === 0) return null;
129
+
130
+ const bytes = buffer.subarray(0, end);
131
+ return { bytes, records: bytes.toString('utf8').split('\n').filter(Boolean).length };
132
+ } finally {
133
+ fs.closeSync(handle);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * One rewrite of the log down to its cap, returning the record count it left
139
+ * behind — or null when a contended rename made it give up. Records drained from
140
+ * concurrent appenders ride above the cap, so the caller has to check.
141
+ * Must be called with the trim lock held.
142
+ */
143
+ function rewriteToCap(logPath, maxRecords, pendingLine = null) {
144
+ const snapshot = snapshotJsonl(logPath);
145
+ if (snapshot.lines.length < maxRecords && pendingLine !== null) {
146
+ fs.appendFileSync(logPath, `${pendingLine}\n`, 'utf8');
147
+ return snapshot.lines.length + 1;
148
+ }
149
+ if (snapshot.lines.length <= maxRecords && pendingLine === null) return snapshot.lines.length;
150
+
151
+ const candidates = pendingLine === null ? snapshot.lines : [...snapshot.lines, pendingLine];
152
+ const kept = candidates.slice(-maxRecords);
153
+ // Write-then-rename so a crash mid-trim cannot leave a torn log behind.
154
+ const tempPath = `${logPath}.${process.pid}.tmp`;
155
+ fs.writeFileSync(tempPath, `${kept.join('\n')}\n`, 'utf8');
156
+
157
+ // Appenders hold no lock, so records keep landing in the log this rename is
158
+ // about to replace. Copy them onto the temp file first — repeatedly, since
159
+ // the copy itself takes time a writer can append into. Nothing heavier goes
160
+ // between the last copy and the rename, which is what keeps the window small.
161
+ let offset = snapshot.size;
162
+ let drained = 0;
163
+ for (let pass = 0; pass < TRIM_DELTA_PASSES; pass += 1) {
164
+ const appended = readAppendsSince(logPath, offset);
165
+ if (appended === null) break;
166
+ fs.appendFileSync(tempPath, appended.bytes);
167
+ offset += appended.bytes.length;
168
+ drained += appended.records;
169
+ }
170
+ if (pendingLine !== null && drained > 0) {
171
+ const prepared = snapshotJsonl(tempPath).lines;
172
+ fs.writeFileSync(tempPath, `${prepared.slice(-maxRecords).join('\n')}\n`, 'utf8');
173
+ }
174
+
175
+ try {
176
+ fs.renameSync(tempPath, logPath);
177
+ } catch (error) {
178
+ // Another writer still has the log open for its append. Same answer as
179
+ // losing the lock race: drop this trim, the next writer will do it.
180
+ if (!FILE_CONTENDED_CODES.has(error.code)) throw error;
181
+ fs.rmSync(tempPath, { force: true });
182
+ return null;
183
+ }
184
+ return pendingLine === null ? kept.length + drained : Math.min(kept.length + drained, maxRecords);
185
+ }
186
+
187
+ function appendAtCap(logPath, recordLine, maxRecords) {
188
+ const snapshot = snapshotJsonl(logPath);
189
+
190
+ const lockPath = `${logPath}.lock`;
191
+ const lock = acquireTrimLock(lockPath);
192
+ // A held lock means another writer is already trimming. These logs are
193
+ // best-effort sinks on the hot path of a hook or a command, so skip and let
194
+ // the next writer past the cap trim instead of blocking the caller.
195
+ if (lock === null) {
196
+ fs.appendFileSync(logPath, `${recordLine}\n`, 'utf8');
197
+ return snapshot.lines.length + 1;
198
+ }
199
+
200
+ try {
201
+ const rewritten = rewriteToCap(logPath, maxRecords, recordLine);
202
+ if (rewritten === null) {
203
+ fs.appendFileSync(logPath, `${recordLine}\n`, 'utf8');
204
+ return snapshotJsonl(logPath).lines.length;
205
+ }
206
+ return rewritten;
207
+ } finally {
208
+ releaseTrimLock(lock, lockPath);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Below the cap, O_APPEND keeps each small record atomic. At the cap, the new
214
+ * record is prepared inside the locked replacement so failure cannot be
215
+ * reported after the authorization has already landed in the live log.
216
+ *
217
+ * That leaves one window, and the trim narrows rather than closes it: records
218
+ * appended after the trim's snapshot are drained onto the replacement file right
219
+ * before the rename, so only a record landing between the final drain and the
220
+ * rename syscall itself can still be lost. Closing it completely would mean
221
+ * locking every append, which these hot-path sinks do not pay for.
222
+ */
223
+ function appendCappedJsonlRecord(logPath, record, maxRecords) {
224
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
225
+ const recordLine = JSON.stringify(record);
226
+ const snapshot = snapshotJsonl(logPath);
227
+ if (snapshot.lines.length < maxRecords) {
228
+ fs.appendFileSync(logPath, `${recordLine}\n`, 'utf8');
229
+ return snapshot.lines.length + 1;
230
+ }
231
+ return appendAtCap(logPath, recordLine, maxRecords);
232
+ }
233
+
234
+ module.exports = {
235
+ appendCappedJsonlRecord,
236
+ };
@@ -135,9 +135,9 @@ function resolveCodexRepoSkillsDir(projectRoot) {
135
135
  * @param {boolean} [params.clean=false] - Remove stale canonical-managed dirs first.
136
136
  * @returns {{targetSkillsDir: string, written: string[]}}
137
137
  */
138
- function populateCodexRepoSkills({ sourceRoot, projectRoot, clean = false }) {
138
+ function populateCodexRepoSkills({ sourceRoot, projectRoot, clean = false, excludeRelativePaths }) {
139
139
  const targetSkillsDir = resolveCodexRepoSkillsDir(projectRoot);
140
- const { written } = populateAgentSkills({ sourceRoot, targetSkillsDir, clean });
140
+ const { written } = populateAgentSkills({ sourceRoot, targetSkillsDir, clean, excludeRelativePaths });
141
141
  return { targetSkillsDir, written };
142
142
  }
143
143
 
@@ -39,6 +39,7 @@ const commands = [
39
39
  { file: "explain.js", module: require("./explain") },
40
40
  { file: "export.js", module: require("./export") },
41
41
  { file: "gate.js", module: require("./gate") },
42
+ { file: "github.js", module: require("./github") },
42
43
  { file: "hooks.js", module: require("./hooks") },
43
44
  { file: "inbox.js", module: require("./inbox") },
44
45
  { file: "init.js", module: require("./init") },