@staix/agent-hub 0.20.0 → 0.21.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/CHANGELOG.md +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/hub/sdlc.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { planConformanceBlockers, type PlanConformance } from "./plan-conformanc
|
|
|
3
3
|
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
4
4
|
import type { CostPlan } from "./cost-governance.ts";
|
|
5
5
|
import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
|
|
6
|
+
import { configPathList, evalGapBlockers, evalGateView, evalRequiredChecks, type EvalGate, type EvalResult } from "./eval-gate.ts";
|
|
7
|
+
import { SECURITY_SOURCES, type SecuritySource } from "./security-baseline.ts";
|
|
6
8
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
7
9
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
8
10
|
import { join } from "node:path";
|
|
@@ -36,9 +38,11 @@ export type SdlcMode = "off" | "advise" | "enforce";
|
|
|
36
38
|
/** #786: `off` never merges, `dry-run` reports the decision and runs nothing, `on` runs the guarded `gh pr merge`. */
|
|
37
39
|
export type AutoMergeMode = "off" | "dry-run" | "on";
|
|
38
40
|
export const AUTO_MERGE_MODES: readonly AutoMergeMode[] = ["off", "dry-run", "on"];
|
|
41
|
+
/** #481 S7: which GitHub alert sources the scan reads, how often, and for which repository (absent: the project repository). */
|
|
42
|
+
export interface SecurityScanConfig { everyH: number; sources: SecuritySource[]; repo?: string }
|
|
39
43
|
/** #786: one repository's own gate: its required checks (before the top-level list) and its own merge mode. */
|
|
40
44
|
export interface RepoMergePolicy { requiredChecks?: string[]; autoMerge?: AutoMergeMode }
|
|
41
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; /** #786: absent reads as "off"; written only when the file names it. */ autoMerge?: AutoMergeMode; /** #786: per-repository tables, keyed by lowercase owner/name. */ repos?: Record<string, RepoMergePolicy>; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
45
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; /** #481 S7: the scheduled security-scan baseline; absent means none is scheduled. */ securityScan?: SecurityScanConfig; /** #481 S5: the one check a configuration PR needs; no default, the person names it. */ evalCheck?: string; /** #481 S5: patterns that mark a PR as a configuration change; absent reads as the defaults in eval-gate.ts, an empty list turns the gate off. */ configPaths?: string[]; mode: SdlcMode; requiredChecks: string[]; /** #786: absent reads as "off"; written only when the file names it. */ autoMerge?: AutoMergeMode; /** #786: per-repository tables, keyed by lowercase owner/name. */ repos?: Record<string, RepoMergePolicy>; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
42
46
|
export interface PlanReviewReceipt {
|
|
43
47
|
planner: string;
|
|
44
48
|
planAuthor?: string | null;
|
|
@@ -56,6 +60,8 @@ export interface MergeRecord {
|
|
|
56
60
|
cleanup: "not_started" | "claimed" | "deleted" | "already_gone" | "skipped" | "unknown";
|
|
57
61
|
reason?: "not_merged" | "missing_commit" | "metadata_unknown" | "identity_changed" | "protected_branch" | "default_branch" | "head_changed" | "authority_changed" | "delete_unconfirmed";
|
|
58
62
|
ownerNotice: "not_started" | "claimed" | "sent" | "unknown";
|
|
63
|
+
/** #481 S7: the merged PR's review and CI figures, read once at merge by the host. Absent when the PR could not be read; fields inside are null when unknown. */
|
|
64
|
+
cost?: import("./cost-governance.ts").MergeCost;
|
|
59
65
|
}
|
|
60
66
|
export interface SdlcReceipt {
|
|
61
67
|
merge?: MergeRecord;
|
|
@@ -152,6 +158,22 @@ const checkList = (value: unknown, what: string): string[] => {
|
|
|
152
158
|
if (!Array.isArray(value) || value.length > 32 || value.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(value).size !== value.length) throw new Error(`${what}: expected at most 32 unique nonempty names`);
|
|
153
159
|
return [...value] as string[];
|
|
154
160
|
};
|
|
161
|
+
const evalCheckName = (value: unknown): string => {
|
|
162
|
+
if (typeof value !== "string" || !value.trim() || value.length > 120 || /[\r\n\x00-\x1f]/.test(value)) throw new Error("sdlc.eval_check: expected one nonempty check name");
|
|
163
|
+
return value;
|
|
164
|
+
};
|
|
165
|
+
const securityScanConfig = (value: unknown): SecurityScanConfig => {
|
|
166
|
+
const fail = (path: string, why: string): never => { throw new Error(`sdlc.security_scan${path}: ${why}`); };
|
|
167
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail("", "expected a table");
|
|
168
|
+
const raw = value as Record<string, unknown>;
|
|
169
|
+
for (const key of Object.keys(raw)) if (!["every_h", "sources", "repo"].includes(key)) fail(`.${key}`, "unknown field");
|
|
170
|
+
const everyH = raw.every_h === undefined ? 24 : raw.every_h;
|
|
171
|
+
if (typeof everyH !== "number" || !Number.isSafeInteger(everyH) || everyH < 1 || everyH > 720) fail(".every_h", "expected an integer number of hours from 1 to 720");
|
|
172
|
+
const sources = raw.sources;
|
|
173
|
+
if (!Array.isArray(sources) || !sources.length || sources.length > SECURITY_SOURCES.length || new Set(sources).size !== sources.length || sources.some(s => !SECURITY_SOURCES.includes(s as SecuritySource))) fail(".sources", `expected a nonempty list of unique names from ${SECURITY_SOURCES.join(", ")}; the scan has no default source`);
|
|
174
|
+
if (raw.repo !== undefined && !repo(raw.repo)) fail(".repo", "expected owner/repository");
|
|
175
|
+
return { everyH: everyH as number, sources: [...sources as SecuritySource[]], ...(raw.repo === undefined ? {} : { repo: raw.repo as string }) };
|
|
176
|
+
};
|
|
155
177
|
const autoMergeMode = (value: unknown, what: string): AutoMergeMode => {
|
|
156
178
|
if (!AUTO_MERGE_MODES.includes(value as AutoMergeMode)) throw new Error(`${what}: expected off, dry-run or on`);
|
|
157
179
|
return value as AutoMergeMode;
|
|
@@ -178,11 +200,15 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
178
200
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
179
201
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
180
202
|
const raw = value as Record<string, unknown>;
|
|
181
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands", "auto_merge", "repos"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
203
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands", "auto_merge", "repos", "eval_check", "config_paths", "security_scan"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
182
204
|
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
183
205
|
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
184
206
|
const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
|
|
185
|
-
const
|
|
207
|
+
const evalConfig = {
|
|
208
|
+
...(raw.eval_check === undefined ? {} : { evalCheck: evalCheckName(raw.eval_check) }),
|
|
209
|
+
...(raw.config_paths === undefined ? {} : { configPaths: configPathList(raw.config_paths, "sdlc.config_paths") }),
|
|
210
|
+
};
|
|
211
|
+
const findingConfig = { ...(raw.max_nits === undefined ? {} : { maxNits }), ...bandConfig, ...evalConfig, ...(raw.security_scan === undefined ? {} : { securityScan: securityScanConfig(raw.security_scan) }) };
|
|
186
212
|
const mode = raw.mode ?? "off";
|
|
187
213
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
188
214
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
|
@@ -336,12 +362,13 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
|
336
362
|
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
337
363
|
unknown: "spec edit time unknown: the issue could not be read",
|
|
338
364
|
};
|
|
339
|
-
export interface SdlcStatus { testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
365
|
+
export interface SdlcStatus { evalGate?: EvalGate; testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
340
366
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
341
367
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
342
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity): SdlcStatus {
|
|
368
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity, evalResult?: EvalResult): SdlcStatus {
|
|
343
369
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
344
370
|
const definition = stageDefinition(config, stage);
|
|
371
|
+
const evalGate = evidence ? evalGateView(config, evidence, evalResult) : undefined;
|
|
345
372
|
const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
|
|
346
373
|
const findings = items.length ? findingTally(items) : undefined;
|
|
347
374
|
if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
|
|
@@ -355,10 +382,13 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
355
382
|
if (receipt?.reviewedHead !== evidence.head) blockers.push("current head has not been reviewed");
|
|
356
383
|
if (receipt?.localHead !== evidence.head || receipt.localResult !== "passed") blockers.push("local gate on current head missing or failed");
|
|
357
384
|
if (!config.requiredChecks.length) blockers.push("required check set unknown");
|
|
358
|
-
|
|
385
|
+
// #481 S5: a configuration PR also needs the configured eval check; a code-only PR is unaffected.
|
|
386
|
+
for (const name of [...new Set([...config.requiredChecks, ...evalRequiredChecks(config, evidence)])]) {
|
|
359
387
|
const matches = evidence.checks.filter(c => c.name === name);
|
|
360
388
|
if (!matches.length || matches.some(c => c.state !== "passed")) blockers.push(`required check ${name}: not successful on current head`);
|
|
361
389
|
}
|
|
390
|
+
// Gaps in the eval evidence block only in enforce; advise shows them in `evalGate` and the advice notice (never a silent pass, never an advise block).
|
|
391
|
+
if (config.mode === "enforce") blockers.push(...evalGapBlockers(evalGate));
|
|
362
392
|
if (evidence.review !== "approved") blockers.push("GitHub review approval missing or changes requested");
|
|
363
393
|
if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
|
|
364
394
|
if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
|
|
@@ -371,7 +401,7 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
371
401
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
372
402
|
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
373
403
|
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
374
|
-
return { ...spec, ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
404
|
+
return { ...spec, ...(evalGate ? { evalGate } : {}), ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
375
405
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
376
406
|
// #694: the identity blocker keeps its exact text for gate matching; the next step says how to supply it.
|
|
377
407
|
next: blockers[0] === "issue identity missing" ? "issue identity missing: name one issue (title #N, refs or hub_sdlc_record)" : blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SdlcGh } from "./sdlc.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #481 S7: the scheduled security-scan baseline. The hub runs no scanner and invents no control: it reads the open-alert counts GitHub already holds for the
|
|
5
|
+
* repository (Dependabot, secret scanning, code scanning), records one scan row each interval, and the existing control-band path judges the series. A source that
|
|
6
|
+
* is not enabled, not permitted or unreadable counts as unknown, never as zero alerts, so a repository with no source configured records nothing.
|
|
7
|
+
*/
|
|
8
|
+
export const SECURITY_SOURCES = ["dependabot", "secret_scanning", "code_scanning"] as const;
|
|
9
|
+
export type SecuritySource = typeof SECURITY_SOURCES[number];
|
|
10
|
+
export interface SecurityScan { repo: string; sources: SecuritySource[]; counts: Partial<Record<SecuritySource, number | null>>; total: number | null }
|
|
11
|
+
const PATH: Record<SecuritySource, string> = { dependabot: "dependabot/alerts", secret_scanning: "secret-scanning/alerts", code_scanning: "code-scanning/alerts" };
|
|
12
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
13
|
+
const MAX_ALERTS = 1000;
|
|
14
|
+
|
|
15
|
+
/** Open alerts of one source, exactly (paged to the cap), or null when the source cannot be read completely. Counts only: no alert text, name or location is kept. */
|
|
16
|
+
async function openAlerts(repo: string, source: SecuritySource, run: SdlcGh, deadline: number): Promise<number | null> {
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (let page = 1; page <= MAX_ALERTS / 100; page++) {
|
|
19
|
+
const left = deadline - performance.now();
|
|
20
|
+
if (left <= 0) return null;
|
|
21
|
+
const reply = await run(["api", `repos/${repo}/${PATH[source]}?state=open&per_page=100&page=${page}`, "--jq", "length"], Math.min(10_000, left), undefined, 256).catch(() => undefined);
|
|
22
|
+
if (!reply || reply.code !== 0) return null;
|
|
23
|
+
const n = Number(reply.stdout.trim());
|
|
24
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 100) return null;
|
|
25
|
+
total += n;
|
|
26
|
+
if (n < 100) return total;
|
|
27
|
+
}
|
|
28
|
+
return null; // more than the cap: unknown, not a clipped number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fixed argv, bounded time and output. The total is known only when every named source was read. */
|
|
32
|
+
export async function readSecurityScan(repo: string, sources: readonly SecuritySource[], run: SdlcGh): Promise<SecurityScan | undefined> {
|
|
33
|
+
if (!REPO.test(repo) || repo.startsWith("-") || !sources.length || sources.some(source => !SECURITY_SOURCES.includes(source))) return undefined;
|
|
34
|
+
const deadline = performance.now() + 20_000, counts: SecurityScan["counts"] = {};
|
|
35
|
+
for (const source of sources) counts[source] = await openAlerts(repo, source, run, deadline);
|
|
36
|
+
const known = sources.every(source => counts[source] !== null && counts[source] !== undefined);
|
|
37
|
+
return { repo, sources: [...sources], counts, total: known ? sources.reduce((sum, source) => sum + counts[source]!, 0) : null };
|
|
38
|
+
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { OPERATIONAL_NUMBERS } from "./operational-settings.ts";
|
|
2
|
+
import { QUEUE_BACKPRESSURE_LIMITS } from "./queue-backpressure.ts";
|
|
1
3
|
import { operationalRosterSetting, operationalSetting } from "./operational-authority.ts";
|
|
2
4
|
import { ACTION_CLASSES } from "./action-class.ts";
|
|
3
5
|
import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
@@ -57,7 +59,10 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
57
59
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
58
60
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
59
61
|
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
62
|
+
...OPERATIONAL_NUMBERS.map(({ defaultValue, unit, inventory, ...def }): SettingDef => def),
|
|
60
63
|
{ key: "tasks.auto_pull", group: "Switches", label: "Auto-pull the top reserved ready card when a planner slot frees", type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["tasks", "auto_pull"] },
|
|
64
|
+
{ key: "tasks.queue_backpressure.max_queued", group: "Routing", label: "Automatic offers: max queued envelopes (0 disables)", type: "integer", min: 0, max: QUEUE_BACKPRESSURE_LIMITS.max_queued, store: "config", applies: "live", risk: "raises", path: ["tasks", "queue_backpressure", "max_queued"] },
|
|
65
|
+
{ key: "tasks.queue_backpressure.max_oldest_s", group: "Routing", label: "Automatic offers: max oldest queued age in seconds (0 disables)", type: "integer", min: 0, max: QUEUE_BACKPRESSURE_LIMITS.max_oldest_s, store: "config", applies: "live", risk: "raises", path: ["tasks", "queue_backpressure", "max_oldest_s"] },
|
|
61
66
|
{ key: "planner", group: "Roles", label: "Planners (task-local selection)", type: "peers", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
62
67
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
63
68
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
@@ -80,7 +85,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
80
85
|
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
81
86
|
* text go. A key equal to an entry, or below it, is refused by name.
|
|
82
87
|
*/
|
|
83
|
-
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances", "adapters", "adapter_modules"] as const;
|
|
88
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
|
|
84
89
|
|
|
85
90
|
/** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
|
|
86
91
|
export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import example from "../../templates/packs/example.pack.json" with { type: "text" };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The packs the hub ships (#513a), by file name without `.pack.json`. Imported as text so the bundle carries them, as `shipped-roles.ts` carries the role catalog.
|
|
5
|
+
* Only the format example is here: the writing, analysis and ops packs are #513b, which adds a file to `templates/packs/` and a line here.
|
|
6
|
+
*/
|
|
7
|
+
// TypeScript types a `.json` import as its parsed shape even with `type: "text"`; Bun hands over the file text, which `parsePack` parses and bounds itself.
|
|
8
|
+
export const SHIPPED_PACKS: Readonly<Record<string, string>> = { example: example as unknown as string };
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
2
|
+
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -26,6 +26,15 @@ export const budgetNow = (budget: SnapshotBudget): number => budget.now ? budget
|
|
|
26
26
|
export class SnapshotBudgetExceeded extends Error {
|
|
27
27
|
constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
|
|
28
28
|
}
|
|
29
|
+
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
30
|
+
/**
|
|
31
|
+
* The wall-clock timeout of one git child. Production (no `budget.now`): the remaining budget, between 1 ms and the 10 s ceiling, so a child is killed at the budget.
|
|
32
|
+
* ponytail: a host-held clock (`budget.now`, only the host-only snapshotClock seam sets it) holds the deadline, so the child's timeout is the 10 s ceiling, not the remaining budget:
|
|
33
|
+
* with the clock fixed the remaining budget never shrinks, and 250 ms of real time per child timed a slow git out under load (#554). Production never sets `now`, so its child is still killed
|
|
34
|
+
* at the budget. A child past the 10 s ceiling still reports ETIMEDOUT and the evidence stays unknown. Upgrade path: a fake spawn seam, if a held clock ever needs to model a slow child.
|
|
35
|
+
*/
|
|
36
|
+
export const childTimeoutMs = (budget?: SnapshotBudget): number =>
|
|
37
|
+
budget?.now ? SNAPSHOT_TIMEOUT_MS : Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS)));
|
|
29
38
|
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number, input?: string | Buffer) => {
|
|
30
39
|
const argv = repoGitArgs(top, args);
|
|
31
40
|
const remaining = budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS;
|
|
@@ -33,12 +42,11 @@ const git = (top: string | Repo, args: string[], env: Record<string, string> = {
|
|
|
33
42
|
const result = hubGitSync(argv, {
|
|
34
43
|
encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
35
44
|
...(input === undefined ? {} : { input }),
|
|
36
|
-
timeout:
|
|
45
|
+
timeout: childTimeoutMs(budget),
|
|
37
46
|
});
|
|
38
47
|
if (budget && (budgetNow(budget) >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
|
|
39
48
|
return result;
|
|
40
49
|
};
|
|
41
|
-
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
42
50
|
|
|
43
51
|
/** A repository and where the project sits in it: `prefix` is "" at the top level, else "sub/dir/". */
|
|
44
52
|
export interface Repo {
|
|
@@ -543,6 +551,8 @@ export interface TurnRecord {
|
|
|
543
551
|
start_tree: string | null;
|
|
544
552
|
end_tree: string | null;
|
|
545
553
|
changed: string[];
|
|
554
|
+
/** Dirty submodules at the turn's end, whose contents nothing here observes (#798 D2). Absent: not recorded (an older turn, or git could not answer), which is unknown, never none. */
|
|
555
|
+
unobserved?: string[];
|
|
546
556
|
native: string | null;
|
|
547
557
|
}
|
|
548
558
|
|
|
@@ -606,6 +616,43 @@ export function dirtySubmodules(repo: Repo, budget?: SnapshotBudget): string[] |
|
|
|
606
616
|
return result.status === 0 ? parseDirtySubmodules(result.stdout) : undefined;
|
|
607
617
|
}
|
|
608
618
|
|
|
619
|
+
/**
|
|
620
|
+
* One git-config value as git reads it: an unquoted `#` or `;` starts a comment, a quoted run keeps them, quotes are dropped, and `\"` and `\\` are unescaped inside a quoted
|
|
621
|
+
* run. ponytail: other escapes (`\t`, octal) are kept as the character after the backslash; use `git config -f .gitmodules --get-regexp path` if a name needs them.
|
|
622
|
+
*/
|
|
623
|
+
function configValue(raw: string): string {
|
|
624
|
+
let out = "", keep = 0, quoted = false;
|
|
625
|
+
for (let i = 0; i < raw.length; i++) {
|
|
626
|
+
const c = raw[i]!;
|
|
627
|
+
if (quoted && c === "\\" && i + 1 < raw.length) { out += raw[++i]; keep = out.length; continue; }
|
|
628
|
+
if (c === '"') { quoted = !quoted; keep = out.length; continue; }
|
|
629
|
+
if (!quoted && (c === "#" || c === ";")) break;
|
|
630
|
+
if (!quoted && out.length === 0 && /\s/.test(c)) continue; // leading blanks
|
|
631
|
+
out += c;
|
|
632
|
+
if (quoted || !/\s/.test(c)) keep = out.length; // trailing blanks outside quotes are not part of the value
|
|
633
|
+
}
|
|
634
|
+
return out.slice(0, keep);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The submodule paths a project declares, relative to the project (#798 D1), read from `.gitmodules` with no git call. Empty without the file, and for a shadow
|
|
639
|
+
* repository. A path outside the project's own directory in a larger repository is not the project's. A path git quotes (`"a\tb"`) is taken as written, never
|
|
640
|
+
* unescaped beyond `\"` and `\\`: see `configValue`.
|
|
641
|
+
*/
|
|
642
|
+
export function submodulePaths(repo: Repo): string[] {
|
|
643
|
+
if (repo.shadow) return [];
|
|
644
|
+
let text: string;
|
|
645
|
+
try { text = readFileSync(join(repo.top, ".gitmodules"), "utf8"); } catch { return []; }
|
|
646
|
+
const found = new Set<string>();
|
|
647
|
+
for (const line of text.split(/\r?\n/)) {
|
|
648
|
+
const match = /^\s*path\s*=(.*)$/.exec(line);
|
|
649
|
+
let path = match ? configValue(match[1]!) : "";
|
|
650
|
+
path = path.replace(/\/+$/, "");
|
|
651
|
+
if (path && !path.startsWith("/") && !path.split("/").includes("..") && path.startsWith(repo.prefix)) found.add(path.slice(repo.prefix.length));
|
|
652
|
+
}
|
|
653
|
+
return [...found].sort();
|
|
654
|
+
}
|
|
655
|
+
|
|
609
656
|
/** Turn records in hub.db: the daemon writes them, `ahub turns` and `ahub undo` read them. */
|
|
610
657
|
export class Turns {
|
|
611
658
|
private readonly db: Database;
|
|
@@ -623,6 +670,7 @@ export class Turns {
|
|
|
623
670
|
this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
|
|
624
671
|
this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
|
|
625
672
|
this.db.run("CREATE TABLE IF NOT EXISTS turn_workspaces (turn TEXT PRIMARY KEY, root TEXT NOT NULL, start_tree TEXT, end_tree TEXT, changed TEXT NOT NULL DEFAULT '[]')");
|
|
673
|
+
this.db.run("CREATE TABLE IF NOT EXISTS turn_unobserved (turn TEXT PRIMARY KEY, paths TEXT NOT NULL)");
|
|
626
674
|
this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
|
|
627
675
|
}
|
|
628
676
|
/** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
|
|
@@ -665,8 +713,9 @@ export class Turns {
|
|
|
665
713
|
native(id: string, native: string): void {
|
|
666
714
|
this.db.query("UPDATE turns SET native = ? WHERE id = ? AND native IS NULL").run(native, id);
|
|
667
715
|
}
|
|
668
|
-
end(id: string, endTree: string | undefined, changed: string[], keep: number): void {
|
|
716
|
+
end(id: string, endTree: string | undefined, changed: string[], keep: number, unobserved?: string[]): void {
|
|
669
717
|
this.db.transaction(() => {
|
|
718
|
+
if (unobserved !== undefined) this.db.query("INSERT OR REPLACE INTO turn_unobserved (turn, paths) VALUES (?, ?)").run(id, JSON.stringify(unobserved));
|
|
670
719
|
const scoped = this.workspace(id) !== undefined;
|
|
671
720
|
// Older CLIs do not know workspace receipts. Leave their canonical-checkout snapshot view unknown.
|
|
672
721
|
this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), scoped ? null : endTree ?? null, scoped ? "[]" : JSON.stringify(changed), id);
|
|
@@ -675,18 +724,35 @@ export class Turns {
|
|
|
675
724
|
const peer = (this.db.query("SELECT peer FROM turns WHERE id = ?").get(id) as { peer: string } | null)?.peer;
|
|
676
725
|
if (peer) this.db.query("DELETE FROM turns WHERE peer = ? AND id NOT IN (SELECT id FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?)").run(peer, peer, keep);
|
|
677
726
|
this.db.run("DELETE FROM turn_workspaces WHERE turn NOT IN (SELECT id FROM turns)");
|
|
727
|
+
this.db.run("DELETE FROM turn_unobserved WHERE turn NOT IN (SELECT id FROM turns)");
|
|
728
|
+
}
|
|
729
|
+
/** The dirty submodules recorded when the turn ended; undefined when none were recorded (unknown, not "none"). */
|
|
730
|
+
unobserved(id: string): string[] | undefined {
|
|
731
|
+
try {
|
|
732
|
+
const row = this.db.query("SELECT paths FROM turn_unobserved WHERE turn = ?").get(id) as { paths: string } | null;
|
|
733
|
+
if (!row) return undefined;
|
|
734
|
+
const paths = JSON.parse(row.paths);
|
|
735
|
+
return Array.isArray(paths) && paths.every(path => typeof path === "string") ? paths : undefined;
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (/no such table/.test((error as Error).message)) return undefined;
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
private withUnobserved<T extends { id: string }>(record: T): T & { unobserved?: string[] } {
|
|
742
|
+
const unobserved = this.unobserved(record.id);
|
|
743
|
+
return unobserved === undefined ? record : { ...record, unobserved };
|
|
678
744
|
}
|
|
679
745
|
list(peer?: string, limit = 20): TurnRecord[] {
|
|
680
746
|
const rows = (peer
|
|
681
747
|
? this.db.query("SELECT * FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?").all(peer, limit)
|
|
682
748
|
: this.db.query("SELECT * FROM turns ORDER BY started DESC, rowid DESC LIMIT ?").all(limit)) as (Omit<TurnRecord, "changed"> & { changed: string })[];
|
|
683
|
-
return rows.map(row => this.snapshotFields(row)).map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
749
|
+
return rows.map(row => this.snapshotFields(row)).map((r) => this.withUnobserved({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
684
750
|
}
|
|
685
751
|
get(id: string): TurnRecord | undefined {
|
|
686
752
|
const r = this.db.query("SELECT * FROM turns WHERE id = ?").get(id) as (Omit<TurnRecord, "changed"> & { changed: string }) | null;
|
|
687
753
|
if (!r) return undefined;
|
|
688
754
|
const record = this.snapshotFields(r);
|
|
689
|
-
return { ...record, changed: JSON.parse(record.changed) as string[] };
|
|
755
|
+
return this.withUnobserved({ ...record, changed: JSON.parse(record.changed) as string[] });
|
|
690
756
|
}
|
|
691
757
|
/**
|
|
692
758
|
* Other peers' turns that overlapped this one in time: their changes are in this turn's diff too. `unknown` lists
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
3
|
+
import { validSourceId } from "./sources.ts";
|
|
4
|
+
import type { SourceMeta } from "./sources.ts";
|
|
5
|
+
|
|
6
|
+
export const MAX_SOURCE_READ_BYTES = 32 * 1024;
|
|
7
|
+
const CUT_MARKER = " [cut]";
|
|
8
|
+
const REFUSAL = "source read unavailable";
|
|
9
|
+
|
|
10
|
+
export interface SourceReadRegistry {
|
|
11
|
+
get(id: string): SourceMeta | undefined;
|
|
12
|
+
readText(id: string): string | undefined;
|
|
13
|
+
markPrivate(id: string): SourceMeta | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SourceReadPolicy {
|
|
17
|
+
/** Screen the complete stored text. True means clear; false means private or unknown. */
|
|
18
|
+
screen(text: string, meta: SourceMeta): Promise<boolean>;
|
|
19
|
+
/** A synchronous, fresh authorization check. */
|
|
20
|
+
valid(): boolean;
|
|
21
|
+
/** True only for an authorized on-prem worker inside a PII task. */
|
|
22
|
+
canReadPrivate(): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SourceReadArgs { id: string; range?: { offset: number; length: number } }
|
|
26
|
+
export type SourceReadResult =
|
|
27
|
+
| { id: string; kind: SourceMeta["kind"]; private: true }
|
|
28
|
+
| { id: string; kind: SourceMeta["kind"]; private: true; text: string; next: number }
|
|
29
|
+
| { id: string; kind: SourceMeta["kind"]; private: false; text: string; next: number };
|
|
30
|
+
|
|
31
|
+
function refuse(): never { throw new PreEffectToolRefusal(REFUSAL); }
|
|
32
|
+
|
|
33
|
+
/** Validate the exact tool shape before touching the registry or privacy policy. */
|
|
34
|
+
export function validateSourceRead(value: unknown): SourceReadArgs {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) refuse();
|
|
36
|
+
const input = value as Record<string, unknown>;
|
|
37
|
+
if (Object.keys(input).some(key => key !== "id" && key !== "range") || !validSourceId(input.id)) refuse();
|
|
38
|
+
if (input.range === undefined) return { id: input.id };
|
|
39
|
+
if (!input.range || typeof input.range !== "object" || Array.isArray(input.range)) refuse();
|
|
40
|
+
const range = input.range as Record<string, unknown>;
|
|
41
|
+
if (Object.keys(range).length !== 2 || Object.keys(range).some(key => key !== "offset" && key !== "length")
|
|
42
|
+
|| !Number.isSafeInteger(range.offset) || (range.offset as number) < 0
|
|
43
|
+
|| !Number.isSafeInteger(range.length) || (range.length as number) < 1
|
|
44
|
+
|| !Number.isSafeInteger((range.offset as number) + (range.length as number))) refuse();
|
|
45
|
+
return { id: input.id, range: { offset: range.offset as number, length: range.length as number } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sameRecord(a: SourceMeta, b: SourceMeta): boolean {
|
|
49
|
+
return a.id === b.id && a.kind === b.kind && a.origin === b.origin && a.sha256 === b.sha256 && a.bytes === b.bytes
|
|
50
|
+
&& a.added_at === b.added_at && a.private === b.private && JSON.stringify(a.origins) === JSON.stringify(b.origins);
|
|
51
|
+
}
|
|
52
|
+
function sameContent(a: SourceMeta, b: SourceMeta): boolean {
|
|
53
|
+
return a.id === b.id && a.kind === b.kind && a.sha256 === b.sha256 && a.bytes === b.bytes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function privateResult(registry: SourceReadRegistry, id: string, meta: SourceMeta, range: SourceReadArgs["range"], policy: SourceReadPolicy): Promise<SourceReadResult> {
|
|
57
|
+
const authorized = await policy.canReadPrivate();
|
|
58
|
+
if (!policy.valid()) refuse();
|
|
59
|
+
const afterAuth = registry.get(id);
|
|
60
|
+
if (!afterAuth || !afterAuth.private || !sameRecord(meta, afterAuth)) refuse();
|
|
61
|
+
if (!authorized) {
|
|
62
|
+
if (!policy.valid()) refuse();
|
|
63
|
+
return { id, kind: afterAuth.kind, private: true };
|
|
64
|
+
}
|
|
65
|
+
const text = registry.readText(id), finalMeta = registry.get(id);
|
|
66
|
+
if (text === undefined || !finalMeta?.private || !sameRecord(afterAuth, finalMeta)) refuse();
|
|
67
|
+
if (!policy.valid()) refuse();
|
|
68
|
+
return render(id, finalMeta.kind, text, range, true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Screen the whole source before selecting a bounded excerpt; re-read identity and privacy after async policy boundaries. */
|
|
72
|
+
export async function sourceRead(registry: SourceReadRegistry, value: unknown, policy: SourceReadPolicy): Promise<SourceReadResult> {
|
|
73
|
+
const args = validateSourceRead(value);
|
|
74
|
+
try {
|
|
75
|
+
if (!policy.valid()) refuse();
|
|
76
|
+
let meta = registry.get(args.id);
|
|
77
|
+
if (!meta) refuse();
|
|
78
|
+
if (meta.private) {
|
|
79
|
+
const result = await privateResult(registry, args.id, meta, args.range, policy);
|
|
80
|
+
if (!policy.valid()) refuse();
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let fullText = registry.readText(args.id);
|
|
85
|
+
if (fullText === undefined) refuse();
|
|
86
|
+
let clear = false;
|
|
87
|
+
try { clear = await policy.screen(fullText, meta); }
|
|
88
|
+
catch { clear = false; }
|
|
89
|
+
if (!policy.valid()) refuse();
|
|
90
|
+
const afterScreen = registry.get(args.id), afterScreenText = registry.readText(args.id);
|
|
91
|
+
if (!afterScreen || afterScreenText === undefined || !sameContent(meta, afterScreen) || afterScreenText !== fullText) refuse();
|
|
92
|
+
if (afterScreen.private) {
|
|
93
|
+
const result = await privateResult(registry, args.id, afterScreen, args.range, policy);
|
|
94
|
+
if (!policy.valid()) refuse();
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
if (!sameRecord(meta, afterScreen)) refuse();
|
|
98
|
+
meta = afterScreen;
|
|
99
|
+
if (!clear) {
|
|
100
|
+
if (!policy.valid()) refuse();
|
|
101
|
+
meta = registry.markPrivate(args.id) ?? registry.get(args.id);
|
|
102
|
+
if (!meta?.private) refuse();
|
|
103
|
+
const result = await privateResult(registry, args.id, meta, args.range, policy);
|
|
104
|
+
if (!policy.valid()) refuse();
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!policy.valid()) refuse();
|
|
109
|
+
const current = registry.get(args.id);
|
|
110
|
+
if (!current || !sameRecord(meta, current) || current.private) refuse();
|
|
111
|
+
fullText = registry.readText(args.id);
|
|
112
|
+
if (fullText === undefined || fullText !== afterScreenText) refuse();
|
|
113
|
+
if (!policy.valid()) refuse();
|
|
114
|
+
return render(args.id, current.kind, fullText, args.range, false);
|
|
115
|
+
} catch {
|
|
116
|
+
refuse();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Walk only the requested, byte-bounded prefix. Sanitizer growth is measured by a bounded binary search over that prefix. */
|
|
121
|
+
function render(id: string, kind: SourceMeta["kind"], fullText: string, range: SourceReadArgs["range"], isPrivate: boolean): SourceReadResult {
|
|
122
|
+
const offset = range?.offset ?? 0, limit = range?.length ?? Number.MAX_SAFE_INTEGER;
|
|
123
|
+
const iterator = fullText[Symbol.iterator]();
|
|
124
|
+
let skipped = 0;
|
|
125
|
+
while (skipped < offset) {
|
|
126
|
+
if (iterator.next().done) return isPrivate
|
|
127
|
+
? { id, kind, private: true, text: "", next: offset }
|
|
128
|
+
: { id, kind, private: false, text: "", next: offset };
|
|
129
|
+
skipped++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const chars: string[] = [];
|
|
133
|
+
let rawBytes = 0, exhausted = false, stoppedAtByteLimit = false;
|
|
134
|
+
while (chars.length < limit) {
|
|
135
|
+
const next = iterator.next();
|
|
136
|
+
if (next.done) { exhausted = true; break; }
|
|
137
|
+
const point = next.value;
|
|
138
|
+
const size = Buffer.byteLength(point, "utf8");
|
|
139
|
+
if (rawBytes + size > MAX_SOURCE_READ_BYTES) { stoppedAtByteLimit = true; break; }
|
|
140
|
+
chars.push(point);
|
|
141
|
+
rawBytes += size;
|
|
142
|
+
}
|
|
143
|
+
const hasMoreAfterSelection = stoppedAtByteLimit || (!exhausted && chars.length >= limit && !iterator.next().done);
|
|
144
|
+
const fits = (count: number): boolean => {
|
|
145
|
+
const candidate = sanitize(chars.slice(0, count).join(""));
|
|
146
|
+
const needsMarker = hasMoreAfterSelection || count < chars.length;
|
|
147
|
+
return Buffer.byteLength(candidate, "utf8") + (needsMarker ? Buffer.byteLength(CUT_MARKER, "utf8") : 0) <= MAX_SOURCE_READ_BYTES;
|
|
148
|
+
};
|
|
149
|
+
let low = 0, high = chars.length;
|
|
150
|
+
while (low < high) {
|
|
151
|
+
const middle = Math.ceil((low + high) / 2);
|
|
152
|
+
if (fits(middle)) low = middle; else high = middle - 1;
|
|
153
|
+
}
|
|
154
|
+
const consumed = low;
|
|
155
|
+
let text = sanitize(chars.slice(0, consumed).join(""));
|
|
156
|
+
const hasMore = hasMoreAfterSelection || consumed < chars.length;
|
|
157
|
+
if (hasMore) text += CUT_MARKER;
|
|
158
|
+
const next = offset + consumed;
|
|
159
|
+
return isPrivate ? { id, kind, private: true, text, next } : { id, kind, private: false, text, next };
|
|
160
|
+
}
|