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.
- package/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge.js +90 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +9 -0
- package/docs/reference/shepherd.md +42 -2
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adoption-profiles.js +17 -4
- package/lib/beads-detect.js +60 -0
- package/lib/beads-nudge.js +91 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +39 -0
- package/lib/commands/_manifest.js +2 -0
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +0 -31
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +139 -4
- 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 +48 -8
- package/lib/commands/pr.js +88 -0
- 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 +14 -1
- package/lib/commands/remember.js +86 -20
- package/lib/commands/setup.js +135 -72
- package/lib/commands/shepherd.js +67 -2
- package/lib/commands/ship.js +40 -4
- package/lib/commands/worktree.js +60 -4
- package/lib/core/runtime-graph.js +34 -3
- 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/hook-renderer.js +93 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/sqlite-driver.js +37 -1
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/render-sticky.js +43 -8
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-pull.js +43 -2
- package/lib/release-readiness.js +17 -1
- package/lib/upgrade-safety.js +53 -1
- package/lib/workflow/enforce-stage.js +59 -2
- package/package.json +2 -2
- package/scripts/pr-auto-actions.js +93 -0
- 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
|
+
};
|
package/lib/commands/_issue.js
CHANGED
|
@@ -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,
|
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,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
|
|