@nurix/etna 0.19.76 → 0.19.77

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": "ae6ba8fb523a",
2
+ "fingerprint": "f747b8cde1b8",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -29,6 +29,7 @@ Ground every edit in this ladder, in order. Never invent content; a fact you can
29
29
 
30
30
  ## Rules
31
31
 
32
+ - **One drain at a time — check the claim board first.** Before touching the queue, read `.claude/state/claims.log` (tab-separated: timestamp · start/end · activity · session id). An open `bookkeeping-drain` claim — a `start` with no later `end` — that is under 20 minutes old and belongs to a session whose registry row (`.claude/state/sessions.tsv`) points at a recently-modified transcript means another drain is mid-flight: stop and report that instead of draining. Otherwise append your own `start` line before the pass and an `end` line after it, so a second bookkeeper can see you and a waiting session can see you finish. A stale claim (older, or holder gone) is yours to note and proceed past.
32
33
  - **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
33
34
  - **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
34
35
  - **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: retro
3
- description: Session-distillation agent for the changelog. Reads the per-unit-of-work entries under `docs/history/changelog/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), and cross-project harness rules. Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on "run retro", "distill the changelog", "refinement retro", "/retro".
3
+ description: Session-distillation agent for the changelog and the session gate logs. Reads the per-unit-of-work entries under `docs/history/changelog/` and the coordination events in `docs/history/sessions/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), cross-project harness rules, and recurring coordination patterns (contested files, commit contention, stale-claim steals). Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on "run retro", "distill the changelog", "refinement retro", "/retro".
4
4
  tools: Read, Glob, Grep, Bash, Write, Edit
5
5
  model: opus
6
6
  ---
@@ -12,7 +12,7 @@ You distill refinement history into documentation and harness feedback. Your inp
12
12
  ## Before starting
13
13
 
14
14
  1. Read `docs/memory/retro.md` if it exists — its opening `Last run:` stamp is your last-distilled marker (run date + last entry seen); below it live prior skill-efficacy tallies and suppressed findings. No file ⇒ first run ⇒ distill everything. **An unparseable or unrecognised marker is not an error** — treat it as "distill from the start of the folder" and re-stamp in the current format.
15
- 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Empty set ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
15
+ 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Alongside the entries, scan the session gate logs (`docs/history/sessions/*.md`) dated after the marker for **coordination events** — lines whose kind is `coord` (commit-claim waits and steals, file-claim contacts, skipped queue writes). The log genre promises its failure stream to later intelligence, and you are that reader. Empty set (no entries and no coord events) ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
16
16
  3. **Skip entries carrying no field block.** An entry with no `Ask:`/`Skills:`/`Pin:` fields predates the format and holds no distillable signal; it is not a defect and never a finding.
17
17
  4. Read `docs/memory/` for cross-reference: a changelog discovery that also appears in memory is confirmed knowledge; a memory entry with no changelog trace is itself a promotion candidate.
18
18
 
@@ -25,6 +25,7 @@ Classify every finding into exactly one bucket. Work from the entries — recurr
25
25
  3. **Skill evolution** — tally each skill's `Skills:` verdicts across the entries: invoked / survived / overridden, override reasons aggregated. A skill overridden in most of its invocations is a finding. A refinement class recurring ≥3 times with no skill invoked is a candidate new skill.
26
26
  4. **Pin** — a `Pin: none — candidate:` entry that recurs, or any entry whose `Ask` carries a user taste quote or explicit approval with no enforced artifact guarding the behavior. Output the exact pin (test, token, guardrail, schema) and where it goes.
27
27
  5. **Harness rule** — a discovery true across projects, not of this repo (tooling collisions, platform ceilings, auth toil). Route to the shared rule file it belongs in.
28
+ 6. **Coordination** — a `coord` pattern recurring across sessions: one file drawing repeated cross-session contact (candidate ownership split, or a rendezvous artifact wanting a "discover rather than register" refactor), repeated commit-claim contention (sessions batching too much per commit), repeated stale-claim steals or skipped queue writes (a liveness or lock-health defect). A single event is operations, never a finding.
28
29
 
29
30
  ## Report & record
30
31
 
@@ -27,6 +27,7 @@ Ground every edit in this ladder, in order. Never invent content; a fact you can
27
27
 
28
28
  ## Rules
29
29
 
30
+ - **One drain at a time — check the claim board first.** Before touching the queue, read `.claude/state/claims.log` (tab-separated: timestamp · start/end · activity · session id). An open `bookkeeping-drain` claim — a `start` with no later `end` — that is under 20 minutes old and belongs to a session whose registry row (`.claude/state/sessions.tsv`) points at a recently-modified transcript means another drain is mid-flight: stop and report that instead of draining. Otherwise append your own `start` line before the pass and an `end` line after it, so a second bookkeeper can see you and a waiting session can see you finish. A stale claim (older, or holder gone) is yours to note and proceed past.
30
31
  - **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
31
32
  - **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
32
33
  - **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
@@ -1,6 +1,6 @@
1
1
  # dropped Claude tools: Read, Glob, Grep, Bash, Write, Edit
2
2
  name = "retro"
