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
@@ -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,
@@ -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
- * @param {{ dryRun: boolean, prNumber?: string|number, startWatcher: Function }} params
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,
@@ -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
- runFile('git', ['worktree', 'add', worktreePath, '-b', branchName], { stdio: 'pipe' });
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 (options && Object.keys(options).length > 0) {
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
  }
@@ -23,7 +23,7 @@
23
23
  * runtime graph + `.forge/config.yaml`; this module only records/reads the events.
24
24
  */
25
25
 
26
- const { buildMigratedKernelIssueDeps } = require('./kernel/cli-broker-factory');
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
- * Resolve the kernel broker + driver + config. Tests (and the orchestrator) inject
42
- * a shared, already-migrated kernel via `deps`; the CLI path builds a fresh one for
43
- * the (short-lived) process.
44
- *
45
- * @param {string} projectRoot
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 { driver, config } = await resolveGateKernel(projectRoot, deps);
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
- const idempotencyKey = gateIdempotencyKey(eventType, issueId, gateId, actor);
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
- const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
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
- const payload = { gate: gateId, actor };
128
- if (typeof reason === 'string' && reason.length > 0) payload.reason = reason;
129
-
130
- const event = {
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
- try {
143
- const inserted = await driver.insertKernelEvent(event, {}, config);
144
- return { ok: true, duplicate: false, event: parseGateEvent(inserted), actor };
145
- } catch (error) {
146
- if (isIdempotencyRace(error)) {
147
- const winner = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
148
- return { ok: true, duplicate: true, event: winner ? parseGateEvent(winner) : parseGateEvent(event), actor };
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
- throw error;
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 { driver, config } = await resolveGateKernel(projectRoot, options.deps);
164
- const rows = await driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, config);
165
- return (rows || [])
166
- .filter(row => typeof row.event_type === 'string' && row.event_type.startsWith('gate.'))
167
- .map(parseGateEvent);
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
  /**
@@ -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
  };