@wirecat/cli-core 0.362.0 → 0.363.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cli/index.d.ts +1 -1
  3. package/dist/cli/index.js +1 -1
  4. package/dist/cli/meeting-findings-input.d.ts +2 -1
  5. package/dist/cli/meeting-findings-input.js +10 -7
  6. package/dist/context/evidence.d.ts +6 -0
  7. package/dist/context/evidence.js +4 -0
  8. package/dist/errors/reasons.js +2 -0
  9. package/dist/meetings/findings-rules.d.ts +60 -0
  10. package/dist/meetings/findings-rules.js +117 -0
  11. package/dist/meetings/findings.d.ts +10 -48
  12. package/dist/meetings/findings.js +10 -116
  13. package/dist/meetings/index.d.ts +1 -0
  14. package/dist/meetings/index.js +1 -0
  15. package/dist/services/context-evidence.d.ts +2 -1
  16. package/dist/services/context-evidence.js +1 -0
  17. package/dist/services/index.d.ts +3 -2
  18. package/dist/services/index.js +2 -1
  19. package/dist/services/project-gathering.d.ts +1 -0
  20. package/dist/services/project-gathering.js +3 -0
  21. package/dist/services/proposal-tasks.js +5 -0
  22. package/dist/store/evidence-findings.d.ts +63 -0
  23. package/dist/store/evidence-findings.js +15 -0
  24. package/dist/store/index.d.ts +1 -0
  25. package/dist/store/index.js +1 -0
  26. package/dist/store/sqlite/context-evidence.d.ts +9 -1
  27. package/dist/store/sqlite/context-evidence.js +43 -1
  28. package/dist/store/sqlite/evidence-findings.d.ts +3 -0
  29. package/dist/store/sqlite/evidence-findings.js +172 -0
  30. package/dist/store/sqlite/findings-core.d.ts +17 -0
  31. package/dist/store/sqlite/findings-core.js +108 -0
  32. package/dist/store/sqlite/meeting-findings.js +47 -121
  33. package/dist/store/sqlite/proposed-actions.d.ts +4 -1
  34. package/dist/store/sqlite/proposed-actions.js +12 -4
  35. package/dist/store/store.d.ts +5 -1
  36. package/dist/store/store.js +10 -1
  37. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ import { type FindingOutcome, type FindingsInput, type FindingsReceipt } from "../meetings/index.js";