3
- description = "Session-distillation agent for the changelog. Reads the per-unit-of-work entries under `docs/history/changelog/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), and cross-project harness rules. Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on \"run retro\", \"distill the changelog\", \"refinement retro\", \"/retro\"."
3
+ description = "Session-distillation agent for the changelog and the session gate logs. Reads the per-unit-of-work entries under `docs/history/changelog/` and the coordination events in `docs/history/sessions/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), cross-project harness rules, and recurring coordination patterns (contested files, commit contention, stale-claim steals). Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on \"run retro\", \"distill the changelog\", \"refinement retro\", \"/retro\"."
4
4
  developer_instructions = '''
5
5
 
6
6
  # Retro — changelog distillation
@@ -10,7 +10,7 @@ You distill refinement history into documentation and harness feedback. Your inp
10
10
  ## Before starting
11
11
 
12
12
  1. Read `docs/memory/retro.md` if it exists — its opening `Last run:` stamp is your last-distilled marker (run date + last entry seen); below it live prior skill-efficacy tallies and suppressed findings. No file ⇒ first run ⇒ distill everything. **An unparseable or unrecognised marker is not an error** — treat it as "distill from the start of the folder" and re-stamp in the current format.
13
- 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Empty set ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
13
+ 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Alongside the entries, scan the session gate logs (`docs/history/sessions/*.md`) dated after the marker for **coordination events** — lines whose kind is `coord` (commit-claim waits and steals, file-claim contacts, skipped queue writes). The log genre promises its failure stream to later intelligence, and you are that reader. Empty set (no entries and no coord events) ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
14
14
  3. **Skip entries carrying no field block.** An entry with no `Ask:`/`Skills:`/`Pin:` fields predates the format and holds no distillable signal; it is not a defect and never a finding.
15
15
  4. Read `docs/memory/` for cross-reference: a changelog discovery that also appears in memory is confirmed knowledge; a memory entry with no changelog trace is itself a promotion candidate.
16
16
 
@@ -23,6 +23,7 @@ Classify every finding into exactly one bucket. Work from the entries — recurr
23
23
  3. **Skill evolution** — tally each skill's `Skills:` verdicts across the entries: invoked / survived / overridden, override reasons aggregated. A skill overridden in most of its invocations is a finding. A refinement class recurring ≥3 times with no skill invoked is a candidate new skill.
24
24
  4. **Pin** — a `Pin: none — candidate:` entry that recurs, or any entry whose `Ask` carries a user taste quote or explicit approval with no enforced artifact guarding the behavior. Output the exact pin (test, token, guardrail, schema) and where it goes.
25
25
  5. **Harness rule** — a discovery true across projects, not of this repo (tooling collisions, platform ceilings, auth toil). Route to the shared rule file it belongs in.
26
+ 6. **Coordination** — a `coord` pattern recurring across sessions: one file drawing repeated cross-session contact (candidate ownership split, or a rendezvous artifact wanting a "discover rather than register" refactor), repeated commit-claim contention (sessions batching too much per commit), repeated stale-claim steals or skipped queue writes (a liveness or lock-health defect). A single event is operations, never a finding.
26
27
 
27
28
  ## Report & record
28
29
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.10.2",
4
+ "version": "3.10.3",
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": "7eff5a4d7de8"
18
+ "digest": "950e458df1d7"
19
19
  }
@@ -32,6 +32,15 @@ import {
32
32
  } from "./lib/state.mjs";
33
33
  import { frozenStatus } from "./lib/frozen.mjs";
34
34
  import { approvedPackages } from "./lib/approved.mjs";
35
+ import {
36
+ recordSession,
37
+ liveSiblings,
38
+ openActivityClaims,
39
+ recentForeignTouches,
40
+ releaseActivity,
41
+ pruneState,
42
+ } from "./lib/claims.mjs";
43
+ import { appendEvent } from "./lib/session-log.mjs";
35
44
 
36
45
  const today = () => new Date().toISOString().slice(0, 10);
37
46
 
@@ -185,15 +194,50 @@ function driftBanner(projectDir) {
185
194
  );
186
195
  }
187
196
 
188
- function sessionStart(projectDir) {
197
+ function sessionStart(projectDir, payload = {}) {
189
198
  // Stamp the session's start so gitless repos still have a "modified since" horizon for the
190
- // outcome detector's fallback scan (dirtyPaths). Overwritten per session; best-effort.
199
+ // outcome detector's fallback scan (dirtyPaths). The legacy shared stamp stays for its reader;
200
+ // the per-sid twin exists because two concurrent sessions clobber the shared one. Best-effort.
201
+ const sid = payload.session_id;
202
+ const stamp = new Date().toISOString() + "\n";
191
203
  try {
192
- fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), new Date().toISOString() + "\n");
204
+ fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), stamp);
205
+ if (sid) {
206
+ const short = String(sid).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8);
207
+ fs.writeFileSync(path.join(stateDir(projectDir), `session-started-${short}`), stamp);
208
+ }
193
209
  } catch (e) {
194
210
  /* the stamp is a convenience for the fallback; git repos never need it */
195
211
  }
212
+ // The claim board's registry + eviction: announce this session (liveness reads the transcript's
213
+ // mtime), and prune state rows past retention — capture with no reader and no expiry is pollution.
214
+ try {
215
+ recordSession(projectDir, sid, payload.transcript_path);
216
+ pruneState(projectDir);
217
+ } catch (e) {
218
+ /* presence is a convenience */
219
+ }
196
220
  const lines = [];
221
+ // Presence, injected instead of excavated: who else is live in this checkout, what they touched
222
+ // recently, and any open activity claims — the awareness the operator used to carry by hand.
223
+ try {
224
+ const siblings = liveSiblings(projectDir, sid);
225
+ if (siblings.length) {
226
+ const touches = recentForeignTouches(projectDir, sid);
227
+ const parts = siblings.map((s) => {
228
+ const t = touches.find((f) => f.sid === s.sid);
229
+ const recent = t ? ` — recently: ${t.paths.slice(0, 3).map((p) => p.rel).join(", ")}${t.paths.length > 3 ? " …" : ""}` : "";
230
+ return `sid=${s.sid8} (active ${s.lastActiveMin}m ago${recent})`;
231
+ });
232
+ lines.push(`Concurrency: ${siblings.length} other live session(s) in this checkout · ${parts.join(" · ")} — treat their recent paths as claimed; scope commits with explicit pathspecs.`);
233
+ }
234
+ const claims = openActivityClaims(projectDir, sid).filter((c) => !c.mine);
235
+ if (claims.length) {
236
+ lines.push(`Open activity claims: ${claims.map((c) => `${c.activity} by sid=${c.sid8} (${c.ageMin}m${c.holderLive ? "" : ", holder gone — will be stolen on next contention"})`).join(" · ")}`);
237
+ }
238
+ } catch (e) {
239
+ /* presence is a convenience */
240
+ }
197
241
  const pm = detectPackageManager(projectDir);
198
242
  if (pm) lines.push(`Package manager: ${pm}`);
199
243
  const harness = harnessLine(projectDir);
@@ -208,7 +252,7 @@ function sessionStart(projectDir) {
208
252
  /* the banner is optional; the index below still injects */
209
253
  }
210
254
  const block = lines.length
211
- ? "# Session context (generated — a convenience, never a gate)\n" + lines.slice(0, 10).join("\n")
255
+ ? "# Session context (generated — a convenience, never a gate)\n" + lines.slice(0, 12).join("\n")
212
256
  : "";
213
257
  const index = buildIndex(projectDir);
214
258
  const context = [block, index].filter(Boolean).join("\n\n");
@@ -479,6 +523,19 @@ function postToolUse(projectDir, payload) {
479
523
  const input = payload.tool_input || {};
480
524
  const rows = [];
481
525
  if (payload.tool_name === "Bash") {
526
+ // A completed `git commit` releases the claim compliance took at PreToolUse — the `end` line
527
+ // is what lets a waiting session verify the actor finished. Root resolution mirrors the
528
+ // gate's: an explicit `cd` in the command wins, so cross-repo commits release where they claimed.
529
+ if (/(?:^|&&|;|\|)\s*(?:sudo\s+)?git\s+commit\b/.test(input.command || "")) {
530
+ const cdMatch = /(?:^|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(input.command || "");
531
+ const root = cdMatch && path.isAbsolute(cdMatch[1]) && fs.existsSync(cdMatch[1]) ? cdMatch[1] : projectDir;
532
+ try {
533
+ releaseActivity(root, "commit", payload.session_id);
534
+ appendEvent(root, payload.session_id, "coord", "commit-claim", "released");
535
+ } catch (e) {
536
+ /* an unreleased claim goes stale in minutes; never fail the detector over it */
537
+ }
538
+ }
482
539
  detectRenames(projectDir, input.command, meta, rows);
483
540
  detectOutcomes(projectDir, meta, rows);
484
541
  } else {
@@ -507,7 +564,7 @@ function main() {
507
564
  const projectDir = resolveProjectDir(payload);
508
565
  const event = payload.hook_event_name || "SessionStart";
509
566
  if (event === "PostToolUse") postToolUse(projectDir, payload);
510
- else sessionStart(projectDir);
567
+ else sessionStart(projectDir, payload);
511
568
  }
512
569
 
513
570
  try {
@@ -14,6 +14,7 @@ import { frozenStatus } from "./lib/frozen.mjs";
14
14
  import { undischargedRows } from "./lib/ledger.mjs";
15
15
  import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
16
16
  import { approvedPackages } from "./lib/approved.mjs";
17
+ import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
17
18
 
18
19
  function readSafe(file) {
19
20
  try {
@@ -119,6 +120,26 @@ function fileGate(projectDir, rel, toolName, sessionId) {
119
120
  return deny(projectDir, sessionId, "protected-file", rel, `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
120
121
  }
121
122
 
123
+ // The file half of the claim board: a path another LIVE session touched minutes ago belongs to
124
+ // someone else right now. Advisory by design — one ask per sibling per session, never a wall —
125
+ // this is the presence protocol ("git status archaeology") moved into the gate that fires anyway.
126
+ try {
127
+ const holder = recentForeignTouches(projectDir, sessionId).find((f) => f.paths.some((p) => p.rel === rel));
128
+ if (holder) {
129
+ const recent = holder.paths.find((p) => p.rel === rel);
130
+ appendEvent(projectDir, sessionId, "coord", "file-claim", rel, `touched ${recent.ageMin}m ago by sid=${holder.sid8}`);
131
+ const asked = askOnce(
132
+ projectDir,
133
+ sessionId,
134
+ `foreign-claim:${holder.sid8}`,
135
+ `Another live session (sid=${holder.sid8}, active ${holder.lastActiveMin}m ago) touched \`${rel}\` ${recent.ageMin}m ago and may still be working there. Edit anyway? (Asked once per session for that sibling; scope commits with explicit pathspecs either way.)`
136
+ );
137
+ if (asked) return asked;
138
+ }
139
+ } catch (e) {
140
+ /* presence is a convenience — never let it block an edit on failure */
141
+ }
142
+
122
143
  const inDocs = rel.startsWith("docs/") || rel.startsWith(".claude/") || rel.endsWith(".md");
123
144
  if (!inDocs && SOURCE_LIKE.test(rel)) {
124
145
  for (const plan of openGatedPlans(projectDir)) {
@@ -312,7 +333,28 @@ function bashGate(projectDir, command, sessionId) {
312
333
  if (!command) return null;
313
334
 
314
335
  if (statement(String.raw`git\s+commit\b`).test(command)) {
315
- return commitGate(projectDir, sessionId);
336
+ // The claim board serializes the commit sequence: disjoint files do not make disjoint
337
+ // commits — every session writes the same branch through the same index, so one committer
338
+ // at a time. A held claim is a plain wait (no denied/resolved pair to resolve — the retry
339
+ // clears it); a stale claim (holder dead or past TTL) is stolen loudly on the record.
340
+ const claim = claimActivity(projectDir, "commit", sessionId);
341
+ if (claim.held) {
342
+ appendEvent(projectDir, sessionId, "coord", "commit-claim", `waited — held by sid=${claim.held.sid8}`, `${claim.held.ageMin}m old`);
343
+ return decision(
344
+ "deny",
345
+ `WAIT — another session (sid=${claim.held.sid8}, claimed ${claim.held.ageMin}m ago) is mid-commit in this checkout. Re-run the commit in a moment; its completion (or staleness) releases the claim automatically. Do not delete .git/index.lock.`
346
+ );
347
+ }
348
+ if (claim.stole) {
349
+ appendEvent(projectDir, sessionId, "coord", "commit-claim", `stale claim by sid=${claim.stole.sid8} evicted`);
350
+ }
351
+ const gate = commitGate(projectDir, sessionId);
352
+ if (gate) {
353
+ // The commit is not happening — release immediately so a walled commit never holds the board.
354
+ releaseActivity(projectDir, "commit", sessionId);
355
+ return gate;
356
+ }
357
+ return null; // allowed — the awareness PostToolUse releases the claim when the command completes
316
358
  }
317
359
 
318
360
  // `git branch <name>` creates; flag-led forms (--show-current, --list, -d, -vv) query or manage
@@ -13,6 +13,29 @@ function readSafe(file) {
13
13
  }
14
14
  }
15
15
 
16
+ // A rule names its rejected alternatives in backticks — instruction-style § Negative space requires
17
+ // it — so harvesting every backticked token adds the ban list to the approved set. Two shapes carry
18
+ // a ban: a framework table's "Don't use" column, and the text after a bolded **never** or ❌.
19
+ // A bare "never" does not count: "never against a hand-started dev server (Workers:
20
+ // `@cloudflare/vitest-pool-workers`)" names the prescribed package, not a forbidden one.
21
+ function approvedPart(line, banColumn) {
22
+ if (line.trimStart().startsWith("|")) {
23
+ if (banColumn < 0) return line;
24
+ const cells = line.split("|");
25
+ return cells.filter((_, i) => i !== banColumn).join("|");
26
+ }
27
+ const ban = line.search(/\*\*never\*\*|❌/i);
28
+ return ban === -1 ? line : line.slice(0, ban);
29
+ }
30
+
31
+ // The "Don't use" column index, held for the rows of the table that declared it.
32
+ function banColumnOf(line, current) {
33
+ if (!line.trimStart().startsWith("|")) return -1;
34
+ const cells = line.split("|");
35
+ const header = cells.findIndex((c) => /don'?t use|never use/i.test(c));
36
+ return header === -1 ? current : header;
37
+ }
38
+
16
39
  // Package names approved in .claude/hooks/approved-packages.md (plus testing.md frameworks and
17
40
  // general.md). Empty set ⇒ no rules installed ⇒ nothing to enforce.
18
41
  export function approvedPackages(projectDir) {
@@ -23,8 +46,13 @@ export function approvedPackages(projectDir) {
23
46
  path.join(projectDir, ".claude", "rules", "testing.md"),
24
47
  ];
25
48
  for (const src of sources) {
26
- const text = readSafe(src);
27
- for (const m of text.matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) names.add(m[1]);
49
+ let banColumn = -1;
50
+ for (const line of readSafe(src).split("\n")) {
51
+ banColumn = banColumnOf(line, banColumn);
52
+ for (const m of approvedPart(line, banColumn).matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) {
53
+ names.add(m[1]);
54
+ }
55
+ }
28
56
  }
29
57
  return names;
30
58
  }
@@ -0,0 +1,203 @@
1
+ // harness/code/hooks/lib/claims.mjs — the claim board: cross-session coordination through
2
+ // machine-local state (.claude/state/), never through conversation. Two surfaces, per the
3
+ // operator's design: an ACTIVITY log (claims.log — serialized activities like `commit`: an agent
4
+ // records sid + start before acting, others read first and wait, completion is an `end` line so a
5
+ // waiter can verify the actor finished) and a FILE log (touched.tsv, which already records
6
+ // ts · path · docs · sid per edit — presence over files is *derived* from its recent rows rather
7
+ // than registered separately, so the file half cannot drift from reality). Liveness is read from
8
+ // the session registry (sessions.tsv): a session is live while its transcript file is recently
9
+ // modified — no PIDs, no daemons. A stale claim (holder dead or past TTL) is stolen loudly, never
10
+ // waited on forever: agents die mid-task as the normal case, so every claim is a lease.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { stateDir, withFileLock, atomicWrite } from "./state.mjs";
15
+
16
+ const LIVE_WINDOW_MIN = 30; // transcript modified within this window ⇒ the session is live
17
+ const CLAIM_TTL_MIN = 20; // an activity claim older than this is stale even if the holder lives
18
+ const RETENTION_DAYS = 14; // registry + touched.tsv rows older than this are pruned
19
+
20
+ function sid8(sessionId) {
21
+ return String(sessionId || "unknown").replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8) || "unknown";
22
+ }
23
+
24
+ function minutesSince(iso) {
25
+ const t = Date.parse(iso);
26
+ return Number.isFinite(t) ? (Date.now() - t) / 60000 : Infinity;
27
+ }
28
+
29
+ function claimsFile(projectDir) {
30
+ return path.join(stateDir(projectDir), "claims.log");
31
+ }
32
+
33
+ function sessionsFile(projectDir) {
34
+ return path.join(stateDir(projectDir), "sessions.tsv");
35
+ }
36
+
37
+ // Record this session in the registry (idempotent per sid — the latest row wins on read).
38
+ export function recordSession(projectDir, sessionId, transcriptPath) {
39
+ if (!sessionId) return;
40
+ const line = `${new Date().toISOString()}\t${sessionId}\t${transcriptPath || "-"}\n`;
41
+ fs.appendFileSync(sessionsFile(projectDir), line);
42
+ }
43
+
44
+ // Sessions other than `sessionId` whose transcript was modified inside the live window.
45
+ // The transcript is the liveness signal: it grows on every event, needs no PID, and survives
46
+ // the hook process (which is itself transient).
47
+ export function liveSiblings(projectDir, sessionId) {
48
+ let text = "";
49
+ try {
50
+ text = fs.readFileSync(sessionsFile(projectDir), "utf8");
51
+ } catch (e) {
52
+ return [];
53
+ }
54
+ const latest = new Map(); // sid → { started, transcript }
55
+ for (const line of text.split("\n")) {
56
+ const [ts, sid, transcript] = line.split("\t");
57
+ if (ts && sid) latest.set(sid, { started: ts, transcript });
58
+ }
59
+ const out = [];
60
+ for (const [sid, rec] of latest) {
61
+ if (sid === sessionId) continue;
62
+ let ageMin = Infinity;
63
+ try {
64
+ ageMin = (Date.now() - fs.statSync(rec.transcript).mtimeMs) / 60000;
65
+ } catch (e) {
66
+ continue; // transcript gone — not live
67
+ }
68
+ if (ageMin <= LIVE_WINDOW_MIN) out.push({ sid, sid8: sid8(sid), lastActiveMin: Math.round(ageMin) });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function parseClaims(projectDir) {
74
+ let text = "";
75
+ try {
76
+ text = fs.readFileSync(claimsFile(projectDir), "utf8");
77
+ } catch (e) {
78
+ return new Map();
79
+ }
80
+ const open = new Map(); // activity → { sid, since }
81
+ for (const line of text.split("\n")) {
82
+ const [ts, event, activity, sid] = line.split("\t");
83
+ if (!ts || !activity) continue;
84
+ if (event === "start") open.set(activity, { sid, since: ts });
85
+ else if (event === "end") open.delete(activity);
86
+ }
87
+ return open;
88
+ }
89
+
90
+ // Open activity claims, annotated with age and holder liveness — for the presence banner.
91
+ export function openActivityClaims(projectDir, sessionId) {
92
+ const live = new Set(liveSiblings(projectDir, "").map((s) => s.sid));
93
+ const out = [];
94
+ for (const [activity, rec] of parseClaims(projectDir)) {
95
+ out.push({
96
+ activity,
97
+ sid: rec.sid,
98
+ sid8: sid8(rec.sid),
99
+ ageMin: Math.round(minutesSince(rec.since)),
100
+ mine: rec.sid === sessionId,
101
+ holderLive: live.has(rec.sid) || rec.sid === sessionId,
102
+ });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ // Claim an activity for this session. Returns { ok } on success (with `stole` when a stale
108
+ // claim was evicted), or { held: { sid8, ageMin } } when another live session holds it fresh.
109
+ // The read-check-append runs under the claims lock so two claimants cannot both see "free".
110
+ export function claimActivity(projectDir, activity, sessionId) {
111
+ const lock = path.join(stateDir(projectDir), "claims.lock");
112
+ const res = withFileLock(lock, () => {
113
+ const open = parseClaims(projectDir);
114
+ const current = open.get(activity);
115
+ if (current && current.sid !== sessionId) {
116
+ const ageMin = minutesSince(current.since);
117
+ const holderLive = liveSiblings(projectDir, sessionId).some((s) => s.sid === current.sid);
118
+ if (ageMin <= CLAIM_TTL_MIN && holderLive) {
119
+ return { held: { sid8: sid8(current.sid), ageMin: Math.round(ageMin) } };
120
+ }
121
+ // Stale: holder dead or past TTL — evict loudly and take over.
122
+ fs.appendFileSync(
123
+ claimsFile(projectDir),
124
+ `${new Date().toISOString()}\tend\t${activity}\t${current.sid}\tstale-steal by ${sid8(sessionId)}\n`
125
+ );
126
+ }
127
+ if (!current || current.sid !== sessionId) {
128
+ fs.appendFileSync(claimsFile(projectDir), `${new Date().toISOString()}\tstart\t${activity}\t${sessionId}\t-\n`);
129
+ }
130
+ return {
131
+ ok: true,
132
+ stole: current && current.sid !== sessionId ? { sid8: sid8(current.sid) } : null,
133
+ };
134
+ });
135
+ // Lock unavailable: fail open — the claim board is a convenience, never a wall on its own.
136
+ return res.ok ? res.result : { ok: true, unlocked: true };
137
+ }
138
+
139
+ // Mark this session's claim on an activity complete (the `end` line a waiter reads).
140
+ export function releaseActivity(projectDir, activity, sessionId) {
141
+ const lock = path.join(stateDir(projectDir), "claims.lock");
142
+ withFileLock(lock, () => {
143
+ const current = parseClaims(projectDir).get(activity);
144
+ if (current && current.sid === sessionId) {
145
+ fs.appendFileSync(claimsFile(projectDir), `${new Date().toISOString()}\tend\t${activity}\t${sessionId}\t-\n`);
146
+ }
147
+ return true;
148
+ });
149
+ }
150
+
151
+ // The file half of the board, derived: paths other LIVE sessions touched inside the window,
152
+ // grouped per sibling — the mechanization of "someone else is working there right now".
153
+ export function recentForeignTouches(projectDir, sessionId, windowMin = 45) {
154
+ let text = "";
155
+ try {
156
+ text = fs.readFileSync(path.join(projectDir, ".claude", "state", "touched.tsv"), "utf8");
157
+ } catch (e) {
158
+ return [];
159
+ }
160
+ const live = new Map(liveSiblings(projectDir, sessionId).map((s) => [s.sid, s]));
161
+ const bySid = new Map(); // sid → { paths: Map(path → lastMin) }
162
+ for (const line of text.split("\n")) {
163
+ const [ts, rel, , sid] = line.split("\t");
164
+ if (!ts || !rel || !sid || sid === sessionId || !live.has(sid)) continue;
165
+ const ageMin = minutesSince(ts);
166
+ if (ageMin > windowMin) continue;
167
+ if (!bySid.has(sid)) bySid.set(sid, new Map());
168
+ const paths = bySid.get(sid);
169
+ paths.set(rel, Math.min(paths.get(rel) ?? Infinity, ageMin));
170
+ }
171
+ return [...bySid.entries()].map(([sid, paths]) => ({
172
+ sid,
173
+ sid8: sid8(sid),
174
+ lastActiveMin: live.get(sid).lastActiveMin,
175
+ paths: [...paths.entries()]
176
+ .sort((a, b) => a[1] - b[1])
177
+ .map(([rel, ageMin]) => ({ rel, ageMin: Math.round(ageMin) })),
178
+ }));
179
+ }
180
+
181
+ // Evict rows past retention from touched.tsv and sessions.tsv — the capture doctrine's eviction
182
+ // half: both files have named readers (the sid-scoped gates, the presence banner) that only read
183
+ // recent rows, so older rows are pollution. Claims.log stays whole inside retention too.
184
+ export function pruneState(projectDir) {
185
+ const cutoff = Date.now() - RETENTION_DAYS * 86400000;
186
+ for (const name of ["touched.tsv", "sessions.tsv", "claims.log"]) {
187
+ const file = path.join(stateDir(projectDir), name);
188
+ let text;
189
+ try {
190
+ text = fs.readFileSync(file, "utf8");
191
+ } catch (e) {
192
+ continue;
193
+ }
194
+ const kept = text.split("\n").filter((line) => {
195
+ const ts = Date.parse(line.split("\t")[0]);
196
+ return !Number.isFinite(ts) ? false : ts >= cutoff;
197
+ });
198
+ const next = kept.join("\n") + (kept.length ? "\n" : "");
199
+ if (next !== text) {
200
+ withFileLock(path.join(stateDir(projectDir), "claims.lock"), () => atomicWrite(file, next));
201
+ }
202
+ }
203
+ }
@@ -21,6 +21,74 @@ export function queueFile(projectDir) {
21
21
  return path.join(projectDir, ...QUEUE_PATH);
22
22
  }
23
23
 
24
+ // --- The write cell -------------------------------------------------------
25
+ // Every queue mutation is a whole-file read-modify-write, and the queue is shared by every
26
+ // session's hooks in this checkout — measured under two concurrent writers, the bare
27
+ // readFileSync→writeFileSync path silently loses rows and can tear-wipe the file (a concurrent
28
+ // reader catches a truncated file mid-write and writes the fragment back). The cell closes both:
29
+ // mutual exclusion via a create-exclusive lockfile (stale-evicted by age — the holder is a
30
+ // transient hook process, so age is the only honest liveness signal), and torn-read immunity via
31
+ // temp-file + atomic rename. On lock timeout the caller SKIPS its write and records that it did
32
+ // (fail open: a coordination guard must never become an outage; auto: rows are recomputable).
33
+
34
+ const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
35
+
36
+ function sleepMs(ms) {
37
+ Atomics.wait(SLEEP_BUF, 0, 0, ms);
38
+ }
39
+
40
+ // Run fn while holding the lockfile. Returns { ok: true, result } or { ok: false } on timeout.
41
+ export function withFileLock(lockPath, fn, { retries = 40, delayMs = 25, staleMs = 10000 } = {}) {
42
+ for (let i = 0; i < retries; i++) {
43
+ let fd = null;
44
+ try {
45
+ fd = fs.openSync(lockPath, "wx");
46
+ } catch (e) {
47
+ try {
48
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) fs.unlinkSync(lockPath);
49
+ } catch (e2) {
50
+ /* raced another evictor or already gone — retry */
51
+ }
52
+ sleepMs(delayMs);
53
+ continue;
54
+ }
55
+ try {
56
+ return { ok: true, result: fn() };
57
+ } finally {
58
+ try {
59
+ fs.closeSync(fd);
60
+ fs.unlinkSync(lockPath);
61
+ } catch (e) {
62
+ /* already evicted as stale — nothing to release */
63
+ }
64
+ }
65
+ }
66
+ return { ok: false };
67
+ }
68
+
69
+ // Write via temp-file + rename so no reader ever sees a truncated file.
70
+ export function atomicWrite(file, text) {
71
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}`);
72
+ fs.writeFileSync(tmp, text);
73
+ fs.renameSync(tmp, file);
74
+ }
75
+
76
+ function queueLock(projectDir) {
77
+ return path.join(stateDir(projectDir), "bookkeeping.lock");
78
+ }
79
+
80
+ // A skipped write is a coordination outcome — record it where the next SessionStart looks.
81
+ function recordSkippedWrite(projectDir, what) {
82
+ try {
83
+ fs.appendFileSync(
84
+ path.join(stateDir(projectDir), "hook-failures.log"),
85
+ `${new Date().toISOString()} state.mjs lock-timeout: skipped ${what}\n`
86
+ );
87
+ } catch (e) {
88
+ /* nowhere to write — fail open stays absolute */
89
+ }
90
+ }
91
+
24
92
  const ROW_RE = /^-\s+(auto|note|needs-session|waived):\s+(.*)$/;
25
93
 
26
94
  // Parse the queue text into rows: { kind, date, body, raw }. Non-row lines are ignored.
@@ -72,48 +140,65 @@ export function formatNeedsSessionRow({ date, description, refs }) {
72
140
 
73
141
  // Append rows to the queue, creating the file with its genre header when absent.
74
142
  // Dedupes on the row text before the `| sid=` tail, so a re-fired detector never stacks duplicates.
143
+ // Runs inside the write cell; a healed header covers the one wipe residue a torn write can leave
144
+ // (file present, header gone — recreation used to trigger only on file-absent).
75
145
  export function appendRows(projectDir, rowLines) {
76
146
  if (!rowLines || rowLines.length === 0) return 0;
77
147
  const file = queueFile(projectDir);
78
- let text = "";
79
- try {
80
- text = fs.readFileSync(file, "utf8");
81
- } catch (e) {
82
- fs.mkdirSync(path.dirname(file), { recursive: true });
83
- text = QUEUE_HEADER + "\n";
148
+ const res = withFileLock(queueLock(projectDir), () => {
149
+ let text = "";
150
+ try {
151
+ text = fs.readFileSync(file, "utf8");
152
+ } catch (e) {
153
+ fs.mkdirSync(path.dirname(file), { recursive: true });
154
+ text = QUEUE_HEADER + "\n";
155
+ }
156
+ if (!text.startsWith("# Bookkeeping")) text = QUEUE_HEADER + "\n" + text;
157
+ const key = (line) => line.split("| sid=")[0].trim();
158
+ const existing = new Set(parseQueue(text).map((r) => key(r.raw)));
159
+ const fresh = rowLines.filter((line) => !existing.has(key(line)));
160
+ if (fresh.length === 0) return 0;
161
+ if (!text.endsWith("\n")) text += "\n";
162
+ atomicWrite(file, text + fresh.join("\n") + "\n");
163
+ return fresh.length;
164
+ });
165
+ if (!res.ok) {
166
+ recordSkippedWrite(projectDir, `append of ${rowLines.length} queue row(s)`);
167
+ return 0;
84
168
  }
85
- const key = (line) => line.split("| sid=")[0].trim();
86
- const existing = new Set(parseQueue(text).map((r) => key(r.raw)));
87
- const fresh = rowLines.filter((line) => !existing.has(key(line)));
88
- if (fresh.length === 0) return 0;
89
- if (!text.endsWith("\n")) text += "\n";
90
- fs.writeFileSync(file, text + fresh.join("\n") + "\n");
91
- return fresh.length;
169
+ return res.result;
92
170
  }
93
171
 
94
172
  // Delete queue rows matching the predicate (used by the detector to auto-resolve
95
173
  // rows whose finding the session has since addressed). Returns the count removed.
96
174
  export function resolveRows(projectDir, predicate) {
97
175
  const file = queueFile(projectDir);
98
- let text;
99
- try {
100
- text = fs.readFileSync(file, "utf8");
101
- } catch (e) {
102
- return 0;
103
- }
104
- let removed = 0;
105
- const kept = text.split(/\r?\n/).filter((line) => {
106
- const m = ROW_RE.exec(line.trim());
107
- if (!m) return true;
108
- const row = { kind: m[1], body: m[2].trim(), raw: line };
109
- if (predicate(row)) {
110
- removed += 1;
111
- return false;
176
+ const res = withFileLock(queueLock(projectDir), () => {
177
+ let text;
178
+ try {
179
+ text = fs.readFileSync(file, "utf8");
180
+ } catch (e) {
181
+ return 0;
112
182
  }
113
- return true;
183
+ let removed = 0;
184
+ const kept = text.split(/\r?\n/).filter((line) => {
185
+ const m = ROW_RE.exec(line.trim());
186
+ if (!m) return true;
187
+ const row = { kind: m[1], body: m[2].trim(), raw: line };
188
+ if (predicate(row)) {
189
+ removed += 1;
190
+ return false;
191
+ }
192
+ return true;
193
+ });
194
+ if (removed > 0) atomicWrite(file, kept.join("\n"));
195
+ return removed;
114
196
  });
115
- if (removed > 0) fs.writeFileSync(file, kept.join("\n"));
116
- return removed;
197
+ if (!res.ok) {
198
+ recordSkippedWrite(projectDir, "resolve pass");
199
+ return 0;
200
+ }
201
+ return res.result;
117
202
  }
118
203
 
119
204
  // Unexpired waived rows for a finding kind (the dead-glob allowlist CI honors).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: retro
3
- description: Session-distillation agent for the changelog. Reads the per-unit-of-work entries under `docs/history/changelog/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), and cross-project harness rules. Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on "run retro", "distill the changelog", "refinement retro", "/retro".
3
+ description: Session-distillation agent for the changelog and the session gate logs. Reads the per-unit-of-work entries under `docs/history/changelog/` and the coordination events in `docs/history/sessions/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), cross-project harness rules, and recurring coordination patterns (contested files, commit contention, stale-claim steals). Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on "run retro", "distill the changelog", "refinement retro", "/retro".
4
4
  tools: Read, Glob, Grep, Bash, Write, Edit
5
5
  model: opus
6
6
  ---
@@ -12,7 +12,7 @@ You distill refinement history into documentation and harness feedback. Your inp
12
12
  ## Before starting
13
13
 
14
14
  1. Read `docs/memory/retro.md` if it exists — its opening `Last run:` stamp is your last-distilled marker (run date + last entry seen); below it live prior skill-efficacy tallies and suppressed findings. No file ⇒ first run ⇒ distill everything. **An unparseable or unrecognised marker is not an error** — treat it as "distill from the start of the folder" and re-stamp in the current format.
15
- 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Empty set ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
15
+ 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Alongside the entries, scan the session gate logs (`docs/history/sessions/*.md`) dated after the marker for **coordination events** — lines whose kind is `coord` (commit-claim waits and steals, file-claim contacts, skipped queue writes). The log genre promises its failure stream to later intelligence, and you are that reader. Empty set (no entries and no coord events) ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
16
16
  3. **Skip entries carrying no field block.** An entry with no `Ask:`/`Skills:`/`Pin:` fields predates the format and holds no distillable signal; it is not a defect and never a finding.
17
17
  4. Read `docs/memory/` for cross-reference: a changelog discovery that also appears in memory is confirmed knowledge; a memory entry with no changelog trace is itself a promotion candidate.
18
18
 
@@ -25,6 +25,7 @@ Classify every finding into exactly one bucket. Work from the entries — recurr
25
25
  3. **Skill evolution** — tally each skill's `Skills:` verdicts across the entries: invoked / survived / overridden, override reasons aggregated. A skill overridden in most of its invocations is a finding. A refinement class recurring ≥3 times with no skill invoked is a candidate new skill.
26
26
  4. **Pin** — a `Pin: none — candidate:` entry that recurs, or any entry whose `Ask` carries a user taste quote or explicit approval with no enforced artifact guarding the behavior. Output the exact pin (test, token, guardrail, schema) and where it goes.
27
27
  5. **Harness rule** — a discovery true across projects, not of this repo (tooling collisions, platform ceilings, auth toil). Route to the shared rule file it belongs in.
28
+ 6. **Coordination** — a `coord` pattern recurring across sessions: one file drawing repeated cross-session contact (candidate ownership split, or a rendezvous artifact wanting a "discover rather than register" refactor), repeated commit-claim contention (sessions batching too much per commit), repeated stale-claim steals or skipped queue writes (a liveness or lock-health defect). A single event is operations, never a finding.
28
29
 
29
30
  ## Report & record
30
31
 
@@ -1,6 +1,6 @@
1
1
  # dropped Claude tools: Read, Glob, Grep, Bash, Write, Edit
2
2
  name = "retro"
3
- description = "Session-distillation agent for the changelog. Reads the per-unit-of-work entries under `docs/history/changelog/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), and cross-project harness rules. Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on \"run retro\", \"distill the changelog\", \"refinement retro\", \"/retro\"."
3
+ description = "Session-distillation agent for the changelog and the session gate logs. Reads the per-unit-of-work entries under `docs/history/changelog/` and the coordination events in `docs/history/sessions/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), cross-project harness rules, and recurring coordination patterns (contested files, commit contention, stale-claim steals). Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on \"run retro\", \"distill the changelog\", \"refinement retro\", \"/retro\"."
4
4
  developer_instructions = '''
