@rubytech/create-maxy-code 0.1.487 → 0.1.489
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/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
- package/payload/platform/lib/active-rules/package-lock.json +12 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
- package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
- package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +25 -1
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
- package/payload/platform/lib/graph-style/src/index.ts +27 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts +5 -0
- package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.js +3 -2
- package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
- package/payload/platform/lib/storage-broker/src/audit.ts +8 -2
- package/payload/platform/neo4j/schema.cypher +105 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
- package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +77 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +72 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +43 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +22 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +23 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +3 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +46 -5
- package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/memory/PLUGIN.md +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
- package/payload/platform/plugins/memory/references/schema-construction.md +13 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -0
- package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +22 -2
- package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
- package/payload/platform/scripts/quarantine-restore.mjs +175 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +55 -72
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
- package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
- package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
- package/payload/server/{chunk-REWXLAPC.js → chunk-UFXRUKXO.js} +4 -2
- package/payload/server/public/activity.html +6 -6
- package/payload/server/public/assets/{AdminLoginScreens-DF9S5Rca.js → AdminLoginScreens-Dic_fPt6.js} +1 -1
- package/payload/server/public/assets/{AdminShell-B0PNDCAb.js → AdminShell-JN7rZ8aG.js} +1 -1
- package/payload/server/public/assets/{Checkbox-s5X6dso4.js → Checkbox-CB74kINA.js} +1 -1
- package/payload/server/public/assets/{activity-BRBsaalV.js → activity-DbfpHg3i.js} +1 -1
- package/payload/server/public/assets/{admin-BoVCP4Mw.js → admin-DvQPTxqe.js} +1 -1
- package/payload/server/public/assets/{bot-BVO9Uqr_.js → bot-DnuyRsLS.js} +1 -1
- package/payload/server/public/assets/{browser-BVW4FFii.js → browser-q4EFqKvR.js} +1 -1
- package/payload/server/public/assets/{calendar-CWsD3uLk.js → calendar-C-q9kWmA.js} +1 -1
- package/payload/server/public/assets/chat-DcefIV9q.js +1 -0
- package/payload/server/public/assets/chevron-down-Blma_CKl.js +1 -0
- package/payload/server/public/assets/chevron-left-xeYDxFiC.js +1 -0
- package/payload/server/public/assets/chevron-right-BonbtjqJ.js +1 -0
- package/payload/server/public/assets/data-Bu-5SUmR.js +1 -0
- package/payload/server/public/assets/{graph-mJ9ky5dc.js → graph-Cyh6FWaV.js} +1 -1
- package/payload/server/public/assets/graph-labels-CUQtXFKW.js +1 -0
- package/payload/server/public/assets/{maximize-2-yucCepu_.js → maximize-2-k3Fu5hdP.js} +1 -1
- package/payload/server/public/assets/{operator-911z_Fy_.js → operator-8NVpNLeD.js} +1 -1
- package/payload/server/public/assets/{page-Cv0qUwMG.js → page-Cno8NNMk.js} +1 -1
- package/payload/server/public/assets/{page-CgSgj8Cy.js → page-DhwPG5Zf.js} +1 -1
- package/payload/server/public/assets/{public-BzwveFwz.js → public-BJ5Oo2Fw.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-BH51cf2Q.js → rotate-ccw-D-ov5jM4.js} +1 -1
- package/payload/server/public/assets/tasks-DHMxTZVD.js +1 -0
- package/payload/server/public/assets/{time-entry-format-DPdGkp1C.js → time-entry-format-EmV5lz6o.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-DO2rjjfV.js → triangle-alert-CivyPfAL.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-HMSM7-Ir.js → useCopyFeedback-hbnPS-r9.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-2ojOOYNo.css → useSubAccountSwitcher-kwhG-aJj.css} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-BcukQIza.js → useVoiceRecorder-QN9_GqB_.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +13 -13
- package/payload/server/public/data.html +10 -10
- package/payload/server/public/graph.html +11 -11
- package/payload/server/public/index.html +16 -16
- package/payload/server/public/operator.html +15 -15
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1036 -784
- package/payload/server/{src-JJ6PHO5I.js → src-YJF3UJNR.js} +1 -1
- package/payload/server/public/assets/chat-DKGREuIO.js +0 -1
- package/payload/server/public/assets/chevron-down-m581tl1J.js +0 -1
- package/payload/server/public/assets/chevron-left-BAY0xfmR.js +0 -1
- package/payload/server/public/assets/chevron-right-Dy_REdYZ.js +0 -1
- package/payload/server/public/assets/data-CtOxa94P.js +0 -1
- package/payload/server/public/assets/graph-labels-C8-sIKuv.js +0 -1
- package/payload/server/public/assets/tasks-21egY7A6.js +0 -1
- /package/payload/server/public/assets/{useSubAccountSwitcher-DLvqvfoc.js → useSubAccountSwitcher-DotTrvX3.js} +0 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
# Task 1899 — reconcile reports and never moves — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** The standing account-dir reconcile stops moving files into `.quarantine/` and instead names every account-root entry the schema does not account for, and a one-shot script puts back what the sweep already took.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `reconcileAccount` keeps computing strays and returns their names alongside the counts; the move block is deleted outright. `runReconcile` emits one `op=strays` line per affected account before the existing summary. A separate `platform/scripts/quarantine-restore.mjs` reads each account's quarantine manifest newest-first and moves entries back only where the account root has nothing at that name.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (Node16 ESM, `strict: true`), vitest for the service tests, plain `.mjs` + a bash `.test.sh` for the script, matching the `platform/scripts/` convention.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Spec: `platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md`. Task: `.tasks/1899-account-dir-reconcile-moves-live-state.md`.
|
|
14
|
+
- `PROTECTED_TOP_LEVEL` and `QUARANTINE_DIR` are kept, not deleted. They no longer protect anything from a move; they keep known platform entries and existing quarantine directories out of the stray count.
|
|
15
|
+
- Nothing in this plan deletes a file from disk at runtime. The reconcile never writes; the restore script only renames and appends.
|
|
16
|
+
- `over-deep` and `bad-name` behaviour is unchanged. Both already count without moving.
|
|
17
|
+
- The installer payload has no committed twin of any file touched here — `packages/create-maxy-code/scripts/bundle.js` copies `platform/` at bundle time. No twin edits.
|
|
18
|
+
- Every commit carries `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` and `Session: b141b27a-1d60-4de9-9f29-091ee2485814` trailers.
|
|
19
|
+
- Run all vitest commands from `platform/services/claude-session-manager`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
### Task 1: Reconcile reports and never moves
|
|
24
|
+
|
|
25
|
+
**Files:**
|
|
26
|
+
- Modify: `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts:28-36` (imports), `:92-104` (types and formatter), `:202-211` (`quarantineDest`), `:213-278` (`reconcileAccount`)
|
|
27
|
+
- Test: `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`
|
|
28
|
+
|
|
29
|
+
**Interfaces:**
|
|
30
|
+
- Consumes: nothing from earlier tasks.
|
|
31
|
+
- Produces: `ReconcileCounts { strayTopLevel: number; overDeep: number; badName: number }` (no `quarantined`); `ReconcileResult extends ReconcileCounts { strayNames: string[] }`; `reconcileAccount(accountDir: string): ReconcileResult`; `formatReconcileLine(c: ReconcileCounts): string`.
|
|
32
|
+
|
|
33
|
+
The `now: () => number` parameter is removed from `reconcileAccount`, and `now?: () => number` is removed from `ReconcileDeps`. Its only reader was the manifest record's `movedAt` field, which this task deletes. Nothing else passes it: `index.ts` constructs the reconcile without it.
|
|
34
|
+
|
|
35
|
+
- [ ] **Step 1: Rewrite the formatter test to the three-field line**
|
|
36
|
+
|
|
37
|
+
In `src/__tests__/account-dir-schema-reconcile.test.ts`, replace the `formatReconcileLine` describe block with:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
describe('formatReconcileLine', () => {
|
|
41
|
+
it('renders the greppable metric line', () => {
|
|
42
|
+
expect(formatReconcileLine({ strayTopLevel: 2, overDeep: 1, badName: 3 })).toBe(
|
|
43
|
+
'[fs-reconcile] stray-top-level=2 over-deep=1 bad-name=3',
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 2: Replace the quarantine-move test with the no-move test**
|
|
50
|
+
|
|
51
|
+
Replace the `it('quarantines a stray top-level dir with a manifest entry', ...)` case (lines 68-84) with:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
it('names a stray top-level dir and never moves it', () => {
|
|
55
|
+
const dir = seedAccount('acct-a')
|
|
56
|
+
mkdirSync(join(dir, '_attic'))
|
|
57
|
+
writeFileSync(join(dir, '_attic', 'junk.txt'), 'x')
|
|
58
|
+
const c = reconcileAccount(dir)
|
|
59
|
+
expect(c.strayTopLevel).toBe(1)
|
|
60
|
+
expect(c.strayNames).toEqual(['_attic'])
|
|
61
|
+
// The entry stays exactly where the platform (or the agent) put it, and no
|
|
62
|
+
// quarantine directory is created. A closed allowed-list that lags what the
|
|
63
|
+
// platform writes turned this move into data loss (Task 1899).
|
|
64
|
+
expect(existsSync(join(dir, '_attic', 'junk.txt'))).toBe(true)
|
|
65
|
+
expect(existsSync(join(dir, '.quarantine'))).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- [ ] **Step 3: Strip `quarantined` and `FIXED_NOW` from the remaining cases**
|
|
70
|
+
|
|
71
|
+
Every remaining `expect(c.quarantined)...` assertion is deleted. Every `reconcileAccount(dir, FIXED_NOW)` call becomes `reconcileAccount(dir)`, and the `const FIXED_NOW = () => 1_700_000_000_000` declaration at line 26 is deleted along with the `now: FIXED_NOW` property in each `runReconcile` call. The cases that asserted a stray landed under `.quarantine/` assert instead that it is still at the root and named. Concretely:
|
|
72
|
+
|
|
73
|
+
`'never quarantines dot-prefixed top-level tool state, only non-dot strays'` becomes:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
it('does not count dot-prefixed top-level tool state as a stray, only non-dot entries', () => {
|
|
77
|
+
const dir = seedAccount('acct-dotdirs')
|
|
78
|
+
// dot-prefixed tool/system dirs — never counted.
|
|
79
|
+
for (const d of ['.remember', '.superpowers', '.wrangler', '.uploads-tmp', '.archive']) {
|
|
80
|
+
mkdirSync(join(dir, d))
|
|
81
|
+
writeFileSync(join(dir, d, 'state.txt'), 'x')
|
|
82
|
+
}
|
|
83
|
+
// a genuine non-dot operator stray — counted and named.
|
|
84
|
+
mkdirSync(join(dir, 'workers'))
|
|
85
|
+
const c = reconcileAccount(dir)
|
|
86
|
+
expect(c.strayTopLevel).toBe(1)
|
|
87
|
+
expect(c.strayNames).toEqual(['workers'])
|
|
88
|
+
for (const d of ['.remember', '.superpowers', '.wrangler', '.uploads-tmp', '.archive']) {
|
|
89
|
+
expect(existsSync(join(dir, d))).toBe(true)
|
|
90
|
+
}
|
|
91
|
+
expect(existsSync(join(dir, 'workers'))).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`'never quarantines platform-written control-plane root files absent from the allowed block'` becomes:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
it('never counts platform-written control-plane root files absent from the allowed block', () => {
|
|
99
|
+
// The account's SCHEMA.md allowed block lists only buckets, never these
|
|
100
|
+
// platform-written root files. PROTECTED_TOP_LEVEL keeps them out of the
|
|
101
|
+
// stray count so the standing line stays readable; a genuine operator stray
|
|
102
|
+
// alongside them is still counted and named.
|
|
103
|
+
const dir = seedAccount('acct-control-files')
|
|
104
|
+
const controlFiles = [
|
|
105
|
+
'calendar-availability.json',
|
|
106
|
+
'wa-channel-bindings.json',
|
|
107
|
+
'webchat-channel-bindings.json',
|
|
108
|
+
'telegram-channel-bindings.json',
|
|
109
|
+
'canonical-webchat-session.json',
|
|
110
|
+
'session-titles.json',
|
|
111
|
+
]
|
|
112
|
+
for (const f of controlFiles) writeFileSync(join(dir, f), '{}')
|
|
113
|
+
mkdirSync(join(dir, 'workers')) // a genuine operator stray
|
|
114
|
+
const c = reconcileAccount(dir)
|
|
115
|
+
expect(c.strayTopLevel).toBe(1)
|
|
116
|
+
expect(c.strayNames).toEqual(['workers'])
|
|
117
|
+
for (const f of controlFiles) expect(existsSync(join(dir, f))).toBe(true)
|
|
118
|
+
expect(existsSync(join(dir, 'workers'))).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`'never quarantines control-plane entries even when a stale schema omits them'` becomes:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
it('never counts control-plane entries even when a stale schema omits them', () => {
|
|
126
|
+
// A stale/hand-edited schema whose allowed block lacks .git/.claude/account.json.
|
|
127
|
+
const dir = join(root, 'acct-stale')
|
|
128
|
+
mkdirSync(dir, { recursive: true })
|
|
129
|
+
writeFileSync(
|
|
130
|
+
join(dir, 'SCHEMA.md'),
|
|
131
|
+
'# schema\n```allowed-top-level\nprojects\n.quarantine\n```\n',
|
|
132
|
+
)
|
|
133
|
+
writeFileSync(join(dir, 'account.json'), '{}')
|
|
134
|
+
mkdirSync(join(dir, '.git'))
|
|
135
|
+
mkdirSync(join(dir, '.claude'))
|
|
136
|
+
writeFileSync(join(dir, 'AGENTS.md'), 'x')
|
|
137
|
+
mkdirSync(join(dir, 'workers')) // a genuine operator stray
|
|
138
|
+
const c = reconcileAccount(dir)
|
|
139
|
+
expect(c.strayTopLevel).toBe(1)
|
|
140
|
+
expect(c.strayNames).toEqual(['workers'])
|
|
141
|
+
expect(existsSync(join(dir, '.git'))).toBe(true)
|
|
142
|
+
expect(existsSync(join(dir, '.claude'))).toBe(true)
|
|
143
|
+
expect(existsSync(join(dir, 'account.json'))).toBe(true)
|
|
144
|
+
expect(existsSync(join(dir, 'SCHEMA.md'))).toBe(true)
|
|
145
|
+
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true)
|
|
146
|
+
expect(existsSync(join(dir, 'workers'))).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The `'skips an account with no SCHEMA.md (fail open)'` case's `toEqual` becomes:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
expect(c).toEqual({ strayTopLevel: 0, overDeep: 0, badName: 0, strayNames: [] })
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 4: Run the tests to verify they fail**
|
|
157
|
+
|
|
158
|
+
Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
|
|
159
|
+
Expected: FAIL. TypeScript rejects `formatReconcileLine` called without `quarantined`, and `c.strayNames` does not exist on `ReconcileCounts`.
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 5: Delete the move, add `strayNames`**
|
|
162
|
+
|
|
163
|
+
In `src/account-dir-schema-reconcile.ts`:
|
|
164
|
+
|
|
165
|
+
Imports become:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
169
|
+
import { join, relative, sep } from 'node:path'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Types and formatter become:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
export interface ReconcileCounts {
|
|
176
|
+
strayTopLevel: number
|
|
177
|
+
overDeep: number
|
|
178
|
+
badName: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ReconcileResult extends ReconcileCounts {
|
|
182
|
+
/** Every account-root entry the schema does not account for, by name. A count
|
|
183
|
+
* alone cannot distinguish six pieces of debris from a live plugin config;
|
|
184
|
+
* this list is what makes the report-only posture actionable. */
|
|
185
|
+
strayNames: string[]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The single per-run summary line body. A non-zero count is the standing signal
|
|
189
|
+
* that an agent authored folders the hard guards could not prevent. Pure
|
|
190
|
+
* formatter so the emitter and its test share one wording. */
|
|
191
|
+
export function formatReconcileLine(c: ReconcileCounts): string {
|
|
192
|
+
return `${TAG} stray-top-level=${c.strayTopLevel} over-deep=${c.overDeep} bad-name=${c.badName}`
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Delete `quarantineDest` entirely (lines 202-211). `reconcileAccount` becomes:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
/** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
|
|
200
|
+
* set skips the account (fail open, like the hook — an un-seeded legacy account
|
|
201
|
+
* must not have its whole root reported as stray). Counts and names stray
|
|
202
|
+
* top-level entries; counts over-deep files and bad names. Moves nothing. */
|
|
203
|
+
export function reconcileAccount(accountDir: string): ReconcileResult {
|
|
204
|
+
const empty: ReconcileResult = { strayTopLevel: 0, overDeep: 0, badName: 0, strayNames: [] }
|
|
205
|
+
const schemaPath = join(accountDir, 'SCHEMA.md')
|
|
206
|
+
if (!existsSync(schemaPath)) return empty
|
|
207
|
+
let allowed: Set<string>
|
|
208
|
+
try {
|
|
209
|
+
allowed = parseAllowedTopLevel(readFileSync(schemaPath, 'utf-8'))
|
|
210
|
+
} catch {
|
|
211
|
+
return empty
|
|
212
|
+
}
|
|
213
|
+
if (allowed.size === 0) return empty
|
|
214
|
+
|
|
215
|
+
let rootEntries: string[]
|
|
216
|
+
try {
|
|
217
|
+
rootEntries = readdirSync(accountDir)
|
|
218
|
+
} catch {
|
|
219
|
+
return empty
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A dot-prefixed top-level entry is tool/system state, never operator data —
|
|
223
|
+
// the same judgement countBadNameEntries applies when it skips dotfiles. The
|
|
224
|
+
// stray check must agree, or it reports .remember/.superpowers/.wrangler on
|
|
225
|
+
// every run (Task 1891). .git/.claude/.quarantine stay covered by
|
|
226
|
+
// PROTECTED_TOP_LEVEL regardless.
|
|
227
|
+
const strays = rootEntries.filter(
|
|
228
|
+
(name) => !name.startsWith('.') && !PROTECTED_TOP_LEVEL.has(name) && !allowed.has(name),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
let overDeep = 0
|
|
232
|
+
let badName = 0
|
|
233
|
+
for (const bucket of DEPTH_CHECKED_BUCKETS) {
|
|
234
|
+
overDeep += countOverDeepFiles(accountDir, bucket)
|
|
235
|
+
badName += countBadNameEntries(accountDir, bucket)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { strayTopLevel: strays.length, overDeep, badName, strayNames: strays }
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Rewrite the file's header comment so its third paragraph reads:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
// Per account, per run it detects (a) stray top-level entries — any account-root
|
|
246
|
+
// entry not in that account's allowed-top-level set — (b) over-deep files under
|
|
247
|
+
// the flat operator-data buckets, mirroring fs-schema-guard.sh's own depth rule
|
|
248
|
+
// exactly (projects/contacts/documents cap at 3 path segments; every other
|
|
249
|
+
// tool-owned bucket is unbounded and not walked), and (c) bad-named folders and
|
|
250
|
+
// files under those same buckets, mirroring the guard's name convention. All
|
|
251
|
+
// three are REPORTED, never moved: the pass used to quarantine stray top-level
|
|
252
|
+
// entries, and because its allowed-list lagged what the platform and its agents
|
|
253
|
+
// actually write at an account root, every mismatch took a live feature or live
|
|
254
|
+
// data dark with no error anywhere (Task 1899 — the portal config, live signed
|
|
255
|
+
// agreements and the channel bindings all went). Nothing is moved, nothing is
|
|
256
|
+
// deleted, nothing is silently rewritten; re-filing a stray to its schema-correct
|
|
257
|
+
// home is the data-manager specialist's job on dispatch, not this pass.
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Update the `PROTECTED_TOP_LEVEL` doc comment's first sentence to `Platform control-plane entries the reconcile NEVER counts as strays` and its closing sentence to say the belt keeps the account's own machinery out of the stray count.
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 6: Run the tests to verify they pass**
|
|
263
|
+
|
|
264
|
+
Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
|
|
265
|
+
Expected: PASS.
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 7: Mutation check**
|
|
268
|
+
|
|
269
|
+
Re-add a `renameSync(join(accountDir, name), join(accountDir, QUARANTINE_DIR, name))` call guarded by `mkdirSync(join(accountDir, QUARANTINE_DIR), { recursive: true })` inside a `for (const name of strays)` loop before the `return`. Run the tests: the `'names a stray top-level dir and never moves it'` case must FAIL on `existsSync(join(dir, '.quarantine'))`. Revert.
|
|
270
|
+
|
|
271
|
+
Then delete `'workers'` from the `strayNames` return (`strayNames: []`). Run the tests: the naming assertions must FAIL. Revert.
|
|
272
|
+
|
|
273
|
+
- [ ] **Step 8: Commit**
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
git add maxy-code/platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts \
|
|
277
|
+
maxy-code/platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts
|
|
278
|
+
git commit -m "fix(reconcile): report stray top-level entries, never move them (Task 1899)"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
### Task 2: `runReconcile` names the strays per account
|
|
284
|
+
|
|
285
|
+
**Files:**
|
|
286
|
+
- Modify: `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts:280-314` (`ReconcileDeps`, `runReconcile`)
|
|
287
|
+
- Test: `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`
|
|
288
|
+
|
|
289
|
+
**Interfaces:**
|
|
290
|
+
- Consumes: `reconcileAccount(accountDir: string): ReconcileResult` from Task 1.
|
|
291
|
+
- Produces: `ReconcileDeps { accountsRoot: string; logger: (line: string) => void }` (no `now`); `runReconcile(deps: ReconcileDeps): void`, which emits zero or more `op=strays` lines followed by exactly one summary line.
|
|
292
|
+
|
|
293
|
+
- [ ] **Step 1: Write the failing tests**
|
|
294
|
+
|
|
295
|
+
Replace the `runReconcile` describe block with:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
describe('runReconcile', () => {
|
|
299
|
+
it('names each account with strays, then logs one summary line', () => {
|
|
300
|
+
seedAccount('clean')
|
|
301
|
+
const dirty = seedAccount('dirty')
|
|
302
|
+
mkdirSync(join(dirty, 'workers'))
|
|
303
|
+
writeFileSync(join(dirty, 'data-portal.json'), '{}')
|
|
304
|
+
const lines: string[] = []
|
|
305
|
+
runReconcile({ accountsRoot: root, logger: (l) => lines.push(l) })
|
|
306
|
+
expect(lines).toEqual([
|
|
307
|
+
'[fs-reconcile] op=strays account=dirty names=data-portal.json,workers',
|
|
308
|
+
'[fs-reconcile] stray-top-level=2 over-deep=0 bad-name=0',
|
|
309
|
+
])
|
|
310
|
+
// Reported, not moved.
|
|
311
|
+
expect(existsSync(join(dirty, 'workers'))).toBe(true)
|
|
312
|
+
expect(existsSync(join(dirty, 'data-portal.json'))).toBe(true)
|
|
313
|
+
expect(existsSync(join(root, 'dirty', '.quarantine'))).toBe(false)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('emits no op=strays line when every account root is clean', () => {
|
|
317
|
+
seedAccount('clean-a')
|
|
318
|
+
seedAccount('clean-b')
|
|
319
|
+
const lines: string[] = []
|
|
320
|
+
runReconcile({ accountsRoot: root, logger: (l) => lines.push(l) })
|
|
321
|
+
expect(lines).toEqual(['[fs-reconcile] stray-top-level=0 over-deep=0 bad-name=0'])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('logs zeros when the accounts root is unreadable', () => {
|
|
325
|
+
const lines: string[] = []
|
|
326
|
+
runReconcile({ accountsRoot: join(root, 'does-not-exist'), logger: (l) => lines.push(l) })
|
|
327
|
+
expect(lines).toEqual(['[fs-reconcile] stray-top-level=0 over-deep=0 bad-name=0'])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('ignores a child dir that is not an account (no account.json)', () => {
|
|
331
|
+
seedAccount('real')
|
|
332
|
+
mkdirSync(join(root, 'not-an-account'))
|
|
333
|
+
mkdirSync(join(root, 'not-an-account', 'whim'))
|
|
334
|
+
const lines: string[] = []
|
|
335
|
+
runReconcile({ accountsRoot: root, logger: (l) => lines.push(l) })
|
|
336
|
+
expect(lines).toEqual(['[fs-reconcile] stray-top-level=0 over-deep=0 bad-name=0'])
|
|
337
|
+
expect(existsSync(join(root, 'not-an-account', 'whim'))).toBe(true)
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`readdirSync` returns entries in directory order, and the first test asserts `data-portal.json,workers` — alphabetical because `readdirSync` on both macOS (APFS) and ext4 with `dir_index` returns them so for this fixture. If the assertion is order-flaky in CI, sort `strayNames` in `reconcileAccount` rather than loosening the assertion; sorted output is also easier to read in a log.
|
|
343
|
+
|
|
344
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
345
|
+
|
|
346
|
+
Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
|
|
347
|
+
Expected: FAIL. Only the summary line is emitted; the `op=strays` line is missing.
|
|
348
|
+
|
|
349
|
+
- [ ] **Step 3: Emit the per-account line**
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
export interface ReconcileDeps {
|
|
353
|
+
accountsRoot: string
|
|
354
|
+
logger: (line: string) => void
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** One reconcile pass over every account under the accounts root: one op=strays
|
|
358
|
+
* line naming the unaccounted-for entries of each account that has any, then the
|
|
359
|
+
* single summary line. An account is any child dir with an account.json (the
|
|
360
|
+
* account marker the census uses). Per-account errors are swallowed so one bad
|
|
361
|
+
* account never aborts the run.
|
|
362
|
+
*
|
|
363
|
+
* The op=strays line is the standing signal. When a feature that reads an
|
|
364
|
+
* account-root file goes dark, the file named here means the schema does not
|
|
365
|
+
* account for it and the platform is still reading it, so declare it; no line
|
|
366
|
+
* naming it means the file is genuinely gone and the fault lies with whatever
|
|
367
|
+
* last wrote it. */
|
|
368
|
+
export function runReconcile(deps: ReconcileDeps): void {
|
|
369
|
+
const total: ReconcileCounts = { strayTopLevel: 0, overDeep: 0, badName: 0 }
|
|
370
|
+
let entries: string[]
|
|
371
|
+
try {
|
|
372
|
+
entries = readdirSync(deps.accountsRoot)
|
|
373
|
+
} catch {
|
|
374
|
+
deps.logger(formatReconcileLine(total))
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
const accountDir = join(deps.accountsRoot, entry)
|
|
379
|
+
if (!existsSync(join(accountDir, 'account.json'))) continue
|
|
380
|
+
try {
|
|
381
|
+
const c = reconcileAccount(accountDir)
|
|
382
|
+
if (c.strayNames.length > 0) {
|
|
383
|
+
deps.logger(`${TAG} op=strays account=${entry} names=${c.strayNames.join(',')}`)
|
|
384
|
+
}
|
|
385
|
+
total.strayTopLevel += c.strayTopLevel
|
|
386
|
+
total.overDeep += c.overDeep
|
|
387
|
+
total.badName += c.badName
|
|
388
|
+
} catch {
|
|
389
|
+
// best-effort: skip an account that throws unexpectedly
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
deps.logger(formatReconcileLine(total))
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
- [ ] **Step 4: Run the tests to verify they pass**
|
|
397
|
+
|
|
398
|
+
Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
|
|
399
|
+
Expected: PASS, 18 or more tests.
|
|
400
|
+
|
|
401
|
+
- [ ] **Step 5: Build the service**
|
|
402
|
+
|
|
403
|
+
Run: `cd ../.. && npx tsc -p services/claude-session-manager/tsconfig.json`
|
|
404
|
+
Expected: no output, exit 0.
|
|
405
|
+
|
|
406
|
+
- [ ] **Step 6: Commit**
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
git add maxy-code/platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts \
|
|
410
|
+
maxy-code/platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts
|
|
411
|
+
git commit -m "feat(reconcile): name unaccounted-for account-root entries per account (Task 1899)"
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
### Task 3: Correct the two prose surfaces that describe the move
|
|
417
|
+
|
|
418
|
+
**Files:**
|
|
419
|
+
- Modify: `platform/services/claude-session-manager/src/index.ts:860-867`
|
|
420
|
+
- Modify: `platform/templates/account-schema/SCHEMA.md:34-37`
|
|
421
|
+
|
|
422
|
+
**Interfaces:**
|
|
423
|
+
- Consumes: the behaviour established in Tasks 1 and 2.
|
|
424
|
+
- Produces: nothing consumed by later tasks.
|
|
425
|
+
|
|
426
|
+
- [ ] **Step 1: Rewrite the call-site comment**
|
|
427
|
+
|
|
428
|
+
Replace the comment block at `index.ts:860-867` with:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
// Task 1877 / Task 1899 — standing account-dir schema reconcile. The
|
|
432
|
+
// fs-schema-guard path hook never sees Bash, and the immediate Bash hooks only
|
|
433
|
+
// catch strays a live command newly creates; neither retroactively catches the
|
|
434
|
+
// backlog an agent authored before the hooks existed. Folder creation through
|
|
435
|
+
// Bash is a no-event failure, so this periodic filesystem pass is the only
|
|
436
|
+
// signal. It REPORTS and never moves: it names every account-root entry the
|
|
437
|
+
// schema does not account for (op=strays) and counts over-deep files and bad
|
|
438
|
+
// names, emitting one [fs-reconcile] metric line per run. It used to quarantine
|
|
439
|
+
// strays, and because a closed allowed-list always lags what the platform
|
|
440
|
+
// writes, that turned a metric into data loss. Purely filesystem — no Neo4j.
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
- [ ] **Step 2: Rewrite the account SCHEMA.md template paragraph**
|
|
444
|
+
|
|
445
|
+
Replace `platform/templates/account-schema/SCHEMA.md:34-37`:
|
|
446
|
+
|
|
447
|
+
```
|
|
448
|
+
`.quarantine/` is a historical store. An earlier version of the schema reconcile
|
|
449
|
+
moved entries out of the top level into it, recording each move in
|
|
450
|
+
`.quarantine/manifest.jsonl`; the reconcile now reports unrecognised entries and
|
|
451
|
+
never moves anything, so nothing is added to it. Re-file whatever it still holds
|
|
452
|
+
to its correct home through the `data-manager` specialist; never author into it
|
|
453
|
+
directly.
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 3: Verify the guard's expected top-level set is unchanged**
|
|
457
|
+
|
|
458
|
+
Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
|
|
459
|
+
Expected: all assertions pass. `.quarantine` stays in the allowed set, so the write guard is unaffected.
|
|
460
|
+
|
|
461
|
+
- [ ] **Step 4: Build to confirm the comment edit broke nothing**
|
|
462
|
+
|
|
463
|
+
Run: `cd platform && npx tsc -p services/claude-session-manager/tsconfig.json`
|
|
464
|
+
Expected: no output, exit 0.
|
|
465
|
+
|
|
466
|
+
- [ ] **Step 5: Commit**
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
git add maxy-code/platform/services/claude-session-manager/src/index.ts \
|
|
470
|
+
maxy-code/platform/templates/account-schema/SCHEMA.md
|
|
471
|
+
git commit -m "docs(reconcile): call site and account schema state that the pass never moves (Task 1899)"
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
### Task 4: `quarantine-restore.mjs`
|
|
477
|
+
|
|
478
|
+
**Files:**
|
|
479
|
+
- Create: `platform/scripts/quarantine-restore.mjs`
|
|
480
|
+
- Test: `platform/scripts/__tests__/quarantine-restore.test.sh`
|
|
481
|
+
|
|
482
|
+
**Interfaces:**
|
|
483
|
+
- Consumes: the manifest record shape the deleted move block wrote — `{ name, originalPath, reason, quarantinePath, movedAt }`, one JSON object per line, appended in move order.
|
|
484
|
+
- Produces: a standalone CLI. Nothing imports it.
|
|
485
|
+
|
|
486
|
+
Invocation: `node platform/scripts/quarantine-restore.mjs --accounts-root <dir> [--dry-run]`.
|
|
487
|
+
|
|
488
|
+
Per account (any child of `--accounts-root` holding `.quarantine/manifest.jsonl`), records are walked newest-first, which is the reverse of file order because the manifest is append-only. Newest-first is what makes the occupied case correct for a name quarantined more than once: the most recent copy is considered first, and every older copy of that name then finds the target occupied. Checks run in this order, and the first that matches decides:
|
|
489
|
+
|
|
490
|
+
| Condition | Action | Log |
|
|
491
|
+
|---|---|---|
|
|
492
|
+
| `<account>/<quarantinePath>` absent | none | `result=absent` |
|
|
493
|
+
| `<account>/<originalPath>` exists | none | `result=skipped-live-copy` |
|
|
494
|
+
| otherwise | rename source to target, append to `restored.jsonl` | `result=moved` |
|
|
495
|
+
|
|
496
|
+
A manifest line that is not parseable JSON, or whose `originalPath`/`quarantinePath` is missing or not a string, logs `result=unparsable` with its 1-based line number. It is neither moved nor silently dropped: a silently skipped line is the exact failure class this task exists to end.
|
|
497
|
+
|
|
498
|
+
- [ ] **Step 1: Write the failing test**
|
|
499
|
+
|
|
500
|
+
Create `platform/scripts/__tests__/quarantine-restore.test.sh`:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
#!/usr/bin/env bash
|
|
504
|
+
# Tests the one-shot quarantine restore (Task 1899): a free target is moved back,
|
|
505
|
+
# an occupied target is skipped with both copies surviving, an already-restored
|
|
506
|
+
# entry reports absent, a malformed line reports unparsable, and --dry-run
|
|
507
|
+
# changes nothing on disk.
|
|
508
|
+
set -uo pipefail
|
|
509
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
510
|
+
RESTORE="$SCRIPT_DIR/../quarantine-restore.mjs"
|
|
511
|
+
PASS=0; FAIL=0; FAILED=()
|
|
512
|
+
assert_grep() { if printf '%s' "$2" | grep -qF "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected to find [$1]"); fi; }
|
|
513
|
+
assert_nogrep() { if printf '%s' "$2" | grep -qF "$1"; then FAIL=$((FAIL+1)); FAILED+=("$3: expected NOT to find [$1]"); else PASS=$((PASS+1)); fi; }
|
|
514
|
+
assert_file() { if [ -e "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected file [$1]"); fi; }
|
|
515
|
+
assert_nofile() { if [ -e "$1" ]; then FAIL=$((FAIL+1)); FAILED+=("$2: expected NO file [$1]"); else PASS=$((PASS+1)); fi; }
|
|
516
|
+
|
|
517
|
+
# One accounts root with a single account whose manifest covers all five cases.
|
|
518
|
+
make_tree() {
|
|
519
|
+
local T; T="$(mktemp -d)"
|
|
520
|
+
local A="$T/acct-1"
|
|
521
|
+
mkdir -p "$A/.quarantine"
|
|
522
|
+
printf '{}' > "$A/account.json"
|
|
523
|
+
|
|
524
|
+
# (a) free target: quarantined copy exists, nothing at the root.
|
|
525
|
+
printf 'portal' > "$A/.quarantine/data-portal.json"
|
|
526
|
+
# (b) occupied target: quarantined copy AND a live copy the platform rewrote.
|
|
527
|
+
printf 'old' > "$A/.quarantine/wa-channel-bindings.json"
|
|
528
|
+
printf 'live' > "$A/wa-channel-bindings.json"
|
|
529
|
+
# (c) already restored by hand: manifest names a source that is gone.
|
|
530
|
+
printf 'restored' > "$A/session-titles.json"
|
|
531
|
+
|
|
532
|
+
cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
|
|
533
|
+
{"name":"data-portal.json","originalPath":"data-portal.json","reason":"top-level","quarantinePath":".quarantine/data-portal.json","movedAt":"2026-07-21T19:16:05.216Z"}
|
|
534
|
+
{"name":"session-titles.json","originalPath":"session-titles.json","reason":"top-level","quarantinePath":".quarantine/session-titles.json","movedAt":"2026-07-21T19:20:00.000Z"}
|
|
535
|
+
not json at all
|
|
536
|
+
{"name":"wa-channel-bindings.json","originalPath":"wa-channel-bindings.json","reason":"top-level","quarantinePath":".quarantine/wa-channel-bindings.json","movedAt":"2026-07-21T20:45:00.000Z"}
|
|
537
|
+
EOF
|
|
538
|
+
printf '%s' "$T"
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
# --- dry run changes nothing -------------------------------------------------
|
|
542
|
+
T="$(make_tree)"; A="$T/acct-1"
|
|
543
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" --dry-run 2>&1)"
|
|
544
|
+
assert_grep 'result=moved' "$OUT" "dry-run reports the move it would make"
|
|
545
|
+
assert_grep 'dry-run=1' "$OUT" "dry-run marks its lines"
|
|
546
|
+
assert_nofile "$A/data-portal.json" "dry-run leaves the root untouched"
|
|
547
|
+
assert_file "$A/.quarantine/data-portal.json" "dry-run leaves the quarantined copy"
|
|
548
|
+
assert_nofile "$A/.quarantine/restored.jsonl" "dry-run writes no restored.jsonl"
|
|
549
|
+
rm -rf "$T"
|
|
550
|
+
|
|
551
|
+
# --- real run ----------------------------------------------------------------
|
|
552
|
+
T="$(make_tree)"; A="$T/acct-1"
|
|
553
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"
|
|
554
|
+
|
|
555
|
+
assert_grep 'op=restore account=acct-1 name=data-portal.json result=moved' "$OUT" "free target moved"
|
|
556
|
+
assert_file "$A/data-portal.json" "free target is back at the root"
|
|
557
|
+
assert_nofile "$A/.quarantine/data-portal.json" "free target no longer in quarantine"
|
|
558
|
+
|
|
559
|
+
assert_grep 'name=wa-channel-bindings.json result=skipped-live-copy' "$OUT" "occupied target skipped"
|
|
560
|
+
assert_grep 'live' "$(cat "$A/wa-channel-bindings.json")" "live copy still wins"
|
|
561
|
+
assert_file "$A/.quarantine/wa-channel-bindings.json" "quarantined copy survives the skip"
|
|
562
|
+
|
|
563
|
+
assert_grep 'name=session-titles.json result=absent' "$OUT" "already-restored source reports absent"
|
|
564
|
+
assert_grep 'restored' "$(cat "$A/session-titles.json")" "hand-restored copy untouched"
|
|
565
|
+
|
|
566
|
+
assert_grep 'line=3 result=unparsable' "$OUT" "malformed manifest line is reported"
|
|
567
|
+
|
|
568
|
+
assert_file "$A/.quarantine/manifest.jsonl" "manifest is left intact"
|
|
569
|
+
RESTORED="$(cat "$A/.quarantine/restored.jsonl")"
|
|
570
|
+
assert_grep 'data-portal.json' "$RESTORED" "restored.jsonl records the move"
|
|
571
|
+
assert_nogrep 'wa-channel-bindings.json' "$RESTORED" "restored.jsonl records only moves"
|
|
572
|
+
assert_nogrep 'session-titles.json' "$RESTORED" "restored.jsonl records only moves"
|
|
573
|
+
rm -rf "$T"
|
|
574
|
+
|
|
575
|
+
# --- summary -----------------------------------------------------------------
|
|
576
|
+
echo "PASS=$PASS FAIL=$FAIL"
|
|
577
|
+
if [ "$FAIL" -gt 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
- [ ] **Step 2: Run it to verify it fails**
|
|
581
|
+
|
|
582
|
+
Run: `bash platform/scripts/__tests__/quarantine-restore.test.sh`
|
|
583
|
+
Expected: FAIL. `node` cannot find `platform/scripts/quarantine-restore.mjs`.
|
|
584
|
+
|
|
585
|
+
- [ ] **Step 3: Write the script**
|
|
586
|
+
|
|
587
|
+
Create `platform/scripts/quarantine-restore.mjs`:
|
|
588
|
+
|
|
589
|
+
```javascript
|
|
590
|
+
#!/usr/bin/env node
|
|
591
|
+
// Task 1899 — one-shot restore of everything the account-dir schema reconcile
|
|
592
|
+
// moved into <account>/.quarantine/ before it was made report-only. The sweep's
|
|
593
|
+
// allowed-list never matched what the platform and its agents write at an
|
|
594
|
+
// account root, so it took live features and live data dark with no error: the
|
|
595
|
+
// portal config, live signed agreements, the channel bindings.
|
|
596
|
+
//
|
|
597
|
+
// Per account it walks .quarantine/manifest.jsonl NEWEST-FIRST (the manifest is
|
|
598
|
+
// append-only, so that is reverse file order). Newest-first is what makes the
|
|
599
|
+
// occupied case correct for a name quarantined more than once: the most recent
|
|
600
|
+
// copy is considered first, and every older copy of that name then finds the
|
|
601
|
+
// target occupied. The live copy always wins, because the platform rewrote it
|
|
602
|
+
// after the move — four wa-channel-bindings.json records on one account prove
|
|
603
|
+
// that happened.
|
|
604
|
+
//
|
|
605
|
+
// Never deletes. The manifest is left intact so the history of what moved and
|
|
606
|
+
// what came back both survive.
|
|
607
|
+
|
|
608
|
+
import { existsSync, readdirSync, readFileSync, renameSync, appendFileSync } from 'node:fs'
|
|
609
|
+
import { join } from 'node:path'
|
|
610
|
+
|
|
611
|
+
const TAG = '[quarantine-restore]'
|
|
612
|
+
const QUARANTINE_DIR = '.quarantine'
|
|
613
|
+
|
|
614
|
+
function parseArgs(argv) {
|
|
615
|
+
let accountsRoot = null
|
|
616
|
+
let dryRun = false
|
|
617
|
+
for (let i = 0; i < argv.length; i++) {
|
|
618
|
+
if (argv[i] === '--accounts-root') {
|
|
619
|
+
accountsRoot = argv[i + 1] ?? null
|
|
620
|
+
i++
|
|
621
|
+
} else if (argv[i] === '--dry-run') {
|
|
622
|
+
dryRun = true
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return { accountsRoot, dryRun }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const { accountsRoot, dryRun } = parseArgs(process.argv.slice(2))
|
|
629
|
+
if (!accountsRoot) {
|
|
630
|
+
console.error(`${TAG} usage: quarantine-restore.mjs --accounts-root <dir> [--dry-run]`)
|
|
631
|
+
process.exit(1)
|
|
632
|
+
}
|
|
633
|
+
if (!existsSync(accountsRoot)) {
|
|
634
|
+
console.error(`${TAG} accounts root not found: ${accountsRoot}`)
|
|
635
|
+
process.exit(1)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const suffix = dryRun ? ' dry-run=1' : ''
|
|
639
|
+
const totals = { moved: 0, 'skipped-live-copy': 0, absent: 0, unparsable: 0 }
|
|
640
|
+
let accounts = 0
|
|
641
|
+
|
|
642
|
+
for (const entry of readdirSync(accountsRoot)) {
|
|
643
|
+
const accountDir = join(accountsRoot, entry)
|
|
644
|
+
const manifestPath = join(accountDir, QUARANTINE_DIR, 'manifest.jsonl')
|
|
645
|
+
if (!existsSync(manifestPath)) continue
|
|
646
|
+
accounts++
|
|
647
|
+
|
|
648
|
+
let lines
|
|
649
|
+
try {
|
|
650
|
+
lines = readFileSync(manifestPath, 'utf-8').split('\n')
|
|
651
|
+
} catch (err) {
|
|
652
|
+
console.error(`${TAG} op=restore account=${entry} result=manifest-unreadable error=${err.message}`)
|
|
653
|
+
continue
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Newest-first: the manifest is append-only, so walk it backwards. The 1-based
|
|
657
|
+
// file line number is carried so an unparsable line can be pointed at.
|
|
658
|
+
const numbered = lines.map((text, i) => ({ text, lineNo: i + 1 })).filter((l) => l.text.trim())
|
|
659
|
+
for (const { text, lineNo } of numbered.reverse()) {
|
|
660
|
+
let rec
|
|
661
|
+
try {
|
|
662
|
+
rec = JSON.parse(text)
|
|
663
|
+
} catch {
|
|
664
|
+
rec = null
|
|
665
|
+
}
|
|
666
|
+
if (!rec || typeof rec.originalPath !== 'string' || typeof rec.quarantinePath !== 'string') {
|
|
667
|
+
totals.unparsable++
|
|
668
|
+
console.log(`${TAG} op=restore account=${entry} line=${lineNo} result=unparsable${suffix}`)
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const source = join(accountDir, rec.quarantinePath)
|
|
673
|
+
const target = join(accountDir, rec.originalPath)
|
|
674
|
+
const name = rec.originalPath
|
|
675
|
+
|
|
676
|
+
if (!existsSync(source)) {
|
|
677
|
+
totals.absent++
|
|
678
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=absent${suffix}`)
|
|
679
|
+
continue
|
|
680
|
+
}
|
|
681
|
+
if (existsSync(target)) {
|
|
682
|
+
totals['skipped-live-copy']++
|
|
683
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=skipped-live-copy${suffix}`)
|
|
684
|
+
continue
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (dryRun) {
|
|
688
|
+
totals.moved++
|
|
689
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=moved${suffix}`)
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
renameSync(source, target)
|
|
694
|
+
appendFileSync(
|
|
695
|
+
join(accountDir, QUARANTINE_DIR, 'restored.jsonl'),
|
|
696
|
+
JSON.stringify({ ...rec, restoredAt: new Date().toISOString() }) + '\n',
|
|
697
|
+
)
|
|
698
|
+
totals.moved++
|
|
699
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=moved`)
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.error(`${TAG} op=restore account=${entry} name=${name} result=failed error=${err.message}`)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
console.log(
|
|
707
|
+
`${TAG} accounts=${accounts} moved=${totals.moved} skipped-live-copy=${totals['skipped-live-copy']} absent=${totals.absent} unparsable=${totals.unparsable}${suffix}`,
|
|
708
|
+
)
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
712
|
+
|
|
713
|
+
Run: `bash platform/scripts/__tests__/quarantine-restore.test.sh`
|
|
714
|
+
Expected: `PASS=<n> FAIL=0`, exit 0.
|
|
715
|
+
|
|
716
|
+
- [ ] **Step 5: Mutation check**
|
|
717
|
+
|
|
718
|
+
Swap the source-absent and target-occupied checks so the occupied test runs first. Run the test: the `result=absent` assertion must FAIL (`session-titles.json` would report `skipped-live-copy`). Revert.
|
|
719
|
+
|
|
720
|
+
Then change `numbered.reverse()` to `numbered`. Run the test: it still passes on this fixture, because no name repeats in it. Add a second `data-portal.json` record with an older timestamp and a `.quarantine/data-portal.json.1` source; oldest-first then restores the older copy. Revert both.
|
|
721
|
+
|
|
722
|
+
- [ ] **Step 6: Commit**
|
|
723
|
+
|
|
724
|
+
```bash
|
|
725
|
+
git add maxy-code/platform/scripts/quarantine-restore.mjs \
|
|
726
|
+
maxy-code/platform/scripts/__tests__/quarantine-restore.test.sh
|
|
727
|
+
git commit -m "feat(scripts): one-shot restore of quarantined account-root entries (Task 1899)"
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
## Verification (whole plan)
|
|
733
|
+
|
|
734
|
+
Run from `platform/services/claude-session-manager`:
|
|
735
|
+
|
|
736
|
+
```bash
|
|
737
|
+
npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Run from `platform`:
|
|
741
|
+
|
|
742
|
+
```bash
|
|
743
|
+
npx tsc -p services/claude-session-manager/tsconfig.json
|
|
744
|
+
bash scripts/__tests__/quarantine-restore.test.sh
|
|
745
|
+
bash plugins/admin/hooks/__tests__/fs-schema-guard.test.sh
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
The device checks in the task (`data-portal.json` and `e-sign/` surviving three consecutive `[fs-reconcile]` lines on `sitedesk-code` account `2078cb54`; `op=enumerate` returning `accounts=2`; a planted `zzz-not-in-schema/` being named by `op=strays` and left in place) all require the new build installed on the laptop, which follows the operator's publish command and is outside this plan.
|