@papillon6814/copi 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE.unicode +39 -0
  2. package/README.md +57 -5
  3. package/dist/native/rename-exclusive-arm64 +0 -0
  4. package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
  5. package/dist/src/apply.d.ts +9 -0
  6. package/dist/src/apply.js +110 -9
  7. package/dist/src/catalog.d.ts +21 -4
  8. package/dist/src/catalog.js +265 -133
  9. package/dist/src/cli.d.ts +2 -1
  10. package/dist/src/cli.js +73 -3
  11. package/dist/src/groups.js +8 -3
  12. package/dist/src/inspect.d.ts +3 -1
  13. package/dist/src/inspect.js +16 -6
  14. package/dist/src/instruction-reference.d.ts +6 -0
  15. package/dist/src/instruction-reference.js +21 -0
  16. package/dist/src/manage.d.ts +2 -0
  17. package/dist/src/manage.js +329 -0
  18. package/dist/src/migrate.d.ts +25 -0
  19. package/dist/src/migrate.js +171 -0
  20. package/dist/src/migration-backup.d.ts +22 -0
  21. package/dist/src/migration-backup.js +403 -0
  22. package/dist/src/migration-inventory.d.ts +9 -0
  23. package/dist/src/migration-inventory.js +237 -0
  24. package/dist/src/migration-resolution.d.ts +28 -0
  25. package/dist/src/migration-resolution.js +1618 -0
  26. package/dist/src/migration.d.ts +49 -0
  27. package/dist/src/migration.js +1004 -0
  28. package/dist/src/names.d.ts +11 -0
  29. package/dist/src/names.js +40 -0
  30. package/dist/src/native-placement.d.ts +16 -0
  31. package/dist/src/native-placement.js +557 -0
  32. package/dist/src/refresh.d.ts +20 -0
  33. package/dist/src/refresh.js +143 -0
  34. package/dist/src/resource-mutations.d.ts +45 -0
  35. package/dist/src/resource-mutations.js +225 -0
  36. package/dist/src/source-content.d.ts +4 -0
  37. package/dist/src/source-content.js +208 -0
  38. package/dist/src/storage-journal.d.ts +111 -0
  39. package/dist/src/storage-journal.js +613 -0
  40. package/dist/src/storage-root-lease.d.ts +7 -0
  41. package/dist/src/storage-root-lease.js +92 -0
  42. package/dist/src/storage-transaction.d.ts +100 -0
  43. package/dist/src/storage-transaction.js +3951 -0
  44. package/dist/src/terminal-editor.d.ts +8 -0
  45. package/dist/src/terminal-editor.js +391 -0
  46. package/dist/src/types.d.ts +18 -2
  47. package/dist/src/unicode-case-folding-data.d.ts +1 -0
  48. package/dist/src/unicode-case-folding-data.js +1596 -0
  49. package/dist/src/unicode-case-folding.d.ts +2 -0
  50. package/dist/src/unicode-case-folding.js +9 -0
  51. package/dist/src/wizard.d.ts +11 -3
  52. package/dist/src/wizard.js +274 -18
  53. package/package.json +5 -3
@@ -0,0 +1,39 @@
1
+ UNICODE LICENSE V3
2
+
3
+ COPYRIGHT AND PERMISSION NOTICE
4
+
5
+ Copyright © 1991-2026 Unicode, Inc.
6
+
7
+ NOTICE TO USER: Carefully read the following legal agreement. BY
8
+ DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
9
+ SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
10
+ TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
11
+ DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a
14
+ copy of data files and any associated documentation (the "Data Files") or
15
+ software and any associated documentation (the "Software") to deal in the
16
+ Data Files or Software without restriction, including without limitation
17
+ the rights to use, copy, modify, merge, publish, distribute, and/or sell
18
+ copies of the Data Files or Software, and to permit persons to whom the
19
+ Data Files or Software are furnished to do so, provided that either (a)
20
+ this copyright and permission notice appear with all copies of the Data
21
+ Files or Software, or (b) this copyright and permission notice appear in
22
+ associated Documentation.
23
+
24
+ THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
25
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
26
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
27
+ THIRD PARTY RIGHTS.
28
+
29
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
30
+ BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
31
+ OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
32
+ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
33
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
34
+ FILES OR SOFTWARE.
35
+
36
+ Except as contained in this notice, the name of a copyright holder shall
37
+ not be used in advertising or otherwise to promote the sale, use or other
38
+ dealings in these Data Files or Software without prior written
39
+ authorization of the copyright holder.
package/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  ## Status and requirements
6
6
 
