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
package/lib/commands/shepherd.js
CHANGED
|
@@ -36,8 +36,10 @@ const { validatePrStateAdapter } = require('../pr-state-validator');
|
|
|
36
36
|
const { gatherMonitorSnapshot } = require('../pr-monitor/gather');
|
|
37
37
|
const { pollEvents } = require('../pr-monitor/monitor');
|
|
38
38
|
const { watchLoop } = require('../pr-monitor/watch');
|
|
39
|
+
const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
|
|
39
40
|
const monitorJournal = require('../pr-monitor/journal');
|
|
40
41
|
const { EVENT_TYPES: T } = require('../pr-monitor/events');
|
|
42
|
+
const { autoShepherdRailEnabled } = require('./ship');
|
|
41
43
|
|
|
42
44
|
const DEFAULT_RERUN_BUDGET = 3;
|
|
43
45
|
|
|
@@ -270,11 +272,74 @@ function wireSignals() {
|
|
|
270
272
|
* @param {object} [deps]
|
|
271
273
|
* @returns {Promise<object>}
|
|
272
274
|
*/
|
|
275
|
+
/**
|
|
276
|
+
* List every OPEN PR number via `gh pr list`. Fail-open: any error yields an
|
|
277
|
+
* empty list (adopt then arms nothing) rather than throwing.
|
|
278
|
+
*
|
|
279
|
+
* @param {Function} [exec] - gh runner (test injection).
|
|
280
|
+
* @returns {number[]}
|
|
281
|
+
*/
|
|
282
|
+
function defaultListOpenPrs(exec = execFileSync) {
|
|
283
|
+
try {
|
|
284
|
+
const out = exec('gh', ['pr', 'list', '--state', 'open', '--json', 'number', '-q', '.[].number'], {
|
|
285
|
+
encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
286
|
+
});
|
|
287
|
+
return String(out)
|
|
288
|
+
.split(/\r?\n/)
|
|
289
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
290
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
291
|
+
} catch {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `forge shepherd watch --adopt` — arm a detached watcher for EVERY currently-open
|
|
298
|
+
* PR (covers PRs created via gh/UI, or before this rail existed). Idempotent: the
|
|
299
|
+
* watch loop's PID/journal lock means an already-watched PR is not double-started.
|
|
300
|
+
* Fail-open per PR and overall — never throws. Honors the default-ON
|
|
301
|
+
* `rail.auto_shepherd` rail: when a maintainer has disabled it, adoption is a
|
|
302
|
+
* no-op — no PR listing, no watcher spawn — matching `forge push`/`forge ship`.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} projectRoot
|
|
305
|
+
* @param {object} [deps]
|
|
306
|
+
* @returns {{ success: true, adopted: number[], total: number, reason?: string }}
|
|
307
|
+
*/
|
|
308
|
+
function handleAdopt(projectRoot, deps = {}) {
|
|
309
|
+
const railEnabled = deps.railEnabled || autoShepherdRailEnabled;
|
|
310
|
+
// Gate BEFORE listing PRs / spawning watchers: a disabled rail must not spawn
|
|
311
|
+
// detached watchers. No-op result mirrors the fail-open (empty) adoption shape.
|
|
312
|
+
if (!railEnabled(projectRoot)) {
|
|
313
|
+
return { success: true, adopted: [], total: 0, reason: 'rail.auto_shepherd disabled' };
|
|
314
|
+
}
|
|
315
|
+
const listOpenPrs = deps.listOpenPrs || defaultListOpenPrs;
|
|
316
|
+
const startWatcher = deps.startWatcher || startPrWatcherDetached;
|
|
317
|
+
let prs;
|
|
318
|
+
try {
|
|
319
|
+
prs = listOpenPrs();
|
|
320
|
+
} catch {
|
|
321
|
+
prs = [];
|
|
322
|
+
}
|
|
323
|
+
if (!Array.isArray(prs)) prs = [];
|
|
324
|
+
const adopted = [];
|
|
325
|
+
for (const pr of prs) {
|
|
326
|
+
try {
|
|
327
|
+
const res = startWatcher({ prNumber: pr, cwd: projectRoot });
|
|
328
|
+
if (res?.started) adopted.push(pr);
|
|
329
|
+
} catch { /* fail-open per PR: one bad arm never blocks the rest */ }
|
|
330
|
+
}
|
|
331
|
+
return { success: true, adopted, total: prs.length };
|
|
332
|
+
}
|
|
333
|
+
|
|
273
334
|
async function handleWatch(args, projectRoot, deps = {}) {
|
|
274
335
|
const rawArgs = args || [];
|
|
336
|
+
// `--adopt` (no PR arg): arm a detached watcher for every open PR.
|
|
337
|
+
if (rawArgs.includes('--adopt')) {
|
|
338
|
+
return handleAdopt(projectRoot, deps);
|
|
339
|
+
}
|
|
275
340
|
const pr = rawArgs.find((a) => !String(a).startsWith('--') && a !== 'watch');
|
|
276
341
|
if (!pr) {
|
|
277
|
-
return { success: false, error: 'Usage: forge shepherd watch <pr>' };
|
|
342
|
+
return { success: false, error: 'Usage: forge shepherd watch <pr> | forge shepherd watch --adopt' };
|
|
278
343
|
}
|
|
279
344
|
|
|
280
345
|
const built = await buildMonitorContext(pr, projectRoot, deps);
|
|
@@ -424,7 +489,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
424
489
|
module.exports = {
|
|
425
490
|
name: 'shepherd',
|
|
426
491
|
description: 'Run one bounded monitor pass over a PR (rerun flaky checks, escalate, hand off — never merges)',
|
|
427
|
-
usage: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json] | forge shepherd events <pr> --since <seq> [--json] | forge shepherd watch <pr>',
|
|
492
|
+
usage: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json] | forge shepherd events <pr> --since <seq> [--json] | forge shepherd watch <pr> | forge shepherd watch --adopt',
|
|
428
493
|
handler,
|
|
429
494
|
handleEvents,
|
|
430
495
|
handleWatch,
|
package/lib/commands/ship.js
CHANGED
|
@@ -13,6 +13,32 @@ const fs = require('node:fs');
|
|
|
13
13
|
const path = require('node:path');
|
|
14
14
|
|
|
15
15
|
const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
|
|
16
|
+
const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
17
|
+
|
|
18
|
+
const AUTO_SHEPHERD_RAIL = 'rail.auto_shepherd';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether the default-ON `rail.auto_shepherd` rail permits auto-starting the PR
|
|
22
|
+
* watcher. Reads the SAME resolved runtime graph the `forge gate enable|disable`
|
|
23
|
+
* surface writes (`workflow.gates.rail.auto_shepherd.enabled`), so the toggle is
|
|
24
|
+
* config-honest. FAIL-OPEN: only an explicit `enabled === false` disables it — a
|
|
25
|
+
* missing rail or any resolution error falls back to enabled (the default), and
|
|
26
|
+
* this NEVER throws (ship must not fail on a config read).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} [projectRoot]
|
|
29
|
+
* @param {Function} [resolveGraph] - injectable graph resolver (tests).
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function autoShepherdRailEnabled(projectRoot = process.cwd(), resolveGraph = getResolvedRuntimeGraph) {
|
|
33
|
+
try {
|
|
34
|
+
const graph = resolveGraph({ projectRoot });
|
|
35
|
+
const rail = [...(graph.rails || []), ...(graph.gates || [])]
|
|
36
|
+
.find(entry => entry.id === AUTO_SHEPHERD_RAIL);
|
|
37
|
+
return !(rail && rail.enabled === false);
|
|
38
|
+
} catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
16
42
|
|
|
17
43
|
function getExecOptions() {
|
|
18
44
|
return { encoding: 'utf8', cwd: process.cwd(), timeout: 120000 };
|
|
@@ -490,12 +516,21 @@ async function createPR(options) { // NOSONAR S3776
|
|
|
490
516
|
* ship: `startWatcher` (startPrWatcherDetached) already never throws, and this
|
|
491
517
|
* guard keeps even a surprise error from surfacing to the ship caller.
|
|
492
518
|
*
|
|
493
|
-
*
|
|
519
|
+
* Gated by the default-ON `rail.auto_shepherd` rail: when a maintainer has
|
|
520
|
+
* disabled it (`forge gate disable rail.auto_shepherd`), the watcher is skipped
|
|
521
|
+
* so the auto-start is honestly toggleable. The rail check is fail-open and
|
|
522
|
+
* wrapped in the same try/catch, so neither a disabled rail nor a config-read
|
|
523
|
+
* error ever fails ship.
|
|
524
|
+
*
|
|
525
|
+
* @param {{ dryRun: boolean, prNumber?: string|number, startWatcher: Function, railEnabled?: Function }} params
|
|
494
526
|
* @returns {{ started: boolean, reason?: string }}
|
|
495
527
|
*/
|
|
496
|
-
function maybeStartPrWatcher({ dryRun, prNumber, startWatcher }) {
|
|
528
|
+
function maybeStartPrWatcher({ dryRun, prNumber, startWatcher, railEnabled = autoShepherdRailEnabled }) {
|
|
497
529
|
if (dryRun || !prNumber) return { started: false, reason: 'skipped' };
|
|
498
530
|
try {
|
|
531
|
+
if (!railEnabled(process.cwd())) {
|
|
532
|
+
return { started: false, reason: 'rail.auto_shepherd disabled' };
|
|
533
|
+
}
|
|
499
534
|
return startWatcher({ prNumber, cwd: process.cwd() });
|
|
500
535
|
} catch (err) {
|
|
501
536
|
return { started: false, reason: err.message };
|
|
@@ -503,7 +538,7 @@ function maybeStartPrWatcher({ dryRun, prNumber, startWatcher }) {
|
|
|
503
538
|
}
|
|
504
539
|
|
|
505
540
|
async function executeShip(options) {
|
|
506
|
-
const { featureSlug, title, dryRun = false, startWatcher = startPrWatcherDetached } = options || {};
|
|
541
|
+
const { featureSlug, title, dryRun = false, startWatcher = startPrWatcherDetached, railEnabled = autoShepherdRailEnabled } = options || {};
|
|
507
542
|
|
|
508
543
|
// Validate feature slug
|
|
509
544
|
if (!featureSlug || typeof featureSlug !== 'string' || featureSlug.trim() === '') {
|
|
@@ -555,7 +590,7 @@ async function executeShip(options) {
|
|
|
555
590
|
});
|
|
556
591
|
const result = await createPR({ title, body: prBody, dryRun });
|
|
557
592
|
if (!result.success) return result;
|
|
558
|
-
maybeStartPrWatcher({ dryRun, prNumber: result.prNumber, startWatcher });
|
|
593
|
+
maybeStartPrWatcher({ dryRun, prNumber: result.prNumber, startWatcher, railEnabled });
|
|
559
594
|
return {
|
|
560
595
|
success: true,
|
|
561
596
|
prUrl: result.prUrl,
|
|
@@ -589,6 +624,7 @@ module.exports = {
|
|
|
589
624
|
};
|
|
590
625
|
},
|
|
591
626
|
maybeStartPrWatcher,
|
|
627
|
+
autoShepherdRailEnabled,
|
|
592
628
|
extractKeyDecisions,
|
|
593
629
|
extractTestScenarios,
|
|
594
630
|
getTestCoverage,
|
package/lib/commands/worktree.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
+
const { detectDefaultBranch } = require('../beads-sync-scaffold');
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Forge Worktree Command
|
|
@@ -53,6 +54,40 @@ function branchExists(branchName, runFile) {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* True when a git ref resolves to a commit (branch, tag, remote-tracking ref, or SHA).
|
|
59
|
+
* @param {string} ref - The ref to check
|
|
60
|
+
* @param {string} projectRoot - Repo root to run git in
|
|
61
|
+
* @param {Function} runFile - execFileSync function (for DI)
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function refExists(ref, projectRoot, runFile) {
|
|
65
|
+
try {
|
|
66
|
+
runFile('git', ['-C', projectRoot, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
67
|
+
return true;
|
|
68
|
+
} catch (_err) { /* intentional: ref does not resolve */ // NOSONAR S2486
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the base ref a NEW worktree branch should fork from. Defaults to the
|
|
75
|
+
* repo's DEFAULT branch (NOT the checkout's current HEAD) so a worktree created
|
|
76
|
+
* off a WIP branch never silently inherits unrelated commits. Prefers the remote
|
|
77
|
+
* default (`origin/<default>`) when present, else the local default branch, and
|
|
78
|
+
* falls back to the detected name so git surfaces a clear error if neither exists.
|
|
79
|
+
* @param {string} projectRoot - Repo root
|
|
80
|
+
* @param {Function} runFile - execFileSync function (for DI)
|
|
81
|
+
* @returns {string} The base ref to pass to `git worktree add ... <base>`
|
|
82
|
+
*/
|
|
83
|
+
function resolveDefaultBase(projectRoot, runFile) {
|
|
84
|
+
const def = detectDefaultBranch(projectRoot, { _exec: runFile });
|
|
85
|
+
const originRef = `origin/${def}`;
|
|
86
|
+
if (refExists(originRef, projectRoot, runFile)) return originRef;
|
|
87
|
+
if (refExists(def, projectRoot, runFile)) return def;
|
|
88
|
+
return def;
|
|
89
|
+
}
|
|
90
|
+
|
|
56
91
|
/**
|
|
57
92
|
* True when a link failure is a privilege/support problem (Windows without the
|
|
58
93
|
* symlink privilege, restricted FS) rather than a real error. In that case we
|
|
@@ -278,6 +313,14 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
278
313
|
|
|
279
314
|
const worktreePath = path.resolve(worktreesDir, slug);
|
|
280
315
|
|
|
316
|
+
// Validate an explicit --base up front so a bad ref errors BEFORE anything is
|
|
317
|
+
// created (no branch, no worktree dir). Base only applies when creating a NEW
|
|
318
|
+
// branch; an existing branch is checked out as-is.
|
|
319
|
+
const explicitBase = flags['--base'] || null;
|
|
320
|
+
if (explicitBase && !refExists(explicitBase, projectRoot, runFile)) {
|
|
321
|
+
return { success: false, error: `Invalid --base: ref '${explicitBase}' not found. Verify it exists (git rev-parse --verify ${explicitBase}).` };
|
|
322
|
+
}
|
|
323
|
+
|
|
281
324
|
// Step 0: Check if worktree already exists
|
|
282
325
|
if (fsApi.existsSync(worktreePath)) {
|
|
283
326
|
// A pre-existing worktree may be checked out on a different branch than the
|
|
@@ -303,12 +346,17 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
303
346
|
// Step 1: Ensure .worktrees/ dir exists
|
|
304
347
|
fsApi.mkdirSync(worktreesDir, { recursive: true });
|
|
305
348
|
|
|
306
|
-
// Step 2: Create git worktree
|
|
349
|
+
// Step 2: Create git worktree. For a NEW branch, fork from an explicit --base or
|
|
350
|
+
// the repo's DEFAULT branch — NOT the checkout's current HEAD — so the worktree
|
|
351
|
+
// never silently inherits unrelated WIP commits (B2). An existing branch is
|
|
352
|
+
// checked out as-is (no base applies).
|
|
307
353
|
const hasBranch = branchExists(branchName, runFile);
|
|
354
|
+
let base = null;
|
|
308
355
|
if (hasBranch) {
|
|
309
356
|
runFile('git', ['worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
|
|
310
357
|
} else {
|
|
311
|
-
|
|
358
|
+
base = explicitBase || resolveDefaultBase(projectRoot, runFile);
|
|
359
|
+
runFile('git', ['worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
|
|
312
360
|
}
|
|
313
361
|
|
|
314
362
|
// Step 3: Populate node_modules (link to the shared install, else install).
|
|
@@ -329,14 +377,19 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
329
377
|
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: branchName, issueId, workFolder, opts });
|
|
330
378
|
const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: branchName, issueId, opts });
|
|
331
379
|
|
|
380
|
+
// Report the base so the fork point is never silent. `base` is null when an
|
|
381
|
+
// existing branch was checked out (no fork happened).
|
|
382
|
+
const baseNote = base ? `based on ${base}` : `existing branch ${branchName}`;
|
|
332
383
|
return {
|
|
333
384
|
success: true,
|
|
334
385
|
worktreePath,
|
|
335
386
|
branch: branchName,
|
|
387
|
+
base,
|
|
336
388
|
depsLinked: deps.linked,
|
|
337
389
|
depsInstalled: deps.installed,
|
|
338
390
|
linkage,
|
|
339
391
|
backing,
|
|
392
|
+
output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).`,
|
|
340
393
|
};
|
|
341
394
|
}
|
|
342
395
|
|
|
@@ -383,8 +436,8 @@ async function handleRemove(slug, projectRoot, opts) {
|
|
|
383
436
|
}
|
|
384
437
|
|
|
385
438
|
// Long flags that take a value, in both `--flag value` and `--flag=value` forms.
|
|
386
|
-
const WORKTREE_VALUE_FLAGS = ['--branch', '--issue', '--work-folder'];
|
|
387
|
-
const WORKTREE_USAGE_HINT = 'Usage: forge worktree create <slug> [--branch <name>] [--issue <id>] [--work-folder <path>]';
|
|
439
|
+
const WORKTREE_VALUE_FLAGS = ['--branch', '--issue', '--work-folder', '--base'];
|
|
440
|
+
const WORKTREE_USAGE_HINT = 'Usage: forge worktree create <slug> [--branch <name>] [--base <ref>] [--issue <id>] [--work-folder <path>]';
|
|
388
441
|
|
|
389
442
|
function parseWorktreeArgs(args, flags) {
|
|
390
443
|
const positional = [];
|
|
@@ -429,6 +482,7 @@ module.exports = {
|
|
|
429
482
|
usage: 'forge worktree <create|remove|list> <slug>',
|
|
430
483
|
flags: {
|
|
431
484
|
'--branch': 'Custom branch name (default: feat/<slug>)',
|
|
485
|
+
'--base': 'Base ref a new branch forks from (default: the repo default branch, e.g. origin/main)',
|
|
432
486
|
'--issue': 'Kernel issue id to link this worktree to (records issue → worktree)',
|
|
433
487
|
'--work-folder': 'Repo-relative work-folder this issue owns (records worktree → work-folder + drops a .forge-issue marker)',
|
|
434
488
|
},
|
|
@@ -486,5 +540,7 @@ module.exports = {
|
|
|
486
540
|
setupWorktreeDeps,
|
|
487
541
|
runInstall,
|
|
488
542
|
autoFileBackingIssue,
|
|
543
|
+
refExists,
|
|
544
|
+
resolveDefaultBase,
|
|
489
545
|
},
|
|
490
546
|
};
|
|
@@ -412,6 +412,27 @@ const RESOLVED_RUNTIME_GRAPH = {
|
|
|
412
412
|
label: 'Issue write verification (check-after-write)',
|
|
413
413
|
requires: [],
|
|
414
414
|
}),
|
|
415
|
+
// Grounding gates (epic 6ef96e92, design docs/work/2026-07-16-grounding-
|
|
416
|
+
// enforcement/design.md) — the first gates that DENY (fd4c03b3's first real
|
|
417
|
+
// payment). gate.read_first: acting on an issue requires having read it —
|
|
418
|
+
// consulted fail-closed at the `forge claim` boundary against a
|
|
419
|
+
// `context.loaded` kernel event (lib/grounding/context-events.js), remedy
|
|
420
|
+
// `forge recap <id>`. gate.cite: shipped artifacts must cite their sources —
|
|
421
|
+
// registered here (togglable) but its scanner lands in P3; it denies nothing
|
|
422
|
+
// yet. Both phase-less, default-ON, UNLOCKED, same toggle surface as
|
|
423
|
+
// gate.issue_verify (`forge gate disable gate.read_first`). Master switch is
|
|
424
|
+
// the unlocked rail.grounding below. `requires: []` — the context.loaded
|
|
425
|
+
// event (not evidence) is the exit condition.
|
|
426
|
+
Gate({
|
|
427
|
+
id: 'gate.read_first',
|
|
428
|
+
label: 'Read the issue before acting on it',
|
|
429
|
+
requires: [],
|
|
430
|
+
}),
|
|
431
|
+
Gate({
|
|
432
|
+
id: 'gate.cite',
|
|
433
|
+
label: 'Cite sources in shipped artifacts',
|
|
434
|
+
requires: [],
|
|
435
|
+
}),
|
|
415
436
|
],
|
|
416
437
|
evidence: [
|
|
417
438
|
Evidence({
|
|
@@ -446,12 +467,18 @@ const RESOLVED_RUNTIME_GRAPH = {
|
|
|
446
467
|
// may opt out via `forge gate disable rail.kernel_tracking`, consumed by the
|
|
447
468
|
// resolver's rail-aware gate loop over workflow.gates.<id>.enabled).
|
|
448
469
|
rails: [
|
|
449
|
-
{ key: 'tdd_intent', label: 'TDD intent evidence', description: 'Source changes require test intent and TDD evidence.' },
|
|
470
|
+
{ key: 'tdd_intent', label: 'TDD intent evidence', description: 'Source changes require test intent and TDD evidence. Strong default (ON), but not a hard floor — disable with `forge gate disable rail.tdd_intent`.', locked: false },
|
|
450
471
|
{ key: 'secret_scan', label: 'Secret scan', description: 'Validation must not knowingly ship secrets.' },
|
|
451
472
|
{ key: 'branch_protection', label: 'Branch protection', description: 'Ship through reviewed branches instead of direct protected-branch edits.' },
|
|
452
473
|
{ key: 'signed_commits', label: 'Signed commits', description: 'Preserve commit provenance requirements where configured.' },
|
|
453
474
|
{ key: 'schema_integrity', label: 'Schema integrity', description: 'Keep runtime graph and config schemas internally consistent.' },
|
|
454
475
|
{ key: 'kernel_tracking', label: 'Kernel issue tracking', description: 'Every issue, idea, bug, and decision discussed is filed to the Forge Kernel.', locked: false },
|
|
476
|
+
{ key: 'auto_shepherd', label: 'Auto-start PR shepherd watch', description: 'On `forge ship` success, auto-start the detached, self-stopping `forge shepherd watch <pr>` monitor so a shipped PR is tended without a manual trigger. Default-ON, UNLOCKED — opt out with `forge gate disable rail.auto_shepherd`.', locked: false },
|
|
477
|
+
// grounding — the one-switch master toggle over gate.read_first + gate.cite.
|
|
478
|
+
// UNLOCKED, default-ON (like kernel_tracking): a maintainer may opt out via
|
|
479
|
+
// `forge gate disable rail.grounding`, which the read_first consult treats as
|
|
480
|
+
// "allow, logged". Ingrains "read the documented source before acting; cite it".
|
|
481
|
+
{ key: 'grounding', label: 'Documented-grounding enforcement', description: 'Read the issue before acting on it; cite sources in shipped artifacts.', locked: false },
|
|
455
482
|
].map(def => Rail({ id: `rail.${def.key}`, ...def })),
|
|
456
483
|
adapters: [
|
|
457
484
|
Adapter({
|
|
@@ -605,14 +632,18 @@ function applyRailConfig(graph, config, errors) {
|
|
|
605
632
|
if (!enabled.valid) {
|
|
606
633
|
continue;
|
|
607
634
|
}
|
|
608
|
-
if (enabled.value === false) {
|
|
635
|
+
if (enabled.value === false && rail.locked === true) {
|
|
636
|
+
// Locked L1 rails remain a non-negotiable floor; only unlocked rails
|
|
637
|
+
// (e.g. tdd_intent, kernel_tracking) may be disabled via config.
|
|
609
638
|
errors.push({
|
|
610
639
|
code: 'LOCKED_L1_RAIL_DISABLED',
|
|
611
640
|
message: `Cannot disable locked L1 rail '${key}'.`,
|
|
612
641
|
});
|
|
613
642
|
continue;
|
|
614
643
|
}
|
|
615
|
-
if (
|
|
644
|
+
if (enabled.present) {
|
|
645
|
+
markConfigured(rail, { enabled: enabled.value });
|
|
646
|
+
} else if (options && Object.keys(options).length > 0) {
|
|
616
647
|
rail.configSource = CONFIG_SOURCE;
|
|
617
648
|
}
|
|
618
649
|
}
|
package/lib/gate-events.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* runtime graph + `.forge/config.yaml`; this module only records/reads the events.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
const {
|
|
26
|
+
const { resolveOwnedKernel, closeIfOwned } = require('./kernel/owned-kernel');
|
|
27
27
|
const { resolveIssueActor } = require('./forge-issues');
|
|
28
28
|
|
|
29
29
|
const GATE_APPROVED_EVENT = 'gate.approved';
|
|
@@ -37,22 +37,12 @@ const GATE_EVENT_TYPES = {
|
|
|
37
37
|
const ISSUE_ENTITY_TYPE = 'issue';
|
|
38
38
|
const GATE_EVENT_ORIGIN = 'cli';
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* @param {{ kernelBroker?: Object, kernelDriver?: Object }} [deps]
|
|
47
|
-
* @returns {Promise<{ broker: Object, driver: Object, config: Object }>}
|
|
48
|
-
*/
|
|
49
|
-
async function resolveGateKernel(projectRoot, deps = {}) {
|
|
50
|
-
if (deps.kernelBroker && deps.kernelDriver) {
|
|
51
|
-
return { broker: deps.kernelBroker, driver: deps.kernelDriver, config: deps.kernelBroker.config };
|
|
52
|
-
}
|
|
53
|
-
const built = await buildMigratedKernelIssueDeps({ projectRoot });
|
|
54
|
-
return { broker: built.kernelBroker, driver: built.kernelDriver, config: built.kernelBroker.config };
|
|
55
|
-
}
|
|
40
|
+
// Kernel lifecycle (resolve + close-what-you-built) is shared with
|
|
41
|
+
// grounding/context-events via lib/kernel/owned-kernel. A gate read/append that
|
|
42
|
+
// builds its own kernel must close it — an unclosed SQLite handle leaks and, on
|
|
43
|
+
// Windows, locks the DB directory (EBUSY on rmSync, kernel issue e62e4bde). Safe
|
|
44
|
+
// here today because gate events only fire on explicit `forge gate` commands,
|
|
45
|
+
// but the leak is identical so we close proactively via closeIfOwned.
|
|
56
46
|
|
|
57
47
|
/**
|
|
58
48
|
* Idempotency key for a gate event. Scoped to issue + gate + actor + decision so a
|
|
@@ -110,44 +100,49 @@ async function recordGateEvent(projectRoot, params = {}) {
|
|
|
110
100
|
}
|
|
111
101
|
|
|
112
102
|
const actor = resolveIssueActor(env || process.env) || 'forge';
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const entity = await driver.loadKernelEntity(ISSUE_ENTITY_TYPE, issueId, {}, config);
|
|
116
|
-
if (!entity) {
|
|
117
|
-
return { ok: false, issueMissing: true, actor };
|
|
118
|
-
}
|
|
103
|
+
const kernel = await resolveOwnedKernel(projectRoot, deps);
|
|
104
|
+
const { driver, config } = kernel;
|
|
119
105
|
|
|
120
|
-
|
|
106
|
+
try {
|
|
107
|
+
const entity = await driver.loadKernelEntity(ISSUE_ENTITY_TYPE, issueId, {}, config);
|
|
108
|
+
if (!entity) {
|
|
109
|
+
return { ok: false, issueMissing: true, actor };
|
|
110
|
+
}
|
|
121
111
|
|
|
122
|
-
|
|
123
|
-
if (existing) {
|
|
124
|
-
return { ok: true, duplicate: true, event: parseGateEvent(existing), actor };
|
|
125
|
-
}
|
|
112
|
+
const idempotencyKey = gateIdempotencyKey(eventType, issueId, gateId, actor);
|
|
126
113
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
entity_type: ISSUE_ENTITY_TYPE,
|
|
132
|
-
entity_id: issueId,
|
|
133
|
-
event_type: eventType,
|
|
134
|
-
idempotency_key: idempotencyKey,
|
|
135
|
-
expected_revision: 0,
|
|
136
|
-
actor,
|
|
137
|
-
origin: GATE_EVENT_ORIGIN,
|
|
138
|
-
payload,
|
|
139
|
-
created_at: now || new Date().toISOString(),
|
|
140
|
-
};
|
|
114
|
+
const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
|
|
115
|
+
if (existing) {
|
|
116
|
+
return { ok: true, duplicate: true, event: parseGateEvent(existing), actor };
|
|
117
|
+
}
|
|
141
118
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
119
|
+
const payload = { gate: gateId, actor };
|
|
120
|
+
if (typeof reason === 'string' && reason.length > 0) payload.reason = reason;
|
|
121
|
+
|
|
122
|
+
const event = {
|
|
123
|
+
entity_type: ISSUE_ENTITY_TYPE,
|
|
124
|
+
entity_id: issueId,
|
|
125
|
+
event_type: eventType,
|
|
126
|
+
idempotency_key: idempotencyKey,
|
|
127
|
+
expected_revision: 0,
|
|
128
|
+
actor,
|
|
129
|
+
origin: GATE_EVENT_ORIGIN,
|
|
130
|
+
payload,
|
|
131
|
+
created_at: now || new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const inserted = await driver.insertKernelEvent(event, {}, config);
|
|
136
|
+
return { ok: true, duplicate: false, event: parseGateEvent(inserted), actor };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isIdempotencyRace(error)) {
|
|
139
|
+
const winner = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
|
|
140
|
+
return { ok: true, duplicate: true, event: winner ? parseGateEvent(winner) : parseGateEvent(event), actor };
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
149
143
|
}
|
|
150
|
-
|
|
144
|
+
} finally {
|
|
145
|
+
closeIfOwned(kernel);
|
|
151
146
|
}
|
|
152
147
|
}
|
|
153
148
|
|
|
@@ -160,11 +155,15 @@ async function recordGateEvent(projectRoot, params = {}) {
|
|
|
160
155
|
* @returns {Promise<Array<{ event_type: string, gate: string, actor: string, created_at: string, reason?: string }>>}
|
|
161
156
|
*/
|
|
162
157
|
async function listGateEvents(projectRoot, issueId, options = {}) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
const kernel = await resolveOwnedKernel(projectRoot, options.deps);
|
|
159
|
+
try {
|
|
160
|
+
const rows = await kernel.driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, kernel.config);
|
|
161
|
+
return (rows || [])
|
|
162
|
+
.filter(row => typeof row.event_type === 'string' && row.event_type.startsWith('gate.'))
|
|
163
|
+
.map(parseGateEvent);
|
|
164
|
+
} finally {
|
|
165
|
+
closeIfOwned(kernel);
|
|
166
|
+
}
|
|
168
167
|
}
|
|
169
168
|
|
|
170
169
|
/**
|
package/lib/global-flags.js
CHANGED
|
@@ -67,8 +67,38 @@ function stripGlobalFlags(args) {
|
|
|
67
67
|
return kept;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Index of the first positional token (a bare, non-flag argument), skipping any
|
|
72
|
+
* leading global flags and their consumed values. Returns -1 when no positional
|
|
73
|
+
* exists. Uses the SAME flag-consumption rules as {@link stripGlobalFlags} so the
|
|
74
|
+
* two agree on what counts as a positional. Unlike stripGlobalFlags this returns
|
|
75
|
+
* an index into the ORIGINAL array, so callers can splice out the positional
|
|
76
|
+
* while preserving the intervening flags.
|
|
77
|
+
*
|
|
78
|
+
* @param {string[]} args - Raw command arguments.
|
|
79
|
+
* @param {number} [start=0] - Index to begin scanning from.
|
|
80
|
+
* @returns {number} Index into `args` of the first positional token, or -1.
|
|
81
|
+
*/
|
|
82
|
+
function firstPositionalIndex(args, start = 0) {
|
|
83
|
+
for (let index = start; index < args.length; index += 1) {
|
|
84
|
+
const arg = args[index];
|
|
85
|
+
if (GLOBAL_BOOLEAN_FLAGS.has(arg)) continue;
|
|
86
|
+
if (GLOBAL_VALUE_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) continue;
|
|
87
|
+
if (GLOBAL_VALUE_FLAGS.has(arg)) {
|
|
88
|
+
const next = args[index + 1];
|
|
89
|
+
if (next !== undefined && !next.startsWith('-')) index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Any other dash-prefixed token is a non-global flag, not the positional.
|
|
93
|
+
if (arg.startsWith('-')) continue;
|
|
94
|
+
return index;
|
|
95
|
+
}
|
|
96
|
+
return -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
module.exports = {
|
|
71
100
|
GLOBAL_BOOLEAN_FLAGS,
|
|
72
101
|
GLOBAL_VALUE_FLAGS,
|
|
73
102
|
stripGlobalFlags,
|
|
103
|
+
firstPositionalIndex,
|
|
74
104
|
};
|