diff --git a/README.md b/README.md
index 29386c1a..ee78dc4c 100644
--- a/README.md
+++ b/README.md
@@ -119,11 +119,12 @@ hyp query sql "select count(*) from ai_gateway_messages" --remote
```
**Privacy review before anything ships.** Nothing is forwarded immediately.
-The first sync (which includes backfilled history) waits until at least
-11:59pm local time on the day you enroll, and the login prints the exact
-deadline. Before then, open Claude or Codex and run the `hypaware-privacy`
-skill to review what will ship, mark directories ignore / local-only / sync,
-and purge anything sensitive.
+The first sync (which includes backfilled history) waits until 11:59pm local
+time on the day you enroll, and the login prints the exact deadline. Before
+then, open Claude or Codex and run the `hypaware-privacy` skill to review what
+will ship, mark directories ignore / local-only / sync, and purge anything
+sensitive. Done reviewing, or want to see data on the server sooner? `hyp sync`
+shows what would leave and sends it once you confirm.
Useful login flags: `--no-forward` signs in for remote queries only (no
enrollment), `--no-browser` prints the sign-in URL instead of opening one,
diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md
index a4ddfb3b..330e4b7d 100644
--- a/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md
+++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md
@@ -57,7 +57,7 @@ hyp status --json # daemon running? enrolled (a central sink present)?
hyp query status # cache state and last refresh
```
-Then run the enumeration query (Step 3) **twice, a short interval apart** (say ~30-60s). If the per-directory `rows` counts are still climbing, backfill is still landing: **warn the user and offer to wait** until counts stabilize before proposing any markings. Surveying mid-backfill risks marking against an incomplete picture. There is no deadline pressure here - the first-sync hold gives hours.
+Then run the enumeration query (Step 3) **twice, a short interval apart** (say ~30-60s). If the per-directory `rows` counts are still climbing, backfill is still landing: **warn the user and offer to wait** until counts stabilize before proposing any markings. Surveying mid-backfill risks marking against an incomplete picture. There is no deadline pressure here - the first-sync hold gives hours. Note the user can also end that window early at any time with `hyp sync` (it prints what would leave and asks first), so if they say they are in a hurry, finishing the review is what unblocks them, not waiting.
## Step 3 - Survey the captured directories, then sample content (R4 applies)
@@ -137,6 +137,6 @@ hyp policy set
ignore && hyp purge
## After the review
-- Nothing you did contacts the server. At the deadline, the hold expires and export begins: `ignore`d data was never recorded (or was purged), `local-only` rows are withheld at the export seam, and everything else - the `sync` directories and anything left at the default - ships, backfill included.
+- Nothing you did contacts the server. At the deadline - or sooner, if the user runs `hyp sync` and confirms the prompt - the hold expires and export begins: `ignore`d data was never recorded (or was purged), `local-only` rows are withheld at the export seam, and everything else - the `sync` directories and anything left at the default - ships, backfill included.
- Check the pending deadline any time with `hyp status` (it shows the first-sync deadline while the hold is live).
- Re-running this skill later is safe and idempotent; already-decided directories drop out of the survey.
diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md
index 21274a18..aa291d71 100644
--- a/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md
+++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md
@@ -85,7 +85,7 @@ hyp status --json # daemon running? enrolled (a central sink present)?
hyp query status # cache state and last refresh
```
-Then run the enumeration query (Step 3) **twice, a short interval apart** (say ~30-60s). If the per-directory `rows` counts are still climbing, backfill is still landing: **warn the user and offer to wait** until counts stabilize before proposing any markings. Surveying mid-backfill risks marking against an incomplete picture. There is no deadline pressure here - the first-sync hold gives hours.
+Then run the enumeration query (Step 3) **twice, a short interval apart** (say ~30-60s). If the per-directory `rows` counts are still climbing, backfill is still landing: **warn the user and offer to wait** until counts stabilize before proposing any markings. Surveying mid-backfill risks marking against an incomplete picture. There is no deadline pressure here - the first-sync hold gives hours. Note the user can also end that window early at any time with `hyp sync` (it prints what would leave and asks first), so if they say they are in a hurry, finishing the review is what unblocks them, not waiting.
## Step 3 - Survey the captured directories, then sample content (R4 applies)
@@ -165,6 +165,6 @@ hyp policy set ignore && hyp purge
## After the review
-- Nothing you did contacts the server. At the deadline, the hold expires and export begins: `ignore`d data was never recorded (or was purged), `local-only` rows are withheld at the export seam, and everything else - the `sync` directories and anything left at the default - ships, backfill included.
+- Nothing you did contacts the server. At the deadline - or sooner, if the user runs `hyp sync` and confirms the prompt - the hold expires and export begins: `ignore`d data was never recorded (or was purged), `local-only` rows are withheld at the export seam, and everything else - the `sync` directories and anything left at the default - ships, backfill included.
- Check the pending deadline any time with `hyp status` (it shows the first-sync deadline while the hold is live).
- Re-running this skill later is safe and idempotent; already-decided directories drop out of the survey.
diff --git a/hypaware-core/plugins-workspace/format-parquet/src/index.js b/hypaware-core/plugins-workspace/format-parquet/src/index.js
index bdb4dc11..fea84a54 100644
--- a/hypaware-core/plugins-workspace/format-parquet/src/index.js
+++ b/hypaware-core/plugins-workspace/format-parquet/src/index.js
@@ -31,7 +31,7 @@ const DEFAULT_MAX_CLUSTER_KEYS = 16
const DEFAULT_MAX_ROWS_PER_GROUP = 50_000
// Hard ceiling on how many estimated row bytes accumulate in one in-memory
// group before it is written out as a row group and freed. This is the knob
-// that bounds peak heap during a sink force: the encoder never holds more than
+// that bounds peak heap during a forced sync: the encoder never holds more than
// ~one group (plus its columnar copy) at once, instead of materializing the
// whole partition. Independent of blob size, so a fat-`tools` partition cannot
// push a group into the gigabytes.
@@ -183,7 +183,7 @@ async function encodePartition(partition, ctx, settings) {
// Derive a stable schema from the declared column types (not from the
// data) so we can write row groups incrementally: never holding more
// than one cluster group of rows (plus its columnar copy) in memory.
- // This is what stops `hyp sink force` on a large partition from OOMing
+ // This is what stops `hyp sync` on a large partition from OOMing
// while materializing the whole partition at once.
const schema = schemaFromColumnData({ columnData: rowsToColumnSources(columns, []) })
const writer = new ByteWriter()
diff --git a/hypaware-core/smoke/flows/local_parquet_export.js b/hypaware-core/smoke/flows/local_parquet_export.js
index 063989aa..b4ae7cd5 100644
--- a/hypaware-core/smoke/flows/local_parquet_export.js
+++ b/hypaware-core/smoke/flows/local_parquet_export.js
@@ -27,7 +27,7 @@ import { dispatch } from '../../../src/core/cli/dispatch.js'
* ENOTDIR. The driver then routes the failed batch
* into `/sinks/broken/outbox/.json`.
*
- * The forced tick is driven through `hyp sink force` so the CLI
+ * The forced tick is driven through `hyp sync` so the CLI
* surface is exercised, not just the in-process driver. Dispatch is
* handed the daemon's runtime via `opts.kernel` because Phase 7 does
* not yet wire config-driven sink instantiation. The smoke owns the
@@ -40,7 +40,7 @@ import { dispatch } from '../../../src/core/cli/dispatch.js'
* - The good sink produces a Parquet file decodable by
* `parquetReadObjects` containing the captured log row.
* - The broken sink's failure lands in the outbox.
- * - `hyp sink force good` exits 0 and the report mentions the
+ * - `hyp sync --yes` exits 0 and the report mentions the
* instance as `exported`.
* - Daemon self-telemetry includes `source.start` (otlp),
* `sink.tick`, `sink.export_batch` (status=ok for `good`, !=ok
@@ -144,11 +144,13 @@ export async function run({ harness, expect }) {
(v) => v !== undefined && v.kind === 'blob',
)
- // ----- Drive the forced tick through the CLI (`hyp sink force`) -----
+ // ----- Drive the forced tick through the CLI (`hyp sync`) -----
+ // `--yes` because the smoke has no TTY and `hyp sync` always confirms
+ // before exporting (LLP 0101 #no-release).
const forceStdout = makeBuf()
const forceStderr = makeBuf()
const forceCode = await dispatch(
- ['sink', 'force'],
+ ['sync', '--yes'],
{
stdout: forceStdout,
stderr: forceStderr,
@@ -157,18 +159,18 @@ export async function run({ harness, expect }) {
},
)
expect.that(
- `dispatch: hyp sink force exited 0 (stderr=${forceStderr.text()})`,
+ `dispatch: hyp sync exited 0 (stderr=${forceStderr.text()})`,
forceCode,
(v) => v === 0,
)
const forceOut = forceStdout.text()
expect.that(
- 'stdout: hyp sink force reported the good instance as exported',
+ 'stdout: hyp sync reported the good instance as exported',
forceOut,
(v) => typeof v === 'string' && /good: exported/.test(v),
)
expect.that(
- 'stdout: hyp sink force reported the broken instance as partial (failed mkdir)',
+ 'stdout: hyp sync reported the broken instance as partial (failed mkdir)',
forceOut,
(v) => typeof v === 'string' && /broken: (partial|failed)/.test(v),
)
diff --git a/llp/0100-enrollment-privacy-review.spec.md b/llp/0100-enrollment-privacy-review.spec.md
index cc04e104..303c4064 100644
--- a/llp/0100-enrollment-privacy-review.spec.md
+++ b/llp/0100-enrollment-privacy-review.spec.md
@@ -101,10 +101,12 @@ Its job, in order:
## Requirements {#requirements}
- **R1.** An attended enrolling login MUST print the first-sync deadline as an
- absolute local time and the skill invocation hint, and MUST state that the
- first sync includes backfilled history. A non-TTY enrolling login gets the
- same hold and the same message on stderr; nothing prompts
- ([LLP 0063 D3](./0063-login-auto-provision-forward-sink.decision.md#d3)
+ absolute local time **with its time zone**, the skill invocation hint, and
+ the verb that ends the window early (`hyp sync`), and MUST state that the
+ first sync includes backfilled history. A deadline the reader cannot place
+ on a clock, or cannot act on, is a countdown rather than a choice. A non-TTY
+ enrolling login gets the same hold and the same message on stderr; nothing
+ prompts ([LLP 0063 D3](./0063-login-auto-provision-forward-sink.decision.md#d3)
stands).
- **R1a.** The enrolling login's destination surfaces - the forwarding line and
the privacy block - MUST name the server by its configured target name and
@@ -120,9 +122,14 @@ Its job, in order:
paths (`already connected to `, the replaced-identity note) still
print origins, where the origin is the fact the user must act on. Revisit if
the server root becomes a browsable landing page.
-- **R2.** No export tick may run before the deadline
- ([LLP 0101](./0101-first-sync-review-window.decision.md)); the hold MUST be
- written before `enrollCentralSink` so no daemon tick can precede it.
+- **R2.** *(amended 2026-07-27, [LLP 0101 #no-release](./0101-first-sync-review-window.decision.md#no-release))*
+ No export tick may run before the deadline **except one the user releases
+ through an interactive, unscoped `hyp sync` whose plan named every
+ destination the release unblocks**; the hold MUST be written before
+ `enrollCentralSink` so no daemon tick can precede it, and the daemon MUST
+ NOT clear the marker itself. An instance-scoped run and `--yes` MUST refuse
+ to release, since the hold is driver-wide and their consent is not.
+ Originally: "No export tick may run before the deadline."
- **R3.** The skill MUST opt its own session out of capture
([LLP 0066](./0066-session-opt-out.spec.md)) as its first action and verify
success; on failure it MUST say so and continue only with explicit user
diff --git a/llp/0101-first-sync-review-window.decision.md b/llp/0101-first-sync-review-window.decision.md
index 19861809..e83c2518 100644
--- a/llp/0101-first-sync-review-window.decision.md
+++ b/llp/0101-first-sync-review-window.decision.md
@@ -9,8 +9,10 @@
> On an attended enrolling login, the sink driver exports nothing until a
> **printed, absolute deadline**: the next local 11:59pm, rolled to the
-> following day when that is less than four hours away. No early release, no
-> extension. Supersedes the pick-pending marker of
+> following day when that is less than four hours away. The deadline is the
+> latest the first sync can happen, not the earliest: a confirmed `hyp sync`
+> ends the window early (amended 2026-07-27, see [#no-release](#no-release)).
+> No extension. Supersedes the pick-pending marker of
> [LLP 0093](./0093-pick-pending-export-hold.decision.md), generalizing its
> enforcement point from a 10-minute picker guard to an hours-long review
> window.
@@ -60,12 +62,52 @@ deadline; `createSinkDriver.tick` exports nothing while `now < deadline`.**
fleet rollout by hours would be the surprise in the other direction.
Re-logins hold nothing: the daemon is already forwarding, so there is no
"first" sync to defer.
-- **No early release** {#no-release}: rejected a release verb ("sync now")
- and release-on-review-completion. The hold simply runs to its deadline,
- even for a user who finished the review in ten minutes. This keeps the
- printed message unconditionally true, needs no completion signal from the
- skill, and costs only latency on a machine that was not forwarding
- yesterday either.
+- **Release only by confirmed, attended request** {#no-release}
+ *(amended 2026-07-27; the original text is kept below it)*: `hyp sync`
+ prints what would leave, warns that the window is open and that sent
+ history cannot be un-sent, and on an explicit `y` clears the marker and
+ exports. Release-on-review-completion stays rejected: it would need a
+ completion signal from the skill, and a skill that finished is not the
+ same as a user who decided. The daemon never clears the marker, so an
+ unattended machine still waits out the full window.
+
+ Two shapes of `hyp sync` cannot release, because the consent they carry is
+ narrower than what the release does:
+
+ - **Instance-scoped** (`hyp sync `) refuses while a hold is live.
+ The hold is driver-wide (`#hold`), so a plan built from one named handle
+ omits every destination the release would unblock; confirming it would
+ forward the others unseen. Releasing is all-or-nothing because the hold
+ is.
+ - **`--yes`** refuses while a hold is live. "Attended" is the operative
+ word: a provisioning script is not a person, and its destination list
+ scrolls past in a log nobody reads. `--yes` still works for ordinary
+ syncs; what it must not buy is somebody's review window.
+
+ A release that cannot be completed (an unlink failure) is an error, not a
+ quiet no-op: the marker survives, the driver holds the tick, and the
+ command must say so rather than exit 0 having sent nothing.
+
+ What the amendment concedes is the third clause of the original: the cost
+ is *not* only latency. On an attended onboarding the hold blocks the
+ demonstration that the product works at all, and the person running it has
+ no way to say "I have seen enough". Worse, the window is sized for a review
+ (`hypaware-privacy`) too slow to run in the meeting the hold was scoped to
+ (`#which` holds the attended lane and lets unattended `hyp join` forward
+ immediately, which is backwards for exactly this case). Nothing in
+ [LLP 0069](./0069-local-only-dir-selection.spec.md) R6 is given up: R6
+ forbids a *silent* first forward, and an explicit confirmation naming the
+ destination is the consent it exists to obtain.
+
+ The first clause survives with a wording change: the printed message says
+ "no later than ", so it stays unconditionally true.
+
+ > *Original (2026-07-13):* **No early release**: rejected a release verb
+ > ("sync now") and release-on-review-completion. The hold simply runs to
+ > its deadline, even for a user who finished the review in ten minutes.
+ > This keeps the printed message unconditionally true, needs no completion
+ > signal from the skill, and costs only latency on a machine that was not
+ > forwarding yesterday either.
- **No extension**: bounded always. A hold that can be pushed out is a kill
switch with extra steps ([LLP 0093 #bounded](./0093-pick-pending-export-hold.decision.md#bounded)).
@@ -77,7 +119,13 @@ deadline; `createSinkDriver.tick` exports nothing while `now < deadline`.**
- Live capture during the window lands in the cache and ships at the deadline
unless marked; watermarks ([LLP 0040](./0040-incremental-sink-reads.design.md))
make the first post-deadline tick a plain catch-up.
-- `hyp status` shows the pending deadline (LLP 0100 R9).
+- `hyp status` shows the pending deadline (LLP 0100 R9), and names `hyp sync`
+ beside it: a countdown the reader cannot act on is where the original
+ decision's cost was hiding.
+- The three surfaces that print the deadline (the login message, the wizard's
+ privacy narration, `hyp status`) all name `hyp sync`, and the formatted
+ deadline carries its time zone - an absolute time is only memorable if the
+ reader knows which clock it is on.
- LLP 0093's pick-pending semantics retire with the picker
([LLP 0102](./0102-skill-replaces-enrollment-picker.decision.md)); its
driver-side hold machinery is reused, resized, and renamed.
diff --git a/src/core/cli/confirm.js b/src/core/cli/confirm.js
new file mode 100644
index 00000000..6dffa411
--- /dev/null
+++ b/src/core/cli/confirm.js
@@ -0,0 +1,55 @@
+// @ts-check
+
+import process from 'node:process'
+import readline from 'node:readline/promises'
+
+import { isTty } from './stdio.js'
+
+/**
+ * @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js'
+ */
+
+/**
+ * Ask a y/N question on the interactive terminal. The question goes to
+ * stderr, not stdout, so a command's machine-readable output stays clean
+ * for a caller that pipes it.
+ *
+ * Anything other than `y`/`yes` is a no: the default has to be the safe
+ * one for a verb nobody can undo.
+ *
+ * @param {CommandRunContext} ctx
+ * @param {string} question rendered verbatim, including its `[y/N]` suffix
+ * @returns {Promise}
+ */
+export async function askYesNo(ctx, question) {
+ const rl = readline.createInterface({
+ input: /** @type {NodeJS.ReadableStream} */ (ctx.stdin ?? process.stdin),
+ output: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (ctx.stderr)),
+ })
+ try {
+ const answer = await rl.question(question)
+ return /^y(es)?$/i.test(answer.trim())
+ } finally {
+ rl.close()
+ }
+}
+
+/**
+ * The confirmation posture `hyp purge` established and every irreversible
+ * verb has followed since (LLP 0104, LLP 0111#delete-confirm): prompt on an
+ * interactive TTY, require an explicit `--yes` anywhere else. A command that
+ * cannot ask must not assume.
+ *
+ * Returning a discriminant rather than a boolean keeps the refusal text with
+ * the command: "refusing to purge" and "refusing to sync" name different
+ * flags in their hints, and a shared string would have to be vague about
+ * both.
+ *
+ * @param {{ ctx: CommandRunContext, yes: boolean, question: string }} opts
+ * @returns {Promise<'confirmed' | 'declined' | 'no-tty'>}
+ */
+export async function requireConfirmation({ ctx, yes, question }) {
+ if (yes) return 'confirmed'
+ if (!isTty(ctx.stdin)) return 'no-tty'
+ return (await askYesNo(ctx, question)) ? 'confirmed' : 'declined'
+}
diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js
index 9a554642..c6f7d0f8 100644
--- a/src/core/cli/core_commands.js
+++ b/src/core/cli/core_commands.js
@@ -36,7 +36,8 @@ import {
} from '../commands/daemon.js'
import { runMcp } from '../commands/mcp.js'
import { runSmoke, runVersion } from '../commands/misc.js'
-import { runSinkForce, runSinkMaintain } from '../commands/sink.js'
+import { runSinkMaintain } from '../commands/sink.js'
+import { runSync } from '../commands/sync.js'
import { runInit } from '../commands/init.js'
import { runJoin, runLeave } from '../commands/central.js'
import { runPurge } from '../commands/purge.js'
@@ -406,17 +407,17 @@ function buildCoreCommands(registry) {
usage: 'hyp daemon restart',
run: runDaemonRestart,
},
+ {
+ name: 'sync',
+ summary: 'Send captured data to its destinations now, after confirming what leaves',
+ usage: 'hyp sync [instance] [--yes] [--dry-run]',
+ run: runSync,
+ },
makeGroupCommand({
registry,
name: 'sink',
- summary: 'Manage sink instances (force, maintain)',
+ summary: 'Maintain sink instances (to export now, see `hyp sync`)',
}),
- {
- name: 'sink force',
- summary: 'Force the sink driver to fire a tick now (optionally for one instance)',
- usage: 'hyp sink force [instance]',
- run: runSinkForce,
- },
{
name: 'sink maintain',
summary: 'Run export maintenance (snapshot expiration; data-file compaction with --compact) on table-format sinks',
diff --git a/src/core/cli/remote_commands.js b/src/core/cli/remote_commands.js
index 0876d128..be22bac7 100644
--- a/src/core/cli/remote_commands.js
+++ b/src/core/cli/remote_commands.js
@@ -202,7 +202,13 @@ const FIRST_SYNC_RULE = '─'.repeat(62)
* this block is deliberately self-contained (stderr, while the forwarding line
* is stdout - redirect either and the other must still stand on its own).
*
- * @ref LLP 0100#requirements [implements]: R1 - absolute deadline, backfill statement, skill hint, same on TTY and non-TTY
+ * The deadline is the latest the first sync can happen, not the earliest
+ * (LLP 0101 #no-release, as amended), so it reads "no later than" and the
+ * block names the verb that ends the window early. A deadline with no way to
+ * act on it is a countdown, which is how this message read in the onboarding
+ * session that prompted both changes.
+ *
+ * @ref LLP 0100#requirements [implements]: R1 - absolute deadline with its zone, backfill statement, skill hint, release verb, same on TTY and non-TTY
* @ref LLP 0100#requirements [implements]: R1a - name the server, never its URL, and name the command that maps the name back
* @param {number} deadlineMs
* @param {string} serverName
@@ -214,12 +220,15 @@ export function firstSyncHoldMessage(deadlineMs, serverName) {
`${FIRST_SYNC_RULE}\n` +
' PRIVACY - review before first sync\n' +
'\n' +
- ` first sync to the '${serverName}' server is ${formatFirstSyncDeadline(deadlineMs)}\n` +
+ ` first sync to the '${serverName}' server is no later than\n` +
+ ` ${formatFirstSyncDeadline(deadlineMs)}\n` +
' and includes your backfilled history\n' +
" (run 'hyp remote list' to see that server's URL)\n" +
'\n' +
' to review what ships before then,\n' +
' open Claude or Codex and run the hypaware-privacy skill\n' +
+ '\n' +
+ ' to send it sooner, run: hyp sync\n' +
`${FIRST_SYNC_RULE}\n` +
'\n'
)
diff --git a/src/core/cli/report_commands.js b/src/core/cli/report_commands.js
index 74617b22..1cf96c92 100644
--- a/src/core/cli/report_commands.js
+++ b/src/core/cli/report_commands.js
@@ -4,10 +4,10 @@ import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
-import readline from 'node:readline/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
+import { askYesNo } from './confirm.js'
import { readObservabilityEnv } from '../observability/env.js'
import { effectiveDefaultRemote, effectiveRemotes } from '../remote/builtin_remotes.js'
import {
@@ -296,7 +296,10 @@ export async function runReportDelete(argv, ctx) {
ctx.stderr.write('error: refusing to delete without confirmation - pass --yes to delete non-interactively\n')
return 2
}
- const ok = await confirm(ctx, `${kind}/${period}/${id}`)
+ const ok = await askYesNo(
+ ctx,
+ `Delete report ${kind}/${period}/${id} for the whole org? This cannot be undone. [y/N] `
+ )
if (!ok) {
ctx.stdout.write('delete cancelled\n')
return 0
@@ -500,20 +503,3 @@ async function fileExists(p) {
}
}
-/**
- * @param {CommandRunContext} ctx
- * @param {string} what
- * @returns {Promise}
- */
-async function confirm(ctx, what) {
- const rl = readline.createInterface({
- input: /** @type {NodeJS.ReadableStream} */ (ctx.stdin ?? process.stdin),
- output: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (ctx.stderr)),
- })
- try {
- const answer = await rl.question(`Delete report ${what} for the whole org? This cannot be undone. [y/N] `)
- return /^y(es)?$/i.test(answer.trim())
- } finally {
- rl.close()
- }
-}
diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js
index 943b5499..1e0ef36c 100644
--- a/src/core/cli/wizard/index.js
+++ b/src/core/cli/wizard/index.js
@@ -328,7 +328,9 @@ async function narratePrivacyIfTeamPath(opts) {
'Nothing has been uploaded yet - nothing leaves this machine before\n' +
`${formatFirstSyncDeadline(deadline)}. That first sync includes your imported history.\n` +
'To review or exclude anything before then, run the hypaware-privacy\n' +
- 'skill in Claude or Codex. `hyp status` shows the countdown.\n'
+ 'skill in Claude or Codex. `hyp status` shows the countdown.\n' +
+ 'To send it sooner, run `hyp sync`: it shows what would leave and asks\n' +
+ 'before sending anything.\n'
)
}
diff --git a/src/core/commands/purge.js b/src/core/commands/purge.js
index e900eacf..9f1599b1 100644
--- a/src/core/commands/purge.js
+++ b/src/core/commands/purge.js
@@ -3,8 +3,8 @@
import { createHash } from 'node:crypto'
import path from 'node:path'
import process from 'node:process'
-import readline from 'node:readline/promises'
+import { askYesNo } from '../cli/confirm.js'
import { parseCommandArgv } from '../cli/verb_codec.js'
import { isTty } from '../cli/stdio.js'
import { Attr, getLogger, withSpan } from '../observability/index.js'
@@ -58,7 +58,10 @@ export async function runPurge(argv, ctx) {
)
return 2
}
- const ok = await confirm(ctx, describeTarget(target))
+ const ok = await askYesNo(
+ ctx,
+ `Permanently delete ${describeTarget(target)} from the local cache? [y/N] `
+ )
if (!ok) {
ctx.stdout.write('purge cancelled\n')
return 0
@@ -177,26 +180,6 @@ function hashTargetToken(target) {
return createHash('sha256').update(token).digest('hex').slice(0, 16)
}
-/**
- * Interactive y/N confirmation for the destructive verb. Only reached when
- * stdin is a TTY (the non-TTY path requires `--yes`).
- *
- * @param {CommandRunContext} ctx
- * @param {string} what
- * @returns {Promise}
- */
-async function confirm(ctx, what) {
- const rl = readline.createInterface({
- input: /** @type {NodeJS.ReadableStream} */ (ctx.stdin ?? process.stdin),
- output: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (ctx.stderr)),
- })
- try {
- const answer = await rl.question(`Permanently delete ${what} from the local cache? [y/N] `)
- return /^y(es)?$/i.test(answer.trim())
- } finally {
- rl.close()
- }
-}
/**
* @param {string[]} argv
diff --git a/src/core/commands/sink.js b/src/core/commands/sink.js
index 732ca4a3..b2b02199 100644
--- a/src/core/commands/sink.js
+++ b/src/core/commands/sink.js
@@ -1,62 +1,13 @@
// @ts-check
-import { readObservabilityEnv } from '../observability/env.js'
import { parseCommandArgv } from '../cli/verb_codec.js'
/**
* @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js'
- * @import { ExtendedQueryStorageService } from '../../../src/core/cache/types.js'
* @import { ExportMaintenanceDatasetReport } from '../../../hypaware-core/plugins-workspace/format-iceberg/src/types.js'
* @import { ExtendedSinkRegistry } from '../../../src/core/registry/types.js'
*/
-/**
- * `hyp sink force [instance]`
- *
- * Drives one tick of the sink driver immediately, bypassing each
- * sink's cron schedule. The optional `instance` argument restricts
- * the tick to a single sink (useful when an operator just wants to
- * flush one configured destination without waking the others.
- *
- * The driver writes the same `sink.export_batch` span and outbox
- * artifacts it does on a scheduled tick. The only difference is the
- * trigger.
- *
- * @param {string[]} argv
- * @param {CommandRunContext} ctx
- */
-export async function runSinkForce(argv, ctx) {
- const instance = argv[0]
- const obsEnv = readObservabilityEnv(ctx.env)
- const { createSinkDriver } = await import('../sinks/driver.js')
- const driver = createSinkDriver({
- sinkRegistry: /** @type {ExtendedSinkRegistry} */ (ctx.sinks),
- queryRegistry: ctx.query,
- storage: /** @type {ExtendedQueryStorageService} */ (ctx.storage),
- stateRoot: obsEnv.stateDir,
- config: ctx.config,
- })
- const tickOpts = { now: new Date(), force: true, source: /** @type {'manual'} */ ('manual') }
- if (instance) /** @type {any} */ (tickOpts).sinkInstance = instance
- const report = await driver.tick(tickOpts)
- if (report.sinks.length === 0) {
- if (instance) {
- ctx.stderr.write(`hyp sink force: no sink named '${instance}' was instantiated\n`)
- return 1
- }
- ctx.stdout.write('no sinks instantiated; nothing to do\n')
- return 0
- }
- for (const r of report.sinks) {
- ctx.stdout.write(
- `${r.instance}: ${r.status} (partitions=${r.partitionsExported}, bytes=${r.bytesWritten}${
- r.error ? `, error=${r.error}` : ''
- })\n`
- )
- }
- return report.sinks.some((r) => r.status === 'failed') ? 1 : 0
-}
-
/**
* `hyp sink maintain [instance] [--compact] [--dry-run]`
*
diff --git a/src/core/commands/status.js b/src/core/commands/status.js
index 5ff23cfe..eafc1307 100644
--- a/src/core/commands/status.js
+++ b/src/core/commands/status.js
@@ -382,7 +382,7 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std
// text output is unchanged.
if (report.firstSyncHoldDeadline !== null) {
stdout.write(
- ` first sync: held until ${formatFirstSyncDeadline(report.firstSyncHoldDeadline)} (review with the hypaware-privacy skill)\n`
+ ` first sync: held until ${formatFirstSyncDeadline(report.firstSyncHoldDeadline)} (review with the hypaware-privacy skill; \`hyp sync\` sends it now)\n`
)
}
diff --git a/src/core/commands/sync.js b/src/core/commands/sync.js
new file mode 100644
index 00000000..67590c84
--- /dev/null
+++ b/src/core/commands/sync.js
@@ -0,0 +1,399 @@
+// @ts-check
+
+import { requireConfirmation } from '../cli/confirm.js'
+import { parseCommandArgv } from '../cli/verb_codec.js'
+import { Attr, getLogger } from '../observability/index.js'
+import { readObservabilityEnv } from '../observability/env.js'
+import { effectiveRemotes } from '../remote/builtin_remotes.js'
+import {
+ clearFirstSyncHold,
+ firstSyncHoldMarkerPath,
+ formatFirstSyncDeadline,
+ readFirstSyncDeadline,
+} from '../usage-policy/first_sync_hold.js'
+import { readLocalOnlyEntries } from '../usage-policy/index.js'
+
+/**
+ * @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js'
+ * @import { ExtendedQueryStorageService } from '../../../src/core/cache/types.js'
+ * @import { ExtendedSinkHandle, ExtendedSinkRegistry } from '../../../src/core/registry/types.js'
+ */
+
+const USAGE = 'usage: hyp sync [instance] [--yes] [--dry-run]'
+
+/**
+ * `hyp sync [instance] [--yes] [--dry-run]`
+ *
+ * Export every configured sink now, rather than on its cron schedule. The
+ * user-facing name for the one action the driver performs; it replaced
+ * `hyp sink force`, which spelled the same tick in the driver's vocabulary
+ * instead of the user's.
+ *
+ * Two things make this more than a scheduling shortcut:
+ *
+ * 1. **It always confirms.** Not only when data leaves the machine: one
+ * unconditional rule beats a prompt whose appearance the user has to
+ * predict. `--yes` is the scripted bypass, as it is for `hyp purge`.
+ * 2. **It is the only way to end the first-sync review window early.** While
+ * that hold is live the prompt escalates (see {@link renderFirstSyncWarning})
+ * and a confirmation clears the marker.
+ *
+ * @ref LLP 0101#no-release [implements]: supersedes "no early release" - a confirmed, attended release verb
+ * @param {string[]} argv
+ * @param {CommandRunContext} ctx
+ * @returns {Promise}
+ */
+export async function runSync(argv, ctx) {
+ const log = getLogger('sync')
+ const parsed = parseCommandArgv(
+ argv,
+ {
+ type: 'object',
+ properties: {
+ instance: { type: 'string' },
+ yes: { type: 'boolean', default: false },
+ 'dry-run': { type: 'boolean', default: false },
+ },
+ positional: ['instance'],
+ },
+ { aliases: { '-y': '--yes' } }
+ )
+ if ('help' in parsed) {
+ ctx.stdout.write(`${USAGE}\n`)
+ return 0
+ }
+ if (!parsed.ok) {
+ ctx.stderr.write(`hyp sync: ${parsed.error}\n${USAGE}\n`)
+ return 2
+ }
+ const { instance, yes, 'dry-run': dryRun } =
+ /** @type {{ instance?: string, yes: boolean, 'dry-run': boolean }} */ (parsed.params)
+
+ const allHandles = /** @type {ExtendedSinkRegistry} */ (ctx.sinks).listHandles?.() ?? []
+ const handles = instance ? allHandles.filter((h) => h.instanceName === instance) : allHandles
+ if (instance && handles.length === 0) {
+ ctx.stderr.write(`hyp sync: no sink named '${instance}' was instantiated\n`)
+ const available = allHandles.map((h) => h.instanceName)
+ if (available.length > 0) ctx.stderr.write(` available: ${available.join(', ')}\n`)
+ return 1
+ }
+ if (handles.length === 0) {
+ ctx.stdout.write('no sinks instantiated; nothing to do\n')
+ return 0
+ }
+
+ const stateDir = readObservabilityEnv(ctx.env).stateDir
+ const deadline = await readFirstSyncDeadline({ stateDir })
+ const remotes = effectiveRemotes(ctx.config)
+ const destinations = handles.map((handle) => describeDestination(handle, remotes))
+
+ // Two refusals, both because the hold is driver-wide (LLP 0101 #hold)
+ // while the consent in front of it would not be. They come before the plan
+ // is rendered: a scoped plan is exactly the misleading artifact the first
+ // refusal exists to prevent, and printing "syncing now ends it early"
+ // ahead of "you cannot end it this way" reads as a contradiction.
+ //
+ // A named instance shows a plan built from that one handle, so confirming
+ // it would release every *other* destination unseen - the silent first
+ // forward the hold exists to prevent. Releasing is all-or-nothing because
+ // the hold is.
+ //
+ // `--yes` skips the plan's whole purpose. `#no-release` licenses release by
+ // "confirmed, attended request" and "an explicit `y`"; a provisioning
+ // script satisfies neither, and its destination list scrolls past in a log
+ // nobody reads. Ordinary (unheld) syncs keep `--yes` - what it must not buy
+ // is somebody's review window.
+ //
+ // `--dry-run` is exempt: it sends nothing, so showing a held machine what
+ // one destination would export is information, not consent.
+ if (deadline !== null && !dryRun) {
+ const held = `hyp sync: the first-sync review window is open until ${formatFirstSyncDeadline(deadline)}.\n`
+ if (instance) {
+ ctx.stderr.write(
+ held +
+ ` Ending it is all-or-nothing: the hold covers every destination, so releasing it\n` +
+ ` from a run that names only '${instance}' would forward the others unseen.\n` +
+ ' Run `hyp sync` with no instance to review every destination and release,\n' +
+ ' or wait for the deadline.\n'
+ )
+ return 2
+ }
+ if (yes) {
+ ctx.stderr.write(
+ held +
+ ' Ending it early takes an interactive confirmation, so --yes cannot do it.\n' +
+ ' Run `hyp sync` from a terminal, or wait for the deadline.\n'
+ )
+ return 2
+ }
+ }
+
+ ctx.stdout.write(renderPlan({ destinations, exclusions: await readExclusions(stateDir) }))
+ if (deadline !== null) ctx.stdout.write(renderFirstSyncWarning(deadline))
+
+ if (dryRun) {
+ ctx.stdout.write('\n[dry-run] nothing was sent\n')
+ return 0
+ }
+
+ const outcome = await requireConfirmation({
+ ctx,
+ yes,
+ question: deadline !== null
+ ? 'Send now and end the review window? [y/N] '
+ : `Send now to ${describeScope(destinations)}? [y/N] `,
+ })
+ if (outcome === 'no-tty') {
+ ctx.stderr.write(
+ 'error: refusing to sync without confirmation - pass --yes to send non-interactively\n'
+ )
+ return 2
+ }
+ if (outcome === 'declined') {
+ ctx.stdout.write('sync cancelled\n')
+ return 0
+ }
+
+ // Release before the tick, because the driver reads the marker itself and
+ // would otherwise hold the very export just consented to. A failed export
+ // leaves the window ended: consent was given, and the retry belongs to the
+ // ordinary schedule rather than to a window that has served its purpose.
+ //
+ // A failed unlink must not read as success. The marker survives, the driver
+ // holds the tick, and without this check the command would print nothing
+ // further and exit 0 - the exact "opposite of the truth" output this verb
+ // was written to replace.
+ if (deadline !== null) {
+ const cleared = await clearFirstSyncHold({ stateDir })
+ const stillHeld = await readFirstSyncDeadline({ stateDir })
+ if (!cleared || stillHeld !== null) {
+ ctx.stderr.write(
+ 'hyp sync: could not end the review window - the hold marker could not be removed\n' +
+ ` ${firstSyncHoldMarkerPath(stateDir)}\n` +
+ ' Nothing was sent. Check the file\'s permissions and re-run.\n'
+ )
+ return 1
+ }
+ // The one moment a machine's history becomes forwardable ahead of its
+ // deadline, and clearing the marker destroys the only on-disk evidence it
+ // happened. Without this line nothing afterwards distinguishes "the window
+ // expired" from "somebody released it".
+ log.info('sync.first_sync_hold_released', {
+ [Attr.COMPONENT]: 'cmd-sync',
+ [Attr.OPERATION]: 'sync.first_sync_hold_released',
+ hyp_deadline: new Date(deadline).toISOString(),
+ hyp_released_early_ms: deadline - Date.now(),
+ destinations: destinations.length,
+ off_machine_destinations: destinations.filter((d) => d.offMachine === true).length,
+ })
+ }
+
+ const { createSinkDriver } = await import('../sinks/driver.js')
+ const driver = createSinkDriver({
+ sinkRegistry: /** @type {ExtendedSinkRegistry} */ (ctx.sinks),
+ queryRegistry: ctx.query,
+ storage: /** @type {ExtendedQueryStorageService} */ (ctx.storage),
+ stateRoot: stateDir,
+ config: ctx.config,
+ })
+ /** @type {{ now: Date, force: true, source: 'manual', sinkInstance?: string }} */
+ const tickOpts = { now: new Date(), force: true, source: 'manual' }
+ if (instance) tickOpts.sinkInstance = instance
+ const report = await driver.tick(tickOpts)
+
+ // A hold that appeared between the check above and the tick (a concurrent
+ // enrolling login) would otherwise print an empty report and exit 0.
+ if (report.held) {
+ ctx.stderr.write(`hyp sync: nothing was sent - the sink driver is holding every tick (${report.held})\n`)
+ return 1
+ }
+
+ for (const r of report.sinks) {
+ ctx.stdout.write(
+ `${r.instance}: ${r.status} (partitions=${r.partitionsExported}, bytes=${r.bytesWritten}${
+ r.error ? `, error=${r.error}` : ''
+ })\n`
+ )
+ }
+ return report.sinks.some((r) => r.status === 'failed') ? 1 : 0
+}
+
+/**
+ * Where a sink writes, and whether that is off this machine.
+ *
+ * The driver deliberately has no notion of which sinks leave the host
+ * ([LLP 0101 #hold](../../../llp/0101-first-sync-review-window.decision.md#hold)
+ * holds all of them for exactly that reason), so this reads the instance's
+ * own config rather than inventing a registration concept for one prompt.
+ * An `http(s)` destination is off-machine on the evidence of the URL; a
+ * filesystem path is on-machine on the evidence of the path. Anything else
+ * reports `null` and the summary stays silent about it - a confirmation
+ * prompt that guesses is worse than one that admits the gap.
+ *
+ * A server is named, never spelled as a URL. R1a binds the enrolling login's
+ * surfaces by its own text, but its reason is about terminals, not about
+ * which command printed the line: any `https://` run autolinks with no way
+ * to opt out, and the server root answers `{"error":"unknown_path"}` in a
+ * browser. This prompt appears at the same moment in onboarding and would
+ * collect the same dead-link click. An origin with no configured name falls
+ * back to its host, which is still not a URL a terminal will linkify.
+ *
+ * @ref LLP 0100#requirements [constrained-by]: R1a's reason - name the server, never its URL - applied to the consent prompt R1a's text does not reach
+ * @param {ExtendedSinkHandle} handle
+ * @param {Record} remotes configured targets, name to URL
+ * @returns {{ instance: string, text: string, offMachine: boolean | null }}
+ */
+function describeDestination(handle, remotes) {
+ const config = /** @type {Record} */ (handle.config ?? {})
+ const url = config.url
+ if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
+ return { instance: handle.instanceName, text: nameServer(url, remotes), offMachine: true }
+ }
+ const dir = config.dir
+ if (typeof dir === 'string' && dir.length > 0) {
+ return { instance: handle.instanceName, text: dir, offMachine: false }
+ }
+ return { instance: handle.instanceName, text: handle.plugin ?? 'unknown destination', offMachine: null }
+}
+
+/**
+ * Render a server URL as the name the user configured for it, matching on
+ * origin so a target registered with a trailing path or slash still resolves.
+ *
+ * @param {string} url
+ * @param {Record} remotes
+ * @returns {string}
+ */
+function nameServer(url, remotes) {
+ /** @param {string} value */
+ const originOf = (value) => {
+ try {
+ return new URL(value).origin
+ } catch {
+ return null
+ }
+ }
+ const origin = originOf(url)
+ if (origin) {
+ for (const [name, target] of Object.entries(remotes ?? {})) {
+ if (typeof target?.url === 'string' && originOf(target.url) === origin) {
+ return `the '${name}' server`
+ }
+ }
+ return new URL(url).host
+ }
+ return url
+}
+
+/**
+ * Count the directories the machine-local policy lists withhold, so the
+ * prompt can state what is *not* going as well as what is.
+ *
+ * An unreadable list is reported, never swallowed and never fatal: the query
+ * seam (not this summary) is what actually withholds those rows, so a failed
+ * read changes what the user is told, not what ships.
+ *
+ * @param {string} stateDir
+ * @returns {Promise<{ localOnly: number, ignore: number } | { error: string }>}
+ */
+async function readExclusions(stateDir) {
+ try {
+ const entries = await readLocalOnlyEntries({ stateDir })
+ return {
+ localOnly: entries.filter((e) => e.class === 'local-only').length,
+ ignore: entries.filter((e) => e.class === 'ignore').length,
+ }
+ } catch (err) {
+ return { error: err instanceof Error ? err.message : String(err) }
+ }
+}
+
+/**
+ * The pre-confirmation summary: every destination, named, with the
+ * exclusions that will not travel. "Are you sure?" with nothing to be sure
+ * *about* is a keystroke, not a decision.
+ *
+ * @param {{
+ * destinations: { instance: string, text: string, offMachine: boolean | null }[],
+ * exclusions: { localOnly: number, ignore: number } | { error: string },
+ * }} args
+ * @returns {string}
+ */
+function renderPlan({ destinations, exclusions }) {
+ const width = Math.max(...destinations.map((d) => d.instance.length))
+ const lines = [`hyp sync: ${plural(destinations.length, 'destination')}\n`, '\n']
+ for (const dest of destinations) {
+ const note = dest.offMachine === true
+ ? ' (leaves this machine)'
+ : dest.offMachine === false
+ ? ' (stays on this machine)'
+ : ''
+ lines.push(` ${dest.instance.padEnd(width)} ${dest.text}${note}\n`)
+ }
+ // Naming a server instead of its URL is only safe if the name stays
+ // auditable: R1a's second half, applied here for the same reason.
+ if (destinations.some((d) => d.offMachine === true)) {
+ lines.push(" (run 'hyp remote list' to see server URLs)\n")
+ }
+ lines.push('\n')
+ if ('error' in exclusions) {
+ lines.push(` warning: could not read the local-only list (${exclusions.error});\n`)
+ lines.push(' exclusions still apply, but cannot be summarized here\n')
+ } else if (exclusions.localOnly > 0 || exclusions.ignore > 0) {
+ const parts = []
+ if (exclusions.localOnly > 0) parts.push(`${plural(exclusions.localOnly, 'directory', 'directories')} marked local-only`)
+ if (exclusions.ignore > 0) parts.push(`${plural(exclusions.ignore, 'directory', 'directories')} marked ignore`)
+ lines.push(` withholding ${parts.join(', ')}\n`)
+ } else {
+ lines.push(' no directories are marked local-only or ignore\n')
+ }
+ return lines.join('')
+}
+
+/**
+ * The escalated warning shown while the first-sync review window is open.
+ *
+ * This is the one prompt in the CLI where the user has never sent anything
+ * before, so it says so, states what confirming gives up (the rest of the
+ * window), and names the command that excludes a folder - a warning that
+ * only warns leaves the user with no move except yes or no.
+ *
+ * @ref LLP 0100#requirements [implements]: R2's review window ends by deadline or by informed consent
+ * @param {number} deadlineMs
+ * @returns {string}
+ */
+function renderFirstSyncWarning(deadlineMs) {
+ return (
+ '\n' +
+ ' FIRST SYNC - nothing has left this machine yet\n' +
+ '\n' +
+ ` Your review window runs until ${formatFirstSyncDeadline(deadlineMs)}.\n` +
+ ' Syncing now ends it early and sends your backfilled history.\n' +
+ ' What has been sent cannot be un-sent. To exclude a folder first:\n' +
+ ' hyp policy set local-only\n'
+ )
+}
+
+/**
+ * Name the scope of an ordinary (unheld) confirmation by where the data
+ * goes, so the question is answerable without scrolling back to the plan.
+ *
+ * @param {{ offMachine: boolean | null }[]} destinations
+ * @returns {string}
+ */
+function describeScope(destinations) {
+ const offMachine = destinations.filter((d) => d.offMachine === true).length
+ if (offMachine === 0) return plural(destinations.length, 'destination')
+ if (offMachine === destinations.length) return `${plural(offMachine, 'destination')} off this machine`
+ return `${plural(destinations.length, 'destination')} (${offMachine} off this machine)`
+}
+
+/**
+ * @param {number} n
+ * @param {string} singular
+ * @param {string} [pluralForm]
+ * @returns {string}
+ */
+function plural(n, singular, pluralForm = `${singular}s`) {
+ return `${n} ${n === 1 ? singular : pluralForm}`
+}
diff --git a/src/core/usage-policy/first_sync_hold.js b/src/core/usage-policy/first_sync_hold.js
index 64ecd2e7..76fde72a 100644
--- a/src/core/usage-policy/first_sync_hold.js
+++ b/src/core/usage-policy/first_sync_hold.js
@@ -50,12 +50,25 @@ export function computeFirstSyncDeadline(now = Date.now()) {
* R1) and `hyp status` (R9) render "the deadline", so the two consent
* surfaces cannot drift apart on wording.
*
+ * The zone is named. The deadline is computed in the machine's local time,
+ * but a user reading "11:59 PM" on a printed line has no way to know whether
+ * it means their zone, the server's, or UTC, and the answer decides whether
+ * they wait an hour or a day.
+ *
* @ref LLP 0100#requirements [implements]: shared formatting keeps R1's login message and R9's status line in sync
* @param {number} deadlineMs epoch ms
* @returns {string}
*/
export function formatFirstSyncDeadline(deadlineMs) {
- return new Date(deadlineMs).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+ const at = new Date(deadlineMs)
+ const stamp = at.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+ // ECMA-402 forbids mixing `timeZoneName` with the `dateStyle`/`timeStyle`
+ // shorthands, so the zone comes from a second formatter and is appended.
+ // A runtime that names no zone yields the bare stamp rather than an error.
+ const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
+ .formatToParts(at)
+ .find((part) => part.type === 'timeZoneName')?.value
+ return zone ? `${stamp} ${zone}` : stamp
}
/**
@@ -99,6 +112,32 @@ export async function writeFirstSyncHoldMarker({ stateDir, now = Date.now(), fs
return deadlineMs
}
+/**
+ * Remove the hold marker, ending the review window before its deadline.
+ *
+ * The deadline is a promise about the *latest* moment the first sync can
+ * happen, not the earliest: a user who has finished reviewing (or who never
+ * wanted the wait) can end the window by running `hyp sync` and confirming
+ * an explicit prompt. That confirmation is the consent
+ * [LLP 0069](../../../llp/0069-local-only-dir-selection.spec.md) R6 protects;
+ * what R6 forbids is a *silent* first forward, which nothing here can cause.
+ *
+ * Only ever called behind that prompt. The daemon never clears the marker,
+ * so an unattended machine still waits out the full window.
+ *
+ * @ref LLP 0101#no-release [implements]: the confirmed `hyp sync` is the one path that ends the window early
+ * @param {{ stateDir: string, fs?: typeof fsp }} opts
+ * @returns {Promise} true when a marker was removed, false when none was present
+ */
+export async function clearFirstSyncHold({ stateDir, fs = fsp }) {
+ try {
+ await fs.unlink(firstSyncHoldMarkerPath(stateDir))
+ return true
+ } catch {
+ return false
+ }
+}
+
/**
* Read the live first-sync hold deadline, or `null` when no hold applies.
* Never throws.
diff --git a/test/core/sync-command.test.js b/test/core/sync-command.test.js
new file mode 100644
index 00000000..cdc5b123
--- /dev/null
+++ b/test/core/sync-command.test.js
@@ -0,0 +1,368 @@
+// @ts-check
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { PassThrough } from 'node:stream'
+
+import { runSync } from '../../src/core/commands/sync.js'
+import {
+ firstSyncHoldMarkerPath,
+ writeFirstSyncHoldMarker,
+} from '../../src/core/usage-policy/first_sync_hold.js'
+import { writeLocalOnlyEntries } from '../../src/core/usage-policy/index.js'
+
+// `hyp sync` (LLP 0101 #no-release, as amended): the user-facing export verb
+// that replaced `hyp sink force`. What these cover is the consent gate, not
+// the tick - the driver's export path is already covered by the sink tests
+// and the local_parquet_export smoke.
+//
+// The load-bearing claims:
+// 1. Nothing exports without a confirmation, in either tier.
+// 2. Confirming during the review window is the one thing that ends it early.
+// 3. Declining, or having no TTY to ask, leaves the window intact.
+// @ref LLP 0101#no-release [tests]: the confirmed release path and its refusals
+
+/** @param {string} prefix */
+async function makeHome(prefix) {
+ const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), `hyp-sync-${prefix}-`))
+ await fs.mkdir(path.join(hypHome, 'hypaware'), { recursive: true })
+ return hypHome
+}
+
+/** @param {string} hypHome */
+function stateDir(hypHome) {
+ return path.join(hypHome, 'hypaware')
+}
+
+function captureStream() {
+ let buf = ''
+ return {
+ write(/** @type {string} */ chunk) { buf += String(chunk); return true },
+ get text() { return buf },
+ }
+}
+
+/**
+ * A sink handle the driver can tick, recording every exportBatch call so a
+ * test can assert that nothing was sent.
+ *
+ * @param {string} instanceName
+ * @param {Record} config
+ * @param {{ status?: string }} [result]
+ */
+function fakeSink(instanceName, config, result = {}) {
+ /** @type {unknown[]} */
+ const exported = []
+ return {
+ instanceName,
+ plugin: '@hypaware/fake',
+ kind: 'blob',
+ config,
+ exported,
+ sink: {
+ async exportBatch(/** @type {unknown} */ batch) {
+ exported.push(batch)
+ return { status: result.status ?? 'exported', partitionsExported: 0, bytesWritten: 0 }
+ },
+ },
+ }
+}
+
+/**
+ * @param {{ hypHome: string, sinks: any[], tty?: boolean, answer?: string, remotes?: Record }} args
+ */
+function makeCtx({ hypHome, sinks, tty = false, answer, remotes }) {
+ const stdout = captureStream()
+ const stderr = captureStream()
+ const stdin = Object.assign(new PassThrough(), { isTTY: tty })
+ if (answer !== undefined) stdin.write(`${answer}\n`)
+ const ctx = /** @type {any} */ ({
+ stdout,
+ stderr,
+ stdin,
+ env: { HYP_HOME: hypHome, HYP_CONFIG: '' },
+ cwd: '/home/u',
+ config: remotes ? { version: 2, query: { remotes } } : { version: 2 },
+ query: { listDatasets: () => [] },
+ storage: {
+ cacheRoot: path.join(hypHome, 'cache'),
+ tableExists: () => false,
+ hasPendingSync: () => false,
+ async flushTable() {},
+ },
+ sinks: { listHandles: () => sinks },
+ })
+ return { ctx, stdout, stderr }
+}
+
+/** @param {string} hypHome */
+async function holdExists(hypHome) {
+ try {
+ await fs.access(firstSyncHoldMarkerPath(stateDir(hypHome)))
+ return true
+ } catch {
+ return false
+ }
+}
+
+test('no TTY and no --yes: refuses, exports nothing, and leaves the hold standing', async () => {
+ const hypHome = await makeHome('no-tty')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const { ctx, stderr } = makeCtx({ hypHome, sinks: [sink], tty: false })
+
+ const code = await runSync([], ctx)
+
+ assert.equal(code, 2)
+ assert.match(stderr.text, /refusing to sync without confirmation/)
+ assert.deepEqual(sink.exported, [], 'a refusal must not export')
+ assert.ok(await holdExists(hypHome), 'a refusal must not end the review window')
+})
+
+test('declining at the prompt cancels, exports nothing, and leaves the hold standing', async () => {
+ const hypHome = await makeHome('decline')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink], tty: true, answer: 'n' })
+
+ const code = await runSync([], ctx)
+
+ assert.equal(code, 0, 'declining is a normal outcome, not an error')
+ assert.match(stdout.text, /sync cancelled/)
+ assert.deepEqual(sink.exported, [])
+ assert.ok(await holdExists(hypHome), 'declining must not end the review window')
+})
+
+test('confirming during the review window ends it and exports', async () => {
+ const hypHome = await makeHome('confirm-held')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink], tty: true, answer: 'y' })
+
+ const code = await runSync([], ctx)
+
+ assert.equal(code, 0)
+ assert.equal(sink.exported.length, 1, 'a confirmed sync exports')
+ assert.equal(await holdExists(hypHome), false, 'the marker is cleared, not merely bypassed')
+ assert.match(stdout.text, /central: exported/)
+})
+
+test('the held prompt states the window, the irreversibility, and the way out', async () => {
+ const hypHome = await makeHome('held-warning')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const { ctx, stdout } = makeCtx({
+ hypHome,
+ sinks: [fakeSink('central', { url: 'https://hypaware.example.com' })],
+ tty: true,
+ answer: 'n',
+ })
+
+ await runSync([], ctx)
+
+ const text = stdout.text
+ assert.match(text, /FIRST SYNC - nothing has left this machine yet/)
+ assert.match(text, /Your review window runs until /)
+ assert.match(text, /cannot be un-sent/)
+ assert.match(text, /hyp policy set local-only/)
+})
+
+test('--dry-run prints the plan, exports nothing, and keeps the window open', async () => {
+ const hypHome = await makeHome('dry-run')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
+ // A TTY with no answer queued: a dry run must not reach the prompt at all,
+ // so this would hang if it did.
+ const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink], tty: true })
+
+ const code = await runSync(['--dry-run'], ctx)
+
+ assert.equal(code, 0)
+ assert.match(stdout.text, /\[dry-run\] nothing was sent/)
+ assert.deepEqual(sink.exported, [])
+ assert.ok(await holdExists(hypHome))
+})
+
+test('the plan names each destination and whether it leaves the machine', async () => {
+ const hypHome = await makeHome('plan')
+ const { ctx, stdout } = makeCtx({
+ hypHome,
+ sinks: [
+ fakeSink('central', { url: 'https://hypaware.example.com' }),
+ fakeSink('parquet', { dir: '/home/u/exports' }),
+ fakeSink('mystery', {}),
+ ],
+ tty: true,
+ remotes: { prod: { url: 'https://hypaware.example.com/' } },
+ })
+
+ await runSync(['--dry-run'], ctx)
+
+ const text = stdout.text
+ // A server is named, never spelled as a URL a terminal would autolink
+ // (LLP 0100 R1a's reason, applied to this surface).
+ assert.match(text, /central\s+the 'prod' server\s+\(leaves this machine\)/)
+ assert.doesNotMatch(text, /https:\/\//)
+ assert.match(text, /\(run 'hyp remote list' to see server URLs\)/)
+ assert.match(text, /parquet\s+\/home\/u\/exports\s+\(stays on this machine\)/)
+ // An undeclarable destination says nothing rather than guessing either way.
+ assert.match(text, /mystery\s+@hypaware\/fake\n/)
+})
+
+test('an unnamed server falls back to its host, still not a linkifiable URL', async () => {
+ const hypHome = await makeHome('unnamed-server')
+ const { ctx, stdout } = makeCtx({
+ hypHome,
+ sinks: [fakeSink('central', { url: 'https://elsewhere.example.com/ingest' })],
+ tty: true,
+ remotes: {},
+ })
+
+ await runSync(['--dry-run'], ctx)
+
+ assert.match(stdout.text, /central\s+elsewhere\.example\.com\s+\(leaves this machine\)/)
+ assert.doesNotMatch(stdout.text, /https:\/\//)
+})
+
+test('a named instance cannot release the hold: the plan it showed was not the hold\'s scope', async () => {
+ // The hold is driver-wide (LLP 0101 #hold). A plan built from one handle
+ // omits every other destination, so confirming it would forward them
+ // unseen - the silent first forward the hold exists to prevent.
+ const hypHome = await makeHome('scoped-held')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const central = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const parquet = fakeSink('parquet', { dir: '/home/u/exports' })
+ const { ctx, stderr } = makeCtx({
+ hypHome,
+ sinks: [central, parquet],
+ tty: true,
+ answer: 'y',
+ })
+
+ const code = await runSync(['parquet'], ctx)
+
+ assert.equal(code, 2)
+ assert.match(stderr.text, /review window is open until /)
+ assert.match(stderr.text, /all-or-nothing/)
+ assert.match(stderr.text, /would forward the others unseen/)
+ assert.ok(await holdExists(hypHome), 'the marker must survive a scoped run')
+ assert.deepEqual(parquet.exported, [], 'nothing exports while the window stands')
+ assert.deepEqual(central.exported, [])
+})
+
+test('--yes cannot release the hold: #no-release licenses an attended confirmation only', async () => {
+ const hypHome = await makeHome('yes-held')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const { ctx, stderr } = makeCtx({ hypHome, sinks: [sink], tty: false })
+
+ const code = await runSync(['--yes'], ctx)
+
+ assert.equal(code, 2)
+ assert.match(stderr.text, /--yes cannot do it/)
+ assert.ok(await holdExists(hypHome), 'a script must not end somebody else\'s review window')
+ assert.deepEqual(sink.exported, [])
+})
+
+test('a hold that cannot be cleared fails loudly instead of exiting 0 with nothing sent', async () => {
+ const hypHome = await makeHome('clear-fails')
+ await writeFirstSyncHoldMarker({ stateDir: stateDir(hypHome) })
+ const policyDir = path.dirname(firstSyncHoldMarkerPath(stateDir(hypHome)))
+ await fs.chmod(policyDir, 0o500)
+ const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const { ctx, stderr } = makeCtx({ hypHome, sinks: [sink], tty: true, answer: 'y' })
+
+ try {
+ const code = await runSync([], ctx)
+
+ assert.equal(code, 1, 'a held tick that exported nothing is not a success')
+ assert.match(stderr.text, /could not end the review window/)
+ assert.match(stderr.text, /Nothing was sent/)
+ assert.deepEqual(sink.exported, [])
+ } finally {
+ await fs.chmod(policyDir, 0o700)
+ }
+})
+
+test('the plan counts the directories being withheld', async () => {
+ const hypHome = await makeHome('exclusions')
+ await writeLocalOnlyEntries({
+ stateDir: stateDir(hypHome),
+ entries: [
+ { dir: '/home/u/secret', class: 'local-only' },
+ { dir: '/home/u/other', class: 'local-only' },
+ { dir: '/home/u/never', class: 'ignore' },
+ ],
+ })
+ const { ctx, stdout } = makeCtx({
+ hypHome,
+ sinks: [fakeSink('central', { url: 'https://hypaware.example.com' })],
+ tty: true,
+ })
+
+ await runSync(['--dry-run'], ctx)
+
+ assert.match(stdout.text, /withholding 2 directories marked local-only, 1 directory marked ignore/)
+})
+
+test('with no hold, --yes exports without inventing a review window', async () => {
+ const hypHome = await makeHome('unheld')
+ const sink = fakeSink('parquet', { dir: '/home/u/exports' })
+ const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink] })
+
+ const code = await runSync(['--yes'], ctx)
+
+ assert.equal(code, 0)
+ assert.equal(sink.exported.length, 1)
+ assert.doesNotMatch(stdout.text, /FIRST SYNC/)
+})
+
+test('an unknown instance names the ones that exist', async () => {
+ const hypHome = await makeHome('unknown')
+ const { ctx, stderr } = makeCtx({
+ hypHome,
+ sinks: [fakeSink('central', { url: 'https://hypaware.example.com' })],
+ })
+
+ const code = await runSync(['nope', '--yes'], ctx)
+
+ assert.equal(code, 1)
+ assert.match(stderr.text, /no sink named 'nope'/)
+ assert.match(stderr.text, /available: central/)
+})
+
+test('an instance argument ticks only that sink (no hold in play)', async () => {
+ const hypHome = await makeHome('one-instance')
+ const central = fakeSink('central', { url: 'https://hypaware.example.com' })
+ const parquet = fakeSink('parquet', { dir: '/home/u/exports' })
+ const { ctx } = makeCtx({ hypHome, sinks: [central, parquet] })
+
+ const code = await runSync(['parquet', '--yes'], ctx)
+
+ assert.equal(code, 0)
+ assert.equal(parquet.exported.length, 1)
+ assert.deepEqual(central.exported, [], 'a named instance must not wake the others')
+})
+
+test('no sinks at all is a no-op, not an error', async () => {
+ const hypHome = await makeHome('no-sinks')
+ const { ctx, stdout } = makeCtx({ hypHome, sinks: [] })
+
+ const code = await runSync([], ctx)
+
+ assert.equal(code, 0)
+ assert.match(stdout.text, /no sinks instantiated; nothing to do/)
+})
+
+test('a failed export reports a nonzero exit', async () => {
+ const hypHome = await makeHome('failed')
+ const sink = fakeSink('parquet', { dir: '/home/u/exports' }, { status: 'failed' })
+ const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink] })
+
+ const code = await runSync(['--yes'], ctx)
+
+ assert.equal(code, 1)
+ assert.match(stdout.text, /parquet: failed/)
+})