takomi 2.1.34 → 2.1.35

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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: photo-book-builder
3
3
  description: >
4
- Automates high-resolution photo book creation, including chronological sorting, orientation balancing (landscape vs. portrait), print-ready full-bleed masonry layout generation, and cover art upscaling. Use this skill when asked to: (1) Organize a set of photos into chronological pages, (2) Generate 12" x 30" open spreads (9000 x 3600 px at 300 DPI) with exact gutter margins (150 px vertical center gutter) and custom margins, (3) Create or assemble full-bleed masonry grid layouts, (4) Upscale front, back, or briefcase box covers using high-quality LANCZOS interpolation.
4
+ Automates high-resolution photo book creation and correction passes, including chronological sorting, orientation balancing, print-ready full-bleed masonry layout generation, existing layout crop corrections, contact-sheet/cell mapping, and cover art upscaling. Use this skill when asked to: (1) Organize photos into page folders, (2) Generate or regenerate 12" x 30" open spreads (9000 x 3600 px at 300 DPI) with gutter margins, (3) Create or adapt full-bleed masonry grid layouts with project-specific photo counts, (4) Correct existing exports by raising/lowering images or adjusting per-image crop framing without overwriting originals, (5) Upscale front, back, or briefcase box covers using high-quality LANCZOS interpolation.
5
5
  ---
6
6
 
7
7
  # Photo Book Builder
@@ -10,7 +10,7 @@ This skill provides a complete system for organizing, structuring, and generatin
10
10
 
11
11
  ## Core Capabilities
12
12
 
13
- The photo book builder relies on four main stages:
13
+ The photo book builder supports five main stages:
14
14
 
15
15
  ```mermaid
16
16
  graph TD
@@ -18,6 +18,8 @@ graph TD
18
18
  B --> C[Stage 2 & 3: Layout Generation]
19
19
  C -->|create_full_bleed_layouts.py| D[9000x3600 px Print Spreads]
20
20
  E[Stage 4: Cover Upscaling] -->|upscale_covers.py| F[Print-Ready Cover JPEGs]
21
+ D --> G[Stage 5: Correction Pass]
22
+ G -->|custom renderer or patched generator| H[New Corrected Export Folder]
21
23
  ```
22
24
 
23
25
  ---
@@ -50,6 +52,8 @@ Layout configurations are determined dynamically based on photo counts and orien
50
52
  * **Template 6A (6 Photos: 5L, 1P)**: Two landscape rows (4 total), one portrait + landscape col.
51
53
  * **Template 6B (6 Photos: 4L, 2P)**: Two large landscape rows (2 total), one double-portrait + double-landscape col.
52
54
 
55
+ These templates are defaults, not hard limits. When a project already has a custom generator, page folders, or spreads with a different number of photos (for example 23-24 images per spread), preserve that project's generator and layout rules. Do not force the default 5/6-photo half-page templates onto an existing custom book.
56
+
53
57
  For a complete coordinate reference and details on column configurations, see [layout_templates.md](references/layout_templates.md).
54
58
 
55
59
  ---