7
7
  - Package: `@papillon6814/copi`; command: `copi`.
8
- - macOS only; Node.js 22.19.0 or newer.
9
- - The package is not published yet. Do not rely on the npm install command below until publication is announced.
10
- - If published, install with `npm install --global @papillon6814/copi`.
8
+ - macOS only (saving resources into the library requires Apple Silicon / arm64); Node.js 22.19.0 or newer.
9
+ - Install with `npm install --global @papillon6814/copi`. npm creates the `copi` command.
10
+ - Version 0.1.0 predates the catalog v2 `sources` layout. Use 0.2.0 or newer with a migrated library.
11
11
  - An older, unrelated tool may already provide a `copi` command. Check `command -v copi` before installing globally; resolve any command-name conflict deliberately.
12
12
 
13
13
  For a local checkout, run `npm install --ignore-scripts`, `npm run build`, then `node dist/src/cli.js help`.
@@ -16,22 +16,74 @@ For a local checkout, run `npm install --ignore-scripts`, `npm run build`, then
16
16
 
17
17
  ```text
18
18
  copi setup
19
+ copi manage
19
20
  copi groups
20
21
  copi list
21
22
  copi apply <group> [target]
22
23
  copi apply <group> [target] --dry-run
24
+ copi migrate [--dry-run]
25
+ copi migrate --resolve [--dry-run]
23
26
  copi help
24
27
  ```
25
28
 
26
29
  `setup` requires an interactive terminal. It asks which locations may be searched and asks for explicit authorization before searching. Selecting the entire `HOME` is broad: it can inspect supported resources throughout that directory, including Pi profiles and the configured Pi agent directory. Selecting the current project scopes discovery to that project. The wizard then lets you choose individual resources, review masked previews, assign each saved resource to one or more groups, and confirm before anything is copied into the library. Declining authorization performs no search.
27
30
 
31
+ `manage` requires both stdin and stdout to be an interactive TTY. Choose saved resources and groups by name; UUID input is not required and normal screens identify resources by display name, kind, groups and destination. It provides masked viewing, terminal text editing, display-name changes, separate destination changes, source refresh, group management and apply. Running without arguments, or with `help`, `--help` or `-h`, still shows help.
32
+
33
+ Resource display names and group names are each unique across the library, in separate namespaces. Comparison uses `trim → NFKC → Unicode 17.0.0 default full case folding → NFKC` (not locale-specific); display names and body text are not automatically normalized or rewritten. For example, `A` and `a`, or `Straße` and `STRASSE`, conflict within one namespace. Legacy duplicate catalogs remain readable: use **Resolve name conflicts** to rename them manually. Until resolved, only listing, masked viewing and conflict resolution are allowed; no automatic renaming occurs.
34
+
28
35
  `groups` lists groups in a non-interactive terminal; group changes require a TTY and are saved only after confirmation. `list` is read-only. `apply --dry-run` prints the planned paths, masked text diffs, and warnings without changing the target. A real apply requires a TTY and confirmation.
29
36
 
