weftens 0.1.3 → 0.1.5

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/agents/writer.md CHANGED
@@ -9,10 +9,10 @@ words built from found language, aimed at a reader who owes you nothing.
9
9
  ## The method spine (both output shapes)
10
10
  The writing is not the job — research is. Before drafting, mine the real language:
11
11
  - For content pieces: the research packet (Researcher's facts, sources, claims-to-verify) and the
12
- format brief; the audience's own words from comments/UGC where available.
12
+ format brief; the audience's own words from comments/UGC where available.
13
13
  - For conversion copy: the corpus (reviews of the product AND competitors, threads, transcripts,
14
- support logs) → a voice-of-customer map: pains, desired outcomes, objections, the switch moment,
15
- competitor complaints — verbatim, with frequency and emotional weight per phrase.
14
+ support logs) → a voice-of-customer map: pains, desired outcomes, objections, the switch moment,
15
+ competitor complaints — verbatim, with frequency and emotional weight per phrase.
16
16
  Copy assembled from found language beats invented copy; the customer's words are more specific,
17
17
  more emotional, and more believable than anything written at a desk.
18
18
 
@@ -34,11 +34,11 @@ the highest frequency × weight pain, sequence proof against objections in desce
34
34
 
35
35
  ## Hard rules (both shapes)
36
36
  - Banned: marketer vocabulary ("streamline," "unlock," "empower," "seamless," "elevate"), wordplay
37
- that costs clarity, invented benefits, superlatives without a source.
37
+ that costs clarity, invented benefits, superlatives without a source.
38
38
  - Every claim traces to the corpus, the research packet, or the offer — a caught overclaim kills
39
- the asset.
39
+ the asset.
40
40
  - Voice is locked per surface (Showrunner's guardrails for own channels; the client voice doc for
41
- client work). Drift is a defect even when the copy is good.
41
+ client work). Drift is a defect even when the copy is good.
42
42
  - Nothing publishes from this seat — Script Editor / Disclosure-QC / human approval sit downstream.
43
43
 
44
44
  Good output: a line the audience could have said themselves, in the locked voice. Bad output:
package/bin/weftens.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import { readFileSync } from "node:fs";
16
16
  import readline from "node:readline";
17
17
  import { orchestrate } from "../src/weftens.js";
18
- import { isChangeSheet, formatChangeSheet } from "../src/format.js";
18
+ import { isChangeSheet, formatChangeSheet, looksUnparsed, unparsedNotice } from "../src/format.js";
19
19
  import { askWeftens } from "../src/agent.js";
20
20
  import { buildSpecialistClients } from "../src/specialists.js";
21
21
  import { route } from "../src/agent-router.js";
