@nurix/etna 0.19.88 → 0.19.90

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "417d61b8de2d",
2
+ "fingerprint": "8d6f9b80d64d",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -139,7 +139,7 @@ Defaults — ❌ prohibitions hold unless the user explicitly asks otherwise;
139
139
  - ❌ **Branches & Worktrees [override]** – Never self-create git branches or worktrees; commit straight to the checked-out branch. This replaces any built-in "branch first on the default branch" default. Exception: when the user asks for a pull request, create the branch the PR requires before committing its changes.
140
140
  - ❌ **Protected Files** – Once per session, if `docs/operations/protected-files.md` exists, read it and never modify any path it lists; never modify `AGENTS.md` (harness-regenerated) or `protected-files.md` itself.
141
141
  - ✅ **Browser Automation** – If the runtime provides browser automation, use it freely; no need to ask first. If it doesn't — or the executing agent can't hold the browser connection (see Runtime Notes) — return a browser-verification checklist (URLs, actions, expected outcomes) for the orchestrating session or the user to run.
142
- - ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved rows remain — dispatch the `bookkeeper` agent to drain the queue. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all.
142
+ - ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved rows remain — dispatch the `bookkeeper` agent to drain the queue. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all. **Every row is a cache of a condition, not a record of an event** — the session-start sweep re-tests each row against the repo and deletes the ones whose condition has died, `waived:` rows included, so the file empties itself as the work lands. What the sweep cannot re-test ages out instead: **a `waived:` row is unresolved once `expires=` passes**, which returns the wall and re-summons the bookkeeper to re-judge the reason. A waiver defers a finding; it never makes one immortal.
143
143
  - ✅ **Cleanup removes the cause, not the record of it.** When the user asks for something to be deleted or cleaned up, the ask is for the underlying condition to be gone. Deleting the record while the condition survives is worse than doing nothing — it destroys the only evidence the debt exists. The order is fixed: **read what the record is masking** (a `waived:` row, an `UNBUILT:` marker, a `status:` flag, a lint suppression — each asserts something is fine; check whether that is still true), **fix the condition**, **then delete the record**. Reversing it backfires concretely: a deleted waiver's finding is re-appended by the detector as an `auto:` row, which *fails* the commit wall the `waived:` row passed.
144
144
  - ✅ **Delegation** — when to fan out, tiers, and trust are § Delegation.
145
145
  - ⚠️ **Plans** – Plans are created directly in `docs/history/plans/` (see Workflows). Once `Status: Complete` or `Status: Abandoned`, the file is frozen — never edited. Abandoning a plan whose code was committed first converts its undischarged Test Obligations into backlog items (the `testing` rule). If scope changes after completion, write a new dated plan that links back to the old one.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.10.12",
4
+ "version": "3.10.13",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -15,5 +15,5 @@
15
15
  "docs/memory/main.md",
16
16
  ".mcp.json"
17
17
  ],
18
- "digest": "8b4f074b1311"
18
+ "digest": "e58c5f75136f"
19
19
  }
@@ -126,15 +126,117 @@ function openPlanLines(projectDir) {
126
126
  return lines;
127
127
  }
128
128
 
