@indigoai-us/hq-cli 5.17.0 → 5.18.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,327 @@
1
+ /**
2
+ * `hq sync narrow` (US-007) — explicit migration ritual to flip a company's
3
+ * membership from `syncMode: all` to `syncMode: shared`, with a dry-run
4
+ * preview and a dirty-file safety gate.
5
+ *
6
+ * Two call shapes:
7
+ *
8
+ * hq sync narrow --dry-run [--company <slug>]
9
+ * Resolves the membership for <slug> (or the active company), fetches
10
+ * the caller's explicit grants, coalesces them, walks the local
11
+ * `companies/<slug>/` tree, and prints a table:
12
+ *
13
+ * files staying: N
14
+ * clean orphans: N (size)
15
+ * dirty orphans: N (size)
16
+ *
17
+ * Writes nothing — no journal, no local file, no server call beyond
18
+ * the read-only GET on grants + sync-config.
19
+ *
20
+ * hq sync narrow --apply [--yes] [--force] [--company <slug>]
21
+ * Same computation, then:
22
+ * - if `dirty > 0` and `--force` NOT set → abort with conflict report
23
+ * and a recovery message.
24
+ * - else delete clean orphans on disk, tombstone their journal
25
+ * entries, optionally delete dirty orphans too (with `--force`),
26
+ * then PUT sync-config to `shared`. The server's PUT writes the
27
+ * authoritative `MEMBERSHIP_SYNC_CONFIG_CHANGED` audit row; the
28
+ * CLI prints a local audit line as well.
29
+ *
30
+ * Refuses on already-`shared` or `custom` memberships (acceptance 5) —
31
+ * narrowing makes sense only as `all → shared`.
32
+ *
33
+ * Cross-package note: depends on hq-cloud helpers shipped in US-005
34
+ * (`coalescePrefixes`, `readJournal`/`writeJournal`, `tombstoneEntry`) and
35
+ * the VaultClient sync-config endpoints from US-003. While that hq-cloud
36
+ * release is unpublished, hq-cli pins `@indigoai-us/hq-cloud` to
37
+ * `file:../hq-cloud` via `pnpm.overrides`.
38
+ */
39
+
40
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a6c1e2b9-5d9a-57b2-a80b-adfdbce88206")}catch(e){}}();
41
+ import chalk from "chalk";
42
+ import * as readline from "node:readline";
43
+ import * as fs from "node:fs";
44
+ import { VaultClient, coalescePrefixes, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
45
+ import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
46
+ import { readActiveCompanySlug } from "./sync-mode.js";
47
+ import { buildNarrowPlan, formatBytes, formatNarrowPlanSummary, } from "../lib/local-tree-diff.js";
48
+ const realJournalIO = {
49
+ read: readJournal,
50
+ write: writeJournal,
51
+ };
52
+ const realFsIO = {
53
+ unlinkSync: fs.unlinkSync,
54
+ };
55
+ const realConfirm = async (message) => {
56
+ const rl = readline.createInterface({
57
+ input: process.stdin,
58
+ output: process.stdout,
59
+ });
60
+ return new Promise((resolve) => {
61
+ rl.question(`${message} [y/N] `, (answer) => {
62
+ rl.close();
63
+ resolve(/^y(es)?$/i.test(answer.trim()));
64
+ });
65
+ });
66
+ };
67
+ /**
68
+ * Resolve the caller's membership for `companySlug` and read its current
69
+ * sync-config. Throws if the slug doesn't match any membership or if the
70
+ * current mode isn't eligible for narrowing (`all` → `shared` only).
71
+ */
72
+ export async function resolveNarrowTarget(input) {
73
+ const { companySlug, vaultClient } = input;
74
+ const memberships = await vaultClient.listMyMemberships();
75
+ const enriched = await Promise.all(memberships.map(async (m) => {
76
+ try {
77
+ const ent = await vaultClient.entity.get(m.companyUid);
78
+ return { membership: m, slug: ent.slug, name: ent.name };
79
+ }
80
+ catch {
81
+ return { membership: m, slug: undefined, name: undefined };
82
+ }
83
+ }));
84
+ const match = enriched.find((row) => row.slug === companySlug);
85
+ if (!match) {
86
+ const known = enriched
87
+ .map((r) => r.slug)
88
+ .filter((s) => !!s)
89
+ .join(", ");
90
+ throw new Error(`No membership found for company '${companySlug}'. Memberships visible to you: ${known || "(none)"}.`);
91
+ }
92
+ const membership = match.membership;
93
+ const syncConfig = await vaultClient.getMembershipSyncConfig(membership.membershipKey);
94
+ if (syncConfig.syncMode !== "all") {
95
+ throw new Error(`Refusing to narrow: membership for '${companySlug}' is currently ` +
96
+ `syncMode='${syncConfig.syncMode}'. The narrow ritual only runs on ` +
97
+ `syncMode='all' memberships (shared → shared is a no-op; ` +
98
+ `custom requires \`hq sync mode shared\` directly).`);
99
+ }
100
+ return {
101
+ membership,
102
+ companyUid: membership.companyUid,
103
+ companySlug,
104
+ syncConfig,
105
+ };
106
+ }
107
+ /**
108
+ * Compute the narrow plan for one company: fetch explicit grants, coalesce,
109
+ * read the per-company journal, walk the local tree, classify.
110
+ *
111
+ * Reads only — never mutates the journal or hits a destructive endpoint.
112
+ * `apply()` consumes this and performs the destructive steps.
113
+ */
114
+ export async function computeNarrowPlan(input) {
115
+ const { hqRoot, companySlug, companyUid, vaultClient } = input;
116
+ const io = input.journalIO ?? realJournalIO;
117
+ const grants = await vaultClient.listMyExplicitGrants(companyUid);
118
+ const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
119
+ const journal = io.read(companySlug);
120
+ const plan = buildNarrowPlan({
121
+ hqRoot,
122
+ companySlug,
123
+ prospectivePrefixSet,
124
+ journal,
125
+ });
126
+ return { plan, prospectivePrefixSet, grants, journal };
127
+ }
128
+ /**
129
+ * Apply the destructive side of a narrow:
130
+ * 1. Delete clean orphans (best-effort — ENOENT is ignored).
131
+ * 2. Tombstone their journal entries.
132
+ * 3. If `force`, also delete + tombstone dirty orphans.
133
+ * 4. Persist the journal.
134
+ * 5. PUT sync-config → `shared` (server writes the audit row).
135
+ *
136
+ * Order is fail-safe: if a delete throws partway, the journal write below
137
+ * won't run, but the in-progress deletions are already on disk — that's the
138
+ * "don't roll back I/O" contract from the spec. The CLI surfaces a
139
+ * recovery message when this throws.
140
+ *
141
+ * Pure-ish: no prompting (the CLI wrapper handles that), no console
142
+ * output. Just disk + network.
143
+ */
144
+ export async function applyNarrow(input) {
145
+ const { plan, journal, force, companySlug, membershipId, vaultClient } = input;
146
+ const journalIO = input.journalIO ?? realJournalIO;
147
+ const fsIO = input.fsIO ?? realFsIO;
148
+ let cleanDeleted = 0;
149
+ let dirtyDeleted = 0;
150
+ let tombstoned = 0;
151
+ const cleanBytes = plan.clean.reduce((sum, f) => sum + f.bytes, 0);
152
+ const dirtyBytes = force
153
+ ? plan.dirty.reduce((sum, f) => sum + f.bytes, 0)
154
+ : 0;
155
+ // 1. Delete clean orphans + tombstone.
156
+ for (const file of plan.clean) {
157
+ try {
158
+ fsIO.unlinkSync(file.absPath);
159
+ cleanDeleted++;
160
+ }
161
+ catch (err) {
162
+ const code = err.code;
163
+ if (code !== "ENOENT")
164
+ throw err;
165
+ }
166
+ tombstoneEntry(journal, file.relPath, "narrow_apply");
167
+ tombstoned++;
168
+ }
169
+ // 2. With --force, delete dirty orphans too.
170
+ if (force) {
171
+ for (const file of plan.dirty) {
172
+ try {
173
+ fsIO.unlinkSync(file.absPath);
174
+ dirtyDeleted++;
175
+ }
176
+ catch (err) {
177
+ const code = err.code;
178
+ if (code !== "ENOENT")
179
+ throw err;
180
+ }
181
+ // tombstone only if a journal entry exists — `not-in-journal` files
182
+ // never had one to tombstone.
183
+ if (journal.files[file.relPath]) {
184
+ tombstoneEntry(journal, file.relPath, "narrow_apply");
185
+ tombstoned++;
186
+ }
187
+ }
188
+ }
189
+ // 3. Persist journal.
190
+ journalIO.write(companySlug, journal);
191
+ // 4. PUT sync-config → shared. Server writes the authoritative
192
+ // MEMBERSHIP_SYNC_CONFIG_CHANGED audit row.
193
+ const newConfig = await vaultClient.setMembershipSyncConfig(membershipId, {
194
+ syncMode: "shared",
195
+ });
196
+ return {
197
+ cleanDeleted,
198
+ cleanBytes,
199
+ dirtyDeleted,
200
+ dirtyBytes,
201
+ tombstoned,
202
+ newConfig,
203
+ };
204
+ }
205
+ // ── Dirty-file conflict report (operator-facing) ───────────────────────────
206
+ /**
207
+ * Format the dirty-file conflict report shown on `--apply` when dirty files
208
+ * are present and `--force` is NOT set. The CLI prints this and exits
209
+ * non-zero so the operator can resolve before retrying.
210
+ */
211
+ export function formatDirtyConflictReport(dirty, companySlug) {
212
+ const lines = [];
213
+ lines.push(`Aborting narrow for '${companySlug}': ${dirty.length} locally-modified file(s) ` +
214
+ `would be removed by this narrow.`);
215
+ lines.push("");
216
+ lines.push("Dirty files:");
217
+ const cap = 25;
218
+ for (const f of dirty.slice(0, cap)) {
219
+ lines.push(` ${f.relPath} (${formatBytes(f.bytes)}, ${f.reason})`);
220
+ }
221
+ if (dirty.length > cap) {
222
+ lines.push(` ... ${dirty.length - cap} more`);
223
+ }
224
+ lines.push("");
225
+ lines.push("Resolve before retrying:");
226
+ lines.push(" - commit or push the modifications via the normal sync flow;");
227
+ lines.push(" - move them outside the company folder; or");
228
+ lines.push(" - delete them locally if they really should be gone.");
229
+ lines.push("");
230
+ lines.push("Alternatively, re-run with --force to remove the dirty files anyway " +
231
+ "(destructive — there is no undo).");
232
+ return lines.join("\n");
233
+ }
234
+ /**
235
+ * Wire `hq sync narrow` onto the existing `sync` Commander group. Called
236
+ * from `src/index.ts` after `registerSyncModeCommand`.
237
+ */
238
+ export function registerSyncNarrowCommand(syncCmd) {
239
+ syncCmd
240
+ .command("narrow")
241
+ .description("Migrate a company's membership from syncMode='all' to 'shared' — " +
242
+ "dry-run previews diff, --apply performs the prune + flip")
243
+ .option("--dry-run", "Compute + print the narrow plan; write nothing (default if neither --dry-run nor --apply is passed)")
244
+ .option("--apply", "Execute the narrow — delete clean orphans, flip sync-mode to 'shared'")
245
+ .option("--yes", "Skip the interactive confirmation prompt on --apply")
246
+ .option("--force", "On --apply, also remove locally-modified (dirty) files (destructive — no undo)")
247
+ .option("--company <slug>", "Company slug (defaults to the active company in <hq-root>/.hq/config.json)")
248
+ .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
249
+ .action(async (options) => {
250
+ try {
251
+ if (options.dryRun && options.apply) {
252
+ throw new Error("--dry-run and --apply are mutually exclusive.");
253
+ }
254
+ // Default to dry-run when nothing is specified — safer.
255
+ const apply = !!options.apply;
256
+ const dryRun = !apply;
257
+ const companySlug = options.company ?? readActiveCompanySlug(options.hqRoot);
258
+ if (!companySlug) {
259
+ throw new Error("No company specified. Pass --company <slug> or set activeCompany in <hq-root>/.hq/config.json.");
260
+ }
261
+ const accessToken = await ensureCognitoToken();
262
+ const vaultConfig = buildVaultConfig(accessToken);
263
+ const client = new VaultClient(vaultConfig);
264
+ const target = await resolveNarrowTarget({
265
+ companySlug,
266
+ vaultClient: client,
267
+ });
268
+ const { plan, prospectivePrefixSet, grants } = await computeNarrowPlan({
269
+ hqRoot: options.hqRoot,
270
+ companySlug: target.companySlug,
271
+ companyUid: target.companyUid,
272
+ vaultClient: client,
273
+ });
274
+ // Always print the summary.
275
+ console.log(chalk.bold(`${dryRun ? "Dry-run" : "Apply"}: narrow '${target.companySlug}' (${grants.length} explicit grant${grants.length === 1 ? "" : "s"}, ${prospectivePrefixSet.length} coalesced prefix${prospectivePrefixSet.length === 1 ? "" : "es"})`));
276
+ console.log(formatNarrowPlanSummary(plan));
277
+ if (dryRun) {
278
+ if (plan.totalDirtyCount > 0) {
279
+ console.log("");
280
+ console.log(chalk.yellow(`Heads-up: ${plan.totalDirtyCount} dirty file(s) would block --apply unless --force is passed.`));
281
+ }
282
+ return;
283
+ }
284
+ // --apply path
285
+ if (plan.totalDirtyCount > 0 && !options.force) {
286
+ console.error(chalk.red(formatDirtyConflictReport(plan.dirty, target.companySlug)));
287
+ process.exit(2);
288
+ return;
289
+ }
290
+ if (!options.yes) {
291
+ const force = !!options.force;
292
+ const dirtyTail = force
293
+ ? ` AND remove ${plan.totalDirtyCount} dirty file(s) (--force)`
294
+ : "";
295
+ const confirmed = await realConfirm(`About to remove ${plan.totalCleanCount} clean file(s)${dirtyTail} and flip '${target.companySlug}' to syncMode='shared'. Proceed?`);
296
+ if (!confirmed) {
297
+ console.log("Cancelled. No changes applied.");
298
+ return;
299
+ }
300
+ }
301
+ // Re-read the journal at apply time to avoid races with concurrent syncs.
302
+ const journal = readJournal(target.companySlug);
303
+ const result = await applyNarrow({
304
+ companySlug: target.companySlug,
305
+ membershipId: target.membership.membershipKey,
306
+ plan,
307
+ journal,
308
+ vaultClient: client,
309
+ force: !!options.force,
310
+ });
311
+ console.log(chalk.green("✓"), `Narrow applied for ${chalk.bold(target.companySlug)}:`);
312
+ console.log(chalk.dim(` removed: ${result.cleanDeleted} clean (${formatBytes(result.cleanBytes)})${result.dirtyDeleted > 0
313
+ ? ` + ${result.dirtyDeleted} dirty (${formatBytes(result.dirtyBytes)})`
314
+ : ""}`));
315
+ console.log(chalk.dim(` tombstoned: ${result.tombstoned} journal entr${result.tombstoned === 1 ? "y" : "ies"}`));
316
+ console.log(chalk.dim(` syncMode: all → ${result.newConfig.syncMode}` +
317
+ (result.newConfig.updatedAt ? ` (updatedAt: ${result.newConfig.updatedAt})` : "")));
318
+ console.log(chalk.dim(` audit: server-side MEMBERSHIP_SYNC_CONFIG_CHANGED row written by PUT /v1/memberships/${target.membership.membershipKey}/sync-config`));
319
+ }
320
+ catch (err) {
321
+ console.error(chalk.red("✗ sync narrow failed:"), err instanceof Error ? err.message : String(err));
322
+ process.exit(1);
323
+ }
324
+ });
325
+ }
326
+ //# sourceMappingURL=sync-narrow.js.map
327
+ //# debugId=a6c1e2b9-5d9a-57b2-a80b-adfdbce88206
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
5
 
6
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="2fc192b3-daa6-5939-a152-1422a7908e87")}catch(e){}}();
6
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a9b69c08-39a6-58c3-a4b6-a309d0ab8254")}catch(e){}}();
7
7
  import { Command } from "commander";