30
- Groups contain resource IDs, not merged documents. The same saved resource may belong to multiple groups. `copi` copies files byte-for-byte; it does not split, rewrite, merge, or run resource contents. It does not install dependencies or apply global-only resources to a project. Review every proposed change.
37
+ Pi automatically loads only context files (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) and `.pi/SYSTEM.md` / `.pi/APPEND_SYSTEM.md`. When a group applies another instruction (for example `RULES.md`) and no context file at the project root mentions its path, `apply` warns. It offers to append one reference line per instruction (`- 作業を始める前に \`RULES.md\` を読み、その指示に従うこと。`) to the root context file Pi prefers (`AGENTS.override.md` first); if no root context file exists, it offers to create `AGENTS.md`. The offer is a separate default-no approval; declining still applies the other files. An existing file is backed up privately before the append. `--dry-run` prints the same warning and diff. No reference is offered when the file Pi prefers is a symbolic link, not a regular file, not UTF-8, or larger than 1 MB, or when the same group also places a root context file (matched case-insensitively); the warning then shows the line to add yourself. Only the project root is checked; parent directories and the Pi agent directory are not.
38
+
39
+ `migrate --dry-run` and `migrate --resolve --dry-run` are read-only and may run without a TTY. A mutating `migrate` or `migrate --resolve` requires both stdin and stdout to be a TTY. Reviews print the complete masked diff, then ask for whole-review approval (default no), a separate default-no approval for each sensitive path, and a final default-no confirmation. There is no `--yes` flag. Refusal, EOF, Ctrl-C, blocked state, usage errors, and non-TTY mutation exit with status 1; ready dry-runs, successful operations and a resolution that is not needed exit with status 0.
40
+
41
+ Ordinary `migrate` adopts only eligible existing `sources` content and keeps a verified backup. If an internal migration lock or resolution record blocks it, the CLI points to `migrate --resolve --dry-run`; do not manually remove internal locks. Resolution is limited to eligible post-switch adopted-source edits: it confirms the reviewed edit, preserves source bytes, retains the verified backup, and removes only owned internal migration leftovers. `migrate --resolve` never falls back to ordinary migration. It does not rewrite the catalog, sources or items, distribute resources, or delete items. A blocked review stops while preserving edits and remaining artifacts; a new review may be needed after a cleanup interruption. Never use an older `copi` binary while resolution artifacts remain. v4 journals do not have the start inventory required for this procedure and remain on the existing recovery/hold-stop path. A migration backup is not general history or a restore feature. Migration, item deletion, main integration and publishing require separate approval. Migration (M) and items deletion (D) each require separate approval from one another.
42
+
43
+ ### Migration support limits and safe-stop guide
44
+
45
+ R4 migration is adopt-only. `copi migrate --dry-run` may show a `ready` preview for an item backed only by legacy `items`, but items-only content and new `sources` additions are not supported. Commit rejects such a plan before writing; a `ready` preview is not proof that migration can be committed. Migration succeeds only when every selected payload can be adopted from eligible content already in `sources`.
46
+
47
+ The packaged native source-placement helper is currently built for macOS arm64. Every product entry point that needs exclusive native placement declares that requirement and performs a read-only check for the supported platform, packaged helper, and filesystem capability before creating or changing any library path, transaction lock, stage, or journal. If the platform or helper is unsupported, the filesystem does not advertise the required operation, or its capability cannot be determined, the request stops before the first filesystem write; no ordinary rename or copy fallback is used. For a library that does not yet exist, the filesystem check uses its nearest existing directory ancestor. This placement limit does not by itself prohibit read-only commands.
48
+
49
+ `StorageTransactions.run` requires TypeScript callers to explicitly declare whether native placement is required. The internal `#commitNewItems` boundary repeats the check so a direct adapter call from JavaScript cannot bypass it by omitting the option or passing `false`. Such an internal misuse may briefly acquire a transaction lock and root lease before rejection; it writes no content payload, stage, or journal, then cleans the lock and releases the lease. Product entry points preflight before that lock or lease is created.
50
+
51
+ During an operation, do not move, rename, or replace the library root, `sources/`, or a saved item's management directory. This rule is about management directories, not ordinary content edits: editing a regular source file, an editor's file replacement, and skill file additions and removals are supported ordinary-edit cases. Do not treat a managed-directory move or replacement as an ordinary edit.
52
+
53
+ For a migration stopped by an internal transaction lock or resolution record, first run `copi migrate --resolve --dry-run`. It is read-only. If it reports `ready`, review the complete masked diff; only then may you run `copi migrate --resolve` for the separate default-no whole-review, per-sensitive-path, and final confirmations. If the preview reports `blocked`, leave the edits and remaining artifacts in place. A v4 journal lacks the start inventory; unknown journal versions and incomplete or malformed states that the current build cannot verify remain held. An interrupted cleanup is eligible only when the current build explicitly verifies it and presents a `ready` review.
54
+
55
+ If rollback leaves an existing `.migration-backups/` namespace, ordinary migration prepare is blocked because the backup is preserved and not adopted automatically. This is not a retry or restore path: never manually remove, move, or restore a migration backup or move the library contents. An offline preservation check of a real library is a separate read-only activity requiring separate explicit approval for that exact scope; neither a dry-run nor an earlier migration approval grants it. Such a check is not authorization to alter, move, restore, or delete anything, and it must not be reported as a successful migration.
56
+
57
+ Do not hand-edit or remove internal migration-management files: `.copi-transaction` stage/journal files, `.copi-transaction-recovery` claims, or `.copi-migration-resolution.json`. A stage/journal rehash is a known LIMIT, not proof of authorization or custody; same-user manual changes to these internal files and manual edits to a completed resolution record are outside the protection boundary. `privateRevisionHash` is checked only against a same-process issued plan at commit; it is not a durable authentication key. The claim-absent/record-present window after claim removal and before completed-record unlink remains unsafe for older binaries; do not run one until all resolution artifacts are gone.
58
+
59
+ If ordinary source edits occur during interrupted cleanup, recovery can hold-stop and preserve them. When a further pass is eligible, it requires a fresh review and new generation with fresh whole-review, per-sensitive-path, and final approvals; never replay an earlier approval. Out-of-scope changes remain held rather than overwritten.
60
+
61
+ Groups contain resource IDs, not merged documents. The same saved resource may belong to multiple groups. Import and apply copy files byte-for-byte; they do not automatically split, rewrite, merge, or run resource contents. It does not install dependencies or apply global-only resources to a project. Review every proposed change.
62
+
63
+ ## Editing and refreshing saved copies
64
+
65
+ A typical flow is `setup` → `manage` → select a resource by name → **Edit saved text** → **Rename display name** → **Refresh from source** → **Manage groups** → **Apply group**. Each change has its own review/confirmation; selecting a menu entry is not permission to save.
66
+
67
+ The terminal editor works only on existing registered UTF-8 files inside the saved copy, up to **1_000_000 bytes** (1 MB), including the resulting edited bytes. Binary/NUL content, unsupported encodings and larger files are refused with a reason. It supports Japanese text, multiline bracketed paste, blank lines, cursor movement and scrolling:
68
+
69
+ - `Ctrl-S`: submit the in-memory text for masked diff review; this does **not** save yet.
70
+ - `Esc`, `Ctrl-C` or `Ctrl-D`: cancel; interruption leaves unconfirmed changes unsaved.
71
+ - `Enter`: newline; arrow keys: move; `Home` / `End`: line start/end.
72
+ - `Ctrl-Home` / `Ctrl-End`: document start/end; `PgUp` / `PgDn`: scroll; Backspace / Delete: remove text.
73
+
74
+ Saving requires diff approval, fresh approval for each detected sensitive file in the fixed prepared bytes, and final confirmation. Normal views and diffs are masked. Before editing a detected sensitive file, a separate individual approval warns that its **raw text** may expose secrets on the screen, scrollback or terminal recordings. That exposure approval is not save approval. Detection is heuristic and may miss secrets. A checksum mismatch or concurrent change stops saving rather than silently repairing or overwriting the saved copy.
75
+
76
+ Renaming changes the display name only: the internal ID, file names, body (including embedded agent names), destination and group references stay unchanged. Destination changes are a separate operation. Text editing cannot add/delete files or rename internal files.
77
+
78
+ Refresh reads an explicitly permitted same-kind source (a directory tree for a skill; a regular file for other kinds). If the original is unavailable, select a new absolute source path or cancel; the saved copy remains available. No HOME or parent tree is searched implicitly. Registered package origins can be rediscovered only in explicitly selected and authorized locations; arbitrary source paths gain no exclusion exception. After masked diff review, inspection and individual sensitive-file approvals, refresh replaces the **whole** saved copy, including bundle file additions/removals and mode changes. All copi-only edits are lost, not merged. ID, display name, savedAt, destination and **all** group memberships are retained. Neither editing nor refresh writes back to the original.
79
+
80
+ There is **no change history, restore feature or automatic merge**. Private apply backups for replaced project files are not saved-copy version history. Applying global settings, automatic dependency installation and content transformation remain outside scope.
81
+
82
+ Japanese/paste/caret functionality is covered by automated CLI/PTY tests; real-device IME, paste and visual caret-width verification is outside this acceptance scope and has not been performed. The minimum Node 22.19.0 version itself and other operating systems have not been verified by these checks.
31
83
 
