@indigoai-us/hq-cli 5.289.0 → 5.291.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
  3. package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
  4. package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
  5. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  6. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  7. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  8. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  9. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  10. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  11. package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
  12. package/dist/command-catalog.generated.d.ts +110 -0
  13. package/dist/command-catalog.generated.js +136 -0
  14. package/dist/commands/cloud.d.ts +26 -14
  15. package/dist/commands/cloud.js +207 -56
  16. package/dist/commands/core-handoff.d.ts +22 -0
  17. package/dist/commands/core-handoff.js +1240 -0
  18. package/dist/commands/core-jobs-native.d.ts +8 -0
  19. package/dist/commands/core-jobs-native.js +624 -0
  20. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  21. package/dist/commands/core-native-jobs-flag.js +7 -0
  22. package/dist/commands/core-ontology.d.ts +6 -1
  23. package/dist/commands/core-ontology.js +42 -7
  24. package/dist/commands/core-policy.js +24 -5
  25. package/dist/commands/core.js +15 -1
  26. package/dist/commands/integrations-core.js +7 -0
  27. package/dist/commands/lanes.js +111 -35
  28. package/dist/commands/members.js +5 -0
  29. package/dist/commands/mesh.js +46 -25
  30. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  31. package/dist/commands/personal-targeted-push-flag.js +7 -0
  32. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  33. package/dist/commands/personal-targeted-push-paths.js +67 -0
  34. package/dist/commands/secrets.js +9 -0
  35. package/dist/commands/skill.d.ts +3 -0
  36. package/dist/commands/skill.js +28 -0
  37. package/dist/fast-core.js +1 -0
  38. package/dist/lib/billing/free-month.d.ts +18 -0
  39. package/dist/lib/billing/free-month.js +31 -0
  40. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  41. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  42. package/dist/lib/billing/plan-lock.d.ts +2 -6
  43. package/dist/lib/billing/plan-lock.js +5 -16
  44. package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
  45. package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
  46. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
  47. package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
  48. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  49. package/dist/lib/core-utils/secret-patterns.js +3 -2
  50. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  51. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  52. package/dist/lib/lanes/autopilot.d.ts +69 -0
  53. package/dist/lib/lanes/autopilot.js +418 -0
  54. package/dist/lib/lanes/mesh-events.js +2 -0
  55. package/dist/lib/lanes/store.d.ts +4 -2
  56. package/dist/lib/lanes/store.js +18 -9
  57. package/dist/lib/mesh/api.js +4 -0
  58. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  59. package/dist/lib/mesh/live/daemon/state.js +34 -2
  60. package/dist/main.js +23 -12
  61. package/dist/utils/plan-gate-error.d.ts +24 -0
  62. package/dist/utils/plan-gate-error.js +219 -9
  63. package/dist/utils/self-update.d.ts +3 -0
  64. package/dist/utils/self-update.js +232 -1
  65. package/package.json +2 -2
@@ -22,6 +22,10 @@ import { resolveUploadAuthor } from "../utils/upload-author.js";
22
22
  import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
23
23
  import { beginSyncHealthReport, } from "../utils/client-health.js";
24
24
  import { recordFirstWorkBearingSync } from "../lib/first-sync-next-step.js";
