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/init.js
CHANGED
|
@@ -335,27 +335,40 @@ function renderDayOneConfigYaml({ profile, classification, harnessTargets, rails
|
|
|
335
335
|
config.workflow.classification = { default: classification };
|
|
336
336
|
config.layer1Rails = {
|
|
337
337
|
confirmed: railsConfirmed,
|
|
338
|
+
// The immutable Layer 1 floor: locked rails that cannot be toggled off.
|
|
338
339
|
rails: [
|
|
339
|
-
'tdd_intent',
|
|
340
340
|
'secret_scan',
|
|
341
341
|
'branch_protection',
|
|
342
342
|
'signed_commits',
|
|
343
343
|
'schema_integrity',
|
|
344
344
|
],
|
|
345
|
+
// tdd_intent is a strong DEFAULT rail, not an immutable L1 floor: it is unlocked
|
|
346
|
+
// (locked:false) and honestly toggleable via `forge gate disable rail.tdd_intent`
|
|
347
|
+
// (the minimal profile ships it off). Listed separately so this display never
|
|
348
|
+
// implies it is a non-negotiable Layer 1 rail (issue eda6d866).
|
|
349
|
+
toggleableRails: ['tdd_intent'],
|
|
345
350
|
};
|
|
346
351
|
config.adapters = config.adapters || {};
|
|
347
352
|
config.adapters.harness = {
|
|
348
353
|
enabled: harnessTargets.length > 0,
|
|
349
354
|
targets: harnessTargets,
|
|
350
355
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
// The adoption profile is authoritative for whether protected-path enforcement runs
|
|
357
|
+
// at all. When it resolves EMPTY (the `minimal` profile = zero active enforcement),
|
|
358
|
+
// the protected-path guard must stay genuinely inert — do NOT re-add the day-one
|
|
359
|
+
// paths, or `init --minimal` would silently reactivate the guard the profile just
|
|
360
|
+
// disabled (issue eda6d866). Standard/full keep their profile paths + the additions.
|
|
361
|
+
const profilePaths = Array.isArray(config.protectedPaths) ? config.protectedPaths : [];
|
|
362
|
+
config.protectedPaths = profilePaths.length === 0
|
|
363
|
+
? []
|
|
364
|
+
: [
|
|
365
|
+
...new Set([
|
|
366
|
+
...profilePaths,
|
|
367
|
+
'.forge/config.yaml',
|
|
368
|
+
'.forge/patch.md',
|
|
369
|
+
'.forge/protected-paths.yaml',
|
|
370
|
+
]),
|
|
371
|
+
];
|
|
359
372
|
return YAML.stringify(config);
|
|
360
373
|
}
|
|
361
374
|
|
|
@@ -543,24 +556,17 @@ async function handler(args, flags, projectRoot = process.cwd(), deps = {}) {
|
|
|
543
556
|
}
|
|
544
557
|
|
|
545
558
|
// Close the onboarding loop that a bare `forge init` used to leave open:
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
559
|
+
// install git hooks so stage commands are not HOOKS_NOT_ACTIVE-blocked. Reuses
|
|
560
|
+
// setup's real implementation (overridable via deps for tests) and degrades to a
|
|
561
|
+
// warning rather than failing init. Beads → Kernel transfer is NOT done here: it
|
|
562
|
+
// is explicit-only via `forge migrate --from beads` (a7e1443c), never implicit.
|
|
550
563
|
const installHooks = deps.installHooks
|
|
551
564
|
|| ((root) => require('./setup').ensureGitHooksInstalled(root));
|
|
552
|
-
const autoMigrateBeads = deps.autoMigrateBeads
|
|
553
|
-
|| ((root) => require('./migrate').autoMigrateBeadsIfPresent(root));
|
|
554
565
|
try {
|
|
555
566
|
await installHooks(projectRoot);
|
|
556
567
|
} catch (err) {
|
|
557
568
|
console.warn(`Warning: git hook installation skipped: ${err.message}`);
|
|
558
569
|
}
|
|
559
|
-
try {
|
|
560
|
-
await autoMigrateBeads(projectRoot);
|
|
561
|
-
} catch (err) {
|
|
562
|
-
console.warn(`Warning: Beads → Kernel migration skipped: ${err.message}`);
|
|
563
|
-
}
|
|
564
570
|
|
|
565
571
|
console.log(`Initialized Forge adoption profile '${profile}' at .forge/`);
|
|
566
572
|
console.log(`Classification: ${choices.classification}`);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const remember = require('./remember');
|
|
4
|
+
const recall = require('./recall');
|
|
5
|
+
const insights = require('./insights');
|
|
6
|
+
const { stripGlobalFlags } = require('../global-flags');
|
|
7
|
+
|
|
8
|
+
// One memorable surface over the EXISTING memory commands (kernel issue 25362344): every
|
|
9
|
+
// subcommand delegates to the standalone remember/recall/insights handlers — the same
|
|
10
|
+
// kernel-backed store, not a reimplementation. The standalone `forge remember`/`forge
|
|
11
|
+
// recall`/`forge insights` commands remain registered as back-compat aliases, so nothing
|
|
12
|
+
// that already calls them breaks.
|
|
13
|
+
const SUBCOMMANDS = {
|
|
14
|
+
add: {
|
|
15
|
+
handler: remember.handler,
|
|
16
|
+
summary: 'Persist a memory note (= forge remember; supports --type + What/Why/Where/Learned)',
|
|
17
|
+
},
|
|
18
|
+
recall: {
|
|
19
|
+
handler: recall.handler,
|
|
20
|
+
summary: 'Retrieve memory notes, newest first (= forge recall; filter with --type)',
|
|
21
|
+
},
|
|
22
|
+
search: {
|
|
23
|
+
// Search IS recall with a query — recall runs a BM25 token-AND search when a query is
|
|
24
|
+
// present, so the same handler serves both without a second code path.
|
|
25
|
+
handler: recall.handler,
|
|
26
|
+
summary: 'Search memory notes by query (recall with a query; filter with --type)',
|
|
27
|
+
},
|
|
28
|
+
insights: {
|
|
29
|
+
handler: insights.handler,
|
|
30
|
+
summary: 'Detect recurring evidence patterns and suggest follow-ups (= forge insights)',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const usage = 'Usage: forge memory <add|recall|search|insights> [args]';
|
|
35
|
+
|
|
36
|
+
function renderHelp() {
|
|
37
|
+
const width = Math.max(...Object.keys(SUBCOMMANDS).map(name => name.length));
|
|
38
|
+
const lines = [
|
|
39
|
+
usage,
|
|
40
|
+
'',
|
|
41
|
+
'Subcommands:',
|
|
42
|
+
...Object.entries(SUBCOMMANDS).map(
|
|
43
|
+
([name, { summary }]) => ` ${name.padEnd(width)} ${summary}`
|
|
44
|
+
),
|
|
45
|
+
'',
|
|
46
|
+
'Back-compat: forge remember / forge recall / forge insights remain available as aliases.',
|
|
47
|
+
];
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function handler(args, flags, projectRoot, opts) {
|
|
52
|
+
// The subcommand is the first positional token; global flags (e.g. `-p <dir>`) are stripped
|
|
53
|
+
// first so they never masquerade as the subcommand.
|
|
54
|
+
const positional = stripGlobalFlags(args).find(arg => !arg.startsWith('-'));
|
|
55
|
+
|
|
56
|
+
if (!positional || positional === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
57
|
+
return { success: true, output: renderHelp() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sub = SUBCOMMANDS[positional];
|
|
61
|
+
if (!sub) {
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error: `Unknown memory subcommand: ${positional}\n\n${renderHelp()}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Forward everything EXCEPT the consumed subcommand token to the delegate, preserving any
|
|
69
|
+
// global flags the delegate re-parses (e.g. `-p <dir>`, `--all`).
|
|
70
|
+
const idx = args.indexOf(positional);
|
|
71
|
+
const childArgs = idx >= 0 ? [...args.slice(0, idx), ...args.slice(idx + 1)] : args;
|
|
72
|
+
return sub.handler(childArgs, flags, projectRoot, opts);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
name: 'memory',
|
|
77
|
+
description:
|
|
78
|
+
'Unified memory surface: forge memory add|recall|search|insights (wraps remember/recall/insights)',
|
|
79
|
+
usage,
|
|
80
|
+
handler,
|
|
81
|
+
};
|
package/lib/commands/migrate.js
CHANGED
|
@@ -301,171 +301,10 @@ async function runBeadsMigration(options, projectRoot, opts) {
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
/**
|
|
305
|
-
* Detect a jsonl-backed Beads store under `<projectRoot>/.beads`. Returns the
|
|
306
|
-
* directory path when it holds *.jsonl sidecars, else null.
|
|
307
|
-
*
|
|
308
|
-
* Onboarding (forge setup/init) uses this to decide whether to auto-migrate.
|
|
309
|
-
* It deliberately never falls back to `bd export` (a Dolt-only store with no
|
|
310
|
-
* jsonl returns null), so setup stays bd-free even when Dolt is down.
|
|
311
|
-
*/
|
|
312
|
-
function detectBeadsJsonlSource(projectRoot, deps = {}) {
|
|
313
|
-
const fsImpl = deps.fs || fs;
|
|
314
|
-
const beadsDir = path.join(projectRoot || process.cwd(), '.beads');
|
|
315
|
-
return fsImpl.existsSync(beadsDir) && directoryHasJsonl(beadsDir, fsImpl)
|
|
316
|
-
? beadsDir
|
|
317
|
-
: null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Idempotently import an existing jsonl-backed Beads store into the Kernel as
|
|
322
|
-
* part of onboarding. Reuses the exact `forge migrate --from beads` spine, so
|
|
323
|
-
* gaps are surfaced honestly and a second run inserts nothing.
|
|
324
|
-
*
|
|
325
|
-
* No-op (`{ migrated: false, reason: 'no-beads-jsonl' }`) when there is no jsonl
|
|
326
|
-
* `.beads/` present. Never requires the `bd` binary.
|
|
327
|
-
*
|
|
328
|
-
* @param {string} projectRoot
|
|
329
|
-
* @param {object} [opts] - Passed through to runBeadsMigration (_broker/_now/_fs seams).
|
|
330
|
-
* @returns {Promise<{ migrated: boolean, reason?: string, result?: object }>}
|
|
331
|
-
*/
|
|
332
|
-
async function autoMigrateBeadsIfPresent(projectRoot, opts = {}) {
|
|
333
|
-
if (!detectBeadsJsonlSource(projectRoot, { fs: opts._fs })) {
|
|
334
|
-
return { migrated: false, reason: 'no-beads-jsonl' };
|
|
335
|
-
}
|
|
336
|
-
const result = await runBeadsMigration({ from: 'beads' }, projectRoot, opts);
|
|
337
|
-
return { migrated: result.success === true, result };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const BEADS_MIGRATE_NUDGE =
|
|
341
|
-
'Forge could not import your Beads issues into the Kernel automatically. '
|
|
342
|
-
+ 'Run `forge migrate --from beads` to import them.';
|
|
343
|
-
|
|
344
|
-
// The "already imported" marker lives as a row in the broker's kernel_migrations
|
|
345
|
-
// ledger (created unconditionally by broker.initialize()), NOT as a file beside the
|
|
346
|
-
// DB. Keeping it INSIDE the kernel DB means a DB reset drops the marker and the import
|
|
347
|
-
// self-heals; a file sentinel would survive the reset and leave issues dark forever.
|
|
348
|
-
// The id uses underscores so it passes the ledger charset /^[0-9a-z_]+$/i and stays
|
|
349
|
-
// clear of the tokens the D20 retirement audit counts (see release-readiness.js).
|
|
350
|
-
const IMPORT_MARKER_TABLE = 'kernel_migrations';
|
|
351
|
-
const IMPORT_MARKER_ID = 'data_import_beads_jsonl';
|
|
352
|
-
|
|
353
|
-
// Read/record the import marker via the DRIVER (the broker exposes no raw SQL). Both
|
|
354
|
-
// calls are param-less (raw SQL + config); the id is a hardcoded constant and appliedAt
|
|
355
|
-
// is an ISO string, so interpolation is injection-safe — mirrors broker.recordMigrationSql.
|
|
356
|
-
async function importMarkerPresent(driver, config) {
|
|
357
|
-
if (!driver || typeof driver.queryAll !== 'function') {
|
|
358
|
-
return false;
|
|
359
|
-
}
|
|
360
|
-
try {
|
|
361
|
-
const rows = await driver.queryAll(
|
|
362
|
-
`SELECT 1 FROM ${IMPORT_MARKER_TABLE} WHERE id = '${IMPORT_MARKER_ID}' LIMIT 1;`,
|
|
363
|
-
config,
|
|
364
|
-
);
|
|
365
|
-
return Array.isArray(rows) && rows.length > 0;
|
|
366
|
-
} catch {
|
|
367
|
-
// Ledger table absent / driver unavailable → treat as not-yet-imported.
|
|
368
|
-
return false;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function recordImportMarker(driver, config, appliedAt) {
|
|
373
|
-
if (!driver || typeof driver.exec !== 'function') {
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
try {
|
|
377
|
-
await driver.exec(
|
|
378
|
-
`INSERT OR IGNORE INTO ${IMPORT_MARKER_TABLE} (id, applied_at) VALUES ('${IMPORT_MARKER_ID}', '${appliedAt}');`,
|
|
379
|
-
config,
|
|
380
|
-
);
|
|
381
|
-
} catch {
|
|
382
|
-
// Best-effort marker; never break the command we ride on.
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* First-use safety net for the kernel default backend. Onboarding auto-migrate runs
|
|
388
|
-
* only from `forge setup`/`init`; an existing repo whose user merely upgrades forge
|
|
389
|
-
* reads an EMPTY kernel on the first issue command, so their existing Beads issues
|
|
390
|
-
* appear to vanish. This imports them ONCE — gated by an in-DB marker row in the
|
|
391
|
-
* kernel_migrations ledger, so the gate shares the DB lifecycle and a DB reset
|
|
392
|
-
* self-heals — idempotently, announcing on stderr only so `--json` stdout stays a pure
|
|
393
|
-
* contract. NEVER throws: the safety net must never break the command it rides on.
|
|
394
|
-
*
|
|
395
|
-
* Only a jsonl-backed store is auto-imported (the migration binary is never shelled).
|
|
396
|
-
* A store with no jsonl export is skipped silently. The marker is SUCCESS-ONLY: a
|
|
397
|
-
* failed import records nothing and is retried (with a nudge) on the next kernel
|
|
398
|
-
* command, so transient failures and DB resets self-heal. Imported issues land in the
|
|
399
|
-
* read model directly and arrive UNCLAIMED — they surface via `forge issue ready`.
|
|
400
|
-
*
|
|
401
|
-
* @param {object} params
|
|
402
|
-
* @param {string} params.projectRoot
|
|
403
|
-
* @param {string} params.databasePath - kernel DB path (used for the driver config).
|
|
404
|
-
* @param {object} params.broker - an initialized kernel broker to import through.
|
|
405
|
-
* @param {object} params.driver - kernel driver, for the ledger marker read/write.
|
|
406
|
-
* @param {object} [deps] - { fs, warn, now, driver, _fs } seams for tests.
|
|
407
|
-
* @returns {Promise<{action:'migrated'|'nudge'|'skip', reason?:string, inserted?:number}>}
|
|
408
|
-
*/
|
|
409
|
-
async function autoMigrateBeadsAtRuntime({ projectRoot, databasePath, broker, driver } = {}, deps = {}) {
|
|
410
|
-
const fsImpl = deps.fs || fs;
|
|
411
|
-
const warn = deps.warn || ((msg) => process.stderr.write(`${msg}\n`));
|
|
412
|
-
const appliedAt = deps.now || new Date().toISOString();
|
|
413
|
-
const markerDriver = deps.driver || driver;
|
|
414
|
-
const markerConfig = databasePath ? { databasePath } : {};
|
|
415
|
-
try {
|
|
416
|
-
if (!databasePath) {
|
|
417
|
-
return { action: 'skip', reason: 'no-db-path' };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// One-time gate: the import already ran on THIS kernel DB (the marker dies with the
|
|
421
|
-
// DB, so deleting kernel.sqlite to reset correctly re-triggers the import).
|
|
422
|
-
if (await importMarkerPresent(markerDriver, markerConfig)) {
|
|
423
|
-
return { action: 'skip', reason: 'already-imported' };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Only a jsonl export can be imported automatically; anything else is skipped
|
|
427
|
-
// silently (an empty or export-less store must never trigger a false nudge).
|
|
428
|
-
if (!detectBeadsJsonlSource(projectRoot, { fs: fsImpl })) {
|
|
429
|
-
return { action: 'skip', reason: 'no-jsonl' };
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Import (idempotent) through the already-initialized broker.
|
|
433
|
-
let outcome;
|
|
434
|
-
try {
|
|
435
|
-
outcome = await autoMigrateBeadsIfPresent(projectRoot, { _broker: broker, _now: deps.now, _fs: deps._fs });
|
|
436
|
-
} catch (err) {
|
|
437
|
-
outcome = { migrated: false, result: { success: false, error: err.message } };
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (outcome.migrated) {
|
|
441
|
-
const inserted = outcome.result?.imported?.issues?.inserted ?? 0;
|
|
442
|
-
const skipped = outcome.result?.imported?.issues?.skipped ?? 0;
|
|
443
|
-
// Record the marker LAST so a concurrent first run is a benign idempotent skip.
|
|
444
|
-
await recordImportMarker(markerDriver, markerConfig, appliedAt);
|
|
445
|
-
if (inserted > 0) {
|
|
446
|
-
warn(`Forge: imported ${inserted} Beads issue(s) into the Kernel — your issues are here, not lost.`);
|
|
447
|
-
}
|
|
448
|
-
return { action: 'migrated', inserted, skipped };
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Success-only marker: a failed import records nothing and re-nudges next run,
|
|
452
|
-
// so a transient failure self-heals once the underlying problem is resolved.
|
|
453
|
-
const error = outcome.result?.error || outcome.reason || 'unknown error';
|
|
454
|
-
warn(BEADS_MIGRATE_NUDGE);
|
|
455
|
-
return { action: 'nudge', reason: 'migrate-failed', error };
|
|
456
|
-
} catch (err) {
|
|
457
|
-
// Absolutely never break the command we ride on.
|
|
458
|
-
return { action: 'skip', reason: 'error', error: err.message };
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
304
|
module.exports = {
|
|
463
305
|
name: 'migrate',
|
|
464
306
|
description: 'Migrate a Beads issue store into the Forge Kernel with --from beads, '
|
|
465
307
|
+ 'or preview the v2→v3 migration (preview only — --dry-run required; applying it is not yet available)',
|
|
466
|
-
detectBeadsJsonlSource,
|
|
467
|
-
autoMigrateBeadsIfPresent,
|
|
468
|
-
autoMigrateBeadsAtRuntime,
|
|
469
308
|
usage: 'forge migrate --from beads [--dry-run] [--source <dir>] [--json]',
|
|
470
309
|
flags: {
|
|
471
310
|
'--from <source>': 'Migration source. Use "beads" to import a Beads issue store into the Kernel.',
|
package/lib/commands/plan.js
CHANGED
|
@@ -517,7 +517,12 @@ function currentBranchIssueFromDriver(driver, currentBranch) {
|
|
|
517
517
|
if (driver && typeof driver.listWorktrees === 'function') {
|
|
518
518
|
try {
|
|
519
519
|
const rows = driver.listWorktrees() || [];
|
|
520
|
-
|
|
520
|
+
// Only an ACTIVE (live) linkage row binds the branch: a superseded/stale
|
|
521
|
+
// registration for a reused branch name must not trigger a false split-state
|
|
522
|
+
// conflict against the OLD issue (R4/be18881c). Tolerate a null state for
|
|
523
|
+
// rows written before the state column was populated.
|
|
524
|
+
const match = rows.find(row => row && row.branch === currentBranch && row.issue_id
|
|
525
|
+
&& (row.state === 'active' || row.state == null));
|
|
521
526
|
if (match) return match.issue_id;
|
|
522
527
|
} catch {
|
|
523
528
|
// fall through to branch-name parsing
|
|
@@ -553,6 +558,9 @@ async function detectBranchIssueConflict(options, explicitIssueId) {
|
|
|
553
558
|
async function registerBranchIssueLinkage(options, branch, issueId) {
|
|
554
559
|
if (!branch || !issueId) return;
|
|
555
560
|
const cwd = options.projectRoot || process.cwd();
|
|
561
|
+
// F6 defaultStageWarn pattern: write to stderr so a dropped linkage never
|
|
562
|
+
// pollutes machine-readable stdout, yet leaves a trace even under FORGE_JSON=1.
|
|
563
|
+
const warn = options.warn || (message => process.stderr.write(`${message}\n`));
|
|
556
564
|
await withPlanDriver(options, driver => {
|
|
557
565
|
if (!driver || typeof driver.registerWorktree !== 'function') return;
|
|
558
566
|
try {
|
|
@@ -566,8 +574,11 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
|
|
|
566
574
|
registered_at: new Date().toISOString(),
|
|
567
575
|
state: 'active',
|
|
568
576
|
});
|
|
569
|
-
} catch {
|
|
570
|
-
//
|
|
577
|
+
} catch (error) {
|
|
578
|
+
// Best-effort: linkage failure must not fail plan, but it must NOT be
|
|
579
|
+
// silent (R3) — otherwise ship later fail-closes with no signal at plan
|
|
580
|
+
// time about the dropped branch->issue linkage.
|
|
581
|
+
warn(`[forge] could not register branch->issue linkage for ${branch} -> ${issueId}: ${error.message}`);
|
|
571
582
|
}
|
|
572
583
|
});
|
|
573
584
|
}
|
|
@@ -648,7 +659,10 @@ async function createKernelIssue(featureName, researchPath, scope, options = {})
|
|
|
648
659
|
|
|
649
660
|
/**
|
|
650
661
|
* Create feature branch
|
|
651
|
-
* Creates
|
|
662
|
+
* Creates a new git branch following feat/<slug> convention WITHOUT switching
|
|
663
|
+
* the shared checkout's HEAD (uses `git branch`, not `git checkout -b`).
|
|
664
|
+
* Switching HEAD in the shared working tree corrupts concurrent agents
|
|
665
|
+
* (kernel issue aa14966c); isolated work happens in a dedicated worktree.
|
|
652
666
|
*
|
|
653
667
|
* Security: Uses execFileSync with array args to prevent command injection
|
|
654
668
|
*
|
|
@@ -678,14 +692,19 @@ function createFeatureBranch(featureSlug) {
|
|
|
678
692
|
execFileSync('git', ['rev-parse', '--verify', branchName], { ...getExecOptions(), stdio: 'pipe' }); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
679
693
|
return {
|
|
680
694
|
success: false,
|
|
681
|
-
error: `Branch ${branchName} already exists\n\
|
|
695
|
+
error: `Branch ${branchName} already exists\n\nWork on it in an isolated checkout: forge worktree create ${featureSlug}\n(or, working solo: git switch ${branchName})`,
|
|
682
696
|
};
|
|
683
697
|
} catch {
|
|
684
698
|
// Branch doesn't exist, continue (expected case)
|
|
685
699
|
}
|
|
686
700
|
|
|
687
|
-
// Create
|
|
688
|
-
|
|
701
|
+
// Create the branch WITHOUT switching the shared checkout's HEAD.
|
|
702
|
+
// Historically this used `git checkout -b`, which flipped the shared
|
|
703
|
+
// working tree onto the new branch and corrupted concurrent agents
|
|
704
|
+
// (kernel issue aa14966c). `git branch` creates the ref at the current
|
|
705
|
+
// HEAD without touching the working tree; isolated work happens in a
|
|
706
|
+
// dedicated worktree (`forge worktree create`), never the shared tree.
|
|
707
|
+
execFileSync('git', ['branch', branchName], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
689
708
|
|
|
690
709
|
return {
|
|
691
710
|
success: true,
|
|
@@ -1066,6 +1085,12 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
|
|
|
1066
1085
|
beadsIssueId: issue.issueId,
|
|
1067
1086
|
branchName: branch.branchName,
|
|
1068
1087
|
linked: Boolean(explicitIssueId),
|
|
1088
|
+
// A FRESH branch was created (HEAD did NOT move — aa14966c). Stage
|
|
1089
|
+
// commands resolve the CHECKED-OUT branch, so the user must enter an
|
|
1090
|
+
// isolated checkout on this branch before /dev, or stage state resolves
|
|
1091
|
+
// against the default branch (no linkage → ship dead-ends). When the
|
|
1092
|
+
// branch was reused, HEAD is already on it and /dev works directly.
|
|
1093
|
+
branchCreated: !branch.reused,
|
|
1069
1094
|
summary: explicitIssueId
|
|
1070
1095
|
? `Plan linked to existing issue ${issue.issueId} (${scope.type} scope)`
|
|
1071
1096
|
: `Plan created for ${featureName} (${scope.type} scope)`,
|
|
@@ -1104,7 +1129,20 @@ module.exports = {
|
|
|
1104
1129
|
const lines = [`${header}: ${result.summary || result.branchName || featureName}`];
|
|
1105
1130
|
if (result.issueId) lines.push(`${issueBackendLabel(result.issueBackend)}: ${result.issueId}`);
|
|
1106
1131
|
if (result.branchName) lines.push(`Branch: ${result.branchName}`);
|
|
1107
|
-
if (result.
|
|
1132
|
+
if (result.branchCreated) {
|
|
1133
|
+
// A fresh branch was created but HEAD was NOT switched (aa14966c). Stage
|
|
1134
|
+
// commands (/dev, /validate, /ship) resolve the CHECKED-OUT branch — from
|
|
1135
|
+
// the shared tree that is still the default branch, which has no
|
|
1136
|
+
// branch->issue linkage, so ship would dead-end. Direct the user into an
|
|
1137
|
+
// isolated checkout on the new branch first.
|
|
1138
|
+
const slug = String(result.branchName).replace(/^feat\//, '');
|
|
1139
|
+
lines.push('Next: work on this branch in an isolated checkout (HEAD stays put in the shared tree):');
|
|
1140
|
+
lines.push(` forge worktree create ${slug} # concurrent-safe; checks out the existing ${result.branchName}`);
|
|
1141
|
+
lines.push(` # or, working solo: git switch ${result.branchName}`);
|
|
1142
|
+
lines.push(`Then run ${result.nextCommand || '/dev'} from that checkout.`);
|
|
1143
|
+
} else if (result.nextCommand) {
|
|
1144
|
+
lines.push(`Next: ${result.nextCommand}`);
|
|
1145
|
+
}
|
|
1108
1146
|
|
|
1109
1147
|
return {
|
|
1110
1148
|
...result,
|
|
@@ -1122,4 +1160,6 @@ module.exports = {
|
|
|
1122
1160
|
applyYAGNIFilter,
|
|
1123
1161
|
executePlan,
|
|
1124
1162
|
issueBackendLabel,
|
|
1163
|
+
registerBranchIssueLinkage,
|
|
1164
|
+
currentBranchIssueFromDriver,
|
|
1125
1165
|
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ship = require('./ship');
|
|
4
|
+
const preflight = require('./preflight');
|
|
5
|
+
const shepherd = require('./shepherd');
|
|
6
|
+
const merge = require('./merge');
|
|
7
|
+
const { stripGlobalFlags } = require('../global-flags');
|
|
8
|
+
|
|
9
|
+
// One memorable surface over the EXISTING pull-request commands (kernel issue
|
|
10
|
+
// 6ab3f30c): every subcommand delegates to the standalone ship/preflight/shepherd/
|
|
11
|
+
// merge handlers — the same code, not a reimplementation. The standalone
|
|
12
|
+
// `forge ship`/`preflight`/`shepherd`/`merge` commands remain registered as
|
|
13
|
+
// back-compat aliases (see lib/commands/_aliases.js), so nothing that already
|
|
14
|
+
// calls them breaks. `pr ship` is the canonical PR-creation form; bare `ship`
|
|
15
|
+
// stays a visible shortcut.
|
|
16
|
+
//
|
|
17
|
+
// Delegates are referenced by MODULE (not a pre-bound `.handler`) so the routed
|
|
18
|
+
// handler is resolved at dispatch time — dispatch always reaches whatever the
|
|
19
|
+
// command module currently exports, keeping the standalone command the single
|
|
20
|
+
// source of truth for its own behaviour.
|
|
21
|
+
const SUBCOMMANDS = {
|
|
22
|
+
ship: {
|
|
23
|
+
module: ship,
|
|
24
|
+
summary: 'Create a pull request from validated feature work (= forge ship)',
|
|
25
|
+
},
|
|
26
|
+
preflight: {
|
|
27
|
+
module: preflight,
|
|
28
|
+
summary: 'Fast deterministic-gate parity with CI (= forge preflight; supports --all)',
|
|
29
|
+
},
|
|
30
|
+
shepherd: {
|
|
31
|
+
module: shepherd,
|
|
32
|
+
summary: 'Run one bounded monitor pass over a PR (= forge shepherd; --bundle/--pull/--json, events, watch)',
|
|
33
|
+
},
|
|
34
|
+
merge: {
|
|
35
|
+
module: merge,
|
|
36
|
+
summary: 'Opt-in conditional auto-merge, OFF by default (= forge merge --auto <pr>)',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const usage = 'Usage: forge pr <ship|preflight|shepherd|merge> [args]';
|
|
41
|
+
|
|
42
|
+
function renderHelp() {
|
|
43
|
+
const width = Math.max(...Object.keys(SUBCOMMANDS).map(name => name.length));
|
|
44
|
+
const lines = [
|
|
45
|
+
usage,
|
|
46
|
+
'',
|
|
47
|
+
'Subcommands:',
|
|
48
|
+
...Object.entries(SUBCOMMANDS).map(
|
|
49
|
+
([name, { summary }]) => ` ${name.padEnd(width)} ${summary}`
|
|
50
|
+
),
|
|
51
|
+
'',
|
|
52
|
+
'Back-compat: forge ship / forge preflight / forge shepherd / forge merge remain available as aliases.',
|
|
53
|
+
];
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handler(args, flags, projectRoot, opts) {
|
|
58
|
+
// The subcommand is the first positional token; global flags (e.g. `-p <dir>`) are stripped
|
|
59
|
+
// first so they never masquerade as the subcommand.
|
|
60
|
+
const positional = stripGlobalFlags(args).find(arg => !arg.startsWith('-'));
|
|
61
|
+
|
|
62
|
+
if (!positional || positional === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
63
|
+
return { success: true, output: renderHelp() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sub = SUBCOMMANDS[positional];
|
|
67
|
+
if (!sub) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: `Unknown pr subcommand: ${positional}\n\n${renderHelp()}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Forward everything EXCEPT the consumed subcommand token to the delegate, preserving
|
|
75
|
+
// every remaining token (including flags like `--pull`/`--json`/`--bundle`, and the
|
|
76
|
+
// `events`/`watch` shepherd sub-shapes) so passthrough stays byte-identical.
|
|
77
|
+
const idx = args.indexOf(positional);
|
|
78
|
+
const childArgs = idx >= 0 ? [...args.slice(0, idx), ...args.slice(idx + 1)] : args;
|
|
79
|
+
return sub.module.handler(childArgs, flags, projectRoot, opts);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
name: 'pr',
|
|
84
|
+
description:
|
|
85
|
+
'Unified pull-request surface: forge pr ship|preflight|shepherd|merge (wraps ship/preflight/shepherd/merge)',
|
|
86
|
+
usage,
|
|
87
|
+
handler,
|
|
88
|
+
};
|
package/lib/commands/push.js
CHANGED
|
@@ -4,6 +4,8 @@ const { execFileSync, spawnSync } = require('node:child_process');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const forgeToken = require('../../scripts/check-forge-token');
|
|
7
|
+
const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
|
|
8
|
+
const { autoShepherdRailEnabled } = require('./ship');
|
|
7
9
|
|
|
8
10
|
const isWindows = process.platform === 'win32';
|
|
9
11
|
|
|
@@ -110,6 +112,57 @@ async function autoFileBackingIssueForPush(projectRoot, execFn, deps = {}) {
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the OPEN PR number for the current branch via `gh pr view`. Returns
|
|
117
|
+
* null when there is no PR, gh is unavailable, or anything errors (fail-open).
|
|
118
|
+
* NEVER throws.
|
|
119
|
+
*
|
|
120
|
+
* @param {function} execFn - execFileSync or mock
|
|
121
|
+
* @returns {number|null}
|
|
122
|
+
*/
|
|
123
|
+
function resolveOpenPrNumber(execFn) {
|
|
124
|
+
try {
|
|
125
|
+
const out = execFn('gh', ['pr', 'view', '--json', 'number', '-q', '.number'], {
|
|
126
|
+
encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
127
|
+
});
|
|
128
|
+
const n = Number.parseInt(String(out).trim(), 10);
|
|
129
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
130
|
+
} catch (_err) { /* intentional: no open PR / gh missing → arm nothing */ // NOSONAR S2486
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort, NON-BLOCKING arm of the constant PR watcher after a successful
|
|
137
|
+
* push, when an OPEN PR exists for the current branch. This closes the gap where
|
|
138
|
+
* PRs not born from `forge ship` (gh pr create, the GitHub UI, an earlier push)
|
|
139
|
+
* never got a watcher. Gated by the default-ON `rail.auto_shepherd`, idempotent
|
|
140
|
+
* via the watch loop's own PID/journal lock, and reusing the same
|
|
141
|
+
* `startPrWatcherDetached` as ship. MUST NEVER throw into or fail the push: a
|
|
142
|
+
* disabled rail, no PR, a gh error, or a spawn error all degrade to
|
|
143
|
+
* `{ armed: false }`.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} params
|
|
146
|
+
* @returns {{ armed: boolean, reason?: string, prNumber?: number }}
|
|
147
|
+
*/
|
|
148
|
+
function maybeArmWatcherAfterPush({
|
|
149
|
+
projectRoot,
|
|
150
|
+
execFn,
|
|
151
|
+
startWatcher = startPrWatcherDetached,
|
|
152
|
+
railEnabled = autoShepherdRailEnabled,
|
|
153
|
+
prLookup = resolveOpenPrNumber,
|
|
154
|
+
}) {
|
|
155
|
+
try {
|
|
156
|
+
if (!railEnabled(projectRoot)) return { armed: false, reason: 'rail.auto_shepherd disabled' };
|
|
157
|
+
const prNumber = prLookup(execFn);
|
|
158
|
+
if (!prNumber) return { armed: false, reason: 'no-open-pr' };
|
|
159
|
+
const res = startWatcher({ prNumber, cwd: projectRoot });
|
|
160
|
+
return { armed: !!(res && res.started), reason: res && res.reason, prNumber };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { armed: false, reason: err.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
113
166
|
/**
|
|
114
167
|
* Run branch protection check as a subprocess.
|
|
115
168
|
* @param {function} execFn - execFileSync or mock
|
|
@@ -247,6 +300,17 @@ module.exports = {
|
|
|
247
300
|
};
|
|
248
301
|
}
|
|
249
302
|
|
|
303
|
+
// Arm the constant PR watcher for this branch's open PR (best-effort,
|
|
304
|
+
// gated by rail.auto_shepherd, never fails the push). Covers PRs not born
|
|
305
|
+
// from `forge ship`.
|
|
306
|
+
maybeArmWatcherAfterPush({
|
|
307
|
+
projectRoot,
|
|
308
|
+
execFn,
|
|
309
|
+
startWatcher: deps?.startWatcher,
|
|
310
|
+
railEnabled: deps?.railEnabled,
|
|
311
|
+
prLookup: deps?.prLookup,
|
|
312
|
+
});
|
|
313
|
+
|
|
250
314
|
return {
|
|
251
315
|
success: true,
|
|
252
316
|
quickMode,
|
|
@@ -259,5 +323,7 @@ module.exports = {
|
|
|
259
323
|
// Exposed for unit tests; not part of the CLI surface.
|
|
260
324
|
_internal: {
|
|
261
325
|
autoFileBackingIssueForPush,
|
|
326
|
+
maybeArmWatcherAfterPush,
|
|
327
|
+
resolveOpenPrNumber,
|
|
262
328
|
},
|
|
263
329
|
};
|