@nurix/etna 0.19.76 → 0.19.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/agents/bookkeeper.md +1 -0
- package/data/harnesses/code/agents/retro.md +3 -2
- package/data/harnesses/code/codex/agents/bookkeeper.toml +1 -0
- package/data/harnesses/code/codex/agents/retro.toml +3 -2
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/approved-packages.md +2 -0
- package/data/harnesses/code/hooks/awareness.mjs +62 -5
- package/data/harnesses/code/hooks/compliance.mjs +56 -4
- package/data/harnesses/code/hooks/lib/approved.mjs +30 -2
- package/data/harnesses/code/hooks/lib/claims.mjs +203 -0
- package/data/harnesses/code/hooks/lib/state.mjs +115 -30
- package/data/harnesses/delivery/agents/retro.md +3 -2
- package/data/harnesses/delivery/codex/agents/retro.toml +3 -2
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/etna/agents/retro.md +3 -2
- package/data/harnesses/etna/codex/agents/retro.toml +3 -2
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/rules/kb.md +1 -1
- package/data/harnesses/hr/harness.json +2 -2
- package/data/harnesses/hr/rules/code-eval-batcher.md +2 -2
- package/package.json +2 -2
package/data/harness-index.json
CHANGED
|
@@ -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),
|
|
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),
|
|
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.
|
|
4
|
+
"version": "3.10.4",
|
|
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": "
|
|
18
|
+
"digest": "4b1ec8d430ab"
|
|
19
19
|
}
|
|
@@ -7,6 +7,8 @@ Read by the PreToolUse install gate (`compliance.mjs`) — an off-table install
|
|
|
7
7
|
| `react` | ^19.2.3 | UI components | — |
|
|
8
8
|
| `vite` | ^8.0.0 | Build tool, dev server | `webpack`, CRA |
|
|
9
9
|
| `tailwindcss` | ^4.1.18 | Styling | `styled-components` |
|
|
10
|
+
| `@nurix/components` | ^0.15.12 | The UI component library — import by subpath | `npx shadcn add`, a `components.json` scaffold |
|
|
11
|
+
| `@tailwindcss/vite` | ^4.1.18 | Tailwind v4 Vite plugin | `postcss`, `tailwind.config.js` |
|
|
10
12
|
| `@vitejs/plugin-react-swc` | ^4.2.2 | JSX compilation | `@vitejs/plugin-react` |
|
|
11
13
|
| `react-router-dom` | ^7.11.0 | Routing | — |
|
|
12
14
|
| `@reduxjs/toolkit` | ^2.11.2 | State management | `zustand`, `jotai` |
|
|
@@ -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).
|
|
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"),
|
|
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,
|
|
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)) {
|
|
@@ -256,11 +277,21 @@ function packageGate(projectDir, command, sessionId) {
|
|
|
256
277
|
if (!m) return null;
|
|
257
278
|
const approved = approvedPackages(projectDir);
|
|
258
279
|
if (approved.size === 0) return null; // no rules file installed — nothing to enforce
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
const raw = m[1] || "";
|
|
281
|
+
// Test packages install at `latest`, outside the table's pin set (.claude/rules/testing.md).
|
|
282
|
+
// Two dev-only installs are a decision already taken rather than a new one: a companion in an
|
|
283
|
+
// approved scope (`@testing-library/react` is approved, so `-D @testing-library/user-event` is
|
|
284
|
+
// not a fresh choice), and `@types/*`, which is the typing of a package already decided. Scoped
|
|
285
|
+
// and dev only — an unscoped or runtime install is still judged against the table.
|
|
286
|
+
const isDev = /(?:^|\s)(?:-D|--save-dev|--dev)(?:\s|$)/.test(raw);
|
|
287
|
+
const approvedScopes = new Set(
|
|
288
|
+
[...approved].filter((n) => n.startsWith("@")).map((n) => n.split("/")[0])
|
|
289
|
+
);
|
|
290
|
+
approvedScopes.add("@types");
|
|
291
|
+
const args = raw.split(/\s+/).filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a));
|
|
262
292
|
for (const arg of args) {
|
|
263
293
|
const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
|
|
294
|
+
if (isDev && pkg.startsWith("@") && approvedScopes.has(pkg.split("/")[0])) continue;
|
|
264
295
|
if (!approved.has(pkg)) {
|
|
265
296
|
return deny(
|
|
266
297
|
projectDir,
|
|
@@ -312,7 +343,28 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
312
343
|
if (!command) return null;
|
|
313
344
|
|
|
314
345
|
if (statement(String.raw`git\s+commit\b`).test(command)) {
|
|
315
|
-
|
|
346
|
+
// The claim board serializes the commit sequence: disjoint files do not make disjoint
|
|
347
|
+
// commits — every session writes the same branch through the same index, so one committer
|
|
348
|
+
// at a time. A held claim is a plain wait (no denied/resolved pair to resolve — the retry
|
|
349
|
+
// clears it); a stale claim (holder dead or past TTL) is stolen loudly on the record.
|
|
350
|
+
const claim = claimActivity(projectDir, "commit", sessionId);
|
|
351
|
+
if (claim.held) {
|
|
352
|
+
appendEvent(projectDir, sessionId, "coord", "commit-claim", `waited — held by sid=${claim.held.sid8}`, `${claim.held.ageMin}m old`);
|
|
353
|
+
return decision(
|
|
354
|
+
"deny",
|
|
355
|
+
`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.`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (claim.stole) {
|
|
359
|
+
appendEvent(projectDir, sessionId, "coord", "commit-claim", `stale claim by sid=${claim.stole.sid8} evicted`);
|
|
360
|
+
}
|
|
361
|
+
const gate = commitGate(projectDir, sessionId);
|
|
362
|
+
if (gate) {
|
|
363
|
+
// The commit is not happening — release immediately so a walled commit never holds the board.
|
|
364
|
+
releaseActivity(projectDir, "commit", sessionId);
|
|
365
|
+
return gate;
|
|
366
|
+
}
|
|
367
|
+
return null; // allowed — the awareness PostToolUse releases the claim when the command completes
|
|
316
368
|
}
|
|
317
369
|
|
|
318
370
|
// `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
|
-
|
|
27
|
-
for (const
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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 (
|
|
116
|
-
|
|
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),
|
|
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),
|
|
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.
|
|
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": "
|
|
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),
|
|
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),
|
|
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,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,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
|
|
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 —
|
|
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.
|
|
3
|
+
"version": "0.19.78",
|
|
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": "
|
|
45
|
+
"etnaFingerprint": "728af89f1e8e"
|
|
46
46
|
}
|