32
84
  ## Private storage and sensitive data
33
85
 
34
- Snapshots and the catalog are stored under `~/.copi/` with owner-only directory/file permissions. `copi` copies the selected source into its library; it does not modify or delete the original. Snapshots remain available if the original is later removed. The copied content retains its original license and ownership; the MIT license for `copi` does not apply to imported resources.
86
+ Snapshots and the catalog are stored under `~/.copi/` with owner-only directory/file permissions: saved files are private `0600`. Original source modes are retained separately in snapshot descriptors/catalog reads and reproduced at the apply destination, not on the private saved inode. `copi` copies the selected source into its library; it does not modify or delete the original. Snapshots remain available if the original is later removed. The copied content retains its original license and ownership; the MIT license for `copi` does not apply to imported resources.
35
87
 
36
88
  Entire directories named `auth` are excluded; authentication-specific code may be missing, but other supported files in the resource are retained. Files named `auth.json`, `mcp-auth.json`, or `oauth.json`, `.env*` paths (including `.envrc` and `.env-production`), and repository/dependency/cache/history paths such as `.git`, `node_modules`, `.cache`, `.npm`, `cache`, `caches`, `history`, and `sessions` are excluded by the current resource filters. Do not use `copi` as a substitute for reviewing the files selected for copying.
