forge-workflow 0.1.0-beta.2 → 0.1.0-beta.4
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 +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- 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 +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- 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 +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- 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/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
package/lib/commands/setup.js
CHANGED
|
@@ -188,7 +188,6 @@ const WORKFLOW_RUNTIME_ASSETS = Object.freeze([
|
|
|
188
188
|
'scripts/forge-team/index.sh',
|
|
189
189
|
'scripts/forge-team/lib/agent-prompt.sh',
|
|
190
190
|
'scripts/forge-team/lib/claim.sh',
|
|
191
|
-
'scripts/forge-team/lib/dashboard.sh',
|
|
192
191
|
'scripts/forge-team/lib/epic.sh',
|
|
193
192
|
'scripts/forge-team/lib/hooks.sh',
|
|
194
193
|
'scripts/forge-team/lib/identity.sh',
|
|
@@ -378,13 +377,13 @@ async function finalizeWorkflowConfig(options = {}) {
|
|
|
378
377
|
|
|
379
378
|
/**
|
|
380
379
|
* Default init runner for finalizeWorkflowConfig. When `skipSideEffects` is set
|
|
381
|
-
* (setup paths that already installed git hooks
|
|
382
|
-
*
|
|
383
|
-
*
|
|
380
|
+
* (setup paths that already installed git hooks), pass no-op deps so init only
|
|
381
|
+
* writes `.forge/config.yaml` instead of re-doing that work — avoids duplicate
|
|
382
|
+
* side effects and warning noise (CodeRabbit on PR #368).
|
|
384
383
|
*/
|
|
385
384
|
function defaultRunInit(root, skipSideEffects) {
|
|
386
385
|
const deps = skipSideEffects
|
|
387
|
-
? { installHooks: () => {}
|
|
386
|
+
? { installHooks: () => {} }
|
|
388
387
|
: {};
|
|
389
388
|
return initCommand.handler(['--yes'], {}, root, deps);
|
|
390
389
|
}
|
|
@@ -2734,6 +2733,102 @@ function installForgeHookScripts() {
|
|
|
2734
2733
|
// kept shipping the broken behaviour. They are imported at the top of this file and
|
|
2735
2734
|
// re-exported below for the existing test surface.
|
|
2736
2735
|
|
|
2736
|
+
// Honest, resolved-config description of what the installed hooks will actually
|
|
2737
|
+
// enforce (issue eda6d866). The hook scripts are installed unconditionally but are
|
|
2738
|
+
// INERT when their gate/rail is disabled in .forge/config.yaml, so setup must not
|
|
2739
|
+
// claim "enforcement active" when the resolved config says otherwise.
|
|
2740
|
+
// Resolve — from the SAME .forge/config.yaml the hooks read at run time — whether the TDD
|
|
2741
|
+
// gate is active and how many protected paths are guarded. The reporting/exit layer gates
|
|
2742
|
+
// its verdict on this so setup never claims (or fails over) a state the user deliberately
|
|
2743
|
+
// disabled (issue eda6d866). On resolver THROW (missing/corrupt config), fail TOWARD
|
|
2744
|
+
// enforcement (tddActive:true, resolved:false) so a broken config can never silently
|
|
2745
|
+
// downgrade the human-facing verdict.
|
|
2746
|
+
function resolveHookEnforcementState(root = projectRoot) {
|
|
2747
|
+
try {
|
|
2748
|
+
const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
2749
|
+
const graph = getResolvedRuntimeGraph({ projectRoot: root });
|
|
2750
|
+
const tddRail = (graph.rails || []).find(rail => rail.id === 'rail.tdd_intent');
|
|
2751
|
+
const tddActive = !tddRail || tddRail.enabled !== false;
|
|
2752
|
+
const protectedCount = Array.isArray(graph.protectedPaths) ? graph.protectedPaths.length : 0;
|
|
2753
|
+
return { tddActive, protectedCount, resolved: true };
|
|
2754
|
+
} catch {
|
|
2755
|
+
return { tddActive: true, protectedCount: 0, resolved: false };
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function describeHookEnforcement(root = projectRoot) {
|
|
2760
|
+
const state = resolveHookEnforcementState(root);
|
|
2761
|
+
if (!state.resolved) {
|
|
2762
|
+
return null; // never let an honest-status line block setup (resolver threw)
|
|
2763
|
+
}
|
|
2764
|
+
const tdd = state.tddActive ? 'active' : 'disabled in config (hook inert)';
|
|
2765
|
+
const pp = state.protectedCount > 0
|
|
2766
|
+
? `active (${state.protectedCount} path${state.protectedCount === 1 ? '' : 's'})`
|
|
2767
|
+
: 'disabled in config (hook inert)';
|
|
2768
|
+
return `TDD gate: ${tdd}; protected-path: ${pp}`;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
// Pure reporting/exit-layer verdict (issue eda6d866): fold the file-presence hook check
|
|
2772
|
+
// (verifyHooksActive) and the resolved config state (resolveHookEnforcementState) into the
|
|
2773
|
+
// message/level and whether setup should exit non-zero. Kept pure + exported so the four
|
|
2774
|
+
// honesty cases are unit-testable without spawning setup. The caller folds "are we in a git
|
|
2775
|
+
// repo" into `loud`, so a non-git dir (nothing to hook into) only ever warns.
|
|
2776
|
+
function buildHookVerdict(verdict, state, options = {}) {
|
|
2777
|
+
const loud = options.loud === true;
|
|
2778
|
+
const tddActive = !state || state.tddActive !== false;
|
|
2779
|
+
if (verdict.active) {
|
|
2780
|
+
if (tddActive) {
|
|
2781
|
+
return {
|
|
2782
|
+
message: ` ✓ Git hook enforcement active (${verdict.method}).`,
|
|
2783
|
+
level: 'log',
|
|
2784
|
+
exitFailure: false,
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
// Hooks installed but the TDD gate is OFF in config → the scripts are inert by the
|
|
2788
|
+
// user's own choice. Say so honestly instead of over-claiming "enforcement active".
|
|
2789
|
+
return {
|
|
2790
|
+
message: ` ✓ Git hooks installed (${verdict.method}) — TDD gate disabled in .forge/config.yaml, hooks are inert. Re-enable: forge gate enable rail.tdd_intent`,
|
|
2791
|
+
level: 'log',
|
|
2792
|
+
exitFailure: false,
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
if (!tddActive) {
|
|
2796
|
+
// Hooks not active AND the user disabled enforcement — this is the CHOSEN state (e.g.
|
|
2797
|
+
// `init --profile minimal`), so never banner or exit non-zero for it, even under loud.
|
|
2798
|
+
return {
|
|
2799
|
+
message: ' ℹ Git hooks not active — enforcement is disabled in .forge/config.yaml anyway.',
|
|
2800
|
+
level: 'info',
|
|
2801
|
+
exitFailure: false,
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
// TDD is ON but hooks are inert — the silent-inert bug B3. Loud setup in a git repo fails
|
|
2805
|
+
// LOUDLY + non-zero; otherwise (init/repair path, or non-git dir folded into !loud) warn.
|
|
2806
|
+
if (loud) {
|
|
2807
|
+
const addCmd = PKG_MANAGER === 'bun'
|
|
2808
|
+
? 'bun add -d'
|
|
2809
|
+
: PKG_MANAGER === 'npm'
|
|
2810
|
+
? 'npm install --save-dev'
|
|
2811
|
+
: `${PKG_MANAGER} add -D`;
|
|
2812
|
+
const message = [
|
|
2813
|
+
'',
|
|
2814
|
+
' ============================================================',
|
|
2815
|
+
' ⚠ TDD ENFORCEMENT IS NOT ACTIVE',
|
|
2816
|
+
` ${verdict.reason || 'no pre-commit hook is installed'}.`,
|
|
2817
|
+
' `forge ship` will block until hooks are active. To fix:',
|
|
2818
|
+
` ${addCmd} lefthook && npx lefthook install`,
|
|
2819
|
+
' (or re-run `forge setup` in the repo root).',
|
|
2820
|
+
' ============================================================',
|
|
2821
|
+
'',
|
|
2822
|
+
].join('\n');
|
|
2823
|
+
return { message, level: 'error', exitFailure: true };
|
|
2824
|
+
}
|
|
2825
|
+
return {
|
|
2826
|
+
message: ` ⚠ TDD enforcement is NOT active: ${verdict.reason || 'no pre-commit hook installed'}.`,
|
|
2827
|
+
level: 'warn',
|
|
2828
|
+
exitFailure: false,
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2737
2832
|
function installGitHooks(options = {}) { // NOSONAR — Extracted as-is from bin/forge.js; complexity reduction deferred
|
|
2738
2833
|
// loud=true (the `forge setup` handlers — quickSetup + executeSetup) surfaces an
|
|
2739
2834
|
// inert-hooks result as a HARD failure — a banner + non-zero exit — so setup never ends
|
|
@@ -2742,13 +2837,27 @@ function installGitHooks(options = {}) { // NOSONAR — Extracted as-is from bin
|
|
|
2742
2837
|
// init deliberately degrades to a warning rather than failing, and repair runs inside
|
|
2743
2838
|
// another command's flow.
|
|
2744
2839
|
const loud = options.loud === true;
|
|
2745
|
-
|
|
2840
|
+
// Honest header: when the TDD gate is disabled in config the hooks install but are inert,
|
|
2841
|
+
// so don't announce "(TDD enforcement)" for a state the user turned off (issue eda6d866).
|
|
2842
|
+
console.log(
|
|
2843
|
+
resolveHookEnforcementState(projectRoot).tddActive
|
|
2844
|
+
? 'Installing git hooks (TDD enforcement)...'
|
|
2845
|
+
: 'Installing git hooks (TDD gate disabled in config — hooks will be inert)...'
|
|
2846
|
+
);
|
|
2746
2847
|
|
|
2747
2848
|
// Install the Forge hook SCRIPTS first, unconditionally: they back BOTH the lefthook
|
|
2748
2849
|
// pre-commit gate AND the native harness hooks that forge setup renders regardless of
|
|
2749
2850
|
// whether the lefthook binary is present.
|
|
2750
2851
|
installForgeHookScripts();
|
|
2751
2852
|
|
|
2853
|
+
// Report what the hooks will ACTUALLY enforce given the resolved config — a
|
|
2854
|
+
// disabled gate/rail installs an inert hook, so say so instead of over-claiming.
|
|
2855
|
+
const enforcementStatus = describeHookEnforcement();
|
|
2856
|
+
if (enforcementStatus) {
|
|
2857
|
+
console.log(` Enforcement (from .forge/config.yaml): ${enforcementStatus}`);
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Skip lefthook.yml creation if binary is not available
|
|
2752
2861
|
const lefthookStatus = checkLefthookStatus(projectRoot);
|
|
2753
2862
|
let lefthookInstalled = false;
|
|
2754
2863
|
if (!lefthookStatus.binaryAvailable && lefthookStatus.message) {
|
|
@@ -2826,29 +2935,24 @@ function installGitHooks(options = {}) { // NOSONAR — Extracted as-is from bin
|
|
|
2826
2935
|
}
|
|
2827
2936
|
|
|
2828
2937
|
const verdict = verifyHooksActive(projectRoot);
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
console.
|
|
2842
|
-
console.error(' ⚠ TDD ENFORCEMENT IS NOT ACTIVE');
|
|
2843
|
-
console.error(` ${verdict.reason || 'no pre-commit hook is installed'}.`);
|
|
2844
|
-
console.error(' `forge ship` will block until hooks are active. To fix:');
|
|
2845
|
-
console.error(` ${addCmd} lefthook && npx lefthook install`);
|
|
2846
|
-
console.error(' (or re-run `forge setup` in the repo root).');
|
|
2847
|
-
console.error(' ============================================================');
|
|
2848
|
-
console.error('');
|
|
2849
|
-
process.exitCode = 1;
|
|
2938
|
+
const state = resolveHookEnforcementState(projectRoot);
|
|
2939
|
+
// A non-git dir has nothing to hook into, so it must only ever WARN (never banner/exit).
|
|
2940
|
+
// Fold that into `loud` before building the verdict so buildHookVerdict stays pure. When
|
|
2941
|
+
// the TDD gate is disabled in config, the verdict is info-level with no failure exit even
|
|
2942
|
+
// under loud setup — enforcement honestly follows the resolved config (issue eda6d866).
|
|
2943
|
+
const inGitRepo = Boolean(resolveGitHooksDir(projectRoot));
|
|
2944
|
+
const built = buildHookVerdict(verdict, state, { loud: loud && inGitRepo });
|
|
2945
|
+
if (built.level === 'error') {
|
|
2946
|
+
console.error(built.message);
|
|
2947
|
+
} else if (built.level === 'warn') {
|
|
2948
|
+
console.warn(built.message);
|
|
2949
|
+
} else if (built.level === 'info') {
|
|
2950
|
+
console.info(built.message);
|
|
2850
2951
|
} else {
|
|
2851
|
-
console.
|
|
2952
|
+
console.log(built.message);
|
|
2953
|
+
}
|
|
2954
|
+
if (built.exitFailure) {
|
|
2955
|
+
process.exitCode = 1;
|
|
2852
2956
|
}
|
|
2853
2957
|
}
|
|
2854
2958
|
|
|
@@ -3258,44 +3362,6 @@ function configureDefaultExternalServices(skipExternal) {
|
|
|
3258
3362
|
console.log('Configuration saved to .env.local');
|
|
3259
3363
|
}
|
|
3260
3364
|
|
|
3261
|
-
// Auto-import an existing Beads store into the Kernel during setup.
|
|
3262
|
-
// Idempotent and CLI-free: reuses the `forge migrate --from beads` spine, which
|
|
3263
|
-
// reads the committed Beads jsonl sidecars directly (no external issue-tracker
|
|
3264
|
-
// binary), so it works even when the legacy SQL backend is offline. Failures
|
|
3265
|
-
// degrade to a setup note rather than aborting setup. Returns the migrate
|
|
3266
|
-
// outcome for callers/tests.
|
|
3267
|
-
async function autoMigrateBeadsToKernel(opts = {}) {
|
|
3268
|
-
const migrateModule = require('./migrate');
|
|
3269
|
-
let outcome;
|
|
3270
|
-
try {
|
|
3271
|
-
outcome = await migrateModule.autoMigrateBeadsIfPresent(projectRoot, opts);
|
|
3272
|
-
} catch (err) {
|
|
3273
|
-
addSetupNote(`Beads → Kernel auto-migration failed: ${err.message}`);
|
|
3274
|
-
return { migrated: false };
|
|
3275
|
-
}
|
|
3276
|
-
|
|
3277
|
-
if (!outcome.migrated) {
|
|
3278
|
-
if (outcome.result && outcome.result.success === false) {
|
|
3279
|
-
addSetupNote(`Beads → Kernel auto-migration skipped: ${outcome.result.error}`);
|
|
3280
|
-
}
|
|
3281
|
-
return outcome;
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3284
|
-
const { imported, gaps } = outcome.result;
|
|
3285
|
-
const inserted = imported.issues.inserted;
|
|
3286
|
-
const skipped = imported.issues.skipped;
|
|
3287
|
-
if (inserted > 0) {
|
|
3288
|
-
let line = ` ✓ Migrated ${inserted} issue(s) from Beads to the Kernel`;
|
|
3289
|
-
if (gaps && gaps.count > 0) {
|
|
3290
|
-
line += ` (${gaps.count} field gap(s): ${gaps.items.map(g => g.field).join(', ')})`;
|
|
3291
|
-
}
|
|
3292
|
-
console.log(line);
|
|
3293
|
-
} else {
|
|
3294
|
-
console.log(` ✓ Beads store already present in the Kernel (${skipped} issue(s))`);
|
|
3295
|
-
}
|
|
3296
|
-
return outcome;
|
|
3297
|
-
}
|
|
3298
|
-
|
|
3299
3365
|
// Install git hooks for a target project root without a full setup run.
|
|
3300
3366
|
// Reuses the same lefthook install path setup performs so `forge init` can
|
|
3301
3367
|
// reach a hook-active state (closing the init → HOOKS_NOT_ACTIVE catch-22).
|
|
@@ -3363,9 +3429,6 @@ async function quickSetup(selectedAgents, skipExternal) {
|
|
|
3363
3429
|
// Auto-setup project tools (Kernel issue store, Skills)
|
|
3364
3430
|
await autoSetupToolsInQuickMode();
|
|
3365
3431
|
|
|
3366
|
-
// Auto-import an existing Beads store into the Kernel (idempotent, CLI-free)
|
|
3367
|
-
await autoMigrateBeadsToKernel();
|
|
3368
|
-
|
|
3369
3432
|
// Setup Claude first if selected, then setup remaining agents
|
|
3370
3433
|
if (selectedAgents.includes('claude')) {
|
|
3371
3434
|
await setupAgent('claude');
|
|
@@ -3905,9 +3968,6 @@ async function executeSetup(config) {
|
|
|
3905
3968
|
console.log('');
|
|
3906
3969
|
installGitHooks({ loud: true });
|
|
3907
3970
|
|
|
3908
|
-
// Auto-import an existing Beads store into the Kernel (idempotent, CLI-free)
|
|
3909
|
-
await autoMigrateBeadsToKernel();
|
|
3910
|
-
|
|
3911
3971
|
// External services (unless skipped)
|
|
3912
3972
|
await handleExternalServices(skipExternal, agents);
|
|
3913
3973
|
|
|
@@ -4319,6 +4379,9 @@ module.exports = {
|
|
|
4319
4379
|
},
|
|
4320
4380
|
|
|
4321
4381
|
// Expose internals for testing and cross-command use
|
|
4382
|
+
describeHookEnforcement,
|
|
4383
|
+
resolveHookEnforcementState,
|
|
4384
|
+
buildHookVerdict,
|
|
4322
4385
|
checkPrerequisites,
|
|
4323
4386
|
setupCoreDocs,
|
|
4324
4387
|
displaySetupSummary,
|
|
@@ -4340,7 +4403,6 @@ module.exports = {
|
|
|
4340
4403
|
installViaBunx,
|
|
4341
4404
|
autoInstallLefthook,
|
|
4342
4405
|
autoSetupToolsInQuickMode,
|
|
4343
|
-
autoMigrateBeadsToKernel,
|
|
4344
4406
|
ensureGitHooksInstalled,
|
|
4345
4407
|
forgeShouldWriteLefthookConfig,
|
|
4346
4408
|
FORGE_USER_LEFTHOOK_YML,
|
package/lib/commands/shepherd.js
CHANGED
|
@@ -36,12 +36,22 @@ 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');
|
|
40
|
+
const reconcileExecutor = require('../pr-monitor/reconcile-executor');
|
|
39
41
|
const monitorJournal = require('../pr-monitor/journal');
|
|
40
42
|
const { EVENT_TYPES: T } = require('../pr-monitor/events');
|
|
43
|
+
const { autoShepherdRailEnabled } = require('./ship');
|
|
41
44
|
|
|
42
45
|
const DEFAULT_RERUN_BUDGET = 3;
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
// windowsHide: true on EVERY spawn here is load-bearing, not cosmetic. The
|
|
48
|
+
// shepherd watcher runs detached in the background and re-polls every ~60s; on
|
|
49
|
+
// Windows a child process spawned WITHOUT windowsHide flashes a visible console
|
|
50
|
+
// window each time (Node's default is windowsHide:false). Background work's
|
|
51
|
+
// preferred home is the harness's managed shell (hidden + reaped); a
|
|
52
|
+
// Forge-spawned detached watcher is the no-session fallback and must be
|
|
53
|
+
// COMPLETELY silent — no console window, ever. See kernel issue 931e7924.
|
|
54
|
+
const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
|
45
55
|
|
|
46
56
|
/**
|
|
47
57
|
* Resolve owner/repo and base branch for the shepherd pass.
|
|
@@ -270,11 +280,74 @@ function wireSignals() {
|
|
|
270
280
|
* @param {object} [deps]
|
|
271
281
|
* @returns {Promise<object>}
|
|
272
282
|
*/
|
|
283
|
+
/**
|
|
284
|
+
* List every OPEN PR number via `gh pr list`. Fail-open: any error yields an
|
|
285
|
+
* empty list (adopt then arms nothing) rather than throwing.
|
|
286
|
+
*
|
|
287
|
+
* @param {Function} [exec] - gh runner (test injection).
|
|
288
|
+
* @returns {number[]}
|
|
289
|
+
*/
|
|
290
|
+
function defaultListOpenPrs(exec = execFileSync) {
|
|
291
|
+
try {
|
|
292
|
+
const out = exec('gh', ['pr', 'list', '--state', 'open', '--json', 'number', '-q', '.[].number'], {
|
|
293
|
+
encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
294
|
+
});
|
|
295
|
+
return String(out)
|
|
296
|
+
.split(/\r?\n/)
|
|
297
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
298
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
299
|
+
} catch {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `forge shepherd watch --adopt` — arm a detached watcher for EVERY currently-open
|
|
306
|
+
* PR (covers PRs created via gh/UI, or before this rail existed). Idempotent: the
|
|
307
|
+
* watch loop's PID/journal lock means an already-watched PR is not double-started.
|
|
308
|
+
* Fail-open per PR and overall — never throws. Honors the default-ON
|
|
309
|
+
* `rail.auto_shepherd` rail: when a maintainer has disabled it, adoption is a
|
|
310
|
+
* no-op — no PR listing, no watcher spawn — matching `forge push`/`forge ship`.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} projectRoot
|
|
313
|
+
* @param {object} [deps]
|
|
314
|
+
* @returns {{ success: true, adopted: number[], total: number, reason?: string }}
|
|
315
|
+
*/
|
|
316
|
+
function handleAdopt(projectRoot, deps = {}) {
|
|
317
|
+
const railEnabled = deps.railEnabled || autoShepherdRailEnabled;
|
|
318
|
+
// Gate BEFORE listing PRs / spawning watchers: a disabled rail must not spawn
|
|
319
|
+
// detached watchers. No-op result mirrors the fail-open (empty) adoption shape.
|
|
320
|
+
if (!railEnabled(projectRoot)) {
|
|
321
|
+
return { success: true, adopted: [], total: 0, reason: 'rail.auto_shepherd disabled' };
|
|
322
|
+
}
|
|
323
|
+
const listOpenPrs = deps.listOpenPrs || defaultListOpenPrs;
|
|
324
|
+
const startWatcher = deps.startWatcher || startPrWatcherDetached;
|
|
325
|
+
let prs;
|
|
326
|
+
try {
|
|
327
|
+
prs = listOpenPrs();
|
|
328
|
+
} catch {
|
|
329
|
+
prs = [];
|
|
330
|
+
}
|
|
331
|
+
if (!Array.isArray(prs)) prs = [];
|
|
332
|
+
const adopted = [];
|
|
333
|
+
for (const pr of prs) {
|
|
334
|
+
try {
|
|
335
|
+
const res = startWatcher({ prNumber: pr, cwd: projectRoot });
|
|
336
|
+
if (res?.started) adopted.push(pr);
|
|
337
|
+
} catch { /* fail-open per PR: one bad arm never blocks the rest */ }
|
|
338
|
+
}
|
|
339
|
+
return { success: true, adopted, total: prs.length };
|
|
340
|
+
}
|
|
341
|
+
|
|
273
342
|
async function handleWatch(args, projectRoot, deps = {}) {
|
|
274
343
|
const rawArgs = args || [];
|
|
344
|
+
// `--adopt` (no PR arg): arm a detached watcher for every open PR.
|
|
345
|
+
if (rawArgs.includes('--adopt')) {
|
|
346
|
+
return handleAdopt(projectRoot, deps);
|
|
347
|
+
}
|
|
275
348
|
const pr = rawArgs.find((a) => !String(a).startsWith('--') && a !== 'watch');
|
|
276
349
|
if (!pr) {
|
|
277
|
-
return { success: false, error: 'Usage: forge shepherd watch <pr>' };
|
|
350
|
+
return { success: false, error: 'Usage: forge shepherd watch <pr> | forge shepherd watch --adopt' };
|
|
278
351
|
}
|
|
279
352
|
|
|
280
353
|
const built = await buildMonitorContext(pr, projectRoot, deps);
|
|
@@ -315,6 +388,28 @@ async function handleWatch(args, projectRoot, deps = {}) {
|
|
|
315
388
|
};
|
|
316
389
|
}
|
|
317
390
|
|
|
391
|
+
/**
|
|
392
|
+
* `forge shepherd daemon` — the SINGLETON reconcile daemon (W-S4b). It acquires
|
|
393
|
+
* the machine-wide shepherd lease for this repo (exiting immediately if a live
|
|
394
|
+
* daemon already owns it), heartbeats, and converges the PR world every ~60s:
|
|
395
|
+
* self-registering hand-opened PRs, restarting killed watchers, reaping verified
|
|
396
|
+
* orphans, retiring merged/closed PRs. It self-retires (releases the lease, kills
|
|
397
|
+
* its verified children, exits) once no PRs remain open. Launched detached by the
|
|
398
|
+
* per-command `fireAndForget` trigger — not meant to be run by hand.
|
|
399
|
+
*
|
|
400
|
+
* @param {string} projectRoot
|
|
401
|
+
* @param {object} [deps] - injected for tests (acquire/heartbeat/gather/etc.).
|
|
402
|
+
* @returns {Promise<object>} result envelope.
|
|
403
|
+
*/
|
|
404
|
+
async function handleDaemon(projectRoot, deps = {}) {
|
|
405
|
+
const res = await reconcileExecutor.runDaemon(projectRoot, { ...deps });
|
|
406
|
+
if (!res.ok) {
|
|
407
|
+
// A live foreign daemon owns the lease — this invocation is a clean no-op.
|
|
408
|
+
return { success: true, started: false, reason: res.reason || 'foreign-lease' };
|
|
409
|
+
}
|
|
410
|
+
return { success: true, started: true };
|
|
411
|
+
}
|
|
412
|
+
|
|
318
413
|
/**
|
|
319
414
|
* Command handler.
|
|
320
415
|
*
|
|
@@ -336,6 +431,9 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
336
431
|
if (positional[0] === 'watch') {
|
|
337
432
|
return handleWatch(args, projectRoot, deps);
|
|
338
433
|
}
|
|
434
|
+
if (positional[0] === 'daemon') {
|
|
435
|
+
return handleDaemon(projectRoot, deps);
|
|
436
|
+
}
|
|
339
437
|
|
|
340
438
|
const pr = positional[0];
|
|
341
439
|
|
|
@@ -343,7 +441,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
343
441
|
return { success: false, error: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json]' };
|
|
344
442
|
}
|
|
345
443
|
|
|
346
|
-
const gh = deps.gh || ((cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000 }));
|
|
444
|
+
const gh = deps.gh || ((cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000, windowsHide: true }));
|
|
347
445
|
const git = deps.git || gh;
|
|
348
446
|
const buildContext = deps.buildContext || defaultBuildContext;
|
|
349
447
|
const runPass = deps.runPass || runShepherdPass;
|
|
@@ -424,7 +522,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
424
522
|
module.exports = {
|
|
425
523
|
name: 'shepherd',
|
|
426
524
|
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>',
|
|
525
|
+
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
526
|
handler,
|
|
429
527
|
handleEvents,
|
|
430
528
|
handleWatch,
|
package/lib/commands/ship.js
CHANGED
|
@@ -13,6 +13,32 @@ const fs = require('node:fs');
|
|
|
13
13
|
const path = require('node:path');
|
|
14
14
|
|
|
15
15
|
const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
|
|
16
|
+
const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
17
|
+
|
|
18
|
+
const AUTO_SHEPHERD_RAIL = 'rail.auto_shepherd';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether the default-ON `rail.auto_shepherd` rail permits auto-starting the PR
|
|
22
|
+
* watcher. Reads the SAME resolved runtime graph the `forge gate enable|disable`
|
|
23
|
+
* surface writes (`workflow.gates.rail.auto_shepherd.enabled`), so the toggle is
|
|
24
|
+
* config-honest. FAIL-OPEN: only an explicit `enabled === false` disables it — a
|
|
25
|
+
* missing rail or any resolution error falls back to enabled (the default), and
|
|
26
|
+
* this NEVER throws (ship must not fail on a config read).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} [projectRoot]
|
|
29
|
+
* @param {Function} [resolveGraph] - injectable graph resolver (tests).
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function autoShepherdRailEnabled(projectRoot = process.cwd(), resolveGraph = getResolvedRuntimeGraph) {
|
|
33
|
+
try {
|
|
34
|
+
const graph = resolveGraph({ projectRoot });
|
|
35
|
+
const rail = [...(graph.rails || []), ...(graph.gates || [])]
|
|
36
|
+
.find(entry => entry.id === AUTO_SHEPHERD_RAIL);
|
|
37
|
+
return !(rail && rail.enabled === false);
|
|
38
|
+
} catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
16
42
|
|
|
17
43
|
function getExecOptions() {
|
|
18
44
|
return { encoding: 'utf8', cwd: process.cwd(), timeout: 120000 };
|
|
@@ -490,12 +516,21 @@ async function createPR(options) { // NOSONAR S3776
|
|
|
490
516
|
* ship: `startWatcher` (startPrWatcherDetached) already never throws, and this
|
|
491
517
|
* guard keeps even a surprise error from surfacing to the ship caller.
|
|
492
518
|
*
|
|
493
|
-
*
|
|
519
|
+
* Gated by the default-ON `rail.auto_shepherd` rail: when a maintainer has
|
|
520
|
+
* disabled it (`forge gate disable rail.auto_shepherd`), the watcher is skipped
|
|
521
|
+
* so the auto-start is honestly toggleable. The rail check is fail-open and
|
|
522
|
+
* wrapped in the same try/catch, so neither a disabled rail nor a config-read
|
|
523
|
+
* error ever fails ship.
|
|
524
|
+
*
|
|
525
|
+
* @param {{ dryRun: boolean, prNumber?: string|number, startWatcher: Function, railEnabled?: Function }} params
|
|
494
526
|
* @returns {{ started: boolean, reason?: string }}
|
|
495
527
|
*/
|
|
496
|
-
function maybeStartPrWatcher({ dryRun, prNumber, startWatcher }) {
|
|
528
|
+
function maybeStartPrWatcher({ dryRun, prNumber, startWatcher, railEnabled = autoShepherdRailEnabled }) {
|
|
497
529
|
if (dryRun || !prNumber) return { started: false, reason: 'skipped' };
|
|
498
530
|
try {
|
|
531
|
+
if (!railEnabled(process.cwd())) {
|
|
532
|
+
return { started: false, reason: 'rail.auto_shepherd disabled' };
|
|
533
|
+
}
|
|
499
534
|
return startWatcher({ prNumber, cwd: process.cwd() });
|
|
500
535
|
} catch (err) {
|
|
501
536
|
return { started: false, reason: err.message };
|
|
@@ -503,7 +538,7 @@ function maybeStartPrWatcher({ dryRun, prNumber, startWatcher }) {
|
|
|
503
538
|
}
|
|
504
539
|
|
|
505
540
|
async function executeShip(options) {
|
|
506
|
-
const { featureSlug, title, dryRun = false, startWatcher = startPrWatcherDetached } = options || {};
|
|
541
|
+
const { featureSlug, title, dryRun = false, startWatcher = startPrWatcherDetached, railEnabled = autoShepherdRailEnabled } = options || {};
|
|
507
542
|
|
|
508
543
|
// Validate feature slug
|
|
509
544
|
if (!featureSlug || typeof featureSlug !== 'string' || featureSlug.trim() === '') {
|
|
@@ -555,7 +590,7 @@ async function executeShip(options) {
|
|
|
555
590
|
});
|
|
556
591
|
const result = await createPR({ title, body: prBody, dryRun });
|
|
557
592
|
if (!result.success) return result;
|
|
558
|
-
maybeStartPrWatcher({ dryRun, prNumber: result.prNumber, startWatcher });
|
|
593
|
+
maybeStartPrWatcher({ dryRun, prNumber: result.prNumber, startWatcher, railEnabled });
|
|
559
594
|
return {
|
|
560
595
|
success: true,
|
|
561
596
|
prUrl: result.prUrl,
|
|
@@ -589,6 +624,7 @@ module.exports = {
|
|
|
589
624
|
};
|
|
590
625
|
},
|
|
591
626
|
maybeStartPrWatcher,
|
|
627
|
+
autoShepherdRailEnabled,
|
|
592
628
|
extractKeyDecisions,
|
|
593
629
|
extractTestScenarios,
|
|
594
630
|
getTestCoverage,
|