@gcunharodrigues/wrxn 0.7.1 → 0.8.0
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/manifest.json +10 -0
- package/package.json +10 -2
- package/payload/.claude/skills/sync/SKILL.md +8 -5
- package/payload/.claude/skills/write-an-agent/EXAMPLES.md +38 -0
- package/payload/.claude/skills/write-an-agent/SKILL.md +96 -0
- package/payload/.mcp.json +1 -1
- package/payload/.wrxn/harvest.cjs +65 -1
- package/payload/.wrxn/sync.cjs +15 -6
package/manifest.json
CHANGED
|
@@ -343,6 +343,16 @@
|
|
|
343
343
|
"class": "managed",
|
|
344
344
|
"profile": "project"
|
|
345
345
|
},
|
|
346
|
+
{
|
|
347
|
+
"path": ".claude/skills/write-an-agent/SKILL.md",
|
|
348
|
+
"class": "managed",
|
|
349
|
+
"profile": "project"
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"path": ".claude/skills/write-an-agent/EXAMPLES.md",
|
|
353
|
+
"class": "managed",
|
|
354
|
+
"profile": "project"
|
|
355
|
+
},
|
|
346
356
|
{
|
|
347
357
|
"path": ".claude/skills/audit/SKILL.md",
|
|
348
358
|
"class": "managed",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"wrxn": "bin/wrxn.cjs"
|
|
@@ -16,11 +16,19 @@
|
|
|
16
16
|
"test": "node --test --require ./test/setup.cjs"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"recon-wrxn": "6.0.0-wrxn.
|
|
19
|
+
"recon-wrxn": "6.0.0-wrxn.6"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/gcunharodrigues/wrxn-kernel.git"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"provenance": true,
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
24
32
|
"license": "MIT",
|
|
25
33
|
"private": false
|
|
26
34
|
}
|
|
@@ -35,14 +35,17 @@ This skill **queries that set and reports it**. It is the third maintenance loop
|
|
|
35
35
|
node .wrxn/sync.cjs report
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
2. **Read the JSON** it prints — `{ status, stale[], unwatermarked[] }`:
|
|
38
|
+
2. **Read the JSON** it prints — `{ status, stale[], unwatermarked[], orphaned[] }`:
|
|
39
39
|
|
|
40
|
-
- **`status: "synced"`** — the stale
|
|
41
|
-
manufacture findings. A clean tree is a successful no-op.
|
|
40
|
+
- **`status: "synced"`** — both the stale AND orphaned sets are empty. **Say so briefly ("all synced") and
|
|
41
|
+
stop.** Do not manufacture findings. A clean tree is a successful no-op.
|
|
42
42
|
- **`status: "drift"`** — present each `stale[]` entry to the operator: the **doc** page, the **symbol**
|
|
43
43
|
that moved, and **`synced_to` → `current`** (the watermark vs the source's current fingerprint). If
|
|
44
|
-
`
|
|
45
|
-
|
|
44
|
+
`orphaned[]` is non-empty, flag those DISTINCTLY — each is a **dangling** doc (`doc` + `synced_to`) whose
|
|
45
|
+
`derived_from:` source symbol was **renamed or deleted**, so its provenance is gone and drift can no
|
|
46
|
+
longer be computed. The reconcile loop below cannot fix an orphan (there is no live source to re-stamp
|
|
47
|
+
against); surface it so the operator can re-anchor or retire the page. If `unwatermarked[]` is non-empty,
|
|
48
|
+
note those separately — docs that declare `derived_from` but were never watermarked.
|
|
46
49
|
- **`status: "unavailable"`** — recon's serve door is not warm (no `recon-wrxn serve` running, or it was
|
|
47
50
|
unreachable). Report "drift unavailable — start `recon-wrxn serve` and retry." Never treat this as
|
|
48
51
|
"all synced": unknown is not clean.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# write-an-agent — Examples
|
|
2
|
+
|
|
3
|
+
## A compressed read-only locator
|
|
4
|
+
|
|
5
|
+
A canonical interactive subagent: a single job, a least-privilege grant (read-only — no `Bash`, so no
|
|
6
|
+
push path), `haiku` (the work is mechanical), and a tight output contract with a concrete example.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
---
|
|
10
|
+
name: route-finder
|
|
11
|
+
description: >
|
|
12
|
+
Read-only locator for HTTP routes. Returns a path:line table of where each route
|
|
13
|
+
is defined and its handler. Use PROACTIVELY when someone asks "where is the X
|
|
14
|
+
route", "what handles the Y endpoint", or "list all routes".
|
|
15
|
+
tools: Read, Grep, Glob
|
|
16
|
+
model: haiku
|
|
17
|
+
---
|
|
18
|
+
You locate HTTP routes and report them. Nothing else.
|
|
19
|
+
|
|
20
|
+
## Process
|
|
21
|
+
1. Grep for route definitions (router calls, decorators, path strings).
|
|
22
|
+
2. Resolve each route's handler symbol.
|
|
23
|
+
3. Emit the table. Stop.
|
|
24
|
+
|
|
25
|
+
## Constraints
|
|
26
|
+
- Read-only — never edit, run git, or propose changes.
|
|
27
|
+
- No preamble, no prose padding.
|
|
28
|
+
|
|
29
|
+
## Output
|
|
30
|
+
Your final message IS the result. One row per route, nothing else:
|
|
31
|
+
`METHOD /path — handler@file:line`
|
|
32
|
+
Example:
|
|
33
|
+
`GET /users — listUsers@api/users.ts:42`
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Why it's SOTA:** read-only grant (no push path), `haiku` (mechanical), the `description` leads
|
|
37
|
+
delegation with "use PROACTIVELY" + exact trigger phrases, and the output contract is a fixed
|
|
38
|
+
one-row-per-route shape — the caller gets a compact table, not prose.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: write-an-agent
|
|
3
|
+
description: Create a state-of-the-art interactive Claude Code subagent (.claude/agents/<name>.md) through a guided interview — pin its single job, routing trigger, least-privilege tools (you approve the grant), model, and a mandatory compressed output contract, then scaffold and validate the subagent file. Use when someone wants to create, add, or scaffold a subagent, build a custom agent, or says "write an agent", "create an agent", or "new subagent".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing an Agent
|
|
7
|
+
|
|
8
|
+
Scaffold an **interactive subagent** — a `.claude/agents/<name>.md` file spawned via the Agent/Task
|
|
9
|
+
tool — through a short interview that gets the few SOTA levers right. Your job here is not to fill a
|
|
10
|
+
template; it is to **interrogate the job until the subagent is sharp, cheap, and safe**, then write a
|
|
11
|
+
validated file.
|
|
12
|
+
|
|
13
|
+
**Scope:** interactive helpers (locators, reviewers, runners you call in-session). **Not** AFK
|
|
14
|
+
pipeline executors — those live in the kernel's dispatch-executor registry (`wrxn dispatch`). If the
|
|
15
|
+
user wants a workflow *they* follow inline, write a skill instead (`write-a-skill`).
|
|
16
|
+
|
|
17
|
+
## Why a subagent (the levers that make it good)
|
|
18
|
+
|
|
19
|
+
A subagent is a **separate context** with its own tools, model, and system prompt. The caller sees
|
|
20
|
+
**only its final message**. That one fact drives every lever:
|
|
21
|
+
|
|
22
|
+
| Lever | Why |
|
|
23
|
+
|---|---|
|
|
24
|
+
| **Single responsibility** | One narrow job → sharp prompt → correct routing. An "and" means two subagents. |
|
|
25
|
+
| **`description` = #1 routing lever** | The main thread matches this text to delegate. Vague → mis-routed. Use "use PROACTIVELY" + trigger phrases. |
|
|
26
|
+
| **Least-privilege `tools`** | Omitting `tools` inherits ALL tools. Smaller surface = safer + cheaper. |
|
|
27
|
+
| **Compressed output contract** | The caller eats only the final message. A terse fixed shape is the highest-value, most-skipped lever. |
|
|
28
|
+
| **Stateless** | No memory but the spawn prompt + this file. Put everything it needs on the page. |
|
|
29
|
+
| **Model match** | Mechanical → haiku; reasoning → sonnet; hard → opus. |
|
|
30
|
+
|
|
31
|
+
## File format (verified — code.claude.com/docs sub-agents)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
---
|
|
35
|
+
name: <kebab-case, equals the filename>
|
|
36
|
+
description: >
|
|
37
|
+
<what it does> + WHEN. Include "use PROACTIVELY" + trigger phrases.
|
|
38
|
+
tools: Read, Grep, Glob # explicit allowlist ALWAYS — omitting inherits ALL tools
|
|
39
|
+
model: haiku # haiku | sonnet | opus | inherit
|
|
40
|
+
skills: tdd # OPTIONAL — preloads that skill's content at startup
|
|
41
|
+
---
|
|
42
|
+
<system prompt — SOTA body order below>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Real fields: `name`, `description`, `tools`, `model`, `skills` (advanced: `disallowedTools`,
|
|
46
|
+
`permissionMode`). Don't invent others.
|
|
47
|
+
|
|
48
|
+
## Interview — run in this order
|
|
49
|
+
|
|
50
|
+
1. **Job** — the ONE responsibility. If it needs an "and", STOP and propose two subagents.
|
|
51
|
+
2. **Trigger** — when should the main thread delegate? → becomes `description`:
|
|
52
|
+
`<what it does>. Use PROACTIVELY when <the exact situations/phrases that route to it>.`
|
|
53
|
+
3. **Propose frontmatter → ONE consolidated approval.** Infer from the job, then show `tools` +
|
|
54
|
+
`model` + `skills` together as a single preview; the operator approves or edits before you continue:
|
|
55
|
+
- **tools** — the minimal allowlist. Locator/reviewer → `Read, Grep, Glob`; + run things → `+Bash`;
|
|
56
|
+
mutates files → `+Edit, Write`; research → `WebFetch, WebSearch`; integration →
|
|
57
|
+
`mcp__<server>__<tool>` (named, never `*`). **Never omit** (= inherit-all). **Never grant a push
|
|
58
|
+
path** (no devops). Flag `Bash` as broad (reaches `git` + the network).
|
|
59
|
+
- **model** — mechanical → `haiku`, reasoning → `sonnet`, hard → `opus`, match the caller → `inherit`.
|
|
60
|
+
- **skills** — propose only if the job maps to a skill **installed here** (`.claude/skills/<name>/`
|
|
61
|
+
exists; a dangling preload = a broken subagent). Don't auto-add `caveman` — compression ships via
|
|
62
|
+
the body instruction, which is portable.
|
|
63
|
+
4. **Output contract (mandatory)** — pin exactly what returns + how terse: a fixed return shape (e.g.
|
|
64
|
+
`path:line — finding`, one per line; or small JSON) **and** a compression instruction (lead with the
|
|
65
|
+
answer, drop prose, backtick exact paths/symbols). Don't finish the subagent without it.
|
|
66
|
+
5. **Generate → validate → write** (below).
|
|
67
|
+
|
|
68
|
+
## Body structure (SOTA order — every subagent)
|
|
69
|
+
|
|
70
|
+
1. **Role** — one line: who it is + the single job.
|
|
71
|
+
2. **Process** — the numbered steps it follows.
|
|
72
|
+
3. **Constraints** — hard NOs: what it refuses (scope creep; edits if read-only; ever pushing).
|
|
73
|
+
4. **Output contract** — the fixed return shape + **one concrete example**. State plainly: *your final
|
|
74
|
+
message IS the return value — return the result, not a conversational reply.* Compress.
|
|
75
|
+
5. **Stateless reminder** — it gets only its spawn prompt + this file; no main-thread memory, no
|
|
76
|
+
inherited CLAUDE.md persona. Keep the page self-sufficient.
|
|
77
|
+
|
|
78
|
+
## Validate (loose by design)
|
|
79
|
+
|
|
80
|
+
- [ ] `---` fences open/close; `name` present, kebab-case, equals the filename.
|
|
81
|
+
- [ ] `description` says **what** + **when** (the routing trigger; uses "use PROACTIVELY").
|
|
82
|
+
- [ ] `tools` is an explicit allowlist of **real** tools; present, never omitted; no push path.
|
|
83
|
+
- [ ] `model` ∈ `haiku | sonnet | opus | inherit`.
|
|
84
|
+
- [ ] `skills` (if present) names a skill that exists under `.claude/skills/`.
|
|
85
|
+
- [ ] Body has Role + Process + Constraints + Output contract (with an example).
|
|
86
|
+
- Don't hard-gate `tools` against a frozen list — new MCP tools are valid.
|
|
87
|
+
|
|
88
|
+
## Write + load
|
|
89
|
+
|
|
90
|
+
- Write `.claude/agents/<name>.md` at the install root. **Brownfield-safe**: if it exists, STOP —
|
|
91
|
+
offer to edit or rename, never overwrite.
|
|
92
|
+
- **Loading:** a file written to disk is **not live this session**. Tell the operator to **restart
|
|
93
|
+
Claude Code, or create/refresh it via the `/agents` interface** (which loads immediately). Then it is
|
|
94
|
+
invokable by the Agent/Task tool, a workflow, or FleetView.
|
|
95
|
+
|
|
96
|
+
See **[EXAMPLES.md](EXAMPLES.md)** for a complete worked subagent.
|
package/payload/.mcp.json
CHANGED
|
@@ -50,6 +50,14 @@ const HARVEST_DIR = ['.wrxn', 'harvest'];
|
|
|
50
50
|
const STAGED_FILE = 'staged.jsonl'; // the proposed-but-unconfirmed merges (survivor body + absorbed, by-reference).
|
|
51
51
|
const AUDIT_FILE = 'audit.jsonl'; // append-only outcome log (stage + commit + decay events).
|
|
52
52
|
const DECAY_STAGED_FILE = 'decay-staged.jsonl'; // harvest-04: proposed-but-unconfirmed decay annotations (by-reference). Distinct fixed name from merge's staged.jsonl; both non-.md so recon never recalls a staged-but-unconfirmed op.
|
|
53
|
+
// Bounded retention for the timestamped <ts>.jsonl check reports (phase-4.5-04). `check` writes a fresh
|
|
54
|
+
// report every run; without a cap the dir grows without bound on a long-lived install. Keep the N most-recent
|
|
55
|
+
// (ISO timestamps sort lexically = chronologically — no clock read). 20 is a generous trailing window of
|
|
56
|
+
// recent checks for trend/diff while strictly bounding growth; env override (clamped >= 1 so the just-written
|
|
57
|
+
// report is never pruned) mirrors synapse's WRXN_HANDOFF_PCT precedent. REPORT_RE matches ONLY a timestamped
|
|
58
|
+
// report — the fixed-name state files (staged/audit/decay-staged.jsonl) + .gitkeep never match, never prune.
|
|
59
|
+
const REPORT_RETAIN_DEFAULT = 20;
|
|
60
|
+
const REPORT_RE = /^\d{4}-\d{2}-\d{2}T.*\.jsonl$/;
|
|
53
61
|
const BODY_MAX = 32000; // survivor body cap (chars) — a durable merged page, not a dump (dream/sync parity).
|
|
54
62
|
const WIKI_REL = ['.wrxn', 'wiki']; // all merge targets confine under <root>/.wrxn/wiki/<knowledge-tier>/.
|
|
55
63
|
const WIKI_PREFIX = '.wrxn/wiki/'; // stripped to form the reinforce.json wiki-rel join key (recall-surface parity).
|
|
@@ -296,6 +304,22 @@ function nearDupQualifies(hit) {
|
|
|
296
304
|
return Number.isFinite(sem) && hasSemantic && sem >= NEAR_DUP_THRESHOLD;
|
|
297
305
|
}
|
|
298
306
|
|
|
307
|
+
// Order clusters by a STABLE total order so an unchanged tree yields a byte-identical report every run
|
|
308
|
+
// (phase-4.5-04). Primary key = the lexically-first member. The old `(a,b) => a.members[0] < b.members[0]
|
|
309
|
+
// ? -1 : 1` returned 1 for BOTH compare(a,b) AND compare(b,a) on an equal leading member — non-antisymmetric,
|
|
310
|
+
// so V8's sort could resolve tied clusters by input permutation → non-reproducible reports. Clusters are
|
|
311
|
+
// disjoint connected components, so leading members never collide TODAY; the secondary keys make this a
|
|
312
|
+
// proper total order regardless (defense in depth): larger cluster first, then the stronger edge score, then
|
|
313
|
+
// the full member list — and two truly-identical clusters compare EQUAL (return 0).
|
|
314
|
+
function compareClusters(a, b) {
|
|
315
|
+
if (a.members[0] !== b.members[0]) return a.members[0] < b.members[0] ? -1 : 1;
|
|
316
|
+
if (a.members.length !== b.members.length) return b.members.length - a.members.length; // larger cluster first
|
|
317
|
+
if (a.score !== b.score) return b.score - a.score; // stronger dup signal first
|
|
318
|
+
const ja = a.members.join('');
|
|
319
|
+
const jb = b.members.join('');
|
|
320
|
+
return ja < jb ? -1 : ja > jb ? 1 : 0; // full member list; identical clusters → 0
|
|
321
|
+
}
|
|
322
|
+
|
|
299
323
|
// Collapse pairwise near-dup edges into connected-component CLUSTERS (union-find), so a symmetric A↔B
|
|
300
324
|
// match is reported once and a transitive A-B-C chain is one cluster of three. Each cluster carries its
|
|
301
325
|
// sorted members + the STRONGEST edge similarity (the clearest dup signal). Singletons are not clusters.
|
|
@@ -333,7 +357,7 @@ function clusterNearDups(edges) {
|
|
|
333
357
|
if (members.size < 2) continue;
|
|
334
358
|
clusters.push({ members: [...members].sort(), score: Math.round((groupScore.get(r) || 0) * 1e4) / 1e4 });
|
|
335
359
|
}
|
|
336
|
-
return clusters.sort(
|
|
360
|
+
return clusters.sort(compareClusters);
|
|
337
361
|
}
|
|
338
362
|
|
|
339
363
|
// ── the door (IO shell, injectable transport) — the recall-surface.cjs contract ──
|
|
@@ -504,6 +528,41 @@ function reportPath(dir, ts) {
|
|
|
504
528
|
return file;
|
|
505
529
|
}
|
|
506
530
|
|
|
531
|
+
// The effective report-retention bound: the WRXN_HARVEST_RETAIN env override (clamped to a whole number >= 1
|
|
532
|
+
// so a bogus/zero value can never prune away the just-written report), else the sane default (phase-4.5-04).
|
|
533
|
+
function reportRetention() {
|
|
534
|
+
const env = Number(process.env.WRXN_HARVEST_RETAIN);
|
|
535
|
+
return Number.isFinite(env) && env >= 1 ? Math.floor(env) : REPORT_RETAIN_DEFAULT;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Prune the timestamped check reports under .wrxn/harvest/ to the retention bound, keeping the `keep` most-
|
|
539
|
+
// recent (phase-4.5-04). ISO timestamps sort lexically = chronologically, so the oldest are the lexical
|
|
540
|
+
// prefix — no clock is read. ONLY <ts>.jsonl reports are eligible (REPORT_RE); the fixed-name state files
|
|
541
|
+
// (staged/audit/decay-staged.jsonl) + .gitkeep never match, so curation/merge/decay trails are never touched.
|
|
542
|
+
// Fail-soft: an unreadable dir or a failed unlink is swallowed — retention is hygiene, never the point of check.
|
|
543
|
+
function pruneReports(dir, keep, protect) {
|
|
544
|
+
let names;
|
|
545
|
+
try {
|
|
546
|
+
names = fs.readdirSync(dir);
|
|
547
|
+
} catch {
|
|
548
|
+
return; // no report dir yet → nothing to prune
|
|
549
|
+
}
|
|
550
|
+
const reports = names.filter((n) => REPORT_RE.test(n)).sort(); // lexical = chronological (oldest first)
|
|
551
|
+
// Delete the oldest down to the `keep` bound, but NEVER the just-written report (`protect`): a same-
|
|
552
|
+
// millisecond collision names it `<base>-N.jsonl`, which collates BEFORE its `<base>.jsonl` sibling, so a
|
|
553
|
+
// blind oldest-prefix prune could delete the fresh report `check` is about to return (phase-4.5-04 review).
|
|
554
|
+
let toDelete = reports.length - keep;
|
|
555
|
+
for (let i = 0; i < reports.length && toDelete > 0; i++) {
|
|
556
|
+
if (reports[i] === protect) continue; // the fresh report is retained and counts toward `keep`
|
|
557
|
+
try {
|
|
558
|
+
fs.unlinkSync(path.join(dir, reports[i]));
|
|
559
|
+
} catch {
|
|
560
|
+
/* fail-soft — a vanished/locked report never breaks the check run */
|
|
561
|
+
}
|
|
562
|
+
toDelete--;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
507
566
|
// ── check: the IO orchestrator ───────────────────────────────────────────────────
|
|
508
567
|
// scanLocal (always) + nearDupFromDoor (degrades to unavailable when cold) → assemble → write the jsonl.
|
|
509
568
|
// REPORT-ONLY: the ONLY write is the report under .wrxn/harvest/. `transport` is injected in tests.
|
|
@@ -520,6 +579,7 @@ async function check(root, { transport, timeoutMs } = {}) {
|
|
|
520
579
|
const dir = harvestDir(root);
|
|
521
580
|
const file = reportPath(dir, ts);
|
|
522
581
|
fs.writeFileSync(file, records.length ? records.map((r) => JSON.stringify(r)).join('\n') + '\n' : '');
|
|
582
|
+
pruneReports(dir, reportRetention(), path.basename(file)); // phase-4.5-04: bound the report dir, never pruning the fresh report
|
|
523
583
|
const nearDupCount = near.status === 'unavailable' ? 0 : near.clusters.length;
|
|
524
584
|
return {
|
|
525
585
|
report: path.relative(root, file),
|
|
@@ -1154,7 +1214,10 @@ module.exports = {
|
|
|
1154
1214
|
scanLocal,
|
|
1155
1215
|
isProse,
|
|
1156
1216
|
nearDupQualifies,
|
|
1217
|
+
compareClusters,
|
|
1157
1218
|
clusterNearDups,
|
|
1219
|
+
pruneReports,
|
|
1220
|
+
reportRetention,
|
|
1158
1221
|
assembleRecords,
|
|
1159
1222
|
tierOfPath,
|
|
1160
1223
|
isHarvestPath,
|
|
@@ -1187,4 +1250,5 @@ module.exports = {
|
|
|
1187
1250
|
NEAR_DUP_THRESHOLD,
|
|
1188
1251
|
FIND_PATH,
|
|
1189
1252
|
REINFORCE_WINDOW_DAYS,
|
|
1253
|
+
REPORT_RETAIN_DEFAULT,
|
|
1190
1254
|
};
|
package/payload/.wrxn/sync.cjs
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Subcommand:
|
|
17
17
|
// report query recon_drift over the serve door and print the drift summary JSON:
|
|
18
|
-
// { status, stale[], unwatermarked[] }
|
|
19
|
-
// · status "drift" — at least one doc is stale
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// { status, stale[], unwatermarked[], orphaned[] }
|
|
19
|
+
// · status "drift" — at least one doc is stale OR orphaned. stale[] names doc/symbol/
|
|
20
|
+
// synced_to/current; orphaned[] (phase-4.5-02) names a doc whose
|
|
21
|
+
// derived_from source symbol is GONE (doc/synced_to, no symbol/current).
|
|
22
|
+
// · status "synced" — the warm door computed an EMPTY stale AND orphaned set ("all synced";
|
|
23
|
+
// AC3 no-op, never manufactures rows).
|
|
22
24
|
// · status "unavailable" — recon is unreachable OR answered without the structured drift sidecar
|
|
23
25
|
// (no warm door, a timeout, a non-200, a malformed body, or a 200 whose
|
|
24
26
|
// body lacks an affirmative `drift.stale` array — unknown is not clean,
|
|
@@ -117,11 +119,18 @@ function summarizeDrift(parsed) {
|
|
|
117
119
|
if (!isEntry(d) || !Array.isArray(d.stale)) return unavailable();
|
|
118
120
|
const stale = d.stale.filter(isEntry).map(normEntry);
|
|
119
121
|
const unwatermarked = (Array.isArray(d.unwatermarked) ? d.unwatermarked : []).filter(isEntry).map(normEntry);
|
|
120
|
-
|
|
122
|
+
// phase-4.5-02: the third drift class — a watermarked page whose derived_from source symbol is GONE from
|
|
123
|
+
// recon's graph (renamed/deleted). Each entry normalizes to { doc, synced_to } (no symbol/current — the
|
|
124
|
+
// source is absent). It is DISTINCT from stale (source moved) and unwatermarked (never reconciled), but
|
|
125
|
+
// like stale it MUST elevate the status off a clean "synced": a dangling page is the exact case sync used
|
|
126
|
+
// to hide (the operator never learned the page was un-reconcilable). An older recon with no drift.orphaned
|
|
127
|
+
// degrades to [] (back-compatible, never throws).
|
|
128
|
+
const orphaned = (Array.isArray(d.orphaned) ? d.orphaned : []).filter(isEntry).map(normEntry);
|
|
129
|
+
return { status: stale.length || orphaned.length ? 'drift' : 'synced', stale, unwatermarked, orphaned };
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
function unavailable() {
|
|
124
|
-
return { status: 'unavailable', stale: [], unwatermarked: [] };
|
|
133
|
+
return { status: 'unavailable', stale: [], unwatermarked: [], orphaned: [] };
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
// ── the door (IO shell, injectable transport) — the recall-surface.cjs contract ─
|