@@ -58,6 +62,8 @@ For a complete coordinate reference and details on column configurations, see [l
58
62
  The layout compiler reads the page directories, detects photo orientations on the fly, applies the coordinate grids, crops photos to fill cells (using centered LANCZOS fit-scale), and merges them into $9000 \times 3600$ px JPEG spreads.
59
63
  * **Page Swapping**: Alternating layout orientation avoids monotonous designs. Left page columns swap if `page_num % 2 == 0`, and right page columns swap if `page_num % 3 == 0`.
60
64
 
65
+ When regenerating an existing project, first inspect local scripts in the delivery/project folder. If a project-specific script exists, prefer adapting it over the bundled default scripts. Avoid rerunning any organizer/reorganizer that flattens or moves page folders unless the user explicitly asks for a new organization pass.
66
+
61
67
  **Execution**:
62
68
  Compile the print-ready layouts:
63
69
  ```bash
@@ -79,6 +85,44 @@ python scripts/upscale_covers.py --src "path/to/cover.png" --out "path/to/upscal
79
85
 
80
86
  ---
81
87
 
88
+ ### Stage 5: Existing Layout Correction Pass
89
+ Use this when the user provides screenshots or notes such as "blue means raise the image" and "red means take it down" for already-exported spreads.
90
+
91
+ **Correction workflow**:
92
+ 1. Identify the current source workspace, existing export folder, and correction screenshots.
93
+ 2. Inspect the local layout generator and `photo_organization_map.json` if present.
94
+ 3. Create a contact sheet or map preview that overlays each cell with its source filename and rectangle. This prevents guessing from screenshots.
95
+ 4. Translate annotations into per-image corrections:
96
+ * Blue / "raise image up": move the visible subject up in the cell. In crop-ratio terms this usually means increasing vertical crop offset when there is extra source height below the crop.
97
+ * Red / "take it down": move the visible subject down in the cell. In crop-ratio terms this usually means decreasing vertical crop offset when there is extra source height above the crop.
98
+ * If the crop is already pinned at the top or bottom, use a small top-anchored or bottom-anchored overscale instead of adding filler or painting pixels. Start tiny, e.g. `1.006` to `1.012`; larger values can overshoot.
99
+ 5. Render to a new export folder. Never overwrite the original export unless the user explicitly asks.
100
+ 6. Verify file count, output dimensions, and a reduced review sheet of corrected spreads.
101
+
102
+ **Per-image correction patterns**:
103
+ ```python
104
+ CROP_OVERRIDES = {
105
+ "DSC01234.jpg": {"y_ratio": 0.30}, # raise visible subject
106
+ "DSC05678.jpg": {"y_ratio": 0.00}, # lower visible subject
107
+ }
108
+ ```
109
+
110
+ For edge-pinned images where ratio changes cannot move the frame:
111
+ ```python
112
+ TOP_ANCHORED_OVERSCALE = {
113
+ "DSC01234.jpg": 1.010, # tiny downward nudge without filler
114
+ }
115
+ ```
116
+
117
+ Keep corrections local to filenames/cells. Avoid global crop changes unless every affected image needs the same behavior.
118
+
119
+ **Safety rules for correction passes**:
120
+ * Preserve existing page folders and mapping files.
121
+ * Prefer a renderer that reads existing pages and writes new spreads over a script that reorganizes files.
122
+ * Use output folder names like `10 - layouts - crop-corrections-YYYYMMDD`.
123
+ * Keep a short correction log with source filenames, correction type, output folder, and verification result.
124
+ * On Windows, avoid giant one-shot patches or command lines; keep scripts and path lists small.
125
+
82
126
  ## Revert Option
83
127
  If you need to restart the process or undo the page folder classification, run the revert tool to move photos back to the root workspace directory and remove empty page folders:
84
128
  ```bash
@@ -88,9 +132,9 @@ python scripts/revert_organization.py --workspace-dir "path/to/workspace"
88
132
  ## Bundled Resources
89
133
 
90
134
  * **Scripts**:
91
- * [organize_photos.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/organize_photos.py) - Groups and balances photo directories.
92
- * [create_full_bleed_layouts.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/create_full_bleed_layouts.py) - Assembles masonry grid pages into print-ready spreads.
93
- * [upscale_covers.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/upscale_covers.py) - General-purpose upscaling script.
94
- * [revert_organization.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/revert_organization.py) - Restores files back to flat source directories.
135
+ * [organize_photos.py](scripts/organize_photos.py) - Groups and balances photo directories.
136
+ * [create_full_bleed_layouts.py](scripts/create_full_bleed_layouts.py) - Assembles masonry grid pages into print-ready spreads.
137
+ * [upscale_covers.py](scripts/upscale_covers.py) - General-purpose upscaling script.
138
+ * [revert_organization.py](scripts/revert_organization.py) - Restores files back to flat source directories.
95
139
  * **References**:
96
- * [layout_templates.md](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/references/layout_templates.md) - Exact coordinates, dimensions, and grid cells.
140
+ * [layout_templates.md](references/layout_templates.md) - Exact coordinates, dimensions, and grid cells.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.34",
3
+ "version": "2.1.35",
4
4
  "description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,9 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "postinstall": "node src/postinstall.js",
26
- "test": "node scripts/test-skill-selection.js",
26
+ "test": "npm run test:typecheck && npm run test:regressions && npm run test:skills",
27
+ "test:typecheck": "tsc --noEmit",
28
+ "test:regressions": "node scripts/test-regressions.js",
27
29
  "test:skills": "node scripts/test-skill-selection.js"
28
30
  },