@@ -153,6 +153,13 @@ async function cmdSend(args, { routeOnly } = {}) {
153
153
  if (outcome.result !== undefined) {
154
154
  // A change sheet renders for a human by default — the whole point of one is that a
155
155
  // person can read a line and decide. `--json` returns the raw object for piping.
156
+ if (!asJson && looksUnparsed(outcome.result)) {
157
+ process.stdout.write(`
158
+ ${unparsedNotice(outcome.result)}
159
+ `);
160
+ process.exitCode = 1;
161
+ return;
162
+ }
156
163
  if (!asJson && isChangeSheet(outcome.result)) {
157
164
  process.stdout.write(`\n${formatChangeSheet(outcome.result)}\n`);
158
165
  } else {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "weftens",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
- "description": "Weftens \u2014 one front door that routes a marketing/representation request to the right agent and runs it on your own AI subscription (bring-your-own-model). Usable as a CLI or an SDK.",
5
+ "description": "Weftens one front door that routes a marketing/representation request to the right agent and runs it on your own AI subscription (bring-your-own-model). Usable as a CLI or an SDK.",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
@@ -23,6 +23,8 @@
23
23
  "providers.json.example",
24
24
  "README.md",
25
25
  "LICENSE",
26
+ "CHANGELOG.md",
27
+ "ARCHITECTURE.md",
26
28
  "!**/.preflight",
27
29
  "!**/.preflight/**"
28
30
  ],
@@ -39,7 +41,8 @@
39
41
  "vitest": "^2.0.0"
40
42
  },
41
43
  "dependencies": {
42
- "@anthropic-ai/sdk": "^0.110.0"
44
+ "@anthropic-ai/sdk": "^0.110.0",
45
+ "weftens": "^0.1.4"
43
46
  },
44
47
  "repository": {
45
48
  "type": "git",
package/src/format.js CHANGED
@@ -35,6 +35,80 @@ export function isChangeSheet(result) {
35
35
  return Boolean(result && typeof result === "object" && Array.isArray(result.changes));
36
36
  }
37
37
 
38
+
39
+ // A core that was handed the WRONG report does not usually error — it parses the rows, finds none of
40
+ // the columns it wants, and returns a well-formed sheet with zero changes. Rendered normally that
41
+ // reads as "your account is clean", which is the most expensive lie this tool could tell: a user
42
+ // exports the wrong file and is told there is nothing to fix. Five of seven cores did exactly this.
43
+ //
44
+ // The signal is that rows were read but every money/volume figure came back zero or null. A real
45
+ // account under audit always has spend. An account with genuinely zero activity is indistinguishable
46
+ // — and for that one, "this doesn't look like the right export, or there was no activity" is also
47
+ // the truthful answer.
48
+ // Two different kinds of number, and conflating them is what let four cores slip past the first two
49
+ // versions of this check. ROW fields count what was READ — a non-zero row count proves the file was
50
+ // opened, not that it was understood. VALUE fields are what the report is FOR. The signal for "wrong
51
+ // file" is: rows were read, and every value came back zero.
52
+ const ROW_FIELDS = ["rows", "terms", "placements", "urls", "pages", "items", "searchTermRows", "campaignRows"];
53
+ const VALUE_FIELDS = [
54
+ "totalCost", "totalSpend", "totalClicks", "totalImpressions", "totalConversions",
55
+ "totalResults", "spend", "cost", "clicks", "impressions", "conversions", "sales", "orders",
56
+ "flaggedSpend", "reclaimableSpend", "totalViews", "views",
57
+ ];
58
+
59
+ export function looksUnparsed(result) {
60
+ if (!result || typeof result !== "object") return false;
61
+ if (Array.isArray(result.changes) && result.changes.length > 0) return false;
62
+ // Cores don't share one result shape: some report totals under `account`, others under `summary`,
63
+ // others at the top level. Check wherever the volume figures actually live rather than assuming a
64
+ // shape — assuming `account` is why four of the five cores slipped past the first version of this.
65
+ // Every core names its totals block differently — `account`, `summary`, `totals` — and the first
66
+ // two versions of this guard each missed the cores they didn't happen to name. Scan any one-level
67
+ // block that carries volume fields instead of guessing at keys; a new core is covered for free.
68
+ const blocks = [result, ...Object.values(result).filter((v) => v && typeof v === "object" && !Array.isArray(v))];
69
+ const zero = (v) => v === 0 || v === null || v === undefined;
70
+
71
+ // Strongest signal first: a core that reports it read ZERO rows read nothing at all. Whatever the
72
+ // file was, it was not this report.
73
+ for (const b of blocks) {
74
+ for (const f of ROW_FIELDS) if (b[f] === 0) return true;
75
+ }
76
+
77
+ for (const b of blocks) {
78
+ const values = VALUE_FIELDS.filter((f) => f in b && typeof b[f] !== "object");
79
+ if (values.length === 0) continue;
80
+ if (!values.every((f) => zero(b[f]))) continue;
81
+ // Every value is zero. If rows were read, the file parsed as text but not as this report.
82
+ // If no row count is reported either, an all-zero result is still not a clean bill of health.
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /** Row count from wherever a core reports it — used only to make the refusal message concrete. */
89
+ function rowsRead(result) {
90
+ if (!result || typeof result !== "object") return null;
91
+ const blocks = [result, ...Object.values(result).filter((v) => v && typeof v === "object" && !Array.isArray(v))];
92
+ for (const b of blocks) {
93
+ for (const f of ROW_FIELDS) if (typeof b[f] === "number" && b[f] > 0) return b[f];
94
+ }
95
+ return null;
96
+ }
97
+
98
+
99
+ /** The one message for "this file did not parse", shared by every core shape. */
100
+ export function unparsedNotice(result) {
101
+ const rows = rowsRead(result);
102
+ return [
103
+ "Could not read this as the report this agent expects.",
104
+ typeof rows === "number" && rows > 0
105
+ ? ` ${rows} row(s) were read, but none of the expected columns (spend, clicks, results) had values.`
106
+ : " No rows with the expected columns were found.",
107
+ " This is NOT a finding that your account is clean — it means the file did not parse.",
108
+ " Check you exported the right report for this agent, then run it again.",
109
+ ].join("\n");
110
+ }
111
+
38
112
  /**
39
113
  * Render a change sheet as plain text. Never invents a field: anything missing is simply
40
114
  * not printed, so a core that doesn't produce risk/evidence still renders cleanly.
@@ -50,6 +124,9 @@ export function formatChangeSheet(result) {
50
124
  const changes = result.changes.filter((c) => c && typeof c === "object");
51
125
  const dropped = result.changes.length - changes.length;
52
126
 
127
+ // Refuse to present an unreadable file as a clean bill of health.
128
+ if (looksUnparsed(result)) return unparsedNotice(result);
129
+
53
130
  const flagged = money(result.wastedSpendRecoverable, currency);
54
131
  const header = [`${changes.length} proposed change${changes.length === 1 ? "" : "s"}`];
55
132
  // "flagged", not "recoverable": the tool measured spend against a threshold, it did not