@indigoai-us/hq-cli 5.20.0 → 5.22.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,86 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.22.0] — 2026-05-25
6
+
7
+ ### Changed
8
+
9
+ - Bumps `@indigoai-us/hq-cloud` to `~5.37.0` (was `~5.34.0`). Picks up
10
+ the 5.35 → 5.37 chain on top of yesterday's 5.34.0 cleanup.
11
+
12
+ - **5.35.0** — `.claude/state/` + `.claude/audit/` in
13
+ `DEFAULT_IGNORES`. Removes ~85% of the conflict-mirror class
14
+ observed during the 5.34.0 live cross-machine test. See
15
+ [hq-cloud#25](https://github.com/indigoai-us/hq-cloud/pull/25).
16
+ - **5.36.0** — two sync speedups: an lstat fast-path that skips
17
+ SHA-256 when `(size, mtimeMs)` match the journal baseline
18
+ (~5–10× on no-op syncs, which are most syncs), and a bounded-
19
+ parallel transfer pool (default 16, knob
20
+ `HQ_SYNC_TRANSFER_CONCURRENCY`) for uploads + downloads (4–8× on
21
+ transfer-heavy syncs). Codex P1 follow-ups serialize the
22
+ interactive conflict prompt under the pool and drain in-flight
23
+ transfers on worker error. See
24
+ [hq-cloud#26](https://github.com/indigoai-us/hq-cloud/pull/26).
25
+ - **5.37.0** — file mtime / birthtime preservation across sync.
26
+ Push stamps source-side `lstat.mtimeMs` (+ `birthtimeMs` when the
27
+ filesystem supports it AND differs from mtime) into S3 metadata as
28
+ `hq-mtime` / `hq-btime`. Pull applies the stamped value via
29
+ `utimesSync` after the byte write, falling back to write-time
30
+ when metadata is absent (back-compat). Symlinks skipped on both
31
+ sides. Composes cleanly with the 5.36.0 fast-path — the journal
32
+ records the post-utimes mtime, so the next sync correctly skips
33
+ re-hashing. Codex P2 widened the accepted mtime domain to include
34
+ `0` (Unix epoch, reproducible-builds clamp value) and negative
35
+ epochs (pre-1970). See
36
+ [hq-cloud#27](https://github.com/indigoai-us/hq-cloud/pull/27).
37
+
38
+ Live verified end-to-end on the macOS desktop ↔ Lightsail outpost
39
+ pair: bilateral convergence in 2–3 sync rounds, mtime round-trip
40
+ exact for 2020 / 2030 / epoch-0 / pre-epoch-negative test cases,
41
+ fast-path correctly skips post-utimes files on the next no-op.
42
+
43
+ ## [5.21.0] — 2026-05-24
44
+
45
+ ### Changed
46
+
47
+ - Bumps `@indigoai-us/hq-cloud` to `~5.34.0` (was `~5.25.0`). 5.34.0
48
+ closes the 10-bug cross-machine sync cleanup from
49
+ [hq-cloud#24](https://github.com/indigoai-us/hq-cloud/pull/24) plus
50
+ the Codex P1/P2 follow-up safeties.
51
+
52
+ Highest-impact fixes that ride this bump:
53
+
54
+ - **Bug #9** (drifted files never converge): the pull walker on
55
+ `~5.25.0` had no tombstone-consumption mechanism, so cross-machine
56
+ deletes never propagated — every host kept the union of "every
57
+ file ever created anywhere." 5.34.0 lands journal-vs-LIST diff +
58
+ HEAD-verify STS-scope guard + local-edit divergence detector for
59
+ both files and symlinks. The Drift counter can finally reach zero.
60
+ - **Bug #7** (concurrent edit data loss): two open laptops editing
61
+ the same file before either had synced silently overwrote the
62
+ slower side on `~5.25.0` — no conflict event, no tray badge,
63
+ invisible data loss. 5.34.0 writes a mirror to
64
+ `<orig>.conflict-<ts>-<short>` instead.
65
+ - **Bug #10** (`ENOTDIR` wedges the whole company sync): one
66
+ dir-vs-file collision on a stale path on `~5.25.0` threw
67
+ `ENOTDIR` and aborted the whole company — `partial: true`,
68
+ `filesSkipped: 0`. 5.34.0 handles both `(local-file, cloud-dir)`
69
+ and `(local-dir, cloud-file)` topologies symmetrically.
70
+
71
+ Plus #1/#6/#8 (`.hq/` directory leak channel — including the
72
+ alternate `.hq/machine.json` and `.hq/install-manifest.json` paths
73
+ that were cross-polluting per-host identity), #2 (PULL walker now
74
+ applies `EPHEMERAL_PATH_PATTERN` — legacy `.conflict-*` litter no
75
+ longer ratchets), #3 (`conflictPaths` deduped within a company), #4
76
+ (dir-vs-file warning + structured event), and #5 (file mode
77
+ preserved across upload/download — was previously collapsing every
78
+ mode to receiver umask default).
79
+
80
+ New `SyncResult` fields surface via the runner's `complete` event:
81
+ `filesTombstoned`, `filesExcludedByPolicy`,
82
+ `filesRefusedStalePaths` (cap 50). hq-cli forwards them through
83
+ unchanged; the menubar can opt in via UI work in a follow-up.
84
+
5
85
  ## [5.20.0] — 2026-05-21
6
86
 
7
87
  ### Added
@@ -54,6 +54,30 @@ export declare function sourceMatchesPackPattern(source: string): boolean;
54
54
  * contract this function pins.
55
55
  */
56
56
  export declare function installToPackages(payloadDir: string, pkg: PackManifest, hqRoot: string): string;
57
+ /**
58
+ * Stamp the install-time `source:` into the destination package.yaml. This is
59
+ * the only on-disk record of which `recommended_packages[].source` produced
60
+ * each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
61
+ * `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
62
+ * against `core/core.yaml:recommended_packages` — without this stamp, the
63
+ * dedup set is empty and every recommended pack is re-prompted on every
64
+ * setup.sh run.
65
+ *
66
+ * Idempotent: re-installs (and `hq update`) replace any pre-existing
67
+ * top-level `source:` line rather than duplicating it. Nested `source:`
68
+ * fields under `contributes:` (or any other block) are left untouched.
69
+ *
70
+ * The value is YAML double-quoted so `github:` sources containing `#`
71
+ * survive round-trip (an unquoted `#` would be parsed as a comment).
72
+ *
73
+ * If the manifest opens with a `---` YAML document marker, the source line is
74
+ * inserted *after* the marker — otherwise the prepend would create a
75
+ * two-document stream that single-doc `yaml.load` callers in this codebase
76
+ * cannot read.
77
+ *
78
+ * Exported for tests.
79
+ */
80
+ export declare function stampInstallSource(destDir: string, source: string): void;
57
81
  /**
58
82
  * Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
59
83
  * pack's contributions into the host paths (skills, hooks, policies, etc.).
@@ -34,7 +34,7 @@
34
34
  * from each pack's package.yaml; rationale lives in the layout-fix PR.)
35
35
  */
36
36
 
37
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="5dea7752-f10c-553c-a419-cf05441b3c13")}catch(e){}}();
37
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="845e5459-a7ba-53ca-a04c-dd055fabe52d")}catch(e){}}();
38
38
  import * as fs from 'fs';