129
+ // --- the re-verification sweep ---------------------------------------------------------------
130
+ //
131
+ // THE QUEUE IS DERIVED, NOT ACCUMULATED. Every finding here is recomputable from the repo, so a row
132
+ // is a cache of a condition rather than a record of an event — and a cache nothing invalidates is
133
+ // an archive. Before any wall reads the queue, each row is re-tested against the repo as it is NOW,
134
+ // and a row whose condition has died is deleted.
135
+ //
136
+ // THIS RUNS OVER `waived:` ROWS TOO, WHICH IS THE POINT. A waiver defers a finding; it does not
137
+ // make the finding immortal. Without this, waiving was strictly WORSE than leaving a row alone —
138
+ // the one existing resolver filtered `kind === "auto"`, so waiving a self-clearing row opted it out
139
+ // of its own return path forever. The condition, not the row's kind, decides.
140
+ //
141
+ // A kind with no verifier here is not swept — it ages out through `expires=` instead. Add a
142
+ // verifier whenever a finding becomes mechanically checkable; silence is the only failure mode
143
+ // this file has, so a verifier that is unsure must return false and leave the row standing.
144
+ const ROW_VERIFIERS = {
145
+ // "<rel> lacks its file-path header comment" — dead once the header is there (or the file is gone).
146
+ "header-missing": (projectDir, body) => {
147
+ const m = /·\s+header-missing\s+·\s+(\S+)\s+lacks/.exec(body);
148
+ if (!m) return false;
149
+ const rel = m[1];
150
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
151
+ return hasHeaderComment(projectDir, rel);
152
+ },
153
+
154
+ // "owns: glob `<glob>` in <doc> matches no files" — dead once the glob matches something again.
155
+ "dead-glob": (projectDir, body, ctx) => {
156
+ const m = /`([^`]+)`/.exec(body);
157
+ if (!m) return false;
158
+ const re = globToRegExp(m[1]);
159
+ return ctx.repoFiles.some((f) => re.test(f));
160
+ },
161
+
162
+ // "<rel> is a frozen record and carries uncommitted modifications" — dead once it is committed.
163
+ // Immutability begins at commit, so the finding exists only while the edit is still in flight.
164
+ "frozen-record-edited": (projectDir, body) => {
165
+ const m = /·\s+frozen-record-edited\s+·\s+(\S+)\s+is a frozen record/.exec(body);
166
+ if (!m) return false;
167
+ const rel = m[1];
168
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
169
+ const res = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], { cwd: projectDir, timeout: 5000 });
170
+ // status 0 = no uncommitted modifications. A git failure leaves the row standing.
171
+ return res.status === 0;
172
+ },
173
+
174
+ // "<src> changed; owning doc <doc> may need an update" — dead once the doc is newer than the
175
+ // source that implicated it, which is the same freshness test the drift report already applies.
176
+ "doc-implicated": (projectDir, body) => {
177
+ const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
178
+ if (!m) return false;
179
+ const [, src, doc] = m;
180
+ const mtime = (rel) => {
181
+ try {
182
+ return fs.statSync(path.join(projectDir, rel)).mtimeMs;
183
+ } catch (e) {
184
+ return null;
185
+ }
186
+ };
187
+ const docTime = mtime(doc);
188
+ const srcTime = mtime(src);
189
+ if (docTime === null) return false; // the doc is gone — a real finding, not a resolved one
190
+ if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
191
+ return docTime >= srcTime;
192
+ },
193
+ };
194
+
195
+ // Delete every row — `auto:` or `waived:` — whose condition no longer holds. Returns the count.
196
+ export function sweepResolvedRows(projectDir, repoFiles) {
197
+ const ctx = { repoFiles: repoFiles || listRepoFiles(projectDir) };
198
+ return resolveRows(projectDir, (row) => {
199
+ if (row.kind === "needs-session") return false; // a human judgment, never swept mechanically
200
+ for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
201
+ if (!row.body.includes(kind)) continue;
202
+ try {
203
+ return verify(projectDir, row.body, ctx);
204
+ } catch (e) {
205
+ return false; // never delete a row because a verifier threw
206
+ }
207
+ }
208
+ return false;
209
+ });
210
+ }
211
+
129
212
  // Compute drift facts once; write the full report, return the one-line banner.
130
213
  function driftBanner(projectDir) {
131
214
  const repoFiles = listRepoFiles(projectDir);
215
+ // Re-verify before counting: the walls must act on conditions that are live NOW, not on rows
216
+ // that happen to still be written down.
217
+ const swept = sweepResolvedRows(projectDir, repoFiles);
132
218
  const dead = deadGlobs(projectDir, repoFiles);
133
219
  const queueRows = readQueue(projectDir);
134
220
  const counts = countRows(queueRows);
135
221
  const waivers = unexpiredWaivers(queueRows, "dead-glob", today());
136
222
  const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
137
223
 
224
+ // A waiver becomes a wall the day it expires, so the queue says so a week ahead — the deferral
225
+ // ending should be a date somebody saw coming, not a commit that suddenly blocks.
226
+ const soon = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
227
+ const expiringSoon = queueRows
228
+ .filter((r) => r.kind === "waived")
229
+ .map((r) => {
230
+ const d = (/expires=(\d{4}-\d{2}-\d{2})/.exec(r.body) || [])[1];
231
+ if (!d || d < today() || d > soon) return null;
232
+ // Drop the row's own raised-date prefix and its reason tail — the expiry and the finding are
233
+ // what a reader is scanning for here.
234
+ const finding = r.body.replace(/^\d{4}-\d{2}-\d{2}\s+·\s+/, "").split(" · reason:")[0];
235
+ return `expires ${d} · ${finding.slice(0, 120)}`;
236
+ })
237
+ .filter(Boolean)
238
+ .sort();
239
+
138
240
  // A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
139
241
  // so waiving a finding class actually empties the rows it supersedes.
140
242
  resolveRows(projectDir, (row) => {
@@ -174,7 +276,10 @@ function driftBanner(projectDir) {
174
276
  `## Docs older than owned code (${staleDocs.length})`,
175
277
  ...staleDocs.map((d) => `- ${d}`),
176
278
  "",
177
- `## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.waived} waived)`,
279
+ `## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.expiredWaived} expired-waived; ${counts.waived} waived total, ${swept} swept as resolved this session)`,
280
+ ...(expiringSoon.length
281
+ ? ["", `## Waivers expiring within 7 days (${expiringSoon.length})`, ...expiringSoon.map((w) => `- ${w}`)]
282
+ : []),
178
283
  ].join("\n");
