@dzhechkov/harness-core 0.7.5 → 0.7.7

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/src/tg-post.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  /**
2
3
  * `dz tg-post` — the pure half: validate an approved draft against what Telegram will accept and
3
4
  * what the channel's own accepted design demands.
@@ -103,13 +104,41 @@ export interface TgSendDecision {
103
104
  * intention; without it the refusal names the local MSK time it computed, so the operator can check
104
105
  * the arithmetic instead of trusting it.
105
106
  */
107
+ /**
108
+ * The dedup key for a post: sha256 of its VISIBLE text (markup stripped). Two drafts that render
109
+ * identically in Telegram are the same post even if their HTML differs by a whitespace — the key
110
+ * must be about what the reader sees, not the bytes (G5, ADR-005).
111
+ */
112
+ export function tgVisibleSha256(html: string): string {
113
+ const visible = html.replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
114
+ return createHash('sha256').update(visible.trim(), 'utf-8').digest('hex');
115
+ }
116
+
117
+ /** One recorded send. sha256 is the visible-text key; ts is ISO. */
118
+ /** One journal line. status: 'pending' written BEFORE the send (crash-window guard), 'sent' after
119
+ * Telegram accepts. A legacy line with no status is treated as 'sent'. */
120
+ export interface TgSentRecord { readonly sha256: string; readonly ts: string; readonly status?: 'pending' | 'sent' }
121
+
106
122
  export function decideTgSend(input: {
107
123
  readonly issues: readonly TgHtmlIssue[];
108
124
  readonly provenanceOutcome: 'allowed' | 'blocked' | 'not-established' | 'skipped';
109
125
  readonly confirmed: boolean;
110
126
  readonly nowUtcIso: string;
111
127
  readonly nightOverride: boolean;
128
+ /** G6 stop-cord: a `.dz/tg-post/HALT` file exists. Checked FIRST, before anything else. */
129
+ readonly halted?: boolean;
130
+ /** sha256 of THIS post's visible text (tgVisibleSha256) — the G5 dedup key. */
131
+ readonly sha256?: string;
132
+ /** The send journal as FACTS (the pure half never reads a file). Undefined ⇒ log unreadable. */
133
+ readonly sentLog?: readonly TgSentRecord[] | undefined;
134
+ /** G4 ceiling; default 10 (ADR-003). Sends in the trailing 24h are counted against it. */
135
+ readonly maxPostsPerDay?: number;
112
136
  }): TgSendDecision {
137
+ // G6 (ADR-001 fail-closed-guard-order): the stop-cord is ABSOLUTE and FIRST — it halts even a
138
+ // perfect post, before any cheaper check, so no bug in a later gate can route around it.
139
+ if (input.halted === true) {
140
+ return { action: 'refuse', reason: 'STOP-CORD engaged (.dz/tg-post/HALT exists) — nothing is published while it is present; remove the file to resume' };
141
+ }
113
142
  if (input.issues.length > 0) {
114
143
  return { action: 'refuse', reason: `${input.issues.length} formatting issue(s) — Telegram would refuse or mangle this draft` };
115
144
  }
@@ -133,5 +162,31 @@ export function decideTgSend(input: {
133
162
  return { action: 'refuse', reason: `it is ${String(mskHour).padStart(2, '0')}:xx MSK — the channel posts nothing between 00:00 and 06:00 MSK (ADR-003). Pass --night if this is deliberate` };
134
163
  }
135
164
  }
136
- return { action: 'send', reason: 'formatted, provenance-cleared, confirmed, and inside posting hours' };
165
+ // G4/G5 need the journal. An UNREADABLE journal (sentLog undefined) is fail-closed: an unreadable
166
+ // limit counter does not prove the limit is unreached (Step-0 recall — never invert fail-closed on
167
+ // an absent/degraded input).
168
+ if (input.sentLog === undefined) {
169
+ return { action: 'refuse', reason: 'the send journal could not be read — an unreadable limit/dedup counter cannot show the ceiling is unreached; refusing (fail-closed)' };
170
+ }
171
+ // G5 dedup: this exact visible-text post already went out.
172
+ // G5 dedup covers BOTH a completed send AND an in-flight PENDING record of the same visible text
173
+ // (Codex A- two-phase): a pending row means a prior attempt reached the send path — do not
174
+ // double-publish. A stuck pending is surfaced (the CLI warns) and cleared by removing its line.
175
+ if (input.sha256 !== undefined && input.sentLog.some((r) => r.sha256 === input.sha256)) {
176
+ const prior = input.sentLog.find((r) => r.sha256 === input.sha256);
177
+ return { action: 'refuse', reason: (prior && prior.status === 'pending')
178
+ ? 'a PENDING send of this exact post exists (a prior attempt reached the send path) — refusing to double-publish; if that attempt truly failed, remove its line from .dz/tg-post/sent-log.jsonl and retry'
179
+ : 'this exact post (by visible text) has already been sent — refusing a duplicate' };
180
+ }
181
+ // G4 daily limit: sends in the trailing 24h against the ceiling.
182
+ const nowMs = Date.parse(input.nowUtcIso);
183
+ const cutoff = Number.isFinite(nowMs) ? nowMs - 24 * 3600_000 : -Infinity;
184
+ // Only ACCEPTED sends count against the daily limit — a pending row may never have landed, so it
185
+ // must not eat the ceiling (mirror of 'record after'): status 'pending' is excluded, 'sent'/legacy counted.
186
+ const inWindow = input.sentLog.filter((r) => { const t = Date.parse(r.ts); return Number.isFinite(t) && t >= cutoff && r.status !== 'pending'; }).length;
187
+ const ceiling = typeof input.maxPostsPerDay === 'number' && input.maxPostsPerDay > 0 ? Math.floor(input.maxPostsPerDay) : 10;
188
+ if (inWindow >= ceiling) {
189
+ return { action: 'refuse', reason: `daily limit reached: ${inWindow} post(s) sent in the last 24h, ceiling ${ceiling} (ADR-003) — the channel does not exceed its own tempo` };
190
+ }
191
+ return { action: 'send', reason: 'stop-cord clear, formatted, provenance-cleared, confirmed, inside hours, not a duplicate, under the daily limit' };
137
192
  }