5
5
 
6
6
  # Retro — changelog distillation
@@ -10,7 +10,7 @@ You distill refinement history into documentation and harness feedback. Your inp
10
10
  ## Before starting
11
11
 
12
12
  1. Read `docs/memory/retro.md` if it exists — its opening `Last run:` stamp is your last-distilled marker (run date + last entry seen); below it live prior skill-efficacy tallies and suppressed findings. No file ⇒ first run ⇒ distill everything. **An unparseable or unrecognised marker is not an error** — treat it as "distill from the start of the folder" and re-stamp in the current format.
13
- 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Empty set ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
13
+ 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Alongside the entries, scan the session gate logs (`docs/history/sessions/*.md`) dated after the marker for **coordination events** — lines whose kind is `coord` (commit-claim waits and steals, file-claim contacts, skipped queue writes). The log genre promises its failure stream to later intelligence, and you are that reader. Empty set (no entries and no coord events) ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
14
14
  3. **Skip entries carrying no field block.** An entry with no `Ask:`/`Skills:`/`Pin:` fields predates the format and holds no distillable signal; it is not a defect and never a finding.
15
15
  4. Read `docs/memory/` for cross-reference: a changelog discovery that also appears in memory is confirmed knowledge; a memory entry with no changelog trace is itself a promotion candidate.
