patchwork-os 1.2.0-beta.2.canary.682 → 1.2.0-beta.2.canary.686

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.
@@ -100,3 +100,18 @@ export interface ShadowSummary {
100
100
  export declare function summariseShadowLog(opts?: {
101
101
  dir?: string;
102
102
  }): ShadowSummary;
103
+ /**
104
+ * Parse well-formed rows out of raw ledger text.
105
+ *
106
+ * The single parse rule, shared by the summary and by promotion. Two readers of
107
+ * one append-only file with two copies of "what counts as a row" is how a
108
+ * report and the thing it reports on come to disagree — and here the report is
109
+ * what a person reads before deciding to promote.
110
+ *
111
+ * Malformed lines are SKIPPED and counted in nothing: a half-written row from
112
+ * an interrupted append is not evidence, and must not inflate a number someone
113
+ * is about to make a trust decision on.
114
+ */
115
+ export declare function parseShadowRows(text: string): ShadowOutcomeRow[];
116
+ /** Every well-formed row in the ledger, oldest first. */
117
+ export declare function readShadowRows(dir?: string): ShadowOutcomeRow[];
@@ -130,6 +130,28 @@ export function summariseShadowLog(opts = {}) {
130
130
  return empty;
131
131
  }
132
132
  const out = { ...empty };
133
+ for (const row of parseShadowRows(text)) {
134
+ out.total++;
135
+ out[row.disposition]++;
136
+ if (row.wouldCountAsEvidence)
137
+ out.wouldCount++;
138
+ }
139
+ return out;
140
+ }
141
+ /**
142
+ * Parse well-formed rows out of raw ledger text.
143
+ *
144
+ * The single parse rule, shared by the summary and by promotion. Two readers of
145
+ * one append-only file with two copies of "what counts as a row" is how a
146
+ * report and the thing it reports on come to disagree — and here the report is
147
+ * what a person reads before deciding to promote.
148
+ *
149
+ * Malformed lines are SKIPPED and counted in nothing: a half-written row from
150
+ * an interrupted append is not evidence, and must not inflate a number someone
151
+ * is about to make a trust decision on.
152
+ */
153
+ export function parseShadowRows(text) {
154
+ const rows = [];
133
155
  for (const line of text.split("\n")) {
134
156
  if (!line.trim())
135
157
  continue;
@@ -145,11 +167,22 @@ export function summariseShadowLog(opts = {}) {
145
167
  row.disposition !== "unknown") {
146
168
  continue;
147
169
  }
148
- out.total++;
149
- out[row.disposition]++;
150
- if (row.wouldCountAsEvidence)
151
- out.wouldCount++;
170
+ if (typeof row.ref !== "string" || !row.ref)
171
+ continue;
172
+ rows.push(row);
173
+ }
174
+ return rows;
175
+ }
176
+ /** Every well-formed row in the ledger, oldest first. */
177
+ export function readShadowRows(dir) {
178
+ const p = shadowLogPath(dir);
179
+ if (!existsSync(p))
180
+ return [];
181
+ try {
182
+ return parseShadowRows(readFileSync(p, "utf-8"));
183
+ }
184
+ catch {
185
+ return [];
152
186
  }
153
- return out;
154
187
  }
