forge-workflow 0.1.0-beta.2 → 0.1.0-beta.3

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 (57) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +1 -0
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge.js +90 -19
  8. package/docs/guides/SETUP.md +4 -1
  9. package/docs/guides/SUPPORT.md +5 -0
  10. package/docs/reference/COMMANDS.md +9 -0
  11. package/docs/reference/shepherd.md +42 -2
  12. package/lib/activation/ensure-forge-home.js +135 -0
  13. package/lib/adapters/beads-kernel-compat.js +67 -0
  14. package/lib/adoption-profiles.js +17 -4
  15. package/lib/beads-detect.js +60 -0
  16. package/lib/beads-nudge.js +91 -0
  17. package/lib/commands/_aliases.js +248 -0
  18. package/lib/commands/_issue.js +39 -0
  19. package/lib/commands/_manifest.js +2 -0
  20. package/lib/commands/_registry.js +14 -0
  21. package/lib/commands/_resolve-command-opts.js +0 -31
  22. package/lib/commands/gate.js +19 -2
  23. package/lib/commands/hooks.js +139 -4
  24. package/lib/commands/init.js +26 -20
  25. package/lib/commands/memory.js +81 -0
  26. package/lib/commands/migrate.js +0 -161
  27. package/lib/commands/plan.js +48 -8
  28. package/lib/commands/pr.js +88 -0
  29. package/lib/commands/push.js +66 -0
  30. package/lib/commands/recall.js +67 -12
  31. package/lib/commands/recap.js +18 -4
  32. package/lib/commands/release.js +14 -1
  33. package/lib/commands/remember.js +86 -20
  34. package/lib/commands/setup.js +135 -72
  35. package/lib/commands/shepherd.js +67 -2
  36. package/lib/commands/ship.js +40 -4
  37. package/lib/commands/worktree.js +60 -4
  38. package/lib/core/runtime-graph.js +34 -3
  39. package/lib/gate-events.js +54 -55
  40. package/lib/global-flags.js +30 -0
  41. package/lib/grounding/context-events.js +230 -0
  42. package/lib/grounding/read-first.js +112 -0
  43. package/lib/hook-renderer.js +93 -3
  44. package/lib/kernel/backing-issue.js +7 -1
  45. package/lib/kernel/owned-kernel.js +43 -0
  46. package/lib/kernel/sqlite-driver.js +37 -1
  47. package/lib/pr-monitor/auto-actions.js +175 -0
  48. package/lib/pr-monitor/digest.js +206 -0
  49. package/lib/pr-monitor/render-sticky.js +43 -8
  50. package/lib/pr-monitor/upsert-sticky.js +169 -0
  51. package/lib/pr-pull.js +43 -2
  52. package/lib/release-readiness.js +17 -1
  53. package/lib/upgrade-safety.js +53 -1
  54. package/lib/workflow/enforce-stage.js +59 -2
  55. package/package.json +2 -2
  56. package/scripts/pr-auto-actions.js +93 -0
  57. package/scripts/pr-verdict-label.js +50 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Command Aliases — declarative back-compat alias map.
