akm-cli 0.8.0-rc2 → 0.8.0
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/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
- package/README.md +22 -6
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +93 -3
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2141 -1268
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +20 -12
- package/dist/commands/agent-support.js +11 -5
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +129 -517
- package/dist/commands/consolidate.js +1533 -144
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +5 -3
- package/dist/commands/distill.js +906 -100
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +3 -0
- package/dist/commands/events.js +3 -0
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +204 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +260 -5
- package/dist/commands/health.js +977 -51
- package/dist/commands/help/help-accept.md +6 -3
- package/dist/commands/help/help-improve.md +36 -8
- package/dist/commands/help/help-proposals.md +7 -4
- package/dist/commands/help/help-reject.md +5 -2
- package/dist/commands/history.js +51 -16
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +236 -0
- package/dist/commands/improve-profiles.js +184 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +1725 -332
- package/dist/commands/info.js +3 -0
- package/dist/commands/init.js +49 -1
- package/dist/commands/installed-stashes.js +6 -23
- package/dist/commands/knowledge.js +3 -0
- package/dist/commands/lint/agent-linter.js +3 -0
- package/dist/commands/lint/base-linter.js +199 -5
- package/dist/commands/lint/command-linter.js +3 -0
- package/dist/commands/lint/default-linter.js +3 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +92 -3
- package/dist/commands/lint/knowledge-linter.js +3 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +3 -0
- package/dist/commands/lint/registry.js +3 -0
- package/dist/commands/lint/skill-linter.js +3 -0
- package/dist/commands/lint/task-linter.js +15 -12
- package/dist/commands/lint/types.js +3 -0
- package/dist/commands/lint/workflow-linter.js +3 -0
- package/dist/commands/lint.js +3 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal-drain-policies.js +128 -0
- package/dist/commands/proposal-drain.js +477 -0
- package/dist/commands/proposal.js +60 -6
- package/dist/commands/propose.js +24 -19
- package/dist/commands/reflect.js +1004 -94
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +3 -0
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +15 -6
- package/dist/commands/schema-repair.js +88 -15
- package/dist/commands/search.js +99 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +32 -13
- package/dist/commands/source-add.js +7 -35
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +3 -0
- package/dist/commands/tasks.js +161 -95
- package/dist/commands/url-checker.js +3 -0
- package/dist/core/action-contributors.js +3 -0
- package/dist/core/asset-ref.js +13 -2
- package/dist/core/asset-registry.js +9 -2
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +61 -5
- package/dist/core/common.js +93 -5
- package/dist/core/concurrent.js +3 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +558 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +366 -1077
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +31 -25
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -10
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +3 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +142 -14
- package/dist/core/parse.js +3 -0
- package/dist/core/paths.js +218 -50
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +11 -3
- package/dist/core/proposals.js +464 -5
- package/dist/core/state-db.js +349 -56
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +3 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +7 -2
- package/dist/core/write-source.js +12 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +136 -28
- package/dist/indexer/db.js +661 -166
- package/dist/indexer/ensure-index.js +3 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +162 -40
- package/dist/indexer/graph-db.js +241 -51
- package/dist/indexer/graph-dedup.js +3 -7
- package/dist/indexer/graph-extraction.js +242 -149
- package/dist/indexer/index-context.js +3 -9
- package/dist/indexer/indexer.js +84 -14
- package/dist/indexer/llm-cache.js +24 -19
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +184 -11
- package/dist/indexer/memory-inference.js +94 -50
- package/dist/indexer/metadata-contributors.js +3 -0
- package/dist/indexer/metadata.js +110 -50
- package/dist/indexer/path-resolver.js +3 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +134 -7
- package/dist/indexer/ranking.js +8 -1
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +91 -2
- package/dist/indexer/search-source.js +20 -1
- package/dist/indexer/semantic-status.js +4 -1
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +3 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +121 -401
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +6 -14
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +3 -0
- package/dist/integrations/agent/prompts.js +137 -8
- package/dist/integrations/agent/runner.js +208 -0
- package/dist/integrations/agent/sdk-runner.js +8 -2
- package/dist/integrations/agent/spawn.js +54 -14
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +22 -51
- package/dist/integrations/session-logs/index.js +4 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +226 -0
- package/dist/integrations/session-logs/providers/opencode.js +231 -25
- package/dist/integrations/session-logs/types.js +3 -0
- package/dist/llm/call-ai.js +14 -26
- package/dist/llm/client.js +16 -2
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +92 -56
- package/dist/llm/graph-extract.js +401 -30
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +30 -2
- package/dist/llm/metadata-enhance.js +3 -7
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
- package/dist/output/cli-hints-full.md +60 -32
- package/dist/output/cli-hints-short.md +10 -7
- package/dist/output/cli-hints.js +5 -2
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +170 -194
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +105 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -549
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1059 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +12 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1329
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +11 -2
- package/dist/registry/providers/static-index.js +10 -1
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17767 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +306 -67
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +3 -11
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +171 -21
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +3 -0
- package/dist/tasks/backends/cron.js +3 -0
- package/dist/tasks/backends/exec-utils.js +3 -0
- package/dist/tasks/backends/index.js +3 -11
- package/dist/tasks/backends/launchd.js +3 -0
- package/dist/tasks/backends/schtasks.js +3 -0
- package/dist/tasks/parser.js +51 -38
- package/dist/tasks/resolveAkmBin.js +3 -0
- package/dist/tasks/runner.js +35 -9
- package/dist/tasks/schedule.js +20 -1
- package/dist/tasks/schema.js +5 -3
- package/dist/tasks/validator.js +6 -3
- package/dist/version.js +3 -0
- package/dist/wiki/wiki-templates.js +3 -0
- package/dist/wiki/wiki.js +3 -0
- package/dist/workflows/authoring.js +3 -0
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +3 -0
- package/dist/workflows/runs.js +18 -1
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +5 -9
- package/docs/README.md +7 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +57 -5
- package/docs/migration/v0.7-to-v0.8.md +1378 -0
- package/package.json +28 -11
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -385
- package/dist/commands/vault.js +0 -310
- package/dist/indexer/match-contributors.js +0 -141
- package/dist/integrations/agent/pipeline.js +0 -39
- package/dist/integrations/agent/runners.js +0 -31
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Built-in deterministic triage policy presets (Proposal-Queue Triage, §3.1).
|
|
6
|
+
*
|
|
7
|
+
* These are the *only* "rule schema" we ship. Custom needs are served by the
|
|
8
|
+
* single `--policy <path>` escape hatch, which zod-validates an external policy
|
|
9
|
+
* file — not by a config-embedded rule engine (see §9, rejected alternatives).
|
|
10
|
+
*
|
|
11
|
+
* | preset | accepts | rejects | leaves pending |
|
|
12
|
+
* |------------------|-----------------------------------------------------------|-------------|----------------------------------------------------|
|
|
13
|
+
* | `personal-stash` | extract (real content); reflect ≤80 lines; consolidate | empty diffs | consolidate mid-band, distill dups, contradictions |
|
|
14
|
+
* | `conservative` | small extract + consolidate only | empty diffs | everything else |
|
|
15
|
+
* | `manual` | nothing | empty diffs | everything else |
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { UsageError } from "../core/errors";
|
|
20
|
+
import { PROPOSAL_SOURCES } from "../core/proposals";
|
|
21
|
+
// Valid `generator` values for a drain rule are exactly the canonical proposal
|
|
22
|
+
// `source` values (see {@link PROPOSAL_SOURCES} in src/core/proposals.ts). The
|
|
23
|
+
// engine matches rules via `policy.accept.find(r => r.generator === proposal.source)`,
|
|
24
|
+
// so a generator that is not a real source can never match — it would be a
|
|
25
|
+
// silent permanent no-op. Validate against the closed set to surface typos.
|
|
26
|
+
const GeneratorSchema = z.enum(PROPOSAL_SOURCES, {
|
|
27
|
+
errorMap: () => ({
|
|
28
|
+
message: `must be one of the known proposal sources: ${PROPOSAL_SOURCES.join(", ")}`,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Built-in presets
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* `personal-stash` encodes the deterministic core of today's hand-rolled
|
|
36
|
+
* rubric (the editable `contradicted` memory). It is shipped as a preset, never
|
|
37
|
+
* hardcoded policy: edit a copy via `--policy <path>` to tune it.
|
|
38
|
+
*/
|
|
39
|
+
export const PERSONAL_STASH = {
|
|
40
|
+
name: "personal-stash",
|
|
41
|
+
accept: [
|
|
42
|
+
// Extract proposals carry freshly-pulled real content — accept when present.
|
|
43
|
+
{ generator: "extract", minContentLines: 1 },
|
|
44
|
+
// Reflect refinements: accept small ones; larger refinements defer to review.
|
|
45
|
+
{ generator: "reflect", maxDiffLines: 80 },
|
|
46
|
+
// Consolidate within the diff band; mid-band lands in `defer` below.
|
|
47
|
+
{ generator: "consolidate", maxDiffLines: 200 },
|
|
48
|
+
],
|
|
49
|
+
rejectEmpty: true,
|
|
50
|
+
// Mid-band consolidate, distill duplicates, and contradiction escalations are
|
|
51
|
+
// the irreducibly-semantic tail — deferred to the (Phase 3) judgment tier.
|
|
52
|
+
defer: ["consolidate", "distill"],
|
|
53
|
+
};
|
|
54
|
+
/** `conservative` accepts only small, low-risk extract + consolidate proposals. */
|
|
55
|
+
export const CONSERVATIVE = {
|
|
56
|
+
name: "conservative",
|
|
57
|
+
accept: [
|
|
58
|
+
{ generator: "extract", maxDiffLines: 80, minContentLines: 1 },
|
|
59
|
+
{ generator: "consolidate", maxDiffLines: 80 },
|
|
60
|
+
],
|
|
61
|
+
rejectEmpty: true,
|
|
62
|
+
defer: [],
|
|
63
|
+
};
|
|
64
|
+
/** `manual` accepts nothing; it only clears empty diffs. */
|
|
65
|
+
export const MANUAL = {
|
|
66
|
+
name: "manual",
|
|
67
|
+
accept: [],
|
|
68
|
+
rejectEmpty: true,
|
|
69
|
+
defer: [],
|
|
70
|
+
};
|
|
71
|
+
const BUILTIN_POLICIES = {
|
|
72
|
+
"personal-stash": PERSONAL_STASH,
|
|
73
|
+
conservative: CONSERVATIVE,
|
|
74
|
+
manual: MANUAL,
|
|
75
|
+
};
|
|
76
|
+
/** Names of the built-in presets, for help text and validation messages. */
|
|
77
|
+
export const BUILTIN_POLICY_NAMES = Object.keys(BUILTIN_POLICIES);
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Custom policy file schema (`--policy <path>`)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
const DrainAcceptRuleSchema = z
|
|
82
|
+
.object({
|
|
83
|
+
generator: GeneratorSchema,
|
|
84
|
+
maxDiffLines: z.number().int().positive().optional(),
|
|
85
|
+
minContentLines: z.number().int().nonnegative().optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
const DrainPolicySchema = z
|
|
89
|
+
.object({
|
|
90
|
+
name: z.string().min(1),
|
|
91
|
+
accept: z.array(DrainAcceptRuleSchema),
|
|
92
|
+
rejectEmpty: z.boolean(),
|
|
93
|
+
defer: z.array(GeneratorSchema),
|
|
94
|
+
})
|
|
95
|
+
.strict();
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a `--policy <preset|path>` argument into a {@link DrainPolicy}.
|
|
98
|
+
*
|
|
99
|
+
* - A bare preset name (`personal-stash` / `conservative` / `manual`) returns
|
|
100
|
+
* the matching built-in.
|
|
101
|
+
* - Anything else is treated as a filesystem path to a JSON policy file, which
|
|
102
|
+
* is read and zod-validated.
|
|
103
|
+
*
|
|
104
|
+
* Throws a {@link UsageError} on an unknown preset, a missing file, or a file
|
|
105
|
+
* that fails schema validation.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveDrainPolicy(arg) {
|
|
108
|
+
const value = (arg ?? "personal-stash").trim();
|
|
109
|
+
const builtin = BUILTIN_POLICIES[value];
|
|
110
|
+
if (builtin)
|
|
111
|
+
return builtin;
|
|
112
|
+
// Treat as a path to a custom policy file.
|
|
113
|
+
if (!fs.existsSync(value)) {
|
|
114
|
+
throw new UsageError(`Unknown policy "${value}". Use a built-in preset (${BUILTIN_POLICY_NAMES.join(", ")}) or a path to a policy file.`, "INVALID_FLAG_VALUE");
|
|
115
|
+
}
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(fs.readFileSync(value, "utf8"));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
throw new UsageError(`Could not parse policy file "${value}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_FLAG_VALUE");
|
|
122
|
+
}
|
|
123
|
+
const validated = DrainPolicySchema.safeParse(parsed);
|
|
124
|
+
if (!validated.success) {
|
|
125
|
+
throw new UsageError(`Invalid policy file "${value}": ${validated.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`, "INVALID_FLAG_VALUE");
|
|
126
|
+
}
|
|
127
|
+
return validated.data;
|
|
128
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic proposal-drain engine (Proposal-Queue Triage, Phase 1).
|
|
6
|
+
*
|
|
7
|
+
* Drains the *standing pending backlog* of proposals using a deterministic,
|
|
8
|
+
* no-LLM policy keyed on generator (proposal `source`) and diff size. This is
|
|
9
|
+
* the engine behind `akm proposal drain` and (later) the `triage` improve
|
|
10
|
+
* pre-pass.
|
|
11
|
+
*
|
|
12
|
+
* Design (see docs/technical/proposal-triage-implementation-plan.md):
|
|
13
|
+
* - Reuses `listProposals` (no source filter — generator filtering is
|
|
14
|
+
* in-memory) and the `akmProposalAccept` / `akmProposalReject` wrappers from
|
|
15
|
+
* `proposal.ts` so the standard `promoted` / `rejected` events are emitted.
|
|
16
|
+
* It deliberately does NOT use `runAutoAcceptGate`, which is confidence-gated.
|
|
17
|
+
* - Backlog-only: `excludeIds` removes this-run's fresh proposals so triage
|
|
18
|
+
* never re-adjudicates the per-run auto-accept gate's decisions (decision #2).
|
|
19
|
+
* - Hard guardrails enforced in code: a `maxAccepts` ceiling checked *before*
|
|
20
|
+
* the promote loop (remainder → `skippedByCap`); `maxDiffLines` defers large
|
|
21
|
+
* accepts; `applyMode: "queue"` (the safe default) never promotes (stage
|
|
22
|
+
* only); `rejectEmpty` rejects empty / near-empty diffs.
|
|
23
|
+
* - The judgment tier (Phase 3) adjudicates the deferred items: when a
|
|
24
|
+
* `judgment` RunnerSpec is supplied the engine pre-fetches context (the live
|
|
25
|
+
* asset + sibling pending proposals for the same ref) into a prompt,
|
|
26
|
+
* dispatches it to the configured runner (llm → `chatCompletion`, agent →
|
|
27
|
+
* `runAgent`, sdk → `runOpencodeSdk`, mirroring `reflect.ts`'s switch), and
|
|
28
|
+
* performs the resulting accept / reject *itself* (the runner only judges).
|
|
29
|
+
* Items the runner cannot resolve — and any deferred items when no runner is
|
|
30
|
+
* configured — surface a `triage_deferred` event so "enabled, no agent"
|
|
31
|
+
* never silently looks like full success.
|
|
32
|
+
*
|
|
33
|
+
* The promote / reject functions and the runner dispatch are injectable
|
|
34
|
+
* (mirrors `improve-auto-accept.ts` and reflect's dual test seams) so tests can
|
|
35
|
+
* run the full engine without touching the filesystem or spawning a process.
|
|
36
|
+
*/
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { parseAssetRef } from "../core/asset-ref";
|
|
40
|
+
import { resolveAssetPathFromName, TYPE_DIRS } from "../core/asset-spec";
|
|
41
|
+
import { appendEvent } from "../core/events";
|
|
42
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
43
|
+
import { listProposals } from "../core/proposals";
|
|
44
|
+
import { info, warn } from "../core/warn";
|
|
45
|
+
import { runAgent } from "../integrations/agent";
|
|
46
|
+
import { runOpencodeSdk } from "../integrations/agent/sdk-runner";
|
|
47
|
+
import { chatCompletion, stripJsonFences } from "../llm/client";
|
|
48
|
+
import { akmProposalAccept, akmProposalReject } from "./proposal";
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Content helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** Number of non-empty body lines (frontmatter excluded). */
|
|
53
|
+
export function contentBodyLineCount(content) {
|
|
54
|
+
// Reuse the canonical frontmatter parser so CRLF / BOM are handled
|
|
55
|
+
// consistently with the rest of the stash (parseFrontmatter returns the body
|
|
56
|
+
// in `content`).
|
|
57
|
+
return parseFrontmatter(content)
|
|
58
|
+
.content.split("\n")
|
|
59
|
+
.filter((line) => line.trim().length > 0).length;
|
|
60
|
+
}
|
|
61
|
+
/** Total line count of the proposed content (matches the bulk-accept measure). */
|
|
62
|
+
export function contentLineCount(content) {
|
|
63
|
+
return content.split("\n").length;
|
|
64
|
+
}
|
|
65
|
+
/** An empty / near-empty diff has no meaningful body content. */
|
|
66
|
+
export function isEmptyDiff(proposal) {
|
|
67
|
+
const content = proposal.payload.content ?? "";
|
|
68
|
+
if (content.trim().length === 0)
|
|
69
|
+
return true;
|
|
70
|
+
return contentBodyLineCount(content) === 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Decide a deterministic verdict for a single backlog proposal under `policy`.
|
|
74
|
+
* Returns `null` when no rule applies (the proposal is left pending untouched).
|
|
75
|
+
*/
|
|
76
|
+
export function classifyProposal(proposal, policy, maxDiffLines) {
|
|
77
|
+
const content = proposal.payload.content ?? "";
|
|
78
|
+
// Empty / near-empty diffs reject first (the reject-empty floor).
|
|
79
|
+
if (policy.rejectEmpty && isEmptyDiff(proposal)) {
|
|
80
|
+
return { verdict: "reject", reason: "empty diff" };
|
|
81
|
+
}
|
|
82
|
+
const rule = policy.accept.find((r) => r.generator === proposal.source);
|
|
83
|
+
if (rule) {
|
|
84
|
+
const lines = contentLineCount(content);
|
|
85
|
+
const body = contentBodyLineCount(content);
|
|
86
|
+
// Per-rule and global diff bounds defer large accepts (no silent rewrites).
|
|
87
|
+
const effectiveMax = Math.min(rule.maxDiffLines ?? Number.POSITIVE_INFINITY, maxDiffLines ?? Number.POSITIVE_INFINITY);
|
|
88
|
+
if (lines > effectiveMax) {
|
|
89
|
+
return { verdict: "defer", reason: "mid-band" };
|
|
90
|
+
}
|
|
91
|
+
if (rule.minContentLines !== undefined && body < rule.minContentLines) {
|
|
92
|
+
// Too little content to confidently auto-accept — leave for judgment.
|
|
93
|
+
return { verdict: "defer", reason: "mid-band" };
|
|
94
|
+
}
|
|
95
|
+
return { verdict: "accept" };
|
|
96
|
+
}
|
|
97
|
+
if (policy.defer.includes(proposal.source)) {
|
|
98
|
+
return { verdict: "defer", reason: deferReasonForSource(proposal.source) };
|
|
99
|
+
}
|
|
100
|
+
// No matching rule — leave pending, untouched.
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function deferReasonForSource(source) {
|
|
104
|
+
return source === "distill" ? "possible-dup" : "mid-band";
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Judgment tier (Phase 3)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
/** Read the live on-disk content of a proposal's target asset, if it exists. */
|
|
110
|
+
function readLiveAssetContent(stashDir, ref) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = parseAssetRef(ref);
|
|
113
|
+
const typeDir = TYPE_DIRS[parsed.type];
|
|
114
|
+
if (!typeDir)
|
|
115
|
+
return undefined;
|
|
116
|
+
const typeRoot = path.join(stashDir, typeDir);
|
|
117
|
+
const assetPath = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
|
|
118
|
+
if (!fs.existsSync(assetPath))
|
|
119
|
+
return undefined;
|
|
120
|
+
return fs.readFileSync(assetPath, "utf8");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Pre-fetch the context the judgment runner needs to adjudicate one deferred
|
|
128
|
+
* proposal: the proposed content, the live asset it would overwrite, and the
|
|
129
|
+
* sibling pending proposals for the same ref (so a dedup verdict can compare).
|
|
130
|
+
*/
|
|
131
|
+
function prefetchJudgmentContext(stashDir, proposal, pending) {
|
|
132
|
+
const liveAsset = readLiveAssetContent(stashDir, proposal.ref);
|
|
133
|
+
const siblings = pending.filter((p) => p.ref === proposal.ref && p.id !== proposal.id);
|
|
134
|
+
return { liveAsset, siblings };
|
|
135
|
+
}
|
|
136
|
+
/** Build the judgment prompt with the proposed content + pre-fetched context. */
|
|
137
|
+
export function buildJudgmentPrompt(proposal, reason, ctx) {
|
|
138
|
+
const proposed = proposal.payload.content ?? "";
|
|
139
|
+
const sections = [
|
|
140
|
+
"You are adjudicating a pending knowledge-base proposal that the deterministic",
|
|
141
|
+
"triage pass could not resolve. Decide whether to accept, reject, or defer it.",
|
|
142
|
+
"",
|
|
143
|
+
`Asset ref: ${proposal.ref}`,
|
|
144
|
+
`Generator (source): ${proposal.source}`,
|
|
145
|
+
`Deferred because: ${reason}`,
|
|
146
|
+
"",
|
|
147
|
+
"## Proposed content",
|
|
148
|
+
"```",
|
|
149
|
+
proposed,
|
|
150
|
+
"```",
|
|
151
|
+
];
|
|
152
|
+
if (ctx.liveAsset !== undefined) {
|
|
153
|
+
sections.push("", "## Current live asset (would be overwritten on accept)", "```", ctx.liveAsset, "```");
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
sections.push("", "## Current live asset", "(none — this proposal would create a new asset)");
|
|
157
|
+
}
|
|
158
|
+
if (ctx.siblings.length > 0) {
|
|
159
|
+
sections.push("", "## Other pending proposals for the same ref (dedup context)");
|
|
160
|
+
for (const sib of ctx.siblings) {
|
|
161
|
+
sections.push("", `### Sibling ${sib.id} (source: ${sib.source})`, "```", sib.payload.content ?? "", "```");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
sections.push("", "## Your task", 'Return ONLY a JSON object: {"decision": "accept" | "reject" | "defer", "reason": "<short reason>"}.', "- accept: the proposed content is a correct, valuable update worth committing.", "- reject: the proposal is wrong, a duplicate, or contradicts the live asset.", "- defer: you cannot decide from the provided context (leave it pending).", "Output the JSON object and nothing else.");
|
|
165
|
+
return sections.join("\n");
|
|
166
|
+
}
|
|
167
|
+
/** Parse a {@link JudgmentVerdict} from raw runner output. Lenient. */
|
|
168
|
+
export function parseJudgmentVerdict(raw) {
|
|
169
|
+
const cleaned = stripJsonFences(raw).trim();
|
|
170
|
+
if (!cleaned)
|
|
171
|
+
return null;
|
|
172
|
+
// Find the first balanced-looking JSON object in the output.
|
|
173
|
+
const start = cleaned.indexOf("{");
|
|
174
|
+
const end = cleaned.lastIndexOf("}");
|
|
175
|
+
if (start === -1 || end === -1 || end <= start)
|
|
176
|
+
return null;
|
|
177
|
+
let obj;
|
|
178
|
+
try {
|
|
179
|
+
obj = JSON.parse(cleaned.slice(start, end + 1));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
if (typeof obj !== "object" || obj === null)
|
|
185
|
+
return null;
|
|
186
|
+
const decision = obj.decision;
|
|
187
|
+
const reason = obj.reason;
|
|
188
|
+
if (decision !== "accept" && decision !== "reject" && decision !== "defer")
|
|
189
|
+
return null;
|
|
190
|
+
return { decision, reason: typeof reason === "string" ? reason : "" };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Dispatch a single judgment prompt to the resolved runner. The switch mirrors
|
|
194
|
+
* the canonical consumer at `reflect.ts:1060-1090`: llm → `chatCompletion`
|
|
195
|
+
* (no filesystem), agent → `runAgent`, sdk → `runOpencodeSdk`.
|
|
196
|
+
*/
|
|
197
|
+
async function dispatchJudgment(runner, prompt, seams) {
|
|
198
|
+
let raw;
|
|
199
|
+
switch (runner.kind) {
|
|
200
|
+
case "llm": {
|
|
201
|
+
const messages = [{ role: "user", content: prompt }];
|
|
202
|
+
raw = seams.chat
|
|
203
|
+
? await seams.chat(runner, messages)
|
|
204
|
+
: await chatCompletion(runner.connection, messages, {
|
|
205
|
+
...(runner.timeoutMs !== undefined ? { timeoutMs: runner.timeoutMs } : {}),
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "agent": {
|
|
210
|
+
const stdout = await runProfileJudgment(seams.runAgentFn ?? runAgent, runner.profile, prompt, "agent", runner.timeoutMs);
|
|
211
|
+
if (stdout === null)
|
|
212
|
+
return null;
|
|
213
|
+
raw = stdout;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "sdk": {
|
|
217
|
+
const stdout = await runProfileJudgment(seams.runSdkFn ?? runOpencodeSdk, runner.profile, prompt, "sdk", runner.timeoutMs);
|
|
218
|
+
if (stdout === null)
|
|
219
|
+
return null;
|
|
220
|
+
raw = stdout;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return parseJudgmentVerdict(raw);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Shared spawn-runner dispatch for the byte-identical `agent` / `sdk` arms: run
|
|
228
|
+
* the profile-based runner, log a labelled warning on failure, and return the
|
|
229
|
+
* captured stdout (or `null` on failure). `label` only changes the warn prefix.
|
|
230
|
+
*/
|
|
231
|
+
async function runProfileJudgment(run, profile, prompt, label, timeoutMs) {
|
|
232
|
+
const result = await run(profile, prompt, {
|
|
233
|
+
stdio: "captured",
|
|
234
|
+
parseOutput: "text",
|
|
235
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
236
|
+
});
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
warn(`[triage] judgment ${label} failed: ${result.error ?? result.reason ?? "unknown error"}`);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return result.stdout;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Run the judgment tier over the deferred items. The runner only *judges*; the
|
|
245
|
+
* engine performs the resulting accept (respecting `applyMode`) / reject write.
|
|
246
|
+
* Returns the ids the engine promoted / rejected, the ids staged (judge said
|
|
247
|
+
* "accept" but queue mode did not promote), the ids dropped by the accept cap,
|
|
248
|
+
* and the items still unresolved (verdict "defer", parse failure, or a runner
|
|
249
|
+
* error).
|
|
250
|
+
*/
|
|
251
|
+
async function runJudgmentTier(input) {
|
|
252
|
+
const byId = new Map(input.pending.map((p) => [p.id, p]));
|
|
253
|
+
const promoted = [];
|
|
254
|
+
const rejected = [];
|
|
255
|
+
const staged = [];
|
|
256
|
+
const skippedByCap = [];
|
|
257
|
+
const stillDeferred = [];
|
|
258
|
+
// Remaining accept budget shared with the deterministic promote loop.
|
|
259
|
+
let acceptBudget = Math.max(0, input.remainingAcceptBudget);
|
|
260
|
+
for (const item of input.deferred) {
|
|
261
|
+
const proposal = byId.get(item.id);
|
|
262
|
+
if (!proposal) {
|
|
263
|
+
stillDeferred.push(item);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const ctx = prefetchJudgmentContext(input.stashDir, proposal, input.pending);
|
|
267
|
+
const prompt = buildJudgmentPrompt(proposal, item.reason, ctx);
|
|
268
|
+
let verdict;
|
|
269
|
+
try {
|
|
270
|
+
verdict = await dispatchJudgment(input.runner, prompt, input.seams);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
warn(`[triage] judgment dispatch failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
274
|
+
stillDeferred.push(item);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!verdict || verdict.decision === "defer") {
|
|
278
|
+
stillDeferred.push(item);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (verdict.decision === "reject") {
|
|
282
|
+
if (input.dryRun) {
|
|
283
|
+
rejected.push(item.id);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
input.rejectFn({ stashDir: input.stashDir, id: item.id, reason: verdict.reason || "judgment: reject" });
|
|
288
|
+
rejected.push(item.id);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
warn(`[triage] judgment reject failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
292
|
+
stillDeferred.push(item);
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// decision === "accept" — gated on applyMode, exactly like the
|
|
297
|
+
// deterministic accept path (queue mode never writes).
|
|
298
|
+
if (input.applyMode !== "promote") {
|
|
299
|
+
// Staged: a queue-mode run never promotes, so the item stays pending but
|
|
300
|
+
// is RESOLVED (the runner judged it). Track separately so it is NOT
|
|
301
|
+
// reported as "left unresolved" and a follow-up promote run picks it up.
|
|
302
|
+
staged.push(item.id);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// Accept cap: once the shared budget is exhausted, route further accepts to
|
|
306
|
+
// skippedByCap instead of promoting (keeps total promotions ≤ maxAccepts).
|
|
307
|
+
if (acceptBudget <= 0) {
|
|
308
|
+
skippedByCap.push(item.id);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (input.dryRun) {
|
|
312
|
+
promoted.push(item.id);
|
|
313
|
+
acceptBudget -= 1;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
await input.promoteFn({ stashDir: input.stashDir, id: item.id });
|
|
318
|
+
promoted.push(item.id);
|
|
319
|
+
acceptBudget -= 1;
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
warn(`[triage] judgment promote failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
323
|
+
stillDeferred.push(item);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { promoted, rejected, staged, skippedByCap, stillDeferred };
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Engine
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
/**
|
|
332
|
+
* Drain the standing pending backlog under a deterministic policy.
|
|
333
|
+
*
|
|
334
|
+
* @param opts Drain options (policy, applyMode, ceilings, dry-run).
|
|
335
|
+
* @param promoteFn Injectable override for `akmProposalAccept` (test seam).
|
|
336
|
+
* @param rejectFn Injectable override for `akmProposalReject` (test seam).
|
|
337
|
+
*/
|
|
338
|
+
export async function drainProposals(opts, promoteFn = akmProposalAccept, rejectFn = akmProposalReject, judgmentSeams = {}) {
|
|
339
|
+
const result = { promoted: [], rejected: [], deferred: [], skippedByCap: [], staged: [] };
|
|
340
|
+
const exclude = opts.excludeIds ?? new Set();
|
|
341
|
+
const pending = listProposals(opts.stashDir, { status: "pending" }).filter((p) => !exclude.has(p.id));
|
|
342
|
+
// First, classify every proposal deterministically.
|
|
343
|
+
const acceptIds = [];
|
|
344
|
+
const rejectTargets = [];
|
|
345
|
+
for (const proposal of pending) {
|
|
346
|
+
const decision = classifyProposal(proposal, opts.policy, opts.maxDiffLines);
|
|
347
|
+
if (decision === null)
|
|
348
|
+
continue;
|
|
349
|
+
if (decision.verdict === "accept") {
|
|
350
|
+
acceptIds.push(proposal.id);
|
|
351
|
+
}
|
|
352
|
+
else if (decision.verdict === "reject") {
|
|
353
|
+
rejectTargets.push({ id: proposal.id, reason: decision.reason });
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
result.deferred.push({ id: proposal.id, reason: decision.reason });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// --- Reject empties (independent of the accept ceiling / applyMode) ---
|
|
360
|
+
for (const target of rejectTargets) {
|
|
361
|
+
if (opts.dryRun) {
|
|
362
|
+
result.rejected.push(target.id);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
rejectFn({ stashDir: opts.stashDir, id: target.id, reason: target.reason });
|
|
367
|
+
result.rejected.push(target.id);
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
warn(`[triage] reject failed for ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// --- Accept ceiling: enforced BEFORE the promote loop ---
|
|
374
|
+
const withinCap = acceptIds.slice(0, Math.max(0, opts.maxAccepts));
|
|
375
|
+
result.skippedByCap = acceptIds.slice(Math.max(0, opts.maxAccepts));
|
|
376
|
+
if (result.skippedByCap.length > 0) {
|
|
377
|
+
info(`[triage] accept ceiling reached: ${withinCap.length} promoted, ${result.skippedByCap.length} skipped by cap (maxAccepts=${opts.maxAccepts})`);
|
|
378
|
+
}
|
|
379
|
+
// --- Promotion gate: applyMode "queue" never promotes (stage only) ---
|
|
380
|
+
// Count deterministic promotions so the judgment tier shares the same accept
|
|
381
|
+
// budget (deterministic + judgment promotions ≤ maxAccepts).
|
|
382
|
+
let deterministicPromoted = 0;
|
|
383
|
+
if (opts.applyMode === "promote" && !opts.dryRun) {
|
|
384
|
+
info(`[triage] auto-promote active: ${withinCap.length} accepts allowed this run`);
|
|
385
|
+
for (const id of withinCap) {
|
|
386
|
+
try {
|
|
387
|
+
await promoteFn({ stashDir: opts.stashDir, id });
|
|
388
|
+
result.promoted.push(id);
|
|
389
|
+
deterministicPromoted += 1;
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
warn(`[triage] promote failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (opts.applyMode === "promote" && opts.dryRun) {
|
|
397
|
+
// Dry-run promote: report (and count, for the shared budget) what would be
|
|
398
|
+
// promoted without writing.
|
|
399
|
+
result.promoted.push(...withinCap);
|
|
400
|
+
deterministicPromoted = withinCap.length;
|
|
401
|
+
}
|
|
402
|
+
// applyMode "queue": leave accept candidates pending (staged). No promotion.
|
|
403
|
+
// Remaining accept budget for the judgment tier: maxAccepts minus what was
|
|
404
|
+
// actually promoted deterministically. Bounds the TOTAL promotions, not just
|
|
405
|
+
// the deterministic path. Moot in queue mode (it promotes nothing).
|
|
406
|
+
const remainingAcceptBudget = Math.max(0, Math.max(0, opts.maxAccepts) - deterministicPromoted);
|
|
407
|
+
// --- Judgment tier (Phase 3): adjudicate the deferred items ---
|
|
408
|
+
// Only runs when a RunnerSpec is configured. The runner returns a verdict; the
|
|
409
|
+
// ENGINE performs the resulting accept (respecting applyMode) / reject write.
|
|
410
|
+
if (opts.judgment && result.deferred.length > 0) {
|
|
411
|
+
const tier = await runJudgmentTier({
|
|
412
|
+
stashDir: opts.stashDir,
|
|
413
|
+
applyMode: opts.applyMode,
|
|
414
|
+
dryRun: opts.dryRun,
|
|
415
|
+
runner: opts.judgment,
|
|
416
|
+
deferred: result.deferred,
|
|
417
|
+
pending,
|
|
418
|
+
promoteFn,
|
|
419
|
+
rejectFn,
|
|
420
|
+
seams: judgmentSeams,
|
|
421
|
+
remainingAcceptBudget,
|
|
422
|
+
});
|
|
423
|
+
result.promoted.push(...tier.promoted);
|
|
424
|
+
result.rejected.push(...tier.rejected);
|
|
425
|
+
result.staged.push(...tier.staged);
|
|
426
|
+
// Judgment-tier accepts dropped by the shared accept cap surface under
|
|
427
|
+
// skippedByCap, same as deterministic cap drops.
|
|
428
|
+
result.skippedByCap.push(...tier.skippedByCap);
|
|
429
|
+
if (tier.skippedByCap.length > 0) {
|
|
430
|
+
info(`[triage] accept ceiling reached in judgment tier: ${tier.skippedByCap.length} judged-accept items skipped by cap (maxAccepts=${opts.maxAccepts})`);
|
|
431
|
+
}
|
|
432
|
+
// Replace the deferred list with only the items the judgment tier could NOT
|
|
433
|
+
// resolve (verdict "defer", parse failure, or runner error). Staged
|
|
434
|
+
// queue-mode accepts are RESOLVED and tracked in result.staged instead.
|
|
435
|
+
result.deferred = tier.stillDeferred;
|
|
436
|
+
}
|
|
437
|
+
emitDrainEvents(opts, result);
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Events
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
function emitDrainEvents(opts, result) {
|
|
444
|
+
const deferredByReason = {};
|
|
445
|
+
for (const d of result.deferred) {
|
|
446
|
+
deferredByReason[d.reason] = (deferredByReason[d.reason] ?? 0) + 1;
|
|
447
|
+
}
|
|
448
|
+
appendEvent({
|
|
449
|
+
eventType: "triage_drained",
|
|
450
|
+
metadata: {
|
|
451
|
+
promoted: result.promoted.length,
|
|
452
|
+
rejected: result.rejected.length,
|
|
453
|
+
deferredByReason,
|
|
454
|
+
skippedByCap: result.skippedByCap.length,
|
|
455
|
+
...(result.staged.length > 0 ? { staged: result.staged.length } : {}),
|
|
456
|
+
policy: opts.policy.name,
|
|
457
|
+
applyMode: opts.applyMode,
|
|
458
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
459
|
+
},
|
|
460
|
+
}, opts.eventsCtx ?? {});
|
|
461
|
+
// Surface any items the judge could NOT resolve after the (optional) judgment
|
|
462
|
+
// tier so a backlog of deferred items never silently looks like full success.
|
|
463
|
+
// This fires when no runner is configured OR the judgment tier ran but could
|
|
464
|
+
// not resolve every item (verdict "defer", parse failure, or a runner error).
|
|
465
|
+
// Queue-mode staged accepts are RESOLVED (the judge decided) and live in
|
|
466
|
+
// result.staged, so they are deliberately excluded from this "unresolved" count.
|
|
467
|
+
if (result.deferred.length > 0) {
|
|
468
|
+
appendEvent({
|
|
469
|
+
eventType: "triage_deferred",
|
|
470
|
+
metadata: {
|
|
471
|
+
deferred: result.deferred.length,
|
|
472
|
+
deferredByReason,
|
|
473
|
+
reason: opts.judgment ? "judgment tier left items unresolved" : "no judgment runner configured",
|
|
474
|
+
},
|
|
475
|
+
}, opts.eventsCtx ?? {});
|
|
476
|
+
}
|
|
477
|
+
}
|