16
16
 
@@ -23,6 +23,7 @@ Classify every finding into exactly one bucket. Work from the entries — recurr
23
23
  3. **Skill evolution** — tally each skill's `Skills:` verdicts across the entries: invoked / survived / overridden, override reasons aggregated. A skill overridden in most of its invocations is a finding. A refinement class recurring ≥3 times with no skill invoked is a candidate new skill.
24
24
  4. **Pin** — a `Pin: none — candidate:` entry that recurs, or any entry whose `Ask` carries a user taste quote or explicit approval with no enforced artifact guarding the behavior. Output the exact pin (test, token, guardrail, schema) and where it goes.
25
25
  5. **Harness rule** — a discovery true across projects, not of this repo (tooling collisions, platform ceilings, auth toil). Route to the shared rule file it belongs in.
26
+ 6. **Coordination** — a `coord` pattern recurring across sessions: one file drawing repeated cross-session contact (candidate ownership split, or a rendezvous artifact wanting a "discover rather than register" refactor), repeated commit-claim contention (sessions batching too much per commit), repeated stale-claim steals or skipped queue writes (a liveness or lock-health defect). A single event is operations, never a finding.
26
27
 
27
28
  ## Report & record
28
29
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "delivery",
3
3
  "name": "delivery",
4
- "version": "0.2.14",
4
+ "version": "0.2.15",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "3ef9954da94f"
11
+ "digest": "c9abef75f3c4"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: retro
3
- description: Session-distillation agent for the changelog. Reads the per-unit-of-work entries under `docs/history/changelog/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), and cross-project harness rules. Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on "run retro", "distill the changelog", "refinement retro", "/retro".
3
+ description: Session-distillation agent for the changelog and the session gate logs. Reads the per-unit-of-work entries under `docs/history/changelog/` and the coordination events in `docs/history/sessions/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), cross-project harness rules, and recurring coordination patterns (contested files, commit contention, stale-claim steals). Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on "run retro", "distill the changelog", "refinement retro", "/retro".
4
4
  tools: Read, Glob, Grep, Bash, Write, Edit
