forge-workflow 0.1.0-beta.2 → 0.1.0-beta.4
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.
- package/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
* handler (the 4th arg). For issue/alias commands this:
|
|
6
6
|
*
|
|
7
7
|
* 1. Strips the selector tokens (`--kernel`, `--issue-backend <val>`) from the
|
|
8
|
-
* args BEFORE they reach the handler
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* be mistaken for an issue id.
|
|
8
|
+
* args BEFORE they reach the handler. This is load-bearing: bin parseFlags()
|
|
9
|
+
* early-returns for issue passthrough commands, so these tokens otherwise flow
|
|
10
|
+
* untouched into the handler and the positional `kernel` value would be
|
|
11
|
+
* mistaken for an issue id.
|
|
13
12
|
* 2. Resolves the backend (flag > env > config > default) via the backend
|
|
14
13
|
* authority module (lib/issue-backend.js). The CLI flag tokens are mapped to
|
|
15
14
|
* that module's `deps.issueBackend` signal; env/config/default precedence is
|
|
16
|
-
* applied by the resolver itself.
|
|
15
|
+
* applied by the resolver itself. `kernel` is the only accepted value — a
|
|
16
|
+
* retired backend named on the FLAG is a hard error (the user typed it in this
|
|
17
|
+
* invocation, so silently ignoring it would run the wrong store), while the
|
|
18
|
+
* same value in env/config only warns and falls back.
|
|
17
19
|
* 3. When kernel, assembles the kernel deps (driver + flag) via the CLI broker
|
|
18
20
|
* factory so createKernelIssueBackend has a real driver (B1).
|
|
19
21
|
*
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
* @module commands/resolve-command-opts
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
|
-
const { resolveIssueBackend } = require('../issue-backend');
|
|
29
|
+
const { resolveIssueBackend, removedBackendHint } = require('../issue-backend');
|
|
28
30
|
const {
|
|
29
31
|
buildMigratedKernelIssueDeps: defaultBuildKernelIssueDeps,
|
|
30
32
|
} = require('../kernel/cli-broker-factory');
|
|
@@ -45,14 +47,11 @@ const ISSUE_COMMANDS = new Set([
|
|
|
45
47
|
'stale',
|
|
46
48
|
'orphans',
|
|
47
49
|
'lint',
|
|
48
|
-
// Epic grouping read
|
|
49
|
-
// verified Beads equivalent), so it belongs in ISSUE_COMMANDS like the rest.
|
|
50
|
+
// Epic grouping read (issue.children rollup).
|
|
50
51
|
'children',
|
|
51
|
-
// Lease-ownership verification
|
|
52
|
-
// so it is kernel-routed like the rest of the issue surface.
|
|
52
|
+
// Lease-ownership verification (leases live in the Kernel).
|
|
53
53
|
'owns',
|
|
54
|
-
// Active-lease listing
|
|
55
|
-
// is kernel-routed like the rest of the issue surface.
|
|
54
|
+
// Active-lease listing (leases live in the Kernel).
|
|
56
55
|
'claims',
|
|
57
56
|
'search',
|
|
58
57
|
'stats',
|
|
@@ -101,7 +100,7 @@ function stripSelectorTokens(rawArgs = []) {
|
|
|
101
100
|
flags.issueBackend = next;
|
|
102
101
|
i += 1;
|
|
103
102
|
} else {
|
|
104
|
-
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel
|
|
103
|
+
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
|
|
105
104
|
}
|
|
106
105
|
continue;
|
|
107
106
|
}
|
|
@@ -109,7 +108,7 @@ function stripSelectorTokens(rawArgs = []) {
|
|
|
109
108
|
// Equals form: reject an empty value (`--issue-backend=`) for the same reason.
|
|
110
109
|
const value = token.slice(ISSUE_BACKEND_FLAG.length + 1).trim();
|
|
111
110
|
if (!value) {
|
|
112
|
-
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel
|
|
111
|
+
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
|
|
113
112
|
}
|
|
114
113
|
flags.issueBackend = value;
|
|
115
114
|
continue;
|
|
@@ -122,12 +121,14 @@ function stripSelectorTokens(rawArgs = []) {
|
|
|
122
121
|
/**
|
|
123
122
|
* Reduce the stripped selector flags to a single explicit backend value (or null
|
|
124
123
|
* when no flag selects one), honoring `--kernel` / `--issue-backend` equivalence
|
|
125
|
-
* and rejecting a mutually-exclusive conflict.
|
|
126
|
-
* here
|
|
124
|
+
* and rejecting a mutually-exclusive conflict. A RETIRED backend value is rejected
|
|
125
|
+
* here with the migrate pointer; any other value is NOT validated here — the backend
|
|
126
|
+
* authority module normalizes/warns on unknown values.
|
|
127
127
|
*
|
|
128
128
|
* @param {{ kernel?: boolean, issueBackend?: string }} flags
|
|
129
129
|
* @returns {string|null}
|
|
130
|
-
* @throws {Error} when --kernel and --issue-backend select different backends
|
|
130
|
+
* @throws {Error} when --kernel and --issue-backend select different backends, or
|
|
131
|
+
* when --issue-backend names a removed backend.
|
|
131
132
|
*/
|
|
132
133
|
function resolveFlagBackend(flags = {}) {
|
|
133
134
|
const fromBackend = typeof flags.issueBackend === 'string' && flags.issueBackend.trim()
|
|
@@ -142,28 +143,20 @@ function resolveFlagBackend(flags = {}) {
|
|
|
142
143
|
);
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|| require('./migrate').autoMigrateBeadsAtRuntime;
|
|
157
|
-
await autoMigrate({
|
|
158
|
-
projectRoot: deps.projectRoot,
|
|
159
|
-
databasePath: kernelDeps.kernelDatabasePath,
|
|
160
|
-
broker: kernelDeps.kernelBroker,
|
|
161
|
-
driver: kernelDeps.kernelDriver,
|
|
162
|
-
});
|
|
163
|
-
} catch {
|
|
164
|
-
// A migration-hook failure (including a require() failure) must never break the
|
|
165
|
-
// command it rides on.
|
|
146
|
+
// Hard error (not the resolver's warn+fallback) because the value was typed into
|
|
147
|
+
// THIS invocation: silently running the kernel after the user explicitly asked for
|
|
148
|
+
// a different store is the kind of surprise that loses writes.
|
|
149
|
+
if (fromBackend) {
|
|
150
|
+
const removedHint = removedBackendHint(fromBackend);
|
|
151
|
+
if (removedHint) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`${ISSUE_BACKEND_FLAG} ${fromBackend} is no longer supported: ${removedHint}. `
|
|
154
|
+
+ `Only '${KERNEL}' is accepted.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
166
157
|
}
|
|
158
|
+
|
|
159
|
+
return fromKernel || fromBackend || null;
|
|
167
160
|
}
|
|
168
161
|
|
|
169
162
|
/**
|
|
@@ -195,9 +188,6 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
|
|
|
195
188
|
databasePath: deps.databasePath,
|
|
196
189
|
gitCommonDir: deps.gitCommonDir,
|
|
197
190
|
});
|
|
198
|
-
// Same first-use safety net as the issue branch: an export-first upgrader would
|
|
199
|
-
// otherwise drain an EMPTY kernel and never trigger the one-time Beads import.
|
|
200
|
-
await runRuntimeAutoMigrate(deps, kernelDeps);
|
|
201
191
|
return { commandOpts: { _broker: kernelDeps.kernelBroker }, args: rawArgs };
|
|
202
192
|
} catch {
|
|
203
193
|
return { commandOpts: {}, args: rawArgs };
|
|
@@ -212,22 +202,15 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
|
|
|
212
202
|
|
|
213
203
|
const { args, flags } = stripSelectorTokens(rawArgs);
|
|
214
204
|
const flagBackend = resolveFlagBackend(flags);
|
|
215
|
-
|
|
205
|
+
// Still routed through the authority module (rather than hardcoding 'kernel') so a
|
|
206
|
+
// retired/unknown env or config value produces its warning exactly once, here, on
|
|
207
|
+
// the way to the kernel fallback.
|
|
208
|
+
resolveIssueBackend({
|
|
216
209
|
deps: flagBackend ? { issueBackend: flagBackend } : {},
|
|
217
210
|
env,
|
|
218
211
|
projectRoot: deps.projectRoot,
|
|
219
212
|
});
|
|
220
213
|
|
|
221
|
-
if (issueBackend !== KERNEL) {
|
|
222
|
-
return {
|
|
223
|
-
commandOpts: {
|
|
224
|
-
issueBackend,
|
|
225
|
-
useKernelBroker: false,
|
|
226
|
-
},
|
|
227
|
-
args,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
214
|
// Kernel backend: assemble the driver (B1) + migrated broker (B2). The factory
|
|
232
215
|
// builder constructs the driver, builds the broker, and runs initialize().
|
|
233
216
|
const kernelDeps = await buildKernelIssueDeps({
|
|
@@ -236,13 +219,6 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
|
|
|
236
219
|
gitCommonDir: deps.gitCommonDir,
|
|
237
220
|
});
|
|
238
221
|
|
|
239
|
-
// Safety net: the kernel is the default backend, but onboarding auto-migrate runs
|
|
240
|
-
// only from `forge setup`/`init`. An existing repo whose user merely upgrades forge
|
|
241
|
-
// would read an EMPTY kernel here and their existing Beads issues would appear to
|
|
242
|
-
// vanish. Import them ONCE on first kernel use (idempotent, gated by an in-DB marker
|
|
243
|
-
// that shares the kernel DB lifecycle, stderr only).
|
|
244
|
-
await runRuntimeAutoMigrate(deps, kernelDeps);
|
|
245
|
-
|
|
246
222
|
return {
|
|
247
223
|
commandOpts: {
|
|
248
224
|
issueBackend: KERNEL,
|
package/lib/commands/claim.js
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
const { runIssueSubcommand } = require('./_issue');
|
|
4
4
|
|
|
5
5
|
// `forge claim <id>` claims an issue through the SHARED issue dispatch
|
|
6
|
-
// (runIssueSubcommand): backend resolution (Kernel
|
|
7
|
-
//
|
|
8
|
-
// Beads backend translates claim to `update <id> --claim`), contract
|
|
9
|
-
// normalization, AND the check-after-write verification loop
|
|
6
|
+
// (runIssueSubcommand): backend resolution (the Kernel is the only backend),
|
|
7
|
+
// contract normalization, AND the check-after-write verification loop
|
|
10
8
|
// (gate.issue_verify). This command previously inlined its own copy of the
|
|
11
9
|
// dispatch, which silently BYPASSED the boundary verify — the exact surface
|
|
12
10
|
// where the d71a824b phantom-claim replay lied to a losing agent — so it now
|
package/lib/commands/gate.js
CHANGED
|
@@ -29,11 +29,21 @@ const {
|
|
|
29
29
|
isGateApproved,
|
|
30
30
|
} = require('../gate-events');
|
|
31
31
|
|
|
32
|
+
// The doc-update gate folds under this noun as `gate doc` (P2, kernel issue
|
|
33
|
+
// 6ab3f30c) — it is a gate concern, not a `pr` one. `doc` delegates to the
|
|
34
|
+
// standalone doc-gate command (same code); bare `forge doc-gate` stays registered
|
|
35
|
+
// as a back-compat alias. Required lazily so the module graph has no cycle and the
|
|
36
|
+
// routed handler is resolved at dispatch time.
|
|
37
|
+
const docGate = require('./doc-gate');
|
|
38
|
+
|
|
32
39
|
const TOGGLE_ACTIONS = new Set(['enable', 'disable']);
|
|
33
40
|
const EVENT_ACTIONS = new Set(['approve', 'reject', 'status', 'check']);
|
|
34
41
|
|
|
35
42
|
function usage() {
|
|
36
|
-
return
|
|
43
|
+
return [
|
|
44
|
+
'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--reason <text>] [--json]',
|
|
45
|
+
' forge gate doc <detect|check|init|okf|...> [args] (doc-update gate; = forge doc-gate, run `forge doc-gate --help`)',
|
|
46
|
+
].join('\n');
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
// The known-toggle set is gates PLUS unlocked toggleable rails (e.g.
|
|
@@ -165,6 +175,13 @@ async function handleCheck(issueId, gateId, projectRoot, opts) {
|
|
|
165
175
|
async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {}) {
|
|
166
176
|
const [action, ...rest] = args;
|
|
167
177
|
|
|
178
|
+
// `gate doc [<doc-gate sub> ...]` → the standalone doc-gate handler, with the
|
|
179
|
+
// consumed `doc` token dropped so its own arg shape (detect/check/init/okf …)
|
|
180
|
+
// and flags (`--base`/`--head`/`--json`/`--skip` …) reach it byte-identically.
|
|
181
|
+
if (action === 'doc') {
|
|
182
|
+
return docGate.handler(rest, flags, projectRoot, opts);
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
if (TOGGLE_ACTIONS.has(action)) {
|
|
169
186
|
return handleToggle(action, rest[0], projectRoot);
|
|
170
187
|
}
|
|
@@ -180,7 +197,7 @@ async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {})
|
|
|
180
197
|
|
|
181
198
|
return {
|
|
182
199
|
success: false,
|
|
183
|
-
error: `Expected 'enable', 'disable', 'approve', 'reject', 'status', or '
|
|
200
|
+
error: `Expected 'enable', 'disable', 'approve', 'reject', 'status', 'check', or 'doc'.\n${usage()}`,
|
|
184
201
|
};
|
|
185
202
|
}
|
|
186
203
|
|
package/lib/commands/hooks.js
CHANGED
|
@@ -27,14 +27,82 @@ const {
|
|
|
27
27
|
renderGlobalHookBlock,
|
|
28
28
|
installGlobalHooks,
|
|
29
29
|
} = require('../hook-global-installer');
|
|
30
|
-
const
|
|
31
|
-
const
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
|
|
33
|
+
const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
|
|
32
34
|
const { collectInbox, buildInboxNudge } = require('../inbox');
|
|
35
|
+
const { collectDigest } = require('../pr-monitor/digest');
|
|
36
|
+
const { loadDispatchText } = require('../using-forge');
|
|
37
|
+
const projectMemory = require('../project-memory');
|
|
38
|
+
const { parseHookInput, selectInjection, DEFAULT_TOKEN_BUDGET, DEFAULT_SCORE_FLOOR } = require('../memory-recall');
|
|
39
|
+
const { fenceUntrusted } = require('../untrusted-content');
|
|
40
|
+
const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
41
|
+
|
|
42
|
+
// Default-ON rail; `forge gate disable rail.memory_recall` turns tier-2 off. Mirrors
|
|
43
|
+
// autoShepherdRailEnabled (lib/commands/ship.js): absent id = enabled, fail-open to true.
|
|
44
|
+
const MEMORY_RECALL_RAIL = 'rail.memory_recall';
|
|
45
|
+
// bm25 candidate pool to rank/floor down from. Wider than what we inject so the floor +
|
|
46
|
+
// dedupe have room (mirrors the research "widen before ranking" guidance).
|
|
47
|
+
const MEMORY_RECALL_CANDIDATES = 25;
|
|
48
|
+
// Cross-turn dedupe memory: how many recently-injected keys to remember per session.
|
|
49
|
+
const SEEN_KEYS_CAP = 40;
|
|
50
|
+
|
|
51
|
+
function memoryRecallRailEnabled(projectRoot, resolveGraph = getResolvedRuntimeGraph) {
|
|
52
|
+
try {
|
|
53
|
+
const graph = resolveGraph({ projectRoot });
|
|
54
|
+
const rail = [...(graph.rails || []), ...(graph.gates || [])].find(entry => entry.id === MEMORY_RECALL_RAIL);
|
|
55
|
+
return !(rail?.enabled === false);
|
|
56
|
+
} catch {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read the hook's stdin payload (Claude delivers UserPromptSubmit JSON on fd 0). Never
|
|
62
|
+
// throws — no stdin / a closed fd yields '' so the hook fails open.
|
|
63
|
+
function readHookStdin() {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(0, 'utf8');
|
|
66
|
+
} catch {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function seenPath(projectRoot, sessionId) {
|
|
72
|
+
const safe = String(sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
73
|
+
return path.join(projectRoot, '.forge', 'memory-recall', `${safe}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Keys injected on recent turns of THIS session (cross-turn dedupe). Best-effort: any read
|
|
77
|
+
// error yields [] so a first turn or a corrupt file simply injects without exclusion.
|
|
78
|
+
function loadSeenKeys(projectRoot, sessionId) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(fs.readFileSync(seenPath(projectRoot, sessionId), 'utf8'));
|
|
81
|
+
return Array.isArray(parsed) ? parsed.filter(k => typeof k === 'string') : [];
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Append the just-injected keys to the session's seen-list (newest last, capped). Best-effort.
|
|
88
|
+
function saveSeenKeys(projectRoot, sessionId, keys) {
|
|
89
|
+
try {
|
|
90
|
+
const merged = [...loadSeenKeys(projectRoot, sessionId), ...keys].slice(-SEEN_KEYS_CAP);
|
|
91
|
+
const file = seenPath(projectRoot, sessionId);
|
|
92
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
93
|
+
fs.writeFileSync(file, JSON.stringify(merged), 'utf8');
|
|
94
|
+
} catch {
|
|
95
|
+
// Dedupe is a nicety, not a correctness gate — a failed write never breaks the prompt.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
33
98
|
|
|
34
99
|
function usage() {
|
|
35
100
|
return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
|
|
36
101
|
+ ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
|
|
37
|
-
+ ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)'
|
|
102
|
+
+ ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
|
|
103
|
+
+ ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
|
|
104
|
+
+ ' forge hooks memory-recall --harness <claude> (machine-facing; emits UserPromptSubmit query-relevant memory)\n'
|
|
105
|
+
+ ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
|
|
38
106
|
}
|
|
39
107
|
|
|
40
108
|
/** Parse `--harness <h>` (defaults to claude) from a session-start arg slice. */
|
|
@@ -46,6 +114,48 @@ function parseHarness(rest) {
|
|
|
46
114
|
return 'claude';
|
|
47
115
|
}
|
|
48
116
|
|
|
117
|
+
/** Parse `--trigger <t>` (defaults to stop) from a capture arg slice. */
|
|
118
|
+
function parseTrigger(rest) {
|
|
119
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
120
|
+
if (rest[i] === '--trigger') return rest[i + 1] || 'stop';
|
|
121
|
+
if (rest[i].startsWith('--trigger=')) return rest[i].slice('--trigger='.length);
|
|
122
|
+
}
|
|
123
|
+
return 'stop';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Capture bounds — the snapshot is a small NUDGE, not a manual. A hard issue cap + per-title
|
|
127
|
+
// cap + overall body cap keep the note (which is re-injected at the NEXT session's SessionStart
|
|
128
|
+
// digest) token-bounded. Tags mark it a session-summary typed note AND a Forge auto-capture
|
|
129
|
+
// (the latter is the dedupe/idempotency key that stops per-turn Stop flooding).
|
|
130
|
+
const CAPTURE_ISSUE_CAP = 5;
|
|
131
|
+
const CAPTURE_TITLE_CAP = 80;
|
|
132
|
+
const CAPTURE_NOTE_CAP = 1000;
|
|
133
|
+
const CAPTURE_AUTO_TAG = 'forge:auto-capture';
|
|
134
|
+
const CAPTURE_TAGS = ['type:session-summary', CAPTURE_AUTO_TAG];
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the deterministic, token-bounded capture note body. PURE. The body deliberately
|
|
138
|
+
* carries NO timestamp (the store stamps its own) so an unchanged session state yields a
|
|
139
|
+
* byte-identical body across repeated Stops — that identity is what the dedupe keys on.
|
|
140
|
+
* @param {string} trigger - 'precompact' | 'stop'
|
|
141
|
+
* @param {object[]} issues - in-progress issues (title/id defensively resolved)
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
function buildCaptureNote(trigger, issues) {
|
|
145
|
+
const capped = issues.slice(0, CAPTURE_ISSUE_CAP);
|
|
146
|
+
const lines = capped.map(issue => {
|
|
147
|
+
const title = String((issue && (issue.title || issue.id)) || 'untitled').replace(/\s+/g, ' ').trim();
|
|
148
|
+
return `- ${title.length > CAPTURE_TITLE_CAP ? `${title.slice(0, CAPTURE_TITLE_CAP)}…` : title}`;
|
|
149
|
+
});
|
|
150
|
+
const more = issues.length > capped.length ? `\n- …and ${issues.length - capped.length} more` : '';
|
|
151
|
+
const body = lines.length
|
|
152
|
+
? `Session boundary (${trigger}) — in-progress:\n${lines.join('\n')}${more}`
|
|
153
|
+
: `Session boundary (${trigger}) — no in-progress issues.`;
|
|
154
|
+
// Reserve one char for the appended ellipsis so the FINAL note (incl. '…') is ≤ the cap,
|
|
155
|
+
// never CAPTURE_NOTE_CAP + 1.
|
|
156
|
+
return body.length > CAPTURE_NOTE_CAP ? `${body.slice(0, CAPTURE_NOTE_CAP - 1)}…` : body;
|
|
157
|
+
}
|
|
158
|
+
|
|
49
159
|
/** Wrap a digest into a harness-native SessionStart payload, or '' when unsupported. */
|
|
50
160
|
function formatSessionStart(harness, text) {
|
|
51
161
|
if (harness === 'claude') {
|
|
@@ -70,17 +180,35 @@ function formatSessionStart(harness, text) {
|
|
|
70
180
|
* @returns {Promise<{ success: boolean, output: string }>}
|
|
71
181
|
*/
|
|
72
182
|
async function handleSessionStart(rest, projectRoot, opts = {}) {
|
|
183
|
+
const harness = parseHarness(rest);
|
|
184
|
+
if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
|
|
185
|
+
|
|
186
|
+
// NOTE: the autonomous-shepherd session-start trigger is deferred to W-S4c along with the
|
|
187
|
+
// per-command dispatch trigger — both need a firing-policy + containment design (a naive
|
|
188
|
+
// trigger spawns a session-outliving daemon that breaks test isolation).
|
|
189
|
+
|
|
190
|
+
// The using-forge dispatch bootstrap is injected FIRST so Forge skills auto-trigger from turn
|
|
191
|
+
// one (the Superpowers mechanism): a reasoning-driven system, not just harness description
|
|
192
|
+
// matching. It is a deterministic file read that survives a kernel outage. The memory digest
|
|
193
|
+
// (remembered notes + top open issues) is appended when present. Either alone is enough to
|
|
194
|
+
// inject; a total blank yields '' (the harness injects nothing). FAIL-OPEN throughout.
|
|
195
|
+
let dispatch = '';
|
|
196
|
+
try {
|
|
197
|
+
// No projectRoot: the dispatch skill is read from the Forge PACKAGE's canonical skills/
|
|
198
|
+
// (a set-up consumer project has no root skills/, only generated mirrors).
|
|
199
|
+
dispatch = (opts.loadDispatchText || loadDispatchText)() || '';
|
|
200
|
+
} catch { /* keep '' — a missing dispatch skill must not break session start */ }
|
|
201
|
+
|
|
202
|
+
let digestText = '';
|
|
73
203
|
try {
|
|
74
|
-
const harness = parseHarness(rest);
|
|
75
|
-
if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
|
|
76
204
|
const data = await collectDigestData(projectRoot, opts);
|
|
77
205
|
const digest = buildMemoryDigest(data, opts);
|
|
78
|
-
if (digest.empty)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
206
|
+
if (!digest.empty) digestText = digest.text;
|
|
207
|
+
} catch { /* fail-open: a kernel outage must not suppress the dispatch bootstrap */ }
|
|
208
|
+
|
|
209
|
+
const combined = [dispatch, digestText].filter(Boolean).join('\n\n');
|
|
210
|
+
if (!combined) return { success: true, output: '' };
|
|
211
|
+
return { success: true, output: formatSessionStart(harness, combined) };
|
|
84
212
|
}
|
|
85
213
|
|
|
86
214
|
/** Wrap a digest into a harness-native UserPromptSubmit payload, or '' when unsupported. */
|
|
@@ -126,6 +254,157 @@ async function handleInboxPickup(rest, projectRoot, opts = {}) {
|
|
|
126
254
|
}
|
|
127
255
|
}
|
|
128
256
|
|
|
257
|
+
/**
|
|
258
|
+
* `forge hooks shepherd-events --harness <h>` — the PR-shepherd CONTEXT hook. On each
|
|
259
|
+
* prompt it emits harness-native UserPromptSubmit JSON carrying a COMPACT, capped digest
|
|
260
|
+
* of NEW PR-monitor events (verdict changes, failed checks, new threads, merged/closed)
|
|
261
|
+
* since the last read across all open-PR journals, then advances the per-PR consumer
|
|
262
|
+
* cursor so nothing re-surfaces. This is the CONSUMER side of the constant watcher: the
|
|
263
|
+
* watch loop writes the journal, this pushes the deltas to the working agent. It reads the
|
|
264
|
+
* user's OWN local journal via a supported hook — it NEVER injects into stdin and NEVER
|
|
265
|
+
* drives the agent (Anthropic Usage Policy). FAIL-OPEN: any failure, an unsupported
|
|
266
|
+
* harness, or no new events yields '' (the harness injects nothing). NEVER throws.
|
|
267
|
+
*
|
|
268
|
+
* @param {string[]} rest - args after the `shepherd-events` action.
|
|
269
|
+
* @param {string} projectRoot
|
|
270
|
+
* @param {object} [opts] - injectable digest collector ({ collectDigest }).
|
|
271
|
+
* @returns {{ success: boolean, output: string }}
|
|
272
|
+
*/
|
|
273
|
+
function handleShepherdEvents(rest, projectRoot, opts = {}) {
|
|
274
|
+
try {
|
|
275
|
+
const harness = parseHarness(rest);
|
|
276
|
+
const capability = userPromptSubmitCapability(harness);
|
|
277
|
+
// Honest capability matrix: a non-Claude harness gets an explicit skip
|
|
278
|
+
// reason as surface-only result metadata (for callers/telemetry) but NEVER
|
|
279
|
+
// any injected output — `reason` must not drive the agent.
|
|
280
|
+
if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
|
|
281
|
+
const collect = opts.collectDigest || collectDigest;
|
|
282
|
+
const { text } = collect({ root: projectRoot });
|
|
283
|
+
if (!text) return { success: true, output: '' };
|
|
284
|
+
return { success: true, output: formatUserPromptSubmit(harness, text) };
|
|
285
|
+
} catch {
|
|
286
|
+
// Fail-open: a context hook must never break a prompt.
|
|
287
|
+
return { success: true, output: '' };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `forge hooks memory-recall --harness <h>` — the QUERY-RELEVANT memory tier (tier-2). On each
|
|
293
|
+
* prompt it reads the submitted prompt from the hook's stdin, ranks stored memories by BM25
|
|
294
|
+
* relevance to it (NOT recency — that is the SessionStart digest's job), applies a relevance
|
|
295
|
+
* floor + anaphora guard + cross-turn dedupe + a hard token budget, and emits the survivors as
|
|
296
|
+
* harness-native UserPromptSubmit context. Complements the always-on recency digest: this one
|
|
297
|
+
* answers "what memory is relevant to THIS turn".
|
|
298
|
+
*
|
|
299
|
+
* Compliance + safety (verified against the Claude Code hooks contract, kernel issue 781f6f65):
|
|
300
|
+
* - It READS its own hook stdin (the supported input channel) — it does NOT inject into a
|
|
301
|
+
* running session's stdin and never drives the agent (Anthropic Usage Policy).
|
|
302
|
+
* - additionalContext APPENDS to history every prompt, so the injection is tiny and gated:
|
|
303
|
+
* below the relevance floor, or on a low-signal (anaphora) prompt, it injects NOTHING.
|
|
304
|
+
* - Injected memory bodies are UNTRUSTED (a planted note could carry directives), so each is
|
|
305
|
+
* provenance-fenced.
|
|
306
|
+
* - UserPromptSubmit has a 30s timeout and blocks the prompt; the read is local BM25 over
|
|
307
|
+
* SQLite (sub-ms) and FAIL-OPEN — any error, a disabled rail, an unsupported harness, or
|
|
308
|
+
* nothing relevant yields '' and the prompt proceeds untouched. NEVER throws.
|
|
309
|
+
* - Kill-switch: `forge gate disable rail.memory_recall`.
|
|
310
|
+
*
|
|
311
|
+
* @param {string[]} rest - args after the `memory-recall` action.
|
|
312
|
+
* @param {string} projectRoot
|
|
313
|
+
* @param {object} [opts] - injectable seams for tests: { railEnabled, readInput, search,
|
|
314
|
+
* loadSeen, saveSeen, scoreFloor, tokenBudget }.
|
|
315
|
+
* @returns {{ success: boolean, output: string }}
|
|
316
|
+
*/
|
|
317
|
+
function handleMemoryRecall(rest, projectRoot, opts = {}) {
|
|
318
|
+
try {
|
|
319
|
+
const harness = parseHarness(rest);
|
|
320
|
+
if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
|
|
321
|
+
|
|
322
|
+
const railEnabled = opts.railEnabled || memoryRecallRailEnabled;
|
|
323
|
+
if (!railEnabled(projectRoot)) return { success: true, output: '' };
|
|
324
|
+
|
|
325
|
+
const readInput = opts.readInput || readHookStdin;
|
|
326
|
+
const { prompt, sessionId } = parseHookInput(readInput());
|
|
327
|
+
if (!prompt) return { success: true, output: '' };
|
|
328
|
+
|
|
329
|
+
const search = opts.search
|
|
330
|
+
|| ((root, query, limit) => projectMemory.searchRankedScored(root, query, limit));
|
|
331
|
+
const loadSeen = opts.loadSeen || loadSeenKeys;
|
|
332
|
+
const saveSeen = opts.saveSeen || saveSeenKeys;
|
|
333
|
+
|
|
334
|
+
const hits = search(projectRoot, prompt, MEMORY_RECALL_CANDIDATES) || [];
|
|
335
|
+
const excludeKeys = loadSeen(projectRoot, sessionId) || [];
|
|
336
|
+
const { lines, injectedKeys } = selectInjection({
|
|
337
|
+
query: prompt,
|
|
338
|
+
hits,
|
|
339
|
+
scoreFloor: typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR,
|
|
340
|
+
tokenBudget: opts.tokenBudget || DEFAULT_TOKEN_BUDGET,
|
|
341
|
+
excludeKeys,
|
|
342
|
+
});
|
|
343
|
+
if (!lines.length) return { success: true, output: '' };
|
|
344
|
+
|
|
345
|
+
saveSeen(projectRoot, sessionId, injectedKeys);
|
|
346
|
+
const fenced = lines.map(line => fenceUntrusted(line, { source: 'memory' })).join('\n');
|
|
347
|
+
return { success: true, output: formatUserPromptSubmit(harness, fenced) };
|
|
348
|
+
} catch {
|
|
349
|
+
// Fail-open: a context hook must never break a prompt.
|
|
350
|
+
return { success: true, output: '' };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
|
|
356
|
+
* PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
|
|
357
|
+
* session-summary note into the memory store BEFORE learnings are lost. This is the WRITE half
|
|
358
|
+
* of Forge memory (SessionStart only INJECTS). It PERSISTS to the store and emits NO stdout — a
|
|
359
|
+
* Stop hook that printed text would inject into the turn, and it never drives the agent
|
|
360
|
+
* (Anthropic Usage Policy). FAIL-OPEN: any failure, an unsupported harness, or nothing worth
|
|
361
|
+
* capturing yields '' and no write. NEVER throws.
|
|
362
|
+
*
|
|
363
|
+
* Flooding guard: a plain Stop with nothing in progress is skipped (Stop fires every turn), and
|
|
364
|
+
* a byte-identical repeat of the newest auto-capture note is skipped — so only meaningful,
|
|
365
|
+
* changed session state is written. PreCompact records a boundary even when nothing is in
|
|
366
|
+
* progress (unlike Stop), but it still goes through the same dedupe — a byte-identical
|
|
367
|
+
* PreCompact repeat is skipped too.
|
|
368
|
+
*
|
|
369
|
+
* @param {string[]} rest - args after the `capture` action.
|
|
370
|
+
* @param {string} projectRoot
|
|
371
|
+
* @param {object} [opts] - injectable { fetchIssues, fetchNotes, append } for tests.
|
|
372
|
+
* @returns {Promise<{ success: boolean, output: string }>}
|
|
373
|
+
*/
|
|
374
|
+
async function handleCapture(rest, projectRoot, opts = {}) {
|
|
375
|
+
try {
|
|
376
|
+
const harness = parseHarness(rest);
|
|
377
|
+
if (!sessionEndCapability(harness).rendered) return { success: true, output: '' };
|
|
378
|
+
const trigger = parseTrigger(rest);
|
|
379
|
+
|
|
380
|
+
const fetchIssues = opts.fetchIssues || defaultFetchIssues;
|
|
381
|
+
const claimed = await fetchIssues(projectRoot, 'in_progress', opts);
|
|
382
|
+
const issues = Array.isArray(claimed) ? claimed : [];
|
|
383
|
+
|
|
384
|
+
// Stop fires every turn; a plain Stop with nothing in progress is not worth a note.
|
|
385
|
+
// PreCompact is rare and precedes real context loss, so it records a boundary even with
|
|
386
|
+
// nothing in progress — but it is NOT exempt from the byte-identical dedupe below.
|
|
387
|
+
if (trigger !== 'precompact' && issues.length === 0) return { success: true, output: '' };
|
|
388
|
+
|
|
389
|
+
const body = buildCaptureNote(trigger, issues);
|
|
390
|
+
|
|
391
|
+
// Content dedupe: if the newest auto-capture note is byte-identical, this is a repeat of
|
|
392
|
+
// an unchanged session — skip the write so the store never floods with duplicates.
|
|
393
|
+
const fetchNotes = opts.fetchNotes || defaultFetchNotes;
|
|
394
|
+
const recent = await fetchNotes(projectRoot, { ...opts, noteLimit: 10 });
|
|
395
|
+
const lastCapture = (Array.isArray(recent) ? recent : [])
|
|
396
|
+
.find(note => Array.isArray(note && note.tags) && note.tags.includes(CAPTURE_AUTO_TAG));
|
|
397
|
+
if (lastCapture && lastCapture.note === body) return { success: true, output: '' };
|
|
398
|
+
|
|
399
|
+
const append = opts.append || require('../memory/router').append;
|
|
400
|
+
append(projectRoot, body, { tags: CAPTURE_TAGS });
|
|
401
|
+
return { success: true, output: '' };
|
|
402
|
+
} catch {
|
|
403
|
+
// Fail-open: a capture hook must never break a session.
|
|
404
|
+
return { success: true, output: '' };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
129
408
|
function parseInstallArgs(rest) {
|
|
130
409
|
const parsed = { global: false, dryRun: false, harness: 'all', unknown: [] };
|
|
131
410
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -222,10 +501,13 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
|
|
|
222
501
|
const action = args[0];
|
|
223
502
|
if (action === 'session-start') return handleSessionStart(args.slice(1), projectRoot, opts);
|
|
224
503
|
if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
|
|
504
|
+
if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
|
|
505
|
+
if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
|
|
506
|
+
if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
|
|
225
507
|
if (action === 'install') return handleInstall(args, flags, opts);
|
|
226
508
|
return {
|
|
227
509
|
success: false,
|
|
228
|
-
error: `forge hooks supports: install, session-start, inbox-pickup.\n${usage()}`,
|
|
510
|
+
error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, capture.\n${usage()}`,
|
|
229
511
|
};
|
|
230
512
|
}
|
|
231
513
|
|