179
284
  try {
180
285
  fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
@@ -13,7 +13,12 @@ if (counts.unresolved === 0) {
13
13
  }
14
14
  console.error(
15
15
  `check-bookkeeping-clean: ${counts.unresolved} unresolved row(s) in docs/operations/bookkeeping.md ` +
16
- `(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session). ` +
17
- "Drain them (bookkeeper agent), resolve them, or convert to waived: with reason + expires=."
16
+ `(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ` +
17
+ `${counts.expiredWaived} expired-waived). ` +
18
+ "Drain them (bookkeeper agent), resolve them, or convert to waived: with reason + expires=. " +
19
+ (counts.expiredWaived > 0
20
+ ? "An expired waiver is a deferral that ran out: re-judge whether its reason still holds — " +
21
+ "re-waive with a fresh date only if it does, and never by default."
22
+ : "")
18
23
  );
19
24
  process.exit(1);
@@ -567,8 +567,11 @@ function stopGate(projectDir, payload) {
567
567
  return {
568
568
  decision: "block",
569
569
  reason:
570
- `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session) in docs/operations/bookkeeping.md. ` +
570
+ `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in docs/operations/bookkeeping.md. ` +
571
571
  "Dispatch the `bookkeeper` agent to drain them, resolve them now, or convert to `waived:` rows with reason + expiry. " +
572
+ (counts.expiredWaived > 0
573
+ ? "An expired waiver is a deferral that ran out — re-judge whether its reason still holds rather than re-waiving by default. "
574
+ : "") +
572
575
  "If implementation of the open plan is complete, also report it and offer the verification gate — never run it unbidden.",
573
576
  };
574
577
  }
@@ -5,7 +5,10 @@
5
5
  // - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=…
6
6
  // - needs-session: {YYYY-MM-DD} · {what could not be grounded} → main session resolves | refs={ts}
7
7
  // - waived: {YYYY-MM-DD} · {what is deferred} · reason: {why} → expires={YYYY-MM-DD}
8
- // Unresolved = auto + note + needs-session. waived: rows are resolved history until expires= passes.
8
+ // Unresolved = auto + note + needs-session + EXPIRED waived. A `waived:` row is resolved history
9
+ // only while its expires= date is in the future; past it, the deferral is over and the wall returns.
10
+ // Rows are also re-verified against the repo before the walls read them (awareness.mjs's sweep),
11
+ // so a row whose condition died is deleted rather than waiting for its date.
9
12
 
10
13
  import fs from "node:fs";
11
14
  import path from "node:path";
@@ -14,7 +17,7 @@ export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
14
17
 
15
18
  export const QUEUE_HEADER = `# Bookkeeping
16
19
 
17
- The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is either **resolved, which deletes it**, or converted to \`waived:\`. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the \`documentation\` skill's \`templates/bookkeeping.md\`. A \`waived:\` row carries \`expires=\` and is PR-reviewable history.
20
+ The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is either **resolved, which deletes it**, or converted to \`waived:\`. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the \`documentation\` skill's \`templates/bookkeeping.md\`.\n\n**Every row is a cache of a condition, not a record of an event.** A session-start sweep re-tests each row against the repo and deletes the ones whose condition has died — \`waived:\` rows included, because a waiver defers a finding and never makes one immortal. What the sweep cannot mechanically re-test ages out instead: a \`waived:\` row carries \`expires=\`, is PR-reviewable history until that date, and counts as **unresolved** past it — which returns the wall and re-summons the bookkeeper to re-judge whether the reason still holds.
18
21
  `;
19
22
 
20
23
  export function queueFile(projectDir) {
@@ -113,15 +116,30 @@ export function readQueue(projectDir) {
113
116
  }
114
117
 
115
118
  // Counts by kind; `unresolved` is the number the walls act on.
116
- export function countRows(rows) {
117
- const c = { auto: 0, note: 0, needsSession: 0, waived: 0 };
119
+ //
120
+ // AN EXPIRED WAIVER IS AN UNRESOLVED ROW. A waiver is a time-boxed deferral, so `expires=` is the
121
+ // whole difference between deferring a finding and burying it — and until this counted, the date
122
+ // was decorative: nothing but the dead-glob allowlist ever read it, so an expired waiver passed
123
+ // every wall forever. Counting it here is what makes the deferral end, and what re-summons the
124
+ // bookkeeper, whose own instructions already say to re-check a waiver's reason and demote a dead
125
+ // one to `needs-session:`. The reviewer existed; this is the trigger it never had.
126
+ //
127
+ // A waiver with NO `expires=` is malformed and counts as expired — the grammar requires the date,
128
+ // and treating an absent one as "never expires" would make the omission the cheapest way to write
129
+ // a permanent waiver.
130
+ export function countRows(rows, today = new Date().toISOString().slice(0, 10)) {
131
+ const c = { auto: 0, note: 0, needsSession: 0, waived: 0, expiredWaived: 0 };
118
132
  for (const row of rows) {
119
133
  if (row.kind === "auto") c.auto += 1;
120
134
  else if (row.kind === "note") c.note += 1;
121
135
  else if (row.kind === "needs-session") c.needsSession += 1;
122
- else if (row.kind === "waived") c.waived += 1;
136
+ else if (row.kind === "waived") {
137
+ c.waived += 1;
138
+ const expires = (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1];
139
+ if (!expires || expires < today) c.expiredWaived += 1;
140
+ }
123
141
  }
124
- c.unresolved = c.auto + c.note + c.needsSession;
142
+ c.unresolved = c.auto + c.note + c.needsSession + c.expiredWaived;
125
143
  return c;
126
144
  }
127
145
 
@@ -86,7 +86,7 @@ Cheap, read-only. Scope the target, grade its access, detect which dimensions it
86
86
 
87
87
  Dispatch one collector per available dimension; collect exhaustively before context is lost. Every collector writes `research/<target>/dimensions/<dim>/_summary.md` (with the **provenance frontmatter** — method · confidence · completeness · gaps) + `raw/`, obeying `.claude/rules/ingestion.md`.
88
88
 
89
- - **Run independents in parallel** — dispatch `docs`, `packages`, `api`, `website`, `community`, `deployed-client-bundle`, `infra-backend-fingerprint`, `distribution-artifacts` together (`subagent_type='<dimension>'`, one message, multiple Agent calls). Seed each collector with **warm entry points (URLs / repos / hosts), not claimed facts** — any fact **or inference** carried from Discovery (a price, a tier name, a version — **or a derived hypothesis like "localStorage-key X ⇒ backend is Y"**) is tagged `verify:may-be-stale` so the collector **re-derives it instead of anchoring** on a possibly-stale (or possibly-wrong) value. A Discovery _inference_ is never seeded as a plan fact: label it a hypothesis in `00-recon-plan.md` and tag it `verify:` in the seed, so a collector that inherits it reports it hedged rather than echoing it as observed (a real run seeded an `apolloAnonId ⇒ GraphQL backend` guess that two captures then repeated before the session refuted it). **A sibling collector's _summary_ is a claim, not a source** — when seeding one collector with something another collector reported, tag it `verify:` too, unless the main session has actually read the underlying `raw/` artifact. A returned summary is a compressed, unverified assertion; relaying it into a fresh brief launders it into apparent provenance, and the receiving sub-agent (which cannot see the conversation) has no way to tell a seeded claim from an established fact. (Origin: Emergent — the orchestrator relayed a "documented limitation: the connector cannot deploy" line from one collector's summary into another's brief; it was **not in the corpus**, and only the receiving agent's own verification caught it.) Only one dimension may drive the browser at a time (see `ingestion` §7 rule 9): the parallel collectors use WebFetch/curl, leaving Chrome to `session`.
89
+ - **Run independents in parallel** — dispatch `docs`, `packages`, `api`, `website`, `community`, `deployed-client-bundle`, `infra-backend-fingerprint`, `distribution-artifacts` together (`subagent_type='<dimension>'`, one message, multiple Agent calls). Seed each collector with **warm entry points (URLs / repos / hosts), not claimed facts** — any fact **or inference** carried from Discovery (a price, a tier name, a version — **or a derived hypothesis like "localStorage-key X ⇒ backend is Y"**) is tagged `verify:may-be-stale` so the collector **re-derives it instead of anchoring** on a possibly-stale (or possibly-wrong) value. A Discovery _inference_ is never seeded as a plan fact: label it a hypothesis in `00-recon-plan.md` and tag it `verify:` in the seed, so a collector that inherits it reports it hedged rather than echoing it as observed (a real run seeded an `apolloAnonId ⇒ GraphQL backend` guess that two captures then repeated before the session refuted it). **A sibling collector's _summary_ is a claim, not a source** — when seeding one collector with something another collector reported, tag it `verify:` too, unless the main session has actually read the underlying `raw/` artifact. A returned summary is a compressed, unverified assertion; relaying it into a fresh brief launders it into apparent provenance, and the receiving sub-agent (which cannot see the conversation) has no way to tell a seeded claim from an established fact. (Origin: Emergent — the orchestrator relayed a "documented limitation: the connector cannot deploy" line from one collector's summary into another's brief; it was **not in the corpus**, and only the receiving agent's own verification caught it.) **Every QUANTITATIVE claim in a brief carries its lane.** The rule above covers a *sibling's summary*; it does not cover a number the **orchestrator itself** picked up in passing — from a search result, a page it skimmed, or its own recollection — and then re-emitted as framing. A figure the main session cannot name a `raw/` anchor for is either seeded **`verify:`** or **omitted**; it is never stated flat. A number in a brief reads as established even when nothing established it, and the receiving agent cannot tell the difference. (Origin: indus-sarvam — the orchestrator seeded a "₹98.68 crore IndiaAI funding" figure into the positioning brief as context; the receiving agent traced it to a **third-party user's GitHub issue body**, not to any first-party source, and had to de-launder it. The vendor states only "compute provided under the IndiaAI mission", with no amount.) Only one dimension may drive the browser at a time (see `ingestion` §7 rule 9): the parallel collectors use WebFetch/curl, leaving Chrome to `session`.
90
90
  - **Codebase fan-out** — dispatch `codebase`; it clones + maps + returns a per-package work-list; then the main session fans out one reader per substantial package.