29
31
  "repository": {
@@ -52,6 +54,20 @@
52
54
  "picocolors": "^1.1.1",
53
55
  "prompts": "^2.4.2"
54
56
  },
57
+ "overrides": {
58
+ "protobufjs": "7.6.3",
59
+ "@protobufjs/utf8": "1.1.1",
60
+ "ws": "8.21.0",
61
+ "brace-expansion": "5.0.6"
62
+ },
63
+ "pnpm": {
64
+ "overrides": {
65
+ "protobufjs": "7.6.3",
66
+ "@protobufjs/utf8": "1.1.1",
67
+ "ws": "8.21.0",
68
+ "brace-expansion": "5.0.6"
69
+ }
70
+ },
55
71
  "bugs": {
56
72
  "url": "https://github.com/JStaRFilms/VibeCode-Protocol-Suite/issues"
57
73
  },
package/src/cli.js CHANGED
@@ -928,11 +928,14 @@ async function add(url) {
928
928
  console.log(pc.cyan('📡 Fetching from GitHub...\n'));
929
929
 
930
930
  // Try assets/.agent/skills/ first (Takomi structure)
931
+ const githubOptions = { repo: repoSlug };
932
+
931
933
  let skillCount = await downloadDirectoryFromGitHub(
932
934
  'assets/.agent/skills',
933
935
  skillsDest,
934
936
  null,
935
- 250
937
+ 250,
938
+ githubOptions
936
939
  );
937
940
 
938
941
  // Fallback: try .agent/skills/
@@ -941,7 +944,8 @@ async function add(url) {
941
944
  '.agent/skills',
942
945
  skillsDest,
943
946
  null,
944
- 250
947
+ 250,
948
+ githubOptions
945
949
  );
946
950
  }
947
951
 
@@ -950,7 +954,8 @@ async function add(url) {
950
954
  'assets/.agent/workflows',
951
955
  workflowsDest,
952
956
  (item) => item.name.endsWith('.md'),
953
- 250
957
+ 250,
958
+ githubOptions
954
959
  );
955
960
 
956
961
  if (workflowCount === 0) {
@@ -958,7 +963,8 @@ async function add(url) {
958
963
  '.agent/workflows',
959
964
  workflowsDest,
960
965
  (item) => item.name.endsWith('.md'),
961
- 250
966
+ 250,
967
+ githubOptions
962
968
  );
963
969
  }
964
970
 