37
89
 
@@ -0,0 +1,12 @@
1
+ {
2
+ "artifact": "rename-exclusive-arm64",
3
+ "sha256": "a8101d6b970dca67c3a3243b1bf49286ce4d26e463529e0bcca244704c98ae02",
4
+ "architecture": "arm64",
5
+ "platform": "darwin",
6
+ "protocolVersion": 1,
7
+ "deploymentTarget": "10.12",
8
+ "compiler": "/usr/bin/clang",
9
+ "compilerVersion": "Apple clang version 17.0.0 (clang-1700.6.3.2)",
10
+ "sdkPath": "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk",
11
+ "sdkVersion": "26.2"
12
+ }
@@ -23,17 +23,25 @@ export interface ApplyCollision {
23
23
  path: string;
24
24
  itemIds: string[];
25
25
  }
26
+ export interface ApplyReferenceProposal {
27
+ path: string;
28
+ status: "new" | "append";
29
+ instructionPaths: string[];
30
+ preview: ApplyPreview;
31
+ }
26
32
  export interface ApplyPlan {
27
33
  projectDir: string;
28
34
  files: ApplyFilePlan[];
29
35
  collisions: ApplyCollision[];
30
36
  warnings: InspectionWarning[];
31
37
  skippedItems: string[];
38
+ referenceProposal?: ApplyReferenceProposal;
32
39
  }
33
40
  export interface ApplyDecisions {
34
41
  collisions?: Record<string, string>;
35
42
  existing?: Record<string, "keep" | "replace" | "abort">;
36
43
  secretApprovals?: string[];
44
+ addReference?: boolean;
37
45
  }