155
188
  //# sourceMappingURL=outcomeShadowLog.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnE,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIpD;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,6BAA6B,CAAC;AAEjE,MAAM,UAAU,aAAa,CAAC,QAAiB;IAC7C,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,aAAa,EAAE,EAAE,mBAAmB,CAAC,CAAC;AACrE,CAAC;AAwBD,qEAAqE;AACrE,MAAM,UAAU,oBAAoB,CAAC,CAAqB;IACxD,OAAO,CAAC,KAAK,SAAS,CAAC;AACzB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmD,EACnD,OAAyB,EAAE;IAE3B,MAAM,IAAI,GAAqB;QAC7B,GAAG,GAAG;QACN,oBAAoB,EAAE,oBAAoB,CAAC,GAAG,CAAC,WAAW,CAAC;KAC5D,CAAC;IACF,IAAI,CAAC;QACH,cAAc,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;QACxE,+BAA+B;IACjC,CAAC;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAyB,EAAE;IAE3B,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,MAAM,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC;IAC/B,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAC;IACb,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,GAAqB,CAAC;QAC1B,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqB,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IAAI,OAAO,GAAG,CAAC,GAAG,KAAK,QAAQ,IAAI,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YACpE,SAAS;QACX,CAAC;QACD,MAAM,IAAI,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,IAAI,KAAK,SAAS,IAAI,GAAG,CAAC,QAAQ,GAAG,IAAI;YAC3C,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAWD;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAyB,EAAE;IAC5D,MAAM,KAAK,GAAkB;QAC3B,KAAK,EAAE,CAAC;QACR,SAAS,EAAE,CAAC;QACZ,IAAI,EAAE,CAAC;QACP,OAAO,EAAE,CAAC;QACV,UAAU,EAAE,CAAC;KACd,CAAC;IACF,MAAM,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACjC,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,GAAG,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC;IACzB,KAAK,MAAM,GAAG,IAAI,eAAe,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,GAAG,CAAC,KAAK,EAAE,CAAC;QACZ,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,CAAC;QACvB,IAAI,GAAG,CAAC,oBAAoB;YAAE,GAAG,CAAC,UAAU,EAAE,CAAC;IACjD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,IAAI,GAAuB,EAAE,CAAC;IACpC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,GAAqB,CAAC;QAC1B,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqB,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IACE,GAAG,CAAC,WAAW,KAAK,WAAW;YAC/B,GAAG,CAAC,WAAW,KAAK,MAAM;YAC1B,GAAG,CAAC,WAAW,KAAK,SAAS,EAC7B,CAAC;YACD,SAAS;QACX,CAAC;QACD,IAAI,OAAO,GAAG,CAAC,GAAG,KAAK,QAAQ,IAAI,CAAC,GAAG,CAAC,GAAG;YAAE,SAAS;QACtD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,cAAc,CAAC,GAAY;IACzC,MAAM,CAAC,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IAC7B,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,EAAE,CAAC;IAC9B,IAAI,CAAC;QACH,OAAO,eAAe,CAAC,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC"}
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Promote graded Butler shadow rows into the trust ledger — flag-gated, OFF.
3
+ *
4
+ * ## Why this is a separate module and not a branch in the ingester
5
+ *
6
+ * `outcomeIngester.ts` and `errandOutcomeGrader.ts` each carry a test asserting
7
+ * they cannot reach `OutcomeStore` — not "do not call it", but *do not import
8
+ * anything that could*. That is what makes "shadow-only" a structural fact
9
+ * rather than a promise, and it is why a reviewer can read the grader without
10
+ * checking whether some path in it writes evidence.
11
+ *
12
+ * Adding promotion there would have meant deleting those guards. This module
13
+ * exists so they stay exactly as they are: the grader stays a pure function of
14
+ * observed state, the ingester stays unable to write trust, and the single
15
+ * place that can is this one, which does nothing else.
16
+ *
17
+ * ## Operator path only
18
+ *
19
+ * Not registered as a recipe tool, and a test asserts it. A recipe step runs AS
20
+ * the worker, so a worker able to reach this could promote its own filings —
21
+ * manufacturing the evidence that raises its own dial. Same reasoning as
22
+ * `outcomes confirm|reject`, which is also operator-only for exactly this.
23
+ *
24
+ * ## Flag-gated OFF, and the flag is about EVIDENCE, not about code
25
+ *
26
+ * Promotion is one-way in the way that matters: once a row is folded, the dial
27
+ * it moved cannot be un-moved by deleting the row, because trust replay has
28
+ * already absorbed it into a checkpoint. So the gate is not "is the code
29
+ * finished" — it is "is there enough evidence to start counting this channel".
30
+ *
31
+ * At the time of writing the shadow ledger holds ONE confirmed row. That is a
32
+ * real positive act, correctly attributed, and it is one row. The flag exists so
33
+ * the code can be reviewed, tested and merged now, and the trust decision made
34
+ * later on more of them, by a person, deliberately.
35
+ *
36
+ * ## What is promoted, and what is refused
37
+ *
38
+ * Only `confirmed` and `junk` — the two dispositions that represent a POSITIVE
39
+ * ACT by the operator. `unknown` is never promoted under any flag, because
40
+ * "nobody has acted yet" is not evidence in either direction, and folding it as
41
+ * good is the trust-by-neglect defect closed four times already in this
42
+ * subsystem (#1064, #1318/#1319, #1320, #1322).
43
+ *
44
+ * ## `origin: "ingester"`, never `"manual"`
45
+ *
46
+ * `OutcomeStore` treats a `"manual"` disposition as STICKY against later
47
+ * ingester writes, because it represents a human's explicit judgment. Nothing
48
+ * here is that: it is an automated grade derived from an HTTP response. Marking
49
+ * these `"manual"` would let an automated observation permanently override an
50
+ * operator who had explicitly ruled the other way — precisely inverting the
51
+ * precedence the field exists to protect.
52
+ */
53
+ /** Feature flag. Absent or anything but a truthy value ⇒ promotion refuses. */
54
+ export declare const BUTLER_PROMOTE_FLAG = "PATCHWORK_FLAG_BUTLER_PROMOTE";
55
+ export interface PromoteOptions {
56
+ /** `~/.patchwork` override (tests). */
57
+ patchworkDir: string;
58
+ /** Clock, injected so a written record is reproducible in tests. */
59
+ now?: number;
60
+ /** Report only; write nothing. Independent of the flag. */
61
+ dryRun?: boolean;
62
+ /** Flag override, injected rather than read from the environment in tests. */
63
+ enabled?: boolean;
64
+ }
65
+ export interface PromoteResult {
66
+ /** Every graded row considered — the DENOMINATOR, always reported. */
67
+ rows: number;
68
+ /** Rows whose disposition is `confirmed` or `junk`. */
69
+ promotable: number;
70
+ /** Rows withheld because the grader said `unknown`. */
71
+ withheld: number;
72
+ /** Rows written to the outcome log. Zero on a dry run or with the flag off. */
73
+ promoted: number;
74
+ /** Rows already carrying the same disposition in the trust ledger. */
75
+ alreadyRecorded: number;
76
+ /** Rows whose stored ref could not be turned back into a key, with why. */
77
+ unkeyable: {
78
+ ref: string;
79
+ reason: string;
80
+ }[];
81
+ /** True when nothing was written because the flag is off. */
82
+ blockedByFlag: boolean;
83
+ }
84
+ /**
85
+ * Split a stored `"<tool>:<id>"` key back into its parts.
86
+ *
87
+ * On the FIRST colon, which is the inverse of how `canonicalActionRef` joins
88
+ * them. Tool ids in this repo are dot-separated (`todoist.create_task`) and
89
+ * never contain a colon, while connector ids routinely do — so splitting on the
90
+ * last colon, or on every colon, would silently rekey the action and attach a
91
+ * confirmation to nothing.
92
+ *
93
+ * A URL-shaped key is refused rather than split. Those belong to the legacy
94
+ * `issueUrl` namespace, which `canonicalActionRef` explicitly refuses to
95
+ * produce; treating one as a `ref` here would write a row under a key no reader
96
+ * looks for.
97
+ */
98
+ export declare function splitStoredRef(stored: string): {
99
+ tool: string;
100
+ id: string;
101
+ } | {
102
+ error: string;
103
+ };
104
+ /**
105
+ * Fold graded shadow rows into the trust ledger.
106
+ *
107
+ * Idempotent: a row whose disposition already matches what the ledger holds is
108
+ * counted as `alreadyRecorded` and NOT rewritten. `upsert` appends, so
109
+ * re-running without this would grow the file the autonomy gate reads with rows
110
+ * that say nothing new — and that file's byte cap is already what starves trust
111
+ * evidence (#1337).
112
+ */
113
+ export declare function promoteShadowOutcomes(opts: PromoteOptions): PromoteResult;
114
+ /**
115
+ * Render the result.
116
+ *
117
+ * Leads with the denominator and never prints a bare promoted count, for the
118
+ * same reason the privacy shadow report refuses to: "3 promoted" reads as a
119
+ * measure of the channel's health when it partly measures how little was
120
+ * observed.
121
+ */
122
+ export declare function formatPromoteResult(r: PromoteResult): string;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Promote graded Butler shadow rows into the trust ledger — flag-gated, OFF.
3
+ *
4
+ * ## Why this is a separate module and not a branch in the ingester
5
+ *
6
+ * `outcomeIngester.ts` and `errandOutcomeGrader.ts` each carry a test asserting
7
+ * they cannot reach `OutcomeStore` — not "do not call it", but *do not import
8
+ * anything that could*. That is what makes "shadow-only" a structural fact
9
+ * rather than a promise, and it is why a reviewer can read the grader without
10
+ * checking whether some path in it writes evidence.
11
+ *
12
+ * Adding promotion there would have meant deleting those guards. This module
13
+ * exists so they stay exactly as they are: the grader stays a pure function of
14
+ * observed state, the ingester stays unable to write trust, and the single
15
+ * place that can is this one, which does nothing else.
16
+ *
17
+ * ## Operator path only
18
+ *
19
+ * Not registered as a recipe tool, and a test asserts it. A recipe step runs AS
20
+ * the worker, so a worker able to reach this could promote its own filings —
21
+ * manufacturing the evidence that raises its own dial. Same reasoning as
22
+ * `outcomes confirm|reject`, which is also operator-only for exactly this.
23
+ *
24
+ * ## Flag-gated OFF, and the flag is about EVIDENCE, not about code
25
+ *
26
+ * Promotion is one-way in the way that matters: once a row is folded, the dial
27
+ * it moved cannot be un-moved by deleting the row, because trust replay has
28
+ * already absorbed it into a checkpoint. So the gate is not "is the code
29
+ * finished" — it is "is there enough evidence to start counting this channel".
30
+ *
31
+ * At the time of writing the shadow ledger holds ONE confirmed row. That is a
32
+ * real positive act, correctly attributed, and it is one row. The flag exists so
33
+ * the code can be reviewed, tested and merged now, and the trust decision made
34
+ * later on more of them, by a person, deliberately.
35
+ *
36
+ * ## What is promoted, and what is refused
37
+ *
38
+ * Only `confirmed` and `junk` — the two dispositions that represent a POSITIVE
39
+ * ACT by the operator. `unknown` is never promoted under any flag, because
40
+ * "nobody has acted yet" is not evidence in either direction, and folding it as
41
+ * good is the trust-by-neglect defect closed four times already in this
42
+ * subsystem (#1064, #1318/#1319, #1320, #1322).
43
+ *
44
+ * ## `origin: "ingester"`, never `"manual"`
45
+ *
46
+ * `OutcomeStore` treats a `"manual"` disposition as STICKY against later
47
+ * ingester writes, because it represents a human's explicit judgment. Nothing
48
+ * here is that: it is an automated grade derived from an HTTP response. Marking
49
+ * these `"manual"` would let an automated observation permanently override an
50
+ * operator who had explicitly ruled the other way — precisely inverting the
51
+ * precedence the field exists to protect.
52
+ */
53
+ import { OutcomeStore } from "../workers/outcomeStore.js";
54
+ import { readShadowRows, wouldCountAsEvidence, } from "./outcomeShadowLog.js";
55
+ /** Feature flag. Absent or anything but a truthy value ⇒ promotion refuses. */
56
+ export const BUTLER_PROMOTE_FLAG = "PATCHWORK_FLAG_BUTLER_PROMOTE";
57
+ function flagEnabled(opts) {
58
+ if (opts.enabled !== undefined)
59
+ return opts.enabled;
60
+ const v = process.env[BUTLER_PROMOTE_FLAG];
61
+ return v === "1" || v?.toLowerCase() === "true";
62
+ }
63
+ /**
64
+ * Split a stored `"<tool>:<id>"` key back into its parts.
65
+ *
66
+ * On the FIRST colon, which is the inverse of how `canonicalActionRef` joins
67
+ * them. Tool ids in this repo are dot-separated (`todoist.create_task`) and
68
+ * never contain a colon, while connector ids routinely do — so splitting on the
69
+ * last colon, or on every colon, would silently rekey the action and attach a
70
+ * confirmation to nothing.
71
+ *
72
+ * A URL-shaped key is refused rather than split. Those belong to the legacy
73
+ * `issueUrl` namespace, which `canonicalActionRef` explicitly refuses to
74
+ * produce; treating one as a `ref` here would write a row under a key no reader
75
+ * looks for.
76
+ */
77
+ export function splitStoredRef(stored) {
78
+ if (/^https?:\/\//i.test(stored)) {
79
+ return {
80
+ error: "URL-shaped key belongs to the legacy issueUrl namespace, not the tool/id one",
81
+ };
82
+ }
83
+ const i = stored.indexOf(":");
84
+ if (i <= 0 || i === stored.length - 1) {
85
+ return { error: "not in '<tool>:<id>' form" };
86
+ }
87
+ return { tool: stored.slice(0, i), id: stored.slice(i + 1) };
88
+ }
89
+ /**
90
+ * Fold graded shadow rows into the trust ledger.
91
+ *
92
+ * Idempotent: a row whose disposition already matches what the ledger holds is
93
+ * counted as `alreadyRecorded` and NOT rewritten. `upsert` appends, so
94
+ * re-running without this would grow the file the autonomy gate reads with rows
95
+ * that say nothing new — and that file's byte cap is already what starves trust
96
+ * evidence (#1337).
97
+ */
98
+ export function promoteShadowOutcomes(opts) {
99
+ const rows = readShadowRows(opts.patchworkDir);
100
+ const enabled = flagEnabled(opts);
101
+ const result = {
102
+ rows: rows.length,
103
+ promotable: 0,
104
+ withheld: 0,
105
+ promoted: 0,
106
+ alreadyRecorded: 0,
107
+ unkeyable: [],
108
+ blockedByFlag: !enabled,
109
+ };
110
+ const store = new OutcomeStore(opts.patchworkDir);
111
+ const now = opts.now ?? Date.now();
112
+ // Last grade wins per ref. The ledger is append-only and an errand is
113
+ // observed repeatedly, so the same ref legitimately appears many times —
114
+ // promoting each one would write the same fact over and over, and an older
115
+ // grade could land after a newer one.
116
+ const latest = new Map();
117
+ for (const row of rows) {
118
+ const prev = latest.get(row.ref);
119
+ if (!prev || row.gradedAt >= prev.gradedAt)
120
+ latest.set(row.ref, row);
121
+ }
122
+ for (const row of latest.values()) {
123
+ if (!wouldCountAsEvidence(row.disposition)) {
124
+ result.withheld++;
125
+ continue;
126
+ }
127
+ result.promotable++;
128
+ const parts = splitStoredRef(row.ref);
129
+ if ("error" in parts) {
130
+ // Reported, never dropped. A row that cannot be keyed is a measurement
131
+ // gap, and a run that silently skipped some looks identical to a clean one.
132
+ result.unkeyable.push({ ref: row.ref, reason: parts.error });
133
+ continue;
134
+ }
135
+ if (store.getDispositionForRef(parts) === row.disposition) {
136
+ result.alreadyRecorded++;
137
+ continue;
138
+ }
139
+ if (!enabled || opts.dryRun)
140
+ continue;
141
+ store.upsert({
142
+ ref: parts,
143
+ disposition: row.disposition,
144
+ checkedAt: now,
145
+ ...(row.recipe ? { recipeName: row.recipe } : {}),
146
+ // NOT "manual" — see the header. This is an automated grade, and marking
147
+ // it manual would make it sticky against a human who ruled otherwise.
148
+ origin: "ingester",
149
+ });
150
+ result.promoted++;
151
+ }
152
+ return result;
153
+ }
154
+ /**
155
+ * Render the result.
156
+ *
157
+ * Leads with the denominator and never prints a bare promoted count, for the
158
+ * same reason the privacy shadow report refuses to: "3 promoted" reads as a
159
+ * measure of the channel's health when it partly measures how little was
160
+ * observed.
161
+ */
162
+ export function formatPromoteResult(r) {
163
+ const lines = [];
164
+ lines.push(`[butler-promote] ${r.rows} graded row(s) in the shadow ledger`);
165
+ lines.push(` ${r.promotable} promotable (confirmed or junk) · ${r.withheld} withheld as unknown`);
166
+ if (r.alreadyRecorded > 0) {
167
+ lines.push(` ${r.alreadyRecorded} already recorded with the same verdict`);
168
+ }
169
+ for (const u of r.unkeyable) {
170
+ lines.push(` unkeyable ${u.ref}: ${u.reason}`);
171
+ }
172
+ lines.push("");
173
+ if (r.blockedByFlag) {
174
+ lines.push(` NOTHING WAS WRITTEN — ${BUTLER_PROMOTE_FLAG} is not set, so this run`);
175
+ lines.push(" was a report. Promotion moves the trust dial and a folded");
176
+ lines.push(" row cannot be un-folded by deleting it, so it is off until");
177
+ lines.push(" a person turns it on against evidence they have read.");
178
+ }
179
+ else {
180
+ lines.push(` ${r.promoted} row(s) written to the trust ledger.`);
181
+ }
182
+ return `${lines.join("\n")}\n`;
183
+ }
184
+ //# sourceMappingURL=promoteShadowOutcomes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promoteShadowOutcomes.js","sourceRoot":"","sources":["../../src/butler/promoteShadowOutcomes.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Todoist connector — manage tasks and projects via the Todoist REST API v2.
2
+ * Todoist connector — manage tasks and projects via the Todoist unified API v1.
3
+ *
4
+ * REST v2 answers 410 Gone. The base URL moved to v1; the response interfaces
5
+ * below moved with it on 2026-08-19, nine days later — see `TodoistTask`.
3
6
  *
4
7
  * Auth: API token (personal or app token).
5
8
  * - Env var: TODOIST_API_KEY overrides stored token for CI/headless use.
@@ -24,6 +27,31 @@ export interface TodoistDue {
24
27
  datetime?: string;
25
28
  timezone?: string;
26
29
  }
30
+ /**
31
+ * A task as the v1 API sends it.
32
+ *
33
+ * These names are v1's, not REST v2's, and the difference is not cosmetic. The
34
+ * base URL moved to v1 when v2 started answering 410 Gone; this interface did
35
+ * not move with it, so for nine days it declared EIGHT fields the wire never
36
+ * sent. Two of them were read to make a decision:
37
+ *
38
+ * `is_completed` → v1 sends `checked` (plus `completed_at`)
39
+ * `created_at` → v1 sends `added_at`
40
+ *
41
+ * `observeTask` read both, so a Butler errand the operator had genuinely
42
+ * completed graded `unknown` / `open-recent` and no filing could ever earn
43
+ * trust. A blind `res.json() as Promise<TodoistTask>` cast reports nothing when
44
+ * it is wrong: the fields simply arrive `undefined`.
45
+ *
46
+ * The other six — `url`, `order`, `comment_count`, `creator_id`, `assignee_id`,
47
+ * `assigner_id` — are removed rather than renamed where v1 has no counterpart.
48
+ * `url` in particular never existed on v1: Todoist exposes no task permalink,
49
+ * which is why the outcome join key had to be generalised to `<tool>:<id>`.
50
+ *
51
+ * Key set captured from the live API on 2026-08-19; the shared test fixture
52
+ * (`__tests__/todoistV1Fixture.ts`) carries the same set and is asserted
53
+ * against it.
54
+ */
27
55
  export interface TodoistTask {
28
56
  id: string;
29
57
  content: string;
@@ -31,30 +59,73 @@ export interface TodoistTask {
31
59
  project_id: string;
32
60
  section_id: string | null;
33
61
  parent_id: string | null;
34
- order: number;
62
+ /** v1's ordering field. Was declared `order`, which v1 does not send. */
63
+ child_order: number;
64
+ day_order: number;
35
65
  priority: number;
36
66
  due: TodoistDue | null;
67
+ deadline: unknown | null;
68
+ duration: unknown | null;
37
69
  labels: string[];
38
- is_completed: boolean;
39
- created_at: string;
40
- url: string;
41
- assignee_id?: string | null;
42
- assigner_id?: string | null;
43
- comment_count: number;
44
- creator_id: string;
70
+ /** Completion flag. Was declared `is_completed`, which v1 does not send. */
71
+ checked: boolean;
72
+ completed_at: string | null;
73
+ completed_by_uid: string | null;
74
+ completed_count: number;
75
+ /** Creation stamp. Was declared `created_at`, which v1 does not send. */
76
+ added_at: string;
77
+ added_by_uid: string | null;
78
+ updated_at: string;
79
+ assigned_by_uid?: string | null;
80
+ responsible_uid?: string | null;
81
+ user_id: string;
82
+ note_count: number;
83
+ postponed_count: number;
84
+ is_collapsed: boolean;
85
+ is_deleted: boolean;
45
86
  }
87
+ /**
88
+ * A project as the v1 API sends it.
89
+ *
90
+ * Same migration, same miss: `order` is `child_order`, `is_inbox_project` is
91
+ * `inbox_project`, `is_team_inbox` does not exist, and there is no `url`.
92
+ *
93
+ * Note the asymmetry with `TodoistTask`, which is real rather than a
94
+ * transcription slip: projects DO carry `created_at`; tasks carry `added_at`.
95
+ * That is a large part of why the task interface's `created_at` looked right.
96
+ */
46
97
  export interface TodoistProject {
47
98
  id: string;
48
99
  name: string;
49
100
  color: string;
101
+ description: string;
50
102
  parent_id: string | null;
51
- order: number;
103
+ child_order: number;
104
+ default_order: number;
105
+ order_key: string;
52
106
  is_favorite: boolean;
53
- is_inbox_project: boolean;
54
- is_team_inbox: boolean;
107
+ inbox_project: boolean;
55
108
  is_shared: boolean;
56
- url: string;
109
+ is_archived: boolean;
110
+ is_collapsed: boolean;
111
+ is_deleted: boolean;
112
+ is_frozen: boolean;
113
+ can_assign_tasks: boolean;
114
+ can_comment: boolean;
115
+ view_style: string;
116
+ created_at: string;
117
+ updated_at: string;
118
+ creator_uid: string;
57
119
  }
120
+ /**
121
+ * A label as the v1 API sends it.
122
+ *
123
+ * DELIBERATELY LEFT AS DECLARED. The account used to capture the task and
124
+ * project shapes has no labels, so `GET /labels` returned an empty list and
125
+ * there is no observed item shape to correct this against. Rewriting it from
126
+ * the pattern of its siblings would be a guess wearing the same clothes as the
127
+ * measurements above, and this file is a demonstration of what that costs.
128
+ */
58
129
  export interface TodoistLabel {
59
130
  id: string;
60
131
  name: string;
@@ -123,10 +194,26 @@ export declare class TodoistConnector extends BaseConnector {
123
194
  * Additive on purpose. Changing `getTask` would alter behaviour for every
124
195
  * existing caller to serve one new one.
125
196
  */
197
+ /**
198
+ * Read one task's current state for the Butler observation channel.
199
+ *
200
+ * `createdAt` is OPTIONAL, and that is the fix for the subtler half of the
201
+ * v1 field mismatch. It previously read `created_at` — absent on v1 — so
202
+ * `Date.parse` returned NaN and the guard below substituted `Date.now()`.
203
+ * The guard is right that 0 would be read as 1970 and graded `junk`; it was
204
+ * wrong to answer with a fabricated timestamp instead, because "created just
205
+ * now" is what the staleness horizon measures, and refreshing it on every
206
+ * run put `stale-unactioned` permanently out of reach. Silently: the channel
207
+ * reported a clean observation throughout.
208
+ *
209
+ * Omitting it is the honest third answer. The grader checks `completed`
210
+ * FIRST, so a real completion still confirms; only the age-based branch
211
+ * withholds, which is exactly what "we could not read its age" means.
212
+ */
126
213
  observeTask(id: string): Promise<{
127
214
  kind: "observed";
128
215
  completed: boolean;
129
- createdAt: number;
216
+ createdAt?: number;
130
217
  } | {
131
218
  kind: "deleted";
132
219
  } | {
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Todoist connector — manage tasks and projects via the Todoist REST API v2.
2
+ * Todoist connector — manage tasks and projects via the Todoist unified API v1.
3
+ *
4
+ * REST v2 answers 410 Gone. The base URL moved to v1; the response interfaces
5
+ * below moved with it on 2026-08-19, nine days later — see `TodoistTask`.
3
6
  *
4
7
  * Auth: API token (personal or app token).
5
8
  * - Env var: TODOIST_API_KEY overrides stored token for CI/headless use.
@@ -246,6 +249,22 @@ export class TodoistConnector extends BaseConnector {
246
249
  * Additive on purpose. Changing `getTask` would alter behaviour for every
247
250
  * existing caller to serve one new one.
248
251
  */
252
+ /**
253
+ * Read one task's current state for the Butler observation channel.
254
+ *
255
+ * `createdAt` is OPTIONAL, and that is the fix for the subtler half of the
256
+ * v1 field mismatch. It previously read `created_at` — absent on v1 — so
257
+ * `Date.parse` returned NaN and the guard below substituted `Date.now()`.
258
+ * The guard is right that 0 would be read as 1970 and graded `junk`; it was
259
+ * wrong to answer with a fabricated timestamp instead, because "created just
260
+ * now" is what the staleness horizon measures, and refreshing it on every
261
+ * run put `stale-unactioned` permanently out of reach. Silently: the channel
262
+ * reported a clean observation throughout.
263
+ *
264
+ * Omitting it is the honest third answer. The grader checks `completed`
265
+ * FIRST, so a real completion still confirms; only the age-based branch
266
+ * withholds, which is exactly what "we could not read its age" means.
267
+ */
249
268
  async observeTask(id) {
250
269
  const result = await this.apiCall(async (token) => {
251
270
  const res = await fetch(`${TODOIST_BASE}/tasks/${id}`, {
@@ -263,13 +282,15 @@ export class TodoistConnector extends BaseConnector {
263
282
  return { kind: "deleted" };
264
283
  return { kind: "unavailable", reason: result.error.code };
265
284
  }
266
- const createdAt = Date.parse(result.data.created_at);
285
+ const createdAt = Date.parse(result.data.added_at);
267
286
  return {
268
287
  kind: "observed",
269
- completed: result.data.is_completed === true,
270
- // An unparseable timestamp must not become 0 — that is 1970, which the
271
- // staleness horizon would read as infinitely old and grade `junk`.
272
- createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
288
+ completed: result.data.checked === true,
289
+ // An unparseable stamp yields NO age rather than a made-up one. Zero
290
+ // reads as 1970 and grades `junk` — a negative manufactured from a parse
291
+ // failure. `Date.now()` reads as brand new, which is what hid the v1
292
+ // field rename for nine days. Neither is an observation of age.
293
+ ...(Number.isFinite(createdAt) ? { createdAt } : {}),
273
294
  };
274
295
  }
275
296
  async createTask(content, projectId, description, dueString, priority, labels) {
@@ -431,7 +452,11 @@ export class TodoistConnector extends BaseConnector {
431
452
  status: res.status,
432
453
  });
433
454
  }
434
- return res.json();
455
+ // `unwrapList`, like its siblings. This was the one list endpoint the v1
456
+ // migration missed, so it returned the raw `{ results, next_cursor }`
457
+ // envelope typed as an array: `.length` undefined, `.map` a TypeError.
458
+ // Latent rather than live only because nothing calls it yet.
459
+ return unwrapList(await res.json());
435
460
  });
436
461
  if ("error" in result)
437
462
  throw new Error(result.error.message);