@openparachute/vault 0.4.8 → 0.4.9-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/core/src/hooks.test.ts +320 -1
  2. package/core/src/hooks.ts +243 -38
  3. package/core/src/mcp.ts +35 -0
  4. package/core/src/portable-md.test.ts +252 -1
  5. package/core/src/portable-md.ts +370 -2
  6. package/core/src/schema.ts +51 -2
  7. package/core/src/store.ts +68 -2
  8. package/package.json +1 -1
  9. package/src/auth.ts +29 -1
  10. package/src/auto-transcribe.test.ts +7 -2
  11. package/src/auto-transcribe.ts +6 -2
  12. package/src/export-watch.test.ts +74 -0
  13. package/src/export-watch.ts +108 -7
  14. package/src/github-device-flow.test.ts +404 -0
  15. package/src/github-device-flow.ts +415 -0
  16. package/src/mcp-http.ts +24 -36
  17. package/src/mcp-tools.ts +286 -2
  18. package/src/mirror-config.test.ts +184 -14
  19. package/src/mirror-config.ts +220 -24
  20. package/src/mirror-credentials.test.ts +450 -0
  21. package/src/mirror-credentials.ts +577 -0
  22. package/src/mirror-deps.ts +42 -1
  23. package/src/mirror-import.test.ts +550 -0
  24. package/src/mirror-import.ts +484 -0
  25. package/src/mirror-manager.test.ts +423 -12
  26. package/src/mirror-manager.ts +579 -62
  27. package/src/mirror-routes.test.ts +966 -10
  28. package/src/mirror-routes.ts +1096 -5
  29. package/src/module-config.ts +11 -5
  30. package/src/routing.test.ts +92 -1
  31. package/src/routing.ts +165 -1
  32. package/src/server.ts +21 -8
  33. package/src/token-store.ts +158 -5
  34. package/src/transcription-worker.ts +9 -4
  35. package/src/triggers.ts +16 -3
  36. package/src/vault.test.ts +380 -1
  37. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  38. package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 18;
5
+ export const SCHEMA_VERSION = 19;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record.
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
118
118
  --
119
119
  -- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
120
120
  -- enforced at runtime, kept only for schema stability.