38
46
  export interface ApplyBackup {
39
47
  path: string;
@@ -46,6 +54,7 @@ export interface ApplyResult {
46
54
  skippedItems: string[];
47
55
  backups: ApplyBackup[];
48
56
  cancelled: boolean;
57
+ referenceAdded?: string;
49
58
  }
50
59
  export declare function planApply(libraryDir: string, groupId: string, projectDir: string): Promise<ApplyPlan>;
51
60
  export declare function executeApply(libraryDir: string, plan: ApplyPlan, decisions: ApplyDecisions): Promise<ApplyResult>;
package/dist/src/apply.js CHANGED
@@ -3,8 +3,10 @@ import { constants } from "node:fs";
3
3
  import { access, chmod, lstat, mkdir, open, readFile, realpath, rename, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { createTwoFilesPatch } from "diff";
6
- import { loadCatalog, readSnapshotFiles } from "./catalog.js";
6
+ import { withSavedLibrary } from "./catalog.js";
7
7
  import { maskSensitiveText } from "./inspect.js";
8
+ import { appendReferenceLines, chooseReferenceTarget, contextFileNames, isAutoLoadedInstruction, referenceLine, unreferencedPaths } from "./instruction-reference.js";
9
+ import { assertNoNameConflicts } from "./names.js";
8
10
  const maxInspectionBytes = 1_000_000;
9
11
  const excludedNames = new Set([".git", ".cache", ".npm", "node_modules", "cache", "caches", "history", "sessions", "auth"]);
10
12
  const planState = new WeakMap();
@@ -168,20 +170,91 @@ function targetPath(item, filePath, fileCount) {
168
170
  throw new Error(`A file resource has an invalid snapshot layout: ${item.name}`);
169
171
  return item.destination.path;
170
172
  }
173
+ async function readRootContextFiles(root) {
174
+ const files = new Map();
175
+ const seen = new Set();
176
+ for (const name of contextFileNames) {
177
+ const target = path.join(root, name);
178
+ const stat = await maybeLstat(target);
179
+ if (!stat || seen.has(stat.ino))
180
+ continue;
181
+ seen.add(stat.ino);
182
+ if (!stat.isFile()) {
183
+ files.set(name, "unusable");
184
+ continue;
185
+ }
186
+ const bytes = await readFile(target);
187
+ files.set(name, textFrom(bytes) === undefined ? "unusable" : { bytes, mode: stat.mode & 0o777, sha256: sha256(bytes) });
188
+ }
189
+ return files;
190
+ }
191
+ async function planInstructionReference(root, instructionPaths, candidates, warnings) {
192
+ const targets = instructionPaths.filter((value) => !isAutoLoadedInstruction(value));
193
+ if (targets.length === 0)
194
+ return undefined;
195
+ const current = await readRootContextFiles(root);
196
+ const lowerNames = new Set(contextFileNames.map((name) => name.toLowerCase()));
197
+ const planned = [...candidates.keys()].filter((key) => !key.includes("/") && lowerNames.has(key.toLowerCase()));
198
+ const plannedLower = new Set(planned.map((key) => key.toLowerCase()));
199
+ const texts = [];
200
+ for (const key of planned) {
201
+ for (const option of candidates.get(key)) {
202
+ const text = textFrom(option.bytes);
203
+ if (text !== undefined)
204
+ texts.push(text);
205
+ }
206
+ }
207
+ for (const name of contextFileNames) {
208
+ if (plannedLower.has(name.toLowerCase()))
209
+ continue;
210
+ const file = current.get(name);
211
+ if (file && file !== "unusable")
212
+ texts.push(textFrom(file.bytes));
213
+ }
214
+ const missing = unreferencedPaths(targets, texts);
215
+ if (missing.length === 0)
216
+ return undefined;
217
+ const targetName = chooseReferenceTarget([...current.keys(), ...planned]);
218
+ const existing = current.get(targetName);
219
+ const manual = planned.length > 0 || existing === "unusable";
220
+ for (const value of missing) {
221
+ const how = manual
222
+ ? `Add this line to ${targetName} yourself: ${referenceLine(value)}`
223
+ : `Approve the optional reference line for ${targetName} during apply, or add it yourself: ${referenceLine(value)}`;
224
+ addWarning(warnings, "instruction-not-loaded", `Pi does not load this instruction automatically and no root context file mentions it. ${how}`, value);
225
+ }
226
+ if (manual)
227
+ return undefined;
228
+ const before = existing; // "unusable" always sets manual, which returned above
229
+ const bytes = Buffer.from(appendReferenceLines(before ? textFrom(before.bytes) : undefined, missing), "utf8");
230
+ const candidate = { itemId: "copi-reference", bytes, mode: before?.mode ?? 0o644, sha256: sha256(bytes), sensitive: false, preview: { kind: "binary", summary: "" } };
231
+ candidate.preview = previewFor(targetName, candidate, before);
232
+ return {
233
+ proposal: { path: targetName, status: before ? "append" : "new", instructionPaths: missing, preview: candidate.preview },
234
+ path: targetName,
235
+ candidate,
236
+ before,
237
+ };
238
+ }
171
239
  function candidateKey(candidate) {
172
240
  return `${candidate.sha256}:${candidate.mode}`;
173
241
  }
174
242
  export async function planApply(libraryDir, groupId, projectDir) {
175
- const catalog = await loadCatalog(libraryDir);
243
+ return withSavedLibrary(libraryDir, async (state) => planApplyLocked(state, groupId, projectDir));
244
+ }
245
+ async function planApplyLocked(state, groupId, projectDir) {
246
+ const catalog = state.catalog;
247
+ assertNoNameConflicts(catalog);
176
248
  const group = catalog.groups.find((item) => item.id === groupId);
177
249
  if (!group)
178
250
  throw new Error(`Unknown group: ${groupId}`);
179
- const storeRoot = await realpath(libraryDir);
251
+ const storeRoot = state.session.root;
180
252
  const project = await safeProjectRoot(projectDir);
181
253
  const warnings = [];
182
254
  const skippedItems = [];
183
255
  const candidates = new Map();
184
256
  const existing = new Map();
257
+ const instructionPaths = [];
185
258
  for (const itemId of group.itemIds) {
186
259
  const item = catalog.items.find((entry) => entry.id === itemId);
187
260
  if (!item)
@@ -191,7 +264,7 @@ export async function planApply(libraryDir, groupId, projectDir) {
191
264
  addWarning(warnings, "global-only", "This resource is saved for reference and cannot be applied to a project.", item.name);
192
265
  continue;
193
266
  }
194
- const snapshotFiles = await readSnapshotFiles(storeRoot, item);
267
+ const snapshotFiles = state.files.get(item.id);
195
268
  for (const snapshot of snapshotFiles) {
196
269
  if (isExcludedSnapshotPath(snapshot.path)) {
197
270
  addWarning(warnings, "private-path-skipped", "Authentication, environment, history, and cache files are not applied.", snapshot.path);
@@ -199,8 +272,10 @@ export async function planApply(libraryDir, groupId, projectDir) {
199
272
  }
200
273
  const destination = targetPath(item, snapshot.path, snapshotFiles.length);
201
274
  const relativePath = path.posix.normalize(destination);
202
- if (!safeRelativePath(relativePath))
203
- throw new Error(`Unsafe project destination in catalog: ${relativePath}`);
275
+ if (!safeRelativePath(relativePath) || isExcludedSnapshotPath(relativePath))
276
+ throw new Error(`Unsafe or private/excluded project destination in catalog: ${relativePath}`);
277
+ if (item.kind === "instruction")
278
+ instructionPaths.push(relativePath);
204
279
  const fileCandidates = candidates.get(relativePath) ?? [];
205
280
  const candidate = {
206
281
  itemId: item.id,
@@ -246,14 +321,22 @@ export async function planApply(libraryDir, groupId, projectDir) {
246
321
  alternatives: uniqueCandidates.map(({ itemId, sensitive, preview }) => ({ itemId, sensitive, preview })),
247
322
  });
248
323
  }
249
- const plan = { projectDir: project.canonical, files, collisions, warnings, skippedItems };
324
+ const reference = await planInstructionReference(project.canonical, instructionPaths, candidates, warnings);
325
+ if (reference)
326
+ existing.set(reference.path, reference.before);
327
+ const plan = reference
328
+ ? { projectDir: project.canonical, files, collisions, warnings, skippedItems, referenceProposal: reference.proposal }
329
+ : { projectDir: project.canonical, files, collisions, warnings, skippedItems };
330
+ await state.session.assertRevision(state.revision);
250
331
  planState.set(plan, {
332
+ revision: state.revision,
251
333
  libraryRoot: storeRoot,
252
334
  projectRoot: project.canonical,
253
335
  requestedProjectDir: project.requested,
254
336
  candidates: new Map([...candidates].map(([key, values]) => [key, [...new Map(values.map((candidate) => [candidateKey(candidate), candidate])).values()]])),
255
337
  existing,
256
338
  skippedItems: [...skippedItems],
339
+ ...(reference ? { reference: { path: reference.path, candidate: reference.candidate } } : {}),
257
340
  });
258
341
  return plan;
259
342
  }
@@ -372,11 +455,16 @@ async function writeSnapshotFile(root, relativePath, candidate) {
372
455
  }
373
456
  }
374
457
  export async function executeApply(libraryDir, plan, decisions) {
458
+ return withSavedLibrary(libraryDir, async (state) => executeApplyLocked(state, plan, decisions));
459
+ }
460
+ async function executeApplyLocked(state, plan, decisions) {
375
461
  const internal = planState.get(plan);
376
462
  if (!internal)
377
463
  throw new Error("Apply plan is not a verified plan created by planApply.");
378
- if (await realpath(libraryDir) !== internal.libraryRoot)
464
+ if (state.session.root !== internal.libraryRoot)
379
465
  throw new Error("Apply plan belongs to a different copi library.");
466
+ assertNoNameConflicts(state.catalog);
467
+ await state.session.assertRevision(internal.revision);
380
468
  if (await realpath(internal.requestedProjectDir) !== internal.projectRoot)
381
469
  throw new Error("Project destination changed after planning.");
382
470
  const operations = [];
@@ -418,6 +506,14 @@ export async function executeApply(libraryDir, plan, decisions) {
418
506
  else
419
507
  operations.push({ path: relativePath, candidate });
420
508
  }
509
+ if (decisions.addReference === true) {
510
+ if (!internal.reference)
511
+ throw new Error("The apply plan has no reference proposal to add.");
512
+ const before = internal.existing.get(internal.reference.path);
513
+ operations.push(before
514
+ ? { path: internal.reference.path, candidate: internal.reference.candidate, before }
515
+ : { path: internal.reference.path, candidate: internal.reference.candidate });
516
+ }
421
517
  if (cancelled)
422
518
  return { written: [], kept: [], skippedItems: [...internal.skippedItems], backups: [], cancelled: true };
423
519
  for (const operation of operations) {
@@ -428,6 +524,7 @@ export async function executeApply(libraryDir, plan, decisions) {
428
524
  }
429
525
  await assertWritableParent(internal.projectRoot, operation.path);
430
526
  }
527
+ await state.session.assertRevision(internal.revision);
431
528
  const backups = await createBackupSet(internal.libraryRoot, operations);
432
529
  const written = [];
433
530
  for (const operation of operations) {
@@ -436,8 +533,12 @@ export async function executeApply(libraryDir, plan, decisions) {
436
533
  if ((current?.sha256 ?? undefined) !== (expected?.sha256 ?? undefined) || (current?.mode ?? undefined) !== (expected?.mode ?? undefined)) {
437
534
  throw new Error(`Destination changed during apply: ${operation.path}`);
438
535
  }
536
+ await state.session.assertRevision(internal.revision);
439
537
  await writeSnapshotFile(internal.projectRoot, operation.path, operation.candidate);
440
538
  written.push(operation.path);
441
539
  }
442
- return { written, kept, skippedItems: [...internal.skippedItems], backups, cancelled: false };
540
+ const referenceAdded = internal.reference && written.includes(internal.reference.path) ? internal.reference.path : undefined;
541
+ return referenceAdded
542
+ ? { written, kept, skippedItems: [...internal.skippedItems], backups, cancelled: false, referenceAdded }
543
+ : { written, kept, skippedItems: [...internal.skippedItems], backups, cancelled: false };
443
544
  }
@@ -1,7 +1,10 @@
1
- import type { Catalog, InspectionResult, ResourceCandidate, SavedItem, SnapshotFileContents } from "./types.ts";
1
+ import { type StorageRevision, type StorageSession } from "./storage-transaction.ts";
2
+ import type { Catalog, InspectionResult, LegacySavedItem, ResourceCandidate, SavedItem, SnapshotFileContents, StoredCatalog } from "./types.ts";
2
3
  export interface ImportOptions {
3
4
  confirmed: boolean;
4
5
  groupIds: string[];
6
+ inspection?: PreparedInspection;
7
+ approvedSensitivePaths?: readonly string[];
5
8
  }
6
9
  declare const preparedResourceBrand: unique symbol;
7
10
  export interface PreparedResource {
@@ -28,12 +31,26 @@ export interface PreparedImportOptions extends ImportOptions {
28
31
  approvedSensitivePaths: readonly string[];
29
32
  }
30
33
  export declare function newCatalog(): Catalog;
34
+ export declare function parseStoredCatalog(value: unknown): StoredCatalog;
35
+ export declare function assertModernCatalog(catalog: StoredCatalog): asserts catalog is Catalog;
31
36
  export declare function prepareResource(candidate: ResourceCandidate): Promise<PreparedResource>;
32
37
  export declare function readPreparedResource(prepared: PreparedResource): PreparedResourceContents;
33
- export declare function loadCatalog(libraryDir: string): Promise<Catalog>;
38
+ export declare function catalogBytes(catalog: Catalog): Buffer;
39
+ export declare function loadCatalog(libraryDir: string): Promise<StoredCatalog>;
40
+ export interface SavedLibraryState {
41
+ catalog: Catalog;
42
+ revision: StorageRevision;
43
+ files: Map<string, SnapshotFileContents[]>;
44
+ session: StorageSession;
45
+ }
46
+ /** Internal seam: a single lock, checked catalog and all snapshots; no recursive reader locking. */
47
+ export declare function withSavedLibrary<T>(libraryDir: string, operation: (state: SavedLibraryState) => Promise<T>): Promise<T>;
34
48
  export declare function saveCatalog(libraryDir: string, catalog: Catalog): Promise<void>;
35
49
  export declare function importPreparedResource(libraryDir: string, prepared: PreparedResource, options: PreparedImportOptions): Promise<SavedItem | null>;
36
50
  export declare function importResource(libraryDir: string, candidate: ResourceCandidate, options: ImportOptions): Promise<SavedItem | null>;
37
- export declare function readSnapshotFiles(libraryDir: string, item: SavedItem): Promise<SnapshotFileContents[]>;
38
- export declare function listSnapshots(libraryDir: string): Promise<SavedItem[]>;
51
+ export declare function readSnapshotFiles(libraryDir: string, item: SavedItem | LegacySavedItem): Promise<SnapshotFileContents[]>;
52
+ /** Persist staged, explicit name-only changes together, so cancellation never partially resolves names. */
53
+ export declare function saveNameChanges(libraryDir: string, before: Catalog, after: Catalog): Promise<void>;
54
+ export declare function renameSavedItem(libraryDir: string, itemId: string, name: string): Promise<SavedItem>;
55
+ export declare function listSnapshots(libraryDir: string): Promise<Array<LegacySavedItem | SavedItem>>;
39
56
  export {};