8
8
  import { initSentry, Sentry } from "./sentry.js";
9
9
  import { registerAddCommand } from "./commands/add.js";
@@ -11,6 +11,8 @@ import { registerSyncCommand } from "./commands/sync.js";
11
11
  import { registerListCommand } from "./commands/list.js";
12
12
  import { registerUpdateCommand } from "./commands/update.js";
13
13
  import { registerCloudCommands } from "./commands/cloud.js";
14
+ import { registerSyncModeCommand } from "./commands/sync-mode.js";
15
+ import { registerSyncNarrowCommand } from "./commands/sync-narrow.js";
14
16
  import { registerCloudProvisionCommands } from "./commands/cloud-provision.js";
15
17
  import { registerCloudDemoteCommands } from "./commands/cloud-demote.js";
16
18
  import { registerLoginCommand } from "./commands/login.js";
@@ -27,6 +29,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
27
29
  import { registerRunCommand } from "./commands/run.js";
28
30
  import { registerGroupsCommand } from "./commands/groups.js";
29
31
  import { registerFilesCommand } from "./commands/files.js";
32
+ import { registerFilesBrowseCommands } from "./commands/files-browse.js";
30
33
  import { registerMembersCommand } from "./commands/members.js";
31
34
  import { registerFeedbackCommand } from "./commands/feedback.js";
