peertable 0.3.8 → 0.3.10

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.
@@ -13,6 +13,8 @@
13
13
  # 工程の記録は、卓そのものより寿命が長い**。ゲスト project を汚さない不可侵原則は `--purge` が持つ。
14
14
  set -e
15
15
  proj="$1"
16
+ script_dir=$(cd "$(dirname "$0")" && pwd -P)
17
+ peertable_repo=$(cd "$script_dir/../.." && pwd -P)
16
18
  mode=archive
17
19
  for arg in "${@:2}"; do
18
20
  case "$arg" in
@@ -255,8 +257,9 @@ if [ "$mode" = archive ]; then
255
257
  # 過去ログが一続きの会話に見えてしまう**(次の campaign の発言と地続きになる)
256
258
  body="解散。この卓はここまで。参加者: ${seat_names}。部屋と過去ログはこのまま残り、次の卓も同じ部屋で続く。"
257
259
  python3 -c "
258
- import json,sys,urllib.request
259
- req=urllib.request.Request('$url/api/$room/messages', method='POST',
260
+ import json,sys,urllib.parse,urllib.request
261
+ room_path=urllib.parse.quote('$room', safe='')
262
+ req=urllib.request.Request('$url/api/' + room_path + '/messages', method='POST',
260
263
  data=json.dumps({'from':'system','to':'system','body':'''$body'''}).encode(),
261
264
  headers={'Content-Type':'application/json','X-Peertable-Token':'$PEERTABLE_POST_TOKEN'})
262
265
  urllib.request.urlopen(req, timeout=10).read()
@@ -268,7 +271,7 @@ urllib.request.urlopen(req, timeout=10).read()
268
271
  c=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room/members/$name" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
269
272
  [ "$c" = 200 ] && n=$((n + 1))
270
273
  done <<<"$member_lines"
271
- did "メンバー登録の解除(${n}名)— **部屋と過去ログは残す**($url/$room)"
274
+ did "メンバー登録の解除(${n}名)— **部屋と過去ログは残す**(${url}/${room})"
272
275
  fi
273
276
  # room 削除は --purge だけ。トークンを要する唯一の段で、ここだけが外部サービスへの依存境界
274
277
  elif [ "$log_saved" = no ]; then
@@ -299,6 +302,12 @@ else
299
302
  skip "外部ペイン(登録なし)"
300
303
  fi
301
304
 
305
+ if node "$script_dir/ensure-codex-room-mcp.mjs" remove "$proj" "$peertable_repo"; then
306
+ did "Codex project設定からPeertable room MCPを撤去"
307
+ else
308
+ miss "Codex project設定のPeertable room MCPを撤去できない"
309
+ fi
310
+
302
311
  rm -rf "$proj/.team"
303
312
  did ".team/ 削除"
304
313
 
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ // 稼働中 campaign へ独立欠陥 ToDo を軽量に追加するための正規入口。
3
+ //
4
+ // 背景: main plan の migrate 時に narrative_ref へ #L 行番号を持たせなかったため、
5
+ // `lattice todo split` が `predecessor_source_inventory_unavailable` で機構的に失敗した
6
+ // (実測 2026-08-11。docs/plan_peertable-autonomy-runtime-fx-20260811.md f4)。`todo revise` は
7
+ // 使えるが desired_plan 全体・source_cutover_batch を要求する重量級 API で、発見者が親裁定なしに
8
+ // 選べる手段ではない。このツールは「計画 Markdown に `### <task_id> <title>` 見出しで task を書く
9
+ // →本ツールで extraction.json を自動生成→`lattice todo migrate` で新規 companion plan として起票」
10
+ // という軽量な代替経路を提供する。既存 plan への task 追加は、新規 companion plan を作り
11
+ // `lattice todo dependency connect` で前提へ接続する(このツールは新規 plan の migrate 入力生成
12
+ // だけを担う。稼働中 plan への revise は対象外)。
13
+ //
14
+ // usage:
15
+ // node todo-extraction-from-plan.mjs <plan.md> <plan_key> --project <project_id> --agent <name>
16
+ // [--session <name>] [--host <name>] [--lane <lane>] [--out <path>]
17
+ //
18
+ // 計画 Markdown の規約: `# <root見出し>` → `## <section見出し>` → `### <task_id> <title>` の3階層。
19
+ // 各 `###` 見出しの本文(次の `#`/`##`/`###` 見出しまで)が design_memo になる。
20
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
21
+ import { execFileSync } from 'node:child_process'
22
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
23
+
24
+ function usage(message) {
25
+ if (message) console.error(`ERROR: ${message}`)
26
+ console.error('usage: todo-extraction-from-plan.mjs <plan.md> <plan_key> --project <id> --agent <name> [--session <name>] [--host <name>] [--lane <lane>] [--out <path>]')
27
+ process.exit(1)
28
+ }
29
+
30
+ function fail(code, message, nextAction) {
31
+ console.error(`${code}: ${message}`)
32
+ if (nextAction) console.error(`next: ${nextAction}`)
33
+ process.exit(1)
34
+ }
35
+
36
+ const args = process.argv.slice(2)
37
+ if (args.length < 2 || args[0].startsWith('--')) usage('plan.md と plan_key は最初の2つの位置引数')
38
+ const [planPath, planKey, ...rest] = args
39
+ const opts = { project: null, agent: null, session: null, host: 'mac', lane: 'defect', out: null }
40
+ for (let i = 0; i < rest.length; i += 2) {
41
+ const key = rest[i]?.replace(/^--/, '')
42
+ if (!(key in opts)) usage(`unknown flag: ${rest[i]}`)
43
+ opts[key] = rest[i + 1]
44
+ }
45
+ if (!opts.project) usage('--project は必須')
46
+ if (!opts.agent) usage('--agent は必須')
47
+ if (!opts.session) opts.session = opts.agent
48
+
49
+ let absolutePlanPath
50
+ try {
51
+ absolutePlanPath = realpathSync(resolve(planPath))
52
+ } catch {
53
+ fail('PLAN_NOT_FOUND', `${planPath} を実在fileとして解決できない`, '実在する計画Markdownを指定する')
54
+ }
55
+ let repoRoot
56
+ try {
57
+ repoRoot = realpathSync(execFileSync('git', ['-C', dirname(absolutePlanPath), 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim())
58
+ } catch {
59
+ fail('PLAN_REPO_UNRESOLVED', `${planPath} の所属git repoを解決できない`, 'git管理下の計画Markdownを指定する')
60
+ }
61
+
62
+ const setupStatePath = join(repoRoot, '.team', 'setup-state.json')
63
+ let latticeCli = process.env.LATTICE_CLI?.trim() ?? ''
64
+ let latticeCliSource = 'LATTICE_CLI'
65
+ if (!latticeCli) {
66
+ latticeCliSource = setupStatePath
67
+ if (!existsSync(setupStatePath)) {
68
+ fail(
69
+ 'LATTICE_CLI_UNRESOLVED',
70
+ `LATTICE_CLIが未指定で ${setupStatePath} も存在しない`,
71
+ 'Peertable setupの正規手順で .team/setup-state.json を生成するか、LATTICE_CLI=<実行可能file> を明示する',
72
+ )
73
+ }
74
+ let setupState
75
+ try {
76
+ setupState = JSON.parse(readFileSync(setupStatePath, 'utf8'))
77
+ } catch {
78
+ fail(
79
+ 'PEERTABLE_SETUP_STATE_INVALID',
80
+ `${setupStatePath} をJSONとして読めない`,
81
+ 'Peertable setupの正規手順で setup-state.json を再生成する',
82
+ )
83
+ }
84
+ latticeCli = typeof setupState.lattice_cli === 'string' ? setupState.lattice_cli.trim() : ''
85
+ if (!latticeCli) {
86
+ fail(
87
+ 'LATTICE_CLI_UNRESOLVED',
88
+ `${setupStatePath} に lattice_cli が無い`,
89
+ 'Peertable setupの正規手順で lattice_cli を記録するか、LATTICE_CLI=<実行可能file> を明示する',
90
+ )
91
+ }
92
+ }
93
+ try {
94
+ latticeCli = realpathSync(latticeCli)
95
+ accessSync(latticeCli, constants.X_OK)
96
+ } catch {
97
+ fail(
98
+ 'LATTICE_CLI_INVALID',
99
+ `${latticeCliSource} の lattice CLI が実行可能fileではない: ${latticeCli}`,
100
+ latticeCliSource === 'LATTICE_CLI'
101
+ ? 'LATTICE_CLI=<実行可能な lattice CLI> を指定する'
102
+ : 'Peertable setupの正規手順で setup-state.json の lattice_cli を再生成する',
103
+ )
104
+ }
105
+ const latticePkgSrc = join(dirname(dirname(latticeCli)), 'src', 'todo-contracts.mjs')
106
+ const { todoSelfDigest } = await import(latticePkgSrc)
107
+
108
+ const planPathFromRoot = relative(repoRoot, absolutePlanPath)
109
+ if (isAbsolute(planPathFromRoot) || planPathFromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || planPathFromRoot === '..') {
110
+ fail('PLAN_OUTSIDE_REPO', `${planPath} は ${repoRoot} の外にある`, 'repo内の計画Markdownを指定する')
111
+ }
112
+ const relPlanPath = execFileSync('git', ['-C', repoRoot, 'ls-files', '--full-name', planPathFromRoot], { encoding: 'utf8' }).trim()
113
+ if (!relPlanPath) usage(`${planPath} は git 管理下にない(先に commit すること)`)
114
+ const dirty = execFileSync('git', ['-C', repoRoot, 'status', '--porcelain', '--', relPlanPath], { encoding: 'utf8' }).trim()
115
+ if (dirty) usage(`${relPlanPath} に未 commit の変更がある。先に commit してから実行すること`)
116
+ const sourceCommit = execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
117
+
118
+ const text = readFileSync(join(repoRoot, relPlanPath), 'utf8')
119
+ const lines = text.split('\n')
120
+
121
+ let rootHeading = null
122
+ let sectionHeading = null
123
+ const tasks = []
124
+ let current = null // { taskId, title, startLine, headingPath }
125
+
126
+ const flush = (endLineExclusive) => {
127
+ if (!current) return
128
+ const bodyLines = lines.slice(current.startLine, endLineExclusive)
129
+ while (bodyLines.length && bodyLines[0].trim() === '') bodyLines.shift()
130
+ while (bodyLines.length && bodyLines.at(-1).trim() === '') bodyLines.pop()
131
+ const designMemo = bodyLines.join('\n').trim()
132
+ if (!designMemo) usage(`task ${current.taskId} の design_memo が空`)
133
+ tasks.push({ ...current, designMemo })
134
+ current = null
135
+ }
136
+
137
+ for (let i = 0; i < lines.length; i += 1) {
138
+ const line = lines[i]
139
+ const h1 = /^# (.+)$/.exec(line)
140
+ const h2 = /^## (.+)$/.exec(line)
141
+ const h3 = /^### (\S+) (.+)$/.exec(line)
142
+ if (h1) { flush(i); rootHeading = h1[1]; sectionHeading = null; continue }
143
+ if (h2) { flush(i); sectionHeading = h2[1]; continue }
144
+ if (h3) {
145
+ flush(i)
146
+ if (!rootHeading || !sectionHeading) usage(`行${i + 1}: task見出しの前に # と ## の見出しが要る`)
147
+ current = {
148
+ taskId: h3[1], title: h3[2], startLine: i + 1,
149
+ headingPath: [rootHeading, sectionHeading, `${h3[1]} ${h3[2]}`],
150
+ headingLine: i + 1,
151
+ }
152
+ }
153
+ }
154
+ flush(lines.length)
155
+ if (tasks.length === 0) usage('`### <task_id> <title>` 見出しが1つも見つからない')
156
+
157
+ const migrationContext = {
158
+ carry_over_ref: null, condition: null, evidence_refs: [], external_canonical_ref: null,
159
+ h_required: false, notes: [],
160
+ }
161
+
162
+ const extraction = {
163
+ actor: { agent: opts.agent, host: opts.host, session: opts.session },
164
+ hard_dependencies: [],
165
+ joins: [],
166
+ plan_key: planKey,
167
+ plan_version: 'v1',
168
+ project_id: opts.project,
169
+ recorded_at: new Date(Date.now() - 60_000).toISOString().replace(/\.\d+Z$/, (m) => m.length === 5 ? m : '.000Z'),
170
+ schema: 'lattice.todo_extraction.v3',
171
+ tasks: tasks.map(({ taskId, title, headingPath, headingLine, designMemo }) => ({
172
+ compile_binding: null,
173
+ completion: null,
174
+ design_memo: designMemo,
175
+ disposition: 'register_pending',
176
+ lane: opts.lane,
177
+ migration_context: migrationContext,
178
+ narrative_ref: relPlanPath,
179
+ source: {
180
+ checkbox_state: 'absent',
181
+ heading_path: headingPath,
182
+ markdown_depth: 3,
183
+ origin_line: headingLine,
184
+ origin_plan_ref: relPlanPath,
185
+ parent_task_id: null,
186
+ source_commit: sourceCommit,
187
+ },
188
+ start: null,
189
+ task_id: taskId,
190
+ title,
191
+ })),
192
+ extraction_digest: '0'.repeat(64),
193
+ }
194
+ extraction.extraction_digest = todoSelfDigest(extraction, 'extraction_digest')
195
+
196
+ const outPath = opts.out ?? join(repoRoot, `.lattice/extraction-${planKey}.json`)
197
+ writeFileSync(outPath, JSON.stringify(extraction, null, 2) + '\n')
198
+ console.log(`written: ${outPath} (${tasks.length} tasks: ${tasks.map((t) => t.taskId).join(', ')})`)
199
+ console.log(`next: lattice todo migrate --input ${outPath} --dry-run --json`)
@@ -0,0 +1,224 @@
1
+ #!/bin/bash
2
+ # 既存卓へ、Peertable が所有する generated asset だけを現行 template へ同期する。
3
+ # usage: upgrade-team-assets.sh <project_dir>
4
+ set -euo pipefail
5
+
6
+ if [ "$#" -ne 1 ]; then
7
+ echo 'PEERTABLE_UPGRADE_USAGE: upgrade-team-assets.sh <project_dir>' >&2
8
+ exit 2
9
+ fi
10
+
11
+ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
12
+ repo_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd)
13
+
14
+ node --input-type=module - "$1" "$repo_dir" <<'NODE'
15
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
16
+ import { createHash, randomBytes } from 'node:crypto'
17
+ import { dirname, join, resolve } from 'node:path'
18
+
19
+ const projectArg = process.argv[2]
20
+ const repoDir = process.argv[3]
21
+
22
+ function fail(code, detail) {
23
+ console.error(JSON.stringify({
24
+ schema: 'peertable.generated_assets_upgrade_result.v1',
25
+ result: 'rejected',
26
+ code,
27
+ detail,
28
+ }))
29
+ process.exit(1)
30
+ }
31
+
32
+ function lstatOrNull(path) {
33
+ try {
34
+ return lstatSync(path)
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') return null
37
+ fail('PEERTABLE_UPGRADE_PATH_UNREADABLE', `${path}: ${error.message}`)
38
+ }
39
+ }
40
+
41
+ function requireRegularFile(path, code, label) {
42
+ const stat = lstatOrNull(path)
43
+ if (!stat || stat.isSymbolicLink() || !stat.isFile()) {
44
+ fail(code, `${label}: ${path}`)
45
+ }
46
+ return stat
47
+ }
48
+
49
+ function validateDirectory(path, required, label) {
50
+ const stat = lstatOrNull(path)
51
+ if (!stat) {
52
+ if (required) fail('PEERTABLE_GENERATED_ASSET_UNSAFE_PATH', `${label} が無い: ${path}`)
53
+ return false
54
+ }
55
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
56
+ fail('PEERTABLE_GENERATED_ASSET_UNSAFE_PATH', `${label} がsymlinkまたはdirectoryでない: ${path}`)
57
+ }
58
+ return true
59
+ }
60
+
61
+ function validateTargetPath(project, relativePath) {
62
+ const parts = relativePath.split('/')
63
+ let current = project
64
+ for (const part of parts.slice(0, -1)) {
65
+ current = join(current, part)
66
+ validateDirectory(current, part === '.team', `管理asset親 ${part}`)
67
+ }
68
+ const target = join(project, relativePath)
69
+ const stat = lstatOrNull(target)
70
+ if (stat && (stat.isSymbolicLink() || !stat.isFile())) {
71
+ fail('PEERTABLE_GENERATED_ASSET_UNSAFE_PATH', `管理assetがsymlinkまたはregular fileでない: ${target}`)
72
+ }
73
+ return { target, stat }
74
+ }
75
+
76
+ function templateText(repo, relativePath) {
77
+ const path = join(repo, relativePath)
78
+ requireRegularFile(path, 'PEERTABLE_GENERATED_ASSET_TEMPLATE_INVALID', 'template')
79
+ try {
80
+ return readFileSync(path)
81
+ } catch (error) {
82
+ fail('PEERTABLE_GENERATED_ASSET_TEMPLATE_INVALID', `${path}: ${error.message}`)
83
+ }
84
+ }
85
+
86
+ function sha256(content) {
87
+ return createHash('sha256').update(content).digest('hex')
88
+ }
89
+
90
+ function renderMember(template, state) {
91
+ if (state.mode === 'standalone') return template
92
+ const phases = state.phases
93
+ const scope = phases.length === 0
94
+ ? 'この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。'
95
+ : `**この卓の claim 範囲は phase ${phases.join(' ')} の task だけ**。範囲外の phase の task は、ready に見えていても取らない——同じ plan へ別 campaign が相乗りしている時、範囲外を取ると他卓の工程を横取りする(越境が2回実測されたことへの対処)。範囲外に手を入れる必要が出たら room へ出して裁定を仰ぐ。`
96
+ return template
97
+ .toString('utf8')
98
+ .replaceAll('{{PLAN_KEY}}', state.plan_key)
99
+ .replaceAll('{{CLAIM_SCOPE}}', scope)
100
+ }
101
+
102
+ function loadState(project) {
103
+ const path = join(project, '.team', 'setup-state.json')
104
+ requireRegularFile(path, 'PEERTABLE_SETUP_STATE_INVALID', 'setup-state.json')
105
+ let state
106
+ try {
107
+ state = JSON.parse(readFileSync(path, 'utf8'))
108
+ } catch (error) {
109
+ fail('PEERTABLE_SETUP_STATE_INVALID', `${path}: JSONを読めない: ${error.message}`)
110
+ }
111
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
112
+ fail('PEERTABLE_SETUP_STATE_INVALID', 'setup-state.json はobjectでなければならない')
113
+ }
114
+ if (!['lattice', 'standalone'].includes(state.mode)) {
115
+ fail('PEERTABLE_SETUP_STATE_INVALID', `modeが不正: ${String(state.mode)}`)
116
+ }
117
+ if (typeof state.room !== 'string' || !state.room || typeof state.server_url !== 'string' || !state.server_url) {
118
+ fail('PEERTABLE_SETUP_STATE_INVALID', 'room/server_url が不正')
119
+ }
120
+ if (typeof state.plan_key !== 'string') {
121
+ fail('PEERTABLE_SETUP_STATE_INVALID', 'plan_key がstringでない')
122
+ }
123
+ if (state.mode === 'lattice' && !state.plan_key) {
124
+ fail('PEERTABLE_SETUP_STATE_INVALID', 'lattice mode の plan_key が空')
125
+ }
126
+ if (state.phases !== undefined && (!Array.isArray(state.phases) || state.phases.some(phase => typeof phase !== 'string' || !phase))) {
127
+ fail('PEERTABLE_SETUP_STATE_INVALID', 'phases がstring配列でない')
128
+ }
129
+ return { ...state, phases: state.phases ?? [] }
130
+ }
131
+
132
+ function writeAtomically(target, content, mode) {
133
+ const temporary = join(dirname(target), `.${target.split('/').at(-1)}.upgrade-${process.pid}-${randomBytes(6).toString('hex')}.tmp`)
134
+ try {
135
+ writeFileSync(temporary, content, { mode })
136
+ chmodSync(temporary, mode)
137
+ renameSync(temporary, target)
138
+ } catch (error) {
139
+ try { lstatSync(temporary); unlinkSync(temporary) } catch {}
140
+ fail('PEERTABLE_GENERATED_ASSET_WRITE_FAILED', `${target}: ${error.message}`)
141
+ }
142
+ }
143
+
144
+ function main() {
145
+ if (!projectArg || !repoDir) fail('PEERTABLE_UPGRADE_USAGE', 'project_dir が無い')
146
+ const projectStat = lstatOrNull(projectArg)
147
+ if (!projectStat || projectStat.isSymbolicLink() || !projectStat.isDirectory()) {
148
+ fail('PEERTABLE_UPGRADE_PROJECT_INVALID', `project directoryが不正: ${projectArg}`)
149
+ }
150
+ const project = resolve(projectArg)
151
+ const repo = resolve(repoDir)
152
+ validateDirectory(join(project, '.team'), true, '.team')
153
+ validateDirectory(join(repo, 'skill'), true, 'Peertable repo')
154
+ const state = loadState(project)
155
+
156
+ const common = [
157
+ ['.team/CLAUDE.md', 'skill/templates/charter.md', 0o644],
158
+ ['.team/roles/parent.md', 'skill/templates/parent.md', 0o644],
159
+ ]
160
+ const modeSpecific = state.mode === 'lattice'
161
+ ? [
162
+ ['.team/roles/member.md', 'skill/templates/member.md', 0o644],
163
+ ['.team/scripts/done.sh', 'skill/templates/done.sh', 0o755],
164
+ ]
165
+ : [['.team/roles/member.md', 'skill/templates/member-standalone.md', 0o644]]
166
+ const definitions = [...common, ...modeSpecific]
167
+
168
+ const prepared = definitions.map(([relativePath, sourcePath, mode]) => {
169
+ const { target, stat } = validateTargetPath(project, relativePath)
170
+ const source = templateText(repo, sourcePath)
171
+ const content = relativePath === '.team/roles/member.md' && state.mode === 'lattice'
172
+ ? Buffer.from(renderMember(source, state), 'utf8')
173
+ : source
174
+ return { relativePath, sourcePath, target, stat, content, mode }
175
+ })
176
+
177
+ const changes = prepared.map(item => {
178
+ if (!item.stat) return { path: item.relativePath, action: 'created', sha256: sha256(item.content) }
179
+ const current = readFileSync(item.target)
180
+ const mode = item.stat.mode & 0o777
181
+ if (current.equals(item.content) && mode === item.mode) {
182
+ return { path: item.relativePath, action: 'unchanged', sha256: sha256(item.content) }
183
+ }
184
+ return { path: item.relativePath, action: current.equals(item.content) ? 'mode-updated' : 'updated', sha256: sha256(item.content) }
185
+ })
186
+ const obsolete = ['.team/scripts/start.sh', '.team/scripts/start-event.mjs']
187
+ .map(relativePath => ({ relativePath, ...validateTargetPath(project, relativePath) }))
188
+
189
+ // 全対象の安全性・template・差分を先に確定してから、管理allowlistだけへ書く。
190
+ for (const [index, item] of prepared.entries()) {
191
+ const change = changes[index]
192
+ if (change.action === 'unchanged') continue
193
+ const parent = dirname(item.target)
194
+ mkdirSync(parent, { recursive: true, mode: 0o755 })
195
+ if (change.action === 'mode-updated') chmodSync(item.target, item.mode)
196
+ else writeAtomically(item.target, item.content, item.mode)
197
+ }
198
+
199
+ const removed = []
200
+ for (const { relativePath, target, stat } of obsolete) {
201
+ if (!stat) continue
202
+ unlinkSync(target)
203
+ removed.push(relativePath)
204
+ }
205
+
206
+ console.log(JSON.stringify({
207
+ schema: 'peertable.generated_assets_upgrade_result.v1',
208
+ result: 'ok',
209
+ project,
210
+ mode: state.mode,
211
+ managed: prepared.map(item => ({ path: item.relativePath, source: item.sourcePath, sha256: sha256(item.content) })),
212
+ changes,
213
+ removed,
214
+ changed_count: changes.filter(change => change.action !== 'unchanged').length + removed.length,
215
+ }))
216
+ }
217
+
218
+ try {
219
+ main()
220
+ } catch (error) {
221
+ if (error?.code && typeof error.message === 'string') fail('PEERTABLE_GENERATED_ASSET_UPGRADE_FAILED', error.message)
222
+ fail('PEERTABLE_GENERATED_ASSET_UPGRADE_FAILED', String(error))
223
+ }
224
+ NODE