@codedealer/pi-sync 1.0.0 → 1.4.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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Pure pi-sync status helpers for the bundled Pi extension.
3
+ *
4
+ * Plain JavaScript with no dependency on @earendil-works/pi-coding-agent,
5
+ * so it is unit-testable with `node --test` without a Pi runtime. The
6
+ * extension (index.ts) is a relay: it feeds this module the `summary` block
7
+ * of `pi-sync status`, and all glyph, ordering, and color decisions for the
8
+ * persistent footer live here.
9
+ *
10
+ * The session-start fetch's freshness gate also lives here: a fetch is
11
+ * skipped when the repository's FETCH_HEAD (rewritten by every fetch) is
12
+ * younger than five minutes.
13
+ *
14
+ * Footer state table (colors are applied by the style callback):
15
+ * pi-sync ✓ clean: no local drift, not behind upstream, nothing needs review
16
+ * pi-sync ↑n n local change(s) not yet on the remote
17
+ * pi-sync ↓n n remote change(s) not yet applied to the local installation
18
+ * pi-sync !n n item(s) need manual review
19
+ * pi-sync ✗ the status check failed (CLI unavailable, repository error)
20
+ * pi-sync … checking (first status check in flight)
21
+ * pi-sync ⟳ auto-sync in flight
22
+ *
23
+ * Non-zero terms combine in the order ↑ ↓ !; zero terms are omitted; a
24
+ * summary with no non-zero terms renders only ✓.
25
+ */
26
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
27
+ import path from "node:path";
28
+
29
+ /** Footer label. */
30
+ export const LABEL = "pi-sync";
31
+
32
+ /**
33
+ * True when value is a well-formed CLI summary block: non-negative integer
34
+ * localPending/remotePending/needsReview counts and a clean boolean.
35
+ */
36
+ export function isSyncSummary(value) {
37
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
38
+ for (const key of ["localPending", "remotePending", "needsReview"]) {
39
+ if (!Number.isInteger(value[key]) || value[key] < 0) return false;
40
+ }
41
+ return typeof value.clean === "boolean";
42
+ }
43
+
44
+ /**
45
+ * Extract the usable summary from a parsed `pi-sync status`/`plan` result.
46
+ * Returns null when the result is not an object or carries no well-formed
47
+ * `summary` block — the older-CLI case, which callers render as "no footer".
48
+ */
49
+ export function extractSummary(result) {
50
+ if (result === null || typeof result !== "object" || Array.isArray(result)) return null;
51
+ return isSyncSummary(result.summary) ? result.summary : null;
52
+ }
53
+
54
+ /**
55
+ * Render the persistent footer from a footer state, or null when no footer
56
+ * is shown (absent state, e.g. an older CLI without a summary block).
57
+ *
58
+ * input is one of:
59
+ * - a sync summary (see isSyncSummary),
60
+ * - { state: "checking" } — the first status check is in flight,
61
+ * - { state: "syncing" } — an automatic sync is in flight,
62
+ * - { state: "failed" } — the status check failed.
63
+ *
64
+ * style(color, text) applies a theme color to text; it defaults to the
65
+ * identity. The label is muted; ✓, …, and ⟳ are dim; ↑ is warning; ↓ is
66
+ * accent; ! and ✗ are error.
67
+ */
68
+ export function renderFooter(input, style = (color, text) => text) {
69
+ if (input === null || input === undefined) return null;
70
+ if (typeof input === "object" && !Array.isArray(input)) {
71
+ const label = style("muted", `${LABEL} `);
72
+ if ("state" in input) {
73
+ if (input.state === "checking") return label + style("dim", "…");
74
+ if (input.state === "syncing") return label + style("dim", "⟳");
75
+ return label + style("error", "✗"); // failed, or an unknown state
76
+ }
77
+ if (isSyncSummary(input)) {
78
+ const terms = [];
79
+ if (input.localPending > 0) terms.push(style("warning", `↑${input.localPending}`));
80
+ if (input.remotePending > 0) terms.push(style("accent", `↓${input.remotePending}`));
81
+ if (input.needsReview > 0) terms.push(style("error", `!${input.needsReview}`));
82
+ return label + (terms.length > 0 ? terms.join(" ") : style("dim", "✓"));
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Derive the exact flat list of paths whose file metadata can change the
90
+ * status summary: the git metadata (any fetch rewrites FETCH_HEAD; local
91
+ * commits and checkouts move HEAD; dirt moves index; ref repacking moves
92
+ * packed-refs), the manifest and lockfile, the agent baseline state, and
93
+ * each manifest-declared settings target under the agent directory.
94
+ * Deliberately flat — no recursive walk of resource targets — so a tick
95
+ * costs a constant number of stat calls regardless of how many resources
96
+ * are managed, and never touches the network.
97
+ */
98
+ export function fingerprintPaths({ repoDir, agentDir, settingsTargets = [] }) {
99
+ const paths = [
100
+ path.join(repoDir, ".git", "HEAD"),
101
+ path.join(repoDir, ".git", "index"),
102
+ path.join(repoDir, ".git", "packed-refs"),
103
+ path.join(repoDir, ".git", "FETCH_HEAD"),
104
+ path.join(repoDir, "pi-sync.json"),
105
+ path.join(repoDir, "pi-sync.lock.json"),
106
+ path.join(agentDir, ".pi-sync", "state.json"),
107
+ ];
108
+ for (const target of settingsTargets) {
109
+ if (typeof target === "string" && target.length > 0) paths.push(path.join(agentDir, target));
110
+ }
111
+ return [...new Set(paths)];
112
+ }
113
+
114
+ /**
115
+ * Compute the fingerprint for a path list: one [path, mtimeMs] pair per
116
+ * path, with the stable absent marker null for paths that do not exist.
117
+ * stat is injectable for tests; it must return { mtimeMs } for existing
118
+ * paths and null for absent ones.
119
+ */
120
+ export function computeFingerprint(paths, stat = defaultStat) {
121
+ return paths.map((item) => [item, stat(item)?.mtimeMs ?? null]);
122
+ }
123
+
124
+ function defaultStat(filePath) {
125
+ try {
126
+ return { mtimeMs: statSync(filePath).mtimeMs };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Structurally compare two fingerprints: the same ordered list of
134
+ * [path, mtimeMs | null] pairs, with the stable absent marker (null)
135
+ * comparing equal to null. Returns false for non-array input, a length
136
+ * mismatch, or malformed entries, so a stale or corrupt fingerprint never
137
+ * matches and forces a recompute.
138
+ */
139
+ export function fingerprintsEqual(a, b) {
140
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
141
+ for (let i = 0; i < a.length; i += 1) {
142
+ const left = a[i];
143
+ const right = b[i];
144
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== 2 || right.length !== 2) return false;
145
+ if (left[0] !== right[0] || left[1] !== right[1]) return false;
146
+ }
147
+ return true;
148
+ }
149
+
150
+ /**
151
+ * The setup repository's FETCH_HEAD, which git rewrites on every fetch and
152
+ * whose mtime gates the session-start fetch.
153
+ */
154
+ export function fetchHeadPath(repoDir) {
155
+ return path.join(repoDir, ".git", "FETCH_HEAD");
156
+ }
157
+
158
+ /** Maximum FETCH_HEAD age (in milliseconds) for which the session-start fetch is skipped. */
159
+ export const FETCH_MAX_AGE_MS = 5 * 60 * 1000;
160
+
161
+ /**
162
+ * The session-start fetch's freshness gate: true when FETCH_HEAD's mtime is
163
+ * younger than maxAgeMs relative to nowMs — a recent fetch already moved the
164
+ * upstream comparison, so fetching again is skipped. A missing FETCH_HEAD
165
+ * (null mtime) is never fresh: the fetch runs. Non-numeric input is never
166
+ * fresh, so a bad read can only cost a redundant fetch, never a stale skip.
167
+ */
168
+ export function isFetchFresh(fetchHeadMtimeMs, nowMs, maxAgeMs = FETCH_MAX_AGE_MS) {
169
+ return (
170
+ typeof fetchHeadMtimeMs === "number" &&
171
+ Number.isFinite(fetchHeadMtimeMs) &&
172
+ nowMs - fetchHeadMtimeMs < maxAgeMs
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Shared status cache location: the agent directory's local bookkeeping
178
+ * state, next to the baseline.
179
+ */
180
+ export function statusCachePath(agentDir) {
181
+ return path.join(agentDir, ".pi-sync", "status-cache.json");
182
+ }
183
+
184
+ /**
185
+ * Read the shared status cache. Returns the entry ({ fingerprint, summary })
186
+ * when the file exists, parses, and carries a usable fingerprint and
187
+ * summary; null otherwise — an absent or corrupt cache is simply recomputed.
188
+ */
189
+ export function readStatusCache(cachePath) {
190
+ let raw;
191
+ try {
192
+ raw = readFileSync(cachePath, "utf8");
193
+ } catch {
194
+ return null;
195
+ }
196
+ try {
197
+ const entry = JSON.parse(raw);
198
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null;
199
+ if (!Array.isArray(entry.fingerprint)) return null;
200
+ if (!isSyncSummary(entry.summary)) return null;
201
+ return entry;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Write the shared status cache atomically (temp file + rename). No
209
+ * locking: the same fingerprint deterministically yields the same summary,
210
+ * so concurrent writers are safe (last writer wins).
211
+ */
212
+ export function writeStatusCache(cachePath, entry) {
213
+ mkdirSync(path.dirname(cachePath), { recursive: true });
214
+ const tempPath = `${cachePath}.tmp-${process.pid}`;
215
+ writeFileSync(tempPath, JSON.stringify(entry));
216
+ renameSync(tempPath, cachePath);
217
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pi-sync
3
- description: Inspect and safely operate the explicitly managed Pi setup using the pi-sync CLI.
3
+ description: Use before adding, editing, or removing any skill, prompt, agent, role, extension, or settings file under the active agent directory (default ~/.pi/agent, or wherever PI_CODING_AGENT_DIR points); also for operating the explicitly managed Pi setup with the pi-sync CLI.
4
4
  ---
5
5
 
6
6
  # Pi Sync agent workflow
@@ -26,6 +26,15 @@ pi-sync plan --repo /path/to/private-setup --agent-dir "$PI_CODING_AGENT_DIR"
26
26
 
27
27
  Omit `--agent-dir` to use `PI_CODING_AGENT_DIR`, then the default `~/.pi/agent`. `status` and `plan` are read-only. Review `actions`, `conflicts`, and `staleResources` in the JSON before taking an action. A stale resource remains installed; ordinary apply does not prune it.
28
28
 
29
+ ## Adding or editing user-level resources
30
+
31
+ When asked to add or edit a user-level (not project-scoped) resource, first determine whether the target is managed, before changing anything. The setup manifest (`pi-sync.json` at the setup repository root) is authoritative: its `resources` entries declare managed resources and its `settings` entries declare managed settings targets and fields. `pi-sync status --repo ... --agent-dir ...` exposes the same set: managed targets appear in `actions` (a locally edited managed resource shows `action: "local-divergence"`) and removed-but-installed targets appear in `staleResources`.
32
+
33
+ - **Managed resource:** never edit the installed copy in the agent directory — it is the output of `apply`, not an input. Edit the source in the setup repository (the manifest-declared `resources/<type-root>/` path) and run `pi-sync apply` to reconcile the local installation. Publish only with an explicit push authorization (see Push only when explicitly authorized). An in-place edit of an installed managed copy is local-only divergence: `status` reports it as a review item (the extension's footer shows `!n`) and keeps counting it until resolved. The clean recovery is to port the content into the setup repository and apply again — once local and repository content match, the resource re-baselines as unchanged.
34
+ - **Unmanaged existing file:** edit it directly. Pi Sync never touches unlisted resources or undeclared settings fields.
35
+ - **New resource:** manage by default. Create the resource, then bring it under management with `pi-sync add` (see Add only a specifically selected local resource) rather than leaving it unmanaged, and apply it through Pi Sync.
36
+ - **Settings:** managed per field, per the manifest's `settings` declarations. Local changes to managed fields belong to the back-sync flow (see Back-sync local setting changes when requested); undeclared fields remain local and can be edited directly.
37
+
29
38
  ## Apply when requested
30
39
 
31
40
  Run apply only when the user asks to reconcile the setup repository into the local installation:
@@ -36,6 +45,17 @@ pi-sync apply --repo /path/to/private-setup --agent-dir "$PI_CODING_AGENT_DIR"
36
45
 
37
46
  Apply follows the reviewed repository state, preserves unlisted content and local-only divergence, and never overwrites a reported collision or conflicted item. A non-zero result requires stopping to explain the structured conflict or error; do not resolve conflicts by guessing, overwriting, or editing managed files around the CLI. Review a new plan after the user resolves the issue.
38
47
 
48
+ ## Back-sync local setting changes when requested
49
+
50
+ Pi rewrites `settings.json` (and other managed settings targets) when the user changes the model, model scope, theme, thinking level, or any other setting in the TUI. Those local-only changes drift away from the setup manifest until someone edits it by hand. The first-party `pi-sync` extension (installed next to this skill) watches the managed files and runs `pi-sync sync --apply` automatically after a short debounce; a `pi-sync` status-line entry and the `/pi-sync` command expose the result. When the extension is absent (or the user asks to persist a setting change), run the CLI directly:
51
+
52
+ ```sh
53
+ pi-sync sync --repo /path/to/private-setup --agent-dir "$PI_CODING_AGENT_DIR"
54
+ pi-sync sync --apply --repo /path/to/private-setup --agent-dir "$PI_CODING_AGENT_DIR"
55
+ ```
56
+
57
+ `sync` promotes local-only managed setting fields into the manifest using the same three-way comparison as `plan` (repository unchanged, local diverged from the last applied baseline). Eligible values are portability-validated, the rewritten manifest is secret-scanned, and the write is atomic. Fields changed in both places are conflicts and are never overwritten; baseline-less collisions and locally removed fields are reported for manual review and never written. `sync` previews by default and writes only with `--apply`. It modifies only the setup manifest; it never touches the local installation and never commits or pushes. A non-zero result means some fields were not synced: explain the structured `conflicts`, `rejected`, or `review` entries, and publish the synced change only through an explicit `pi-sync push`.
58
+
39
59
  ## Add only a specifically selected local resource
40
60
 
41
61
  When the user explicitly asks to add a named local resource, review its contents for private data and use the CLI with the matching type: