patchwork-os 1.2.0-beta.2.canary.684 → 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":"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,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,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"}
1
+ {"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"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"}
@@ -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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"}
package/dist/index.js CHANGED
@@ -4672,6 +4672,29 @@ if (process.argv[2] === "butler") {
4672
4672
  }
4673
4673
  process.exit(0);
4674
4674
  }
4675
+ if (args[0] === "promote") {
4676
+ // The ONE path that turns a graded Butler row into trust evidence, and
4677
+ // it is off unless PATCHWORK_FLAG_BUTLER_PROMOTE is set. Operator path
4678
+ // only, for the same reason `outcomes confirm` is: a recipe step runs
4679
+ // AS the worker, so a worker able to reach this would manufacture the
4680
+ // evidence that raises its own dial.
4681
+ const { formatPromoteResult, promoteShadowOutcomes } = await import("./butler/promoteShadowOutcomes.js");
4682
+ const { patchworkHome } = await import("./patchworkHome.js");
4683
+ const res = promoteShadowOutcomes({
4684
+ patchworkDir: patchworkHome(),
4685
+ dryRun: args.includes("--dry-run"),
4686
+ });
4687
+ if (args.includes("--json")) {
4688
+ process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
4689
+ }
4690
+ else {
4691
+ process.stdout.write(formatPromoteResult(res));
4692
+ }
4693
+ // Exit 0 either way: "the flag is off" is the CONFIGURED state, not a
4694
+ // failure, and a non-zero exit would make a cron wrapper report a
4695
+ // problem every time it ran as designed.
4696
+ process.exit(0);
4697
+ }
4675
4698
  if (args[0] === "ingest") {
4676
4699
  // Observations come from a file or stdin as a JSON array. They are NOT
4677
4700
  // gathered here: reading the operator's trackers is a connector
@@ -4719,13 +4742,19 @@ if (process.argv[2] === "butler") {
4719
4742
  }
4720
4743
  process.exit(0);
4721
4744
  }
4722
- process.stderr.write("Usage: patchwork butler <shadow|ingest|observe> [--json]\n\n" +
4745
+ process.stderr.write("Usage: patchwork butler <shadow|ingest|observe|promote> [--json]\n\n" +
4723
4746
  " shadow summarise the graded shadow ledger\n" +
4724
4747
  " ingest [--file <path>|-] grade a JSON array of observations\n" +
4725
4748
  " observe [--file <path>] discover errands from the run log,\n" +
4726
4749
  " look up live Todoist state, then grade\n" +
4727
4750
  " [--stale-after-days N]\n\n" +
4728
- " Shadow-only: nothing here writes the trust ledger.\n");
4751
+ " promote [--dry-run] fold confirmed/junk grades into the\n" +
4752
+ " trust ledger. Requires\n" +
4753
+ " PATCHWORK_FLAG_BUTLER_PROMOTE=1;\n" +
4754
+ " reports without writing otherwise.\n\n" +
4755
+ " shadow/ingest/observe are shadow-only and write no trust evidence.\n" +
4756
+ " `promote` is the single path that does, and it never promotes an\n" +
4757
+ " `unknown` grade under any flag.\n");
4729
4758
  process.exit(2);
4730
4759
  }
4731
4760
  catch (err) {