forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -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/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- 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 +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- 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 +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- 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 +92 -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/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/commands/plan.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plan Command -
|
|
2
|
+
* Plan Command - Kernel Issue Integration
|
|
3
3
|
* Creates implementation plan after research is complete
|
|
4
4
|
*
|
|
5
5
|
* Security: Uses execFileSync instead of exec/execSync to prevent command injection
|
|
@@ -26,15 +26,14 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Human-readable label for the issue backend that created an issue.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* The kernel is the only backend; anything unresolved prints a neutral label
|
|
30
|
+
* rather than naming a store the issue did not come from.
|
|
31
31
|
*
|
|
32
|
-
* @param {string} [backend] - Resolved issue backend ('kernel'
|
|
33
|
-
* @returns {string} Display label ('Kernel'
|
|
32
|
+
* @param {string} [backend] - Resolved issue backend ('kernel').
|
|
33
|
+
* @returns {string} Display label ('Kernel' or a neutral 'Issue').
|
|
34
34
|
* @private
|
|
35
35
|
*/
|
|
36
36
|
function issueBackendLabel(backend) {
|
|
37
|
-
if (backend === 'beads') return 'Beads';
|
|
38
37
|
if (backend === 'kernel') return 'Kernel';
|
|
39
38
|
return 'Issue';
|
|
40
39
|
}
|
|
@@ -76,7 +75,7 @@ function validateFeatureSlug(slug) {
|
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/**
|
|
79
|
-
* Build the issue description
|
|
78
|
+
* Build the issue description used when creating the kernel tracking issue.
|
|
80
79
|
* Strategic scope appends a design-doc pointer derived from a sanitized slug.
|
|
81
80
|
*
|
|
82
81
|
* @param {string} featureName
|
|
@@ -247,98 +246,12 @@ function detectScope(researchContent) {
|
|
|
247
246
|
};
|
|
248
247
|
}
|
|
249
248
|
|
|
250
|
-
/**
|
|
251
|
-
* Create Beads issue for the feature
|
|
252
|
-
* Executes `bd create` command with appropriate description based on scope
|
|
253
|
-
*
|
|
254
|
-
* Security: Uses execFileSync (not exec) to prevent command injection
|
|
255
|
-
*
|
|
256
|
-
* @param {string} featureName - Feature name (human-readable)
|
|
257
|
-
* @param {string} researchPath - Research document path (e.g., "docs/research/feature.md")
|
|
258
|
-
* @param {'tactical'|'strategic'} scope - Scope type
|
|
259
|
-
* @returns {{success: boolean, issueId?: string, description?: string, error?: string}} Beads creation result
|
|
260
|
-
* @example
|
|
261
|
-
* const result = createBeadsIssue('Payment Integration', 'docs/research/payment.md', 'strategic');
|
|
262
|
-
* if (result.success) {
|
|
263
|
-
* console.log('Created issue:', result.issueId);
|
|
264
|
-
* }
|
|
265
|
-
*/
|
|
266
|
-
function createBeadsIssue(featureName, researchPath, scope) {
|
|
267
|
-
if (!featureName || !researchPath) {
|
|
268
|
-
return {
|
|
269
|
-
success: false,
|
|
270
|
-
error: 'Feature name and research path are required',
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (scope !== 'tactical' && scope !== 'strategic') {
|
|
275
|
-
return {
|
|
276
|
-
success: false,
|
|
277
|
-
error: `Invalid scope '${scope}'. Must be 'tactical' or 'strategic'`,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const built = buildFeatureIssueDescription(featureName, researchPath, scope);
|
|
283
|
-
if (built.error) {
|
|
284
|
-
return { success: false, error: built.error };
|
|
285
|
-
}
|
|
286
|
-
const description = built.description;
|
|
287
|
-
|
|
288
|
-
// Execute bd create command using execFileSync for safety (OWASP A03)
|
|
289
|
-
const result = execFileSync( // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
290
|
-
'bd', // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
291
|
-
['create', `--title=${featureName}`, `--description=${description}`, '--type=feature', '--priority=2'],
|
|
292
|
-
getExecOptions()
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
// Extract issue ID from output (format: "Created issue: forge-xxx" or "forge-xxx.N" for dotted sub-IDs).
|
|
296
|
-
// Character class is [a-z0-9] (not [a-zA-Z0-9]) because the /i flag makes A-Z redundant.
|
|
297
|
-
const createPattern = /Created issue:\s*(forge-[a-z0-9]+(?:\.[a-z0-9]+)*)/i;
|
|
298
|
-
const fallbackPattern = /(forge-[a-z0-9]+(?:\.[a-z0-9]+)*)/i;
|
|
299
|
-
const match = createPattern.exec(result) || fallbackPattern.exec(result);
|
|
300
|
-
|
|
301
|
-
if (!match) {
|
|
302
|
-
return {
|
|
303
|
-
success: false,
|
|
304
|
-
error: 'Failed to extract issue ID from bd create output\n\nEnsure beads is installed: bunx beads init',
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
success: true,
|
|
310
|
-
issueId: match[1],
|
|
311
|
-
description,
|
|
312
|
-
};
|
|
313
|
-
} catch (error) {
|
|
314
|
-
// Check for timeout
|
|
315
|
-
if (error.killed && error.signal === 'SIGTERM') {
|
|
316
|
-
return {
|
|
317
|
-
success: false,
|
|
318
|
-
error: 'Beads command timed out after 2 minutes.',
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Provide actionable error message
|
|
323
|
-
const bdNotFound = error.message.includes('ENOENT') || error.message.includes('not found');
|
|
324
|
-
const errorMsg = bdNotFound
|
|
325
|
-
? 'beads (bd) command not found. Install with: bunx beads init'
|
|
326
|
-
: `Failed to create Beads issue: ${error.message}`;
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
success: false,
|
|
330
|
-
error: errorMsg,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
249
|
/**
|
|
336
250
|
* Create an issue via the Forge Kernel backend (bd-free).
|
|
337
251
|
*
|
|
338
252
|
* Mirrors `forge issue create` on the kernel: routes through runIssueOperation with
|
|
339
253
|
* the kernel broker instead of shelling out to `bd create`. Used by `forge plan` when
|
|
340
|
-
* the
|
|
341
|
-
* install. The beads path (createBeadsIssue) is preserved for backend=beads.
|
|
254
|
+
* the kernel is the only issue backend, so planning needs no bd binary installed.
|
|
342
255
|
*
|
|
343
256
|
* @param {string} featureName
|
|
344
257
|
* @param {string} researchPath
|
|
@@ -413,7 +326,7 @@ async function linkExistingIssue(issueId, options = {}) {
|
|
|
413
326
|
projectRoot,
|
|
414
327
|
{
|
|
415
328
|
issueBackend,
|
|
416
|
-
useKernelBroker:
|
|
329
|
+
useKernelBroker: true,
|
|
417
330
|
kernelBroker: options.kernelBroker,
|
|
418
331
|
},
|
|
419
332
|
);
|
|
@@ -584,9 +497,9 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
|
|
|
584
497
|
}
|
|
585
498
|
|
|
586
499
|
/**
|
|
587
|
-
* Resolve the tracking issue for a plan: LINK an explicit issue, else CREATE
|
|
588
|
-
* the
|
|
589
|
-
* nested ternary in executePlan.
|
|
500
|
+
* Resolve the tracking issue for a plan: LINK an explicit issue, else CREATE in
|
|
501
|
+
* the kernel (the only issue backend — planning needs no bd binary). Extracted to
|
|
502
|
+
* avoid a nested ternary in executePlan.
|
|
590
503
|
*
|
|
591
504
|
* @returns {Promise<{success: boolean, issueId?: string, error?: string}>}
|
|
592
505
|
* @private
|
|
@@ -595,10 +508,7 @@ async function resolveTrackingIssue({ explicitIssueId, issueBackend, featureName
|
|
|
595
508
|
if (explicitIssueId) {
|
|
596
509
|
return linkExistingIssue(explicitIssueId, { ...options, issueBackend });
|
|
597
510
|
}
|
|
598
|
-
|
|
599
|
-
return createKernelIssue(featureName, researchPath, scope, options);
|
|
600
|
-
}
|
|
601
|
-
return createBeadsIssue(featureName, researchPath, scope);
|
|
511
|
+
return createKernelIssue(featureName, researchPath, scope, options);
|
|
602
512
|
}
|
|
603
513
|
|
|
604
514
|
async function createKernelIssue(featureName, researchPath, scope, options = {}) {
|
|
@@ -952,14 +862,14 @@ function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
|
|
|
952
862
|
* Tactical workflow (quick fixes, <1 day):
|
|
953
863
|
* 1. Read research document
|
|
954
864
|
* 2. Detect scope (tactical)
|
|
955
|
-
* 3. Create
|
|
865
|
+
* 3. Create kernel issue
|
|
956
866
|
* 4. Create feature branch
|
|
957
867
|
* → Next: /dev command
|
|
958
868
|
*
|
|
959
869
|
* Strategic workflow (architecture changes, >1 day):
|
|
960
870
|
* 1. Read research document
|
|
961
871
|
* 2. Detect scope (strategic)
|
|
962
|
-
* 3. Create
|
|
872
|
+
* 3. Create kernel issue with design doc link
|
|
963
873
|
* 4. Create feature branch
|
|
964
874
|
* → Next: Create design doc, then /dev command
|
|
965
875
|
*
|
|
@@ -967,7 +877,7 @@ function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
|
|
|
967
877
|
* @returns {Promise<{
|
|
968
878
|
* success: boolean,
|
|
969
879
|
* scope?: 'tactical'|'strategic',
|
|
970
|
-
* issueBackend?: 'kernel'
|
|
880
|
+
* issueBackend?: 'kernel',
|
|
971
881
|
* issueId?: string,
|
|
972
882
|
* beadsIssueId?: string,
|
|
973
883
|
* branchName?: string,
|
|
@@ -975,10 +885,10 @@ function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
|
|
|
975
885
|
* summary?: string,
|
|
976
886
|
* nextCommand?: string,
|
|
977
887
|
* error?: string
|
|
978
|
-
* }>} Execution result. `issueId` is the created issue id
|
|
979
|
-
*
|
|
980
|
-
* alias of `issueId` kept for
|
|
981
|
-
*
|
|
888
|
+
* }>} Execution result. `issueId` is the created kernel issue id, and
|
|
889
|
+
* `issueBackend` names the backend ('kernel'). `beadsIssueId` is a deprecated
|
|
890
|
+
* alias of `issueId` kept for output-shape compatibility — it never implied a
|
|
891
|
+
* Beads store and now always holds the kernel issue id.
|
|
982
892
|
* @example
|
|
983
893
|
* const result = await executePlan('Payment Integration');
|
|
984
894
|
* if (result.success) {
|
|
@@ -995,7 +905,7 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
|
|
|
995
905
|
}
|
|
996
906
|
|
|
997
907
|
// Resolve the active issue backend (explicit opts > env > .forge/config.yaml >
|
|
998
|
-
// default 'kernel').
|
|
908
|
+
// default 'kernel'). The kernel is bd-free, so planning needs no bd binary.
|
|
999
909
|
const issueBackend = resolveIssueBackend({
|
|
1000
910
|
deps: options,
|
|
1001
911
|
env: options.env || process.env,
|
|
@@ -1031,8 +941,7 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
|
|
|
1031
941
|
|
|
1032
942
|
// Step 3: Resolve the tracking issue. `--issue <id>` LINKS an existing issue
|
|
1033
943
|
// (claim-first flow) instead of creating a duplicate (B4). Otherwise create
|
|
1034
|
-
//
|
|
1035
|
-
// (no bd); beads preserves the bd path.
|
|
944
|
+
// through the kernel broker (no bd binary involved).
|
|
1036
945
|
const explicitIssueId = options.issue || options.issueId || null;
|
|
1037
946
|
|
|
1038
947
|
// F4c: never link issue B onto a branch already bound to issue A.
|
|
@@ -1080,8 +989,8 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
|
|
|
1080
989
|
scope: scope.type,
|
|
1081
990
|
issueBackend,
|
|
1082
991
|
issueId: issue.issueId,
|
|
1083
|
-
// Deprecated alias of issueId, retained for
|
|
1084
|
-
//
|
|
992
|
+
// Deprecated alias of issueId, retained for output-shape compatibility.
|
|
993
|
+
// It never implied a Beads store and now always holds the kernel id.
|
|
1085
994
|
beadsIssueId: issue.issueId,
|
|
1086
995
|
branchName: branch.branchName,
|
|
1087
996
|
linked: Boolean(explicitIssueId),
|
|
@@ -1151,7 +1060,6 @@ module.exports = {
|
|
|
1151
1060
|
},
|
|
1152
1061
|
readResearchDoc,
|
|
1153
1062
|
detectScope,
|
|
1154
|
-
createBeadsIssue,
|
|
1155
1063
|
createKernelIssue,
|
|
1156
1064
|
createFeatureBranch,
|
|
1157
1065
|
extractDesignDecisions,
|
package/lib/commands/pr.js
CHANGED
|
@@ -33,7 +33,7 @@ const SUBCOMMANDS = {
|
|
|
33
33
|
},
|
|
34
34
|
merge: {
|
|
35
35
|
module: merge,
|
|
36
|
-
summary: 'Opt-in
|
|
36
|
+
summary: 'Opt-in guarded merge, OFF by default (= forge merge --auto <pr> --expect-head <sha> --issue <id>)',
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
@@ -103,7 +103,16 @@ function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
|
|
|
103
103
|
// Diff against the SAME base we just resolved. getChangedFiles() re-resolves
|
|
104
104
|
// its own diff ref (and can fall back to a different default branch), so use
|
|
105
105
|
// baseRef directly to keep the file list consistent with the resolved base.
|
|
106
|
-
|
|
106
|
+
const changedFiles = changedFilesForBase(exec, baseRef);
|
|
107
|
+
if (changedFiles == null) {
|
|
108
|
+
return {
|
|
109
|
+
resolved: false,
|
|
110
|
+
baseRef,
|
|
111
|
+
changedFiles: [],
|
|
112
|
+
reason: `git diff failed or timed out for ${baseRef}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { resolved: true, baseRef, changedFiles };
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
/** Files changed between the merge-base of HEAD and `baseRef` and HEAD. */
|
|
@@ -111,7 +120,7 @@ function changedFilesForBase(exec, baseRef) {
|
|
|
111
120
|
const mergeBase = gitTryOut(exec, ['merge-base', 'HEAD', baseRef]);
|
|
112
121
|
const range = mergeBase ? `${mergeBase}...HEAD` : `${baseRef}...HEAD`;
|
|
113
122
|
const out = gitTryOut(exec, ['diff', '--name-only', range]);
|
|
114
|
-
if (out == null) return
|
|
123
|
+
if (out == null) return null;
|
|
115
124
|
return out.split(/\r?\n/).filter(Boolean);
|
|
116
125
|
}
|
|
117
126
|
|
package/lib/commands/prime.js
CHANGED
|
@@ -2,12 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
buildPrime,
|
|
5
|
+
collectPrimeLiveState,
|
|
5
6
|
runOrientationCommand,
|
|
6
7
|
} = require('../orientation');
|
|
7
8
|
|
|
9
|
+
const DEPRECATION_NOTICE =
|
|
10
|
+
'forge prime is deprecated — run `forge status -v` for the same briefing.\n';
|
|
11
|
+
|
|
12
|
+
// The single briefing renderer. `forge status -v` calls this so the full
|
|
13
|
+
// session-entry briefing has exactly one implementation.
|
|
14
|
+
// Async: the briefing leads with LIVE state (stage / claims / ready / gates / one
|
|
15
|
+
// adoption nudge), which needs a best-effort (non-throwing) kernel read before the
|
|
16
|
+
// synchronous build assembles it into the bounded orientation.
|
|
17
|
+
async function renderBriefing(args, projectRoot) {
|
|
18
|
+
const liveState = await collectPrimeLiveState(projectRoot);
|
|
19
|
+
return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
module.exports = {
|
|
9
23
|
name: 'prime',
|
|
10
|
-
description: '
|
|
11
|
-
usage: 'Usage: forge prime [--budget N] [--json]',
|
|
12
|
-
|
|
24
|
+
description: 'Deprecated alias for `forge status -v`: session-entry bounded orientation',
|
|
25
|
+
usage: 'Usage: forge prime [--budget N] [--json] (deprecated — use `forge status -v`)',
|
|
26
|
+
// Session-start hooks in consumer repos call this and consume stdout as context,
|
|
27
|
+
// so the notice goes to stderr and stdout stays byte-identical to `status -v`.
|
|
28
|
+
handler: async (args, _flags, projectRoot, options = {}) => {
|
|
29
|
+
(options.stderr || process.stderr).write(DEPRECATION_NOTICE);
|
|
30
|
+
return renderBriefing(args, projectRoot);
|
|
31
|
+
},
|
|
32
|
+
renderBriefing,
|
|
13
33
|
};
|
package/lib/commands/push.js
CHANGED
|
@@ -4,8 +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 {
|
|
8
|
-
const {
|
|
7
|
+
const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE } = require('../../scripts/test');
|
|
8
|
+
const { fireAndForget } = require('../pr-monitor/reconcile-executor');
|
|
9
9
|
|
|
10
10
|
const isWindows = process.platform === 'win32';
|
|
11
11
|
|
|
@@ -113,54 +113,49 @@ async function autoFileBackingIssueForPush(projectRoot, execFn, deps = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* Best-effort wake of the repository-wide singleton after a successful push.
|
|
117
|
+
* The shared trigger owns all containment and gate checks and never selects a
|
|
118
|
+
* per-PR watcher here.
|
|
119
119
|
*
|
|
120
|
-
* @param {
|
|
121
|
-
* @returns {
|
|
120
|
+
* @param {object} params
|
|
121
|
+
* @returns {{ armed: boolean, reason?: string }}
|
|
122
122
|
*/
|
|
123
|
-
function
|
|
123
|
+
function maybeTriggerShepherdAfterPush({
|
|
124
|
+
projectRoot,
|
|
125
|
+
fireAndForget: trigger = fireAndForget,
|
|
126
|
+
}) {
|
|
124
127
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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;
|
|
128
|
+
trigger({ projectRoot });
|
|
129
|
+
return { armed: true };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { armed: false, reason: err.message };
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
*
|
|
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 }`.
|
|
136
|
+
* Build the environment for the spawned `git push`, declaring the push lane.
|
|
144
137
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
138
|
+
* `--quick` skips the test step push.js controls, but the `git push` it spawns
|
|
139
|
+
* still fires the lefthook pre-push hook, whose tests job runs scripts/test.js —
|
|
140
|
+
* so --quick was quick only up to the push. This declares the lane to that hook
|
|
141
|
+
* so the quick lane is lint-only end to end.
|
|
142
|
+
*
|
|
143
|
+
* The declaration is set on the child env only, and is explicitly REMOVED for a
|
|
144
|
+
* full push so an inherited value from an outer shell can never silently drop
|
|
145
|
+
* the tests from a `forge push`.
|
|
146
|
+
*
|
|
147
|
+
* @param {boolean} quickMode - Whether this is a --quick push
|
|
148
|
+
* @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to derive from
|
|
149
|
+
* @returns {NodeJS.ProcessEnv} Environment for the git push child process
|
|
147
150
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 };
|
|
151
|
+
function buildPushEnv(quickMode, baseEnv = process.env) {
|
|
152
|
+
const env = { ...baseEnv };
|
|
153
|
+
if (quickMode) {
|
|
154
|
+
env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
|
|
155
|
+
} else {
|
|
156
|
+
delete env[QUICK_LANE_ENV_VAR];
|
|
163
157
|
}
|
|
158
|
+
return env;
|
|
164
159
|
}
|
|
165
160
|
|
|
166
161
|
/**
|
|
@@ -288,7 +283,7 @@ module.exports = {
|
|
|
288
283
|
// Step 5: git push with passthrough args
|
|
289
284
|
const gitArgs = args.filter(a => a !== '--quick');
|
|
290
285
|
try {
|
|
291
|
-
execFn('git', ['push', ...gitArgs], { stdio: 'inherit' });
|
|
286
|
+
execFn('git', ['push', ...gitArgs], { stdio: 'inherit', env: buildPushEnv(quickMode) });
|
|
292
287
|
} catch (_pushErr) { // NOSONAR S2486
|
|
293
288
|
log('git push failed.');
|
|
294
289
|
return {
|
|
@@ -300,15 +295,10 @@ module.exports = {
|
|
|
300
295
|
};
|
|
301
296
|
}
|
|
302
297
|
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
// from `forge ship`.
|
|
306
|
-
maybeArmWatcherAfterPush({
|
|
298
|
+
// Wake the repository-wide singleton after a successful push.
|
|
299
|
+
maybeTriggerShepherdAfterPush({
|
|
307
300
|
projectRoot,
|
|
308
|
-
|
|
309
|
-
startWatcher: deps?.startWatcher,
|
|
310
|
-
railEnabled: deps?.railEnabled,
|
|
311
|
-
prLookup: deps?.prLookup,
|
|
301
|
+
fireAndForget: deps?.fireAndForget,
|
|
312
302
|
});
|
|
313
303
|
|
|
314
304
|
return {
|
|
@@ -323,7 +313,7 @@ module.exports = {
|
|
|
323
313
|
// Exposed for unit tests; not part of the CLI surface.
|
|
324
314
|
_internal: {
|
|
325
315
|
autoFileBackingIssueForPush,
|
|
326
|
-
|
|
327
|
-
|
|
316
|
+
buildPushEnv,
|
|
317
|
+
maybeTriggerShepherdAfterPush,
|
|
328
318
|
},
|
|
329
319
|
};
|
package/lib/commands/recall.js
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
const memoryRouter = require('../memory/router');
|
|
4
4
|
const { stripGlobalFlags } = require('../global-flags');
|
|
5
5
|
const { fenceUntrusted } = require('../untrusted-content');
|
|
6
|
+
const { applyBudget, buildSection, estimateTokens } = require('../orientation');
|
|
7
|
+
const { memoryTrustStatus } = require('../memory-recall');
|
|
6
8
|
|
|
7
9
|
const usage = 'Usage: forge recall [query] [--kind <type>] [--limit N] [--all] [--json]';
|
|
10
|
+
const RECALL_CONTENT_BUDGET = 1100;
|
|
8
11
|
|
|
9
12
|
// Reserved tag prefix that `remember --kind` writes (kernel issue 8cc1db4d). A `--kind`
|
|
10
13
|
// filter keeps only notes carrying this tag; the prefix is stripped when surfacing the
|
|
@@ -79,21 +82,28 @@ function withType(entry) {
|
|
|
79
82
|
|
|
80
83
|
function formatEntry(entry) {
|
|
81
84
|
const date = entry.timestamp ? entry.timestamp.slice(0, 10) : '';
|
|
82
|
-
const
|
|
85
|
+
const trust = memoryTrustStatus({
|
|
86
|
+
tags: entry.tags,
|
|
87
|
+
sourceAgent: entry.sourceAgent,
|
|
88
|
+
value: entry.machine ? {} : entry.note,
|
|
89
|
+
});
|
|
90
|
+
const sourceAgent = entry.sourceAgent || 'unknown';
|
|
91
|
+
const label = `[source=${sourceAgent} trust=${trust} updated=${date || 'unknown'}] `;
|
|
83
92
|
// The reserved `type:` tag renders as a leading `(kind)` marker, not as a raw tag, so the
|
|
84
93
|
// displayed tags stay the user's own labels.
|
|
85
94
|
const type = typeOf(entry);
|
|
86
|
-
const userTags = (entry.tags || []).filter(
|
|
95
|
+
const userTags = (entry.tags || []).filter(
|
|
96
|
+
t => !t.startsWith(TYPE_TAG_PREFIX) && !t.startsWith('trust:')
|
|
97
|
+
);
|
|
87
98
|
const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
|
|
88
99
|
const typeMarker = type ? `(${type}) ` : '';
|
|
89
100
|
// Machine/insights records are LABELED with their source so they are never mistaken for a
|
|
90
101
|
// plain human note; human `remember` notes render clean.
|
|
91
102
|
const marker = entry.machine && entry.sourceAgent ? `(${entry.sourceAgent}) ` : '';
|
|
92
|
-
// Stored note text is
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return `- ${prefix}${marker}${typeMarker}${note}${tagSuffix}`;
|
|
103
|
+
// Stored note text is untrusted. Fence it after budgeting so a truncation cannot
|
|
104
|
+
// sever the close marker; the `--json` path above keeps the raw note unchanged.
|
|
105
|
+
const note = String(entry.note == null ? '' : entry.note);
|
|
106
|
+
return `- ${label}${marker}${typeMarker}${note}${tagSuffix}`;
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
async function handler(args, flags, projectRoot) {
|
|
@@ -137,23 +147,57 @@ async function handler(args, flags, projectRoot) {
|
|
|
137
147
|
return { success: true, output: reason };
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
const sections = notes
|
|
151
|
+
.map((entry, index) => {
|
|
152
|
+
const content = formatEntry(entry);
|
|
153
|
+
if (estimateTokens(content) > RECALL_CONTENT_BUDGET) return null;
|
|
154
|
+
const trust = memoryTrustStatus({
|
|
155
|
+
tags: entry.tags,
|
|
156
|
+
sourceAgent: entry.sourceAgent,
|
|
157
|
+
value: entry.machine ? {} : entry.note,
|
|
158
|
+
});
|
|
159
|
+
return buildSection({
|
|
160
|
+
id: `recall_${index}`,
|
|
161
|
+
title: '',
|
|
162
|
+
content,
|
|
163
|
+
priority: index,
|
|
164
|
+
preserve: false,
|
|
165
|
+
data: { trust },
|
|
166
|
+
});
|
|
167
|
+
})
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
const budgeted = applyBudget(sections, RECALL_CONTENT_BUDGET).sections
|
|
170
|
+
.filter(section => section.content);
|
|
171
|
+
for (const section of budgeted) {
|
|
172
|
+
section.content = fenceUntrusted(section.content, { source: 'memory' });
|
|
173
|
+
}
|
|
174
|
+
const rendered = budgeted.length;
|
|
140
175
|
const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
|
|
141
176
|
let header;
|
|
142
177
|
if (query) {
|
|
143
178
|
// BM25 returns at most `limit`; when full, signal it is the TOP-N, not the whole set.
|
|
144
|
-
header = capped
|
|
145
|
-
? `Top ${
|
|
146
|
-
: `${
|
|
147
|
-
} else if (capped) {
|
|
148
|
-
// Never a bare full dump: show the
|
|
149
|
-
header = `Showing ${
|
|
179
|
+
header = capped || rendered < notes.length
|
|
180
|
+
? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
|
|
181
|
+
: `${rendered} note(s) matching "${query}":`;
|
|
182
|
+
} else if (capped || rendered < notes.length) {
|
|
183
|
+
// Never a bare full dump: show the rendered count and the true total.
|
|
184
|
+
header = `Showing ${rendered} of ${total} ${noun} (newest first):`;
|
|
150
185
|
} else {
|
|
151
|
-
header = `${
|
|
186
|
+
header = `${rendered} ${noun}:`;
|
|
152
187
|
}
|
|
153
|
-
const
|
|
188
|
+
const confirmed = budgeted.filter(section => section.data.trust === 'confirmed');
|
|
189
|
+
const suggested = budgeted.filter(section => section.data.trust === 'suggested');
|
|
190
|
+
const groups = [
|
|
191
|
+
confirmed.length ? ['Confirmed memory', confirmed] : null,
|
|
192
|
+
suggested.length ? ['Suggested memory — verify before relying', suggested] : null,
|
|
193
|
+
].filter(Boolean);
|
|
194
|
+
const body = groups.flatMap(([title, entries]) => [
|
|
195
|
+
title,
|
|
196
|
+
...entries.map(entry => entry.content),
|
|
197
|
+
]);
|
|
154
198
|
return {
|
|
155
199
|
success: true,
|
|
156
|
-
output: [header, ...
|
|
200
|
+
output: [header, ...body].join('\n'),
|
|
157
201
|
};
|
|
158
202
|
}
|
|
159
203
|
|
package/lib/commands/recap.js
CHANGED
|
@@ -46,7 +46,12 @@ async function handler(args, _flags, projectRoot, opts = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const budget = readOption(args, '--budget', undefined);
|
|
49
|
-
|
|
49
|
+
// `runIssueOperation` is the injectable kernel-read seam (Slice C2) — the same
|
|
50
|
+
// pass-through opts already carry for the grounding write below.
|
|
51
|
+
const recap = await buildIssueRecap(projectRoot, issueId, {
|
|
52
|
+
budgetTokens: budget,
|
|
53
|
+
runIssueOperation: opts.runIssueOperation,
|
|
54
|
+
});
|
|
50
55
|
|
|
51
56
|
// Grounding (gate.read_first): a successful recap IS the load-the-doc action,
|
|
52
57
|
// so append a `context.loaded` event that unblocks a later `forge claim <id>`.
|
package/lib/commands/release.js
CHANGED
|
@@ -8,13 +8,13 @@ const {
|
|
|
8
8
|
} = require('../release-readiness');
|
|
9
9
|
const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
|
|
10
10
|
const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
|
|
11
|
+
const { generateNpmPublishWorkflow } = require('../npm-publish-workflow');
|
|
11
12
|
|
|
12
13
|
// `forge release <id>` releases a claimed issue; `forge release check` runs the
|
|
13
14
|
// release-readiness gate. The two share the top-level verb, so this command
|
|
14
15
|
// dispatches `check` to the gate and routes everything else through the shared
|
|
15
|
-
// issue dispatch (resolve backend → runIssueOperation('release') → normalize).
|
|
16
|
-
|
|
17
|
-
const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit';
|
|
16
|
+
// issue dispatch (resolve backend → runIssueOperation('release') → normalize).
|
|
17
|
+
const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow';
|
|
18
18
|
|
|
19
19
|
async function runReleaseIssue(args, projectRoot, opts = {}) {
|
|
20
20
|
const resolved = withResolvedIssueBackend(projectRoot, opts);
|
|
@@ -56,7 +56,21 @@ function parseReleaseArgs(args = []) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
async function handler(args, _flags, projectRoot, opts = {}) {
|
|
59
|
-
|
|
59
|
+
const parsed = parseReleaseArgs(args);
|
|
60
|
+
|
|
61
|
+
if (parsed.subcommand === 'generate-npm-workflow') {
|
|
62
|
+
const generated = await generateNpmPublishWorkflow(projectRoot, {
|
|
63
|
+
env: opts.env,
|
|
64
|
+
kernelDeps: opts.kernelDeps,
|
|
65
|
+
});
|
|
66
|
+
return generated.success
|
|
67
|
+
? {
|
|
68
|
+
success: true,
|
|
69
|
+
generated,
|
|
70
|
+
output: `Generated ${generated.path} (${generated.contentHash}).\n`,
|
|
71
|
+
}
|
|
72
|
+
: generated;
|
|
73
|
+
}
|
|
60
74
|
|
|
61
75
|
if (parsed.subcommand === 'regen-audit') {
|
|
62
76
|
// forge release regen-audit — rewrite the D20 kill-list from a live re-scan.
|
package/lib/commands/serve.js
CHANGED
|
@@ -240,7 +240,7 @@ async function routeMutation(verb, args, projectRoot, deps = {}) {
|
|
|
240
240
|
function defaultGenerate(projectRoot, dashboardDir) {
|
|
241
241
|
return new Promise((resolve, reject) => {
|
|
242
242
|
const script = path.join(dashboardDir, 'generate-snapshot.mjs');
|
|
243
|
-
const child = spawn(process.execPath, [script], { cwd: projectRoot, stdio: 'ignore' });
|
|
243
|
+
const child = spawn(process.execPath, [script], { cwd: projectRoot, stdio: 'ignore', windowsHide: true });
|
|
244
244
|
const timer = setTimeout(() => { child.kill(); reject(new Error('snapshot generation timed out')); }, GEN_TIMEOUT_MS);
|
|
245
245
|
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
246
246
|
child.on('exit', (code) => {
|
|
@@ -441,7 +441,10 @@ function browserOpener() {
|
|
|
441
441
|
function openBrowser(url) {
|
|
442
442
|
try {
|
|
443
443
|
const { file, args } = browserOpener();
|
|
444
|
-
|
|
444
|
+
// windowsHide keeps this detached opener from flashing a console window on
|
|
445
|
+
// Windows (Node defaults windowsHide to false); background/detached spawns
|
|
446
|
+
// must stay silent (issue 931e7924).
|
|
447
|
+
const child = spawn(file, [...args, url], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
445
448
|
// spawn() failures (e.g. the opener binary is missing) surface ASYNChronously
|
|
446
449
|
// as an 'error' event, NOT via the try/catch — an unhandled one would crash
|
|
447
450
|
// `forge serve`. Opening a browser is best-effort; swallow it (URL is printed).
|