5
5
  model: opus
6
6
  ---
@@ -12,7 +12,7 @@ You distill refinement history into documentation and harness feedback. Your inp
12
12
  ## Before starting
13
13
 
14
14
  1. Read `docs/memory/retro.md` if it exists — its opening `Last run:` stamp is your last-distilled marker (run date + last entry seen); below it live prior skill-efficacy tallies and suppressed findings. No file ⇒ first run ⇒ distill everything. **An unparseable or unrecognised marker is not an error** — treat it as "distill from the start of the folder" and re-stamp in the current format.
15
- 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Empty set ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
15
+ 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Alongside the entries, scan the session gate logs (`docs/history/sessions/*.md`) dated after the marker for **coordination events** — lines whose kind is `coord` (commit-claim waits and steals, file-claim contacts, skipped queue writes). The log genre promises its failure stream to later intelligence, and you are that reader. Empty set (no entries and no coord events) ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
16
16
  3. **Skip entries carrying no field block.** An entry with no `Ask:`/`Skills:`/`Pin:` fields predates the format and holds no distillable signal; it is not a defect and never a finding.
17
17
  4. Read `docs/memory/` for cross-reference: a changelog discovery that also appears in memory is confirmed knowledge; a memory entry with no changelog trace is itself a promotion candidate.
18
18
 
@@ -25,6 +25,7 @@ Classify every finding into exactly one bucket. Work from the entries — recurr
25
25
  3. **Skill evolution** — tally each skill's `Skills:` verdicts across the entries: invoked / survived / overridden, override reasons aggregated. A skill overridden in most of its invocations is a finding. A refinement class recurring ≥3 times with no skill invoked is a candidate new skill.
26
26
  4. **Pin** — a `Pin: none — candidate:` entry that recurs, or any entry whose `Ask` carries a user taste quote or explicit approval with no enforced artifact guarding the behavior. Output the exact pin (test, token, guardrail, schema) and where it goes.
27
27
  5. **Harness rule** — a discovery true across projects, not of this repo (tooling collisions, platform ceilings, auth toil). Route to the shared rule file it belongs in.
28
+ 6. **Coordination** — a `coord` pattern recurring across sessions: one file drawing repeated cross-session contact (candidate ownership split, or a rendezvous artifact wanting a "discover rather than register" refactor), repeated commit-claim contention (sessions batching too much per commit), repeated stale-claim steals or skipped queue writes (a liveness or lock-health defect). A single event is operations, never a finding.
28
29
 
29
30
  ## Report & record
30
31
 
@@ -1,6 +1,6 @@
1
1
  # dropped Claude tools: Read, Glob, Grep, Bash, Write, Edit