@@ -1050,19 +1056,30 @@ async function updateProjectResources() {
1050
1056
  if (!response.components || response.components.length === 0) return;
1051
1057
 
1052
1058
  const destRoot = process.cwd();
1059
+ let remoteDownloadCount = 0;
1060
+ let remoteUpdateRequested = false;
1061
+ const remoteFailures = [];
1062
+ const runRemoteUpdate = async (label, updater) => {
1063
+ remoteUpdateRequested = true;
1064
+ try {
1065
+ remoteDownloadCount += await updater();
1066
+ } catch (error) {
1067
+ remoteFailures.push(`${label}: ${error.message}`);
1068
+ }
1069
+ };
1053
1070
 
1054
1071
  if (response.components.includes('agent')) {
1055
1072
  const agentDest = path.join(destRoot, '.agent');
1056
- await updateWorkflows(path.join(agentDest, 'workflows'));
1057
- await updateSkills(path.join(agentDest, 'skills'));
1073
+ await runRemoteUpdate('workflows', () => updateWorkflows(path.join(agentDest, 'workflows')));
1074
+ await runRemoteUpdate('skills', () => updateSkills(path.join(agentDest, 'skills')));
1058
1075
  }
1059
1076
 
1060
1077
  if (response.components.includes('yamls')) {
1061
- await updateAgentYamls(path.join(destRoot, 'Takomi-Agents'));
1078
+ await runRemoteUpdate('agent YAMLs', () => updateAgentYamls(path.join(destRoot, 'Takomi-Agents')));
1062
1079
  }
1063
1080
 
1064
1081
  if (response.components.includes('legacy')) {
1065
- await updateLegacyManual(path.join(destRoot, 'Legacy-Protocols'));
1082
+ await runRemoteUpdate('legacy protocols', () => updateLegacyManual(path.join(destRoot, 'Legacy-Protocols')));
1066
1083
  }
1067
1084
 
1068
1085
  // Update global store if selected
@@ -1089,6 +1106,19 @@ async function updateProjectResources() {
1089
1106
  await writeManifest(manifest);
1090
1107
  }
1091
1108
 
1109
+ if (remoteFailures.length) {
1110
+ console.log(pc.red('\nSome project resources failed to download from GitHub:'));
1111
+ for (const failure of remoteFailures) console.log(pc.dim(` - ${failure}`));
1112
+ process.exitCode = 1;
1113
+ return;
1114
+ }
1115
+
1116
+ if (remoteUpdateRequested && remoteDownloadCount === 0) {
1117
+ console.log(pc.red('\nNo project resources were downloaded from GitHub. Check the network or repository paths.'));
1118
+ process.exitCode = 1;
1119
+ return;
1120
+ }
1121
+
1092
1122
  console.log(pc.magenta('\n✨ Your toolkit is fresh and ready to ship.'));
1093
1123
  }
1094
1124
 
@@ -1116,7 +1146,7 @@ Examples:
1116
1146
  takomi refresh pi Refresh only Pi-related pieces
1117
1147
 
1118
1148
  Legacy aliases still work:
1119
- install -> setup, sync/upgrade -> refresh, init -> setup project,
1149
+ install -> setup, sync -> sync, upgrade -> refresh, init -> setup project,
1120
1150
  harnesses -> status, update -> refresh project