2
+ export declare const EVIDENCE_FINDINGS_KIND = "evidence_findings";
3
+ export declare const validateEvidenceFindings: (input: FindingsInput) => FindingsInput;
4
+ /** Who decided: the owner, or an agent the host let decide for the owner, with its reason. */
5
+ export type EvidenceFindingsDecider = "owner" | {
6
+ bot: string;
7
+ model?: string;
8
+ reason: string;
9
+ };
10
+ export interface EvidenceFindingsReceipt extends FindingsReceipt {
11
+ decision: {
12
+ by: "owner";
13
+ } | {
14
+ by: "agent";
15
+ bot: string;
16
+ model: string | null;
17
+ reason: string;
18
+ };
19
+ }
20
+ export interface EvidenceFindingsBatch {
21
+ id: string;
22
+ project: string;
23
+ input: FindingsInput;
24
+ fingerprint: string;
25
+ status: "proposed" | "reviewed";
26
+ author: {
27
+ type: "person" | "bot";
28
+ id: string;
29
+ name: string | null;
30
+ };
31
+ decidedBy: {
32
+ type: "person" | "bot";
33
+ id: string;
34
+ } | null;
35
+ decidedAt: string | null;
36
+ previous: FindingOutcome[];
37
+ receipt: EvidenceFindingsReceipt | null;
38
+ }
39
+ /**
40
+ * Cited facts and follow-ups about one project from stored messages, mail and documents, staged by an agent for
41
+ * review. A sibling of meeting findings: the same rules, evidence checked against the current stored text.
42
+ */
43
+ export interface EvidenceFindingsStore {
44
+ stage(input: FindingsInput, agent: {
45
+ bot: string;
46
+ model?: string;
47
+ }): Promise<EvidenceFindingsBatch>;
48
+ get(id: string): Promise<EvidenceFindingsBatch>;
49
+ list(options?: {
50
+ project?: string;
51
+ limit?: number;
52
+ offset?: number;
53
+ status?: "proposed" | "reviewed";
54
+ }): Promise<{
55
+ items: EvidenceFindingsBatch[];
56
+ hasMore: boolean;
57
+ }>;
58
+ review(id: string, input: {
59
+ fingerprint: string;
60
+ accept: string[];
61
+ by?: EvidenceFindingsDecider;
62
+ }): Promise<EvidenceFindingsBatch>;
63
+ }
@@ -0,0 +1,15 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { formatLocator, parseLocator } from "../domain/locator.js";
3
+ import { validateFindings } from "../meetings/index.js";
4
+ export const EVIDENCE_FINDINGS_KIND = "evidence_findings";
5
+ const reference = (raw) => {
6
+ if (/^document:[1-9]\d*$/.test(raw))
7
+ return raw;
8
+ try {
9
+ return formatLocator(parseLocator(raw));
10
+ }
11
+ catch {
12
+ throw new CliError("validation_error", "finding evidence is a msg: or document: reference");
13
+ }
14
+ };
15
+ export const validateEvidenceFindings = (input) => validateFindings(input, { plural: "evidence findings", singular: "evidence finding", cited: "reference", reference });
@@ -9,6 +9,7 @@ export type { DocumentTransferOptions, DocumentTransferPreview, DocumentTransfer
9
9
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
10
10
  export type { CacheDatabase, CacheStatement, OpenDatabase, SqlValue } from "./driver.js";
11
11
  export { PRAGMAS } from "./driver.js";
12
+ export { EVIDENCE_FINDINGS_KIND, type EvidenceFindingsBatch, type EvidenceFindingsDecider, type EvidenceFindingsReceipt, type EvidenceFindingsStore, validateEvidenceFindings, } from "./evidence-findings.js";
12
13
  export type { MeetingFindingsBatch, MeetingFindingsStore } from "./meeting-findings.js";
13
14
  export { MIGRATIONS, type Migration, migrate } from "./migrations.js";
14
15
  export { openCache } from "./open.js";
@@ -2,6 +2,7 @@ export { FACT_STATUSES, FACT_TYPES, MEMORY_KINDS, MEMORY_SCOPES, MEMORY_STATUSES
2
2
  export { createTagRedirectIndex } from "../domain/tag-redirects.js";
3
3
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
4
4
  export { PRAGMAS } from "./driver.js";
5
+ export { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "./evidence-findings.js";
5
6
  export { MIGRATIONS, migrate } from "./migrations.js";
6
7
  export { openCache } from "./open.js";
7
8
  export { storePath } from "./path.js";
@@ -1,7 +1,15 @@
1
- import { type ContextEvidenceReader, type ContextIndex } from "../../context/evidence.js";
1
+ import { type ContextEvidenceReader, type ContextIndex, type ContextRecordReader } from "../../context/evidence.js";
2
2
  import type { Stemmer, Stemmers } from "../../search/stem.js";
3
3
  import type { StoreContext } from "./open.js";
4
4
  /** No index fills, model loads or record hydration outside the read transaction. */
5
5
  export declare const contextEvidenceOver: ({ database }: Pick<StoreContext, "database">) => ContextEvidenceReader;
6
6
  /** The same fills a mail search and the notes search run before reading; a message is indexed when it is saved. */
7
7
  export declare const contextIndexOver: ({ database }: Pick<StoreContext, "database">, stemmerFor: (stemmers: Stemmers) => Stemmer) => ContextIndex;
8
+ /** The cited record by reference, read with the context reader's own columns, so fingerprints agree. */
9
+ export declare const contextRecordOf: (database: StoreContext["database"], ref: string) => {
10
+ ref: string;
11
+ title: string | null;
12
+ text: string;
13
+ revision: number | null;
14
+ } | null;
15
+ export declare const contextRecordsOver: ({ database }: Pick<StoreContext, "database">) => ContextRecordReader;
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { contextCursorScope, contextEvidenceRequest, } from "../../context/evidence.js";
3
- import { formatLocator } from "../../domain/locator.js";
3
+ import { formatLocator, parseLocator } from "../../domain/locator.js";
4
4
  import { NORMALIZER_VERSION, normalize } from "../normalize.js";
5
5
  import { contextDocumentTime, contextDocumentTimeKind, contextDocumentUrl } from "./context-document-source.js";
6
6
  import { readMeetingContextEvidence } from "./context-meeting-source.js";
@@ -178,3 +178,45 @@ export const contextIndexOver = ({ database }, stemmerFor) => ({
178
178
  return { mail, documents };
179
179
  },
180
180
  });
181
+ /** The cited record by reference, read with the context reader's own columns, so fingerprints agree. */
182
+ export const contextRecordOf = (database, ref) => {
183
+ if (typeof ref !== "string")
184
+ return null;
185
+ const document = /^document:([1-9]\d*)$/.exec(ref);
186
+ if (document) {
187
+ const corpus = corpora.document;
188
+ const row = database
189
+ .prepare(`SELECT ${corpus.title} AS title,${corpus.body} AS body,${corpus.revision} AS revision FROM documents n
190
+ WHERE n.id=? AND n.deleted_at IS NULL AND ${corpus.where}`)
191
+ .get(Number(document[1]));
192
+ return row
193
+ ? {
194
+ ref,
195
+ title: row.title == null ? null : String(row.title),
196
+ text: String(row.body),
197
+ revision: row.revision == null ? null : Number(row.revision),
198
+ }
199
+ : null;
200
+ }
201
+ let at;
202
+ try {
203
+ at = parseLocator(ref);
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ const corpus = at.provider === "email" ? corpora.mail : corpora.message;
209
+ const row = database
210
+ .prepare(`SELECT ${corpus.title} AS title,${corpus.body} AS body FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id
211
+ ${corpus.join} WHERE a.provider=? AND a.external_id=? AND c.external_id=? AND n.external_id=?
212
+ AND n.deleted_at IS NULL AND ${corpus.where}`)
213
+ .get(at.provider, at.account, at.chat, at.message);
214
+ return row
215
+ ? { ref, title: row.title == null ? null : String(row.title), text: String(row.body), revision: null }
216
+ : null;
217
+ };
218
+ export const contextRecordsOver = ({ database }) => ({
219
+ async read(ref) {
220
+ return read(database, () => contextRecordOf(database, ref));
221
+ },
222
+ });
@@ -0,0 +1,3 @@
1
+ import { type EvidenceFindingsStore } from "../evidence-findings.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export declare const evidenceFindingsStoreOver: (context: StoreContext) => EvidenceFindingsStore;
@@ -0,0 +1,172 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { contextEvidenceFingerprint } from "../../context/evidence.js";
3
+ import { findingFingerprint, findingHash } from "../../meetings/index.js";
4
+ import { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "../evidence-findings.js";
5
+ import { contextRecordOf } from "./context-evidence.js";
6
+ import { findingsAgent, findingsCoreOver } from "./findings-core.js";
7
+ import { authorActor } from "./memories.js";
8
+ import { proposalOperationsOver } from "./proposed-actions.js";
9
+ import { write } from "./transaction.js";
10
+ const stale = () => {
11
+ throw new CliError("validation_error", "evidence findings changed since they were read; stage a new batch", {
12
+ reason: "evidence-findings.stale",
13
+ });
14
+ };
15
+ const invalid = (message) => {
16
+ throw new CliError("validation_error", message);
17
+ };
18
+ const TEXT_BUDGET = 262144;
19
+ export const evidenceFindingsStoreOver = (context) => {
20
+ const { database } = context;
21
+ const proposals = proposalOperationsOver(context);
22
+ const core = findingsCoreOver(context, EVIDENCE_FINDINGS_KIND);
23
+ const load = (id) => {
24
+ if (typeof id !== "string" || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id)))
25
+ return invalid("a findings batch needs a stored ID");
26
+ const row = database
27
+ .prepare("SELECT length(CAST(payload AS BLOB)) AS bytes,length(CAST(result AS BLOB)) AS result_bytes FROM proposed_actions WHERE id=? AND account_id IS NULL AND kind=?")
28
+ .get(Number(id), EVIDENCE_FINDINGS_KIND);
29
+ if (!row)
30
+ throw new CliError("not_found", "the evidence findings batch is unavailable");
31
+ if (Number(row.bytes) > 100000 || Number(row.result_bytes) > 65536)
32
+ return invalid("stored findings exceed the read budget");
33
+ return proposals.get(id);
34
+ };
35
+ const view = (proposal) => {
36
+ const input = proposal.payload;
37
+ if (!input || !proposal.target || !["proposed", "approved"].includes(proposal.status))
38
+ return invalid("invalid stored findings batch");
39
+ return {
40
+ id: proposal.id,
41
+ project: proposal.target,
42
+ input: { schemaVersion: 1, key: input.key, extractor: input.extractor, findings: input.findings },
43
+ fingerprint: findingHash({ input, author: proposal.proposedBy.id }),
44
+ status: proposal.status === "proposed" ? "proposed" : "reviewed",
45
+ author: proposal.proposedBy,
46
+ decidedBy: proposal.decidedBy,
47
+ decidedAt: proposal.decidedAt,
48
+ previous: proposal.result
49
+ ? proposal.result.items
50
+ : input.findings.flatMap((finding) => {
51
+ const held = core.previous(null, findingFingerprint(finding));
52
+ return held ? [{ ...held, id: finding.id, reused: true }] : [];
53
+ }),
54
+ receipt: proposal.result,
55
+ };
56
+ };
57
+ const snapshot = (input) => {
58
+ let readBytes = 0;
59
+ return core.snapshot(input, (evidence, _finding, _project, seen) => {
60
+ if (seen.has(evidence.reference)) {
61
+ if (seen.get(evidence.reference) !== evidence.fingerprint)
62
+ return stale();
63
+ return;
64
+ }
65
+ const record = contextRecordOf(database, evidence.reference);
66
+ if (!record)
67
+ return stale();
68
+ readBytes += Buffer.byteLength(record.text) + Buffer.byteLength(record.title ?? "");
69
+ if (readBytes > TEXT_BUDGET)
70
+ return invalid(`finding evidence exceeds the ${TEXT_BUDGET}-byte stored text budget`);
71
+ const fingerprint = contextEvidenceFingerprint(record);
72
+ if (fingerprint !== evidence.fingerprint)
73
+ return stale();
74
+ seen.set(evidence.reference, fingerprint);
75
+ });
76
+ };
77
+ const decider = (by) => {
78
+ if (by === undefined || by === "owner")
79
+ return { author: "owner", decision: { by: "owner" } };
80
+ const raw = by;
81
+ const agent = findingsAgent({
82
+ bot: raw.bot,
83
+ ...(raw.model === undefined ? {} : { model: raw.model }),
84
+ });
85
+ if (typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 2000)
86
+ return invalid("an agent deciding for the owner gives a reason of 1–2000 characters");
87
+ return {
88
+ author: agent,
89
+ decision: { by: "agent", bot: agent.bot, model: agent.model ?? null, reason: raw.reason.trim() },
90
+ };
91
+ };
92
+ return {
93
+ stage: async (raw, rawAgent) => {
94
+ const input = validateEvidenceFindings(raw);
95
+ const projects = new Set(input.findings.map((finding) => finding.project));
96
+ if (projects.size !== 1)
97
+ return invalid("an evidence findings batch is about one project");
98
+ const by = findingsAgent(rawAgent);
99
+ return write(database, () => {
100
+ const { actor } = authorActor(context, by);
101
+ const inputHash = findingHash({ input, model: by.model ?? null });
102
+ const held = database
103
+ .prepare("SELECT id FROM proposed_actions WHERE account_id IS NULL AND kind=? AND proposed_by_type=? AND proposed_by_id=? AND json_extract(payload,'$.key')=? ORDER BY id LIMIT 1")
104
+ .get(EVIDENCE_FINDINGS_KIND, actor.type, actor.id, input.key);
105
+ if (held) {
106
+ const proposal = load(String(held.id));
107
+ if (proposal.payload.inputHash !== inputHash)
108
+ return invalid("the findings batch key was already used for different input");
109
+ return view(proposal);
110
+ }
111
+ const snapshotHash = snapshot(input);
112
+ const fingerprints = input.findings.map(findingFingerprint);
113
+ if (new Set(fingerprints).size !== fingerprints.length)
114
+ return invalid("the batch contains duplicate findings");
115
+ return view(proposals.propose({
116
+ kind: EVIDENCE_FINDINGS_KIND,
117
+ by,
118
+ target: [...projects][0],
119
+ payload: { ...input, inputHash, snapshotHash, agentModel: by.model ?? null },
120
+ }));
121
+ });
122
+ },
123
+ get: async (id) => view(load(id)),
124
+ list: async ({ project, limit = 20, offset = 0, status } = {}) => {
125
+ if (status !== undefined && status !== "proposed" && status !== "reviewed")
126
+ return invalid("finding status is proposed or reviewed");
127
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50 || !Number.isSafeInteger(offset) || offset < 0)
128
+ return invalid("findings lists take 1–50 items and a nonnegative offset");
129
+ const projectId = project === undefined ? null : /^project:([1-9]\d*)$/.exec(project)?.[1];
130
+ if (projectId === undefined)
131
+ return invalid("a findings list filters by a project reference");
132
+ const ids = database
133
+ .prepare("SELECT id FROM proposed_actions WHERE account_id IS NULL AND kind=? AND (? IS NULL OR (target_type='project' AND target_id=?)) AND (? IS NULL OR status=?) ORDER BY id DESC LIMIT ? OFFSET ?")
134
+ .all(EVIDENCE_FINDINGS_KIND, projectId, projectId === null ? null : Number(projectId), status ?? null, status === "reviewed" ? "approved" : (status ?? null), limit + 1, offset);
135
+ return { items: ids.slice(0, limit).map((row) => view(load(String(row.id)))), hasMore: ids.length > limit };
136
+ },
137
+ review: async (id, decision) => write(database, () => {
138
+ const proposal = load(id);
139
+ const batch = view(proposal);
140
+ if (!decision || typeof decision.fingerprint !== "string" || decision.fingerprint !== batch.fingerprint)
141
+ return stale();
142
+ if (!Array.isArray(decision.accept) ||
143
+ decision.accept.length > 16 ||
144
+ new Set(decision.accept).size !== decision.accept.length ||
145
+ decision.accept.some((candidate) => !batch.input.findings.some((finding) => finding.id === candidate)))
146
+ return invalid("accept only unique IDs shown in this batch");
147
+ const accepted = [...decision.accept].sort();
148
+ const { author, decision: decidedBy } = decider(decision.by);
149
+ if (batch.receipt) {
150
+ if (JSON.stringify(batch.receipt.accepted) !== JSON.stringify(accepted))
151
+ return invalid("this findings batch was already reviewed with a different decision");
152
+ return batch;
153
+ }
154
+ const input = proposal.payload;
155
+ if (snapshot(batch.input) !== input.snapshotHash)
156
+ return stale();
157
+ if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
158
+ return invalid("a findings author must be an agent");
159
+ const agent = {
160
+ bot: proposal.proposedBy.name,
161
+ ...(input.agentModel === null ? {} : { model: input.agentModel }),
162
+ };
163
+ const items = core.outcomes(null, batch.input, accepted, agent);
164
+ return view(proposals.approve(id, {
165
+ previewFingerprint: batch.fingerprint,
166
+ accepted,
167
+ items,
168
+ decision: decidedBy,
169
+ }, author));
170
+ }),
171
+ };
172
+ };
@@ -0,0 +1,17 @@
1
+ import { type Finding, type FindingEvidence, type FindingOutcome, type FindingsInput } from "../../meetings/index.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export interface FindingsAgent {
4
+ bot: string;
5
+ model?: string;
6
+ }
7
+ export declare const findingsAgent: (agent: FindingsAgent) => FindingsAgent;
8
+ /**
9
+ * What meeting and evidence findings share: the project and assignee snapshot, earlier decisions on an unchanged
10
+ * candidate, creating the accepted facts and tasks, and the receipt items. Each kind checks its own evidence,
11
+ * including a reference it already saw for another finding.
12
+ */
13
+ export declare const findingsCoreOver: (context: StoreContext, kind: string) => {
14
+ snapshot: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string, seen: Map<string, unknown>) => void) => string;
15
+ previous: (account: number | null, fingerprint: string) => FindingOutcome | undefined;
16
+ outcomes: (account: number | null, input: FindingsInput, accepted: string[], agent: FindingsAgent) => FindingOutcome[];
17
+ };
@@ -0,0 +1,108 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { findingFingerprint, findingHash, } from "../../meetings/index.js";
3
+ import { factOperationsOver } from "./facts.js";
4
+ import { linkEvidence } from "./memories.js";
5
+ import { requiredThing } from "./notes.js";
6
+ import { taskRowOf } from "./things.js";
7
+ import { workTaskOperationsOver } from "./work-tasks.js";
8
+ const invalid = (message) => {
9
+ throw new CliError("validation_error", message);
10
+ };
11
+ export const findingsAgent = (agent) => {
12
+ if (!agent ||
13
+ typeof agent.bot !== "string" ||
14
+ !agent.bot.trim() ||
15
+ agent.bot.length > 200 ||
16
+ agent.bot === "owner" ||
17
+ (agent.model !== undefined && (typeof agent.model !== "string" || agent.model.length > 200)))
18
+ return invalid("findings must name an agent author independently of their payload");
19
+ return { bot: agent.bot, ...(agent.model === undefined ? {} : { model: agent.model }) };
20
+ };
21
+ /**
22
+ * What meeting and evidence findings share: the project and assignee snapshot, earlier decisions on an unchanged
23
+ * candidate, creating the accepted facts and tasks, and the receipt items. Each kind checks its own evidence,
24
+ * including a reference it already saw for another finding.
25
+ */
26
+ export const findingsCoreOver = (context, kind) => {
27
+ const { database } = context;
28
+ const facts = factOperationsOver(context);
29
+ const tasks = workTaskOperationsOver(context);
30
+ const snapshot = (input, cite) => {
31
+ const seen = new Map();
32
+ for (const finding of input.findings) {
33
+ const project = requiredThing(database, finding.project, "the project");
34
+ if (project.type !== "project")
35
+ return invalid("a finding needs an owner project");
36
+ const projectRow = database
37
+ .prepare("SELECT id,key,name,description,type,scope,updated_at FROM projects WHERE id=? AND account_id IS NULL AND deleted_at IS NULL")
38
+ .get(project.id);
39
+ if (!projectRow)
40
+ throw new CliError("not_found", "the owner project is unavailable");
41
+ seen.set(finding.project, projectRow);
42
+ if (finding.kind === "task" && finding.assignee) {
43
+ const person = requiredThing(database, finding.assignee, "the assignee");
44
+ if (person.type !== "person")
45
+ return invalid("the assignee must be a stored person");
46
+ const personRow = database.prepare("SELECT id,name,updated_at FROM persons WHERE id=?").get(person.id);
47
+ if (!personRow)
48
+ throw new CliError("not_found", "the assignee is unavailable");
49
+ seen.set(finding.assignee, personRow);
50
+ }
51
+ for (const evidence of finding.evidence)
52
+ cite(evidence, finding, String(project.id), seen);
53
+ }
54
+ return findingHash([...seen].sort(([a], [b]) => a.localeCompare(b)));
55
+ };
56
+ const previous = (account, fingerprint) => {
57
+ const row = database
58
+ .prepare("SELECT substr(j.value,1,4096) AS item FROM proposed_actions p,json_each(p.result,'$.items') j WHERE p.account_id IS ? AND p.kind=? AND p.status='approved' AND json_extract(j.value,'$.fingerprint')=? ORDER BY p.id LIMIT 1")
59
+ .get(account, kind, fingerprint);
60
+ return row ? JSON.parse(String(row.item)) : undefined;
61
+ };
62
+ const create = (finding, agent) => {
63
+ const evidence = finding.evidence.map((item) => item.reference);
64
+ if (finding.kind === "fact") {
65
+ const fact = facts.add({
66
+ type: finding.type,
67
+ statement: finding.statement,
68
+ project: finding.project,
69
+ evidence,
70
+ by: agent,
71
+ });
72
+ return facts.accept(fact.ref).ref;
73
+ }
74
+ const task = tasks.create({
75
+ project: finding.project,
76
+ title: finding.title,
77
+ type: finding.type,
78
+ description: `${finding.commitment}: ${finding.reason}`,
79
+ ...(finding.dueAt ? { dueAt: new Date(finding.dueAt) } : {}),
80
+ }, agent);
81
+ const id = taskRowOf(database, task.id);
82
+ if (id === undefined)
83
+ throw new CliError("configuration_error", "created finding task is unavailable");
84
+ linkEvidence(context, { type: finding.type, id }, evidence, "agent");
85
+ if (finding.assignee)
86
+ tasks.assign(task.id, { to: finding.assignee, role: "assignee" }, agent);
87
+ return task.ref;
88
+ };
89
+ /** Reuses an earlier decision on an unchanged candidate; refuses to accept one that was rejected. */
90
+ const outcomes = (account, input, accepted, agent) => input.findings.map((finding) => {
91
+ const fingerprint = findingFingerprint(finding);
92
+ const prior = previous(account, fingerprint);
93
+ if (prior) {
94
+ if (accepted.includes(finding.id) && prior.status === "rejected")
95
+ return invalid("this unchanged finding was already rejected; changed evidence needs a new candidate");
96
+ return { ...prior, id: finding.id, reused: true };
97
+ }
98
+ const status = accepted.includes(finding.id) ? "accepted" : "rejected";
99
+ return {
100
+ id: finding.id,
101
+ fingerprint,
102
+ status,
103
+ reference: status === "accepted" ? create(finding, agent) : null,
104
+ reused: false,
105
+ };
106
+ });
107
+ return { snapshot, previous, outcomes };
108
+ };