91
91
  - **Gated dimensions** — `session` and `wire-capture` (rung ≥2) need explicit user authorization first (see `ingestion` §7). `session` follows `.claude/rules/ingestion.md` §9. When `session` is authorized, **dispatch the non-browser collectors in the background (`run_in_background`) and drive the gated browser session in the main session concurrently** — they don't contend (Chrome belongs to the main session; the collectors use WebFetch/curl), so the whole of Ingestion runs in parallel.
92
92
  - **Shared seam** — **seed the two shared files `dimensions/_shared/{api-path-catalog.md, feature-flags.md}` (empty, with a header) once, before the collector fan-out**; bundle/session/wire/ distribution then each _append_ a `source:`-tagged section to the same physical file (never their own copy). One owner dimension per static asset; a second mine of the same asset is one source, not corroboration (`ingestion` §6).
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "discover",
3
3
  "name": "discover",
4
- "version": "0.1.15",
4
+ "version": "0.1.16",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "b55e0ea4a713"
11
+ "digest": "0bf7a6a6f479"
12
12
  }
@@ -111,7 +111,10 @@ experiential / IA coverage axis (`self-correction.md` rubric, axis 2, max 20) is
111
111
  unlocated capabilities and hiding them would inflate the rate. But they are **dispositioned**, so they
