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
|
@@ -17,6 +17,7 @@ const { buildMemoryProjectionMigration, memoryFtsDdl } = require('./migrations')
|
|
|
17
17
|
const { rankForPriorityLabel } = require('./taxonomy-validator');
|
|
18
18
|
const { isLeaseExpired } = require('./lease-enforcer');
|
|
19
19
|
const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
|
|
20
|
+
const { normalizeRecallHit } = require('../memory-recall');
|
|
20
21
|
|
|
21
22
|
const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
|
|
22
23
|
let probeCounter = 0;
|
|
@@ -800,6 +801,28 @@ function listKernelEventRows(runtime, db, entityType, entityId) {
|
|
|
800
801
|
);
|
|
801
802
|
}
|
|
802
803
|
|
|
804
|
+
// Bulk activity read (Slice C2, additive + read-only) across ALL entities, newest first,
|
|
805
|
+
// for `forge insights`. `since` is an optional ISO cutoff (created_at >= since); `limit`
|
|
806
|
+
// bounds the row count (default 1000). Imported beads interactions live here as
|
|
807
|
+
// `beads.interaction.<kind>` events, so insights derives interaction patterns from this
|
|
808
|
+
// instead of the retired legacy interactions log. Creates/migrates nothing.
|
|
809
|
+
function listRecentKernelEventRows(runtime, db, since, limit) {
|
|
810
|
+
const params = [];
|
|
811
|
+
let where = '';
|
|
812
|
+
if (since) {
|
|
813
|
+
where = 'WHERE created_at >= ?';
|
|
814
|
+
params.push(since);
|
|
815
|
+
}
|
|
816
|
+
const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 1000;
|
|
817
|
+
params.push(cap);
|
|
818
|
+
return allParams(
|
|
819
|
+
runtime,
|
|
820
|
+
db,
|
|
821
|
+
`SELECT * FROM kernel_events ${where} ORDER BY created_at DESC LIMIT ?`,
|
|
822
|
+
params,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
803
826
|
// Look up the committed event for an idempotency key (the duplicate-replay probe).
|
|
804
827
|
// The broker calls this unconditionally inside a Promise.all even for keyless
|
|
805
828
|
// events, so guard a falsy key up front rather than binding undefined.
|
|
@@ -2049,6 +2072,17 @@ function buildMemoryFtsMatch(query) {
|
|
|
2049
2072
|
return tokens.map(token => `"${token}"`).join(' AND ');
|
|
2050
2073
|
}
|
|
2051
2074
|
|
|
2075
|
+
// Like buildMemoryFtsMatch but OR-joins the tokens: a natural-language prompt matches a note
|
|
2076
|
+
// containing ANY of its keywords, not EVERY one. Used ONLY by the relevance-only SCORED read
|
|
2077
|
+
// (the per-turn recall hook) — a raw prompt ("why is my forge push taking so long") token-ANDed
|
|
2078
|
+
// required every word in one note and matched nothing (0% recall). Each token is double-quoted
|
|
2079
|
+
// for FTS5 safety (tokens may be non-Latin unicode). Returns '' when the query has no tokens.
|
|
2080
|
+
function buildMemoryFtsMatchOr(query) {
|
|
2081
|
+
const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
|
|
2082
|
+
if (!tokens || tokens.length === 0) return '';
|
|
2083
|
+
return tokens.map(token => `"${token}"`).join(' OR ');
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2052
2086
|
// BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
|
|
2053
2087
|
// rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
|
|
2054
2088
|
// query falls back to recent entries so `recall` never returns a bare full dump.
|
|
@@ -2070,6 +2104,105 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
|
2070
2104
|
).map(memoryRowToEntry);
|
|
2071
2105
|
}
|
|
2072
2106
|
|
|
2107
|
+
// Relevance-ONLY BM25 recall that exposes the raw bm25 score on each entry. The
|
|
2108
|
+
// per-turn auto-recall hook needs the score to apply a relevance FLOOR (inject nothing
|
|
2109
|
+
// when nothing clears the bar) — ordinal rank can't express "nothing was relevant".
|
|
2110
|
+
// Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
|
|
2111
|
+
// fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
|
|
2112
|
+
// returns more-negative for stronger matches, so rows come back best (lowest) first.
|
|
2113
|
+
function confirmedMemorySql(alias) {
|
|
2114
|
+
return `(EXISTS (
|
|
2115
|
+
SELECT 1 FROM json_each(${alias}.tags_json)
|
|
2116
|
+
WHERE lower(json_each.value) = 'trust:confirmed'
|
|
2117
|
+
) OR (${alias}.source_agent = 'forge remember'
|
|
2118
|
+
AND json_type(${alias}.value_json) = 'text'
|
|
2119
|
+
AND NOT EXISTS (
|
|
2120
|
+
SELECT 1 FROM json_each(${alias}.tags_json)
|
|
2121
|
+
WHERE lower(json_each.value) LIKE 'trust:%'
|
|
2122
|
+
OR lower(json_each.value) = 'forge:auto-capture'
|
|
2123
|
+
)))`;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function projectMemoryScopeSql(alias) {
|
|
2127
|
+
return `(${alias}.scope IS NULL OR ${alias}.scope = 'project' OR ${alias}.scope = ?)`;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function suggestedFreshnessCutoff(now) {
|
|
2131
|
+
const timestamp = Date.parse(now || new Date().toISOString());
|
|
2132
|
+
return new Date(timestamp - (7 * 24 * 60 * 60 * 1000)).toISOString();
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
|
|
2136
|
+
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2137
|
+
// keyword-OR (NOT the token-AND of searchMemoryRowsRanked): this relevance-only read backs
|
|
2138
|
+
// the per-turn recall hook, where a natural-language prompt must match on ANY keyword.
|
|
2139
|
+
const match = buildMemoryFtsMatchOr(query);
|
|
2140
|
+
if (!match) {
|
|
2141
|
+
return [];
|
|
2142
|
+
}
|
|
2143
|
+
const projectId = options.projectId;
|
|
2144
|
+
if (typeof projectId !== 'string' || !projectId) return [];
|
|
2145
|
+
const cutoff = suggestedFreshnessCutoff(options.now);
|
|
2146
|
+
const excludeKeys = Array.isArray(options.excludeKeys)
|
|
2147
|
+
? [...new Set(options.excludeKeys.filter(key => typeof key === 'string'))].slice(0, 256)
|
|
2148
|
+
: [];
|
|
2149
|
+
const seenSql = excludeKeys.length > 0
|
|
2150
|
+
? `AND m.key NOT IN (${excludeKeys.map(() => '?').join(', ')})`
|
|
2151
|
+
: '';
|
|
2152
|
+
const confirmed = confirmedMemorySql('m');
|
|
2153
|
+
const supersederConfirmed = confirmedMemorySql('s');
|
|
2154
|
+
// Expand eligible supersession edges once. A correlated json_each scan repeated the
|
|
2155
|
+
// entire memory table for every FTS candidate and dominated the 1,000-row prompt path.
|
|
2156
|
+
// Recall temporarily waits less than the connection default so a real lock cannot outlive
|
|
2157
|
+
// the prompt hook; the finally block restores the caller's normal connection behavior.
|
|
2158
|
+
const previousBusyTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
|
|
2159
|
+
const requestedBusyTimeout = Number(options.busyTimeoutMs);
|
|
2160
|
+
const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
|
|
2161
|
+
? Math.floor(requestedBusyTimeout)
|
|
2162
|
+
: Math.min(previousBusyTimeout, 2_500);
|
|
2163
|
+
if (busyTimeout !== previousBusyTimeout) {
|
|
2164
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${busyTimeout};`);
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
return allParams(
|
|
2168
|
+
runtime,
|
|
2169
|
+
db,
|
|
2170
|
+
`WITH eligible_superseders AS MATERIALIZED (
|
|
2171
|
+
SELECT superseded.value AS memory_key,
|
|
2172
|
+
CASE WHEN ${supersederConfirmed} THEN 1 ELSE 0 END AS is_confirmed
|
|
2173
|
+
FROM kernel_memories s,
|
|
2174
|
+
json_each(COALESCE(s.supersedes_json, '[]')) superseded
|
|
2175
|
+
WHERE ${projectMemoryScopeSql('s')}
|
|
2176
|
+
AND (${supersederConfirmed} OR s.updated_at >= ?)
|
|
2177
|
+
)
|
|
2178
|
+
SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
|
|
2179
|
+
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2180
|
+
WHERE kernel_memories_fts MATCH ?
|
|
2181
|
+
AND ${projectMemoryScopeSql('m')}
|
|
2182
|
+
AND (${confirmed} OR m.updated_at >= ?)
|
|
2183
|
+
${seenSql}
|
|
2184
|
+
AND NOT EXISTS (
|
|
2185
|
+
SELECT 1
|
|
2186
|
+
FROM eligible_superseders superseder
|
|
2187
|
+
WHERE superseder.memory_key = m.key
|
|
2188
|
+
AND (superseder.is_confirmed = 1 OR NOT ${confirmed})
|
|
2189
|
+
)
|
|
2190
|
+
ORDER BY bm25(kernel_memories_fts),
|
|
2191
|
+
CASE WHEN ${confirmed} THEN 0 ELSE 1 END,
|
|
2192
|
+
m.source_agent ASC, m.updated_at DESC, m.key ASC
|
|
2193
|
+
LIMIT ?`,
|
|
2194
|
+
[projectId, cutoff, match, projectId, cutoff, ...excludeKeys, capped],
|
|
2195
|
+
).map(row => normalizeRecallHit(
|
|
2196
|
+
{ ...memoryRowToEntry(row), score: row.__score },
|
|
2197
|
+
projectId,
|
|
2198
|
+
));
|
|
2199
|
+
} finally {
|
|
2200
|
+
if (busyTimeout !== previousBusyTimeout) {
|
|
2201
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${previousBusyTimeout};`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2073
2206
|
function closeDatabase(db) {
|
|
2074
2207
|
if (db && typeof db.close === 'function') {
|
|
2075
2208
|
db.close();
|
|
@@ -2085,12 +2218,17 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2085
2218
|
// synchronous project-memory facade writes WITHOUT first running migrations. Lazily
|
|
2086
2219
|
// ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
|
|
2087
2220
|
// plus a busy_timeout for the second connection the issue backend may hold open.
|
|
2088
|
-
function ensureMemorySchema(database) {
|
|
2221
|
+
function ensureMemorySchema(database, busyTimeoutMs) {
|
|
2089
2222
|
if (memorySchemaEnsured) return;
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2223
|
+
const requestedBusyTimeout = Number(busyTimeoutMs);
|
|
2224
|
+
const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
|
|
2225
|
+
? Math.floor(requestedBusyTimeout)
|
|
2226
|
+
: 5_000;
|
|
2227
|
+
execSql(runtime, database, `PRAGMA busy_timeout=${busyTimeout};`);
|
|
2228
|
+
try {
|
|
2229
|
+
for (const statement of buildMemoryProjectionMigration().apply) {
|
|
2230
|
+
execSql(runtime, database, statement);
|
|
2231
|
+
}
|
|
2094
2232
|
// FTS5 recall index (migration 008): create the virtual table + sync triggers
|
|
2095
2233
|
// idempotently so a synchronous memory write stays indexed without a prior
|
|
2096
2234
|
// broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
|
|
@@ -2101,20 +2239,25 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2101
2239
|
// Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
|
|
2102
2240
|
// external-content FTS5 table `count(*)` returns the CONTENT row count, not the
|
|
2103
2241
|
// indexed-doc count, so it can never reveal an un-backfilled index.
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2242
|
+
const ftsDdl = memoryFtsDdl();
|
|
2243
|
+
const ftsExisted = Number(queryOne(
|
|
2244
|
+
runtime,
|
|
2245
|
+
database,
|
|
2246
|
+
"SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
|
|
2247
|
+
).count) > 0;
|
|
2248
|
+
execSql(runtime, database, ftsDdl.create);
|
|
2249
|
+
for (const trigger of ftsDdl.triggers) {
|
|
2250
|
+
execSql(runtime, database, trigger);
|
|
2251
|
+
}
|
|
2252
|
+
if (!ftsExisted) {
|
|
2253
|
+
execSql(runtime, database, ftsDdl.rebuild);
|
|
2254
|
+
}
|
|
2255
|
+
memorySchemaEnsured = true;
|
|
2256
|
+
} finally {
|
|
2257
|
+
if (busyTimeout !== 5_000) {
|
|
2258
|
+
execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
|
|
2259
|
+
}
|
|
2116
2260
|
}
|
|
2117
|
-
memorySchemaEnsured = true;
|
|
2118
2261
|
}
|
|
2119
2262
|
|
|
2120
2263
|
function resolveDatabasePath(config) {
|
|
@@ -2181,6 +2324,124 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2181
2324
|
[`${escaped}%`, limit],
|
|
2182
2325
|
);
|
|
2183
2326
|
},
|
|
2327
|
+
// Open PRs under shepherd for one repo (autonomous-shepherd design §3.4): the
|
|
2328
|
+
// reconciler's "open PRs in this repo" read, keyed by git_common_dir so every
|
|
2329
|
+
// worktree shares one view. Parameterized (git_common_dir is a filesystem path —
|
|
2330
|
+
// never interpolate it), covered by idx_pr_common_dir_state_repo_number. Ordered so
|
|
2331
|
+
// the result is deterministic. `context` is part of the broker contract but unused
|
|
2332
|
+
// by this direct SELECT (prefixed `_` for eslint no-unused-vars).
|
|
2333
|
+
async listOpenPrs(gitCommonDir, _context = {}, config = {}) {
|
|
2334
|
+
return allParams(
|
|
2335
|
+
runtime, getDatabase(config),
|
|
2336
|
+
"SELECT * FROM kernel_pr WHERE git_common_dir = ? AND state = 'open' ORDER BY repo ASC, number ASC",
|
|
2337
|
+
[gitCommonDir],
|
|
2338
|
+
);
|
|
2339
|
+
},
|
|
2340
|
+
// --- kernel_pr WRITE path (autonomous-shepherd design §5a). pr rows are DERIVED
|
|
2341
|
+
// reconcile state (reconstructable from GitHub), not audit-critical issue authority,
|
|
2342
|
+
// so they take a DIRECT idempotent upsert — NOT the event-sourced guarded path
|
|
2343
|
+
// (applyAcceptedIssueMutation). All target the physical `kernel_pr` table (matching
|
|
2344
|
+
// the listOpenPrs read) and are parameterized (git_common_dir/branch/head_sha are
|
|
2345
|
+
// externally-influenced values — never interpolate). `context` is part of the broker
|
|
2346
|
+
// contract but unused by these direct writes (prefixed `_` for eslint no-unused-vars).
|
|
2347
|
+
//
|
|
2348
|
+
// Register/refresh a PR row keyed by (git_common_dir, repo, number). Idempotent via
|
|
2349
|
+
// ON CONFLICT on the unique idx_pr_common_dir_repo_number: a re-upsert updates the
|
|
2350
|
+
// mutable columns and coalesces soft links (a later null never clobbers an existing
|
|
2351
|
+
// issue_id/worktree_id). registered_at is set on INSERT only; state defaults 'open'.
|
|
2352
|
+
async upsertPr(row, _context = {}, config = {}) {
|
|
2353
|
+
const id = row.id || randomUUID();
|
|
2354
|
+
const registeredAt = row.registered_at || new Date().toISOString();
|
|
2355
|
+
const state = row.state || 'open';
|
|
2356
|
+
runParams(
|
|
2357
|
+
runtime, getDatabase(config),
|
|
2358
|
+
`INSERT INTO kernel_pr
|
|
2359
|
+
(id, git_common_dir, repo, number, issue_id, worktree_id, branch, head_sha, journal_ptr, state, registered_at)
|
|
2360
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2361
|
+
ON CONFLICT(git_common_dir, repo, number) DO UPDATE SET
|
|
2362
|
+
head_sha = excluded.head_sha,
|
|
2363
|
+
branch = excluded.branch,
|
|
2364
|
+
issue_id = coalesce(excluded.issue_id, kernel_pr.issue_id),
|
|
2365
|
+
worktree_id = coalesce(excluded.worktree_id, kernel_pr.worktree_id),
|
|
2366
|
+
-- journal_ptr is a soft link: coalesce it (like issue_id/worktree_id) so a
|
|
2367
|
+
-- head-only refresh that omits journalPtr never severs the ledger↔journal
|
|
2368
|
+
-- link with NULL. (Codex review, PR #426.)
|
|
2369
|
+
journal_ptr = coalesce(excluded.journal_ptr, kernel_pr.journal_ptr),
|
|
2370
|
+
-- REOPEN semantics: upsertPr is only ever called for PRs GitHub reports as
|
|
2371
|
+
-- OPEN, so re-registering a previously retired row (a reopened PR) must flip
|
|
2372
|
+
-- it back to open and clear retired_at — else listOpenPrs (state='open') would
|
|
2373
|
+
-- keep the reopened PR invisible forever. (Codex review, PR #426.)
|
|
2374
|
+
state = 'open',
|
|
2375
|
+
retired_at = NULL,
|
|
2376
|
+
-- A new commit INVALIDATES the prior verdict: when the head advances to a
|
|
2377
|
+
-- different non-null sha, clear verdict/source/at so a verdict computed
|
|
2378
|
+
-- against the OLD head is never presented as fresh for the new head (and the
|
|
2379
|
+
-- freshest-head guard in updatePrVerdict keeps intact evidence). IS NOT is the
|
|
2380
|
+
-- null-safe distinctness test; a headless refresh (excluded.head_sha NULL)
|
|
2381
|
+
-- never clears. (Codex review, PR #426.)
|
|
2382
|
+
verdict = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict END,
|
|
2383
|
+
verdict_source = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_source END,
|
|
2384
|
+
verdict_at = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_at END`,
|
|
2385
|
+
[
|
|
2386
|
+
id,
|
|
2387
|
+
row.git_common_dir,
|
|
2388
|
+
row.repo,
|
|
2389
|
+
row.number,
|
|
2390
|
+
row.issue_id ?? null,
|
|
2391
|
+
row.worktree_id ?? null,
|
|
2392
|
+
row.branch ?? null,
|
|
2393
|
+
row.head_sha ?? null,
|
|
2394
|
+
row.journal_ptr ?? null,
|
|
2395
|
+
state,
|
|
2396
|
+
registeredAt,
|
|
2397
|
+
],
|
|
2398
|
+
);
|
|
2399
|
+
return { ok: true, id };
|
|
2400
|
+
},
|
|
2401
|
+
// The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
|
|
2402
|
+
// PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
|
|
2403
|
+
// DISCARDED, not written (kills stale 9d35c14b at the write). A non-local (Actions
|
|
2404
|
+
// backstop) write lands only when its head_sha matches the row's current head (or the
|
|
2405
|
+
// row has none yet); a `local` verdict is computed live against the current head and
|
|
2406
|
+
// is always authoritative, so it bypasses the head match.
|
|
2407
|
+
async updatePrVerdict(key, patch = {}, _context = {}, config = {}) {
|
|
2408
|
+
const headSha = patch.head_sha ?? null;
|
|
2409
|
+
const source = patch.verdict_source ?? null;
|
|
2410
|
+
runParams(
|
|
2411
|
+
runtime, getDatabase(config),
|
|
2412
|
+
`UPDATE kernel_pr SET verdict = ?, verdict_source = ?, verdict_at = ?, head_sha = ?
|
|
2413
|
+
WHERE git_common_dir = ? AND repo = ? AND number = ?
|
|
2414
|
+
AND (head_sha IS NULL OR head_sha = ? OR ? = 'local')`,
|
|
2415
|
+
[
|
|
2416
|
+
patch.verdict ?? null,
|
|
2417
|
+
source,
|
|
2418
|
+
patch.verdict_at ?? null,
|
|
2419
|
+
headSha,
|
|
2420
|
+
key.git_common_dir,
|
|
2421
|
+
key.repo,
|
|
2422
|
+
key.number,
|
|
2423
|
+
headSha,
|
|
2424
|
+
source,
|
|
2425
|
+
],
|
|
2426
|
+
);
|
|
2427
|
+
return { ok: true };
|
|
2428
|
+
},
|
|
2429
|
+
// Retire a PR row (merged/closed): flip state + stamp retired_at so it drops out of
|
|
2430
|
+
// the open-PR read while the reconcile history is retained.
|
|
2431
|
+
async retirePr(key, patch = {}, _context = {}, config = {}) {
|
|
2432
|
+
runParams(
|
|
2433
|
+
runtime, getDatabase(config),
|
|
2434
|
+
'UPDATE kernel_pr SET state = ?, retired_at = ? WHERE git_common_dir = ? AND repo = ? AND number = ?',
|
|
2435
|
+
[
|
|
2436
|
+
patch.state ?? 'closed',
|
|
2437
|
+
patch.retired_at ?? new Date().toISOString(),
|
|
2438
|
+
key.git_common_dir,
|
|
2439
|
+
key.repo,
|
|
2440
|
+
key.number,
|
|
2441
|
+
],
|
|
2442
|
+
);
|
|
2443
|
+
return { ok: true };
|
|
2444
|
+
},
|
|
2184
2445
|
// --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
|
|
2185
2446
|
// `context` is part of the broker contract but unused by these direct SQL
|
|
2186
2447
|
// reads/writes (prefixed `_` for eslint no-unused-vars).
|
|
@@ -2193,6 +2454,11 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2193
2454
|
async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
|
|
2194
2455
|
return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
|
|
2195
2456
|
},
|
|
2457
|
+
// Additive bulk read for `forge insights` (Slice C2). Read-only; NOT part of the
|
|
2458
|
+
// GUARDED_DRIVER_METHODS write-path contract, so existing driver stubs stay valid.
|
|
2459
|
+
async listRecentKernelEvents({ since = null, limit = null } = {}, _context = {}, config = {}) {
|
|
2460
|
+
return listRecentKernelEventRows(runtime, getDatabase(config), since, limit);
|
|
2461
|
+
},
|
|
2196
2462
|
async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
|
|
2197
2463
|
return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
|
|
2198
2464
|
},
|
|
@@ -2313,6 +2579,14 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2313
2579
|
ensureMemorySchema(database);
|
|
2314
2580
|
return searchMemoryRowsRanked(runtime, database, query, limit);
|
|
2315
2581
|
},
|
|
2582
|
+
// Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
|
|
2583
|
+
// caller can apply a relevance floor. A no-match/empty query returns [] (no recency
|
|
2584
|
+
// fallback). Used by the per-turn memory-recall hook.
|
|
2585
|
+
searchMemoriesRankedScored(query, limit, config = {}) {
|
|
2586
|
+
const database = getDatabase(config);
|
|
2587
|
+
ensureMemorySchema(database, config.busyTimeoutMs);
|
|
2588
|
+
return searchMemoryRowsRankedScored(runtime, database, query, limit, config);
|
|
2589
|
+
},
|
|
2316
2590
|
// The newest `limit` entries (default recall with no query). `options.agents` scopes
|
|
2317
2591
|
// the read to a source_agent allow-list (e.g. human `remember` notes only).
|
|
2318
2592
|
recentMemories(limit, options = {}, config = {}) {
|
package/lib/lefthook-wiring.js
CHANGED
|
@@ -55,6 +55,20 @@ pre-push:
|
|
|
55
55
|
run: npm test --if-present
|
|
56
56
|
`;
|
|
57
57
|
|
|
58
|
+
// The same config MINUS the TDD pre-commit job, written when the project already has its own
|
|
59
|
+
// pre-commit TDD/coupling gate (kernel 5b425a85 / 2699b234). Stacking a second gate on the same
|
|
60
|
+
// commit is the reported bug, so Forge defers on pre-commit but still wires pre-push.
|
|
61
|
+
const FORGE_USER_LEFTHOOK_YML_NO_TDD = `# Forge git hooks — installed by \`forge setup\` / \`forge init\`.
|
|
62
|
+
# Your project already has its own pre-commit TDD/coupling gate, so Forge did NOT add a second
|
|
63
|
+
# one here. To use Forge's gate instead, remove yours and run: forge gate enable rail.tdd_intent
|
|
64
|
+
# Edit freely to add your own checks — Forge only replaces a fully-commented stub.
|
|
65
|
+
|
|
66
|
+
pre-push:
|
|
67
|
+
commands:
|
|
68
|
+
tests:
|
|
69
|
+
run: npm test --if-present
|
|
70
|
+
`;
|
|
71
|
+
|
|
58
72
|
/**
|
|
59
73
|
* A fresh `lefthook install` (run by lefthook's own npm postinstall) drops a stock
|
|
60
74
|
* EXAMPLE lefthook.yml with every hook commented out. That disposable stub used to
|
|
@@ -221,10 +235,13 @@ npm test --if-present || exit 1
|
|
|
221
235
|
* `skipped`) rather than destroyed.
|
|
222
236
|
*
|
|
223
237
|
* @param {string} projectRoot - Absolute path to the project root.
|
|
238
|
+
* @param {{ skipHooks?: string[] }} [options] - Hook names to leave alone entirely. `forge setup`
|
|
239
|
+
* passes `['pre-commit']` when the project already has its own pre-commit TDD/coupling gate,
|
|
240
|
+
* so Forge defers instead of stacking a second one (kernel 5b425a85 / 2699b234).
|
|
224
241
|
* @returns {{ installed: boolean, method?: string, hooksDir?: string,
|
|
225
242
|
* written?: string[], skipped?: string[], reason?: string }}
|
|
226
243
|
*/
|
|
227
|
-
function installNativeGitHooks(projectRoot) {
|
|
244
|
+
function installNativeGitHooks(projectRoot, options = {}) {
|
|
228
245
|
const hooksDir = resolveGitHooksDir(projectRoot);
|
|
229
246
|
if (!hooksDir) {
|
|
230
247
|
return { installed: false, reason: 'not-a-git-repo' };
|
|
@@ -235,9 +252,11 @@ function installNativeGitHooks(projectRoot) {
|
|
|
235
252
|
return { installed: false, reason: `hooks-dir-unwritable: ${error.message}` };
|
|
236
253
|
}
|
|
237
254
|
|
|
255
|
+
const skipHooks = new Set(Array.isArray(options.skipHooks) ? options.skipHooks : []);
|
|
238
256
|
const written = [];
|
|
239
257
|
const skipped = [];
|
|
240
258
|
for (const [name, body] of Object.entries(NATIVE_HOOK_BODIES)) {
|
|
259
|
+
if (skipHooks.has(name)) continue;
|
|
241
260
|
const outcome = writeNativeHook(path.join(hooksDir, name), body);
|
|
242
261
|
(outcome === 'written' ? written : skipped).push(name);
|
|
243
262
|
}
|
|
@@ -406,6 +425,7 @@ function verifyHooksActive(projectRoot) {
|
|
|
406
425
|
module.exports = {
|
|
407
426
|
FORGE_NATIVE_HOOK_SENTINEL,
|
|
408
427
|
FORGE_USER_LEFTHOOK_YML,
|
|
428
|
+
FORGE_USER_LEFTHOOK_YML_NO_TDD,
|
|
409
429
|
forgeShouldWriteLefthookConfig,
|
|
410
430
|
resolveGitHooksDir,
|
|
411
431
|
installNativeGitHooks,
|
package/lib/memory/router.js
CHANGED
|
@@ -242,6 +242,21 @@ function legacyJsonlPath(projectRoot) {
|
|
|
242
242
|
return path.join(projectRoot, ...LEGACY_JSONL_RELATIVE);
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
function legacyTags(parsed) {
|
|
246
|
+
const tags = Array.isArray(parsed.tags)
|
|
247
|
+
? parsed.tags.filter(tag => typeof tag === 'string')
|
|
248
|
+
: [];
|
|
249
|
+
if (
|
|
250
|
+
typeof parsed.type === 'string'
|
|
251
|
+
&& /^[a-z0-9][a-z0-9-]{0,63}$/i.test(parsed.type)
|
|
252
|
+
&& !tags.some(tag => tag.startsWith('type:'))
|
|
253
|
+
) {
|
|
254
|
+
tags.push(`type:${parsed.type.toLowerCase()}`);
|
|
255
|
+
}
|
|
256
|
+
if (!tags.some(tag => tag.startsWith('trust:'))) tags.push('trust:suggested');
|
|
257
|
+
return tags;
|
|
258
|
+
}
|
|
259
|
+
|
|
245
260
|
// A STABLE key for a legacy record that lacks an `id`, derived from its content — so a
|
|
246
261
|
// re-run (or a failed rename) upserts the same row instead of double-inserting under a
|
|
247
262
|
// fresh random UUID.
|
|
@@ -292,7 +307,7 @@ function migrateJsonlNotesOnce(projectRoot, options = {}) {
|
|
|
292
307
|
key: typeof parsed.id === 'string' && parsed.id ? parsed.id : legacyContentKey(parsed),
|
|
293
308
|
value: parsed.note,
|
|
294
309
|
sourceAgent: IMPORT_SOURCE_AGENT,
|
|
295
|
-
tags:
|
|
310
|
+
tags: legacyTags(parsed),
|
|
296
311
|
};
|
|
297
312
|
if (typeof parsed.timestamp === 'string' && parsed.timestamp && !Number.isNaN(Date.parse(parsed.timestamp))) {
|
|
298
313
|
entry.timestamp = parsed.timestamp;
|
package/lib/memory-digest.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
const { applyBudget, buildSection, estimateTokens } = require('./orientation');
|
|
23
23
|
const { fenceUntrusted } = require('./untrusted-content');
|
|
24
24
|
const { collectInbox, inboxSection } = require('./inbox');
|
|
25
|
+
const { memoryTrustStatus } = require('./memory-recall');
|
|
25
26
|
|
|
26
27
|
const DEFAULT_DIGEST_BUDGET_TOKENS = 400;
|
|
27
28
|
const DEFAULT_NOTE_LIMIT = 5;
|
|
@@ -103,7 +104,13 @@ async function collectDigestData(projectRoot, opts = {}) {
|
|
|
103
104
|
/** `- [date ]note` for a recall note. */
|
|
104
105
|
function formatNoteLine(note) {
|
|
105
106
|
const date = typeof note.timestamp === 'string' && note.timestamp ? `${note.timestamp.slice(0, 10)} ` : '';
|
|
106
|
-
|
|
107
|
+
const trust = memoryTrustStatus({
|
|
108
|
+
tags: note.tags,
|
|
109
|
+
sourceAgent: note.sourceAgent,
|
|
110
|
+
value: note.machine ? {} : note.note,
|
|
111
|
+
});
|
|
112
|
+
const sourceAgent = note.sourceAgent || 'unknown';
|
|
113
|
+
return `- [source=${sourceAgent} trust=${trust} updated=${date.trim() || 'unknown'}] ${note.note}`;
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
/** `- [label] title` for an issue row (title/id defensively resolved). */
|
|
@@ -112,18 +119,39 @@ function formatIssueLine(label, issue) {
|
|
|
112
119
|
return `- [${label}] ${title}`;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
/** Build
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
/** Build separate confirmed/suggested note sections, skipping entries too large to fit. */
|
|
123
|
+
function notesSections(notes, budgetTokens) {
|
|
124
|
+
const eligible = notes
|
|
125
|
+
.map(note => ({ note, line: formatNoteLine(note) }))
|
|
126
|
+
.filter(entry => estimateTokens(entry.line) <= budgetTokens);
|
|
127
|
+
const groups = [
|
|
128
|
+
{ id: 'digest_notes', title: 'Confirmed memory', trust: 'confirmed', priority: 10 },
|
|
129
|
+
{
|
|
130
|
+
id: 'digest_suggested_memory',
|
|
131
|
+
title: 'Suggested memory — verify before relying',
|
|
132
|
+
trust: 'suggested',
|
|
133
|
+
priority: 11,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
return groups.map(group => {
|
|
137
|
+
const content = eligible
|
|
138
|
+
.filter(({ note }) => memoryTrustStatus({
|
|
139
|
+
tags: note.tags,
|
|
140
|
+
sourceAgent: note.sourceAgent,
|
|
141
|
+
value: note.machine ? {} : note.note,
|
|
142
|
+
}) === group.trust)
|
|
143
|
+
.map(entry => entry.line)
|
|
144
|
+
.join('\n');
|
|
145
|
+
if (!content) return null;
|
|
146
|
+
return buildSection({
|
|
147
|
+
id: group.id,
|
|
148
|
+
title: group.title,
|
|
149
|
+
content,
|
|
150
|
+
priority: group.priority,
|
|
151
|
+
preserve: false,
|
|
152
|
+
untrustedSource: 'memory',
|
|
153
|
+
});
|
|
154
|
+
}).filter(Boolean);
|
|
127
155
|
}
|
|
128
156
|
|
|
129
157
|
/**
|
|
@@ -161,13 +189,17 @@ function buildMemoryDigest(data = {}, options = {}) {
|
|
|
161
189
|
const ready = Array.isArray(data.ready) ? data.ready : [];
|
|
162
190
|
const claimed = Array.isArray(data.claimed) ? data.claimed : [];
|
|
163
191
|
const inbox = Array.isArray(data.inbox) ? data.inbox : [];
|
|
192
|
+
const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
|
|
164
193
|
|
|
165
194
|
// Inbox (priority 5) is a THIRD section beside notes + issues; a fresh human directive
|
|
166
195
|
// outranks stale notes (10) and the agent's own issue list (20) under budget pressure.
|
|
167
|
-
const sections = [
|
|
196
|
+
const sections = [
|
|
197
|
+
inboxSection(inbox),
|
|
198
|
+
...notesSections(notes, budgetTokens),
|
|
199
|
+
issuesSection(ready, claimed),
|
|
200
|
+
].filter(Boolean);
|
|
168
201
|
if (!sections.length) return { text: '', empty: true, tokens: 0 };
|
|
169
202
|
|
|
170
|
-
const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
|
|
171
203
|
const budgeted = applyBudget(sections, budgetTokens);
|
|
172
204
|
const body = budgeted.sections
|
|
173
205
|
.filter(section => section.content)
|