3
+ *
4
+ * Generalises the former hardcoded `ISSUE_ALIAS_COMMANDS` allowlist (bin/forge.js)
5
+ * into a single declarative source of truth for command-surface unification
6
+ * (kernel issue 33d1a906, epic eea186fa). Each entry maps a bare top-level alias
7
+ * to the canonical `<noun> <sub>` form it stands in for, plus two flags:
8
+ *
9
+ * { canonical: 'issue create', visible: false, deprecated?: true }
10
+ *
11
+ * - `canonical` — the canonical noun+subcommand this alias resolves to.
12
+ * - `visible` — true → routable AND shown in `forge --help`;
13
+ * false → routable but hidden from help (back-compat only).
14
+ * - `deprecated` — optional; when true, using the alias emits a one-line hint
15
+ * to STDERR, but ONLY when `FORGE_DEPRECATION_WARNINGS` is set.
16
+ * Aliases are NEVER removed or broken (docker still ships
17
+ * `docker pull`).
18
+ *
19
+ * P0 SCOPE: seeds ONLY the existing issue aliases migrated verbatim from
20
+ * `ISSUE_ALIAS_COMMANDS` (all hidden, none deprecated) so behaviour is identical.
21
+ * No new noun mappings are added here — those land in later phases. The bare
22
+ * files (create.js, update.js, …) still exist and remain the routed handlers, so
23
+ * `resolveDispatch` is a strict no-op for the seed set (its first clause skips any
24
+ * name that is still a registered command).
25
+ *
26
+ * Files starting with `_` are excluded from command auto-discovery (see
27
+ * `_registry.js`), so this module is never mistaken for a command itself.
28
+ *
29
+ * @module _aliases
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} AliasDescriptor
34
+ * @property {string} canonical - Canonical `<noun> <sub>` form the alias resolves to.
35
+ * @property {boolean} visible - Whether the alias appears in `forge --help`.
36
+ * @property {boolean} [deprecated] - Whether using the alias emits an opt-in hint.
37
+ */
38
+
39
+ /** @type {Object<string, AliasDescriptor>} */
40
+ const ALIASES = {
41
+ create: { canonical: 'issue create', visible: false },
42
+ update: { canonical: 'issue update', visible: false },
43
+ claim: { canonical: 'issue claim', visible: false },
44
+ close: { canonical: 'issue close', visible: false },
45
+ show: { canonical: 'issue show', visible: false },
46
+ list: { canonical: 'issue list', visible: false },
47
+ ready: { canonical: 'issue ready', visible: false },
48
+ blocked: { canonical: 'issue blocked', visible: false },
49
+ stale: { canonical: 'issue stale', visible: false },
50
+ orphans: { canonical: 'issue orphans', visible: false },
51
+ lint: { canonical: 'issue lint', visible: false },
52
+ claims: { canonical: 'issue claims', visible: false },
53
+ // `issues` is the plural convenience form of `issue list`.
54
+ issues: { canonical: 'issue list', visible: false },
55
+
56
+ // P1 (kernel issue febf7690) — memory noun shortcuts. The `memory` noun
57
+ // (add/recall/search/insights) shipped in PR #392 (issue 25362344); P1 wires the
58
+ // bare verbs as VISIBLE back-compat shortcuts of the canonical `memory <sub>`
59
+ // form. The canonical WRITE verb is `memory add` (there is NO `save`). These stay
60
+ // registered command files (remember.js/recall.js/insights.js), so resolveDispatch
61
+ // is a strict no-op for them and bare-verb dispatch stays byte-identical; the
62
+ // entries exist to document the mapping and drive the `forge --help` Shortcuts
63
+ // block. They are deliberately NOT issue-backend flag-passthrough aliases (see
64
+ // passthroughAliasNames) so global flags (`-p`, `--help`, `--all`) still parse
65
+ // exactly as they did for the standalone commands.
66
+ remember: { canonical: 'memory add', visible: true },
67
+ recall: { canonical: 'memory recall', visible: true },
68
+ insights: { canonical: 'memory insights', visible: true },
69
+
70
+ // P2 (kernel issue 6ab3f30c) — the `pr` noun (ship/preflight/shepherd/merge) plus
71
+ // folding doc-gate under the existing `gate` noun. `pr ship` is the canonical
72
+ // PR-creation form, but bare `ship` stays a VISIBLE shortcut (hot workflow verb,
73
+ // zero keystroke loss); `preflight` is likewise VISIBLE. shepherd/merge/doc-gate
74
+ // are less-hot, so they stay HIDDEN back-compat aliases. Every one keeps its own
75
+ // registered command file (ship.js/preflight.js/shepherd.js/merge.js/doc-gate.js),
76
+ // so resolveDispatch is a strict no-op for them and bare-verb dispatch stays
77
+ // byte-identical — the entries document the mapping and drive the `forge --help`
78
+ // Shortcuts block. Their canonical is a `pr `/`gate ` form (NOT `issue `), so
79
+ // passthroughAliasNames() excludes them and their global flags (`--pull`,
80
+ // `--json`, `--bundle`, `--all`, `-h`) parse exactly as the standalone commands'.
81
+ ship: { canonical: 'pr ship', visible: true },
82
+ preflight: { canonical: 'pr preflight', visible: true },
83
+ shepherd: { canonical: 'pr shepherd', visible: false },
84
+ merge: { canonical: 'pr merge', visible: false },
85
+ 'doc-gate': { canonical: 'gate doc', visible: false },
86
+ };
87
+
88
+ /**
89
+ * Look up an alias descriptor by bare name.
90
+ * @param {string} name
91
+ * @returns {AliasDescriptor|undefined}
92
+ */
93
+ function resolveAlias(name) {
94
+ return Object.prototype.hasOwnProperty.call(ALIASES, name) ? ALIASES[name] : undefined;
95
+ }
96
+
97
+ /**
98
+ * @param {string} name
99
+ * @returns {boolean} true if `name` is a registered alias.
100
+ */
101
+ function isAlias(name) {
102
+ return resolveAlias(name) !== undefined;
103
+ }
104
+
105
+ /**
106
+ * All alias names. Replaces the flat `ISSUE_ALIAS_COMMANDS` array — used to skip
107
+ * global flag parsing so passthrough flags reach the handler intact.
108
+ * @returns {string[]}
109
+ */
110
+ function aliasNames() {
111
+ return Object.keys(ALIASES);
112
+ }
113
+
114
+ /**
115
+ * The subset of aliases that delegate ALL flag parsing to the issue backend
116
+ * and therefore must SKIP global flag parsing in bin/forge.js so flags like
117
+ * `--type` / `-p` / `--help` reach the handler intact. This is issue-specific:
118
+ * only issue-canonical aliases passthrough. Non-issue aliases (e.g. the P1 memory
119
+ * shortcuts) parse global flags normally — exactly as their standalone command
120
+ * files did — so their behaviour stays byte-identical after becoming aliases.
121
+ * @returns {string[]}
122
+ */
123
+ function passthroughAliasNames() {
124
+ return Object.keys(ALIASES).filter(name => ALIASES[name].canonical.startsWith('issue '));
125
+ }
126
+
127
+ /**
128
+ * Names of aliases shown in the `forge --help` Shortcuts block — visible
129
+ * back-compat shortcuts for a canonical `<noun> <sub>` form. Hidden (back-compat
130
+ * only) aliases are excluded.
131
+ * @returns {string[]}
132
+ */
133
+ function visibleAliasNames() {
134
+ return Object.keys(ALIASES).filter(name => ALIASES[name].visible === true);
135
+ }
136
+
137
+ /**
138
+ * Whether a descriptor is hidden from help. Pure predicate over the `visible`
139
+ * flag so the visible-vs-hidden distinction can be tested independent of the
140
+ * P0 seed (which is entirely hidden).
141
+ * @param {AliasDescriptor|undefined} descriptor
142
+ * @returns {boolean}
143
+ */
144
+ function isHidden(descriptor) {
145
+ return !!descriptor && descriptor.visible === false;
146
+ }
147
+
148
+ /**
149
+ * Whether the named alias is hidden from `forge --help`. Drives the help filter.
150
+ * @param {string} name
151
+ * @returns {boolean}
152
+ */
153
+ function isHiddenAlias(name) {
154
+ return isHidden(resolveAlias(name));
155
+ }
156
+
157
+ /**
158
+ * Whether the named alias is a visible (help-listed) shortcut.
159
+ * @param {string} name
160
+ * @returns {boolean}
161
+ */
162
+ function isVisibleAlias(name) {
163
+ const descriptor = resolveAlias(name);
164
+ return !!descriptor && descriptor.visible === true;
165
+ }
166
+
167
+ /**
168
+ * Render the one-line deprecation hint for an alias.
169
+ * @param {string} name
170
+ * @param {AliasDescriptor} descriptor
171
+ * @returns {string}
172
+ */
173
+ function renderHint(name, descriptor) {
174
+ return `forge ${name} is a back-compat alias; prefer 'forge ${descriptor.canonical}'`;
175
+ }
176
+
177
+ /**
178
+ * Whether a deprecation hint should be emitted: requires BOTH the opt-in env flag
179
+ * AND a descriptor explicitly marked deprecated. Default (flag unset) is silent,
180
+ * so scripted stdout is never affected.
181
+ * @param {AliasDescriptor|undefined} descriptor
182
+ * @param {Object} env
183
+ * @returns {boolean}
184
+ */
185
+ function shouldWarn(descriptor, env) {
186
+ return !!(env && env.FORGE_DEPRECATION_WARNINGS && descriptor && descriptor.deprecated);
187
+ }
188
+
189
+ /**
190
+ * Emit an opt-in deprecation hint to stderr when both gates pass. Never writes to
191
+ * stdout (would corrupt `--json`). Returns whether a hint was emitted.
192
+ * @param {string} name
193
+ * @param {{env?: Object, stderr?: {write: function}, resolve?: function}} [opts]
194
+ * @returns {boolean}
195
+ */
196
+ function maybeWarnDeprecation(name, opts = {}) {
197
+ const env = opts.env || process.env;
198
+ const stderr = opts.stderr || process.stderr;
199
+ const resolve = opts.resolve || resolveAlias;
200
+ const descriptor = resolve(name);
201
+ if (!shouldWarn(descriptor, env)) return false;
202
+ stderr.write(`${renderHint(name, descriptor)}\n`);
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * Resolve a bare command to its canonical noun handler for dispatch.
208
+ *
209
+ * A command that is still a registered command file (`isRegistered(command)` is
210
+ * true) is NEVER rewritten — dispatch stays byte-identical. Only a bare alias
211
+ * whose name is NOT a registered command resolves to `<noun> <sub>`; this
212
+ * activates when a later phase folds a bare verb into a noun handler. For the P0
213
+ * seed every alias is still registered, so this always returns `redirected:false`.
214
+ *
215
+ * @param {string} command - The bare command name (args[0]).
216
+ * @param {string[]} argv - The full argv (argv[0] is the command token).
217
+ * @param {function(string): boolean} isRegistered - Predicate: is this a live command?
218
+ * @returns {{command: string, args: string[], redirected: boolean}}
219
+ */
220
+ function resolveDispatch(command, argv, isRegistered) {
221
+ if (typeof isRegistered === 'function' && isRegistered(command)) {
222
+ return { command, args: argv, redirected: false };
223
+ }
224
+ const descriptor = resolveAlias(command);
225
+ if (!descriptor) {
226
+ return { command, args: argv, redirected: false };
227
+ }
228
+ const parts = String(descriptor.canonical).trim().split(/\s+/);
229
+ const noun = parts[0];
230
+ const rest = Array.isArray(argv) ? argv.slice(1) : [];
231
+ return { command: noun, args: [noun, ...parts.slice(1), ...rest], redirected: true };
232
+ }
233
+
234
+ module.exports = {
235
+ ALIASES,
236
+ resolveAlias,
237
+ isAlias,
238
+ aliasNames,
239
+ passthroughAliasNames,
240
+ visibleAliasNames,
241
+ isHidden,
242
+ isHiddenAlias,
243
+ isVisibleAlias,
244
+ renderHint,
245
+ shouldWarn,
246
+ maybeWarnDeprecation,
247
+ resolveDispatch,
248
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
4
4
  const { resolveIssueBackend, hasExplicitBackendSignal, shouldUseKernelBroker } = require('../issue-backend');
5
+ const { maybeWarnUnmigratedBeads } = require('../beads-nudge');
5
6
  const {
6
7
  ISSUE_COMMAND_SCHEMA_VERSION,
7
8
  ISSUE_COMMAND_ERROR_SCHEMA_VERSION,
@@ -12,6 +13,8 @@ const {
12
13
  const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
13
14
  const { renderIssueEnvelope } = require('../issue-render');
14
15
  const { recordStageTransition } = require('../workflow/stage-transition');
16
+ const { checkReadFirst } = require('../grounding/read-first');
17
+ const { recordContextLoaded } = require('../grounding/context-events');
15
18
 
16
19
  // The Forge issue command surface. Each subcommand routes through the shared
17
20
  // runIssueOperation, which selects the active backend (Kernel by --kernel /
@@ -731,6 +734,21 @@ async function runIssueSubcommand(subcommand, args, projectRoot, rawOpts = {}) {
731
734
 
732
735
  const opts = withResolvedIssueBackend(projectRoot, rawOpts);
733
736
 
737
+ // gate.read_first (grounding, epic 6ef96e92): HARD-BLOCK `forge claim <id>`
738
+ // until the issue has been read this session/window (a `context.loaded` kernel
739
+ // event exists — appended by `forge recap`/`forge show`). Fail-closed: the
740
+ // cheapest path through the gate is the correct behavior, since the remedy
741
+ // (`forge recap <id>`) also injects the context. Consulted here at the command
742
+ // boundary exactly like gate.issue_verify; rail.grounding/gate.read_first
743
+ // disabled -> allow (logged). Read commands never gate; only `claim` in P1.
744
+ if (subcommand === 'claim') {
745
+ const issueId = normalizeArgs(args).find((arg) => !arg.startsWith('--'));
746
+ if (issueId) {
747
+ const block = await checkReadFirst(projectRoot, issueId, opts);
748
+ if (block) return block;
749
+ }
750
+ }
751
+
734
752
  // Both backends are reached through the same runIssueOperation seam. Naming the
735
753
  // injected local `runIssueOperation` keeps the dispatch a literal call to a binding
736
754
  // named `runIssueOperation` (the kernel-evidence gate is syntactic) while still
@@ -769,6 +787,27 @@ async function runIssueSubcommand(subcommand, args, projectRoot, rawOpts = {}) {
769
787
  }
770
788
  // Best-effort, non-blocking: mirror a stage-transition comment into stage_runs.
771
789
  recordStageTransitionFromComment(subcommand, operationArgs, result, opts);
790
+ // Best-effort, non-blocking: nudge a returning 0.0.10 user whose empty Kernel
791
+ // read hides an unmigrated legacy issue store (kernel issue a5399f3d). The hint
792
+ // text lives in lib/beads-nudge.js so this hot path stays token-free.
793
+ maybeWarnUnmigratedBeads(subcommand, result, projectRoot, rawOpts);
794
+ // Grounding (gate.read_first): a successful `forge show <id>` counts as reading
795
+ // the issue, so append a `context.loaded` event. Best-effort and awaited (a
796
+ // fire-and-forget append could lose the event when the CLI process exits); a
797
+ // failure here never fails the read.
798
+ if (subcommand === 'show' && result && result.ok === true) {
799
+ const shownId = normalizeArgs(args).find((arg) => !arg.startsWith('--'));
800
+ if (shownId) {
801
+ const deps = (opts.kernelBroker && opts.kernelDriver)
802
+ ? { kernelBroker: opts.kernelBroker, kernelDriver: opts.kernelDriver }
803
+ : undefined;
804
+ try {
805
+ await recordContextLoaded(projectRoot, {
806
+ issueId: shownId, cmd: 'show', session: opts.session, env: opts.env, deps, now: opts.now,
807
+ });
808
+ } catch { /* best-effort: never fail a read on grounding bookkeeping */ }
809
+ }
810
+ }
772
811
  // Contract output is opt-in for the human-first reads: an explicit --json flag
773
812
  // or FORGE_JSON=1 in the environment (for scripts that cannot alter argv).
774
813
  const jsonRequested = normalizeArgs(args).includes('--json')
@@ -48,6 +48,7 @@ const commands = [
48
48
  { file: "issues.js", module: require("./issues") },
49
49
  { file: "lint.js", module: require("./lint") },
50
50
  { file: "list.js", module: require("./list") },
51
+ { file: "memory.js", module: require("./memory") },
51
52
  { file: "merge.js", module: require("./merge") },
52
53
  { file: "migrate.js", module: require("./migrate") },
53
54
  { file: "new.js", module: require("./new") },
@@ -56,6 +57,7 @@ const commands = [
56
57
  { file: "orphans.js", module: require("./orphans") },
57
58
  { file: "patch.js", module: require("./patch") },
58
59
  { file: "plan.js", module: require("./plan") },
60
+ { file: "pr.js", module: require("./pr") },
59
61
  { file: "preflight.js", module: require("./preflight") },
60
62
  { file: "prime.js", module: require("./prime") },
61
63
  { file: "push.js", module: require("./push") },
@@ -12,6 +12,7 @@
12
12
  const { existsSync, readdirSync } = require('node:fs');
13
13
  const path = require('node:path');
14
14
  const { normalizeStageId } = require('../workflow/stages');
15
+ const { ensureForgeHome, isMutatingVerb } = require('../activation/ensure-forge-home');
15
16
 
16
17
  // Static command manifest (bundleable fast path). This is a static require so
17
18
  // `bun build --compile` can bundle the command graph; the file is generated by
@@ -199,6 +200,19 @@ async function executeCommand(commands, commandName, args, flags, projectRoot, o
199
200
  }
200
201
  }
201
202
 
203
+ // Lazy `.forge/` home (activation foundation): the FIRST mutating verb in a
204
+ // bare repo materializes the gates-disabled skeleton on demand. Read-only
205
+ // verbs never enter this branch, so they write nothing; an already-inited
206
+ // repo is a no-op (never clobbered). Failure to create the home must not
207
+ // crash the command — degrade to a warning. Opt out via `skipEnsureHome`.
208
+ if (projectRoot && options.skipEnsureHome !== true && isMutatingVerb(commandName, command)) {
209
+ try {
210
+ (options.ensureForgeHome || ensureForgeHome)(projectRoot);
211
+ } catch (err) {
212
+ console.warn(`[forge] Could not initialize .forge/ home: ${err?.message ?? err}`);
213
+ }
214
+ }
215
+
202
216
  // Forward a resolved per-command opts object as the handler's 4th arg. Issue/
203
217
  // alias handlers read it (shouldUseKernelBroker(opts)); other handlers ignore
204
218
  // it. Backward-compatible: defaults to {} so existing 3-arg handlers are
@@ -145,27 +145,6 @@ function resolveFlagBackend(flags = {}) {
145
145
  return fromKernel || fromBackend || null;
146
146
  }
147
147
 
148
- // Run the first-use Beads import safety net for a kernel-routed command. Resolving the
149
- // migrate module lives INSIDE the try so even a require() failure (missing/corrupt
150
- // module) can never break command-opts resolution — the whole point of the safety net.
151
- // Shared by both kernel branches (issue + KERNEL_TOOL_COMMANDS) so the two call sites
152
- // cannot drift.
153
- async function runRuntimeAutoMigrate(deps, kernelDeps) {
154
- try {
155
- const autoMigrate = deps.autoMigrateBeadsAtRuntime
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.
166
- }
167
- }
168
-
169
148
  /**
170
149
  * Resolve the command opts and selector-stripped args for a dispatched command.
171
150
  *
@@ -195,9 +174,6 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
195
174
  databasePath: deps.databasePath,
196
175
  gitCommonDir: deps.gitCommonDir,
197
176
  });
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
177
  return { commandOpts: { _broker: kernelDeps.kernelBroker }, args: rawArgs };
202
178
  } catch {
203
179
  return { commandOpts: {}, args: rawArgs };
@@ -236,13 +212,6 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
236
212
  gitCommonDir: deps.gitCommonDir,
237
213
  });
238
214
 
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
215
  return {
247
216
  commandOpts: {
248
217
  issueBackend: KERNEL,
@@ -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 'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--reason <text>] [--json]';
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 'check'.\n${usage()}`,
200
+ error: `Expected 'enable', 'disable', 'approve', 'reject', 'status', 'check', or 'doc'.\n${usage()}`,
184
201
  };
185
202
  }
186
203
 
@@ -27,14 +27,17 @@ const {
27
27
  renderGlobalHookBlock,
28
28
  installGlobalHooks,
29
29
  } = require('../hook-global-installer');
30
- const { sessionStartCapability, userPromptSubmitCapability } = require('../hook-renderer');
31
- const { collectDigestData, buildMemoryDigest } = require('../memory-digest');
30
+ const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
31
+ const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
32
32
  const { collectInbox, buildInboxNudge } = require('../inbox');
33
+ const { collectDigest } = require('../pr-monitor/digest');
33
34
 
34
35
  function usage() {
35
36
  return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
36
37
  + ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
37
- + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)';
38
+ + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
39
+ + ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
40
+ + ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
38
41
  }
39
42
 
40
43
  /** Parse `--harness <h>` (defaults to claude) from a session-start arg slice. */
@@ -46,6 +49,48 @@ function parseHarness(rest) {
46
49
  return 'claude';
47
50
  }
48
51
 
52
+ /** Parse `--trigger <t>` (defaults to stop) from a capture arg slice. */
53
+ function parseTrigger(rest) {
54
+ for (let i = 0; i < rest.length; i += 1) {
55
+ if (rest[i] === '--trigger') return rest[i + 1] || 'stop';
56
+ if (rest[i].startsWith('--trigger=')) return rest[i].slice('--trigger='.length);
57
+ }
58
+ return 'stop';
59
+ }
60
+
61
+ // Capture bounds — the snapshot is a small NUDGE, not a manual. A hard issue cap + per-title
62
+ // cap + overall body cap keep the note (which is re-injected at the NEXT session's SessionStart
63
+ // digest) token-bounded. Tags mark it a session-summary typed note AND a Forge auto-capture
64
+ // (the latter is the dedupe/idempotency key that stops per-turn Stop flooding).
65
+ const CAPTURE_ISSUE_CAP = 5;
66
+ const CAPTURE_TITLE_CAP = 80;
67
+ const CAPTURE_NOTE_CAP = 1000;
68
+ const CAPTURE_AUTO_TAG = 'forge:auto-capture';
69
+ const CAPTURE_TAGS = ['type:session-summary', CAPTURE_AUTO_TAG];
70
+
71
+ /**
72
+ * Build the deterministic, token-bounded capture note body. PURE. The body deliberately
73
+ * carries NO timestamp (the store stamps its own) so an unchanged session state yields a
74
+ * byte-identical body across repeated Stops — that identity is what the dedupe keys on.
75
+ * @param {string} trigger - 'precompact' | 'stop'
76
+ * @param {object[]} issues - in-progress issues (title/id defensively resolved)
77
+ * @returns {string}
78
+ */
79
+ function buildCaptureNote(trigger, issues) {
80
+ const capped = issues.slice(0, CAPTURE_ISSUE_CAP);
81
+ const lines = capped.map(issue => {
82
+ const title = String((issue && (issue.title || issue.id)) || 'untitled').replace(/\s+/g, ' ').trim();
83
+ return `- ${title.length > CAPTURE_TITLE_CAP ? `${title.slice(0, CAPTURE_TITLE_CAP)}…` : title}`;
84
+ });
85
+ const more = issues.length > capped.length ? `\n- …and ${issues.length - capped.length} more` : '';
86
+ const body = lines.length
87
+ ? `Session boundary (${trigger}) — in-progress:\n${lines.join('\n')}${more}`
88
+ : `Session boundary (${trigger}) — no in-progress issues.`;
89
+ // Reserve one char for the appended ellipsis so the FINAL note (incl. '…') is ≤ the cap,
90
+ // never CAPTURE_NOTE_CAP + 1.
91
+ return body.length > CAPTURE_NOTE_CAP ? `${body.slice(0, CAPTURE_NOTE_CAP - 1)}…` : body;
92
+ }
93
+
49
94
  /** Wrap a digest into a harness-native SessionStart payload, or '' when unsupported. */
50
95
  function formatSessionStart(harness, text) {
51
96
  if (harness === 'claude') {
@@ -126,6 +171,94 @@ async function handleInboxPickup(rest, projectRoot, opts = {}) {
126
171
  }
127
172
  }
128
173
 
174
+ /**
175
+ * `forge hooks shepherd-events --harness <h>` — the PR-shepherd CONTEXT hook. On each
176
+ * prompt it emits harness-native UserPromptSubmit JSON carrying a COMPACT, capped digest
177
+ * of NEW PR-monitor events (verdict changes, failed checks, new threads, merged/closed)
178
+ * since the last read across all open-PR journals, then advances the per-PR consumer
179
+ * cursor so nothing re-surfaces. This is the CONSUMER side of the constant watcher: the
180
+ * watch loop writes the journal, this pushes the deltas to the working agent. It reads the
181
+ * user's OWN local journal via a supported hook — it NEVER injects into stdin and NEVER
182
+ * drives the agent (Anthropic Usage Policy). FAIL-OPEN: any failure, an unsupported
183
+ * harness, or no new events yields '' (the harness injects nothing). NEVER throws.
184
+ *
185
+ * @param {string[]} rest - args after the `shepherd-events` action.
186
+ * @param {string} projectRoot
187
+ * @param {object} [opts] - injectable digest collector ({ collectDigest }).
188
+ * @returns {{ success: boolean, output: string }}
189
+ */
190
+ function handleShepherdEvents(rest, projectRoot, opts = {}) {
191
+ try {
192
+ const harness = parseHarness(rest);
193
+ const capability = userPromptSubmitCapability(harness);
194
+ // Honest capability matrix: a non-Claude harness gets an explicit skip
195
+ // reason as surface-only result metadata (for callers/telemetry) but NEVER
196
+ // any injected output — `reason` must not drive the agent.
197
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
198
+ const collect = opts.collectDigest || collectDigest;
199
+ const { text } = collect({ root: projectRoot });
200
+ if (!text) return { success: true, output: '' };
201
+ return { success: true, output: formatUserPromptSubmit(harness, text) };
202
+ } catch {
203
+ // Fail-open: a context hook must never break a prompt.
204
+ return { success: true, output: '' };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
210
+ * PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
211
+ * session-summary note into the memory store BEFORE learnings are lost. This is the WRITE half
212
+ * of Forge memory (SessionStart only INJECTS). It PERSISTS to the store and emits NO stdout — a
213
+ * Stop hook that printed text would inject into the turn, and it never drives the agent
214
+ * (Anthropic Usage Policy). FAIL-OPEN: any failure, an unsupported harness, or nothing worth
215
+ * capturing yields '' and no write. NEVER throws.
216
+ *
217
+ * Flooding guard: a plain Stop with nothing in progress is skipped (Stop fires every turn), and
218
+ * a byte-identical repeat of the newest auto-capture note is skipped — so only meaningful,
219
+ * changed session state is written. PreCompact records a boundary even when nothing is in
220
+ * progress (unlike Stop), but it still goes through the same dedupe — a byte-identical
221
+ * PreCompact repeat is skipped too.
222
+ *
223
+ * @param {string[]} rest - args after the `capture` action.
224
+ * @param {string} projectRoot
225
+ * @param {object} [opts] - injectable { fetchIssues, fetchNotes, append } for tests.
226
+ * @returns {Promise<{ success: boolean, output: string }>}
227
+ */
228
+ async function handleCapture(rest, projectRoot, opts = {}) {
229
+ try {
230
+ const harness = parseHarness(rest);
231
+ if (!sessionEndCapability(harness).rendered) return { success: true, output: '' };
232
+ const trigger = parseTrigger(rest);
233
+
234
+ const fetchIssues = opts.fetchIssues || defaultFetchIssues;
235
+ const claimed = await fetchIssues(projectRoot, 'in_progress', opts);
236
+ const issues = Array.isArray(claimed) ? claimed : [];
237
+
238
+ // Stop fires every turn; a plain Stop with nothing in progress is not worth a note.
239
+ // PreCompact is rare and precedes real context loss, so it records a boundary even with
240
+ // nothing in progress — but it is NOT exempt from the byte-identical dedupe below.
241
+ if (trigger !== 'precompact' && issues.length === 0) return { success: true, output: '' };
242
+
243
+ const body = buildCaptureNote(trigger, issues);
244
+
245
+ // Content dedupe: if the newest auto-capture note is byte-identical, this is a repeat of
246
+ // an unchanged session — skip the write so the store never floods with duplicates.
247
+ const fetchNotes = opts.fetchNotes || defaultFetchNotes;
248
+ const recent = await fetchNotes(projectRoot, { ...opts, noteLimit: 10 });
249
+ const lastCapture = (Array.isArray(recent) ? recent : [])
250
+ .find(note => Array.isArray(note && note.tags) && note.tags.includes(CAPTURE_AUTO_TAG));
251
+ if (lastCapture && lastCapture.note === body) return { success: true, output: '' };
252
+
253
+ const append = opts.append || require('../memory/router').append;
254
+ append(projectRoot, body, { tags: CAPTURE_TAGS });
255
+ return { success: true, output: '' };
256
+ } catch {
257
+ // Fail-open: a capture hook must never break a session.
258
+ return { success: true, output: '' };
259
+ }
260
+ }
261
+
129
262
  function parseInstallArgs(rest) {
130
263
  const parsed = { global: false, dryRun: false, harness: 'all', unknown: [] };
131
264
  for (let i = 0; i < rest.length; i += 1) {
@@ -222,10 +355,12 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
222
355
  const action = args[0];
223
356
  if (action === 'session-start') return handleSessionStart(args.slice(1), projectRoot, opts);
224
357
  if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
358
+ if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
359
+ if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
225
360
  if (action === 'install') return handleInstall(args, flags, opts);
226
361
  return {
227
362
  success: false,
228
- error: `forge hooks supports: install, session-start, inbox-pickup.\n${usage()}`,
363
+ error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, capture.\n${usage()}`,
229
364
  };
230
365
  }
231
366