112
112
  never draw the −2 un-dispositioned penalty, and the scorecard carries `features_not_yet_reachable` so a
113
113
  reader can tell a *policy/authorization* boundary from a *thoroughness* failure.
114
- - `flow_coverage` = `100 × (primary journeys diagrammed in ux-flows.md ÷ journeys identified)`.
114
+ - `flow_coverage` = `100 × (primary journeys diagrammed in ux-flows.md ÷ journeys identified)` — **read both
115
+ numbers from `ux-flows.md`'s `journeys_diagrammed` / `journeys_identified` frontmatter**
116
+ (`cartography-flows.md`), never re-derive the denominator here. A reconstructed denominator lets the
117
+ scorer pick the number that flatters the score, and makes the metric non-reproducible across runs.
115
118
  - `screenshot_coverage` = `100 × (primary surfaces with a screenshot ÷ all primary surfaces)`.
116
119
  - `ia_nav_complete` = every D0 nav destination has a surface card.
117
120
 
@@ -60,6 +60,15 @@ job id, a streamed result). Stop at the terminal write or external response.
60
60
 
61
61
  ````markdown
62
62
  # <Target> — UX Flows
63
+ ---
64
+ journeys_identified: <N> # REQUIRED — every primary journey the corpus identifies, whether or not it
65
+ # was diagrammed. This is the DENOMINATOR of feature-coverage's
66
+ # `flow_coverage`; without it the metric is reconstructed by whoever scores
67
+ # it, and a run that identifies few journeys scores the same as one that
68
+ # identifies many and diagrams them all.
69
+ journeys_diagrammed: <N> # the count of sequence diagrams below
70
+ undiagrammed: [<name>, …] # the named difference — never an empty list with N_identified > N_diagrammed
71
+ ---
63
72
  ## TL;DR (how the product gets work done: sync-REST vs async-job, where realtime lives,
64
73
  the approval/gate pattern, what's brokered server-side)
65
74
  ## Flow index (table: flow · category · trigger · participants · complexity)
@@ -78,11 +78,11 @@ Keep `status` and `confidence` honest — they drive the entire evaluation weigh
78
78
 
79
79
  ## 3. Method & confidence vocabulary (drives evaluation weighting)
80
80
 
81
- `method` is one of: `clone-and-map`, `source-map-reassembly`, `registry-metadata`, `ats-api`, `openapi-verbatim`, `graphql-introspection`, `crawl-clip`, `docs-reconstructed`, `bundle-string-mine`, `dns-ct-fingerprint`, `wire-tap-browser`, `wire-har`, `wire-mitmproxy`, `binary-extract` (asar/apk/crx), `session-playbook`, `sdk-harness`, `inferred`.
81
+ `method` is one of: `clone-and-map`, `source-map-reassembly`, `registry-metadata`, `ats-api`, `first-party-api-json`, `openapi-verbatim`, `graphql-introspection`, `crawl-clip`, `docs-reconstructed`, `bundle-string-mine`, `dns-ct-fingerprint`, `wire-tap-browser`, `wire-har`, `wire-mitmproxy`, `binary-extract` (asar/apk/crx), `session-playbook`, `sdk-harness`, `inferred`.
82
82
 
83
83
  **Confidence is a function of method** (evaluation relies on this mapping):
84
84
 
85
- - **high** — verbatim machine artifacts: `openapi-verbatim`, `graphql-introspection`, `registry-metadata`, `ats-api`, `clone-and-map`, `binary-extract`.
85
+ - **high** — verbatim machine artifacts: `openapi-verbatim`, `graphql-introspection`, `registry-metadata`, `ats-api`, `first-party-api-json`, `clone-and-map`, `binary-extract`.
86
86
 
87
87
  > **`ats-api` — a first-party job-board / ATS JSON API is NOT a crawl.** A response from an official