32
35
  import { registerMeetingsCommand } from "./commands/meetings.js";
@@ -77,6 +80,8 @@ const syncCmd = program
77
80
  .command("sync")
78
81
  .description("Cloud sync commands — sync HQ to S3 for mobile access");
79
82
  registerCloudCommands(syncCmd);
83
+ registerSyncModeCommand(syncCmd);
84
+ registerSyncNarrowCommand(syncCmd);
80
85
  // Cloud provisioning subcommand group (entity + bucket + initial sync)
81
86
  // Distinct from `hq sync` which assumes provisioning has already happened.
82
87
  const cloudCmd = program
@@ -98,7 +103,10 @@ registerRunCommand(program);
98
103
  // Groups management (subcommand group — hq groups create|delete|add|remove|list|members)
99
104
  registerGroupsCommand(program);
100
105
  // Files ACL management (subcommand group — hq files share|unshare|acl)
101
- registerFilesCommand(program);
106
+ // `registerFilesCommand` returns the `files` group so we can attach the
107
+ // browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
108
+ const filesCmd = registerFilesCommand(program);
109
+ registerFilesBrowseCommands(filesCmd);
102
110
  // Membership management (subcommand group — hq members invite|list|revoke)
103
111
  registerMembersCommand(program);
104
112
  // Onboarding (top-level — Cognito + vault-service provisioning)