39
39
  import * as os from 'os';
40
40
  import * as path from 'path';
@@ -446,6 +446,61 @@ export function installToPackages(payloadDir, pkg, hqRoot) {
446
446
  execFileSync('rsync', ['-a', srcSlashed, destSlashed], { stdio: 'inherit' });
447
447
  return destDir;
448
448
  }
449
+ /**
450
+ * Stamp the install-time `source:` into the destination package.yaml. This is
451
+ * the only on-disk record of which `recommended_packages[].source` produced
452
+ * each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
453
+ * `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
454
+ * against `core/core.yaml:recommended_packages` — without this stamp, the
455
+ * dedup set is empty and every recommended pack is re-prompted on every
456
+ * setup.sh run.
457
+ *
458
+ * Idempotent: re-installs (and `hq update`) replace any pre-existing
459
+ * top-level `source:` line rather than duplicating it. Nested `source:`
460
+ * fields under `contributes:` (or any other block) are left untouched.
461
+ *
462
+ * The value is YAML double-quoted so `github:` sources containing `#`
463
+ * survive round-trip (an unquoted `#` would be parsed as a comment).
464
+ *
465
+ * If the manifest opens with a `---` YAML document marker, the source line is
466
+ * inserted *after* the marker — otherwise the prepend would create a
467
+ * two-document stream that single-doc `yaml.load` callers in this codebase
468
+ * cannot read.
469
+ *
470
+ * Exported for tests.
471
+ */
472
+ export function stampInstallSource(destDir, source) {
473
+ const manifestPath = path.join(destDir, 'package.yaml');
474
+ if (!fs.existsSync(manifestPath)) {
475
+ // installToPackages already validated the payload contained package.yaml.
476
+ // If it's gone by the time we stamp, something deleted it between calls —
477
+ // fail loud so the regression surfaces in tests.
478
+ throw new Error(`stampInstallSource: ${manifestPath} not found`);
479
+ }
480
+ const original = fs.readFileSync(manifestPath, 'utf-8');
481
+ // Strip any existing top-level `source:` line. Match only lines starting at
482
+ // column 0 so nested `source:` fields (none in spec today, but defensive)
483
+ // are not touched.
484
+ const lines = original.split('\n').filter((line) => !/^source\s*:/.test(line));
485
+ const quoted = `"${source.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
486
+ const sourceLine = `source: ${quoted}`;
487
+ // Detect a leading YAML document marker (`---`, optionally followed by
488
+ // trailing whitespace or a comment). If present, the source line goes
489
+ // *after* it so the manifest stays a single document.
490
+ let insertAt = 0;
491
+ while (insertAt < lines.length && lines[insertAt].trim() === '')
492
+ insertAt++;
493
+ const opensWithDocMarker = insertAt < lines.length && /^---(\s|#|$)/.test(lines[insertAt]);
494
+ if (opensWithDocMarker) {
495
+ lines.splice(insertAt + 1, 0, sourceLine);
496
+ }
497
+ else {
498
+ // Drop any leading blank lines we walked past so the file doesn't grow a
499
+ // blank-line gutter on every re-stamp.
500
+ lines.splice(0, insertAt, sourceLine);
501
+ }
502
+ fs.writeFileSync(manifestPath, lines.join('\n'));
503
+ }
449
504
  /**
450
505
  * Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
451
506
  * pack's contributions into the host paths (skills, hooks, policies, etc.).
@@ -509,12 +564,15 @@ export async function installPack(source, opts = {}) {
509
564
  return;
510
565
  }
511
566
  const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
512
- // Under the v12 HQ layout, packs live at `core/packages/<name>/` and are
513
- // tracked by filesystem presence alone — no `modules.yaml` write. That
514
- // removes the side effect that created a top-level `modules/` directory
515
- // alongside the canonical `core/`. `hq update <pack>` will re-resolve a
516
- // pack's source from its on-disk package.yaml or prompt for it, but that
517
- // tradeoff is intentional see the layout-fix PR for rationale.
567
+ // Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
568
+ // are tracked by filesystem presence — no `modules.yaml` write (that
569
+ // removed the orphan top-level `modules/` tree alongside the canonical
570
+ // `core/`). The one piece of install-time provenance we DO record is the
571
+ // input `source` string, stamped into the destination `package.yaml` so
572
+ // setup.sh can dedup against `core/core.yaml:recommended_packages` on
573
+ // re-runs. Stamping the literal input (not the resolved SHA/version)
574
+ // matches the verbatim equality check in setup.sh.
575
+ stampInstallSource(destDir, source);
518
576
  runScanPackages(hqRoot);
519
577
  console.log(chalk.green(`\n✓ Installed ${pkg.name}@${pkg.version} → ${path.relative(hqRoot, destDir)}/`));
520
578
  console.log(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
@@ -525,4 +583,4 @@ export async function installPack(source, opts = {}) {
525
583
  }
526
584
  }
527
585
  //# sourceMappingURL=pack-install.js.map
528
- //# debugId=5dea7752-f10c-553c-a419-cf05441b3c13
586
+ //# debugId=845e5459-a7ba-53ca-a04c-dd055fabe52d
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.20.0",
3
+ "version": "5.22.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "~5.25.0",
18
+ "@indigoai-us/hq-cloud": "~5.37.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
21
21
  import * as fs from 'fs';
22
22
  import * as os from 'os';
23
23
  import * as path from 'path';
24
- import { installToPackages, runScanPackages } from './pack-install.js';
24
+ import { installToPackages, runScanPackages, stampInstallSource } from './pack-install.js';
25
25
  import type { PackManifest } from '../types.js';
26
26
 
27
27
  // ---------------------------------------------------------------------------
@@ -145,4 +145,118 @@ describe('pack-install: install path layout', () => {
145
145
  expect(fs.existsSync(path.join(hqRoot, 'modules'))).toBe(false);
146
146
  expect(fs.existsSync(path.join(hqRoot, 'modules.yaml'))).toBe(false);
147
147
  });
148
+
149
+ // ---- 5. stampInstallSource — setup.sh dedup contract --------------------
150
+ // setup.sh in hq-core ≥14.2.x reads `^source:` from every installed
151
+ // core/packages/*/package.yaml and uses verbatim equality against
152
+ // core.yaml:recommended_packages[].source to skip "already installed" packs.
153
+ // Without the stamp the dedup set is empty and every recommended pack gets
154
+ // re-prompted on every setup.sh run — the bug this stamp closes.
155
+ it('stampInstallSource writes a top-level source: line into package.yaml', () => {
156
+ const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
157
+ const src = '@indigoai-us/hq-pack-test@1.2.3';
158
+ stampInstallSource(dest, src);
159
+
160
+ const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
161
+ // First line so a `grep ^source:` style scan never misses it.
162
+ expect(written.split('\n')[0]).toBe(`source: "${src}"`);
163
+ // Original fields preserved verbatim — the stamp must not clobber name/version.
164
+ expect(written).toContain('name: hq-pack-test');
165
+ expect(written).toContain('version: 1.0.0');
166
+ });
167
+
168
+ it('stampInstallSource is idempotent — re-install replaces (not duplicates) the source line', () => {
169
+ const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
170
+ stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.0.0');
171
+ stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.2.3');
172
+
173
+ const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
174
+ // Exactly one top-level source: line, and it carries the latest value.
175
+ const sourceLines = written.split('\n').filter((l) => /^source\s*:/.test(l));
176
+ expect(sourceLines).toHaveLength(1);
177
+ expect(sourceLines[0]).toBe('source: "@indigoai-us/hq-pack-test@1.2.3"');
178
+ });
179
+
180
+ it('stampInstallSource quotes github: sources so the # is not parsed as a YAML comment', () => {
181
+ const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
182
+ const src = 'github:indigoai-us/hq-packages#packages/hq-pack-test';
183
+ stampInstallSource(dest, src);
184
+
185
+ const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
186
+ // Double-quoted form survives the '#' that would otherwise truncate the
187
+ // value at the first YAML comment marker.
188
+ expect(written.split('\n')[0]).toBe(`source: "${src}"`);
189
+ // Round-trip through js-yaml to confirm a real parser reads the full value.
190
+ const yamlLib = require('js-yaml');
191
+ const parsed = yamlLib.load(written) as { source: string };
192
+ expect(parsed.source).toBe(src);
193
+ });
194
+
195
+ it('stampInstallSource only strips top-level source: lines, not nested ones', () => {
196
+ // Defensive: today's spec has no nested `source:` keys, but if a pack
197
+ // author adds e.g. `metadata: { source: ... }` we should leave it alone.
198
+ const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
199
+ fs.writeFileSync(
200
+ path.join(dest, 'package.yaml'),
201
+ [
202
+ 'name: hq-pack-test',
203
+ 'version: 1.0.0',
204
+ 'metadata:',
205
+ ' source: nested-value-keep-me',
206
+ '',
207
+ ].join('\n'),
208
+ );
209
+
210
+ stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.0.0');
211
+
212
+ const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
213
+ expect(written.split('\n')[0]).toBe('source: "@indigoai-us/hq-pack-test@1.0.0"');
214
+ // Nested source line survives.
215
+ expect(written).toContain(' source: nested-value-keep-me');
216
+ });
217
+
218
+ it('stampInstallSource throws when package.yaml is missing — surfaces upstream regressions loudly', () => {
219
+ const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
220
+ fs.unlinkSync(path.join(dest, 'package.yaml'));
221
+
222
+ expect(() => stampInstallSource(dest, '@scope/x@1.0.0')).toThrow(/package\.yaml.*not found/);
223
+ });
224
+
225
+ it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
226
+ // Regression: prepending `source:` before a `---` would split the file
227
+ // into two YAML documents, and single-doc `yaml.load` callers downstream
228
+ // (in this codebase + hq-core) silently misread or fail. Insert AFTER
229
+ // the marker so the manifest stays a single document.
230
+ const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
231
+ fs.writeFileSync(
232
+ path.join(dest, 'package.yaml'),
233
+ ['---', 'name: hq-pack-test', 'version: 1.0.0', ''].join('\n'),
234
+ );
235
+ const src = 'github:indigoai-us/hq-packages#packages/hq-pack-test';
236
+
237
+ stampInstallSource(dest, src);
238
+
239
+ const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
240
+ // `---` stays on line 0, `source:` follows immediately so `grep ^source:`
241
+ // dedup scans still match.
242
+ const lines = written.split('\n');
243
+ expect(lines[0]).toBe('---');
244
+ expect(lines[1]).toBe(`source: "${src}"`);
245
+
246
+ // Critical assertion: js-yaml's single-doc loader returns the merged
247
+ // manifest, not just the first of two documents.
248
+ const yamlLib = require('js-yaml');
249
+ const parsed = yamlLib.load(written) as { source: string; name: string; version: string };
250
+ expect(parsed.source).toBe(src);
251
+ expect(parsed.name).toBe('hq-pack-test');
252
+ expect(parsed.version).toBe('1.0.0');
253
+
254
+ // Re-stamp is still idempotent in the doc-marker case.
255
+ stampInstallSource(dest, '@scope/x@2.0.0');
256
+ const rewritten = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
257
+ const sourceLines = rewritten.split('\n').filter((l) => /^source\s*:/.test(l));
258
+ expect(sourceLines).toHaveLength(1);
259
+ expect(sourceLines[0]).toBe('source: "@scope/x@2.0.0"');
260
+ expect(rewritten.split('\n')[0]).toBe('---');
261
+ });
148
262
  });
@@ -547,6 +547,62 @@ export function installToPackages(
547
547
  return destDir;
548
548
  }
549
549
 
550
+ /**
551
+ * Stamp the install-time `source:` into the destination package.yaml. This is
552
+ * the only on-disk record of which `recommended_packages[].source` produced
553
+ * each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
554
+ * `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
555
+ * against `core/core.yaml:recommended_packages` — without this stamp, the
556
+ * dedup set is empty and every recommended pack is re-prompted on every
557
+ * setup.sh run.
558
+ *
559
+ * Idempotent: re-installs (and `hq update`) replace any pre-existing
560
+ * top-level `source:` line rather than duplicating it. Nested `source:`
561
+ * fields under `contributes:` (or any other block) are left untouched.
562
+ *
563
+ * The value is YAML double-quoted so `github:` sources containing `#`
564
+ * survive round-trip (an unquoted `#` would be parsed as a comment).
565
+ *
566
+ * If the manifest opens with a `---` YAML document marker, the source line is
567
+ * inserted *after* the marker — otherwise the prepend would create a
568
+ * two-document stream that single-doc `yaml.load` callers in this codebase
569
+ * cannot read.
570
+ *
571
+ * Exported for tests.
572
+ */
573
+ export function stampInstallSource(destDir: string, source: string): void {
574
+ const manifestPath = path.join(destDir, 'package.yaml');
575
+ if (!fs.existsSync(manifestPath)) {
576
+ // installToPackages already validated the payload contained package.yaml.
577
+ // If it's gone by the time we stamp, something deleted it between calls —
578
+ // fail loud so the regression surfaces in tests.
579
+ throw new Error(`stampInstallSource: ${manifestPath} not found`);
580
+ }
581
+ const original = fs.readFileSync(manifestPath, 'utf-8');
582
+ // Strip any existing top-level `source:` line. Match only lines starting at
583
+ // column 0 so nested `source:` fields (none in spec today, but defensive)
584
+ // are not touched.
585
+ const lines = original.split('\n').filter((line) => !/^source\s*:/.test(line));
586
+ const quoted = `"${source.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
587
+ const sourceLine = `source: ${quoted}`;
588
+
589
+ // Detect a leading YAML document marker (`---`, optionally followed by
590
+ // trailing whitespace or a comment). If present, the source line goes
591
+ // *after* it so the manifest stays a single document.
592
+ let insertAt = 0;
593
+ while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
594
+ const opensWithDocMarker =
595
+ insertAt < lines.length && /^---(\s|#|$)/.test(lines[insertAt]);
596
+ if (opensWithDocMarker) {
597
+ lines.splice(insertAt + 1, 0, sourceLine);
598
+ } else {
599
+ // Drop any leading blank lines we walked past so the file doesn't grow a
600
+ // blank-line gutter on every re-stamp.
601
+ lines.splice(0, insertAt, sourceLine);
602
+ }
603
+ fs.writeFileSync(manifestPath, lines.join('\n'));
604
+ }
605
+
550
606
  /**
551
607
  * Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
552
608
  * pack's contributions into the host paths (skills, hooks, policies, etc.).
@@ -640,12 +696,15 @@ export async function installPack(
640
696
  }
641
697
 
642
698
  const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
643
- // Under the v12 HQ layout, packs live at `core/packages/<name>/` and are
644
- // tracked by filesystem presence alone — no `modules.yaml` write. That
645
- // removes the side effect that created a top-level `modules/` directory
646
- // alongside the canonical `core/`. `hq update <pack>` will re-resolve a
647
- // pack's source from its on-disk package.yaml or prompt for it, but that
648
- // tradeoff is intentional see the layout-fix PR for rationale.
699
+ // Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
700
+ // are tracked by filesystem presence — no `modules.yaml` write (that
701
+ // removed the orphan top-level `modules/` tree alongside the canonical
702
+ // `core/`). The one piece of install-time provenance we DO record is the
703
+ // input `source` string, stamped into the destination `package.yaml` so
704
+ // setup.sh can dedup against `core/core.yaml:recommended_packages` on
705
+ // re-runs. Stamping the literal input (not the resolved SHA/version)
706
+ // matches the verbatim equality check in setup.sh.
707
+ stampInstallSource(destDir, source);
649
708
  runScanPackages(hqRoot);
650
709
 
651
710
  console.log(