88
88
  > applicant-tracking endpoint (`boards-api.greenhouse.io/v1/boards/<slug>/jobs?content=true`,
@@ -94,6 +94,20 @@ Keep `status` and `confidence` honest — they drive the entire evaluation weigh
94
94
  > (Origin: Emergent — a 433 KB verbatim Greenhouse JSON body carrying a byte-identical declared tech stack
95
95
  > across two postings was labelled `crawl-clip` with `confidence: high`, a mismatch against this very
96
96
  > table. The confidence was right and the vocabulary had no word for the artifact.)
97
+
98
+ > **`first-party-api-json` — the general case of the rule above.** `ats-api` fixed the *hiring* instance
99
+ > of a wider gap: **any verbatim JSON response from a first-party or official platform API is a machine
100
+ > artifact, not a crawl**, and maps to **high**. Use it for a GitHub org/issues/releases API response
101
+ > (`api.github.com/orgs/<org>/repos`, `/search/issues?q=org:<org>`), a status-page API
102
+ > (`status.<vendor>.com/api/v2/summary.json`), a changelog/release JSON feed, a public app-store listing
103
+ > API, or any comparable official endpoint. **The distinction is the artifact, not the topic:** the
104
+ > *rendered page* of the same information is `crawl-clip` (medium); the *API body* is
105
+ > `first-party-api-json` (high). Prefer the more specific term where one exists — `registry-metadata` for
106
+ > npm/PyPI/crates, `ats-api` for job boards, `openapi-verbatim` for a served spec — and reach for this one
107
+ > only when none fits. (Origin: indus-sarvam — the `community` lane mined the GitHub API and, finding no
108
+ > correct term, graded itself `registry-metadata`. The evidence class and the `high` confidence were both
109
+ > right; the label named the wrong registry. A vocabulary with no word for an artifact does not stop a
110
+ > collector using it — it just makes the provenance record lie.)
97
111
  - **medium** — `crawl-clip`, `docs-reconstructed`, `bundle-string-mine`, `dns-ct-fingerprint`, `wire-har`.
98
112
  - **low** — `inferred`, `session-playbook` Pass-1-only, `wire-tap-browser` shapes-only, any wire-drift observation without a spec.
99
113
 
@@ -284,7 +298,7 @@ the elements needing redaction change as you navigate.
284
298
 
285
299
  `deployed-client-bundle` (static unauth SPA), `session` (authenticated browser runtime), and `wire-capture` (non-browser client) can all surface the same **API path catalog** and **feature flags**. To keep them consumable instead of triplicated **N times** (a real run forked `api-path-catalog.md` up to 7×, and "two copies agree" then mis-reads as corroboration):
286
300
 
287
- - **One physical file, one canonical location, append-only.** The seam files live at `dimensions/_shared/api-path-catalog.md` and `dimensions/_shared/feature-flags.md` — **Ingestion seeds the two empty files (with a header) once, before the collector fan-out.** Every contributing dimension **appends a `source:`-tagged section** (`source: bundle | session | wire | distribution`) and **never creates its own copy**; each collector's `_summary.md` Artifacts list **links** the shared file rather than duplicating it. **Shared-seam allowlist:** these two filenames are the _only_ shared/single-physical artifacts — every other `raw/*` is dimension-private.
301
+ - **One physical file, one canonical location, append-only.** The seam files live at `dimensions/_shared/api-path-catalog.md` and `dimensions/_shared/feature-flags.md` — **Ingestion seeds the two empty files (with a header) once, before the collector fan-out.** Every contributing dimension **appends a `source:`-tagged section** (`source: bundle | session | wire | distribution | api`) and **never creates its own copy**; each collector's `_summary.md` Artifacts list **links** the shared file rather than duplicating it. **Shared-seam allowlist:** these two filenames are the _only_ shared/single-physical artifacts — every other `raw/*` is dimension-private.
288
302
  - Evaluation owns the **API-path diff** (an _adaptive N-way_ diff over whichever runtime sources were actually run — see evaluation template 3) in `data-model-api-surface.md`. A path that appears in `bundle` but never in `session`/`wire` is a dormant/unshipped route — a finding, not noise.
289
303
  - **The cloned source tree is a shared working copy too (a sequencing race, not just the seam files).** On a `source:full` target, `codebase` clones into `source/<target>/`, and other dimensions (`docs`/`packages`) may read in-repo files from it. A clone-dependent collector dispatched **concurrently** with the clone can read a **mid-checkout tree** (a `.git`-only state) and silently fall back to a weaker source. So either (a) treat the `codebase` clone as a **sequencing barrier** — let it finish before dispatching any collector that reads `source/<target>/` — or (b) require each clone-dependent collector to **fetch its own copy** (GitHub API / its own shallow clone) and never assume the shared clone is ready. (Origin: Twenty — `docs` raced the `codebase` clone, saw a `.git`-only tree, and fell back to the live docs site; `packages` correctly fetched its own manifests and was unaffected.)
290
304
 
@@ -294,7 +308,9 @@ the elements needing redaction change as you navigate.
294
308
  >
295
309
  > **Paired inputs make the diff feedable (capture cue).** The split is only emittable if BOTH lanes were captured: when a target has **a published API AND an authed session**, the **pre-nav in-page fetch/XHR tap** (`session` — the app-own paths it actually calls) and the **unauth bundle/route-manifest** (`deployed-client-bundle` — the published paths) are **paired required inputs**. Install the tap _before_ navigation (else on-load app calls are missed) and capture the manifest unauth; a session that only screenshots, or a bundle mine with no session, leaves the diff un-feedable. (Origin: Akeneo — 54 session-observed app-own paths vs a 124-path unauth `/api/rest/v1` manifest, near-disjoint.)
296
310
  >
297
- > **Host-level dimensions cite, they don't append (seam allowlist).** The shared `api-path-catalog.md` `## source:` allowlist is **bundle / session / wire / distribution** only — the path-contributing runtime lanes. A host-level dimension (`infra-backend-fingerprint`) that wants to cross-reference the catalog does so **from its own `raw/`** (cite hosts/paths by reference), and does **not** append its own `## source:` section to the shared file. (Origin: Akeneo — infra appended a host-level note to the path catalog; benign but blurs the allowlist.)
311
+ > **Host-level dimensions cite, they don't append (seam allowlist).** The shared `api-path-catalog.md` `## source:` allowlist is **bundle / session / wire / distribution** (the path-contributing *runtime* lanes) **plus `api`** (the *published* lane — see below). A host-level dimension (`infra-backend-fingerprint`) that wants to cross-reference the catalog does so **from its own `raw/`** (cite hosts/paths by reference), and does **not** append its own `## source:` section to the shared file. (Origin: Akeneo — infra appended a host-level note to the path catalog; benign but blurs the allowlist.)
312
+ >
313
+ > **`api` IS on the allowlist — but it is the PUBLISHED lane, and it never corroborates a runtime path.** The published developer/partner spec belongs in the same physical file as the runtime lanes because the **published-vs-app-own diff** (evaluation template 3) is the whole point of the seam, and it is far easier to compute when both sides sit side by side under one header. Two hard constraints ride with the inclusion: (a) an `## source: api` section carries the **spec-declared** surface only — never a path the collector merely inferred; and (b) **`api` is excluded from the promotion rule for app-own paths** — a path appearing in `## source: api` and in `## source: session` is *the published surface and the runtime surface agreeing*, which is a **finding about drift (or its absence)**, not two independent dimensions corroborating one claim. (Origin: indus-sarvam — the `api` lane appended a 70-operation published catalog in violation of the then-allowlist; deleting it would have made the corpus strictly worse, because that section is exactly what made the published-vs-app-own diff feedable. The rule was wrong, not the collector.)
298
314
 
299
315
  ---
300
316
 
@@ -559,7 +575,27 @@ A single linear sweep wastes context on things you can read for free and wastes
559
575
 
560
576
  **Pass 2 — fresh-run validation (with explicit cost/state-change confirmation).** Only after Pass 1, and only with explicit user OK stating the exact action + cheapest mode + estimated cost. Catches what Pass 1 can't: the submit/write request body, live state-machine transitions, the completion event, server-side resolution of shorthand, ID/slug-generation, the real iteration endpoint, and the full realtime event catalog. **Legitimate to skip** if the user opts out — leave write-side shapes as open questions; never fabricate them.
561
577
 
562
- > **Record the write-side decision as a machine flag, not re-derived prose.** A Pass-1-only run leaves the _entire_ write/generate/launch surface unobserved — feature _existence/gating_ is solid, but feature _execution behavior_ rests on marketing/docs and must never be promoted to fact (this caveat otherwise gets re-hedged across 3–4 rollups every cost-gated run). So the session `_summary.md` frontmatter carries `write_side_observed: <true|false>` and `pass2: <not-applicable | offered-pending | declined-by-user | deferred | done>` (this flag is the load-bearing part — it lets Evaluation render the write-side gap _once_). When it would help a later run resume, optionally add `captures/pass2-followup.md` listing the **exact cheapest-mode write actions + est. cost** that would close the gap. Discovery seeds the `pass2:` decision in `00-recon-plan.md` for any `auth:have-login` target.
578
+ > **Record the write-side decision as a machine flag, not re-derived prose.** A Pass-1-only run leaves the _entire_ write/generate/launch surface unobserved — feature _existence/gating_ is solid, but feature _execution behavior_ rests on marketing/docs and must never be promoted to fact (this caveat otherwise gets re-hedged across 3–4 rollups every cost-gated run). So the session `_summary.md` frontmatter carries `write_side_observed: <true|false>` and `pass2: <not-applicable | offered-pending | declined-by-user | deferred | authorized-declined | done>` (this flag is the load-bearing part — it lets Evaluation render the write-side gap _once_).
579
+
580
+ > **An AUTHORIZED Pass-2 that is never exercised is a DEFECT, not a default.** When the user has granted
581
+ > Pass-2 (explicitly, or by saying the run may spend credits / test it), the session lane must do one of
582
+ > exactly two things before Evaluation:
583
+ >
584
+ > 1. **Exercise the cheapest write action** that closes the gap — and there is almost always a cheap one:
585
+ > a simulated/dry run, a draft save, a version bump, a single smallest-unit generation. Price it, name
586
+ > it, run it, capture the request+response envelope.
587
+ > 2. **Record `pass2: authorized-declined` with the reason** (the user wrapped the run early; the cheapest
588
+ > action still mutated shared state; no non-destructive write exists), plus the exact actions and costs
589
+ > that would close it.
590
+ >
591
+ > `pass2: deferred` is **not** a valid terminal state for an authorized run — it is the state that lets an
592
+ > authorization quietly expire. Mode 5 treats an authorized-but-unexercised Pass-2 as a **medium Ingestion
593
+ > defect** and charges it, because a run that holds permission to observe the write side and ships without
594
+ > it has left its single largest blind spot open by inattention rather than by policy. (Origin:
595
+ > indus-sarvam — the user granted credit spend mid-run; the orchestrator acknowledged it, planned around
596
+ > it, asked which action to take, and shipped `write_side_observed: false` when no reply arrived. The
597
+ > whole write surface, the deploy transition and the live-call realtime plane went unobserved with 100
598
+ > credits unspent.) When it would help a later run resume, optionally add `captures/pass2-followup.md` listing the **exact cheapest-mode write actions + est. cost** that would close the gap. Discovery seeds the `pass2:` decision in `00-recon-plan.md` for any `auth:have-login` target.
563
599
 
564
600
  **Pass 3 — synthesis.** Walk the captures, pull each finding into the dimension's `_summary.md`, note the cleverest design choices, defaults imposed, performance characteristics, and open questions.
565
601
 
@@ -128,6 +128,40 @@ pbpaste > capture.json # macOS
128
128
  xclip -o > capture.json # Linux
129
129
  ```
130
130
 
131
+ > **⚠ VERIFY THE PASTE — `copied=true` is NOT proof the OS clipboard changed.** `execCommand('copy')`
132
+ > reports success from *inside the page* and can still leave the system clipboard holding a **previously
133
+ > copied payload** — most reliably when Chrome is not the OS-focused window (the agent's terminal is).
134
+ > A naive `pbpaste > raw/x.json` then writes **the wrong body** into `raw/` under a confident `_meta`,
135
+ > which is the worst artifact class there is: a stale file that scores silently.
136
+ >
137
+ > **Always assert the length matches what the tap reported**, before trusting the file:
138
+ >
139
+ > ```bash
140
+ > EXPECT=14866 # the len= the __CP call returned
141
+ > GOT=$(pbpaste | wc -m | tr -d ' ') # -m = CHARACTERS, never -c (bytes). See below.
142
+ > [ "$GOT" -ge $((EXPECT-100)) ] && [ "$GOT" -le $((EXPECT+100)) ] \
143
+ > && pbpaste > raw/capture.json && echo "ok len=$GOT" \
144
+ > || echo "STALE CLIPBOARD: expected ~$EXPECT, got $GOT — do NOT write"
145
+ > ```
146
+ >
147
+ > **Compare CHARACTERS (`wc -m`), not BYTES (`wc -c`).** JavaScript's `String.length` counts UTF-16 code
148
+ > units; `wc -c` counts UTF-8 bytes. Any non-ASCII payload makes the two diverge badly — Devanagari,
149
+ > Bengali, Tamil, CJK and emoji are 3–4 bytes per character, so a perfectly fresh 16,378-character capture
150
+ > measures 19,740 bytes and a byte-based guard **rejects a good artifact**. Allow ±100 for the trailing
151
+ > newline and minor encoding slack. (Origin: indus-sarvam — this guard's own first use produced a false
152
+ > positive on a Hindi-language agent transcript; the clipboard was fine and the check was wrong. On a
153
+ > multilingual target the byte form fails *more often than it succeeds*.)
154
+ >
155
+ > Cheap corroboration: also compare a head token (`pbpaste | head -c 40`) against the head the tap
156
+ > returned. **Escalation ladder when it is stale:** (1) click inside the page and retry — it is flaky, not
157
+ > permanently broken; (2) retry once more with the textarea left mounted a beat longer; (3) fall back to
158
+ > paging via `javascript_tool` slices (budget ~1 KB of *usable* output per slice, not the 3 KB the slice
159
+ > size suggests); (4) if paging is uneconomic, write a **decoded** artifact with `verbatim: false`,
160
+ > `_meta.why_not_verbatim` naming the failure, and `_meta.refetch` carrying the exact command — an honest
161
+ > decoded artifact beats a confident wrong one. (Origin: indus-sarvam — a 14,866-byte flag payload
162
+ > reported `copied=true` three times while `pbpaste` kept returning a 195,216-byte body copied minutes
163
+ > earlier. Caught only because the length was checked.)
164
+
131
165
  **Scrub before copying**, not after: build the object with `__SCRUB` first, then `window.__CP(pack)`. The
132
166
  clipboard is local, so this stays inside the no-exfiltration rule (ingestion §7 rule 7).
133
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.88",
3
+ "version": "0.19.90",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "417d61b8de2d"
45
+ "etnaFingerprint": "8d6f9b80d64d"
46
46
  }