@nurix/etna 0.19.75 → 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.
Files changed (29) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/agents/bookkeeper.md +1 -0
  3. package/data/harnesses/code/agents/retro.md +3 -2
  4. package/data/harnesses/code/codex/agents/bookkeeper.toml +1 -0
  5. package/data/harnesses/code/codex/agents/retro.toml +3 -2
  6. package/data/harnesses/code/harness.json +2 -2
  7. package/data/harnesses/code/hooks/awareness.mjs +62 -5
  8. package/data/harnesses/code/hooks/compliance.mjs +43 -1
  9. package/data/harnesses/code/hooks/lib/approved.mjs +30 -2
  10. package/data/harnesses/code/hooks/lib/claims.mjs +203 -0
  11. package/data/harnesses/code/hooks/lib/state.mjs +115 -30
  12. package/data/harnesses/code/rules/changelog.md +0 -2
  13. package/data/harnesses/code/rules/cloudflare.md +28 -20
  14. package/data/harnesses/code/rules/frontend.md +3 -3
  15. package/data/harnesses/code/rules/general.md +3 -3
  16. package/data/harnesses/delivery/agents/retro.md +3 -2
  17. package/data/harnesses/delivery/codex/agents/retro.toml +3 -2
  18. package/data/harnesses/delivery/harness.json +2 -2
  19. package/data/harnesses/delivery/rules/changelog.md +0 -2
  20. package/data/harnesses/etna/CLAUDE.md +10 -2
  21. package/data/harnesses/etna/agents/retro.md +3 -2
  22. package/data/harnesses/etna/codex/agents/retro.toml +3 -2
  23. package/data/harnesses/etna/harness.json +2 -2
  24. package/data/harnesses/etna/rules/changelog.md +0 -2
  25. package/data/harnesses/etna/rules/general.md +3 -3
  26. package/data/harnesses/etna/rules/kb.md +1 -1
  27. package/data/harnesses/hr/harness.json +2 -2
  28. package/data/harnesses/hr/rules/code-eval-batcher.md +2 -2
  29. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "a4e2f407bbc6",
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.9.6",
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": "f1da48ec8895"
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).
@@ -10,8 +10,6 @@ Every unit of work leaves one record — `docs/history/changelog/YYYY-MM-DD-{thr
10
10
  | The user asks for a changelog | The entry for the work in hand. No commit required |
11
11
  | A commit is made | Every non-exempt commit is covered by an entry — the `commit` skill writes or completes it (no `commit` skill in the runtime ⇒ write the entry from the `documentation` skill's `templates/changelog-entry.md` and stage it with the commit) |
12
12
 
13
- An entry is owed only by work that changes promised behavior or takes a design decision; mechanical and cosmetic commits — a typo, a copy tweak, a config value, a log line — take only their `docs/history/CHANGELOG.md` row, no entry file. The failure fields (`Ask:`, `Skills:`, `Pin:`) are required only when non-none.
14
-
15
13
  Route the lesson by who pays for losing it: product context — what changed and why — belongs in the entry; anything the **next session would re-pay** — a library's API drifted from what the model expects, a validator's quirk, a tooling workaround — goes to `docs/memory/main.md` as well, because a changelog is read by people tracing history and memory is read by every session before it repeats the mistake.
16
14
 
17
15
  ## Lifecycle
@@ -6,30 +6,37 @@ Conventions for Cloudflare Workers — `wrangler.jsonc` setup, vars and secrets,
6
6
 
7
7
  ### 1.1 Wrangler version
8
8
 
9
- - Use Wrangler **v4** — v3's bundled config schema is missing newer keys (e.g. `secrets_store_secrets`), so editors flag valid config as invalid.
10
- - **No `package.json`** (dep-free worker): use `npx wrangler@4` in every command and doc — the `@4` is the pin. Don't create a `package.json` just to record it.
9
+ - Use Wrangler **v4**. If an editor flags valid config as invalid, check the wrangler version first.
10
+ - **No `package.json`** (dep-free worker): use `npx wrangler@4` in every command and doc. Never create a `package.json` just to record the version.
11
11
  - **`package.json` exists**: pin wrangler to the exact version tested against (not `^4.0.0`), reinstall, and verify with `wrangler deploy --dry-run` before pushing.
12
12
 
13
13
  ### 1.2 Config format
14
14
 
15
15
  - Use `wrangler.jsonc` (not `wrangler.toml`).
16
- - Include `"$schema": "node_modules/wrangler/config-schema.json"` so the editor validates against the exact installed wrangler version.
16
+ - Include `"$schema": "node_modules/wrangler/config-schema.json"`.
17
17
 
18
18
  ### 1.3 Comments in jsonc
19
19
 
20
20
  None by default. Keep a comment only when it captures a non-obvious **WHY** — a hidden constraint, workaround, or account-specific quirk. Never narrate what a key already says.
21
21
 
22
- ### 1.4 workers.dev URLs
22
+ ### 1.4 Public entry points — workers.dev & preview URLs
23
23
 
24
- - **Don't expose on `workers.dev` by default** — it is a public, guessable, internet-facing domain (`<worker>.<account>.workers.dev`), indexed and reachable by anyone the moment the Worker deploys. Default to `"workers_dev": false`; when a deploy is requested and no custom domain or route exists yet, warn the user that enabling `workers.dev` makes the Worker public, and enable it only if they confirm they want that.
25
- - Set `"workers_dev": false` once a custom domain is wired up, so the custom domain is the only entry point.
26
- - Pair with a `routes` entry using `custom_domain: true` so Cloudflare manages the DNS record and cert.
24
+ - **Don't expose on `workers.dev` by default** — it is public and guessable (`<worker>.<account>.workers.dev`). Default to `"workers_dev": false`; when a deploy is requested and no custom domain or route exists yet, warn the user that enabling it makes the Worker public and enable it only on confirmation.
25
+ - **`preview_urls` is a second public door** (`<version>-<worker>.<account>.workers.dev`) — `workers_dev` alone is not the whole switch. Set `"preview_urls": false` with it.
26
+ - Pair a custom domain with a `routes` entry using `custom_domain: true`. **The zone must be in the same account as the Worker** — a route pointing at a zone the account doesn't hold fails at deploy, so check the zone before writing the route.
27
+ - **Declare both keys explicitly, in the same edit that adds the route** — never leave them to the implicit default, and never delete them as redundant once a route exists.
27
28
 
28
- ### 1.5 Observability
29
+ > **Adding a route cuts over atomically — you cannot stage it.** The moment a `routes` entry lands, an absent `workers_dev` key disables `workers.dev` on that same deploy; "add the route now, turn it off once the custom domain answers" is not a sequence wrangler will execute. Where the certificate is slow to provision, that gap is downtime — confirm the outage window with the user before cutting over.
29
30
 
30
- Default block for every Worker:
31
+ Deleting a route is likewise a cutover: `workers.dev` does not return until `"workers_dev": true` is set explicitly, and a torn-down hostname `404`s rather than redirecting — callers holding the old URL get no forwarding.
32
+
33
+ ### 1.5 Default config
34
+
35
+ Every Worker carries these keys. Deviate only where a rule below says to.
31
36
 
32
37
  ```jsonc