@@ -129,4 +137,4 @@ registerSignalsCommand(program);
129
137
  }
130
138
  })();
131
139
  //# sourceMappingURL=index.js.map
132
- //# debugId=2fc192b3-daa6-5939-a152-1422a7908e87
140
+ //# debugId=a9b69c08-39a6-58c3-a4b6-a309d0ab8254
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure helper for `hq sync narrow` (US-007).
3
+ *
4
+ * Walks a local directory tree under `<hqRoot>/companies/{slug}/`, classifies
5
+ * each file against a prospective `shared`-mode prefix set, and groups the
6
+ * results into three buckets:
7
+ *
8
+ * - **staying** — file is covered by the prospective prefix set; survives.
9
+ * - **clean** — file is NOT covered (orphan) AND is provably unchanged
10
+ * since the last journal sync (safe to delete).
11
+ * - **dirty** — file is NOT covered (orphan) AND has been locally
12
+ * modified since the last sync (sacred — abort without
13
+ * `--force`).
14
+ *
15
+ * The clean-vs-dirty rule mirrors hq-cloud's implicit-shrink path
16
+ * (`scope-shrink.ts::classifyOrphan`) verbatim so the two narrowing paths —
17
+ * implicit (next pull after sync-mode flip) and explicit (`hq sync narrow
18
+ * --apply`) — agree on what "dirty" means. See US-000 Task 3 in
19
+ * `companies/indigo/projects/hq-sync-browse-vs-sync/references.md`.
20
+ *
21
+ * Pure: no network. Reads the journal off `SyncJournal` (caller passes it
22
+ * in), stats + hashes files on disk — no journal mutations and no remote
23
+ * calls happen here. The CLI orchestrator (`sync-narrow.ts`) is responsible
24
+ * for the destructive side effects (delete, tombstone, PUT sync-config).
25
+ */
26
+ import { type SyncJournal } from "@indigoai-us/hq-cloud";
27
+ export type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
28
+ export interface NarrowFile {
29
+ /** Path relative to `hqRoot` (matches journal key + S3 key naming). */
30
+ relPath: string;
31
+ /** Absolute path on disk (convenience for the CLI delete loop). */
32
+ absPath: string;
33
+ /** Size in bytes from the local stat. 0 for symlinks (lstat size is the link, not target). */
34
+ bytes: number;
35
+ }
36
+ export interface NarrowDirtyFile extends NarrowFile {
37
+ reason: DirtyReason;
38
+ }
39
+ export interface NarrowPlan {
40
+ /** Files covered by `prospectivePrefixSet` — survive the narrow. */
41
+ staying: NarrowFile[];
42
+ /** Orphan files that are journal-clean — safe to delete on `--apply`. */
43
+ clean: NarrowFile[];
44
+ /** Orphan files that are locally dirty — block `--apply` unless `--force`. */
45
+ dirty: NarrowDirtyFile[];
46
+ totalStayingCount: number;
47
+ totalCleanCount: number;
48
+ totalCleanBytes: number;
49
+ totalDirtyCount: number;
50
+ totalDirtyBytes: number;
51
+ }
52
+ export interface BuildNarrowPlanInput {
53
+ /** Absolute HQ root path (the dir containing `companies/`). */
54
+ hqRoot: string;
55
+ /** Company slug — used to derive the walk root `<hqRoot>/companies/<slug>/`. */
56
+ companySlug: string;
57
+ /**
58
+ * Coalesced prospective `shared`-mode prefix set (the result of running
59
+ * the caller's explicit grants through `coalescePrefixes`). Prefixes are
60
+ * hq-root-relative (e.g. `companies/indigo/meetings/`).
61
+ */
62
+ prospectivePrefixSet: readonly string[];
63
+ /**
64
+ * Active per-company journal. Hash + mtime comparisons key off
65
+ * `journal.files[relPath]`.
66
+ */
67
+ journal: SyncJournal;
68
+ }
69
+ /**
70
+ * Build a NarrowPlan by walking `<hqRoot>/companies/<slug>/` and classifying
71
+ * each regular file against `prospectivePrefixSet` + `journal`.
72
+ *
73
+ * Walk semantics:
74
+ * - Recursive `fs.readdirSync(... withFileTypes)`.
75
+ * - Symlinks are RECORDED but never followed (`lstat`, not `stat`), matching
76
+ * the `share()`/sync engine's contract.
77
+ * - Hidden files + `.DS_Store` + `node_modules` are walked as-is — the
78
+ * caller is expected to point this at HQ content, where `.hqignore`
79
+ * filtering happens upstream of the journal. The journal already
80
+ * reflects what's been pushed/pulled, so `not-in-journal` is the
81
+ * classifier signal for "we didn't put this here".
82
+ * - If the walk root is missing entirely, returns an empty plan (rather
83
+ * than throwing) — a freshly-flipped membership may not have synced any
84
+ * files yet.
85
+ */
86
+ export declare function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan;
87
+ /**
88
+ * Render the dry-run summary as plain text (no chalk — keep it pure). The
89
+ * CLI wrapper can colorize lines afterwards if desired.
90
+ */
91
+ export declare function formatNarrowPlanSummary(plan: NarrowPlan): string;
92
+ /** Human-readable byte counts with a fixed unit ladder. */
93
+ export declare function formatBytes(n: number): string;
94
+ //# sourceMappingURL=local-tree-diff.d.ts.map