2
2
  name = "retro"
3
- description = "Session-distillation agent for the changelog. Reads the per-unit-of-work entries under `docs/history/changelog/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), and cross-project harness rules. Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on \"run retro\", \"distill the changelog\", \"refinement retro\", \"/retro\"."
3
+ description = "Session-distillation agent for the changelog and the session gate logs. Reads the per-unit-of-work entries under `docs/history/changelog/` and the coordination events in `docs/history/sessions/` accumulated since its last run and distills the refinement delta — what user-driven iteration added that the one-shot build lacked — into a typed punch list of doc-content gaps, doc-structure moves (missing doc / mothership split / merge), skill-evolution findings backed by survived-vs-overridden counts, unpinned discoveries needing an enforced artifact (test, token, guardrail, schema), cross-project harness rules, and recurring coordination patterns (contested files, commit contention, stale-claim steals). Proposes only — actionable items land in the backlog, never applied directly. Run on demand: before a brainstorm or LLD session, before starting a new project, or after a refinement arc. Triggers on \"run retro\", \"distill the changelog\", \"refinement retro\", \"/retro\"."
4
4
  developer_instructions = '''
5
5
 
6
6
  # Retro — changelog distillation
@@ -10,7 +10,7 @@ You distill refinement history into documentation and harness feedback. Your inp
10
10
  ## Before starting
11
11
 
12
12
  1. Read `docs/memory/retro.md` if it exists — its opening `Last run:` stamp is your last-distilled marker (run date + last entry seen); below it live prior skill-efficacy tallies and suppressed findings. No file ⇒ first run ⇒ distill everything. **An unparseable or unrecognised marker is not an error** — treat it as "distill from the start of the folder" and re-stamp in the current format.
13
- 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Empty set ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
13
+ 2. Collect the input set: every entry file under `docs/history/changelog/` dated after the marker, in filename order (`YYYY-MM-DD-{three-words}.md` sorts chronologically). Alongside the entries, scan the session gate logs (`docs/history/sessions/*.md`) dated after the marker for **coordination events** — lines whose kind is `coord` (commit-claim waits and steals, file-claim contacts, skipped queue writes). The log genre promises its failure stream to later intelligence, and you are that reader. Empty set (no entries and no coord events) ⇒ report "nothing to distill since {date}" in one line, refresh the `Last run:` stamp, and stop — never manufacture findings.
14
14
  3. **Skip entries carrying no field block.** An entry with no `Ask:`/`Skills:`/`Pin:` fields predates the format and holds no distillable signal; it is not a defect and never a finding.
15
15
  4. Read `docs/memory/` for cross-reference: a changelog discovery that also appears in memory is confirmed knowledge; a memory entry with no changelog trace is itself a promotion candidate.
16
16
 
@@ -23,6 +23,7 @@ Classify every finding into exactly one bucket. Work from the entries — recurr
23
23
  3. **Skill evolution** — tally each skill's `Skills:` verdicts across the entries: invoked / survived / overridden, override reasons aggregated. A skill overridden in most of its invocations is a finding. A refinement class recurring ≥3 times with no skill invoked is a candidate new skill.
24
24
  4. **Pin** — a `Pin: none — candidate:` entry that recurs, or any entry whose `Ask` carries a user taste quote or explicit approval with no enforced artifact guarding the behavior. Output the exact pin (test, token, guardrail, schema) and where it goes.
25
25
  5. **Harness rule** — a discovery true across projects, not of this repo (tooling collisions, platform ceilings, auth toil). Route to the shared rule file it belongs in.
26
+ 6. **Coordination** — a `coord` pattern recurring across sessions: one file drawing repeated cross-session contact (candidate ownership split, or a rendezvous artifact wanting a "discover rather than register" refactor), repeated commit-claim contention (sessions batching too much per commit), repeated stale-claim steals or skipped queue writes (a liveness or lock-health defect). A single event is operations, never a finding.
26
27
 
27
28
  ## Report & record
28
29
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "etna",
3
3
  "name": "etna",
4
- "version": "1.5.1",
4
+ "version": "1.5.2",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "7fbf00581722"
11
+ "digest": "615e0382294b"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  # Knowledge-Base Folders
2
2
 
3
- Conventions for any folder that is majority markdown — `docs/`, `research/`, an Obsidian vault, any knowledge base. Folder shape and linking only.
3
+ Conventions for any folder that is majority markdown — `docs/`, `research/`, an Obsidian vault, any knowledge base. Folder shape and linking only. **Instruction files are not a knowledge base and this rule does not reach them** — `rules/`, `agents/`, and skill bodies carry their own linking discipline and no `Readme.md`.
4
4
 
5
5
  - **Every topic folder carries a `Readme.md`**, created with the topic's first document — **never** `index.md` or an unscoped filename. `Readme.md` is what GitHub and Obsidian surface first, so every folder introduces itself.
6
6
  - **Every mention is a link** — wikilinks (`[[other-doc]]`) inside Obsidian-native vaults, relative markdown links (`[label](../other-doc.md)`) everywhere else. Unlinked mentions decay a knowledge graph into a pile of files.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "hr",
3
3
  "name": "hr",
4
- "version": "0.1.13",
4
+ "version": "0.1.14",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "598500b95045"
11
+ "digest": "a8da585229ba"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  # Code-Eval Batcher
2
2
 
3
- Evaluating and ranking **multiple code projects** — candidate take-homes, submissions, a folder of repos — against the production-readiness + product-quality rubric. The main agent drives this directly: it can dispatch sub-agents in parallel and a sub-agent cannot, so this is a rule to perform, not an agent to delegate to. A single project does **not** need this rule — invoke the `evaluator` skill directly. Scoring how a person *steered a session* is `interview-eval`, not this rule.
3
+ Evaluating and ranking **multiple code projects** — candidate take-homes, submissions, a folder of repos — against the production-readiness + product-quality rubric. The main agent performs this directly rather than delegating it: the rollup needs every scorecard in one context, so the session that ranks must be the one that fans out. A single project does **not** need this rule — invoke the `evaluator` skill directly. Scoring how a person *steered a session* is `interview-eval`, not this rule.
4
4
 
5
5
  ## Division of labor
6
6
 
@@ -12,7 +12,7 @@ Per-project scoring — the rubric, gates, absolute banding, the 8 sub-criteria,
12
12
  | --- | --- |
13
13
  | 0 | **Preflight once:** `bash .claude/skills/evaluator/preflight.sh`. Halt on non-zero and report the missing tools — never score with degraded signals. |
14
14
  | 1 | **Enumerate** the projects → local paths, one slug each (its dir name). Choose an `out_dir` (default `./code-eval-out`). |
15
- | 2 | **Dispatch in parallel — rolling window of 4.** Per project: `Agent(subagent_type="general-purpose", run_in_background=True, …)` instructing it to invoke the `evaluator` skill on that one path, write the workspace to `<out_dir>/<slug>/`, validate it (`python3 .claude/skills/evaluator/validate_scorecard.py <out_dir>/<slug>/scorecard.yaml`), and return the workspace path + the scorecard's `normalized`. Fire a replacement the moment a slot frees. |
15
+ | 2 | **Dispatch in parallel — batches of 4.** Per project: an `Agent` call with `subagent_type="general-purpose"` instructing it to invoke the `evaluator` skill on that one path, write the workspace to `<out_dir>/<slug>/`, validate it (`python3 .claude/skills/evaluator/validate_scorecard.py <out_dir>/<slug>/scorecard.yaml`), and return the workspace path + the scorecard's `normalized`. **Issue all four calls in one block** — that is what makes them concurrent; start the next batch when the block returns. |
16
16
  | 3 | **Collect** every `<out_dir>/*/scorecard.yaml`. Re-dispatch a validation failure once; on the second failure mark the project `VALIDATION_FAILED` and continue. |
17
17
  | 4 | **Weight + normalize** every project (math below) using the weights block. |
18
18
  | 5 | **Rollup** → `<out_dir>/results/{scorecard.csv, ranking.md, summary.md}` (contents below), sorted by rank score desc. Leave everything uncommitted. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.76",
3
+ "version": "0.19.77",
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": "ae6ba8fb523a"
45
+ "etnaFingerprint": "f747b8cde1b8"
46
46
  }