38
+ "workers_dev": false,
39
+ "preview_urls": false,
33
40
  "observability": {
34
41
  "logs": {
35
42
  "enabled": false,
@@ -38,14 +45,15 @@ Default block for every Worker:
38
45
  }
39
46
  ```
40
47
 
41
- `enabled: false` avoids per-log billing; `invocation_logs: true` keeps the cheap per-request audit trail. **Bootstrap override:** a new Worker (especially cron-only) runs with `enabled: true` until a few real runs look clean — with logs off, `console.log` is visible only live via `npx wrangler tail` and nothing persists after a failed run. Flip back once stable.
48
+ - `workers_dev` / `preview_urls` — entry-point policy and cutover mechanics: §1.4.
49
+ - **Bootstrap override:** a new Worker (especially cron-only) runs `logs.enabled: true` until a few real runs look clean, then flips back — with logs off, `console.log` is live-only via `npx wrangler tail` and nothing persists after a failed run.
42
50
 
43
51
  ### 1.6 Project structure & dependencies
44
52
 
45
53
  - Main entry file: `index.ts`. Reusable helpers: `helper.ts`.
46
- - TypeScript under `strict: true` — wrangler bundles it natively, no separate build step; a minimal `tsconfig.json` ships even in dep-free Workers.
54
+ - TypeScript under `strict: true` — no build step, and a minimal `tsconfig.json` ships even in dep-free Workers.
47
55
  - Don't run `npm run build` or `npm run deploy` in examples.
48
- - **`package.json` / `package-lock.json` are opt-in, not default.** Create them only when code under `src/` imports an npm package at runtime — a Worker on built-in Web/Workers APIs alone needs no manifest; run wrangler, linters, and one-offs via `npx <tool>`. Add both files the moment the first real dep lands. `node_modules/` stays in `.gitignore` either way.
56
+ - **`package.json` / `package-lock.json` are opt-in.** Create them only when code under `src/` imports an npm package at runtime; add both the moment the first real dep lands. Run wrangler, linters, and one-offs via `npx <tool>`. `node_modules/` stays in `.gitignore` either way.
49
57
 
50
58
  ### 1.7 Environment & configuration
51
59
 
@@ -68,15 +76,15 @@ Access in code: `await env.CLICKUP_KEY.get()`.
68
76
 
69
77
  - Repo/dir: `cf-<name>` (e.g. `cf-extract-design`).
70
78
  - Worker name: `<name>` — repo name minus the `cf-` prefix.
71
- - R2 bucket, D1 database, and KV namespace names: equal to the worker name. One name, one resource set.
79
+ - R2 bucket, D1 database, and KV namespace names: equal to the worker name.
72
80
 
73
81
  ### 1.10 Vars & secrets
74
82
 
75
- **Non-secret vars live in the `vars` block in `wrangler.jsonc`** — checked in, one source of truth. Vars are configuration, not runtime state; do not move them to the dashboard. A single-tenant application Worker (one deployment — e.g. Apollo) declares them at the top level; a multi-tenant Worker declares per-tenant vars under each `env.<tenant>` block (§11).
83
+ **Non-secret vars live in the `vars` block in `wrangler.jsonc`**, checked in. Never move them to the dashboard. A single-tenant application Worker declares them at the top level; a multi-tenant Worker declares per-tenant vars under each `env.<tenant>` block (§11).
76
84
 
77
85
  **Secrets never touch git.** Local dev: a gitignored `.dev.vars` (`KEY=VALUE` dotenv, auto-loaded by `wrangler dev`) with a committed `.dev.vars.example` listing the keys and placeholder values. Production: `wrangler secret put <NAME>`, a gitignored `.secrets` file pushed with `wrangler secret bulk .secrets` (or shipped with a deploy via `wrangler deploy --secrets-file .secrets`), or the Secrets Store (§1.8). Never log or echo a secret value.
78
86
 
79
- **No `keep_vars`.** Leave it unset (default `false`) — each deploy reconciles vars to exactly what the config declares. `keep_vars` only guards plaintext vars set out-of-band in the dashboard; it never touches secrets — `wrangler secret put` secrets survive every deploy regardless. Only the multi-tenant pattern sets `keep_vars: true` (§11).
87
+ **No `keep_vars`** — leave it unset. It guards only plaintext vars set out-of-band in the dashboard and never touches secrets; `wrangler secret put` secrets survive every deploy regardless, so never add it to protect them. Only the multi-tenant pattern sets `keep_vars: true` (§11).
80
88
 
81
89
  **Never name a deployed-value file `.env` or `.env.<anything>`** — `wrangler dev` auto-loads a root `.env` as local dev vars, so a prod-valued `.env` silently becomes the local environment on a fresh clone or in CI. Committed `*.example` files carry keys with placeholder values only.
82
90
 
@@ -84,11 +92,11 @@ Access in code: `await env.CLICKUP_KEY.get()`.
84
92
 
85
93
  ### 1.11 Deploys
86
94
 
87
- **Never auto-deploy.** `wrangler deploy` (any variant — `--env`, `versions upload`, etc.) runs only when the user explicitly asks for a deploy. Building, dry-running (`wrangler deploy --dry-run`), and local dev (`wrangler dev`) are fine unprompted; pushing to Cloudflare is not — finish the change, then tell the user it's ready to deploy.
95
+ **Never auto-deploy.** `wrangler deploy` (any variant — `--env`, `versions upload`) runs only when the user explicitly asks. Building, dry-running (`wrangler deploy --dry-run`), and `wrangler dev` are fine unprompted. Finish the change, then tell the user it's ready to deploy.
88
96
 
89
97
  ## 2. Code & Helpers
90
98
 
91
- - Use `generateUuidV7()` in `helper.ts` for IDs (time-sortable UUIDv7).
99
+ - Use `generateUuidV7()` in `helper.ts` for IDs.
92
100
  - Only `await` what's needed (e.g. `request.json()`, `do.fetch(...)`).
93
101
 
94
102
  ## 3. fetch() Structure
@@ -160,13 +168,13 @@ Follows the shared database naming and column-type vocabulary; what earns a tabl
160
168
 
161
169
  ## 11. Multi-tenant Workers (native environments)
162
170
 
163
- Rare — only when **one Worker codebase ships as several independent tenants** (separate Workers, domains, and data stores from a single `src/`, e.g. `~/dev/auth` → `auth-nuplay`, `auth-nustack`). Use **native wrangler environments**, never one Worker that switches tenant at runtime. For a single-tenant application Worker, §1.10 applies and this section does not.
171
+ Rare — only when **one Worker codebase ships as several independent tenants**: separate Workers, domains, and data stores from a single `src/`. Use **native wrangler environments**, never one Worker that switches tenant at runtime. For a single-tenant application Worker, §1.10 applies and this section does not.
164
172
 
165
173
  - **One config, `env.<tenant>` blocks.** The top-level block is **local dev** (`wrangler dev`): a `*-dev` name and placeholder D1/KV ids, simulated locally. Each `env.<tenant>` block is **one production tenant**; deploy it with `wrangler deploy --env <tenant>`.
166
174
  - **Named environments never inherit** `vars` / `d1_databases` / `kv_namespaces` / `routes` / `secrets_store_secrets` / `assets` from the top level — **each `env.<tenant>` declares them in full**. Dropping a binding from an env block disables that capability for that one tenant.
167
- - **Per-tenant non-secret vars live in `env.<tenant>.vars`** — still checked in; one file holds every tenant's config side by side.
175
+ - **Per-tenant non-secret vars live in `env.<tenant>.vars`**, checked in.
168
176
  - **Local-dev vars** come from a gitignored `.vars.local`, injected as `--var` by the dev script (the top-level block carries no in-config `vars`).
169
177
  - **Per-tenant secrets, never in git** — `wrangler secret put <NAME> --env <tenant>`, or a gitignored per-tenant `.secrets` (e.g. `worker/tenants/<tenant>/.secrets`) pushed with `wrangler secret bulk --env <tenant>`, plus a committed `.secrets.example`. Secrets Store bindings are declared inside each env block.
170
178
  - **`keep_vars: true`** (top level) keeps a per-tenant deploy **additive**: tenants inject their non-secret vars via `--var` (or the dashboard), and without it a deploy would wipe them. On secrets it changes nothing (§1.10).
171
179
 
172
- `.gitignore`: `.vars.local`, `.secrets`, `.secrets.*`, `.env`, `.env.*`
180
+ `.gitignore`: §1.10's list, with `.vars.local` in place of `.dev.vars`.
@@ -25,6 +25,6 @@ Every package compiles under `strict: true` and `noUncheckedIndexedAccess: true`
25
25
 
26
26
  Shared libraries (`@nurix/components`, etc.) are generic on purpose — that's what types `cell(row)`, checks `slug` against `keyof TData`, and ties `data`/`columns` together; **never** widen to `any` to dodge it. State the row type **once per file** and let inference carry it — repeating `fn<Type>(…)` / `<Comp<Type>>` per call site is cosmetic (same safety, same emitted JS):
27
27
 
28
- - **Bind a repeated factory once** (preferred): `const col = defineColumn<BriefRow>;` → `col({...})`. Canonical: `modules/briefs/BriefsListPage.tsx`.
29
- - **Infer JSX type args from props:** `<Table data={rows} columns={columns} />`, not `<Table<BriefRow> …>`.
30
- - **Anchor with a collection annotation** when nothing else can: `const columns: ColumnDefinition<BriefRow>[] = [...]`.
28
+ - **Bind a repeated factory once** (preferred): `const col = defineColumn<Row>;` → `col({...})`.
29
+ - **Infer JSX type args from props:** `<Table data={rows} columns={columns} />`, not `<Table<Row> …>`.
30
+ - **Anchor with a collection annotation** when nothing else can: `const columns: ColumnDefinition<Row>[] = [...]`.
@@ -4,13 +4,13 @@ Runtime versions, package policy, the API response envelope, and GitHub Actions
4
4
 
5
5
  ## Runtime Versions
6
6
 
7
- - **Node.js:** the version pinned in `.nvmrc` / `package.json` `engines` — **never** a hardcoded number, **never** the runner's default. Workflows use `node-version-file: .nvmrc`.
7
+ - **Node.js:** the version pinned in `.nvmrc` / `package.json` `engines` — never a hardcoded number anywhere.
8
8
  - **Package manager:** detect from the lockfile — **never** hardcode one.
9
9
  - **PostgreSQL:** 17 · **Redis:** 7.x (latest)
10
10
 
11
11
  ## Packages
12
12
 
13
- **Only packages in the approved table** — `.claude/hooks/approved-packages.md`, enforced by the PreToolUse install gate, which names the file on a block. The table changes only by explicit user decision. Test packages install at `latest`; fake data comes from `@ngneat/falso` — **never** `@faker-js/faker` / `chance`.
13
+ **Only packages in the approved table** — `.claude/hooks/approved-packages.md`, enforced by the PreToolUse install gate, which names the file on a block. The table changes only by explicit user decision.
14
14
 
15
15
  ## API Response Envelope
16
16
 
@@ -37,4 +37,4 @@ Pin the major, and check it against the action's releases — a major behind sti
37
37
  | `actions/checkout` | v7 | Repo checkout |
38
38
  | `actions/setup-node` | v7 | Node toolchain + npm cache |
39
39
 
40
- Set `node-version` via `node-version-file: .nvmrc`, **never** a hardcoded number and **never** the runner's default.
40
+ Set `node-version` via `node-version-file: .nvmrc`, **never** the runner's default.
@@ -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.12",
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": "599762913a4e"
11
+ "digest": "c9abef75f3c4"
12
12
  }
@@ -10,8 +10,6 @@ Every unit of work leaves one record — `docs/history/changelog/YYYY-MM-DD-{thr
10
10
  | The user asks for a changelog | The entry for the work in hand. No commit required |
11
11
  | A commit is made | Every non-exempt commit is covered by an entry — the `commit` skill writes or completes it (no `commit` skill in the runtime ⇒ write the entry from the `documentation` skill's `templates/changelog-entry.md` and stage it with the commit) |
12
12
 
13
- An entry is owed only by work that changes promised behavior or takes a design decision; mechanical and cosmetic commits — a typo, a copy tweak, a config value, a log line — take only their `docs/history/CHANGELOG.md` row, no entry file. The failure fields (`Ask:`, `Skills:`, `Pin:`) are required only when non-none.
14
-
15
13
  Route the lesson by who pays for losing it: product context — what changed and why — belongs in the entry; anything the **next session would re-pay** — a library's API drifted from what the model expects, a validator's quirk, a tooling workaround — goes to `docs/memory/main.md` as well, because a changelog is read by people tracing history and memory is read by every session before it repeats the mistake.
16
14
 
17
15
  ## Lifecycle
@@ -178,9 +178,17 @@ The single policy for sub-agent dispatch — when to fan out, at what tier, and
178
178
 
179
179
  ## Authoring Conventions
180
180
 
181
- Most artifacts are markdown — skills, agents, rules, docs. Follow the conventions in `docs/architecture/skills/` and the `kb` / `nomenclature` rules; markdown has no file-path-comment rule. Keep the `.claude/rules/*` reference docs frontmatter-free.
181
+ Most artifacts are markdown — skills, agents, rules, docs. Follow the `kb` / `nomenclature` rules; markdown has no file-path-comment rule. Keep the `.claude/rules/*` reference docs frontmatter-free.
182
182
 
183
- Authoring or updating an **agent** keeps both runtimes in sync: the `.md` is the single source, and its Codex `.codex.toml` twin is derived from it by following [`docs/architecture/harness/authoring-agents.md`](docs/architecture/harness/authoring-agents.md), then checked with `node packages/cli/lib/codex/validate-codex-agents.mjs`.
183
+ **Each artifact class has an authoring guide — read the one you are editing, before you edit it.** The forge is the only repo that authors these, so the guides live here and ship nowhere:
184
+
185
+ | Editing | Guide |
186
+ | --- | --- |
187
+ | A **rule** (`rules/*.md`) | [`docs/architecture/rules.md`](docs/architecture/rules.md) — the catalog, the conventions, and the whole-file audit |
188
+ | A **skill** (`skills/*/`) | [`docs/architecture/skills/playbook/`](docs/architecture/skills/playbook/Readme.md) — fit and scope through shipping and maintenance |
189
+ | An **agent** (`agents/*.md`) | [`docs/architecture/harness/authoring-agents.md`](docs/architecture/harness/authoring-agents.md) |
190
+
191
+ Authoring or updating an **agent** keeps both runtimes in sync: the `.md` is the single source, and its Codex `.codex.toml` twin is derived from it by the guide above, then checked with `node packages/cli/lib/codex/validate-codex-agents.mjs`.
184
192
 
185
193
  For real code (the CLI under `packages/`, hook scripts):
186
194
 
@@ -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.4.11",
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": "84ce5acb1a74"
11
+ "digest": "615e0382294b"
12
12
  }
@@ -10,8 +10,6 @@ Every unit of work leaves one record — `docs/history/changelog/YYYY-MM-DD-{thr
10
10
  | The user asks for a changelog | The entry for the work in hand. No commit required |
11
11
  | A commit is made | Every non-exempt commit is covered by an entry — the `commit` skill writes or completes it (no `commit` skill in the runtime ⇒ write the entry from the `documentation` skill's `templates/changelog-entry.md` and stage it with the commit) |
12
12
 
13
- An entry is owed only by work that changes promised behavior or takes a design decision; mechanical and cosmetic commits — a typo, a copy tweak, a config value, a log line — take only their `docs/history/CHANGELOG.md` row, no entry file. The failure fields (`Ask:`, `Skills:`, `Pin:`) are required only when non-none.
14
-
15
13
  Route the lesson by who pays for losing it: product context — what changed and why — belongs in the entry; anything the **next session would re-pay** — a library's API drifted from what the model expects, a validator's quirk, a tooling workaround — goes to `docs/memory/main.md` as well, because a changelog is read by people tracing history and memory is read by every session before it repeats the mistake.
16
14
 
17
15
  ## Lifecycle
@@ -4,13 +4,13 @@ Runtime versions, package policy, the API response envelope, and GitHub Actions
4
4
 
5
5
  ## Runtime Versions
6
6
 
7
- - **Node.js:** the version pinned in `.nvmrc` / `package.json` `engines` — **never** a hardcoded number, **never** the runner's default. Workflows use `node-version-file: .nvmrc`.
7
+ - **Node.js:** the version pinned in `.nvmrc` / `package.json` `engines` — never a hardcoded number anywhere.
8
8
  - **Package manager:** detect from the lockfile — **never** hardcode one.
9
9
  - **PostgreSQL:** 17 · **Redis:** 7.x (latest)
10
10
 
11
11
  ## Packages
12
12
 
13
- **Only packages in the approved table** — `.claude/hooks/approved-packages.md`, enforced by the PreToolUse install gate, which names the file on a block. The table changes only by explicit user decision. Test packages install at `latest`; fake data comes from `@ngneat/falso` — **never** `@faker-js/faker` / `chance`.
13
+ **Only packages in the approved table** — `.claude/hooks/approved-packages.md`, enforced by the PreToolUse install gate, which names the file on a block. The table changes only by explicit user decision.
14
14
 
15
15
  ## API Response Envelope
16
16
 
@@ -37,4 +37,4 @@ Pin the major, and check it against the action's releases — a major behind sti
37
37
  | `actions/checkout` | v7 | Repo checkout |
38
38
  | `actions/setup-node` | v7 | Node toolchain + npm cache |
39
39
 
40
- Set `node-version` via `node-version-file: .nvmrc`, **never** a hardcoded number and **never** the runner's default.
40
+ Set `node-version` via `node-version-file: .nvmrc`, **never** the runner's default.
@@ -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.75",
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": "a4e2f407bbc6"
45
+ "etnaFingerprint": "f747b8cde1b8"
46
46
  }