121
+ -- created_via (v19) records the provenance of a token. NULL means the
122
+ -- legacy/unspecified path (CLI, REST mint, YAML import); 'mcp_mint' means
123
+ -- the token was minted by the manage-token MCP tool, which lets the
124
+ -- list/revoke surface of that tool restrict itself to its own session's
125
+ -- mints (no cross-session token management from inside MCP).
126
+ --
127
+ -- parent_jti (v19) is the display id (t_hashprefix) of the token that
128
+ -- minted this one, or the hub-JWT jti claim when minted from a hub
129
+ -- session. Session-pinned list+revoke in manage-token filters on this.
130
+ --
131
+ -- revoked_at (v19) marks soft-revocation. Revoke from manage-token sets
132
+ -- this rather than deleting the row, so the audit trail stays intact and
133
+ -- the second revoke of the same jti is idempotent (returns ok=true).
134
+ -- resolveToken treats a revoked_at-set row as not-found.
121
135
  CREATE TABLE IF NOT EXISTS tokens (
122
136
  token_hash TEXT PRIMARY KEY,
123
137
  label TEXT NOT NULL,
@@ -129,7 +143,10 @@ CREATE TABLE IF NOT EXISTS tokens (
129
143
  expires_at TEXT,
130
144
  created_at TEXT NOT NULL,
131
145
  last_used_at TEXT,
132
- vault_name TEXT
146
+ vault_name TEXT,
147
+ created_via TEXT,
148
+ parent_jti TEXT,
149
+ revoked_at TEXT
133
150
  );
134
151
 
135
152
  -- OAuth: registered clients (Dynamic Client Registration)
@@ -380,6 +397,12 @@ export function initSchema(db: Database): void {
380
397
  // (markdown), unchanged in meaning. See vault#328.
381
398
  migrateToV18(db);
382
399
 
400
+ // Migrate v18 → v19: add MCP-mint provenance columns to `tokens`
401
+ // (created_via, parent_jti, revoked_at) for vault#376's manage-token tool.
402
+ // All three are nullable; existing rows backfill to NULL which means
403
+ // "non-MCP-minted, not revoked" — identical pre-v19 semantics.
404
+ migrateToV19(db);
405
+
383
406
  // Rebuild any generated columns + indexes declared in indexed_fields.
384
407
  // No-op for a fresh vault; idempotent on existing vaults.
385
408
  rebuildIndexes(db);
@@ -952,6 +975,32 @@ function migrateToV18(db: Database): void {
952
975
  }
953
976
  }
954
977
 
978
+ /**
979
+ * Migrate v18 → v19: add `created_via`, `parent_jti`, `revoked_at` columns
980
+ * to `tokens` so manage-token can attribute mints to the MCP session that
981
+ * minted them, scope its list+revoke to those tokens, and soft-revoke
982
+ * (preserving the audit trail).
983
+ *
984
+ * All three columns are nullable; existing rows backfill to NULL with
985
+ * back-compat semantics — `created_via IS NULL` matches the CLI/REST-mint
986
+ * provenance, `revoked_at IS NULL` matches "still active". Idempotent —
987
+ * the column-existence guard means the migration is safe to re-run on a
988
+ * post-v19 vault. See vault#376.
989
+ */
990
+ function migrateToV19(db: Database): void {
991
+ if (!hasTable(db, "tokens")) return;
992
+ const cols: [string, string][] = [
993
+ ["created_via", "TEXT"],
994
+ ["parent_jti", "TEXT"],
995
+ ["revoked_at", "TEXT"],
996
+ ];
997
+ for (const [col, type] of cols) {
998
+ if (!hasColumn(db, "tokens", col)) {
999
+ db.exec(`ALTER TABLE tokens ADD COLUMN ${col} ${type}`);
1000
+ }
1001
+ }
1002
+ }
1003
+
955
1004
  function hasTable(db: Database, name: string): boolean {
956
1005
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
957
1006
  return !!row;
package/core/src/store.ts CHANGED
@@ -217,10 +217,23 @@ export class BunSqliteStore implements Store {
217
217
  }
218
218
 
219
219
  async deleteNote(id: string): Promise<void> {
220
- // Read before delete so we can invalidate config caches on the way out.
220
+ // Read before delete so we can invalidate config caches on the way out
221
+ // AND so the post-delete hook dispatch carries the minimum payload
222
+ // ({ id, path }). The full note can't be reconstructed post-delete —
223
+ // by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
224
+ // not a Note.
221
225
  const existing = noteOps.getNote(this.db, id);
222
226
  noteOps.deleteNote(this.db, id);
223
227
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
228
+ // Dispatch even when `existing` was null — the caller asked for a
229
+ // deletion, and downstream consumers (e.g. the mirror) reconcile via
230
+ // id. Path is undefined in that case; the mirror sweep will catch
231
+ // any orphans missed by the targeted-removal fast path.
232
+ this.hooks.dispatch(
233
+ "deleted",
234
+ { id, ...(existing?.path ? { path: existing.path } : {}) },
235
+ this,
236
+ );
224
237
  }
225
238
 
226
239
  async queryNotes(opts: QueryOpts): Promise<Note[]> {
@@ -340,6 +353,10 @@ export class BunSqliteStore implements Store {
340
353
  // and may have declared `fields` powering schema validation.
341
354
  this._tagHierarchy = null;
342
355
  this._schemaConfig = null;
356
+ // Fire "deleted" only when SOMETHING happened (the underlying
357
+ // deleteTag returns `deleted: false` when the tag didn't exist).
358
+ // The git-mirror reacts to this by sweeping the schema sidecar.
359
+ if (result.deleted) this.hooks.dispatchTag("deleted", name, this);
343
360
  return result;
344
361
  }
345
362
 
@@ -352,6 +369,16 @@ export class BunSqliteStore implements Store {
352
369
  // the schema-config by parent_names + fields content.
353
370
  this._tagHierarchy = null;
354
371
  this._schemaConfig = null;
372
+ // Rename = delete-then-upsert from the perspective of any consumer
373
+ // that keys schema artifacts on the tag name (e.g. the git-mirror's
374
+ // `.parachute/schemas/<tag>.yaml` sidecar file). Fire both events
375
+ // so the consumer drops the old artifact and writes the new one.
376
+ // Only dispatch when the rename actually happened — error returns
377
+ // ({ error: ... }) shouldn't notify subscribers about phantom moves.
378
+ if ("renamed" in result) {
379
+ this.hooks.dispatchTag("deleted", oldName, this);
380
+ this.hooks.dispatchTag("upserted", newName, this);
381
+ }
355
382
  return result;
356
383
  }
357
384
 
@@ -365,6 +392,15 @@ export class BunSqliteStore implements Store {
365
392
  // bust the schema cache — `fields` declarations follow tag identity.
366
393
  this._tagHierarchy = null;
367
394
  this._schemaConfig = null;
395
+ // Each merged source vanishes from the tag set; the target's
396
+ // schema may have absorbed fields/relationships from the sources.
397
+ // Fire "deleted" for each source and "upserted" for the target so
398
+ // the mirror sweeps the source sidecars and rewrites the target.
399
+ for (const source of sources) {
400
+ if (source === target) continue;
401
+ this.hooks.dispatchTag("deleted", source, this);
402
+ }
403
+ this.hooks.dispatchTag("upserted", target, this);
368
404
  return result;
369
405
  }
370
406
 
@@ -440,12 +476,26 @@ export class BunSqliteStore implements Store {
440
476
  // `fields` drives validation — bust the schema cache so the next
441
477
  // create/update sees the new declarations.
442
478
  this._schemaConfig = null;
479
+ // The tag schema sidecar in the mirror needs to track this. Fire
480
+ // "upserted" regardless of whether the row was created or modified
481
+ // — the mirror writes the sidecar fresh either way.
482
+ this.hooks.dispatchTag("upserted", tag, this);
443
483
  return result;
444
484
  }
445
485
 
446
486
  async deleteTagSchema(tag: string) {
447
487
  const result = tagSchemaOps.deleteTagSchema(this.db, tag);
448
- if (result) this._schemaConfig = null;
488
+ if (result) {
489
+ this._schemaConfig = null;
490
+ // Schema-only delete: the tag may still exist as a name in the
491
+ // hierarchy, but the sidecar lost its content. Mirror reacts by
492
+ // sweeping the sidecar file. (If the underlying row was reduced
493
+ // to a bare name with no schema content, hasSchemaContent() in
494
+ // exportVaultToDir already wouldn't have written it on the next
495
+ // export pass — the targeted delete is the fast path; the sweep
496
+ // is the safety net.)
497
+ this.hooks.dispatchTag("deleted", tag, this);
498
+ }
449
499
  return result;
450
500
  }
451
501
 
@@ -494,6 +544,11 @@ export class BunSqliteStore implements Store {
494
544
  this._tagHierarchy = null;
495
545
  this._schemaConfig = null;
496
546
  }
547
+ // Tag-mutation event for the git-mirror and any other downstream
548
+ // consumer. Fire "upserted" on every successful tag-record write —
549
+ // schema/relationship/parent-name mutations all alter the sidecar
550
+ // contents the mirror persists.
551
+ this.hooks.dispatchTag("upserted", tag, this);
497
552
  return result;
498
553
  }
499
554
 
@@ -599,6 +654,17 @@ export class BunSqliteStore implements Store {
599
654
  const other = this.db.prepare(
600
655
  "SELECT 1 FROM attachments WHERE path = ? LIMIT 1",
601
656
  ).get(row.path);
657
+
658
+ // Post-delete event for downstream consumers (e.g. the git-mirror's
659
+ // sweep of `.parachute/attachments/<id>/...`). Payload is the
660
+ // DeletedAttachmentRef — the row is gone, so we pass only id /
661
+ // note_id / path.
662
+ this.hooks.dispatchAttachment(
663
+ "deleted",
664
+ { id: attachmentId, noteId, path: row.path },
665
+ this,
666
+ );
667
+
602
668
  return { deleted: true, path: row.path, orphaned: !other };
603
669
  }
604
670
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.8",
3
+ "version": "0.4.9-rc.10",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/auth.ts CHANGED
@@ -91,6 +91,12 @@ function tryServerWideAuth(
91
91
  legacyDerived: false,
92
92
  scoped_tags: null,
93
93
  vault_name: null,
94
+ // No stable session id for the env-var operator token — every request
95
+ // is the operator-bearer, not a minted session. manage-token's session
96
+ // pin is a no-op for this caller (it'd still mint, but list/revoke
97
+ // would see no other operator mints; that's fine — env-var-bearer is
98
+ // explicitly the operator-channel, not a user surface).
99
+ caller_jti: null,
94
100
  };
95
101
  }
96
102
 
@@ -120,6 +126,15 @@ export interface AuthResult {
120
126
  * legacy / server-wide / hub JWT — no per-vault binding. See vault#257.
121
127
  */
122
128
  vault_name: string | null;
129
+ /**
130
+ * Session identifier (v19). For `pvt_*` tokens this is the display id
131
+ * (`t_<hashprefix>`) of the presented token. For hub JWTs it's the
132
+ * `jti` claim, when present. NULL for legacy YAML keys / server-wide
133
+ * env-var tokens / hub JWTs without a `jti`. Used by the manage-token
134
+ * MCP tool to stamp child tokens with `parent_jti` so list/revoke can
135
+ * scope to this session's mints. See vault#376.
136
+ */
137
+ caller_jti: string | null;
123
138
  }
124
139
 
125
140
  /**
@@ -134,6 +149,7 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
134
149
  legacyDerived: true,
135
150
  scoped_tags: null,
136
151
  vault_name: null,
152
+ caller_jti: null,
137
153
  };
138
154
  }
139
155
 
@@ -285,6 +301,7 @@ export async function authenticateVaultRequest(
285
301
  legacyDerived: resolved.legacyDerived,
286
302
  scoped_tags: resolved.scoped_tags,
287
303
  vault_name: resolved.vault_name,
304
+ caller_jti: resolved.jti,
288
305
  };
289
306
  }
290
307
  } catch {
@@ -396,7 +413,17 @@ async function authenticateHubJwt(
396
413
  hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
397
414
  ? "full"
398
415
  : "read";
399
- return { permission, scopes: claims.scopes, legacyDerived: false, scoped_tags: null, vault_name: null };
416
+ return {
417
+ permission,
418
+ scopes: claims.scopes,
419
+ legacyDerived: false,
420
+ scoped_tags: null,
421
+ vault_name: null,
422
+ // claims.jti is `undefined` when the issuer didn't stamp one. Pass it
423
+ // through verbatim — manage-token's session-pin will be null in that
424
+ // case, and list/revoke from that session sees no mints.
425
+ caller_jti: claims.jti ?? null,
426
+ };
400
427
  } catch (err) {
401
428
  if (err instanceof HubJwtError) {
402
429
  // Revocation-related codes get sanitized client messages: server-side
@@ -511,6 +538,7 @@ export async function authenticateGlobalRequest(
511
538
  legacyDerived: resolved.legacyDerived,
512
539
  scoped_tags: resolved.scoped_tags,
513
540
  vault_name: resolved.vault_name,
541
+ caller_jti: resolved.jti,
514
542
  };
515
543
  }
516
544
  } catch {
@@ -69,11 +69,16 @@ describe("shouldAutoTranscribe", () => {
69
69
  })).toBe(false);
70
70
  });
71
71
 
72
- test("skips when enabled is unset (no auto_transcribe block in config)", () => {
72
+ test("fires when enabled is unset unset config means ON", () => {
73
+ // Default behavior (no `auto_transcribe` block in config) is opt-out:
74
+ // once an operator has scribe reachable, audio attachments transcribe
75
+ // automatically. Operators wanting it OFF set
76
+ // `auto_transcribe.enabled: false` explicitly. Previously default-off;
77
+ // flipped to default-on so installing scribe is the only opt-in signal.
73
78
  expect(shouldAutoTranscribe("audio/wav", {
74
79
  readGlobalConfigImpl: readGlobalConfig(undefined),
75
80
  getCachedScribeUrlImpl: scribePresent,
76
- })).toBe(false);
81
+ })).toBe(true);
77
82
  });
78
83
 
79
84
  test("skips when scribe URL is undefined (no services.json entry, no env)", () => {
@@ -19,7 +19,11 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
19
19
  *
20
20
  * Returns `true` only when ALL three conditions hold:
21
21
  * 1. mime-type starts with `audio/` (case-insensitive).
22
- * 2. `globalConfig.auto_transcribe?.enabled === true`.
22
+ * 2. `globalConfig.auto_transcribe?.enabled` is not explicitly false.
23
+ * Default behavior (when unset) is ON — once an operator has scribe
24
+ * reachable, audio attachments transcribe automatically without a
25
+ * separate config step. Operators who want it OFF set
26
+ * `auto_transcribe.enabled: false` explicitly.
23
27
  * 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
24
28
  *
25
29
  * The three conditions are independent guards: a single `false` is sufficient
@@ -40,7 +44,7 @@ export function shouldAutoTranscribe(
40
44
  }
41
45
  const enabled = opts.enabledOverride
42
46
  ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
43
- ?? false;
47
+ ?? true;
44
48
  if (!enabled) return false;
45
49
  const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
46
50
  if (!url || !url.trim()) return false;
@@ -27,6 +27,7 @@ import {
27
27
  DEFAULT_COMMIT_TEMPLATE,
28
28
  gitAddAll,
29
29
  gitCommit,
30
+ gitPush,
30
31
  gitUnstageAll,
31
32
  isGitRepo,
32
33
  listStagedFiles,
@@ -315,6 +316,79 @@ describe("git-shell helpers", () => {
315
316
  });
316
317
  });
317
318
 
319
+ // ---------------------------------------------------------------------------
320
+ // 2b. gitPush — first-push upstream tracking (Cut 4 of vault#392)
321
+ //
322
+ // A freshly-bootstrapped mirror has commits but no upstream branch.
323
+ // Bare `git push` fails with "fatal: The current branch X has no upstream
324
+ // branch." gitPush now detects the missing-upstream case and falls back
325
+ // to `git push -u origin <branch>`. Subsequent pushes go bare.
326
+ // ---------------------------------------------------------------------------
327
+
328
+ describe("gitPush — upstream tracking", () => {
329
+ let workdir: string;
330
+ let remote: string;
331
+ beforeEach(() => {
332
+ workdir = makeTmp("vault-push-work-");
333
+ remote = makeTmp("vault-push-remote-");
334
+ // Bare repo as the "remote" — `git push` to it lands like any real remote.
335
+ Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
336
+ initGitRepo(workdir);
337
+ Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: workdir });
338
+ fs.writeFileSync(path.join(workdir, "seed.md"), "# seed\n");
339
+ Bun.spawnSync(["git", "add", "-A"], { cwd: workdir });
340
+ Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: workdir });
341
+ });
342
+ afterEach(() => {
343
+ fs.rmSync(workdir, { recursive: true, force: true });
344
+ fs.rmSync(remote, { recursive: true, force: true });
345
+ });
346
+
347
+ test("first push to a fresh remote establishes upstream tracking", async () => {
348
+ // Pre-Cut-4 this failed with "no upstream branch."
349
+ const result = await gitPush(workdir);
350
+ expect(result.ok).toBe(true);
351
+ // Verify upstream is now set so subsequent pushes can be bare.
352
+ const upstream = Bun.spawnSync(
353
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
354
+ { cwd: workdir, stdout: "pipe" },
355
+ );
356
+ expect(upstream.exitCode).toBe(0);
357
+ expect(
358
+ new TextDecoder().decode(upstream.stdout).trim(),
359
+ ).toBe("origin/main");
360
+ });
361
+
362
+ test("subsequent push (upstream already set) succeeds bare", async () => {
363
+ // First push wires the upstream.
364
+ await gitPush(workdir);
365
+ // Make another commit, push again — should succeed.
366
+ fs.writeFileSync(path.join(workdir, "n.md"), "# n\n");
367
+ Bun.spawnSync(["git", "add", "-A"], { cwd: workdir });
368
+ Bun.spawnSync(["git", "commit", "-q", "-m", "second"], { cwd: workdir });
369
+ const result = await gitPush(workdir);
370
+ expect(result.ok).toBe(true);
371
+ });
372
+
373
+ test("push with no remote configured surfaces the error (back-compat)", async () => {
374
+ // Same shape as the existing "no remote" test in runGitCommitCycle —
375
+ // the branch probe returns a branch but no upstream, gitPush falls
376
+ // back to `git push -u origin main`, which fails because there's no
377
+ // `origin` remote configured.
378
+ const localOnly = makeTmp("vault-push-noremote-");
379
+ initGitRepo(localOnly);
380
+ fs.writeFileSync(path.join(localOnly, "x.md"), "# x\n");
381
+ Bun.spawnSync(["git", "add", "-A"], { cwd: localOnly });
382
+ Bun.spawnSync(["git", "commit", "-q", "-m", "x"], { cwd: localOnly });
383
+ const result = await gitPush(localOnly);
384
+ expect(result.ok).toBe(false);
385
+ // Doesn't matter what the exact error is — just that it's non-fatal
386
+ // (gitPush returns rather than throws).
387
+ expect(typeof result.stderr).toBe("string");
388
+ fs.rmSync(localOnly, { recursive: true, force: true });
389
+ });
390
+ });
391
+
318
392
  // ---------------------------------------------------------------------------
319
393
  // 3. runGitCommitCycle — stage → decide → commit → push
320
394
  // ---------------------------------------------------------------------------
@@ -121,11 +121,72 @@ export async function gitCommit(
121
121
  return { ok: exitCode === 0, stderr: stderr.trim() };
122
122
  }
123
123
 
124
- /** Run `git push` in `repoDir`. Returns true on success. */
124
+ /**
125
+ * Run `git push` in `repoDir`. Returns true on success.
126
+ *
127
+ * Handles the first-push case: a freshly-bootstrapped mirror has
128
+ * commits but no upstream tracking, and a bare `git push` fails with
129
+ * "fatal: The current branch X has no upstream branch." That's
130
+ * unrecoverable from the operator's POV — they'd have to drop to a
131
+ * shell and run `git push -u origin main`. The wiring here detects the
132
+ * missing-upstream case ahead of time and falls back to `git push -u
133
+ * origin <branch>` so the first push works and every subsequent push
134
+ * picks up the now-configured tracking.
135
+ *
136
+ * Vault#382 carried bare `git push`; this is the Cut 4 fix in the
137
+ * credentials-save round-trip work.
138
+ */
125
139
  export async function gitPush(
126
140
  repoDir: string,
127
141
  ): Promise<{ ok: boolean; stderr: string }> {
128
- const proc = Bun.spawn(["git", "push"], {
142
+ // Resolve the current branch name. `git rev-parse --abbrev-ref HEAD`
143
+ // returns the branch on a checked-out branch (e.g. "main"); on a
144
+ // detached HEAD it returns "HEAD" — in that case we bail to bare push
145
+ // since `-u origin HEAD` doesn't mean anything useful.
146
+ let branch: string | null = null;
147
+ try {
148
+ const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
149
+ cwd: repoDir,
150
+ stdout: "pipe",
151
+ stderr: "pipe",
152
+ });
153
+ const branchCode = await branchProc.exited;
154
+ if (branchCode === 0) {
155
+ const out = new TextDecoder()
156
+ .decode(await new Response(branchProc.stdout).arrayBuffer())
157
+ .trim();
158
+ if (out.length > 0 && out !== "HEAD") branch = out;
159
+ }
160
+ } catch {
161
+ // Fall through to bare push.
162
+ }
163
+
164
+ // Probe for an existing upstream. `git rev-parse --abbrev-ref
165
+ // --symbolic-full-name @{u}` exits non-zero when no upstream is
166
+ // configured for the current branch. Exit 0 + a value → upstream
167
+ // exists; bare `git push` is fine.
168
+ let hasUpstream = false;
169
+ if (branch !== null) {
170
+ const upstreamProc = Bun.spawn(
171
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
172
+ {
173
+ cwd: repoDir,
174
+ stdout: "pipe",
175
+ stderr: "pipe",
176
+ },
177
+ );
178
+ const upstreamCode = await upstreamProc.exited;
179
+ hasUpstream = upstreamCode === 0;
180
+ }
181
+
182
+ // First-push path: `git push -u origin <branch>` to establish
183
+ // tracking. Subsequent calls take the bare path because hasUpstream
184
+ // will be true after this lands.
185
+ const cmd =
186
+ branch !== null && !hasUpstream
187
+ ? ["git", "push", "-u", "origin", branch]
188
+ : ["git", "push"];
189
+ const proc = Bun.spawn(cmd, {
129
190
  cwd: repoDir,
130
191
  stdout: "pipe",
131
192
  stderr: "pipe",
@@ -190,7 +251,14 @@ export function shouldCommit(stagedFiles: string[], notesChanged: number): {
190
251
 
191
252
  /**
192
253
  * Stage → decide → commit → optionally push. Logs status to stdout/stderr.
193
- * Returns whether a commit landed.
254
+ * Returns whether a commit landed and (when push was attempted) the
255
+ * push outcome — so callers can surface push status in their UIs
256
+ * without having to grep logs.
257
+ *
258
+ * Cut 5: the return shape now includes a `push` field with the outcome
259
+ * when push was attempted. Tokens are redacted from `push.error` via
260
+ * the userinfo + `gho_/ghp_/glpat-` regex so log + status surfaces are
261
+ * safe to display.
194
262
  */
195
263
  export async function runGitCommitCycle(opts: {
196
264
  repoDir: string;
@@ -201,7 +269,11 @@ export async function runGitCommitCycle(opts: {
201
269
  push: boolean;
202
270
  /** Override for tests — defaults to `new Date().toISOString()`. */
203
271
  now?: () => string;
204
- }): Promise<{ committed: boolean; message?: string }> {
272
+ }): Promise<{
273
+ committed: boolean;
274
+ message?: string;
275
+ push?: { attempted: true; ok: boolean; error?: string };
276
+ }> {
205
277
  const now = opts.now ?? (() => new Date().toISOString());
206
278
 
207
279
  const add = await gitAddAll(opts.repoDir);
@@ -245,11 +317,40 @@ export async function runGitCommitCycle(opts: {
245
317
  if (!pushResult.ok) {
246
318
  // Non-fatal — a network blip shouldn't kill a watch loop. Warn and
247
319
  // move on; the next successful commit's push will catch up history.
248
- console.warn(`[git-commit] git push failed (non-fatal): ${pushResult.stderr}`);
249
- } else {
250
- console.log(`[git-commit] pushed`);
320
+ const redacted = redactToken(pushResult.stderr);
321
+ console.warn(`[git-commit] git push failed (non-fatal): ${redacted}`);
322
+ return {
323
+ committed: true,
324
+ message,
325
+ push: { attempted: true, ok: false, error: redacted },
326
+ };
251
327
  }
328
+ console.log(`[git-commit] pushed`);
329
+ return { committed: true, message, push: { attempted: true, ok: true } };
252
330
  }
253
331
 
254
332
  return { committed: true, message };
255
333
  }
334
+
335
+ /**
336
+ * Redact tokens from a string that came back from `git push` /
337
+ * `git ls-remote` stderr. Same pattern as `redactRemoteUrl` for full
338
+ * URLs; also handles bare `gho_*`/`ghp_*`/`glpat-*` tokens that
339
+ * sometimes show up in git error messages.
340
+ *
341
+ * Best-effort — git's error format isn't a stable contract, so we
342
+ * scrub the obvious patterns and accept that a really unusual
343
+ * formatting could slip through. The credentials file is the
344
+ * authoritative store; logs are diagnostic.
345
+ */
346
+ export function redactToken(text: string): string {
347
+ return text
348
+ // userinfo (https://user:token@host/…)
349
+ .replace(/https?:\/\/[^@\s]+@/g, "https://***@")
350
+ // bare GitHub OAuth tokens
351
+ .replace(/gho_[A-Za-z0-9_]{8,}/g, "gho_***")
352
+ // bare GitHub PATs
353
+ .replace(/ghp_[A-Za-z0-9_]{8,}/g, "ghp_***")
354
+ // bare GitLab PATs
355
+ .replace(/glpat-[A-Za-z0-9_-]{8,}/g, "glpat-***");
356
+ }