1121
1151
  `);
1122
1152
 
@@ -1171,8 +1201,8 @@ program
1171
1201
  // Re-sync (legacy alias)
1172
1202
  program
1173
1203
  .command('sync [target]', { hidden: true })
1174
- .description('Legacy alias: use "takomi refresh [target]"')
1175
- .action(refresh);
1204
+ .description('Legacy alias for global store sync')
1205
+ .action(sync);
1176
1206
 
1177
1207
  // Add remote skills (NEW)
1178
1208
  program
package/src/harness.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import crypto from 'crypto';
5
4
  import pc from 'picocolors';
5
+ import { hashPath, normalizeOwnedMap, copyOwnedTree } from './owned-tree.js';
6
6
 
7
7
  // ─── Cross-Platform Home Directory ───────────────────────────────────────────
8
8
  const HOME = os.homedir();
@@ -170,44 +170,6 @@ export function printHarnessStatus(detected) {
170
170
  * @param {string} label - Human-readable label for logging
171
171
  * @returns {Promise<number>} - Number of items copied
172
172
  */
173
- function sha256(value) {
174
- return crypto.createHash('sha256').update(value).digest('hex');
175
- }
176
-
177
- async function hashPath(targetPath) {
178
- if (!await fs.pathExists(targetPath)) return null;
179
- const stat = await fs.stat(targetPath);
180
- if (stat.isFile()) return sha256(await fs.readFile(targetPath));
181
-
182
- const entries = [];
183
- async function walk(current, prefix = '') {
184
- const names = (await fs.readdir(current)).sort();
185
- for (const name of names) {
186
- const full = path.join(current, name);
187
- const rel = path.join(prefix, name).replace(/\\/g, '/');
188
- const st = await fs.stat(full);
189
- if (st.isDirectory()) {
190
- entries.push(`dir:${rel}`);
191
- await walk(full, rel);
192
- } else {
193
- entries.push(`file:${rel}:${sha256(await fs.readFile(full))}`);
194
- }
195
- }
196
- }
197
- await walk(targetPath);
198
- return sha256(entries.join('\n'));
199
- }
200
-
201
- function normalizeOwnedMap(value) {
202
- if (!value || typeof value !== 'object') return {};
203
- const normalized = {};
204
- for (const [name, entry] of Object.entries(value)) {
205
- if (typeof entry === 'string') normalized[name] = { hash: entry };
206
- else if (entry?.hash) normalized[name] = entry;
207
- }
208
- return normalized;
209
- }
210
-
211
173
  /**
212
174
  * Syncs a directory to a target path. When ownership is supplied, it also
213
175
  * prunes previous Takomi-owned items that are no longer in the source while
@@ -263,7 +225,7 @@ export async function syncDirectory(sourcePath, targetPath, label = '', options
263
225
  await fs.remove(dest);
264
226
  }
265
227
 
266
- await fs.copy(src, dest, { overwrite: true });
228
+ await copyOwnedTree(src, dest);
267
229
  nextOwned[item] = {
268
230
  hash: await hashPath(dest),
269
231
  targetPath: dest,
@@ -285,7 +247,7 @@ export async function syncDirectory(sourcePath, targetPath, label = '', options
285
247
  if (label) {
286
248
  console.log(pc.red(` ✗ ${label}: ${error.message}`));
287
249
  }
288
- return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: {} } : 0;
250
+ return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: normalizeOwnedMap(options.owned), ok: false, error: error.message } : 0;
289
251
  }
290
252
  }
291
253
 
@@ -329,8 +291,8 @@ export async function syncFile(sourceFile, targetPaths, label = '') {
329
291
  * @returns {Promise<{skills: number, workflows: number, yamls: number}>}
330
292
  */
331
293
  export async function syncToHarness(harness, storePath, options = {}) {
332
- const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: {}, workflows: {} } };
333
294
  const harnessOwned = options.owned?.[harness.id] || {};
295
+ const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: normalizeOwnedMap(harnessOwned.skills), workflows: normalizeOwnedMap(harnessOwned.workflows) }, ok: true, errors: [] };
334
296
  const useOwnership = Boolean(options.useOwnership);
335
297
 
336
298
  console.log(pc.cyan(`\n 📡 Syncing to ${harness.name}...`));
@@ -352,6 +314,10 @@ export async function syncToHarness(harness, storePath, options = {}) {
352
314
  if (useOwnership) {
353
315
  results.skills = skillResult.copied;
354
316
  results.owned.skills = skillResult.owned;
317
+ if (skillResult.ok === false) {
318
+ results.ok = false;
319
+ results.errors.push(skillResult.error);
320
+ }
355
321
  } else {
356
322
  results.skills = skillResult;
357
323
  }
@@ -374,6 +340,10 @@ export async function syncToHarness(harness, storePath, options = {}) {
374
340
  if (useOwnership) {
375
341
  results.workflows = workflowResult.copied;
376
342
  results.owned.workflows = workflowResult.owned;
343
+ if (workflowResult.ok === false) {
344
+ results.ok = false;
345
+ results.errors.push(workflowResult.error);
346
+ }
377
347
  } else {
378
348
  results.workflows = workflowResult;
379
349
  }
@@ -0,0 +1,86 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { createReadStream } from 'fs';
5
+
6
+ export function sha256(value) {
7
+ return crypto.createHash('sha256').update(value).digest('hex');
8
+ }
9
+
10
+ async function hashFile(filePath) {
11
+ return new Promise((resolve, reject) => {
12
+ const hash = crypto.createHash('sha256');
13
+ const stream = createReadStream(filePath);
14
+ stream.on('data', (chunk) => hash.update(chunk));
15
+ stream.on('error', reject);
16
+ stream.on('end', () => resolve(hash.digest('hex')));
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Hash a file tree without following symlinks.
22
+ *
23
+ * Symlinks are represented by their link target string instead of the linked
24
+ * file contents. This prevents store/harness reconciliation from recursively
25
+ * reading outside the intended tree or looping through symlink cycles.
26
+ */
27
+ export async function hashPath(targetPath) {
28
+ if (!await fs.pathExists(targetPath)) return null;
29
+
30
+ const rootStat = await fs.lstat(targetPath);
31
+ if (rootStat.isSymbolicLink()) return sha256(`symlink:${await fs.readlink(targetPath).catch(() => '')}`);
32
+ if (rootStat.isFile()) return hashFile(targetPath);
33
+ if (!rootStat.isDirectory()) return sha256(`other:${rootStat.mode}:${rootStat.size}`);
34
+
35
+ const entries = [];
36
+ async function walk(current, prefix = '') {
37
+ const names = (await fs.readdir(current)).sort();
38
+ for (const name of names) {
39
+ const full = path.join(current, name);
40
+ const rel = path.join(prefix, name).replace(/\\/g, '/');
41
+ const stat = await fs.lstat(full);
42
+ if (stat.isSymbolicLink()) {
43
+ const linkTarget = await fs.readlink(full).catch(() => '');
44
+ entries.push(`symlink:${rel}:${linkTarget}`);
45
+ } else if (stat.isDirectory()) {
46
+ entries.push(`dir:${rel}`);
47
+ await walk(full, rel);
48
+ } else if (stat.isFile()) {
49
+ entries.push(`file:${rel}:${await hashFile(full)}`);
50
+ } else {
51
+ entries.push(`other:${rel}:${stat.mode}:${stat.size}`);
52
+ }
53
+ }
54
+ }
55
+ await walk(targetPath);
56
+ return sha256(entries.join('\n'));
57
+ }
58
+
59
+ export function normalizeOwnedMap(value) {
60
+ if (!value || typeof value !== 'object') return {};
61
+ const normalized = {};
62
+ for (const [name, entry] of Object.entries(value)) {
63
+ if (typeof entry === 'string') normalized[name] = { hash: entry };
64
+ else if (entry?.hash) normalized[name] = entry;
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ export async function copyOwnedTree(src, dest, options = {}) {
70
+ const { allowSymlinks = false, filter, ...copyOptions } = options;
71
+ await fs.copy(src, dest, {
72
+ overwrite: true,
73
+ errorOnExist: false,
74
+ dereference: false,
75
+ ...copyOptions,
76
+ filter: async (candidate) => {
77
+ if (filter && !await filter(candidate)) return false;
78
+ if (allowSymlinks) return true;
79
+ const stat = await fs.lstat(candidate).catch(() => null);
80
+ if (stat?.isSymbolicLink()) {
81
+ throw new Error(`Refusing to copy symlink in managed tree: ${candidate}`);
82
+ }
83
+ return true;
84
+ },
85
+ });
86
+ }
@@ -1,47 +1,15 @@
1
1
  import fs from 'fs-extra';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import crypto from 'crypto';
5
4
  import pc from 'picocolors';
6
5
  import { PATHS } from './utils.js';
7
6
  import { getPiGlobalTargets } from './pi-harness.js';
7
+ import { hashPath, copyOwnedTree } from './owned-tree.js';
8
8
 
9
9
  const HOME = os.homedir();
10
10
  const TAKOMI_HOME = path.join(HOME, '.takomi');
11
11
  export const PI_MANIFEST_PATH = path.join(TAKOMI_HOME, 'pi-manifest.json');
12
12
 
13
- function sha256(value) {
14
- return crypto.createHash('sha256').update(value).digest('hex');
15
- }
16
-
17
- async function hashPath(targetPath) {
18
- if (!await fs.pathExists(targetPath)) return null;
19
- const stat = await fs.stat(targetPath);
20
- if (stat.isFile()) {
21
- const buf = await fs.readFile(targetPath);
22
- return sha256(buf);
23
- }
24
-
25
- const entries = [];
26
- async function walk(dir, prefix = '') {
27
- const names = (await fs.readdir(dir)).sort();
28
- for (const name of names) {
29
- const full = path.join(dir, name);
30
- const rel = path.join(prefix, name).replace(/\\/g, '/');
31
- const st = await fs.stat(full);
32
- if (st.isDirectory()) {
33
- entries.push(`dir:${rel}`);
34
- await walk(full, rel);
35
- } else {
36
- const buf = await fs.readFile(full);
37
- entries.push(`file:${rel}:${sha256(buf)}`);
38
- }
39
- }
40
- }
41
- await walk(targetPath);
42
- return sha256(entries.join('\n'));
43
- }
44
-
45
13
  async function pathIsSameSymlink(dest, src) {
46
14
  try {
47
15
  const stat = await fs.lstat(dest);
@@ -69,7 +37,7 @@ async function copyOwnedDirectory(src, dest) {
69
37
  await fs.ensureDir(path.dirname(dest));
70
38
  const mode = await prepareOwnedTarget(src, dest);
71
39
  if (mode === 'symlink') return hashPath(src);
72
- await fs.copy(src, dest, { overwrite: true });
40
+ await copyOwnedTree(src, dest);
73
41
  return hashPath(dest);
74
42
  }
75
43
 
@@ -77,7 +45,7 @@ async function copyOwnedFile(src, dest) {
77
45
  await fs.ensureDir(path.dirname(dest));
78
46
  const mode = await prepareOwnedTarget(src, dest);
79
47
  if (mode === 'symlink') return hashPath(src);
80
- await fs.copy(src, dest, { overwrite: true });
48
+ await copyOwnedTree(src, dest);
81
49
  return hashPath(dest);
82
50
  }
83
51
 
@@ -96,6 +64,8 @@ export async function writePiInstallManifest(manifest) {
96
64
  export async function installPiHarnessAssets(version = 'unknown') {
97
65
  const srcRoot = PATHS.pi;
98
66
  const targets = getPiGlobalTargets();
67
+ const previousSettingsHash = await hashPath(targets.settings);
68
+ const previousRoutingPolicyHash = await hashPath(targets.routingPolicy);
99
69
 
100
70
  const copied = {};
101
71
 
@@ -153,7 +123,9 @@ export async function installPiHarnessAssets(version = 'unknown') {
153
123
  owned: copied,
154
124
  preserved: {
155
125
  settings: targets.settings,
126
+ settingsHash: previousSettingsHash,
156
127
  routingPolicy: targets.routingPolicy,
128
+ routingPolicyHash: previousRoutingPolicyHash,
157
129
  userStateDir: targets.takomi,
158
130
  },
159
131
  };
@@ -168,6 +140,9 @@ export async function syncPiHarnessAssets(version = 'unknown') {
168
140
 
169
141
  export async function validatePiHarnessInstall() {
170
142
  const targets = getPiGlobalTargets();
143
+ const manifest = await readPiInstallManifest();
144
+ const currentSettingsHash = await hashPath(targets.settings);
145
+ const currentRoutingPolicyHash = await hashPath(targets.routingPolicy);
171
146
  return {
172
147
  runtime: await fs.pathExists(path.join(targets.extensions, 'takomi-runtime')),
173
148
  subagents: await fs.pathExists(path.join(targets.extensions, 'takomi-subagents')),
@@ -180,7 +155,8 @@ export async function validatePiHarnessInstall() {
180
155
  readme: await fs.pathExists(path.join(targets.root, 'README.md')),
181
156
  core: await fs.pathExists(path.join(path.dirname(targets.root), 'src', 'pi-takomi-core')),
182
157
  piSubagentsModule: await fs.pathExists(path.join(targets.root, 'node_modules', 'pi-subagents')),
183
- settingsPreserved: !await fs.pathExists(path.join(PATHS.pi, 'settings.json')) || true,
158
+ settingsPreserved: !manifest || manifest.preserved?.settingsHash === currentSettingsHash,
159
+ routingPolicyPreserved: !manifest || manifest.preserved?.routingPolicyHash === currentRoutingPolicyHash,
184
160
  };
185
161
  }
186
162
 
package/src/store.js CHANGED
@@ -1,53 +1,16 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import crypto from 'crypto';
5
4
  import pc from 'picocolors';
6
5
  import { PATHS } from './utils.js';
7
6
  import { CORE_SKILLS } from './skills-catalog.js';
7
+ import { hashPath as hashDirectory, normalizeOwnedMap, copyOwnedTree } from './owned-tree.js';
8
8
 
9
9
  // ─── Global Store Path ───────────────────────────────────────────────────────
10
10
  const HOME = os.homedir();
11
11
  export const STORE_PATH = process.env.TAKOMI_STORE_PATH || process.env.TAKOMI_HOME_DIR || path.join(HOME, '.takomi');
12
12
  export const MANIFEST_PATH = path.join(STORE_PATH, 'manifest.json');
13
13
 
14
- function sha256(value) {
15
- return crypto.createHash('sha256').update(value).digest('hex');
16
- }
17
-
18
- async function hashDirectory(dir) {
19
- if (!await fs.pathExists(dir)) return null;
20
- const rootStat = await fs.stat(dir);
21
- if (rootStat.isFile()) return sha256(await fs.readFile(dir));
22
- const entries = [];
23
- async function walk(current, prefix = '') {
24
- const names = (await fs.readdir(current)).sort();
25
- for (const name of names) {
26
- const full = path.join(current, name);
27
- const rel = path.join(prefix, name).replace(/\\/g, '/');
28
- const stat = await fs.stat(full);
29
- if (stat.isDirectory()) {
30
- entries.push(`dir:${rel}`);
31
- await walk(full, rel);
32
- } else {
33
- entries.push(`file:${rel}:${sha256(await fs.readFile(full))}`);
34
- }
35
- }
36
- }
37
- await walk(dir);
38
- return sha256(entries.join('\n'));
39
- }
40
-
41
- function normalizeOwnedMap(value) {
42
- if (!value || typeof value !== 'object') return {};
43
- const normalized = {};
44
- for (const [name, entry] of Object.entries(value)) {
45
- if (typeof entry === 'string') normalized[name] = { hash: entry };
46
- else if (entry?.hash) normalized[name] = entry;
47
- }
48
- return normalized;
49
- }
50
-
51
14
  function createDefaultManifest() {
52
15
  return {
53
16
  version: '2.0.0',
@@ -250,7 +213,7 @@ export async function populateSkills(mode) {
250
213
  await fs.remove(dest);
251
214
  }
252
215
 
253
- await fs.copy(src, dest, { overwrite: true });
216
+ await copyOwnedTree(src, dest);
254
217
  nextOwned[skill] = {
255
218
  hash: await hashDirectory(dest),
256
219
  targetPath: dest,
@@ -327,7 +290,7 @@ export async function populateWorkflows(mode) {
327
290
  await fs.remove(dest);
328
291
  }
329
292
 
330
- await fs.copy(src, dest, { overwrite: true });
293
+ await copyOwnedTree(src, dest);
331
294
  nextOwned[workflow] = {
332
295
  hash: await hashDirectory(dest),
333
296
  targetPath: dest,
@@ -1,4 +1,5 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { createReadStream, promises as fs } from 'node:fs';
2
+ import readline from 'node:readline';
2
3
  import os from 'os';
3
4
  import path from 'path';
4
5
 
@@ -167,9 +168,16 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
167
168
  const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map(), activeMs: 0, activityIntervals: [] };
168
169
  const rowTracker = createActivityTracker();
169
170
  const toolCalls = new Map();
170
- const text = await fs.readFile(file, 'utf8').catch(() => '');
171
171
 
172
- for (const line of text.split(/\r?\n/)) {
172
+ let lines;
173
+ try {
174
+ lines = readline.createInterface({ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
175
+ } catch {
176
+ continue;
177
+ }
178
+
179
+ try {
180
+ for await (const line of lines) {
173
181
  const obj = safeJson(line); if (!obj) continue;
174
182
  if (obj.timestamp) { row.start ||= obj.timestamp; row.end = obj.timestamp; }
175
183
  if (obj.type === 'session') { session = obj.id || session; cwd = obj.cwd || cwd; row.key = session; row.session = session; row.cwd = cwd; }
@@ -254,6 +262,9 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
254
262
  if (currentTask) currentTask.end = ts;
255
263
  }
256
264
 
265
+ }
266
+ } catch {
267
+ continue;
257
268
  }
258
269
  pushTask(taskRows, currentTask);
259
270
  if (currentTaskTracker && currentTask) {