25
+ import { createPlanLimitRefusalTracker, describeRefusedCount, describeRefusedFiles, mergePlanLimitRefusals, renderPlanLimitRefusalLines, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
26
+ import { formatPlanGateError, planGateErrorFromVaultError, } from "../utils/plan-gate-error.js";
27
+ import { personalTargetedPushPathsEnabled } from "./personal-targeted-push-flag.js";
28
+ import { resolveExplicitPersonalPushPaths } from "./personal-targeted-push-paths.js";
25
29
  /**
26
30
  * Terminal-failure signal for the sync fanout runners. The runners
27
31
  * (`runPushAll` / `runPullAll` / `runPullPersonal` / `runNowSingle` /
@@ -35,10 +39,18 @@ import { recordFirstWorkBearingSync } from "../lib/first-sync-next-step.js";
35
39
  */
36
40
  export class SyncExitError extends Error {
37
41
  code;
38
- constructor(code) {
42
+ /**
43
+ * The client-health outcome to record for this exit. A run whose only
44
+ * problem was a plan-limit refusal exits non-zero (it did not upload what
45
+ * was asked) but is not a sync failure: the engine worked and the refusal
46
+ * is plan state, so it must not count toward `consecutiveFailures`.
47
+ */
48
+ health;
49
+ constructor(code, opts = {}) {
39
50
  super(`sync command exit ${code}`);
40
51
  this.code = code;
41
52
  this.name = "SyncExitError";
53
+ this.health = opts.health ?? "failed";
42
54
  }
43
55
  }
44
56
  /**
@@ -47,6 +59,16 @@ export class SyncExitError extends Error {
47
59
  * CLI are released independently.
48
60
  */
49
61
  export function formatSyncFailure(err) {
62
+ // A plan-limit refusal the runner threw (a 402 on the whole request) renders
63
+ // with the same reason, fix options and upgrade link as every other refusal.
64
+ const planGate = planGateErrorFromVaultError(err);
65
+ if (planGate) {
66
+ const refusedPath = typeof err.path === "string"
67
+ ? err.path
68
+ : undefined;
69
+ return (formatPlanGateError(planGate) +
70
+ (refusedPath ? `\nNot uploaded: ${refusedPath}` : ""));
71
+ }
50
72
  if (isAccessDeniedError(err)) {
51
73
  const key = accessDeniedKeyOf(err);
52
74
  const base = err instanceof Error ? err.message : String(err);
@@ -92,7 +114,12 @@ export async function withSyncHealthReport(run) {
92
114
  return result;
93
115
  }
94
116
  catch (err) {
95
- await health.failed();
117
+ if (err instanceof SyncExitError && err.health === "succeeded") {
118
+ await health.succeeded();
119
+ }
120
+ else {
121
+ await health.failed();
122
+ }
96
123
  if (err instanceof SyncExitError)
97
124
  process.exit(err.code);
98
125
  throw err;
@@ -418,6 +445,7 @@ export async function pushAll(options, deps) {
418
445
  companyUid: m.companyUid,
419
446
  shareOptions: {
420
447
  company: m.companyUid,
448
+ targetSlug: slug,
421
449
  hqRoot: options.hqRoot,
422
450
  paths: [path.join(options.hqRoot, "companies", slug)],
423
451
  skipUnchanged: true,
@@ -526,21 +554,15 @@ export async function resolveCanonicalPersonUid(vaultClient) {
526
554
  return pick.uid;
527
555
  }
528
556
  /**
529
- * Refuse `hq sync push --personal <path>` — the combination silently
530
- * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
531
- * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
532
- * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
533
- * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
534
- * objects to a personal vault before being killed. Cleanup required a
535
- * hand-rolled S3 sweep. Closes hq-cli#25.
536
- *
537
- * Refusal — not silent filtering — is intentional: explicit is better than
538
- * implicit guesswork, and the legitimate "I want to push a subset of my
539
- * personal vault" use case has a clean workaround (drop `--personal`, the
540
- * subset upload targets the active company via standard semantics).
557
+ * Keep `hq sync push --personal <path>` refused by default. Positional paths
558
+ * bypass `computePersonalVaultPaths` and can send excluded `companies/`,
559
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. The
560
+ * default-off targeted-paths gate allows the combination only after every
561
+ * path is realpath-normalized and checked against the computed personal scope
562
+ * and its file exclusions. Closes hq-cli#25.
541
563
  */
542
- export function assertNoPersonalPositionalPaths(opts, paths) {
543
- if (opts.personal && paths && paths.length > 0) {
564
+ export function assertNoPersonalPositionalPaths(opts, paths, targetedPersonalPathsEnabled = false) {
565
+ if (opts.personal && paths && paths.length > 0 && !targetedPersonalPathsEnabled) {
544
566
  throw new Error("`--personal` cannot be combined with explicit [paths]: " +
545
567
  "positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
546
568
  "guard (skips .git/, companies/, repos/, workspace/), risking " +
@@ -670,6 +692,32 @@ async function resolveCliPullScope(client, companyRef, hqRoot) {
670
692
  }
671
693
  return resolvePullScope(client, companyUid, slug, hqRoot);
672
694
  }
695
+ /**
696
+ * The progress-snapshot label share()'s default recorder would use: the
697
+ * target's slug, or null for a personal-vault push. A pre-vended context
698
+ * carries its slug; otherwise the caller's company reference is the best the
699
+ * CLI knows before share() resolves it.
700
+ */
701
+ function shareProgressCompany(input) {
702
+ if (input.personalMode)
703
+ return null;
704
+ if (input.entityContext) {
705
+ return input.entityContext.uid.startsWith("prs_")
706
+ ? null
707
+ : input.entityContext.slug;
708
+ }
709
+ return input.companyRef ?? null;
710
+ }
711
+ /** Only a real slug builds a company billing link; a raw uid does not. */
712
+ function companySlugForBilling(label) {
713
+ return label && !/^(?:cmp|prs)_/.test(label) ? { companySlug: label } : {};
714
+ }
715
+ /** Print one push leg's refusal block under its row. */
716
+ function printPlanLimitRefusal(summary, indent = " ") {
717
+ for (const line of renderPlanLimitRefusalLines(summary, indent)) {
718
+ console.log(chalk.yellow(line));
719
+ }
720
+ }
673
721
  export function registerCloudCommands(program) {
674
722
  program
675
723
  .command("push")
@@ -736,7 +784,13 @@ export function registerCloudCommands(program) {
736
784
  // the fanout" — wired through to `pushAll.skipPersonal`. Outside
737
785
  // `--all` the flag has no effect (logged above as part of the
738
786
  // option's help text).
739
- await withSyncHealthReport(() => runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false));
787
+ await withSyncHealthReport(async () => {
788
+ const refused = await runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false);
789
+ // A plan-limit refusal is not a sync failure, but the push did
790
+ // not upload everything it was asked to: exit non-zero.
791
+ if (refused)
792
+ throw new SyncExitError(1, { health: "succeeded" });
793
+ });
740
794
  return;
741
795
  }
742
796
  const jsonMode = options.json === true;
@@ -752,6 +806,7 @@ export function registerCloudCommands(program) {
752
806
  // Assigned right before the sync engine runs, so option-validation
753
807
  // failures never count as a sync attempt (US-003).
754
808
  let syncHealth;
809
+ let explicitPersonalPaths;
755
810
  try {
756
811
  if (options.personal && options.credsFromStdin) {
757
812
  throw new Error("`--personal` cannot be combined with --creds-from-stdin: " +
@@ -760,7 +815,11 @@ export function registerCloudCommands(program) {
760
815
  "to have already resolved entity + credentials. Pick one.");
761
816
  }
762
817
  // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
763
- assertNoPersonalPositionalPaths(options, paths);
818
+ const targetedPersonalPathsEnabled = Boolean(options.personal && paths && paths.length > 0 && personalTargetedPushPathsEnabled());
819
+ assertNoPersonalPositionalPaths(options, paths, targetedPersonalPathsEnabled);
820
+ if (targetedPersonalPathsEnabled) {
821
+ explicitPersonalPaths = resolveExplicitPersonalPushPaths(options.hqRoot, paths);
822
+ }
764
823
  log(chalk.bold("\nHQ Sync — Push"));
765
824
  log(` HQ root: ${options.hqRoot}`);
766
825
  // Resolve credentials. Two paths:
@@ -818,11 +877,13 @@ export function registerCloudCommands(program) {
818
877
  // Otherwise preserve the historical "default to cwd" semantics so
819
878
  // `hq sync push <file>` and bare `hq sync push` from inside the
820
879
  // company tree both work as before.
821
- const targetPaths = paths && paths.length > 0
822
- ? paths
823
- : options.personal
824
- ? computePersonalVaultPaths(options.hqRoot)
825
- : [process.cwd()];
880
+ const targetPaths = explicitPersonalPaths
881
+ ? explicitPersonalPaths
882
+ : paths && paths.length > 0
883
+ ? paths
884
+ : options.personal
885
+ ? computePersonalVaultPaths(options.hqRoot)
886
+ : [process.cwd()];
826
887
  log(` Company: ${options.personal
827
888
  ? `(personal: ${targetCompany})`
828
889
  : (options.company ?? "(from .hq/config.json or stdin)")}`);
@@ -846,6 +907,16 @@ export function registerCloudCommands(program) {
846
907
  const scope = await resolveCliPullScope(scopeClient, targetCompany, options.hqRoot);
847
908
  pushPrefixSet = scope?.prefixSet;
848
909
  }
910
+ // Collect plan-limit refusals from the runner's events through the
911
+ // progress-recorder seam, which (unlike onEvent) leaves share()'s own
912
+ // console output untouched. The snapshot label mirrors share()'s
913
+ // default recorder: the target slug, or null for the personal vault.
914
+ const progressCompany = shareProgressCompany({
915
+ personalMode,
916
+ entityContext,
917
+ companyRef: targetCompany ?? readActiveCompany(options.hqRoot),
918
+ });
919
+ const planLimitTracker = createPlanLimitRefusalTracker(companySlugForBilling(progressCompany));
849
920
  syncHealth = beginSyncHealthReport();
850
921
  const result = await share({
851
922
  paths: targetPaths,
@@ -859,16 +930,20 @@ export function registerCloudCommands(program) {
859
930
  }),
860
931
  hqRoot: options.hqRoot,
861
932
  onEvent,
933
+ progressRecorder: trackingProgressRecorder(planLimitTracker, progressCompany),
862
934
  ...(personalMode ? { personalMode: true } : {}),
863
935
  ...(journalSlug !== undefined ? { journalSlug } : {}),
864
936
  ...(author ? { author } : {}),
865
937
  ...(pushPrefixSet !== undefined ? { prefixSet: pushPrefixSet } : {}),
866
938
  });
939
+ const planLimitRefused = planLimitTracker.summary();
867
940
  if (jsonMode) {
868
941
  // Synthetic terminal event so subprocess consumers can read final
869
942
  // counts without summing per-file events. Distinguished from
870
943
  // SyncProgressEvent by `type:"complete"` (not in the share()
871
- // event schema — added at the CLI seam).
944
+ // event schema — added at the CLI seam). `planLimitRefused` is
945
+ // additive: present only when a plan limit refused new files, so a
946
+ // consumer never has to infer a refusal from the counts.
872
947
  emitJson({
873
948
  type: "complete",
874
949
  filesUploaded: result.filesUploaded,
@@ -876,6 +951,7 @@ export function registerCloudCommands(program) {
876
951
  filesSkipped: result.filesSkipped,
877
952
  conflictPaths: result.conflictPaths,
878
953
  aborted: result.aborted,
954
+ ...(planLimitRefused ? { planLimitRefused } : {}),
879
955
  });
880
956
  }
881
957
  if (result.aborted) {
@@ -888,6 +964,26 @@ export function registerCloudCommands(program) {
888
964
  filesDeleted: result.filesDeleted,
889
965
  aborted: result.aborted,
890
966
  });
967
+ if (planLimitRefused) {
968
+ // Never a success line over a refusal: say what was pushed, what
969
+ // was refused and how to fix it, then exit non-zero. The sync
970
+ // itself worked, so client health records a success. `--json`
971
+ // callers read `planLimitRefused` on the complete event instead
972
+ // and keep their exit status.
973
+ log(chalk.yellow(`\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
974
+ `${result.filesSkipped} skipped), but ${describeRefusedFiles(planLimitRefused)} ` +
975
+ "refused by your plan limit."));
976
+ for (const line of renderPlanLimitRefusalLines(planLimitRefused)) {
977
+ log(chalk.yellow(line));
978
+ }
979
+ for (const line of scopeExcludedLines(result.filesExcludedByScope, pushPrefixSet)) {
980
+ log(chalk.yellow(line));
981
+ }
982
+ await syncHealth.succeeded();
983
+ if (!jsonMode)
984
+ process.exit(1);
985
+ return;
986
+ }
891
987
  if (result.filesExcludedByScope > 0) {
892
988
  log(chalk.yellow(`\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
893
989
  `${result.filesSkipped} skipped, ${result.filesExcludedByScope} scope-excluded)`));
@@ -1398,6 +1494,11 @@ export async function runPullPersonal(hqRoot, onConflict) {
1398
1494
  throw new SyncExitError(1);
1399
1495
  }
1400
1496
  }
1497
+ /**
1498
+ * Run `push --all` and print its per-target report. Returns the files a plan
1499
+ * limit refused across every target (null when none were), so the caller can
1500
+ * refuse to report success; hard failures still throw {@link SyncExitError}.
1501
+ */
1401
1502
  async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1402
1503
  console.log(chalk.bold("\nHQ Sync — Push (all)"));
1403
1504
  console.log(` HQ root: ${hqRoot}`);
@@ -1432,31 +1533,39 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1432
1533
  }, {
1433
1534
  vaultClient: adapter,
1434
1535
  resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
1435
- share: (opts) => share({
1436
- paths: opts.paths,
1437
- company: opts.company,
1438
- vaultConfig,
1439
- hqRoot: opts.hqRoot,
1440
- ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
1441
- ...(opts.personalMode !== undefined
1442
- ? { personalMode: opts.personalMode }
1443
- : {}),
1444
- ...(opts.journalSlug !== undefined
1445
- ? { journalSlug: opts.journalSlug }
1446
- : {}),
1447
- ...(opts.message !== undefined ? { message: opts.message } : {}),
1448
- ...(opts.skipUnchanged !== undefined
1449
- ? { skipUnchanged: opts.skipUnchanged }
1450
- : {}),
1451
- ...(opts.propagateDeletes !== undefined
1452
- ? { propagateDeletes: opts.propagateDeletes }
1453
- : {}),
1454
- ...(opts.propagateDeletePolicy !== undefined
1455
- ? { propagateDeletePolicy: opts.propagateDeletePolicy }
1456
- : {}),
1457
- ...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
1458
- ...(author ? { author } : {}),
1459
- }),
1536
+ share: async (opts) => {
1537
+ const progressCompany = opts.personalMode
1538
+ ? null
1539
+ : (opts.targetSlug ?? opts.company);
1540
+ const tracker = createPlanLimitRefusalTracker(companySlugForBilling(progressCompany));
1541
+ const shareResult = await share({
1542
+ paths: opts.paths,
1543
+ company: opts.company,
1544
+ vaultConfig,
1545
+ hqRoot: opts.hqRoot,
1546
+ progressRecorder: trackingProgressRecorder(tracker, progressCompany),
1547
+ ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
1548
+ ...(opts.personalMode !== undefined
1549
+ ? { personalMode: opts.personalMode }
1550
+ : {}),
1551
+ ...(opts.journalSlug !== undefined
1552
+ ? { journalSlug: opts.journalSlug }
1553
+ : {}),
1554
+ ...(opts.message !== undefined ? { message: opts.message } : {}),
1555
+ ...(opts.skipUnchanged !== undefined
1556
+ ? { skipUnchanged: opts.skipUnchanged }
1557
+ : {}),
1558
+ ...(opts.propagateDeletes !== undefined
1559
+ ? { propagateDeletes: opts.propagateDeletes }
1560
+ : {}),
1561
+ ...(opts.propagateDeletePolicy !== undefined
1562
+ ? { propagateDeletePolicy: opts.propagateDeletePolicy }
1563
+ : {}),
1564
+ ...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
1565
+ ...(author ? { author } : {}),
1566
+ });
1567
+ return { ...shareResult, planLimit: tracker.summary() };
1568
+ },
1460
1569
  });
1461
1570
  }
1462
1571
  catch (err) {
@@ -1465,28 +1574,42 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1465
1574
  // terminal sync_failure before exiting on our behalf.
1466
1575
  throw new SyncExitError(1);
1467
1576
  }
1577
+ let refused = null;
1578
+ let refusedTargets = 0;
1468
1579
  for (const row of result.perCompany) {
1469
1580
  if (row.error) {
1470
1581
  console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
1471
1582
  }
1472
1583
  else if (row.result) {
1473
1584
  const r = row.result;
1474
- const status = r.aborted || r.filesExcludedByScope > 0
1585
+ const legRefused = r.planLimit ?? null;
1586
+ const status = r.aborted || r.filesExcludedByScope > 0 || legRefused
1475
1587
  ? chalk.yellow("⚠")
1476
1588
  : chalk.green("✓");
1477
1589
  console.log(` ${status} ${row.slug}: ${r.filesUploaded} file(s), ` +
1478
1590
  `${formatBytes(r.bytesUploaded)}, ${r.filesSkipped} skipped, ` +
1479
1591
  `${r.filesDeleted} deleted, ${r.filesExcludedByScope} scope-excluded, ` +
1480
1592
  `${r.conflictPaths.length} conflict(s)` +
1481
- (r.aborted ? " — aborted" : ""));
1593
+ (r.aborted ? " — aborted" : "") +
1594
+ (legRefused
1595
+ ? ` — ${describeRefusedCount(legRefused)} refused by plan limit`
1596
+ : ""));
1597
+ if (legRefused) {
1598
+ printPlanLimitRefusal(legRefused);
1599
+ refused = mergePlanLimitRefusals(refused, legRefused);
1600
+ refusedTargets += 1;
1601
+ }
1482
1602
  }
1483
1603
  }
1484
1604
  const errored = result.errors.length;
1485
1605
  const summary = `\nPushed ${result.filesUploaded} file(s) ` +
1486
1606
  `(${formatBytes(result.bytesUploaded)}) across ${result.attempted} ` +
1487
1607
  `target(s); ${result.filesDeleted} deleted; ${errored} error(s); ` +
1488
- `${result.filesExcludedByScope} scope-excluded`;
1489
- console.log(errored > 0 || result.filesExcludedByScope > 0
1608
+ `${result.filesExcludedByScope} scope-excluded` +
1609
+ (refusedTargets > 0
1610
+ ? `; new files refused by a plan limit on ${refusedTargets} target(s)`
1611
+ : "");
1612
+ console.log(errored > 0 || result.filesExcludedByScope > 0 || refusedTargets > 0
1490
1613
  ? chalk.yellow(summary)
1491
1614
  : chalk.green(summary));
1492
1615
  for (const row of result.perCompany) {
@@ -1506,6 +1629,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1506
1629
  }
1507
1630
  if (errored > 0)
1508
1631
  throw new SyncExitError(1);
1632
+ return refused;
1509
1633
  }
1510
1634
  async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride, forceScopeShrink) {
1511
1635
  console.log(chalk.bold("\nHQ Sync — Now"));
@@ -1523,6 +1647,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1523
1647
  let personalMode = false;
1524
1648
  let journalSlug;
1525
1649
  let pushPaths;
1650
+ let pushSlug;
1526
1651
  if (personal) {
1527
1652
  const client = new VaultClient(vaultConfig);
1528
1653
  targetCompany = await resolveCanonicalPersonUid({
@@ -1547,6 +1672,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1547
1672
  "Use --company <slug>, --personal, or set up .hq/config.json.");
1548
1673
  }
1549
1674
  pushPaths = [path.join(hqRoot, "companies", slug)];
1675
+ pushSlug = slug;
1550
1676
  }
1551
1677
  // Push first so the subsequent pull doesn't redownload files we were
1552
1678
  // about to broadcast (matches hq-sync-runner ordering).
@@ -1557,11 +1683,14 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1557
1683
  const pushScope = await resolveCliPullScope(scopeClient, targetCompany, hqRoot);
1558
1684
  nowPushPrefixSet = pushScope?.prefixSet;
1559
1685
  }
1686
+ const pushProgressCompany = personalMode ? null : (pushSlug ?? null);
1687
+ const planLimitTracker = createPlanLimitRefusalTracker(companySlugForBilling(pushProgressCompany));
1560
1688
  const pushResult = await share({
1561
1689
  paths: pushPaths,
1562
1690
  company: targetCompany,
1563
1691
  vaultConfig,
1564
1692
  hqRoot,
1693
+ progressRecorder: trackingProgressRecorder(planLimitTracker, pushProgressCompany),
1565
1694
  skipUnchanged: true,
1566
1695
  propagateDeletes: true,
1567
1696
  propagateDeletePolicy: resolveDeletePolicy(),
@@ -1572,13 +1701,19 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1572
1701
  ...(author ? { author } : {}),
1573
1702
  ...(nowPushPrefixSet !== undefined ? { prefixSet: nowPushPrefixSet } : {}),
1574
1703
  });
1575
- const pushStatus = pushResult.aborted || pushResult.filesExcludedByScope > 0
1704
+ const pushRefused = planLimitTracker.summary();
1705
+ const pushStatus = pushResult.aborted || pushResult.filesExcludedByScope > 0 || pushRefused
1576
1706
  ? chalk.yellow("⚠")
1577
1707
  : chalk.green("✓");
1578
1708
  console.log(` ${pushStatus} ` +
1579
1709
  `${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
1580
1710
  `${pushResult.filesDeleted} deleted, ${pushResult.filesExcludedByScope} scope-excluded` +
1581
- (pushResult.aborted ? " — aborted" : ""));
1711
+ (pushResult.aborted ? " — aborted" : "") +
1712
+ (pushRefused
1713
+ ? ` — ${describeRefusedCount(pushRefused)} refused by plan limit`
1714
+ : ""));
1715
+ if (pushRefused)
1716
+ printPlanLimitRefusal(pushRefused, " ");
1582
1717
  if (pushResult.filesExcludedByScope > 0) {
1583
1718
  for (const line of scopeExcludedLines(pushResult.filesExcludedByScope, nowPushPrefixSet)) {
1584
1719
  console.log(chalk.yellow(line));
@@ -1698,6 +1833,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1698
1833
  level: bannerLevel,
1699
1834
  });
1700
1835
  }
1836
+ if (pushRefused) {
1837
+ // The pull ran, but the push did not upload everything: no success line.
1838
+ console.log(chalk.yellow(`\n⚠ Sync now finished, but ${describeRefusedFiles(pushRefused)} ` +
1839
+ "refused by your plan limit (listed above)."));
1840
+ throw new SyncExitError(1, { health: "succeeded" });
1841
+ }
1701
1842
  console.log(chalk.green("\n✓ Sync now complete"));
1702
1843
  }
1703
1844
  catch (err) {
@@ -1719,12 +1860,22 @@ async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPerso
1719
1860
  // so the per-target rendering, error isolation, and exit codes are
1720
1861
  // identical to running `push --all` then `pull --all` back-to-back.
1721
1862
  console.log(chalk.dim("→ push --all"));
1722
- await runPushAll(hqRoot, message, onConflict, skipPersonal);
1863
+ const refused = await runPushAll(hqRoot, message, onConflict, skipPersonal);
1723
1864
  console.log(chalk.dim("\n→ pull --all"));
1724
1865
  // US-011: forward --mode-all so the strict refusal applies to the
1725
1866
  // pull leg (push doesn't need a narrow-hint — the narrow ritual is
1726
1867
  // pull-side).
1727
1868
  await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink);
1869
+ if (refused) {
1870
+ // A plan-limit refusal does not stop the pull, but the run must not end
1871
+ // looking like a clean sync: say so last and exit non-zero.
1872
+ console.log(chalk.yellow(`\n⚠ Sync finished, but ${describeRefusedFiles(refused)} refused ` +
1873
+ "by your plan limit (listed under push --all above)."));
1874
+ if (refused.upgradeUrl) {
1875
+ console.log(chalk.yellow(` Upgrade: ${refused.upgradeUrl}`));
1876
+ }
1877
+ throw new SyncExitError(1, { health: "succeeded" });
1878
+ }
1728
1879
  }
1729
1880
  /**
1730
1881
  * Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
@@ -0,0 +1,22 @@
1
+ /** Native implementations of the handoff scaffold command family. */
2
+ import { Command } from "commander";
3
+ type RunResult = {
4
+ code: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ };
8
+ type HandoffContext = {
9
+ root: string;
10
+ cwd: string;
11
+ now?: () => Date;
12
+ /** Test-only override for the finalizer's historical fixed /tmp paths. */
13
+ tmpDir?: string;
14
+ };
15
+ export declare function runHandoffOpenSteps(args: string[], context: HandoffContext): RunResult;
16
+ export declare function runHandoffFinalize(args: string[], context: HandoffContext): RunResult;
17
+ export declare function runHandoffPost(args: string[], context: HandoffContext): RunResult | Promise<RunResult>;
18
+ export declare function runHandoffPostWorker(args: string[], context: HandoffContext): RunResult;
19
+ /** Register native handoff commands behind the shared default-off flag. */
20
+ export declare function registerCoreHandoffCommand(core: Command): void;
21
+ export {};
22
+ //# sourceMappingURL=core-handoff.d.ts.map