halfcycle 0.4.13 → 0.4.15

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 (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/bin.bundle.mjs +6308 -588
  3. package/bin/pre-tool-use-filter.sh +134 -6
  4. package/commands/agent-teams-orchestration.md +1 -1
  5. package/commands/consistency-check.md +1 -1
  6. package/commands/context-update.md +1 -1
  7. package/commands/halfcycle-design.md +1 -1
  8. package/commands/halfcycle-setup.md +1 -1
  9. package/commands/stack-assembly-worker.md +1 -1
  10. package/commands/task-execution.md +1 -1
  11. package/commands/task-graph.md +1 -1
  12. package/commands/write-spec.md +1 -1
  13. package/dist/bin.d.ts +1 -0
  14. package/dist/bin.d.ts.map +1 -1
  15. package/dist/build-record/pair-io.d.ts +49 -0
  16. package/dist/build-record/pair-io.d.ts.map +1 -0
  17. package/dist/build-record/render-change.d.ts +25 -0
  18. package/dist/build-record/render-change.d.ts.map +1 -0
  19. package/dist/build-record/render-v2.d.ts +10 -5
  20. package/dist/build-record/render-v2.d.ts.map +1 -1
  21. package/dist/build-record/write-change.d.ts +63 -0
  22. package/dist/build-record/write-change.d.ts.map +1 -0
  23. package/dist/build-record/write.d.ts +5 -15
  24. package/dist/build-record/write.d.ts.map +1 -1
  25. package/dist/change.d.ts +136 -0
  26. package/dist/change.d.ts.map +1 -0
  27. package/dist/check-tasks.d.ts +71 -0
  28. package/dist/check-tasks.d.ts.map +1 -0
  29. package/dist/claude-plan.d.ts +133 -0
  30. package/dist/claude-plan.d.ts.map +1 -0
  31. package/dist/cli-contract.d.ts +12 -1
  32. package/dist/cli-contract.d.ts.map +1 -1
  33. package/dist/cli.js +9670 -4933
  34. package/dist/cli.js.map +3 -3
  35. package/dist/close-phase.d.ts +53 -1
  36. package/dist/close-phase.d.ts.map +1 -1
  37. package/dist/doctor.d.ts +34 -0
  38. package/dist/doctor.d.ts.map +1 -0
  39. package/dist/folder-refusals.d.ts +27 -0
  40. package/dist/folder-refusals.d.ts.map +1 -0
  41. package/dist/identity.d.ts +31 -1
  42. package/dist/identity.d.ts.map +1 -1
  43. package/dist/index.js +2832 -1324
  44. package/dist/index.js.map +3 -3
  45. package/dist/install-manifest.d.ts +11 -1
  46. package/dist/install-manifest.d.ts.map +1 -1
  47. package/dist/install-plan.d.ts +112 -0
  48. package/dist/install-plan.d.ts.map +1 -0
  49. package/dist/install.d.ts +593 -2
  50. package/dist/install.d.ts.map +1 -1
  51. package/dist/merge-settings.d.ts +21 -2
  52. package/dist/merge-settings.d.ts.map +1 -1
  53. package/dist/open-phase.d.ts +1 -1
  54. package/dist/open-phase.d.ts.map +1 -1
  55. package/dist/pace.d.ts +138 -0
  56. package/dist/pace.d.ts.map +1 -0
  57. package/dist/phase-work.d.ts +37 -0
  58. package/dist/phase-work.d.ts.map +1 -0
  59. package/dist/plan.d.ts +20 -4
  60. package/dist/plan.d.ts.map +1 -1
  61. package/dist/pre-create-checks.d.ts +28 -0
  62. package/dist/pre-create-checks.d.ts.map +1 -1
  63. package/dist/preview.d.ts +40 -0
  64. package/dist/preview.d.ts.map +1 -0
  65. package/dist/prove.d.ts +77 -0
  66. package/dist/prove.d.ts.map +1 -0
  67. package/dist/restore.d.ts +58 -0
  68. package/dist/restore.d.ts.map +1 -0
  69. package/dist/review.d.ts +19 -1
  70. package/dist/review.d.ts.map +1 -1
  71. package/dist/scan.d.ts +11 -0
  72. package/dist/scan.d.ts.map +1 -1
  73. package/dist/setup/discovery.d.ts.map +1 -1
  74. package/dist/setup/rows.d.ts +10 -6
  75. package/dist/setup/rows.d.ts.map +1 -1
  76. package/dist/system-report.d.ts +1 -1
  77. package/dist/system-report.d.ts.map +1 -1
  78. package/dist/uninstall.d.ts.map +1 -1
  79. package/package.json +2 -2
package/dist/install.d.ts CHANGED
@@ -22,8 +22,9 @@
22
22
  * Halfcycle's scaffold contents.
23
23
  */
24
24
  import { BUNDLE_PIN_REL, MCP_REGISTRATION_REL, MCP_SERVER_KEY, SETTINGS_REL, WRITE_CAPABLE_TOOLS } from '@halfcycle/core';
25
- import { lineKey, type ManifestFile } from './install-manifest.js';
26
- import { type ClaudeSettings } from './merge-settings.js';
25
+ import { type PlannedProjectIdentity } from './identity.js';
26
+ import { lineKey, type InstallManifest, type ManifestFile } from './install-manifest.js';
27
+ import { type ClaudeSettings, type StatusLineSetting } from './merge-settings.js';
27
28
  export type EngagementType = 'self-build' | 'client';
28
29
  export interface BundlePin {
29
30
  version: string;
@@ -209,6 +210,13 @@ export interface InstallOptions {
209
210
  * for the same reason.
210
211
  */
211
212
  home?: string;
213
+ /**
214
+ * The environment the installer runs in, read for `CLAUDE_CONFIG_DIR` alone: the
215
+ * fourth place a person's own status line may be set (`statusLineSetElsewhere`).
216
+ * Defaults to `process.env`; a test names its own so the machine running it does
217
+ * not decide what the test sees.
218
+ */
219
+ env?: NodeJS.ProcessEnv;
212
220
  /**
213
221
  * The account that created the project, for the pin (W-8, #812; E1, #1598). `bin.ts`
214
222
  * supplies it on the CREATE arm only, from `confirmActingIdentity`'s answer; every
@@ -369,6 +377,8 @@ export { BUNDLE_PIN_REL };
369
377
  /** The crew roster. Exported for the monorepo uninstall (`self-build-install` AC8),
370
378
  * which must name this one path to check its bytes rather than remove it by path. */
371
379
  export declare const CREW_ROSTER_REL = ".halfcycle/crew.json";
380
+ /** The captured-fixture index, written only when absent. */
381
+ export declare const CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
372
382
  /**
373
383
  * The three files the installer merges into rather than owns. `SETTINGS_REL` lives
374
384
  * with the hook table in `@halfcycle/core` and is re-exported here, as is
@@ -378,6 +388,15 @@ export { SETTINGS_REL, WRITE_CAPABLE_TOOLS };
378
388
  export declare const GITIGNORE_REL = ".gitignore";
379
389
  /** The attributes file the installer appends its line-ending rules to. */
380
390
  export declare const GITATTRIBUTES_REL = ".gitattributes";
391
+ /**
392
+ * The connection-headers helper an earlier version generated and `.mcp.json` named
393
+ * (T-24, #446). RETIRED by `windows-client-seat`: Claude Code runs a `headersHelper`
394
+ * under `cmd.exe` on Windows, where this POSIX script cannot run, so the registration
395
+ * now names a `node` command (`MCP_HEADERS_HELPER_COMMAND`) and the script's work is
396
+ * the vendored runner's `mcp-headers` verb. Declared here, above the list it joins,
397
+ * because that list is read at module load.
398
+ */
399
+ export declare const RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
381
400
  export interface ClosedWriteSet {
382
401
  /** Files the installer writes whole. */
383
402
  readonly files: ReadonlySet<string>;
@@ -399,6 +418,8 @@ export interface ClosedWriteSet {
399
418
  readonly gitattributesLines: ReadonlySet<string>;
400
419
  /** Path to the SHA-256 of the bytes this version writes there, for every member outside `.halfcycle/` whose bytes do not depend on the project. */
401
420
  readonly generatedSha256: ReadonlyMap<string, string>;
421
+ /** The canonical hash of every `statusLine` setting the installer writes (`HALFCYCLE_STATUS_LINE`). */
422
+ readonly statusLineSha256: ReadonlySet<string>;
402
423
  }
403
424
  /**
404
425
  * Deny rules an earlier version generated and this one no longer does. Retiring
@@ -478,6 +499,65 @@ export declare function closedWriteSet(): ClosedWriteSet;
478
499
  * (`startManifest`), so a committed record cannot vouch for a path that is not ours.
479
500
  */
480
501
  export declare function bytesAreOurs(path: string, bytes: Uint8Array, recorded: ManifestFile | undefined, writeSet: ClosedWriteSet): boolean;
502
+ /**
503
+ * Read the manifest an earlier install wrote, for this run to carry forward.
504
+ *
505
+ * AN UNREADABLE ONE IS TREATED AS ABSENT, and that is the safe direction here (it is
506
+ * the opposite of uninstall, which refuses one). This run is about to rewrite it
507
+ * from what it observes; the cost of reading it as absent is that this run is
508
+ * treated as the first manifest run, which claims LESS — nothing it cannot prove —
509
+ * and marks the merged files adopted rather than created, which makes uninstall
510
+ * remove less. Refusing the install over a file the install is about to rewrite
511
+ * would leave a tree with a corrupt manifest no command could repair.
512
+ */
513
+ declare function readPreviousManifest(targetRepo: string): InstallManifest | null;
514
+ /** The bytes a path holds once the install has run (see `noteFile`). */
515
+ type BytesAfter = (rel: string) => Uint8Array;
516
+ /**
517
+ * Which files set up here git would leave out of a commit (`setup-travel-warning`), or
518
+ * `null` when there is nothing to say.
519
+ *
520
+ * THE PATHS COME FROM THE MANIFEST THIS RUN WROTE, NEVER FROM THE HOOK TABLE (decision
521
+ * 15). The table is what a machine runs; this is every file the installer set up, and
522
+ * a new installer path joins it by being recorded, which `assertManifestInWriteSet`
523
+ * already holds to the closed write set. So: the manifest's files except the
524
+ * per-machine one, the merged files it records, and the manifest itself — less this
525
+ * run's collisions (a developer's own file at one of our names was not set up here,
526
+ * even when an earlier manifest recorded the name) and less anything absent from disk.
527
+ *
528
+ * A path is dropped too when a directory above it, below `targetRepo`, is a symbolic
529
+ * link or holds a `.git` entry (a submodule or nested repository). Git refuses the
530
+ * whole batch over one such path, so a symlinked or submodule `.claude` would
531
+ * otherwise silence an ignored `.halfcycle/` or `.mcp.json`. A symlinked FILE is
532
+ * answered normally and stays.
533
+ *
534
+ * The named file is `.claude/settings.json` when it is ignored, else the first ignored
535
+ * path in sorted order. Reads only: nothing here writes to the tree or to git.
536
+ */
537
+ declare function findNotShared(targetRepo: string, manifest: InstallManifest, collidedPaths: readonly string[]): NotShared | null;
538
+ /** A planned command stub: a `PlannedFile` plus whether a collision is an edit of ours (E8). */
539
+ export interface PlannedCommand extends PlannedFile {
540
+ readonly editedCollided: boolean;
541
+ }
542
+ /**
543
+ * One file the installer's plan decided about (`install-plan.ts`), at a path inside the
544
+ * folder. `text` is what the file holds once the plan is applied: the bytes written for
545
+ * `written`, the bytes already there for `skipped`, and for `collided` the bytes that
546
+ * would have been written, which are not. `replacedExisting` is true only when a
547
+ * DIFFERING file of ours is overwritten, never on a fresh write or an identical one.
548
+ */
549
+ export interface PlannedFile {
550
+ readonly rel: string;
551
+ readonly text: string;
552
+ readonly outcome: CopyOutcome;
553
+ readonly replacedExisting: boolean;
554
+ }
555
+ /**
556
+ * The ONE write a planned file gets (`setup-preview-and-doctor` AC1): its planned bytes,
557
+ * at its path, only when the plan says `written`, and only inside the write allowlist.
558
+ * The decision was taken by the plan; this never re-reads the file to take it again.
559
+ */
560
+ export declare function putPlannedFile(targetRepoRoot: string, planned: PlannedFile): void;
481
561
  /**
482
562
  * The write outcome for one target path under the copy set.
483
563
  * - `written` — the file did not exist and was created.
@@ -494,6 +574,19 @@ type CopyOutcome = 'written' | 'skipped' | 'collided';
494
574
  * whose enforcement nothing exercises is the shape this area keeps paying for.
495
575
  * It is not an API: every caller inside this module goes through `recordOwned`. */
496
576
  export declare function writeOwned(targetAbsPath: string, targetRepoRoot: string, content: string): CopyOutcome;
577
+ /** The four path buckets the install accumulates and returns. */
578
+ interface MutableReport {
579
+ writtenPaths: string[];
580
+ skippedPaths: string[];
581
+ mergedPaths: string[];
582
+ collidedPaths: string[];
583
+ /** A SUBSET of `collidedPaths` (E8): the previous manifest already named this
584
+ * path as ours. See `InstallResult.editedCollidedPaths`. */
585
+ editedCollidedPaths: string[];
586
+ /** A SUBSET of `writtenPaths`, not a fifth bucket: files of ours that already
587
+ * existed, differed, and were overwritten (`GWF-2`). See `recordOwnedGenerated`. */
588
+ replacedPaths: string[];
589
+ }
497
590
  /**
498
591
  * The `permissions.deny` patterns a fresh install seeds (F-23(a), operator
499
592
  * decision note 2026-08-10). THE ONE HOME of the list — the method's served step
@@ -582,6 +675,54 @@ export declare function settingsParseRefusal(targetRepo: string, err: unknown):
582
675
  * the developer set after install is theirs and stays.
583
676
  */
584
677
  export declare const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
678
+ /**
679
+ * THE STATUS LINE THE INSTALLER WRITES (`builds-paced-to-plan` AC7): the runner's
680
+ * `statusline` verb, through the same wrapper and in the same
681
+ * `bash "$CLAUDE_PROJECT_DIR"/<script> <verb>` form as every hook command
682
+ * (`@halfcycle/core`'s `hookCommand`). The verb saves the share of the usage
683
+ * window Claude Code reports to it, and prints the person's own status line in
684
+ * place of Halfcycle's when their settings name one.
685
+ *
686
+ * `"$CLAUDE_PROJECT_DIR"` AND NOT `${CLAUDE_PROJECT_DIR:-.}`: the spec's fallback
687
+ * form was for a status line command run without that variable, and the capture
688
+ * shows it set for one on both machines (`G-11`, premise 1;
689
+ * `packages/runner/test/fixtures/usage-limit/premises.md`).
690
+ *
691
+ * WRITTEN ONLY WHERE NONE IS SET (`statusLineSetElsewhere`, `mergeSettings`), and
692
+ * recorded by its canonical hash, so uninstall takes it out only while it is still
693
+ * exactly this (`uninstall.ts`, `unmergeSettings`). Changing this value is a
694
+ * retirement: the old value's hash stays in `closedWriteSet`'s `statusLineSha256`.
695
+ */
696
+ export declare const HALFCYCLE_STATUS_LINE: StatusLineSetting;
697
+ /**
698
+ * WHETHER THE PERSON ALREADY HAS A STATUS LINE SOMEWHERE THE INSTALLER DOES NOT WRITE
699
+ * (`builds-paced-to-plan` AC7). Three of the four places the spec names; the fourth,
700
+ * the project's own `.claude/settings.json`, is the file the merge is given, and
701
+ * `mergeSettings` checks it itself:
702
+ *
703
+ * 1. the project's `.claude/settings.local.json`;
704
+ * 2. `~/.claude/settings.json` (`home` stands in for `~`);
705
+ * 3. `$CLAUDE_CONFIG_DIR/settings.json`, only when that variable is set where the
706
+ * installer runs. A leading `~` is the home folder and a relative value is
707
+ * taken from the working folder; symbolic links are followed as the file read
708
+ * follows them, which reads the same file Claude Code would.
709
+ *
710
+ * "SET" IS A `statusLine` KEY, WHATEVER ITS VALUE. And A FILE THAT EXISTS BUT CANNOT
711
+ * BE READ AS A JSON OBJECT COUNTS AS SET: the installer cannot tell whether it names
712
+ * a status line, and writing one over a line the person may have is the direction
713
+ * that hides their own. A missing file is not set.
714
+ *
715
+ * READ ONLY. Nothing here writes, creates or touches any of these files; the
716
+ * installer's only settings write is the project's `.claude/settings.json`.
717
+ *
718
+ * WHAT IT CANNOT SEE: a status line set after install in any of these places, and a
719
+ * `CLAUDE_CONFIG_DIR` folder used only inside Claude Code. The runner's verb covers
720
+ * those at every render by running the person's own line in place of Halfcycle's
721
+ * (AC7, *A person's own status line is never hidden*), except one set later in the
722
+ * project's `settings.local.json`, which Claude Code reads first, so Halfcycle's
723
+ * verb never runs there.
724
+ */
725
+ export declare function statusLineSetElsewhere(targetRepo: string, home?: string, env?: NodeJS.ProcessEnv): boolean;
585
726
  /**
586
727
  * Generate the .claude/settings.json hook entries + permissions for the target
587
728
  * repo.
@@ -656,6 +797,12 @@ export type RetiredFileRemoval = 'removed' | 'kept-foreign' | 'kept-uncommitted'
656
797
  * otherwise-successful install.
657
798
  */
658
799
  export declare function removeLegacyCiStanza(targetRepo: string): RetiredFileRemoval;
800
+ /** The removal half: only ever reached for a file the plan decided `removed`. Best-effort. */
801
+ export declare function removePlannedRetiredFile(targetRepo: string, rel: string): 'removed' | 'failed';
802
+ /** The path of the CI stanza an earlier version wrote; nothing writes it any more. */
803
+ export declare const LEGACY_CI_STANZA_REL = ".halfcycle/ci-stanza.yml";
804
+ /** `removeLegacyCiStanza`'s decision, by reading alone. */
805
+ export declare function planLegacyCiStanzaRemoval(targetRepo: string): RetiredFileRemoval;
659
806
  /**
660
807
  * **THE INSTALLER WRITES NO `.env.example`, AND THIS IS WHERE THAT GENERATOR WAS.**
661
808
  *
@@ -719,6 +866,8 @@ export { MCP_REGISTRATION_REL, MCP_SERVER_KEY };
719
866
  * with no MCP origin rewrites no registration, so it removes nothing.
720
867
  */
721
868
  export declare function removeRetiredMcpHeadersHelper(targetRepo: string): RetiredFileRemoval;
869
+ /** `removeRetiredMcpHeadersHelper`'s decision, by reading alone. */
870
+ export declare function planMcpHeadersHelperRemoval(targetRepo: string): RetiredFileRemoval;
722
871
  /**
723
872
  * THE ONE HOME of *which generated files this installer claims as its own, and by
724
873
  * what evidence* (#1103). Every entry is a client-tree path this package
@@ -761,6 +910,7 @@ export declare const OWNED_GENERATED_HEADERS: {
761
910
  readonly '.claude/hooks/guard-runner.sh': "# Governance hook wrapper — generated by";
762
911
  readonly '.claude/hooks/user-prompt-reminder.sh': "# UserPromptSubmit house-rule reminder — generated by";
763
912
  readonly '.claude/hooks/user-prompt-scope.sh': "# Typed scope approval — generated by";
913
+ readonly '.claude/hooks/user-prompt-findings.sh': "# Findings hand-over — generated by";
764
914
  readonly '.claude/hooks/session-end-marker.sh': "# Session-end marker cleanup — generated by";
765
915
  readonly '.claude/hooks/session-start-marker.sh': "# Session-start marker — generated by";
766
916
  };
@@ -813,6 +963,32 @@ export declare function lineEndingOf(text: string): '\n' | '\r\n';
813
963
  * CRLF script, which is why `.gitattributes` pins them LF in every clone.
814
964
  */
815
965
  export declare function inLineEndingOf(existing: string | null, generated: string): string;
966
+ /**
967
+ * Ensure `.gitignore` carries every required entry, reconciled BY CONTENT — a
968
+ * newer required entry is appended even on a tree an older install wrote.
969
+ *
970
+ * BEST-EFFORT BY DESIGN: a write failure (e.g. a read-only `.gitignore`) does NOT
971
+ * throw here — and since T-09 there is no longer a hard gate downstream of it,
972
+ * because the thing that gate protected (a bearer token in the tree) is not written
973
+ * any more. What is left on this path is per-device state and Zone-B, whose worst
974
+ * case is a noisy `git status`, not a leaked credential. `failed` is reported so the operator
975
+ * sees the reconcile did not land.
976
+ *
977
+ * @returns the outcome for the report bucket.
978
+ */
979
+ export declare function planGitignore(targetRepo: string): PlannedMerge;
980
+ /** The write half of the `.gitignore` reconcile: the planned bytes, or `failed`. Never throws. */
981
+ export declare function putPlannedGitignore(targetRepo: string, planned: PlannedMerge): CopyOutcome | 'failed';
982
+ /**
983
+ * A planned reconcile of a file the installer appends to (`.gitignore`, `.gitattributes`):
984
+ * the text before, the text after (`null` when there is none to read), and the outcome.
985
+ * `failed` means the plan already knows the write cannot land; nothing is attempted.
986
+ */
987
+ export interface PlannedMerge {
988
+ readonly before: string | null;
989
+ readonly after: string | null;
990
+ readonly outcome: CopyOutcome | 'failed';
991
+ }
816
992
  /**
817
993
  * The header line every `.gitattributes` block this installer appends opens with
818
994
  * (`windows-install-hygiene.md`, AC1). Git for Windows usually clones with
@@ -820,6 +996,99 @@ export declare function inLineEndingOf(existing: string | null, generated: strin
820
996
  * line endings; the rules under this header pin them to LF in every clone.
821
997
  */
822
998
  export declare const GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
999
+ /** A file's text, or `null` when it is absent or cannot be read as a file. */
1000
+ export declare function readTextOrNull(path: string): string | null;
1001
+ /**
1002
+ * Ensure `.gitattributes` carries every required LF rule, reconciled BY CONTENT the
1003
+ * way `reconcileGitignore` reconciles `.gitignore`: a missing rule is appended under
1004
+ * the header, a rule already present (the developer's or an earlier run's) is never
1005
+ * appended twice, and no existing byte is rewritten.
1006
+ *
1007
+ * Routed through `writeAllowlisted`, so this write is held to `WRITE_ALLOWLIST` —
1008
+ * which `reconcileGitignore`'s bare write is not. BEST-EFFORT, as that one is: a
1009
+ * failure (a read-only file, or the allowlist refusing) is caught and returned as
1010
+ * `failed`, and the install goes on. The worst case of a missing rule is a hook
1011
+ * script a Windows clone checks out with CR line endings.
1012
+ *
1013
+ * A SYMBOLIC LINK at `.gitattributes` is refused as `failed`, never written through:
1014
+ * the allowlist checks the path's name, and a link could point outside the tree.
1015
+ */
1016
+ export declare function planGitattributes(targetRepo: string): PlannedMerge;
1017
+ /** The write half of the `.gitattributes` reconcile, held to the allowlist. Never throws. */
1018
+ export declare function putPlannedGitattributes(targetRepo: string, planned: PlannedMerge): CopyOutcome | 'failed';
1019
+ /**
1020
+ * Decide the committed pin's bytes (`writeBundlePin` writes them). Reads only.
1021
+ *
1022
+ * THE CREATOR'S ID IS CARRIED FORWARD, and that is the whole reason this function reads
1023
+ * before it writes. The pin is rewritten from scratch on every install, and only the
1024
+ * create arm supplies the id (E1, #1598); a re-run, a join and a refused join supply
1025
+ * none. Composing a fresh pin from one of those would silently DELETE the record the
1026
+ * create wrote — the exact disappearance this field exists to prevent — and letting
1027
+ * one of them supply its own acting account (the pre-E1 rule) made a joiner in a clone
1028
+ * rewrite the committed owner to itself. So the previous value wins over any supplied
1029
+ * one, and a pin carrying the pre-E1 name `accountId` is read through
1030
+ * `pinCreatedByAccountId` and written back as `createdByAccountId`.
1031
+ *
1032
+ * **`installedAt` IS CARRIED FORWARD TOO, ALWAYS, WITHIN THE SAME ENGAGEMENT (F-02,
1033
+ * #1192 — H-04 walk observation D, 2026-09-17).** The field means THE MOMENT THIS
1034
+ * ENGAGEMENT WAS FIRST SET UP IN THIS REPOSITORY — not the moment the last install ran,
1035
+ * and not anything about a machine. Until this fix it was `new Date()` on every write, so
1036
+ * `npx halfcycle` in a second clone of an already-set-up project (the join arm: same
1037
+ * engagement, same version, same account) rewrote the committed pin with a new timestamp
1038
+ * and left that clone's tree dirty with a change nobody meant to commit — and the
1039
+ * criterion-8 test pinned that as a documented exemption rather than as the defect it
1040
+ * was. A committed file must not carry a per-run value: what it records is a fact about
1041
+ * the repository, which every clone shares, so a re-run that changes no fact must leave
1042
+ * the bytes alone. The rule is therefore the same one the creator's id already follows —
1043
+ * carried within the same engagement, fresh for a different one — and a re-install of the
1044
+ * same engagement at the same version by any account is byte-identical. A version
1045
+ * upgrade changes `version` and nothing else: the set-up moment did not move because the
1046
+ * installer did.
1047
+ *
1048
+ * IT IS CARRIED ONLY WITHIN THE SAME ENGAGEMENT. A run that installs a different
1049
+ * engagement id into this repository (an explicit id argument, or a join) is a different
1050
+ * tenancy, and the previous engagement's creator is not a fact about it: such a run is
1051
+ * not a create, so the pin records nothing rather than a name inherited from an
1052
+ * engagement it has nothing to do with. The same
1053
+ * holds for the set-up moment: a repository re-pointed at another engagement was set up
1054
+ * for THAT engagement now.
1055
+ *
1056
+ * A pin that cannot be read is treated as no pin. This is advisory provenance, and an
1057
+ * unparseable file must not fail an install. A previous pin whose `installedAt` is
1058
+ * missing or blank gets this run's clock — the only value there is — rather than an
1059
+ * empty field.
1060
+ */
1061
+ declare function planBundlePin(targetRepoRoot: string, version: string, engagementId: string, engagementType: EngagementType, createdByAccountId: string | undefined, methodVersion: string | undefined): PlannedFile;
1062
+ /**
1063
+ * The preview's best prediction for the committed pin, read from this repository's OWN
1064
+ * pin and credential store rather than from the service (W-10 R2, #F-65; tightened by
1065
+ * review R-F65 finding 1). A preview never signs in, so it has no FRESH answer for the
1066
+ * method version — and a real run only ever carries the committed pin's method version
1067
+ * forward UNCHANGED, with no network call of its own, on exactly one arm: the RE-USE
1068
+ * arm (`bin.ts`), reached when this machine already holds a complete, usable credential
1069
+ * for this SAME engagement (`readPinnedEngagement`), because `toCredential` never
1070
+ * stores a method version in that local store at all, so `planBundlePin` always falls
1071
+ * back to the previous pin's own value there (`carriedMethodVersion`, above). Every
1072
+ * OTHER real run — a join from a second clone, a fresh container, a credential this
1073
+ * machine never had — asks a live plane for a method version this offline preview
1074
+ * cannot know, and the plane can answer something the committed pin does not expect (a
1075
+ * project whose phase moved on since this pin was last written). Claiming `leave` there
1076
+ * is a guess, not a prediction, and review R-F65 found it wrong in exactly that shape:
1077
+ * a repository pinned to an older method, joined while the service now serves a newer
1078
+ * one, previewed as unchanged and then was not. So this function predicts `leave` (or
1079
+ * an exact `change`) ONLY on the re-use arm; every other case returns `null`, and
1080
+ * `install-plan.ts` leaves the row `PENDING` — `change` by existence alone — exactly as
1081
+ * AC1/AC3 already document for a file whose bytes need the service's fresh answer.
1082
+ * `home` is the preview's own (a test's throwaway, or the real one), read-only, same as
1083
+ * everything else `planInstall` already reads from a folder it never signs in from.
1084
+ */
1085
+ export declare function planBundlePinPreview(targetRepoRoot: string, version: string, home?: string): PlannedFile | null;
1086
+ /**
1087
+ * Write the committed pin a plan decided (`planBundlePin`), through `writeAllowlisted`
1088
+ * alone, so identical bytes are no write and a changed pin lands in `writtenPaths`. One of
1089
+ * the two functions allowed to write the pin.
1090
+ */
1091
+ declare function writeBundlePin(targetRepoRoot: string, planned: PlannedFile, writtenPaths: string[]): void;
823
1092
  /**
824
1093
  * Rewrite the committed pin's `methodVersion` field after `close-phase` or
825
1094
  * `open-phase` moves the project forward between phases
@@ -1013,6 +1282,179 @@ export declare function checkDrift(targetRepoRoot: string): {
1013
1282
  /** The method version the committed pin recorded (T-01), or `null` when none was. */
1014
1283
  methodVersion: string | null;
1015
1284
  };
1285
+ /**
1286
+ * Create `.halfcycle/project.json` (committed identity) when there is none. The file
1287
+ * carries no secret — only the minted id and the repo's root-commit/remote
1288
+ * fingerprint (criterion 2 / operator decision (au)).
1289
+ *
1290
+ * ONE RULE: AN EXISTING `project.json` IS NEVER WRITTEN (`project-identity-reuse`
1291
+ * AC1). Until the onboarding fix wave (T-05, from review R-03) this reconciled by
1292
+ * bytes and rewrote any file that differed from what this installer would write —
1293
+ * an older installer's file without `note`, CRLF line endings, an extra field — by
1294
+ * truncate-then-write, which also raced a concurrent install's read of the same
1295
+ * file. Now a present file is `skipped` whatever its bytes, and an absent one is
1296
+ * created through `createProjectIdentityFile`, whose `linkSync` never replaces a
1297
+ * file that appeared in between. The cost, accepted: an older file never gains the
1298
+ * current `note` (W8-T-04, F-4); a fresh install still writes it.
1299
+ *
1300
+ * **ONE EXCEPTION (#1744, G1): `repairRemoteCredential` runs on a present file
1301
+ * before it is reported `skipped`**, and rewrites it ONLY when its `remote` still
1302
+ * carries a credential — review R-34, F1: this is THE real install path every
1303
+ * run takes (`bin.ts` calls `install()` on every arm), and the first cut of this
1304
+ * fix lived only in `mintOrReadIdentity`, which nothing here ever called, so a
1305
+ * real run left the credential in place. Best-effort and silent either way.
1306
+ *
1307
+ * **WRITTEN FROM THE PLAN (`setup-preview-and-doctor` AC1).** What happens here was
1308
+ * decided by `planProjectIdentity` before anything was written: `add` creates the file
1309
+ * with the identity the plan minted, `change` is the G1 repair with the plan's bytes,
1310
+ * `leave` writes nothing. The create arm's `claimFolderId` calls this same function
1311
+ * with `probe` set, so its write-access check runs on every call, as it always has.
1312
+ *
1313
+ * @returns the outcome for the report bucket: `written` for `add`, else `skipped`.
1314
+ */
1315
+ export declare function writeProjectIdentity(targetRepo: string, planned: PlannedProjectIdentity, probe?: boolean): CopyOutcome;
1316
+ /**
1317
+ * Persist the engagement's credential to the MACHINE-LEVEL store, outside this
1318
+ * repository (T-09) — `~/.halfcycle/engagements/<id>/env`, owner-only.
1319
+ *
1320
+ * **The refusal contract that used to live here is RETIRED, not forgotten.**
1321
+ * Operator decision (av) made this function throw when `.gitignore` did not cover
1322
+ * `.env.local`, so a live bearer token was never written into a tree that would
1323
+ * commit it on the next `git add .`. Nothing is written into the tree any more, so
1324
+ * that refusal now protects a file that does not exist — and a builder who kept it
1325
+ * would refuse installs over its absence. The property it defended is stronger
1326
+ * without it: the credential cannot be committed because it is not there, rather
1327
+ * than because a tracked ignore file says not to.
1328
+ *
1329
+ * NON-DESTRUCTIVE, still: the store is APPEND-RECONCILED by key
1330
+ * (`reconcileEnvText`), because Studio's provider writes its own keys into the same
1331
+ * per-engagement file — `CONTROL_TELEMETRY_TOKEN` and `HALFCYCLE_GUARD_EVAL_LOG_DIR`
1332
+ * — and a whole-file overwrite here would silently delete them.
1333
+ *
1334
+ * **`CONTROL_TELEMETRY_URL` is NOT in that "Studio's own key" set, and saying it was
1335
+ * would be a false claim (review finding, T-10 follow-up).** This docblock named
1336
+ * `CONTROL_TELEMETRY_*` as a whole family until this correction, which was true
1337
+ * before T-10 and stopped being true once `CONTROL_TELEMETRY_URL` joined
1338
+ * `ENGAGEMENT_ENV_KEYS` — it is reconciled BY THIS installer now, exactly like
1339
+ * `HALFCYCLE_TOKEN`, and a Studio-written value for it is overwritten on the next
1340
+ * Connect install rather than preserved. `CONTROL_TELEMETRY_TOKEN` alone is the
1341
+ * still-genuinely-foreign example.
1342
+ *
1343
+ * The engagement id travels into the store as a value, and no name reaches the
1344
+ * client's tree at all: since 2026-08-30 the installer generates no env scaffold, so
1345
+ * what this engagement was created AGAINST is recorded only here, at machine level.
1346
+ */
1347
+ declare function writeEngagementCredential(credential: DeviceCredential, engagementId: string, home?: string): CopyOutcome;
1348
+ /**
1349
+ * What the installer's plan expects the machine-level store to hold once the credential
1350
+ * is written, and whether that write changes it — computed with the store's own
1351
+ * reconcile (`reconcileEnvText`), reading the store and writing nothing. The legacy
1352
+ * migration is PLANNED against `storedAfter`; it is still DECIDED again at apply time
1353
+ * against the store read back from disk (`migrateLegacyEnvLocal`'s rule).
1354
+ */
1355
+ export declare function planEngagementCredential(credential: DeviceCredential, engagementId: string, home?: string): {
1356
+ outcome: CopyOutcome;
1357
+ existed: boolean;
1358
+ storedAfter: Record<string, string>;
1359
+ };
1360
+ /** Where the credential used to live, before T-09. Named once, and only the
1361
+ * migration below may use it. */
1362
+ export declare const LEGACY_ENV_LOCAL_REL = ".env.local";
1363
+ /** What the legacy-credential migration did, for the installer's report. */
1364
+ export interface LegacyMigration {
1365
+ outcome: 'removed' | 'stripped' | 'failed' | 'absent' | 'kept';
1366
+ /**
1367
+ * On `kept` ONLY: the Halfcycle keys the in-tree file carries that the
1368
+ * machine-level store does not, i.e. the values that would have been destroyed.
1369
+ * Empty on every other outcome.
1370
+ */
1371
+ unbacked: string[];
1372
+ /**
1373
+ * On `kept` ONLY: which of `CREDENTIAL_REQUIRED_KEYS` this file cannot supply —
1374
+ * the actual REASON the store was not written, derived rather than narrated. A
1375
+ * pre-#439 repo is missing `HALFCYCLE_MCP_URL` and may or may not be missing
1376
+ * `GUARD_SERVICE_URL`; a message that guesses which would be wrong for half of them.
1377
+ */
1378
+ missingForAdoption: string[];
1379
+ /** On a `stripped` DECISION only: the file's text with our keys taken out. */
1380
+ keptText?: string;
1381
+ }
1382
+ /**
1383
+ * DECIDE (and since the plan-then-apply split, only decide; `applyLegacyMigration`
1384
+ * writes) taking the seven Halfcycle keys OUT of a pre-T-09 `.env.local` — but ONLY once the
1385
+ * machine-level store demonstrably holds them — and delete the file if that is all
1386
+ * it held.
1387
+ *
1388
+ * **This is the step that makes the feature true for a repository that already
1389
+ * exists.** Writing the machine-level store while leaving the old file in place
1390
+ * would satisfy every "a fresh install writes no credential" assertion and leave a
1391
+ * live bearer token in the tree of every repo installed before today — the exact
1392
+ * exposure this task closes, surviving in the population that actually has one.
1393
+ *
1394
+ * ---------------------------------------------------------------------------
1395
+ * **THE RULE IS: NEVER REMOVE THIS REPOSITORY'S COPY UNLESS A COPY NOW EXISTS
1396
+ * OUTSIDE IT.** `stored` is the machine-level store READ BACK FROM DISK after the
1397
+ * write at step 7c, and this function strips nothing that is not in it.
1398
+ *
1399
+ * This is not defensive dressing; it is the review blocker. The first version ran
1400
+ * unconditionally, on the stated ground that a repo reaching this point must have
1401
+ * supplied a credential. It need not, and **the population where it does not is
1402
+ * exactly the population the migration exists for.** `toCredential` yields a
1403
+ * credential only when ALL FOUR of `HALFCYCLE_SERVICE_URL`, `HALFCYCLE_TOKEN`,
1404
+ * `HALFCYCLE_MCP_URL` and `GUARD_SERVICE_URL` are present; `HALFCYCLE_MCP_URL`
1405
+ * entered the write set on 2026-08-08 (#439) and `GUARD_SERVICE_URL` on 2026-08-10
1406
+ * (FW-4). Every engagement installed before those dates has a `.env.local` holding
1407
+ * a live token and NOT those keys — so the store write was skipped, and this
1408
+ * function deleted the only copy of a session token that cannot be re-minted, then
1409
+ * reported *"REMOVED — the credential now lives outside this repository"*, at exit
1410
+ * 0, when nothing had been written outside the repository at all.
1411
+ *
1412
+ * Keying on the store's CONTENTS rather than on "was a credential passed in" is
1413
+ * deliberate: a partial or truncated write reads as missing keys here and takes the
1414
+ * same refusal, whereas the `credential !== undefined` test would call it a success.
1415
+ * The comparison is PRESENCE, not value equality — a store holding a NEWER token
1416
+ * for the same engagement must still be allowed to retire the stale in-tree copy,
1417
+ * which is the whole point; what may never happen is dropping a key the store has
1418
+ * no value for at all.
1419
+ *
1420
+ * And the copy that remains must be USABLE, not merely present — see the two
1421
+ * conditions at the refusal below. A store that holds some of the keys but cannot
1422
+ * produce a whole `DeviceCredential` leaves the NEXT command with nothing in either
1423
+ * place, which is a quieter failure than this one.
1424
+ *
1425
+ * ---------------------------------------------------------------------------
1426
+ * **IT REMOVES OUR KEYS, NOT THE DEVELOPER'S FILE.** `.env.local` is where a
1427
+ * developer keeps their own `DATABASE_URL` — that is why the write path was made
1428
+ * append-reconciling in the first place (#375) — so deleting it wholesale would
1429
+ * destroy work the installer never owned. Every line that is not one of ours
1430
+ * survives byte-for-byte, and the file is `unlink`ed only when what remains is
1431
+ * nothing but our own header and blank lines.
1432
+ *
1433
+ * **A file holding NONE of our keys is not ours to touch, whatever else is in it.**
1434
+ * The removal test used to be "what survives the strip is only blank lines and our
1435
+ * header", which is true of a file the installer never wrote — an empty
1436
+ * `.env.local`, or one holding a single `# Halfcycle …` note of the developer's own
1437
+ * — so those were deleted and reported as the credential move. The presence of one
1438
+ * of our keys is now the gate on touching the file at all.
1439
+ *
1440
+ * BEST-EFFORT on the rewrite: a failure (a read-only file, say) is reported, never
1441
+ * thrown. The credential is already safe in the machine store by the time a rewrite
1442
+ * is attempted; failing the whole install over a file we are trying to clean up
1443
+ * would turn a successful migration into a broken one.
1444
+ */
1445
+ export declare function migrateLegacyEnvLocal(targetRepo: string, stored: Record<string, string>): LegacyMigration;
1446
+ /**
1447
+ * The write half of the migration: carry out a `removed` or `stripped` decision,
1448
+ * best-effort (`failed`, never thrown). Every other decision writes nothing.
1449
+ *
1450
+ * **THE APPLY DECIDES AGAIN, ON PURPOSE.** The plan decides this migration against the
1451
+ * store it EXPECTS after the credential write (`planEngagementCredential`), which is
1452
+ * what the preview lists. The rule above is that the tree's copy goes only once a copy
1453
+ * demonstrably lives outside it, so `applyInstallPlan` reads the store back from disk
1454
+ * after its credential write and calls `migrateLegacyEnvLocal` again; only that
1455
+ * decision reaches this function. A plan's prediction never deletes a credential.
1456
+ */
1457
+ export declare function applyLegacyMigration(targetRepo: string, decided: LegacyMigration): LegacyMigration;
1016
1458
  /**
1017
1459
  * Install the bundle into a target engagement repo.
1018
1460
  *
@@ -1039,6 +1481,155 @@ export declare function checkDrift(targetRepoRoot: string): {
1039
1481
  * 11. Whether git ignores any of it — read, never changed (`setup-travel-warning`)
1040
1482
  */
1041
1483
  export declare function install(options: InstallOptions): Promise<InstallResult>;
1484
+ /**
1485
+ * Every decision the installer's plan takes, in write order. Built by `planInstall`
1486
+ * (`install-plan.ts`) from the planning functions below, applied by
1487
+ * `applyInstallPlan`, and turned into the report and the install record by
1488
+ * `accountInstall` — the same function for the plan's prediction and the apply's
1489
+ * result, so the two cannot list different paths.
1490
+ *
1491
+ * `engagementId` absent is a PREVIEW: the service's answer (the project id, the
1492
+ * credential, the method version) is still to come, so the pin, the server entry and
1493
+ * the install record are `pending`, and such steps cannot be applied.
1494
+ */
1495
+ export interface InstallSteps {
1496
+ readonly targetRepo: string;
1497
+ readonly version: string;
1498
+ readonly engagementId: string | undefined;
1499
+ readonly engagementType: EngagementType | undefined;
1500
+ readonly credential: DeviceCredential | undefined;
1501
+ readonly home: string | undefined;
1502
+ readonly writeSet: ClosedWriteSet;
1503
+ readonly previous: InstallManifest | null;
1504
+ /** The write set's directories already there BEFORE anything is written. */
1505
+ readonly dirsBefore: ReadonlySet<string>;
1506
+ readonly commands: readonly PlannedCommand[];
1507
+ readonly vendored: PlannedFile;
1508
+ readonly settings: PlannedSettings;
1509
+ readonly hooks: readonly PlannedFile[];
1510
+ readonly ciStanza: RetiredFileRemoval;
1511
+ readonly mcp: PlannedMcp;
1512
+ /** The retired connection-headers helper; `null` when no server entry is written. */
1513
+ readonly mcpHelper: RetiredFileRemoval | null;
1514
+ readonly gitignore: PlannedMerge;
1515
+ readonly gitattributes: PlannedMerge;
1516
+ readonly identity: PlannedProjectIdentity;
1517
+ /** `null` when no credential is supplied (nothing is written outside the folder). */
1518
+ readonly credentialWrite: {
1519
+ readonly outcome: CopyOutcome;
1520
+ readonly existed: boolean;
1521
+ readonly path: string;
1522
+ } | null;
1523
+ /** The legacy migration as PLANNED; the apply decides it again (see `applyLegacyMigration`). */
1524
+ readonly legacy: LegacyMigration;
1525
+ /** `null` while the project id is pending (a preview). */
1526
+ readonly pin: PlannedFile | null;
1527
+ readonly crew: PlannedFile;
1528
+ readonly scan: {
1529
+ readonly text: string | null;
1530
+ };
1531
+ }
1532
+ /** The `.claude/settings.json` merge as planned. */
1533
+ export interface PlannedSettings {
1534
+ readonly file: PlannedFile;
1535
+ readonly preexisted: boolean;
1536
+ readonly existing: ClaudeSettings;
1537
+ readonly generated: ClaudeSettings;
1538
+ readonly merged: ClaudeSettings;
1539
+ readonly retiredOurs: readonly string[];
1540
+ }
1541
+ /** The `.mcp.json` server entry as planned. */
1542
+ export type PlannedMcp = {
1543
+ readonly kind: 'write';
1544
+ readonly existing: string | null;
1545
+ readonly text: string;
1546
+ readonly outcome: 'written' | 'merged' | 'skipped';
1547
+ } | {
1548
+ readonly kind: 'none';
1549
+ readonly existing: string | null;
1550
+ } | {
1551
+ readonly kind: 'pending';
1552
+ readonly existing: string | null;
1553
+ };
1554
+ /** The plugin's version and the command stubs it declares, decided against this folder. */
1555
+ export declare function planCommandStubs(targetRepo: string, previous: InstallManifest | null, writeSet: ClosedWriteSet): {
1556
+ version: string;
1557
+ commands: PlannedCommand[];
1558
+ };
1559
+ /** The install record an earlier install left here, or `null`. Reads only. */
1560
+ export { readPreviousManifest };
1561
+ /**
1562
+ * 4. The vendored guard binary — the SELF-CONTAINED esbuild output at
1563
+ * `.halfcycle/bin/bin.bundle.mjs`, owned BY PATH (T-08 walk, F-2): collision-safety froze
1564
+ * it at whichever build first landed. It is ~600 kB with no header this package
1565
+ * controls, so evidence-based ownership is not available here — a constraint, not a choice.
1566
+ */
1567
+ export declare function planVendoredBinary(targetRepo: string): PlannedFile;
1568
+ /**
1569
+ * 5. `.claude/settings.json` — MERGED, never replaced (criterion 4): every developer key
1570
+ * kept, Halfcycle's hook entries upserted by identity. An unparseable file throws
1571
+ * `SettingsParseRefused` with the same sentence the create arm prints (T-16, #1567).
1572
+ * The retired deny rules this install's own record says Halfcycle added are taken out
1573
+ * once; the status line is offered only when none is set elsewhere (`builds-paced-to-plan`
1574
+ * AC7), those other files read and never written.
1575
+ */
1576
+ export declare function planSettings(targetRepo: string, previous: InstallManifest | null, home: string | undefined, env: NodeJS.ProcessEnv | undefined): PlannedSettings;
1577
+ /**
1578
+ * EVERY GENERATED HOOK SCRIPT, decided through `decideOwnedGenerated` — the criterion-5
1579
+ * rule (#1103; G-02, #1065): a file carrying OUR generated header prefix on its header
1580
+ * line is replaced, one that does not is `collided`. The freeze collision-safety caused
1581
+ * was walked, not inferred (#1103).
1582
+ */
1583
+ export declare function planHookScripts(targetRepo: string): PlannedFile[];
1584
+ /**
1585
+ * 6b. The remote MCP registration (`.mcp.json`, T-11) — merged with the developer's own
1586
+ * servers, the credential carried BY REFERENCE only (`MCP_HEADERS_HELPER_COMMAND`).
1587
+ * Written ONLY when the MCP origin is known (T-24): no credential is `none`, and a
1588
+ * preview, whose credential is still to come, is `pending`.
1589
+ */
1590
+ export declare function planMcpRegistration(targetRepo: string, credential: DeviceCredential | 'pending' | undefined): PlannedMcp;
1591
+ /** The write half of the server entry, through `writeAllowlisted`. */
1592
+ export declare function putPlannedMcp(targetRepo: string, planned: PlannedMcp): void;
1593
+ /** 8b. The crew roster, owned by path (see `writeCrewRoster`). */
1594
+ export declare function planCrewRoster(targetRepo: string): PlannedFile;
1595
+ /** 10. The install record, owned by path, decided once its bytes are known. */
1596
+ export declare function planInstallRecord(targetRepo: string, text: string): PlannedFile;
1597
+ export { writeBundlePin, planBundlePin, writeEngagementCredential, findNotShared };
1598
+ /**
1599
+ * What actually happened at the apply, for the steps whose write can fail without
1600
+ * throwing (or, for the legacy migration, is decided again against the store read back
1601
+ * from disk). For the plan's own prediction these are the planned outcomes.
1602
+ */
1603
+ export interface InstallOutcomes {
1604
+ readonly ciStanza: RetiredFileRemoval;
1605
+ readonly mcpHelper: RetiredFileRemoval | null;
1606
+ readonly gitignore: CopyOutcome | 'failed';
1607
+ readonly gitignoreAfter: string | null;
1608
+ readonly gitattributes: CopyOutcome | 'failed';
1609
+ readonly gitattributesAfter: string | null;
1610
+ readonly credential: CopyOutcome | null;
1611
+ readonly legacy: LegacyMigration;
1612
+ /** The bytes a path holds once the install has run (read back by the apply). */
1613
+ readonly bytesAfter: BytesAfter;
1614
+ /** Whether a write-set directory exists once the install has run. */
1615
+ readonly dirExistsAfter: (dir: string) => boolean;
1616
+ /** Whether `.halfcycle/state.json` exists once the install has run. */
1617
+ readonly stateExistsAfter: boolean;
1618
+ }
1619
+ /**
1620
+ * Build the install's report and its install record from the plan's steps and what
1621
+ * happened (`InstallOutcomes`): the ONE place either is assembled, so the plan's
1622
+ * prediction and the apply's result list the same paths by construction. Writes
1623
+ * nothing. Requires a complete plan (no `pending` step).
1624
+ */
1625
+ export declare function accountInstall(steps: InstallSteps, actual: InstallOutcomes): {
1626
+ report: MutableReport;
1627
+ installManifest: InstallManifest;
1628
+ manifestText: string;
1629
+ };
1630
+ /** Record the install record's own write, last in the report (step 10). */
1631
+ export declare function recordInstallRecord(report: MutableReport, planned: PlannedFile): void;
1632
+ export type { MutableReport, CopyOutcome, BytesAfter };
1042
1633
  /** The one ignore line `.halfcycle/.gitignore` holds: the runner copy stays out of git.
1043
1634
  * Exported: the monorepo uninstall (`self-build-install` AC8) removes this path by
1044
1635
  * name from the same constant. */