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/orientation.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { fenceUntrusted } = require('./untrusted-content');
|
|
6
|
+
const { runIssueOperation: defaultRunIssueOperation } = require('./forge-issues');
|
|
7
|
+
const { memoryTrustStatus } = require('./memory-recall');
|
|
6
8
|
|
|
7
9
|
const DEFAULT_BUDGET_TOKENS = 2000;
|
|
8
10
|
const MIN_BUDGET_TOKENS = 40;
|
|
@@ -43,25 +45,6 @@ function readJsonFile(projectRoot, relativeFilePath) {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
function readJsonl(projectRoot, relativeFilePath) {
|
|
47
|
-
const text = readText(projectRoot, relativeFilePath);
|
|
48
|
-
if (!text) return [];
|
|
49
|
-
return text
|
|
50
|
-
.split(/\r?\n/)
|
|
51
|
-
.map(line => line.trim())
|
|
52
|
-
.filter(Boolean)
|
|
53
|
-
.map((line, index) => {
|
|
54
|
-
try {
|
|
55
|
-
return JSON.parse(line);
|
|
56
|
-
} catch (error) {
|
|
57
|
-
return {
|
|
58
|
-
_parseError: true,
|
|
59
|
-
line: index + 1,
|
|
60
|
-
error: error.message,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
48
|
|
|
66
49
|
function firstLine(value) {
|
|
67
50
|
return String(value || '')
|
|
@@ -426,17 +409,43 @@ function buildMemorySection(projectRoot, options = {}) {
|
|
|
426
409
|
const result = memoryRouter.recall(projectRoot, { limit: MEMORY_SECTION_NOTE_LIMIT }, { store });
|
|
427
410
|
const notes = Array.isArray(result && result.notes) ? result.notes : [];
|
|
428
411
|
if (notes.length === 0) return [];
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
412
|
+
const groups = [
|
|
413
|
+
{
|
|
414
|
+
id: 'remembered_notes',
|
|
415
|
+
title: 'Confirmed Memory',
|
|
416
|
+
trust: 'confirmed',
|
|
417
|
+
priority: 45,
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: 'suggested_memory',
|
|
421
|
+
title: 'Suggested Memory — Verify Before Relying',
|
|
422
|
+
trust: 'suggested',
|
|
423
|
+
priority: 46,
|
|
424
|
+
},
|
|
425
|
+
];
|
|
426
|
+
return groups.map(group => {
|
|
427
|
+
const content = notes
|
|
428
|
+
.filter(note => memoryTrustStatus({
|
|
429
|
+
tags: note.tags,
|
|
430
|
+
sourceAgent: note.sourceAgent,
|
|
431
|
+
value: note.machine ? {} : note.note,
|
|
432
|
+
}) === group.trust)
|
|
433
|
+
.map(formatMemoryNote)
|
|
434
|
+
.filter(line => estimateTokens(line) <= DEFAULT_BUDGET_TOKENS)
|
|
435
|
+
.join('\n');
|
|
436
|
+
if (!content) return null;
|
|
437
|
+
return buildSection({
|
|
438
|
+
id: group.id,
|
|
439
|
+
title: group.title,
|
|
440
|
+
content,
|
|
441
|
+
sources: [source('kernel.remembered_notes', 'kernel_memory', 'project_memory', group.id)],
|
|
442
|
+
priority: group.priority,
|
|
443
|
+
preserve: false,
|
|
444
|
+
// Remembered notes are untrusted DATA (a planted note must not read as directives).
|
|
445
|
+
// Raw here; the assembler fences it AFTER applyBudget truncation.
|
|
446
|
+
untrustedSource: 'memory',
|
|
447
|
+
});
|
|
448
|
+
}).filter(Boolean);
|
|
440
449
|
} catch {
|
|
441
450
|
return [];
|
|
442
451
|
} finally {
|
|
@@ -470,8 +479,13 @@ function openMemoryStore(projectRoot) {
|
|
|
470
479
|
/** Render one recall note as a compact `- [date] text` line. */
|
|
471
480
|
function formatMemoryNote(note) {
|
|
472
481
|
const date = typeof note.timestamp === 'string' && note.timestamp ? note.timestamp.slice(0, 10) : '';
|
|
473
|
-
const
|
|
474
|
-
|
|
482
|
+
const trust = memoryTrustStatus({
|
|
483
|
+
tags: note.tags,
|
|
484
|
+
sourceAgent: note.sourceAgent,
|
|
485
|
+
value: note.machine ? {} : note.note,
|
|
486
|
+
});
|
|
487
|
+
const sourceAgent = note.sourceAgent || 'unknown';
|
|
488
|
+
return `- [source=${sourceAgent} trust=${trust} updated=${date || 'unknown'}] ${note.note}`;
|
|
475
489
|
}
|
|
476
490
|
|
|
477
491
|
function buildQueueSections() {
|
|
@@ -671,7 +685,8 @@ const PRIME_KEY_COMMANDS_CONTENT = [
|
|
|
671
685
|
'forge upgrade [--dry-run] — preview/self-heal safe Forge upgrade readiness',
|
|
672
686
|
'forge gate <verb> <gate-id> — toggle a workflow gate, or approve/reject a human gate',
|
|
673
687
|
'forge role <role> --use <skill> — bind a role to a skill/ideology',
|
|
674
|
-
'forge merge --auto <pr> — opt-in
|
|
688
|
+
'forge merge --auto <pr> --expect-head <full-sha> --issue <issue-id> — opt-in guarded merge (off by default)',
|
|
689
|
+
'post-ship loop: forge ship → forge shepherd <pr> --pull --json → guarded forge merge with exact head + owned issue',
|
|
675
690
|
].map(line => `- ${line}`).join('\n');
|
|
676
691
|
|
|
677
692
|
function buildPrimeKeyCommandsSection() {
|
|
@@ -685,14 +700,24 @@ function buildPrimeKeyCommandsSection() {
|
|
|
685
700
|
});
|
|
686
701
|
}
|
|
687
702
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
703
|
+
// Read a single issue from the Kernel (the sole issue-state authority). The `show`
|
|
704
|
+
// contract returns the issue summary DIRECTLY as `result.data` (ISSUE_SUMMARY_SCHEMA),
|
|
705
|
+
// NOT `result.data.issue` — the nested shape belongs to the mutation envelope. Resilient
|
|
706
|
+
// by contract: any read failure degrades to `null` (not-found) so orientation never crashes.
|
|
707
|
+
async function findIssue(projectRoot, issueId, runIssueOperation = defaultRunIssueOperation) {
|
|
708
|
+
try {
|
|
709
|
+
const result = await runIssueOperation('show', [issueId], projectRoot, { issueBackend: 'kernel' });
|
|
710
|
+
if (result && result.ok && result.data && result.data.id) {
|
|
711
|
+
return result.data;
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
// best-effort: a kernel read failure surfaces as "not found", never a thrown error
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
692
717
|
}
|
|
693
718
|
|
|
694
|
-
function buildIssueSection(projectRoot, issueId) {
|
|
695
|
-
const issue = findIssue(projectRoot, issueId);
|
|
719
|
+
async function buildIssueSection(projectRoot, issueId, options = {}) {
|
|
720
|
+
const issue = await findIssue(projectRoot, issueId, options.runIssueOperation);
|
|
696
721
|
const content = issue
|
|
697
722
|
? [
|
|
698
723
|
`id: ${issue.id}`,
|
|
@@ -700,7 +725,7 @@ function buildIssueSection(projectRoot, issueId) {
|
|
|
700
725
|
issue.status ? `status: ${issue.status}` : null,
|
|
701
726
|
issue.description ? `description: ${issue.description}` : null,
|
|
702
727
|
].filter(Boolean).join('\n')
|
|
703
|
-
: `id: ${issueId}\nstatus: unknown\nIssue not found in
|
|
728
|
+
: `id: ${issueId}\nstatus: unknown\nIssue not found in the kernel.`;
|
|
704
729
|
|
|
705
730
|
return {
|
|
706
731
|
issue: issue ? {
|
|
@@ -716,19 +741,19 @@ function buildIssueSection(projectRoot, issueId) {
|
|
|
716
741
|
id: 'issue_summary',
|
|
717
742
|
title: 'Issue Summary',
|
|
718
743
|
content,
|
|
719
|
-
sources: [source('
|
|
744
|
+
sources: [source('kernel', 'kernel', 'issue_read', 'issue_summary')],
|
|
720
745
|
priority: 5,
|
|
721
746
|
preserve: true,
|
|
722
747
|
}),
|
|
723
748
|
};
|
|
724
749
|
}
|
|
725
750
|
|
|
726
|
-
function buildIssueRecap(projectRoot, issueId, options = {}) {
|
|
751
|
+
async function buildIssueRecap(projectRoot, issueId, options = {}) {
|
|
727
752
|
if (!issueId || typeof issueId !== 'string') {
|
|
728
753
|
throw new Error('Issue id is required for issue-scoped recap.');
|
|
729
754
|
}
|
|
730
755
|
|
|
731
|
-
const issueResult = buildIssueSection(projectRoot, issueId);
|
|
756
|
+
const issueResult = await buildIssueSection(projectRoot, issueId, options);
|
|
732
757
|
const sections = [
|
|
733
758
|
issueResult.section,
|
|
734
759
|
...buildProjectDesignSections(projectRoot),
|
|
@@ -761,11 +786,16 @@ function buildIssueRecap(projectRoot, issueId, options = {}) {
|
|
|
761
786
|
|
|
762
787
|
function buildPrime(projectRoot, options = {}) {
|
|
763
788
|
const { project, sections } = buildOrientationSections(projectRoot, options);
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
789
|
+
// Prime is the session-entry command, so it LEADS the COMPLETE orientation with LIVE state
|
|
790
|
+
// (stage / claims / ready / gates / one adoption nudge) when the caller supplied it — the
|
|
791
|
+
// live-state section is prepended to the full section list (not just the extra sections), so
|
|
792
|
+
// prime leads with it in every output path. Collected async by the command handler and injected
|
|
793
|
+
// here so buildPrime itself stays pure and synchronous.
|
|
794
|
+
const keyCommands = buildPrimeKeyCommandsSection();
|
|
795
|
+
const allSections = options.liveState
|
|
796
|
+
? [...buildPrimeLiveStateSections(options.liveState), ...sections, keyCommands]
|
|
797
|
+
: [...sections, keyCommands];
|
|
798
|
+
const orientation = assembleOrientationResult(project, allSections, options);
|
|
769
799
|
return {
|
|
770
800
|
schema_version: 1,
|
|
771
801
|
kind: 'prime',
|
|
@@ -774,6 +804,7 @@ function buildPrime(projectRoot, options = {}) {
|
|
|
774
804
|
token_budget: orientation.token_budget,
|
|
775
805
|
orientation,
|
|
776
806
|
sources: orientation.sources,
|
|
807
|
+
...(options.liveState ? { live_state: sanitizeLiveStateForJson(options.liveState) } : {}),
|
|
777
808
|
next_commands: [
|
|
778
809
|
'forge orient --json',
|
|
779
810
|
'forge status --json',
|
|
@@ -782,6 +813,290 @@ function buildPrime(projectRoot, options = {}) {
|
|
|
782
813
|
};
|
|
783
814
|
}
|
|
784
815
|
|
|
816
|
+
// Cap on claimed issues rendered in the prime live-state block — a bounded nudge, not a dump.
|
|
817
|
+
const LIVE_STATE_CLAIM_LIMIT = 3;
|
|
818
|
+
const LIVE_STATE_GATE_LIMIT = 6;
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Render the prime LIVE-state block: current stage, claimed issue(s), ready count, enabled
|
|
822
|
+
* gates/rails, and ONE progressive-adoption nudge. PURE and bounded — the output is always
|
|
823
|
+
* ≤ ~10 lines (well under the 20-line cap), with honest fallbacks for every missing field so
|
|
824
|
+
* a repo with no kernel data still renders a coherent block.
|
|
825
|
+
*
|
|
826
|
+
* @param {object} [liveState]
|
|
827
|
+
* @returns {string}
|
|
828
|
+
*/
|
|
829
|
+
// Hard cap on any single EXTERNAL value (stage name, issue title, gate id) rendered into the
|
|
830
|
+
// live-state block. Counts alone don't bound the block: one long or multiline title/name/id could
|
|
831
|
+
// otherwise bloat live_state or break its one-value-per-line structure. clipValue enforces both.
|
|
832
|
+
const LIVE_STATE_VALUE_MAX = 60;
|
|
833
|
+
|
|
834
|
+
/** Collapse all whitespace (incl. newlines) to single spaces and hard-cap length with an ellipsis. */
|
|
835
|
+
/**
|
|
836
|
+
* Sanitized copy of the raw liveState for the `--json` envelope. The rendered text sections are
|
|
837
|
+
* clipped + provenance-fenced, but `forge prime --json` also emits a `live_state` object — without
|
|
838
|
+
* this, an attacker-influenceable title/id from Kernel/GitHub would land RAW (unbounded, with
|
|
839
|
+
* newlines) in the trusted session-entry envelope, bypassing the budget + fence. Clip every string
|
|
840
|
+
* field so the JSON copy carries the same bounded/newline-collapsed representation as the text path.
|
|
841
|
+
*/
|
|
842
|
+
function sanitizeLiveStateForJson(liveState) {
|
|
843
|
+
if (!liveState || typeof liveState !== 'object') return liveState;
|
|
844
|
+
const clip = v => (typeof v === 'string' ? clipValue(v) : v);
|
|
845
|
+
const clipIssue = i => (i && typeof i === 'object' ? { ...i, id: clip(i.id), title: clip(i.title) } : i);
|
|
846
|
+
return {
|
|
847
|
+
...liveState,
|
|
848
|
+
stage: liveState.stage && typeof liveState.stage === 'object'
|
|
849
|
+
? { ...liveState.stage, id: clip(liveState.stage.id), name: clip(liveState.stage.name) }
|
|
850
|
+
: liveState.stage,
|
|
851
|
+
claimed: Array.isArray(liveState.claimed) ? liveState.claimed.map(clipIssue) : liveState.claimed,
|
|
852
|
+
topReady: clipIssue(liveState.topReady),
|
|
853
|
+
gates: Array.isArray(liveState.gates) ? liveState.gates.map(clip) : liveState.gates,
|
|
854
|
+
nudge: clip(liveState.nudge),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function clipValue(value, max = LIVE_STATE_VALUE_MAX) {
|
|
859
|
+
const flat = String(value).replace(/\s+/g, ' ').trim();
|
|
860
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** One-line "Stage: <id> — <name>" (or "not recorded"). */
|
|
864
|
+
function formatStageLine(stage) {
|
|
865
|
+
if (!stage?.id) return 'Stage: not recorded';
|
|
866
|
+
const suffix = stage.name ? ` — ${clipValue(stage.name)}` : '';
|
|
867
|
+
return `Stage: ${clipValue(stage.id)}${suffix}`;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Bounded "Claimed:" lines (capped, with an "…and N more" tail) for the UNTRUSTED claimed block.
|
|
872
|
+
* id + title are clipped (bounded + newlines collapsed); the title is NOT fenced inline. The whole
|
|
873
|
+
* block is emitted as a section carrying `untrustedSource`, so the shared post-budget
|
|
874
|
+
* fenceUntrustedSections wraps it — the ⟦END UNTRUSTED⟧ terminator then always survives a budget
|
|
875
|
+
* truncation, which an inline per-title fence could not guarantee. Caller guards the empty case.
|
|
876
|
+
*/
|
|
877
|
+
function formatClaimedLines(claimed) {
|
|
878
|
+
const lines = claimed
|
|
879
|
+
.slice(0, LIVE_STATE_CLAIM_LIMIT)
|
|
880
|
+
.map(issue => {
|
|
881
|
+
const title = issue.title ? ` ${clipValue(issue.title)}` : '';
|
|
882
|
+
return `Claimed: ${clipValue(issue.id)}${title}`;
|
|
883
|
+
});
|
|
884
|
+
if (claimed.length > LIVE_STATE_CLAIM_LIMIT) {
|
|
885
|
+
lines.push(`Claimed: …and ${claimed.length - LIVE_STATE_CLAIM_LIMIT} more`);
|
|
886
|
+
}
|
|
887
|
+
return lines;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** One-line "Ready: N issue(s) waiting" (or "none"). */
|
|
891
|
+
function formatReadyLine(readyCount) {
|
|
892
|
+
if (readyCount <= 0) return 'Ready: none';
|
|
893
|
+
return `Ready: ${readyCount} issue${readyCount === 1 ? '' : 's'} waiting (forge ready)`;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** One-line "Gates on: <capped list>" (or "defaults"). */
|
|
897
|
+
function formatGatesLine(gates) {
|
|
898
|
+
if (gates.length === 0) return 'Gates on: defaults';
|
|
899
|
+
const shown = gates.slice(0, LIVE_STATE_GATE_LIMIT).map(gate => clipValue(gate)).join(', ');
|
|
900
|
+
return `Gates on: ${shown}${gates.length > LIVE_STATE_GATE_LIMIT ? ', …' : ''}`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* The TRUSTED prime live-state block: stage, ready count, enabled gates, and one adoption nudge —
|
|
905
|
+
* all internally sourced and safe to act on. Attacker-influenceable claimed issue TITLES are NOT
|
|
906
|
+
* here; they render separately via formatClaimedBlock into an untrusted, provenance-fenced section.
|
|
907
|
+
* When nothing is claimed there is no untrusted data, so a plain "Claimed: none" is noted here.
|
|
908
|
+
*/
|
|
909
|
+
function formatPrimeLiveState(liveState = {}) {
|
|
910
|
+
const claimed = Array.isArray(liveState.claimed) ? liveState.claimed : [];
|
|
911
|
+
const readyCount = Number.isFinite(liveState.readyCount) ? liveState.readyCount : 0;
|
|
912
|
+
const gates = Array.isArray(liveState.gates) ? liveState.gates : [];
|
|
913
|
+
|
|
914
|
+
const lines = [formatStageLine(liveState.stage)];
|
|
915
|
+
if (claimed.length === 0) lines.push('Claimed: none');
|
|
916
|
+
lines.push(formatReadyLine(readyCount), formatGatesLine(gates));
|
|
917
|
+
if (liveState.nudge) lines.push(`Next: ${liveState.nudge}`);
|
|
918
|
+
return lines.join('\n');
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* The UNTRUSTED claimed-work block (attacker-influenceable issue titles), or '' when nothing is
|
|
923
|
+
* claimed. Emitted as its own section marked `untrustedSource` so fenceUntrustedSections fences it
|
|
924
|
+
* AFTER applyBudget — the fence terminator survives truncation.
|
|
925
|
+
*/
|
|
926
|
+
function formatClaimedBlock(liveState = {}) {
|
|
927
|
+
const claimed = Array.isArray(liveState.claimed) ? liveState.claimed : [];
|
|
928
|
+
if (claimed.length === 0) return '';
|
|
929
|
+
return formatClaimedLines(claimed).join('\n');
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Build the prime live-state sections: a TRUSTED `live_state` block always, plus an UNTRUSTED
|
|
934
|
+
* `live_state_claimed` block when work is claimed. Splitting is deliberate — only the claimed
|
|
935
|
+
* titles are attacker-influenceable, so only that block carries `untrustedSource` (the trusted
|
|
936
|
+
* stage/ready/gates/nudge must stay actionable, not fenced as "data only").
|
|
937
|
+
* @returns {object[]}
|
|
938
|
+
*/
|
|
939
|
+
function buildPrimeLiveStateSections(liveState) {
|
|
940
|
+
const sections = [buildSection({
|
|
941
|
+
id: 'live_state',
|
|
942
|
+
title: 'Live State',
|
|
943
|
+
content: formatPrimeLiveState(liveState),
|
|
944
|
+
sources: [source('kernel.live_state', 'kernel_read_model', 'live_session_state', 'live_state')],
|
|
945
|
+
// Priority 0 so prime LEADS with live state in every output path: applyBudget orders sections
|
|
946
|
+
// by priority (project_identity is also 0), and the id tiebreak ('live_state' < 'project_
|
|
947
|
+
// identity') puts live state first — the session-entry "where am I right now" belongs on top.
|
|
948
|
+
priority: 0,
|
|
949
|
+
preserve: true,
|
|
950
|
+
})];
|
|
951
|
+
const claimedContent = formatClaimedBlock(liveState);
|
|
952
|
+
if (claimedContent) {
|
|
953
|
+
sections.push(buildSection({
|
|
954
|
+
id: 'live_state_claimed',
|
|
955
|
+
title: 'Claimed Work',
|
|
956
|
+
content: claimedContent,
|
|
957
|
+
sources: [source('kernel.live_state', 'kernel_read_model', 'live_session_state', 'live_state_claimed')],
|
|
958
|
+
// Issue titles are attacker-influenceable. Marking the WHOLE block untrusted lets the shared
|
|
959
|
+
// post-budget fenceUntrustedSections wrap it, so the ⟦END UNTRUSTED⟧ terminator always
|
|
960
|
+
// survives a budget cut (an inline per-title fence could be severed mid-truncation). Id
|
|
961
|
+
// 'live_state_claimed' sorts right after 'live_state' and before other priority-0 sections.
|
|
962
|
+
untrustedSource: 'issue-titles',
|
|
963
|
+
priority: 0,
|
|
964
|
+
preserve: true,
|
|
965
|
+
}));
|
|
966
|
+
}
|
|
967
|
+
return sections;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Deterministic, single-line progressive-adoption nudge (at-most-one) for prime live-state. */
|
|
971
|
+
function buildAdoptionNudge({ claimed = [], readyCount = 0, topReady = null } = {}) {
|
|
972
|
+
// Issue ids are attacker-influenceable (the broker accepts `--id` as a raw string), and this
|
|
973
|
+
// string lands in the trusted Live State `Next:` line — clip it (bound + collapse newlines) so a
|
|
974
|
+
// crafted id cannot break the one-value-per-line structure or inject a fake directive line.
|
|
975
|
+
if (claimed.length > 0) return `Resume with forge recap ${clipValue(claimed[0].id)} for full context.`;
|
|
976
|
+
if (readyCount > 0 && topReady && topReady.id) return `Claim work: forge claim ${clipValue(topReady.id)}, then plan or dev.`;
|
|
977
|
+
return 'No active or ready work — forge plan "<feature>" to start, or forge ready to check.';
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* True only when a Kernel DB ALREADY EXISTS on disk. `forge prime` is a read-only, session-entry
|
|
982
|
+
* command, so the live-state read must NEVER lazily create/migrate the Kernel DB (which the
|
|
983
|
+
* default snapshot path would otherwise do in a fresh repo). resolveKernelDatabasePath only
|
|
984
|
+
* COMPUTES the path (no side effects); we check the file separately. Never throws.
|
|
985
|
+
* @param {string} projectRoot
|
|
986
|
+
* @returns {boolean}
|
|
987
|
+
*/
|
|
988
|
+
function hasExistingKernelDb(projectRoot) {
|
|
989
|
+
try {
|
|
990
|
+
const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
|
|
991
|
+
const databasePath = resolveKernelDatabasePath({ projectRoot });
|
|
992
|
+
return !!databasePath && fs.existsSync(databasePath);
|
|
993
|
+
} catch {
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* True when the live-state read must be SKIPPED to keep `forge prime` strictly READ-ONLY. The
|
|
1000
|
+
* Kernel is the SOLE runtime issue backend (Beads is retired from the runtime — the only remaining
|
|
1001
|
+
* Beads surface is the opt-in `forge migrate` path, so there is NO runtime Beads live-data source
|
|
1002
|
+
* by design). The Kernel read lazily creates/migrates `.git/forge/kernel.sqlite`, so we read live
|
|
1003
|
+
* ONLY when that DB already exists; otherwise prime shows honest-degraded/empty state and never
|
|
1004
|
+
* creates a store. Never throws.
|
|
1005
|
+
* @param {string} projectRoot
|
|
1006
|
+
* @returns {boolean} true iff the read must be skipped.
|
|
1007
|
+
*/
|
|
1008
|
+
function shouldSkipLiveSnapshot(projectRoot) {
|
|
1009
|
+
return !hasExistingKernelDb(projectRoot);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Acquire the status snapshot for live-state WITHOUT ever creating state. An injected
|
|
1014
|
+
* `_readSnapshot` (tests) bypasses the guards; otherwise the read is gated on a real git repo and
|
|
1015
|
+
* an existing Kernel DB (the sole runtime issue backend — see shouldSkipLiveSnapshot), so a
|
|
1016
|
+
* fresh/un-initialized repo returns null (honest fallback) and nothing is written. Never throws.
|
|
1017
|
+
* @returns {Promise<object|null>}
|
|
1018
|
+
*/
|
|
1019
|
+
async function acquireLiveSnapshot(projectRoot, env, options) {
|
|
1020
|
+
if (options._readSnapshot) {
|
|
1021
|
+
try { return await options._readSnapshot(); } catch { return null; }
|
|
1022
|
+
}
|
|
1023
|
+
if (!fs.existsSync(path.join(projectRoot, '.git'))) return null;
|
|
1024
|
+
if (shouldSkipLiveSnapshot(projectRoot)) return null; // read-only: never create the store
|
|
1025
|
+
try {
|
|
1026
|
+
const { readStatusSnapshot } = require('./status/snapshot');
|
|
1027
|
+
return await readStatusSnapshot(projectRoot, { env });
|
|
1028
|
+
} catch {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Resolve the current stage for live-state (best-effort, non-throwing). Injectable via options. */
|
|
1034
|
+
function resolveLiveStage(projectRoot, claimed, options) {
|
|
1035
|
+
if (Object.hasOwn(options, '_workflowState')) {
|
|
1036
|
+
const ws = options._workflowState;
|
|
1037
|
+
return ws && ws.currentStage ? { id: ws.currentStage, name: ws.currentStage } : null;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
const status = require('./commands/status');
|
|
1041
|
+
const issueId = claimed[0] ? claimed[0].id : null;
|
|
1042
|
+
const { workflowState } = status.resolveWorkflowState({ projectRoot, issueId });
|
|
1043
|
+
if (workflowState && workflowState.currentStage) {
|
|
1044
|
+
return { id: workflowState.currentStage, name: status.buildAuthoritativeStatus(workflowState).stageName };
|
|
1045
|
+
}
|
|
1046
|
+
} catch { /* stage stays null */ }
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Best-effort LIVE-state collector for prime. Async + NON-THROWING and strictly READ-ONLY: it
|
|
1052
|
+
* never creates or migrates the Kernel DB (a fresh repo yields honest fallbacks, not a new DB).
|
|
1053
|
+
* `options.liveState` bypasses all reads; `options._readSnapshot` injects a snapshot (tests).
|
|
1054
|
+
*
|
|
1055
|
+
* @param {string} projectRoot
|
|
1056
|
+
* @param {object} [options] - `{ liveState, env, _readSnapshot, _workflowState }` (all injectable).
|
|
1057
|
+
* @returns {Promise<{stage: object|null, claimed: object[], readyCount: number, gates: string[], nudge: string}>}
|
|
1058
|
+
*/
|
|
1059
|
+
async function collectPrimeLiveState(projectRoot, options = {}) {
|
|
1060
|
+
if (options.liveState) return options.liveState;
|
|
1061
|
+
const env = options.env || process.env;
|
|
1062
|
+
const gates = readEnabledGates(projectRoot); // config-file backed — safe even with no repo/DB
|
|
1063
|
+
|
|
1064
|
+
const snapshot = await acquireLiveSnapshot(projectRoot, env, options);
|
|
1065
|
+
if (!snapshot) {
|
|
1066
|
+
return { stage: null, claimed: [], readyCount: 0, gates, nudge: buildAdoptionNudge({}) };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const claimed = (Array.isArray(snapshot.activeAssigned) ? snapshot.activeAssigned : [])
|
|
1070
|
+
.map(issue => ({ id: issue.id, title: issue.title || null }));
|
|
1071
|
+
const readyList = Array.isArray(snapshot.ready) ? snapshot.ready : [];
|
|
1072
|
+
const readyCount = readyList.length;
|
|
1073
|
+
|
|
1074
|
+
return {
|
|
1075
|
+
stage: resolveLiveStage(projectRoot, claimed, options),
|
|
1076
|
+
claimed,
|
|
1077
|
+
readyCount,
|
|
1078
|
+
gates,
|
|
1079
|
+
nudge: buildAdoptionNudge({ claimed, readyCount, topReady: readyList[0] || null }),
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Read the enabled gate/rail ids from the resolved runtime graph (config-file backed, no kernel
|
|
1085
|
+
* DB — safe on a non-repo path). Never throws; returns [] on any failure.
|
|
1086
|
+
* @param {string} projectRoot
|
|
1087
|
+
* @returns {string[]}
|
|
1088
|
+
*/
|
|
1089
|
+
function readEnabledGates(projectRoot) {
|
|
1090
|
+
try {
|
|
1091
|
+
const { getResolvedRuntimeGraph } = require('./core/runtime-graph');
|
|
1092
|
+
const graph = getResolvedRuntimeGraph({ projectRoot }) || {};
|
|
1093
|
+
const primitives = [...(graph.rails || []), ...(graph.gates || [])];
|
|
1094
|
+
return primitives.filter(p => p && p.enabled !== false).map(p => p.id).filter(Boolean);
|
|
1095
|
+
} catch {
|
|
1096
|
+
return [];
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
785
1100
|
function formatOrientationText(result) {
|
|
786
1101
|
const lines = [
|
|
787
1102
|
orientationTitle(result.kind),
|
|
@@ -834,9 +1149,10 @@ function readOption(args, name, fallback) {
|
|
|
834
1149
|
return fallback;
|
|
835
1150
|
}
|
|
836
1151
|
|
|
837
|
-
function runOrientationCommand(build, args, projectRoot) {
|
|
1152
|
+
function runOrientationCommand(build, args, projectRoot, extraOptions = {}) {
|
|
838
1153
|
const result = build(projectRoot, {
|
|
839
1154
|
budgetTokens: readOption(args, '--budget', undefined),
|
|
1155
|
+
...extraOptions,
|
|
840
1156
|
});
|
|
841
1157
|
return {
|
|
842
1158
|
success: true,
|
|
@@ -848,12 +1164,18 @@ module.exports = {
|
|
|
848
1164
|
DEFAULT_BUDGET_TOKENS,
|
|
849
1165
|
applyBudget,
|
|
850
1166
|
buildSection,
|
|
1167
|
+
buildAdoptionNudge,
|
|
851
1168
|
buildIssueRecap,
|
|
852
1169
|
buildMemorySection,
|
|
853
1170
|
buildOrientation,
|
|
854
1171
|
buildOrientationSections,
|
|
855
1172
|
buildPrime,
|
|
1173
|
+
buildPrimeLiveStateSections,
|
|
1174
|
+
collectPrimeLiveState,
|
|
1175
|
+
shouldSkipLiveSnapshot,
|
|
856
1176
|
discoverWorkFolder,
|
|
1177
|
+
formatPrimeLiveState,
|
|
1178
|
+
formatClaimedBlock,
|
|
857
1179
|
estimateTokens,
|
|
858
1180
|
formatOrientationText,
|
|
859
1181
|
normalizeBudgetTokens,
|
package/lib/plugin-catalog.js
CHANGED
|
@@ -91,15 +91,25 @@ const CATALOG = Object.freeze({
|
|
|
91
91
|
},
|
|
92
92
|
|
|
93
93
|
// ── Plan ──
|
|
94
|
-
|
|
95
|
-
name: '
|
|
94
|
+
'forge-kernel-issues': {
|
|
95
|
+
name: 'Forge Kernel issues',
|
|
96
96
|
type: 'cli',
|
|
97
97
|
tier: 'free',
|
|
98
98
|
stage: 'plan',
|
|
99
|
-
description: '
|
|
99
|
+
description: 'Built-in issue tracker backing /plan — forge issue create, forge ready, forge claim',
|
|
100
100
|
detectWhen: [],
|
|
101
|
-
install: { method: '
|
|
101
|
+
install: { method: 'config', cmd: 'built-in (forge issue create)' },
|
|
102
102
|
},
|
|
103
|
+
'mermaid-cli': {
|
|
104
|
+
name: 'Mermaid CLI',
|
|
105
|
+
type: 'cli',
|
|
106
|
+
tier: 'free',
|
|
107
|
+
stage: 'plan',
|
|
108
|
+
description: 'Render Mermaid architecture diagrams from design docs to SVG/PNG',
|
|
109
|
+
detectWhen: [],
|
|
110
|
+
install: { method: 'npm', cmd: 'bun add -D @mermaid-js/mermaid-cli', dev: true },
|
|
111
|
+
},
|
|
112
|
+
|
|
103
113
|
// ── Dev ──
|
|
104
114
|
'typescript-lsp': {
|
|
105
115
|
name: 'TypeScript LSP',
|
package/lib/pr-bundle.js
CHANGED
|
@@ -86,12 +86,12 @@ async function gatherUnresolvedComments(adapter, { owner, repo, pr }) {
|
|
|
86
86
|
* Predict merge conflicts. `detectConflicts` is optional on the adapter; when
|
|
87
87
|
* absent or failing, report it as unsupported rather than throwing.
|
|
88
88
|
*/
|
|
89
|
-
async function gatherConflicts(adapter, { baseRef, cwd }) {
|
|
89
|
+
async function gatherConflicts(adapter, { baseRef, cwd, headRef }) {
|
|
90
90
|
if (typeof adapter.detectConflicts !== 'function') {
|
|
91
91
|
return { supported: false, reason: 'adapter has no detectConflicts capability' };
|
|
92
92
|
}
|
|
93
93
|
try {
|
|
94
|
-
return await adapter.detectConflicts({ baseRef, cwd });
|
|
94
|
+
return await adapter.detectConflicts({ baseRef, cwd, headRef });
|
|
95
95
|
} catch (error) {
|
|
96
96
|
return { supported: false, reason: error.message || 'conflict detection failed' };
|
|
97
97
|
}
|
|
@@ -145,11 +145,12 @@ async function gatherPrBundle({ pr, owner, repo, base, baseRef, cwd, adapter })
|
|
|
145
145
|
const state = await adapter.readState(pr);
|
|
146
146
|
// NOTE: required-check lookup needs the base BRANCH name (`base`), not the
|
|
147
147
|
// remote ref (`baseRef`); passing the ref builds a bad protection path and
|
|
148
|
-
// silently yields a null required set.
|
|
149
|
-
|
|
150
|
-
const
|
|
148
|
+
// silently yields a null required set. `pr` lets the adapter fall back to the
|
|
149
|
+
// rollup `isRequired` set when branch protection is unreadable in CI.
|
|
150
|
+
const requiredRaw = await adapter.readRequiredChecks({ owner, repo, base, pr });
|
|
151
|
+
const divergence = await adapter.readDivergence({ baseRef, cwd, headRef: state.headSha });
|
|
151
152
|
const comments = await gatherUnresolvedComments(adapter, { owner, repo, pr });
|
|
152
|
-
const conflicts = await gatherConflicts(adapter, { baseRef, cwd });
|
|
153
|
+
const conflicts = await gatherConflicts(adapter, { baseRef, cwd, headRef: state.headSha });
|
|
153
154
|
|
|
154
155
|
const requiredSet = requiredRaw === undefined ? null : requiredRaw;
|
|
155
156
|
|
|
@@ -171,6 +172,8 @@ async function gatherPrBundle({ pr, owner, repo, base, baseRef, cwd, adapter })
|
|
|
171
172
|
state: String(state.state || 'OPEN').toUpperCase(),
|
|
172
173
|
},
|
|
173
174
|
ci: buildCi(state.checks, requiredSet),
|
|
175
|
+
// The authoritative required-check source (`protection` or null).
|
|
176
|
+
requiredSource: adapter.lastRequiredSource || null,
|
|
174
177
|
branch: {
|
|
175
178
|
ahead: divergence.ahead || 0,
|
|
176
179
|
behind: divergence.behind || 0,
|
|
@@ -35,14 +35,29 @@ function sanitize(part) {
|
|
|
35
35
|
return String(part || '').replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the one journal authority shared by every worktree. Standard repos
|
|
40
|
+
* use `<main-root>/.forge/pr-monitor`; a nonstandard common dir centralizes
|
|
41
|
+
* beneath that shared metadata directory. No common dir preserves legacy use.
|
|
42
|
+
*/
|
|
43
|
+
function resolveJournalRoot({ root, gitCommonDir }) {
|
|
44
|
+
const fallbackRoot = path.resolve(root);
|
|
45
|
+
if (!gitCommonDir) return path.join(fallbackRoot, '.forge', 'pr-monitor');
|
|
46
|
+
const commonDir = path.resolve(gitCommonDir);
|
|
47
|
+
if (path.basename(commonDir).toLowerCase() === '.git') {
|
|
48
|
+
return path.join(path.dirname(commonDir), '.forge', 'pr-monitor');
|
|
49
|
+
}
|
|
50
|
+
return path.join(commonDir, 'forge', 'pr-monitor');
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/**
|
|
39
54
|
* Resolve (and create) the per-PR journal directory.
|
|
40
55
|
*
|
|
41
56
|
* @param {{ root: string, repo: string, pr: string|number }} ctx
|
|
42
57
|
* @returns {string} absolute directory path
|
|
43
58
|
*/
|
|
44
|
-
function journalDir({ root, repo, pr }) {
|
|
45
|
-
const dir = path.join(root,
|
|
59
|
+
function journalDir({ root, gitCommonDir, repo, pr }) {
|
|
60
|
+
const dir = path.join(resolveJournalRoot({ root, gitCommonDir }), `${sanitize(repo)}-${sanitize(pr)}`);
|
|
46
61
|
fs.mkdirSync(dir, { recursive: true });
|
|
47
62
|
return dir;
|
|
48
63
|
}
|
|
@@ -278,6 +293,7 @@ function watcherRunning(dir) {
|
|
|
278
293
|
|
|
279
294
|
module.exports = {
|
|
280
295
|
sanitize,
|
|
296
|
+
resolveJournalRoot,
|
|
281
297
|
journalDir,
|
|
282
298
|
journalPath,
|
|
283
299
|
snapshotPath,
|