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
@@ -105,12 +105,21 @@ forge sync
105
105
 
106
106
  ```bash
107
107
  forge worktree create <slug> --branch <branch-name>
108
+ forge worktree create <slug> --base <ref>
108
109
  forge worktree remove <slug>
109
110
  forge clean --dry-run
110
111
  ```
111
112
 
112
113
  Slugs must not contain `..`, `/`, or `\`.
113
114
 
115
+ A new worktree's branch is forked from the repository's **default branch**
116
+ (`origin/<default>` when the remote ref exists, else the local default) — **not**
117
+ the checkout's current branch/HEAD — so a worktree created from a WIP branch never
118
+ silently inherits unrelated commits. Pass `--base <ref>` to fork from a specific
119
+ ref instead; an invalid `--base` errors and creates nothing. `create` prints the
120
+ base it used (e.g. `Created worktree <path> on <branch> (based on origin/main).`)
121
+ so the fork point is never silent.
122
+
114
123
  ## Adapters
115
124
 
116
125
  ```bash
@@ -67,6 +67,43 @@ up from there.
67
67
  a `/loop`) that re-invokes the bounded pass with a debounce of at least 60
68
68
  seconds and cancel-in-progress. The shepherd itself never waits in-process.
69
69
 
70
+ ## Auto-start on ship (`rail.auto_shepherd`)
71
+
72
+ `forge shepherd watch <pr>` is the constant, self-stopping local monitor loop
73
+ (≈60 s jittered cadence; appends events to the per-PR NDJSON journal under
74
+ `.forge/pr-monitor/<repo>-<pr>/`; self-stops on `PR_MERGED`/`PR_CLOSED`). On a
75
+ successful `forge ship`, the new PR's watcher is **auto-started detached** so a
76
+ shipped PR is tended without a manual trigger. The spawn is best-effort and
77
+ **never fails ship** (a spawn or config-read error degrades to "not started"),
78
+ and it is idempotent — the watch-lifecycle PID/journal lock prevents a second
79
+ watcher for the same PR.
80
+
81
+ This auto-start is governed by the default-ON, unlocked **`rail.auto_shepherd`**
82
+ rail. Opt out with `forge gate disable rail.auto_shepherd` (re-enable with
83
+ `forge gate enable rail.auto_shepherd`); when disabled, `forge ship` skips the
84
+ auto-start. This keeps the behavior honestly toggleable through the same config
85
+ surface as every other rail.
86
+
87
+ ## Surfacing events back to the agent (`forge hooks shepherd-events`)
88
+
89
+ The constant watch loop is the PRODUCER: it writes per-PR NDJSON journals under
90
+ `.forge/pr-monitor/<repo>-<pr>/`, while the `forge shepherd events <pr> --since
91
+ <seq>` pull surface reads existing records back from them. But a journal only
92
+ helps if the working agent sees it. `forge hooks shepherd-events` is the thin,
93
+ agent-agnostic CONSUMER: it reads the NEW budget events across all open-PR journals
94
+ since a persisted per-PR **consumer cursor** (kept in `consumer.cursor`, distinct
95
+ from the watcher's snapshot), renders a **compact, capped** summary of the
96
+ actionable transitions only — verdict changes, failed checks, new review threads,
97
+ merged/closed — then advances the cursor so nothing re-surfaces.
98
+
99
+ For Claude Code this is wired as a **UserPromptSubmit** context hook (the honest
100
+ capability matrix: only Claude exposes that additionalContext surface; Cursor /
101
+ Codex / Hermes carry an explicit skip reason). It is **additive and FAIL-OPEN** —
102
+ a missing/empty digest, a corrupt journal, or no `.forge/pr-monitor` at all never
103
+ blocks a prompt — and it reads the user's own local journal only: it never
104
+ injects into stdin and never drives the agent. Any other harness can call the
105
+ same verb (or `forge shepherd events`) on its own cadence.
106
+
70
107
  ## Terminal states
71
108
 
72
109
  | State | Meaning |
@@ -111,5 +148,8 @@ seconds and cancel-in-progress. The shepherd itself never waits in-process.
111
148
 
112
149
  ## State
113
150
 
114
- Progress is durable in GitHub PR comments and labels plus `git`. There is no
115
- separate local state store.
151
+ Progress is durable in GitHub PR comments and labels plus `git`. The one local
152
+ store is the constant monitor's per-PR journal under
153
+ `.forge/pr-monitor/<repo>-<pr>/` (the append-only `events.ndjson` + snapshot and
154
+ consumer cursors) — the delivery/replay surface for `forge shepherd watch` and
155
+ `events --since`. The bounded shepherd pass itself keeps no separate local state.
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Lazy `.forge/` home creation (activation foundation).
5
+ *
6
+ * Discovery ≠ initialization. The global Forge plugin (SessionStart hook +
7
+ * `activation` skill) must create NOTHING in a user's repo. The FIRST verb that
8
+ * performs a real MUTATION (e.g. `forge claim`, `forge create`, `forge
9
+ * remember`) is what lazily materializes the bare-minimum `.forge/` skeleton.
10
+ * Read-only verbs (`ready`, `show`, `status`, `recap`, …) never call this, so a
11
+ * bare repo stays untouched until the user actually changes state.
12
+ *
13
+ * "Bare-minimum" here is strictly LESS than `forge init --minimal`: this writes
14
+ * ONLY `.forge/config.yaml` with every gate disabled (the `minimal` adoption
15
+ * profile). It installs NO git hooks, NO lefthook.yml, NO protected-paths
16
+ * manifest, NO `.mcp.json`, and NO scripts tree — those remain the opt-in
17
+ * payload of `forge setup`, never forced. Progressive growth: heavier slices
18
+ * initialize themselves on first use.
19
+ *
20
+ * @module activation/ensure-forge-home
21
+ */
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const { renderAdoptionConfigYaml } = require('../adoption-profiles');
27
+
28
+ /**
29
+ * Core verbs that MUTATE project state and therefore need `.forge/` to exist.
30
+ *
31
+ * Deliberately excludes `init`/`setup` — they own `.forge/` creation with their
32
+ * own (richer) logic, and pre-creating a minimal config would trip their
33
+ * no-clobber guard. Also excludes every read-only verb, so those write nothing.
34
+ *
35
+ * Deliberately EXCLUDES `gate` and `stage`: each has read-only subcommands
36
+ * (`gate status`/`gate check`, `stage --list`/`--current`) that must not create
37
+ * `.forge/` — a verb-level trigger would violate the foundation's own
38
+ * "read-only writes nothing" invariant. Their genuinely-mutating forms
39
+ * self-manage without ensureForgeHome: `gate enable|disable` and `role` write
40
+ * via the config writer (which creates `.forge/config.yaml` if absent), while
41
+ * `gate approve|reject` and `stage --start|--complete` write kernel events/runs
42
+ * through the broker (which lazily creates its own store). Subcommand-level
43
+ * granularity is deferred; excluding the whole verb is the correct, safe default
44
+ * because the read-only forms are the common case. `role` is retained: it has NO
45
+ * read-only form (every valid invocation writes config), so it violates nothing.
46
+ *
47
+ * A command module may override membership by exporting `mutating: true|false`;
48
+ * this Set is the default classification for the foundation.
49
+ */
50
+ const MUTATING_VERBS = new Set([
51
+ 'claim',
52
+ 'close',
53
+ 'create',
54
+ 'comment',
55
+ 'update',
56
+ 'add',
57
+ 'new',
58
+ 'remember',
59
+ 'role',
60
+ 'patch',
61
+ ]);
62
+
63
+ /**
64
+ * Decide whether a verb should lazily ensure the `.forge/` home.
65
+ *
66
+ * A command module's explicit `mutating` boolean wins; otherwise fall back to
67
+ * the default {@link MUTATING_VERBS} classification.
68
+ *
69
+ * @param {string} name - Command/verb name being dispatched.
70
+ * @param {object} [command] - The resolved command module (may declare `mutating`).
71
+ * @returns {boolean}
72
+ */
73
+ function isMutatingVerb(name, command) {
74
+ if (command && typeof command === 'object' && typeof command.mutating === 'boolean') {
75
+ return command.mutating;
76
+ }
77
+ return MUTATING_VERBS.has(name);
78
+ }
79
+
80
+ /**
81
+ * Render the bare-minimum, gates-disabled config body.
82
+ *
83
+ * Reuses the canonical `minimal` adoption profile so the produced YAML is
84
+ * schema-valid and identical in spirit to `forge init --minimal` (minus the
85
+ * hooks/protected-paths side effects). Overridable via `deps.renderConfig` for
86
+ * tests that don't want to depend on the profile renderer.
87
+ *
88
+ * @param {object} [deps]
89
+ * @returns {string} config.yaml contents
90
+ */
91
+ function renderMinimalConfig(deps = {}) {
92
+ const render = deps.renderConfig || (() => renderAdoptionConfigYaml('minimal'));
93
+ return render();
94
+ }
95
+
96
+ /**
97
+ * Idempotently create the bare-minimum `.forge/` skeleton for a mutating verb.
98
+ *
99
+ * No-clobber by construction: if `.forge/` already exists (inited repo, or a
100
+ * partially-created home), this is a NO-OP and never touches the user's files.
101
+ * The kernel/issue store (`.forge/kernel/…`) is created lazily by the broker on
102
+ * the mutating verb itself — this function only guarantees the config skeleton.
103
+ *
104
+ * @param {string} [projectRoot=process.cwd()] - Repo root to initialize.
105
+ * @param {object} [deps] - Injectable seams: `fs`, `renderConfig`.
106
+ * @returns {{ created: boolean, reason?: string, configPath: string }}
107
+ */
108
+ function ensureForgeHome(projectRoot = process.cwd(), deps = {}) {
109
+ const fsImpl = deps.fs || fs;
110
+ const forgeDir = path.join(projectRoot, '.forge');
111
+ const configPath = path.join(forgeDir, 'config.yaml');
112
+
113
+ // Idempotent + self-healing. The presence of `config.yaml` — NOT the `.forge/`
114
+ // directory alone — is what marks the home as initialized. Keying the
115
+ // no-clobber check on the config FILE means a half-init (dir created but config
116
+ // never written: disk full, permission error, or the process killed between
117
+ // mkdir and write) is COMPLETED on the next call instead of being permanently
118
+ // stuck behind a dir-exists early-return. We only ever ADD a missing
119
+ // config.yaml and never overwrite an existing one, so a real inited repo is
120
+ // still never clobbered. `mkdirSync({recursive})` is a no-op if the dir exists.
121
+ if (fsImpl.existsSync(configPath)) {
122
+ return { created: false, reason: 'config-exists', configPath };
123
+ }
124
+
125
+ fsImpl.mkdirSync(forgeDir, { recursive: true });
126
+ fsImpl.writeFileSync(configPath, renderMinimalConfig(deps), 'utf8');
127
+ return { created: true, configPath };
128
+ }
129
+
130
+ module.exports = {
131
+ ensureForgeHome,
132
+ isMutatingVerb,
133
+ renderMinimalConfig,
134
+ MUTATING_VERBS,
135
+ };
@@ -95,11 +95,60 @@ function readBeadsSidecar(beadsDir, file) {
95
95
  return found ? parseJsonl(fs.readFileSync(found, 'utf8'), path.basename(found)) : [];
96
96
  }
97
97
 
98
+ // The *.jsonl sidecars this migrator knows how to map into the Kernel. Any other *.jsonl
99
+ // present in the store is unmapped data; the loader records it so the import can report it
100
+ // as an honest gap rather than dropping it silently.
101
+ const HANDLED_SIDECARS = new Set([
102
+ 'issues.jsonl',
103
+ 'comments.jsonl',
104
+ 'dependencies.jsonl',
105
+ 'events.jsonl',
106
+ 'interactions.jsonl',
107
+ 'labels.jsonl',
108
+ ]);
109
+
110
+ // Scan the same locations readBeadsSidecar reads (the dir itself, a `backup/` subdir, and the
111
+ // parent) for *.jsonl files this migrator does NOT map. Returns their sorted, de-duplicated
112
+ // basenames (`unmigrated`) so importBeadsSnapshot can surface each as a `sidecar.<name>`
113
+ // field-gap — closing the "an unknown/config sidecar was dropped without a word" data-loss
114
+ // class — plus any `scanErrors`: a candidate dir ABSENT from disk (ENOENT) is expected and
115
+ // swallowed, but a REAL scan failure (EACCES/ENOTDIR/…) could hide sidecars silently, so it is
116
+ // reported too rather than masquerading as "no sidecars".
117
+ function detectUnmigratedSidecars(beadsDir) {
118
+ const dirs = [beadsDir, path.join(beadsDir, 'backup'), path.dirname(beadsDir)];
119
+ const unmigrated = new Set();
120
+ const scanErrors = [];
121
+ for (const dir of dirs) {
122
+ let entries;
123
+ try {
124
+ entries = fs.readdirSync(dir);
125
+ } catch (err) {
126
+ // A missing candidate dir is the normal case (not every layout has a backup/ subdir);
127
+ // only ENOENT is safe to swallow. Any other error is a real scan failure that may hide
128
+ // sidecars — record it so importBeadsSnapshot can surface an honest gap.
129
+ if (err && err.code !== 'ENOENT') {
130
+ scanErrors.push({ dir, code: err.code || 'EUNKNOWN' });
131
+ }
132
+ continue;
133
+ }
134
+ for (const entry of entries) {
135
+ if (entry.endsWith('.jsonl') && !HANDLED_SIDECARS.has(entry)) {
136
+ unmigrated.add(entry);
137
+ }
138
+ }
139
+ }
140
+ return {
141
+ unmigrated: [...unmigrated].sort((a, b) => a.localeCompare(b)),
142
+ scanErrors,
143
+ };
144
+ }
145
+
98
146
  function loadBeadsSnapshotFromDirectory(beadsDir) {
99
147
  if (!beadsDir || typeof beadsDir !== 'string') {
100
148
  throw new TypeError('beadsDir must be a directory path');
101
149
  }
102
150
 
151
+ const { unmigrated, scanErrors } = detectUnmigratedSidecars(beadsDir);
103
152
  return {
104
153
  beadsDir,
105
154
  issues: readBeadsSidecar(beadsDir, 'issues.jsonl'),
@@ -108,6 +157,8 @@ function loadBeadsSnapshotFromDirectory(beadsDir) {
108
157
  events: readBeadsSidecar(beadsDir, 'events.jsonl'),
109
158
  interactions: readBeadsSidecar(beadsDir, 'interactions.jsonl'),
110
159
  labels: readBeadsSidecar(beadsDir, 'labels.jsonl'),
160
+ unmigratedSidecars: unmigrated,
161
+ sidecarScanErrors: scanErrors,
111
162
  };
112
163
  }
113
164
 
@@ -725,6 +776,22 @@ function importBeadsSnapshot(snapshot = {}, options = {}) {
725
776
  ...(Array.isArray(snapshot.events) ? snapshot.events : []).map(event => mapBeadsActivityEventToKernel(event, importedAt)),
726
777
  ...(Array.isArray(snapshot.interactions) ? snapshot.interactions : []).map(interaction => mapBeadsInteractionToKernel(interaction, importedAt)),
727
778
  ];
779
+ // Honest field-gap report: any *.jsonl sidecar present in the store that this migrator does
780
+ // not map (e.g. config.jsonl, or a future/unknown sidecar) is surfaced here rather than
781
+ // silently dropped. The loader records these on `unmigratedSidecars`.
782
+ for (const sidecar of Array.isArray(snapshot.unmigratedSidecars) ? snapshot.unmigratedSidecars : []) {
783
+ addGap(gaps, seenGaps, `sidecar.${sidecar}`, 'present in the Beads store but has no Kernel target — not migrated');
784
+ }
785
+ // A directory the sidecar scan could not read (EACCES/ENOTDIR/…, but NOT a merely-absent dir)
786
+ // might hide sidecars; surface it so the loss is never silent.
787
+ for (const scanError of Array.isArray(snapshot.sidecarScanErrors) ? snapshot.sidecarScanErrors : []) {
788
+ addGap(
789
+ gaps,
790
+ seenGaps,
791
+ `sidecar-scan.${path.basename(scanError.dir)}`,
792
+ `could not scan ${scanError.dir} for unmapped sidecars (${scanError.code}) — any sidecars there were not migrated`,
793
+ );
794
+ }
728
795
  const kernel = {
729
796
  issues: sourceIssues.map(issue => mapBeadsIssueToKernel(issue, importedAt, gaps, seenGaps)),
730
797
  dependencies: collectDependencies(normalizedSnapshot)
@@ -1,6 +1,6 @@
1
1
  const YAML = require('yaml');
2
2
 
3
- const ADOPTION_VERSION = '0.0.15';
3
+ const ADOPTION_VERSION = '0.0.16';
4
4
  const RUNTIME_ANCESTRY = 'forge.runtimeGraph.currentCommandFlow@0.0.17';
5
5
  const WORKFLOW_PHASES = Object.freeze(['plan', 'dev', 'validate', 'ship']);
6
6
  const WORKFLOW_GATES = Object.freeze(['gate.plan-exit', 'gate.dev-exit', 'gate.validate-exit', 'gate.ship-entry']);
@@ -32,12 +32,18 @@ function harnessAdapter(targets) {
32
32
  };
33
33
  }
34
34
 
35
- function adoptionConfig({ gatesEnabled, issueEnabled, harnessTargets, protectedPaths, rails }) {
35
+ function adoptionConfig({ gatesEnabled, issueEnabled, harnessTargets, protectedPaths, rails, disabledRails = [] }) {
36
+ // Disabled rails are written through the same `workflow.gates.<rail-id>.enabled`
37
+ // toggle surface `forge gate disable` uses, so the resolver + the installed hooks
38
+ // read one consistent signal. Only unlocked rails (e.g. rail.tdd_intent) may appear.
39
+ const railGateOverrides = Object.fromEntries(
40
+ disabledRails.map(railId => [railId, { enabled: false }]),
41
+ );
36
42
  return {
37
43
  ...(rails ? { rails } : {}),
38
44
  workflow: {
39
45
  phases: enabledEntries(WORKFLOW_PHASES),
40
- gates: enabledEntries(WORKFLOW_GATES, gatesEnabled),
46
+ gates: { ...enabledEntries(WORKFLOW_GATES, gatesEnabled), ...railGateOverrides },
41
47
  },
42
48
  adapters: {
43
49
  issue: issueAdapter(issueEnabled),
@@ -54,7 +60,14 @@ const PROFILE_CONFIGS = Object.freeze({
54
60
  gatesEnabled: false,
55
61
  issueEnabled: false,
56
62
  harnessTargets: [],
57
- protectedPaths: ['.forge/config.yaml'],
63
+ // Minimal = zero active enforcement, so there is nothing left to guard: with the
64
+ // gates disabled and the TDD rail off, the protected-path guard has no enforcement
65
+ // surface to protect. Leaving it empty keeps `init --minimal` fully inert instead of
66
+ // contradicting itself by still guarding config.yaml.
67
+ protectedPaths: [],
68
+ // Minimal = no active enforcement: the TDD pre-commit rail is off (it is a
69
+ // strong default, not a hard floor), so `init --minimal` installs inert hooks.
70
+ disabledRails: ['rail.tdd_intent'],
58
71
  }),
59
72
  },
60
73
  standard: {
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ // Single-source, side-effect-free Beads-store detector (kernel issue a5399f3d).
7
+ //
8
+ // Both upgrade-safety surfaces reuse this ONE definition: the issue-path
9
+ // unmigrated-beads nudge (lib/commands/_issue.js) and the `forge upgrade`
10
+ // advisory (lib/upgrade-safety.js). It deliberately lives in a neutral module,
11
+ // NOT in lib/commands/migrate.js — re-exporting a `detectBeadsJsonlSource` name
12
+ // from the migrate command would revive the identifier the a7e1443c
13
+ // implicit-auto-migrate tombstone pins gone (test/commands/runtime-no-auto-migrate).
14
+ // This detector only READS the filesystem and never triggers a migration, so it
15
+ // honors that tombstone's spirit while giving both nudges a shared source.
16
+
17
+ function dirHasJsonl(dir) {
18
+ try {
19
+ return fs.readdirSync(dir).some(entry => entry.endsWith('.jsonl'));
20
+ } catch (_err) { // NOSONAR S2486 — line-scoped; must sit on the catch line
21
+ /* intentional: an unreadable directory has no usable jsonl */
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Return the absolute path of the directory holding a returning user's Beads
28
+ * JSONL under `projectRoot`, or null when none is found. Checks the top-level
29
+ * `.beads/` first, then the split-store `.beads/backup/` layout (jsonl there and
30
+ * nowhere else — a layout the migrator itself reads), so neither surface misses
31
+ * it. Never throws.
32
+ *
33
+ * @param {string} [projectRoot]
34
+ * @returns {string|null}
35
+ */
36
+ function detectBeadsJsonlSource(projectRoot) {
37
+ const root = projectRoot || process.cwd();
38
+ const beadsDir = path.join(root, '.beads');
39
+ try {
40
+ if (!fs.existsSync(beadsDir)) {
41
+ return null;
42
+ }
43
+ if (dirHasJsonl(beadsDir)) {
44
+ return beadsDir;
45
+ }
46
+ const backupDir = path.join(beadsDir, 'backup');
47
+ if (fs.existsSync(backupDir) && dirHasJsonl(backupDir)) {
48
+ return backupDir;
49
+ }
50
+ return null;
51
+ } catch (_err) { // NOSONAR S2486 — line-scoped; must sit on the catch line
52
+ /* intentional: an unreadable project root has no detectable beads source */
53
+ return null;
54
+ }
55
+ }
56
+
57
+ module.exports = {
58
+ detectBeadsJsonlSource,
59
+ dirHasJsonl,
60
+ };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const { resolveIssueBackend } = require('./issue-backend');
4
+ const { detectBeadsJsonlSource } = require('./beads-detect');
5
+
6
+ // Unmigrated legacy-store nudge (kernel issue a5399f3d — upgrade-safety
7
+ // 0.0.10 -> current).
8
+ //
9
+ // The 0.0.10 -> current upgrade flipped the DEFAULT issue backend to the Kernel,
10
+ // but the legacy -> kernel migration fires ONLY from `forge setup`/`init`, never
11
+ // lazily on the issue path. So a user who runs `bun update` then `forge list` /
12
+ // `forge ready` reads an EMPTY kernel and their 0.0.10 issues APPEAR GONE — the
13
+ // data is safe on disk, just invisible, with no hint. This helper closes that
14
+ // footgun: when the resolved backend is the (default) Kernel, a read comes back
15
+ // empty, AND a legacy jsonl store still exists, it prints a one-time guided hint.
16
+ //
17
+ // It lives in this NEUTRAL module (not lib/commands/_issue.js) on purpose: the
18
+ // message text names the retired backend + its `.beads` store, and _issue.js is a
19
+ // D20 release-readiness hot-path surface that must stay free of those tokens (the
20
+ // bd-call-site audit + kernel-backed checks scan it). Keeping the strings here
21
+ // lets the hot path call a token-free helper.
22
+ //
23
+ // Best-effort and non-blocking: it NEVER throws and never alters the read result.
24
+ // Fires at most once per project root per process to avoid spam.
25
+ const NUDGE_READS = new Set(['list', 'ready']);
26
+ const warnedRoots = new Set();
27
+
28
+ // A kernel read looks empty when its contract data carries no issues. Handles the
29
+ // issue.list/issue.ready shape ({ issues, count }) plus a bare array / null, and
30
+ // is deliberately conservative: anything it cannot confirm as empty is treated as
31
+ // NON-empty so a user with real kernel issues is never nagged.
32
+ function kernelReadLooksEmpty(result) {
33
+ if (!result || typeof result !== 'object' || result.ok !== true) {
34
+ return false;
35
+ }
36
+ const data = result.data;
37
+ if (data === null || data === undefined) {
38
+ return true;
39
+ }
40
+ if (Array.isArray(data)) {
41
+ return data.length === 0;
42
+ }
43
+ if (Array.isArray(data.issues)) {
44
+ return data.issues.length === 0;
45
+ }
46
+ if (Array.isArray(data.items)) {
47
+ return data.items.length === 0;
48
+ }
49
+ if (typeof data.count === 'number') {
50
+ return data.count === 0;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ function maybeWarnUnmigratedBeads(subcommand, result, projectRoot, rawOpts = {}) {
56
+ try {
57
+ if (!NUDGE_READS.has(subcommand) || !projectRoot) {
58
+ return;
59
+ }
60
+ if (warnedRoots.has(projectRoot)) {
61
+ return;
62
+ }
63
+ const env = rawOpts.env || process.env;
64
+ // Resolve the EFFECTIVE backend (default kernel). An explicit opt-in to the
65
+ // retired backend means the user chose it deliberately — nothing to nudge.
66
+ const backend = resolveIssueBackend({ deps: rawOpts, env, projectRoot, warn: () => {} });
67
+ if (backend !== 'kernel' || !kernelReadLooksEmpty(result)) {
68
+ return;
69
+ }
70
+ if (!detectBeadsJsonlSource(projectRoot)) {
71
+ return;
72
+ }
73
+ warnedRoots.add(projectRoot);
74
+ console.error(
75
+ '\n[forge] Legacy issue data detected (.beads/*.jsonl) but the Kernel issue store is empty.\n'
76
+ + 'Forge now defaults to the Kernel backend (breaking change since 0.0.10). Your legacy\n'
77
+ + 'issues are safe on disk but will not appear until migrated. To migrate:\n'
78
+ + ' forge migrate --from beads # import your legacy issues into the Kernel\n'
79
+ + ' forge setup # (re)wire hooks + provision the Kernel store\n'
80
+ + 'Prefer to stay on the legacy backend? Set `issueBackend: beads` in .forge/config.yaml '
81
+ + '(or FORGE_ISSUE_BACKEND=beads).\n',
82
+ );
83
+ } catch (_err) { // NOSONAR S2486 — line-scoped; must sit on the catch line
84
+ /* the nudge is a best-effort hint; it must never break a read */
85
+ }
86
+ }
87
+
88
+ module.exports = {
89
+ maybeWarnUnmigratedBeads,
90
+ kernelReadLooksEmpty,
91
+ };