@hraness/message-like-me 0.8.5 → 0.8.7

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
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.7 (2026-09-09)
4
+
5
+ - Make the exact public `@hraness/message-like-me@0.8.7` npm package the
6
+ supported default install, with the same reviewed bytes mirrored in the
7
+ immutable GitHub Release.
8
+ - Show how an unsent draft can use evidence about tone, rhythm, and attention
9
+ before introducing import commands. The website examples remain synthetic;
10
+ the site never receives message history or produces drafts.
11
+ - Make supported sources and their limits easier to scan, with import details
12
+ available beside each source. Local-only ingestion and drafts-only behavior
13
+ remain unchanged.
14
+ - Strengthen the development Effect architecture checker. Public protocols,
15
+ the source readers, and command behavior are unchanged.
16
+
17
+ ## 0.8.6 (2026-09-07)
18
+
19
+ - Make the exact public `@hraness/message-like-me@0.8.6` npm package the
20
+ supported default install, with the same reviewed bytes mirrored in the
21
+ immutable GitHub Release.
22
+ - Give the paired Message Like Me and Ensoul skill installer one command-owned
23
+ lifecycle through copying, backup, publication and sequential recovery.
24
+ - Bound physical inventories and verify copied bytes before publication. Retain
25
+ unknown or substituted entries during cleanup, and preserve missing project
26
+ directory creation beneath a verified physical ancestor.
27
+ - Keep both installed skills after publication succeeds, even when backup cleanup
28
+ or output confirmation fails. Report incomplete cleanup with fixed diagnostics
29
+ that omit private data while preserving the original failure.
30
+ - Strengthen the development Effect checker with generator alias and wrapper
31
+ regressions. Public protocol graphs and local-only behavior remain unchanged.
32
+
3
33
  ## 0.8.5 (2026-09-06)
4
34
 
5
35
  - Make the exact public `@hraness/message-like-me@0.8.5` npm package the
package/README.md CHANGED
@@ -43,7 +43,7 @@ Message Like Me requires Bun 1.3.14 or newer. Install the exact public npm
43
43
  package, then install both bundled Agent Skills:
44
44
 
45
45
  ```sh
46
- bun add --global @hraness/message-like-me@0.8.5
46
+ bun add --global @hraness/message-like-me@0.8.7
47
47
  messagelikeme skill install
48
48
  ```
49
49
 
package/dist/cli.js CHANGED
@@ -19637,117 +19637,527 @@ async function readStablePrivateJson(path, label, maximumBytes) {
19637
19637
  }
19638
19638
 
19639
19639
  // src/skill-install.ts
19640
- import { randomBytes as randomBytes2 } from "crypto";
19641
- import { cp, lstat as lstat4, mkdir as mkdir2, realpath as realpath3, rename, rm } from "fs/promises";
19642
- import { homedir as homedir4 } from "os";
19643
- import { dirname as dirname3, join as join7, resolve as resolve5 } from "path";
19640
+ import { dirname as dirname3, resolve as resolve5 } from "path";
19644
19641
  import { fileURLToPath } from "url";
19645
- async function exists5(path) {
19646
- try {
19647
- await lstat4(path);
19648
- return true;
19649
- } catch (error) {
19650
- if (error.code === "ENOENT")
19651
- return false;
19652
- throw error;
19653
- }
19654
- }
19655
19642
  function bundledSkillPath() {
19656
19643
  return resolve5(dirname3(fileURLToPath(import.meta.url)), "../skills/message-like-me");
19657
19644
  }
19658
19645
  function bundledEnsoulSkillPath() {
19659
19646
  return resolve5(dirname3(fileURLToPath(import.meta.url)), "../skills/ensoul");
19660
19647
  }
19661
- function targetRoot(target, scope5, projectDirectory) {
19662
- const directory = target === "codex" ? ".codex" : target === "claude" ? ".claude" : ".agents";
19663
- return scope5 === "user" ? join7(homedir4(), directory, "skills") : join7(resolve5(projectDirectory), directory, "skills");
19648
+
19649
+ // src/skill-install-model.ts
19650
+ var SKILL_INSTALL_PAIR = ["message-like-me", "ensoul"];
19651
+ function skillInstallWarning(outcome) {
19652
+ if (outcome.residuals.length === 0 && !(outcome.committed && outcome.operation._tag === "Failure"))
19653
+ return null;
19654
+ const state = outcome.committed ? "committed" : "not committed";
19655
+ const details = outcome.residuals.map(({ skill, phase }) => `${skill}:${phase}`).join(", ");
19656
+ return `Skill installation ${state}; ${details.length > 0 ? `cleanup incomplete (${details})` : "output confirmation failed"}. Retained installation files need review.
19657
+ `;
19664
19658
  }
19665
- async function installSkill(options) {
19666
- const sources = Object.freeze([
19667
- Object.freeze({ name: "message-like-me", path: bundledSkillPath() }),
19668
- Object.freeze({ name: "ensoul", path: bundledEnsoulSkillPath() })
19669
- ]);
19670
- for (const source of sources) {
19671
- if (!await exists5(source.path)) {
19672
- throw new CliError("not-found", `Bundled ${source.name} skill is missing at ${source.path}`);
19659
+
19660
+ // src/skill-install-program.ts
19661
+ class SkillInstallPlatform extends exports_Context.Tag("@hraness/message-like-me/SkillInstallPlatform")() {
19662
+ }
19663
+ function installSkillProgram(options) {
19664
+ return exports_Effect.uninterruptible(exports_Effect.gen(function* () {
19665
+ const platform2 = yield* SkillInstallPlatform;
19666
+ const prepared = yield* exports_Effect.exit(platform2.preflight(options).pipe(exports_Effect.flatMap((plan) => platform2.transaction(plan))));
19667
+ if (exports_Exit.isFailure(prepared))
19668
+ return { operation: exports_Exit.failCause(prepared.cause), committed: false, residuals: [] };
19669
+ const transaction = prepared.value;
19670
+ let committed = false;
19671
+ const operation = yield* exports_Effect.exit(exports_Effect.gen(function* () {
19672
+ yield* platform2.acquire(transaction);
19673
+ for (const skill of SKILL_INSTALL_PAIR)
19674
+ yield* platform2.copy(transaction, skill);
19675
+ for (const skill of SKILL_INSTALL_PAIR)
19676
+ yield* platform2.backup(transaction, skill);
19677
+ for (const skill of SKILL_INSTALL_PAIR)
19678
+ yield* platform2.publish(transaction, skill);
19679
+ committed = true;
19680
+ }));
19681
+ const residuals = [];
19682
+ const cleanup = (skill, phase) => exports_Effect.gen(function* () {
19683
+ const result = yield* exports_Effect.exit(platform2.cleanup(transaction, skill, phase));
19684
+ if (exports_Exit.isFailure(result))
19685
+ residuals.push({ skill, phase, cause: result.cause });
19686
+ });
19687
+ if (!committed) {
19688
+ for (const skill of [...SKILL_INSTALL_PAIR].reverse()) {
19689
+ yield* cleanup(skill, "rollback-published");
19690
+ yield* cleanup(skill, "restore-backup");
19691
+ yield* cleanup(skill, "stage-cleanup");
19692
+ }
19693
+ } else {
19694
+ for (const skill of SKILL_INSTALL_PAIR)
19695
+ yield* cleanup(skill, "backup-cleanup");
19673
19696
  }
19674
- const sourceMetadata = await lstat4(source.path);
19675
- if (sourceMetadata.isSymbolicLink() || !sourceMetadata.isDirectory()) {
19676
- throw new CliError("unsafe-path", `Bundled ${source.name} skill must be a physical directory`);
19697
+ const output = exports_Exit.isFailure(operation) ? exports_Exit.failCause(operation.cause) : yield* exports_Effect.exit(platform2.destinations(transaction));
19698
+ yield* cleanup("pair", "transaction-cleanup");
19699
+ yield* cleanup("pair", "lock-cleanup");
19700
+ return { operation: output, committed, residuals };
19701
+ }));
19702
+ }
19703
+
19704
+ // src/skill-install-platform.ts
19705
+ import { createHash as createHash5, randomBytes as randomBytes2 } from "crypto";
19706
+ import { constants as constants2 } from "fs";
19707
+ import * as fs from "fs/promises";
19708
+ import { homedir as homedir4 } from "os";
19709
+ import { basename as basename4, dirname as dirname4, join as join7, relative, resolve as resolve6, sep } from "path";
19710
+ var MAX_ENTRIES = 64;
19711
+ var MAX_FILE_BYTES = 256 * 1024;
19712
+ var MAX_TOTAL_BYTES = 2 * 1024 * 1024;
19713
+ var MAX_DEPTH = 8;
19714
+ var plans = new WeakMap;
19715
+ var transactions = new WeakMap;
19716
+ var unsafe = () => new CliError("unsafe-path", "Skill installation path changed or cannot be used safely");
19717
+ var bounded = () => new CliError("invalid-data", "Skill directory exceeds the supported physical inventory bounds");
19718
+ function isMissing(error) {
19719
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
19720
+ }
19721
+ async function optionalStat(path) {
19722
+ try {
19723
+ return await fs.lstat(path, { bigint: true });
19724
+ } catch (error) {
19725
+ if (isMissing(error))
19726
+ return null;
19727
+ throw error;
19728
+ }
19729
+ }
19730
+ function physical(stat2) {
19731
+ if (typeof process.getuid !== "function" || !stat2.isDirectory() && !stat2.isFile() || stat2.uid !== BigInt(process.getuid()))
19732
+ throw unsafe();
19733
+ }
19734
+ function same(left3, right3) {
19735
+ return left3.dev === right3.dev && left3.ino === right3.ino && left3.mode === right3.mode && left3.uid === right3.uid && left3.isDirectory() === right3.isDirectory() && (left3.isDirectory() || left3.size === right3.size && left3.nlink === right3.nlink && left3.mtimeNs === right3.mtimeNs && left3.ctimeNs === right3.ctimeNs);
19736
+ }
19737
+ async function assertIdentity(path, expected) {
19738
+ const actual = await optionalStat(path);
19739
+ if (actual === null || !same(actual, expected))
19740
+ throw unsafe();
19741
+ }
19742
+ function contains5(parent, child) {
19743
+ const path = relative(parent, child);
19744
+ return path === "" || path !== ".." && !path.startsWith(`..${sep}`) && !path.startsWith(sep);
19745
+ }
19746
+
19747
+ class ReadCloseFailure {
19748
+ primary;
19749
+ cleanup;
19750
+ constructor(primary, cleanup) {
19751
+ this.primary = primary;
19752
+ this.cleanup = cleanup;
19753
+ }
19754
+ }
19755
+ function nativeFailure(error) {
19756
+ const cleanup = [];
19757
+ while (error instanceof ReadCloseFailure) {
19758
+ cleanup.push(error.cleanup);
19759
+ error = error.primary;
19760
+ }
19761
+ return cleanup.length === 0 ? commandFailure(error) : { ...commandFailure(error), nativeCleanupCauses: Object.freeze(cleanup.reverse()) };
19762
+ }
19763
+ async function digestFile(path, expected) {
19764
+ if (expected.size > BigInt(MAX_FILE_BYTES) || expected.size < 0n)
19765
+ throw bounded();
19766
+ const handle = await fs.open(path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
19767
+ let result;
19768
+ try {
19769
+ if (!same(await handle.stat({ bigint: true }), expected))
19770
+ throw unsafe();
19771
+ const hash2 = createHash5("sha256");
19772
+ const buffer = Buffer.alloc(16 * 1024);
19773
+ let count = 0;
19774
+ for (;; ) {
19775
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, Number(expected.size) + 1 - count), count);
19776
+ count += bytesRead;
19777
+ if (count > Number(expected.size))
19778
+ throw unsafe();
19779
+ if (bytesRead === 0)
19780
+ break;
19781
+ hash2.update(buffer.subarray(0, bytesRead));
19677
19782
  }
19783
+ if (count !== Number(expected.size) || !same(await handle.stat({ bigint: true }), expected))
19784
+ throw unsafe();
19785
+ result = { ok: true, value: hash2.digest("hex") };
19786
+ } catch (error) {
19787
+ result = { ok: false, error };
19678
19788
  }
19679
- const root = targetRoot(options.target, options.scope, options.projectDirectory ?? process.cwd());
19680
- await mkdir2(root, { recursive: true, mode: 448 });
19681
- const destinations = sources.map((source) => Object.freeze({
19682
- ...source,
19683
- destination: join7(root, source.name)
19684
- }));
19685
- for (const item of destinations) {
19686
- if (await exists5(item.destination)) {
19687
- const metadata = await lstat4(item.destination);
19688
- if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
19689
- throw new CliError("unsafe-path", `Refusing to replace non-directory ${item.destination}`);
19789
+ try {
19790
+ await handle.close();
19791
+ } catch (error) {
19792
+ if (!result.ok)
19793
+ throw new ReadCloseFailure(result.error, error);
19794
+ throw error;
19795
+ }
19796
+ if (!result.ok)
19797
+ throw result.error;
19798
+ return result.value;
19799
+ }
19800
+ async function inventory(root) {
19801
+ const entries2 = [];
19802
+ let bytes = 0n;
19803
+ const visit = async (name, depth) => {
19804
+ if (depth > MAX_DEPTH || name.length > 4096 || entries2.length >= MAX_ENTRIES)
19805
+ throw bounded();
19806
+ const path = join7(root, name);
19807
+ const stat2 = await fs.lstat(path, { bigint: true });
19808
+ physical(stat2);
19809
+ if (stat2.isFile()) {
19810
+ bytes += stat2.size;
19811
+ if (bytes > BigInt(MAX_TOTAL_BYTES))
19812
+ throw bounded();
19813
+ entries2.push({ name, stat: stat2, digest: await digestFile(path, stat2) });
19814
+ } else {
19815
+ entries2.push({ name, stat: stat2 });
19816
+ const directory = await fs.opendir(path);
19817
+ let read = { ok: true };
19818
+ try {
19819
+ for (;; ) {
19820
+ const child = await directory.read();
19821
+ if (child === null)
19822
+ break;
19823
+ await visit(join7(name, child.name), depth + 1);
19824
+ }
19825
+ } catch (error) {
19826
+ read = { ok: false, error };
19690
19827
  }
19691
- if (!options.force) {
19692
- throw new CliError("conflict", `Skill already exists at ${item.destination}; pass --force to replace both bundled skills`);
19828
+ try {
19829
+ await directory.close();
19830
+ } catch (error) {
19831
+ if (!read.ok)
19832
+ throw new ReadCloseFailure(read.error, error);
19833
+ throw error;
19693
19834
  }
19835
+ if (!read.ok)
19836
+ throw read.error;
19837
+ await assertIdentity(path, stat2);
19694
19838
  }
19695
- }
19696
- const nonce = `${process.pid}.${randomBytes2(8).toString("hex")}`;
19697
- const state = destinations.map((item) => ({
19698
- ...item,
19699
- stage: join7(root, `.${item.name}.install.${nonce}`),
19700
- backup: join7(root, `.${item.name}.backup.${nonce}`),
19701
- hadExisting: false,
19702
- published: false
19703
- }));
19704
- try {
19705
- for (const item of state) {
19706
- await cp(item.path, item.stage, { recursive: true, errorOnExist: true });
19839
+ };
19840
+ await visit("", 0);
19841
+ if (!entries2[0]?.stat.isDirectory())
19842
+ throw unsafe();
19843
+ return entries2;
19844
+ }
19845
+ function sameContents(left3, right3) {
19846
+ const byName = new Map(right3.map((entry) => [entry.name, entry]));
19847
+ return left3.length === right3.length && left3.every((entry) => {
19848
+ const other = byName.get(entry.name);
19849
+ return other !== undefined && entry.stat.isDirectory() === other.stat.isDirectory() && entry.stat.size === (entry.stat.isFile() ? other.stat.size : entry.stat.size) && entry.digest === other.digest;
19850
+ });
19851
+ }
19852
+ async function assertInventory(root, expected) {
19853
+ const actual = await inventory(root);
19854
+ if (!sameContents(expected, actual))
19855
+ throw unsafe();
19856
+ const byName = new Map(actual.map((entry) => [entry.name, entry.stat]));
19857
+ if (!expected.every((entry) => {
19858
+ const value = byName.get(entry.name);
19859
+ return value !== undefined && same(entry.stat, value);
19860
+ }))
19861
+ throw unsafe();
19862
+ }
19863
+ async function parents(plan) {
19864
+ for (const parent of plan.parents)
19865
+ await assertIdentity(parent.path, parent.stat);
19866
+ }
19867
+ function state(token3) {
19868
+ const value = transactions.get(token3);
19869
+ if (value === undefined)
19870
+ throw unsafe();
19871
+ return value;
19872
+ }
19873
+ async function custody(transaction) {
19874
+ await parents(transaction.plan);
19875
+ if (transaction.lockStat !== undefined)
19876
+ await assertIdentity(transaction.lock, transaction.lockStat);
19877
+ if (transaction.directoryStat !== undefined)
19878
+ await assertIdentity(transaction.directory, transaction.directoryStat);
19879
+ }
19880
+ async function absent(path) {
19881
+ if (await optionalStat(path) !== null)
19882
+ throw unsafe();
19883
+ }
19884
+ async function projectAnchor(requested) {
19885
+ let cursor = resolve6(requested);
19886
+ if (Buffer.byteLength(cursor, "utf8") > 4096)
19887
+ throw unsafe();
19888
+ const suffix = [];
19889
+ for (;; ) {
19890
+ let existing;
19891
+ try {
19892
+ existing = await fs.realpath(cursor);
19893
+ } catch (error) {
19894
+ if (!isMissing(error))
19895
+ throw error;
19896
+ if (await optionalStat(cursor) !== null || suffix.length >= 64)
19897
+ throw unsafe();
19898
+ const parent = dirname4(cursor);
19899
+ if (parent === cursor)
19900
+ throw error;
19901
+ suffix.push(basename4(cursor));
19902
+ cursor = parent;
19903
+ continue;
19707
19904
  }
19708
- for (const item of state) {
19709
- if (await exists5(item.destination)) {
19710
- await rename(item.destination, item.backup);
19711
- item.hadExisting = true;
19905
+ const stat2 = await fs.lstat(existing, { bigint: true });
19906
+ physical(stat2);
19907
+ if (!stat2.isDirectory())
19908
+ throw unsafe();
19909
+ const missing = [];
19910
+ let anchor = existing;
19911
+ for (const name of suffix.reverse()) {
19912
+ anchor = join7(anchor, name);
19913
+ missing.push(anchor);
19914
+ }
19915
+ return { anchor, parents: [{ path: existing, stat: stat2 }], missing };
19916
+ }
19917
+ }
19918
+ async function preflight(options) {
19919
+ const { anchor, parents: observed, missing } = await projectAnchor(options.scope === "user" ? homedir4() : options.projectDirectory ?? process.cwd());
19920
+ const folder = options.target === "agents" ? ".agents" : options.target === "codex" ? ".codex" : ".claude";
19921
+ const root = join7(anchor, folder, "skills");
19922
+ for (const path of [join7(anchor, folder), root]) {
19923
+ const stat2 = await optionalStat(path);
19924
+ if (stat2 === null)
19925
+ missing.push(path);
19926
+ else {
19927
+ physical(stat2);
19928
+ if (!stat2.isDirectory())
19929
+ throw unsafe();
19930
+ observed.push({ path, stat: stat2 });
19931
+ }
19932
+ }
19933
+ const items = {};
19934
+ for (const name of SKILL_INSTALL_PAIR) {
19935
+ const sourcePath = name === "message-like-me" ? bundledSkillPath() : bundledEnsoulSkillPath();
19936
+ const sourceStat = await optionalStat(sourcePath);
19937
+ if (sourceStat === null)
19938
+ throw new CliError("not-found", `Bundled ${name} skill is missing at ${sourcePath}`);
19939
+ physical(sourceStat);
19940
+ if (!sourceStat.isDirectory())
19941
+ throw unsafe();
19942
+ const source = await fs.realpath(sourcePath);
19943
+ if (contains5(source, root) || contains5(root, source))
19944
+ throw unsafe();
19945
+ const sourceInventory = await inventory(source);
19946
+ const destination = join7(root, name);
19947
+ const current = await optionalStat(destination);
19948
+ if (current !== null) {
19949
+ physical(current);
19950
+ if (!current.isDirectory())
19951
+ throw new CliError("unsafe-path", `Refusing to replace non-directory ${destination}`);
19952
+ if (!options.force)
19953
+ throw new CliError("conflict", `Skill already exists at ${destination}; pass --force to replace both bundled skills`);
19954
+ }
19955
+ items[name] = { source, destination, sourceInventory, original: current === null ? null : await inventory(destination) };
19956
+ }
19957
+ return { parents: observed, missing, root, items };
19958
+ }
19959
+ async function removeObserved(transaction, root, entries2) {
19960
+ for (const entry of [...entries2].reverse()) {
19961
+ await custody(transaction);
19962
+ for (const parent of entries2) {
19963
+ if (parent.stat.isDirectory() && contains5(join7(root, parent.name), join7(root, entry.name))) {
19964
+ await assertIdentity(join7(root, parent.name), parent.stat);
19965
+ }
19966
+ }
19967
+ const path = join7(root, entry.name);
19968
+ await assertIdentity(path, entry.stat);
19969
+ if (entry.stat.isDirectory())
19970
+ await fs.rmdir(path);
19971
+ else
19972
+ await fs.unlink(path);
19973
+ }
19974
+ }
19975
+ var foreign = (operation) => exports_Effect.uninterruptible(exports_Effect.tryPromise({ try: operation, catch: nativeFailure }));
19976
+ var skillInstallPlatform = {
19977
+ preflight: (options) => foreign(async () => {
19978
+ const plan = await preflight(options);
19979
+ const token3 = Object.freeze({ _tag: "SkillInstallPlan" });
19980
+ plans.set(token3, plan);
19981
+ return token3;
19982
+ }),
19983
+ transaction: (token3) => exports_Effect.try({ try: () => {
19984
+ const plan = plans.get(token3);
19985
+ if (plan === undefined)
19986
+ throw unsafe();
19987
+ const directory = join7(plan.root, `.skill-install.${randomBytes2(16).toString("hex")}`);
19988
+ const itemState = (name) => ({ stage: join7(directory, `${name}.stage`), backup: join7(directory, `${name}.backup`), staged: [], backupAttempted: false, publishAttempted: false });
19989
+ const transaction = Object.freeze({ _tag: "SkillInstallTransaction" });
19990
+ transactions.set(transaction, {
19991
+ plan,
19992
+ lock: join7(plan.root, ".message-like-me.install-lock"),
19993
+ lockAttempted: false,
19994
+ directory,
19995
+ directoryAttempted: false,
19996
+ items: { "message-like-me": itemState("message-like-me"), ensoul: itemState("ensoul") }
19997
+ });
19998
+ return transaction;
19999
+ }, catch: commandFailure }),
20000
+ acquire: (token3) => foreign(async () => {
20001
+ const transaction = state(token3);
20002
+ await parents(transaction.plan);
20003
+ for (const path of transaction.plan.missing) {
20004
+ await parents(transaction.plan);
20005
+ await fs.mkdir(path, { mode: 448 });
20006
+ const stat2 = await fs.lstat(path, { bigint: true });
20007
+ physical(stat2);
20008
+ if (!stat2.isDirectory())
20009
+ throw unsafe();
20010
+ transaction.plan.parents.push({ path, stat: stat2 });
20011
+ }
20012
+ await parents(transaction.plan);
20013
+ transaction.lockAttempted = true;
20014
+ await fs.mkdir(transaction.lock, { mode: 448 });
20015
+ transaction.lockStat = await fs.lstat(transaction.lock, { bigint: true });
20016
+ physical(transaction.lockStat);
20017
+ if (!transaction.lockStat.isDirectory())
20018
+ throw unsafe();
20019
+ await custody(transaction);
20020
+ transaction.directoryAttempted = true;
20021
+ await fs.mkdir(transaction.directory, { mode: 448 });
20022
+ transaction.directoryStat = await fs.lstat(transaction.directory, { bigint: true });
20023
+ physical(transaction.directoryStat);
20024
+ if (!transaction.directoryStat.isDirectory())
20025
+ throw unsafe();
20026
+ }),
20027
+ copy: (token3, name) => foreign(async () => {
20028
+ const transaction = state(token3);
20029
+ const item = transaction.plan.items[name];
20030
+ const current = transaction.items[name];
20031
+ await custody(transaction);
20032
+ await assertInventory(item.source, item.sourceInventory);
20033
+ for (const entry of item.sourceInventory) {
20034
+ await custody(transaction);
20035
+ for (const parent of current.staged) {
20036
+ if (parent.stat.isDirectory() && contains5(join7(current.stage, parent.name), join7(current.stage, entry.name))) {
20037
+ await assertIdentity(join7(current.stage, parent.name), parent.stat);
20038
+ }
19712
20039
  }
20040
+ const target = join7(current.stage, entry.name);
20041
+ await assertIdentity(join7(item.source, entry.name), entry.stat);
20042
+ if (entry.stat.isDirectory())
20043
+ await fs.mkdir(target, { mode: 448 });
20044
+ else
20045
+ await fs.cp(join7(item.source, entry.name), target, { force: false, errorOnExist: true, dereference: false });
20046
+ const stat2 = await fs.lstat(target, { bigint: true });
20047
+ physical(stat2);
20048
+ if (stat2.isDirectory() !== entry.stat.isDirectory())
20049
+ throw unsafe();
20050
+ current.staged.push({ name: entry.name, stat: stat2, ...entry.digest === undefined ? {} : { digest: entry.digest } });
20051
+ }
20052
+ const copied = await inventory(current.stage);
20053
+ if (!sameContents(item.sourceInventory, copied))
20054
+ throw unsafe();
20055
+ await assertInventory(item.source, item.sourceInventory);
20056
+ current.staged = [...copied];
20057
+ }),
20058
+ backup: (token3, name) => foreign(async () => {
20059
+ const transaction = state(token3);
20060
+ const item = transaction.plan.items[name];
20061
+ const current = transaction.items[name];
20062
+ await custody(transaction);
20063
+ await absent(current.backup);
20064
+ if (item.original === null) {
20065
+ await absent(item.destination);
20066
+ return;
19713
20067
  }
19714
- for (const item of state) {
19715
- await rename(item.stage, item.destination);
19716
- item.published = true;
19717
- }
19718
- } catch (error) {
19719
- for (const item of [...state].reverse()) {
19720
- if (item.published)
19721
- await rm(item.destination, { recursive: true, force: true }).catch(() => {
19722
- return;
19723
- });
19724
- if (item.hadExisting) {
19725
- await rename(item.backup, item.destination).catch(() => {
19726
- return;
19727
- });
20068
+ await assertInventory(item.destination, item.original);
20069
+ await custody(transaction);
20070
+ current.backupAttempted = true;
20071
+ await fs.rename(item.destination, current.backup);
20072
+ }),
20073
+ publish: (token3, name) => foreign(async () => {
20074
+ const transaction = state(token3);
20075
+ const item = transaction.plan.items[name];
20076
+ const current = transaction.items[name];
20077
+ await custody(transaction);
20078
+ await assertInventory(current.stage, current.staged);
20079
+ await custody(transaction);
20080
+ await absent(item.destination);
20081
+ current.publishAttempted = true;
20082
+ await fs.rename(current.stage, item.destination);
20083
+ }),
20084
+ cleanup: (token3, name, phase) => foreign(async () => {
20085
+ const transaction = state(token3);
20086
+ if (phase === "lock-cleanup") {
20087
+ if (transaction.lockStat === undefined) {
20088
+ if (transaction.lockAttempted) {
20089
+ await parents(transaction.plan);
20090
+ await absent(transaction.lock);
20091
+ }
20092
+ return;
19728
20093
  }
19729
- await rm(item.stage, { recursive: true, force: true }).catch(() => {
20094
+ await parents(transaction.plan);
20095
+ await assertIdentity(transaction.lock, transaction.lockStat);
20096
+ await fs.rmdir(transaction.lock);
20097
+ return;
20098
+ }
20099
+ if (phase === "transaction-cleanup") {
20100
+ if (transaction.directoryStat === undefined) {
20101
+ if (transaction.directoryAttempted) {
20102
+ await parents(transaction.plan);
20103
+ await absent(transaction.directory);
20104
+ }
19730
20105
  return;
19731
- });
20106
+ }
20107
+ await custody(transaction);
20108
+ await fs.rmdir(transaction.directory);
20109
+ return;
19732
20110
  }
19733
- throw error;
19734
- }
19735
- for (const item of state) {
19736
- if (item.hadExisting) {
19737
- await rm(item.backup, { recursive: true, force: true }).catch(() => {
20111
+ if (name === "pair")
20112
+ throw unsafe();
20113
+ const current = transaction.items[name];
20114
+ const item = transaction.plan.items[name];
20115
+ if (phase === "rollback-published") {
20116
+ if (!current.publishAttempted)
19738
20117
  return;
19739
- });
20118
+ await custody(transaction);
20119
+ const destination = await optionalStat(item.destination);
20120
+ if (destination === null)
20121
+ return;
20122
+ const root = current.staged[0];
20123
+ if (root === undefined || !same(root.stat, destination))
20124
+ throw unsafe();
20125
+ await removeObserved(transaction, item.destination, current.staged);
20126
+ } else if (phase === "restore-backup") {
20127
+ if (!current.backupAttempted || item.original === null)
20128
+ return;
20129
+ await custody(transaction);
20130
+ if (await optionalStat(current.backup) === null) {
20131
+ await assertInventory(item.destination, item.original);
20132
+ return;
20133
+ }
20134
+ await assertInventory(current.backup, item.original);
20135
+ await absent(item.destination);
20136
+ await fs.rename(current.backup, item.destination);
20137
+ } else if (phase === "stage-cleanup") {
20138
+ if (current.staged.length === 0)
20139
+ return;
20140
+ await custody(transaction);
20141
+ if (await optionalStat(current.stage) !== null)
20142
+ await removeObserved(transaction, current.stage, current.staged);
20143
+ } else if (phase === "backup-cleanup") {
20144
+ if (item.original !== null)
20145
+ await removeObserved(transaction, current.backup, item.original);
19740
20146
  }
19741
- }
19742
- return Object.freeze({
19743
- messageLikeMe: await realpath3(join7(root, "message-like-me")),
19744
- ensoul: await realpath3(join7(root, "ensoul"))
19745
- });
19746
- }
20147
+ }),
20148
+ destinations: (token3) => foreign(async () => {
20149
+ const transaction = state(token3);
20150
+ await custody(transaction);
20151
+ for (const name of SKILL_INSTALL_PAIR)
20152
+ await assertInventory(transaction.plan.items[name].destination, transaction.items[name].staged);
20153
+ return Object.freeze({ messageLikeMe: await fs.realpath(transaction.plan.items["message-like-me"].destination), ensoul: await fs.realpath(transaction.plan.items.ensoul.destination) });
20154
+ })
20155
+ };
20156
+ var skillInstallPlatformLive = exports_Layer.succeed(SkillInstallPlatform, skillInstallPlatform);
19747
20157
 
19748
20158
  // src/store.ts
19749
20159
  import { Database as Database3 } from "bun:sqlite";
19750
- import { createHash as createHash5 } from "crypto";
20160
+ import { createHash as createHash6 } from "crypto";
19751
20161
  import {
19752
20162
  closeSync as closeSync2,
19753
20163
  constants as fsConstants6,
@@ -21068,7 +21478,7 @@ function globalCorpusRevision(database) {
21068
21478
  }));
21069
21479
  }
21070
21480
  function sourceStateRevision(database, sourceId) {
21071
- const hash2 = createHash5("sha256");
21481
+ const hash2 = createHash6("sha256");
21072
21482
  hash2.update("message-like-me\x00stored-source-state-v1\x00", "utf8");
21073
21483
  const append4 = (kind, row) => {
21074
21484
  const encoded = canonicalJson(row);
@@ -22677,8 +23087,8 @@ class LocalStore {
22677
23087
  }
22678
23088
  preparedHandoffReceiptStatus(value) {
22679
23089
  const handoff = parseAgentMessageHandoffV1(value);
22680
- const exists6 = get13(this.#database, "SELECT handoff_id FROM agent_message_handoffs WHERE handoff_id=?", handoff.handoffId);
22681
- if (exists6 === null)
23090
+ const exists5 = get13(this.#database, "SELECT handoff_id FROM agent_message_handoffs WHERE handoff_id=?", handoff.handoffId);
23091
+ if (exists5 === null)
22682
23092
  return "absent";
22683
23093
  const audit = this.handoffAudit(handoff.handoffId);
22684
23094
  return audit.handoffSha256 === handoff.integrity.canonicalSha256 && audit.contactIdSha256 === sha256(handoff.contact.contactId) && audit.routeCandidateIdSha256 === sha256(handoff.contact.routeCandidateId) && audit.sourceIdSha256 === sha256(handoff.contact.sourceId) && audit.conversationIdSha256 === sha256(handoff.contact.conversationId) && audit.corpusRevision === handoff.evidence.corpusRevision && audit.sourceRevision === handoff.evidence.sourceRevision && audit.profileState === handoff.evidence.profileState && audit.profileEvidenceRevision === handoff.evidence.profileEvidenceRevision && audit.wrenchContractHash === handoff.wrench.contractHash && audit.routeRefSha256 === sha256(handoff.wrench.routeRef) && audit.contextRefSha256 === sha256(handoff.wrench.contextRef) && audit.exactDataRevisionSha256 === handoff.wrench.exactDataRevision && audit.latestMessageRevisionSha256 === handoff.wrench.latestMessageRevision && audit.turnDigest === wrenchMessagingTurnDigestV1(handoff) && audit.partCount === handoff.turn.bubbles.length && audit.createdAt === handoff.createdAt && audit.expiresAt === handoff.expiresAt ? "committed" : "different";
@@ -22813,17 +23223,17 @@ class LocalStore {
22813
23223
  }
22814
23224
 
22815
23225
  // src/x-archive.ts
22816
- import { createHash as createHash6 } from "crypto";
23226
+ import { createHash as createHash7 } from "crypto";
22817
23227
  import {
22818
23228
  closeSync as closeSync3,
22819
- constants as constants2,
23229
+ constants as constants3,
22820
23230
  fstatSync as fstatSync3,
22821
23231
  lstatSync as lstatSync5,
22822
23232
  openSync as openSync3,
22823
23233
  readSync as readSync3,
22824
23234
  realpathSync as realpathSync4
22825
23235
  } from "fs";
22826
- import { isAbsolute as isAbsolute5, resolve as resolve6 } from "path";
23236
+ import { isAbsolute as isAbsolute5, resolve as resolve7 } from "path";
22827
23237
 
22828
23238
  // src/x-archive-zip.ts
22829
23239
  import { readSync as readSync2 } from "fs";
@@ -22854,7 +23264,7 @@ var U32_MAX = 4294967295;
22854
23264
  var MAX_X_ZIP_ARCHIVE_BYTES = 16 * 1024 * 1024 * 1024;
22855
23265
  var MAX_X_ZIP_MEMBER_BYTES = 256 * 1024 * 1024;
22856
23266
  var MAX_COMPRESSED_MEMBER_BYTES = 64 * 1024 * 1024;
22857
- var MAX_ENTRIES = 1e5;
23267
+ var MAX_ENTRIES2 = 1e5;
22858
23268
  var MAX_CENTRAL_BYTES = 64 * 1024 * 1024;
22859
23269
  var MAX_TOTAL_SELECTED_BYTES = 768 * 1024 * 1024;
22860
23270
  var MAX_TOTAL_DECLARED_BYTES = 64 * 1024 * 1024 * 1024;
@@ -22872,8 +23282,8 @@ var CRC32_TABLE = (() => {
22872
23282
  }
22873
23283
  return table;
22874
23284
  })();
22875
- function updateCrc32(state, bytes) {
22876
- let value = state;
23285
+ function updateCrc32(state2, bytes) {
23286
+ let value = state2;
22877
23287
  for (const byte of bytes)
22878
23288
  value = CRC32_TABLE[(value ^ byte) & 255] ^ value >>> 8;
22879
23289
  return value;
@@ -22941,7 +23351,7 @@ function directoryFromEocd(descriptor3, archiveSize) {
22941
23351
  if (legacyDisk !== 0 || legacyCentralDisk !== 0 || legacyOnDisk !== legacyCount) {
22942
23352
  throw new Error("X ZIP multi-disk archives are not supported");
22943
23353
  }
22944
- if (legacyCount < 1 || legacyCount > MAX_ENTRIES || legacySize > MAX_CENTRAL_BYTES) {
23354
+ if (legacyCount < 1 || legacyCount > MAX_ENTRIES2 || legacySize > MAX_CENTRAL_BYTES) {
22945
23355
  throw new Error("X ZIP central directory exceeds its bounds");
22946
23356
  }
22947
23357
  const end4 = checkedEnd(legacyOffset, legacySize, "central directory");
@@ -22966,7 +23376,7 @@ function directoryFromEocd(descriptor3, archiveSize) {
22966
23376
  const offset = u64(zip64, 48, "ZIP64 central directory offset");
22967
23377
  if (onDisk !== count)
22968
23378
  throw new Error("X ZIP multi-disk archives are not supported");
22969
- if (count < 1 || count > MAX_ENTRIES || size9 > MAX_CENTRAL_BYTES) {
23379
+ if (count < 1 || count > MAX_ENTRIES2 || size9 > MAX_CENTRAL_BYTES) {
22970
23380
  throw new Error("X ZIP central directory exceeds its bounds");
22971
23381
  }
22972
23382
  const end3 = checkedEnd(offset, size9, "ZIP64 central directory");
@@ -23379,7 +23789,7 @@ var PROVIDER_ID = /^[1-9][0-9]{0,39}$/u;
23379
23789
  var OPAQUE_PROVIDER_ID = /^-?[0-9]{1,40}$/u;
23380
23790
  var HANDLE = /^[A-Za-z0-9_]{1,15}$/u;
23381
23791
  function sha2563(value) {
23382
- return createHash6("sha256").update(value).digest("hex");
23792
+ return createHash7("sha256").update(value).digest("hex");
23383
23793
  }
23384
23794
  function plain(value, label) {
23385
23795
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -23725,16 +24135,16 @@ function parseConversations(member, selfId, group, seenConversationIds, seenMess
23725
24135
  if (!/^[0-9]+(?:-[0-9]+)?$/u.test(conversationId)) {
23726
24136
  throw new Error(`${label}.dmConversation.conversationId is invalid`);
23727
24137
  }
23728
- let state = combined.get(conversationId);
23729
- if (state === undefined) {
24138
+ let state2 = combined.get(conversationId);
24139
+ if (state2 === undefined) {
23730
24140
  if (seenConversationIds.has(conversationId)) {
23731
24141
  throw new Error(`${label} repeats an X conversation across direct and group members`);
23732
24142
  }
23733
24143
  seenConversationIds.add(conversationId);
23734
- state = { participants: new Set([selfId]), events: [] };
23735
- combined.set(conversationId, state);
24144
+ state2 = { participants: new Set([selfId]), events: [] };
24145
+ combined.set(conversationId, state2);
23736
24146
  }
23737
- const participants = state.participants;
24147
+ const participants = state2.participants;
23738
24148
  const directIds = group ? null : conversationId.split("-").map((id, index) => providerId(id, `${label}.conversationId[${index}]`));
23739
24149
  if (directIds !== null) {
23740
24150
  if (directIds.length !== 2 || !directIds.includes(selfId)) {
@@ -23779,17 +24189,17 @@ function parseConversations(member, selfId, group, seenConversationIds, seenMess
23779
24189
  if (directIds !== null && [...participants].some((id) => !directIds.includes(id))) {
23780
24190
  throw new Error(`${eventLabel} names a user outside its direct conversation`);
23781
24191
  }
23782
- state.events.push(parsed);
24192
+ state2.events.push(parsed);
23783
24193
  }
23784
24194
  }
23785
24195
  const signatures = new Map;
23786
- const conversations = [...combined.entries()].map(([conversationId, state]) => {
23787
- const orderedEvents = state.events.sort((left3, right3) => left3.createdAt.localeCompare(right3.createdAt) || eventSortKey(left3).localeCompare(eventSortKey(right3)));
24196
+ const conversations = [...combined.entries()].map(([conversationId, state2]) => {
24197
+ const orderedEvents = state2.events.sort((left3, right3) => left3.createdAt.localeCompare(right3.createdAt) || eventSortKey(left3).localeCompare(eventSortKey(right3)));
23788
24198
  signatures.set(conversationId, orderedEvents.filter((event) => event.kind === "message-create").map(eventHeaderSignature).sort());
23789
24199
  return {
23790
24200
  conversationId,
23791
24201
  kind: group ? "group" : "direct",
23792
- participantIds: [...state.participants].sort(),
24202
+ participantIds: [...state2.participants].sort(),
23793
24203
  events: orderedEvents
23794
24204
  };
23795
24205
  });
@@ -24067,7 +24477,7 @@ function parseXArchiveMembers(members) {
24067
24477
  };
24068
24478
  }
24069
24479
  function sha256Descriptor(descriptor3, size9) {
24070
- const digest5 = createHash6("sha256");
24480
+ const digest5 = createHash7("sha256");
24071
24481
  const buffer = Buffer.allocUnsafe(8 * 1024 * 1024);
24072
24482
  let position = 0;
24073
24483
  while (position < size9) {
@@ -24083,18 +24493,18 @@ function sameStat(left3, right3) {
24083
24493
  return left3.dev === right3.dev && left3.ino === right3.ino && left3.size === right3.size && left3.mtimeNs === right3.mtimeNs && left3.ctimeNs === right3.ctimeNs && left3.mode === right3.mode && left3.uid === right3.uid && left3.nlink === right3.nlink;
24084
24494
  }
24085
24495
  async function readXArchive(path) {
24086
- if (typeof path !== "string" || path.length < 1 || path.includes("\x00") || !isAbsolute5(path) || resolve6(path) !== path)
24496
+ if (typeof path !== "string" || path.length < 1 || path.includes("\x00") || !isAbsolute5(path) || resolve7(path) !== path)
24087
24497
  throw new Error("X archive path must be a normalized absolute path");
24088
- let physical;
24498
+ let physical2;
24089
24499
  try {
24090
- physical = realpathSync4(path);
24500
+ physical2 = realpathSync4(path);
24091
24501
  } catch (error) {
24092
24502
  throw new Error("X archive path cannot be resolved", { cause: error });
24093
24503
  }
24094
- if (physical !== path)
24504
+ if (physical2 !== path)
24095
24505
  throw new Error("X archive path must not traverse a symbolic link");
24096
24506
  const pathBefore = lstatSync5(path, { bigint: true });
24097
- const descriptor3 = openSync3(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
24507
+ const descriptor3 = openSync3(path, constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0));
24098
24508
  try {
24099
24509
  const before2 = fstatSync3(descriptor3, { bigint: true });
24100
24510
  const uid = typeof process.getuid === "function" ? BigInt(process.getuid()) : null;
@@ -24146,11 +24556,11 @@ function translatedFailure(translate, error) {
24146
24556
  }
24147
24557
  function commandPlatformLive(io, cleanupFailure) {
24148
24558
  const attempt = (tryOperation) => exports_Effect.try({ try: tryOperation, catch: commandFailure });
24149
- const foreign = (tryOperation) => exports_Effect.tryPromise({ try: tryOperation, catch: commandFailure });
24559
+ const foreign2 = (tryOperation) => exports_Effect.tryPromise({ try: tryOperation, catch: commandFailure });
24150
24560
  const paths = (explicit) => attempt(() => dataPaths(explicit));
24151
- const initialize = (requested) => foreign(() => initializeDataPaths(requested));
24152
- const installKey = (path) => foreign(() => loadOrCreateInstallKey(path));
24153
- const exists6 = (path) => foreign(async () => {
24561
+ const initialize = (requested) => foreign2(() => initializeDataPaths(requested));
24562
+ const installKey = (path) => foreign2(() => loadOrCreateInstallKey(path));
24563
+ const exists5 = (path) => foreign2(async () => {
24154
24564
  try {
24155
24565
  await lstat5(path);
24156
24566
  return true;
@@ -24190,7 +24600,7 @@ function commandPlatformLive(io, cleanupFailure) {
24190
24600
  })));
24191
24601
  const existingSession = (explicit) => exports_Effect.gen(function* () {
24192
24602
  const requested = yield* paths(explicit);
24193
- if (!(yield* exists6(requested.root)) || !(yield* exists6(requested.database))) {
24603
+ if (!(yield* exists5(requested.root)) || !(yield* exists5(requested.database))) {
24194
24604
  return yield* exports_Effect.fail(commandFailure(new CliError("not-found", "Message Like Me is not initialized; run messagelikeme init or an ingest command")));
24195
24605
  }
24196
24606
  const initialized = yield* initialize(requested);
@@ -24218,7 +24628,7 @@ function commandPlatformLive(io, cleanupFailure) {
24218
24628
  paths,
24219
24629
  initialize,
24220
24630
  installKey,
24221
- exists: exists6,
24631
+ exists: exists5,
24222
24632
  existingSession,
24223
24633
  writableSession,
24224
24634
  openStore,
@@ -24226,9 +24636,9 @@ function commandPlatformLive(io, cleanupFailure) {
24226
24636
  readContacts: (path, key) => exports_Effect.try({ try: () => readMacOSContacts(path, { hmacKey: key }), catch: (error) => translatedFailure(translateContactsError, error) }),
24227
24637
  readBundle: (path, key) => exports_Effect.tryPromise({ try: () => readMessageBundle(path, { hmacKey: key }), catch: (error) => translatedFailure(translateBundleError, error) }),
24228
24638
  readXArchive: (path) => exports_Effect.tryPromise({ try: () => readXArchive(path), catch: (error) => translatedFailure(translateXArchiveError, error) }),
24229
- readProfile: (path) => foreign(() => readStyleProfile(path)),
24230
- readPrivateJson: (path, label, maximumBytes) => foreign(() => readStablePrivateJson(path, label, maximumBytes)),
24231
- installSkill: (options) => foreign(() => installSkill(options))
24639
+ readProfile: (path) => foreign2(() => readStyleProfile(path)),
24640
+ readPrivateJson: (path, label, maximumBytes) => foreign2(() => readStablePrivateJson(path, label, maximumBytes)),
24641
+ installSkill: (options) => installSkillProgram(options).pipe(exports_Effect.provide(skillInstallPlatformLive))
24232
24642
  });
24233
24643
  }
24234
24644
 
@@ -24329,10 +24739,10 @@ function rejectUnused(parsed, allowedOptions, allowedFlags) {
24329
24739
  }
24330
24740
 
24331
24741
  // src/command-input.ts
24332
- import { isAbsolute as isAbsolute6, resolve as resolve7 } from "path";
24742
+ import { isAbsolute as isAbsolute6, resolve as resolve8 } from "path";
24333
24743
 
24334
24744
  // src/version.ts
24335
- var MESSAGE_LIKE_ME_VERSION = "0.8.5";
24745
+ var MESSAGE_LIKE_ME_VERSION = "0.8.7";
24336
24746
 
24337
24747
  // src/command-input.ts
24338
24748
  var HELP = `Message Like Me ${MESSAGE_LIKE_ME_VERSION}
@@ -24435,7 +24845,7 @@ function absolutePrivatePath(value, label) {
24435
24845
  throw new CliError("usage", `${label} is required`);
24436
24846
  if (!isAbsolute6(value))
24437
24847
  throw new CliError("unsafe-path", `${label} must be an absolute private path`);
24438
- return resolve7(value);
24848
+ return resolve8(value);
24439
24849
  }
24440
24850
  function handoffExpiry(createdAt, contextExpiresAt, lifetimeMilliseconds) {
24441
24851
  return new Date(Math.min(Date.parse(createdAt) + lifetimeMilliseconds, Date.parse(contextExpiresAt))).toISOString();
@@ -24487,7 +24897,7 @@ function commandArtifactsLive(reports) {
24487
24897
  }
24488
24898
 
24489
24899
  // src/metrics.ts
24490
- import { createHash as createHash7 } from "crypto";
24900
+ import { createHash as createHash8 } from "crypto";
24491
24901
  var DEFAULT_SESSION_GAP_SECONDS = 8 * 60 * 60;
24492
24902
  var DEFAULT_BURST_GAP_SECONDS = 5 * 60;
24493
24903
  var DEFAULT_STUDY_LIMIT = 12;
@@ -24500,7 +24910,7 @@ var DEFAULT_MAX_STUDY_PACKET_BODY_BYTES = 256 * 1024;
24500
24910
  var MAX_STUDY_PACKET_BODY_BYTES = 1024 * 1024;
24501
24911
  var MAX_GAP_SECONDS = 30 * 24 * 60 * 60;
24502
24912
  function digest5(namespace, parts2) {
24503
- const hash2 = createHash7("sha256");
24913
+ const hash2 = createHash8("sha256");
24504
24914
  hash2.update(`message-like-me\x00${namespace}\x00`, "utf8");
24505
24915
  for (const part of parts2)
24506
24916
  hash2.update(`${part.length}:`, "utf8").update(part, "utf8");
@@ -24938,15 +25348,15 @@ function studyMessages(response, byId, maximumTextBytes, maximumMessagesPerDirec
24938
25348
  let emittedBodyBytes = sourceBodyBytes;
24939
25349
  if (sourceBodyBytes > maximumTextBytes) {
24940
25350
  let bytes = 0;
24941
- let bounded = "";
25351
+ let bounded2 = "";
24942
25352
  for (const symbol3 of sourceBody) {
24943
25353
  const symbolBytes = Buffer.byteLength(symbol3, "utf8");
24944
25354
  if (bytes + symbolBytes > maximumTextBytes)
24945
25355
  break;
24946
- bounded += symbol3;
25356
+ bounded2 += symbol3;
24947
25357
  bytes += symbolBytes;
24948
25358
  }
24949
- body = bounded;
25359
+ body = bounded2;
24950
25360
  emittedBodyBytes = bytes;
24951
25361
  }
24952
25362
  return Object.freeze({
@@ -27010,13 +27420,19 @@ function commandProgram(argv) {
27010
27420
  if (project3 !== undefined && scope5 !== "project") {
27011
27421
  return yield* exports_Effect.fail(commandFailure(new CliError("usage", "--project requires --scope project")));
27012
27422
  }
27013
- const destinations = yield* platform2.installSkill({
27423
+ const installed = yield* platform2.installSkill({
27014
27424
  target,
27015
27425
  scope: scope5,
27016
27426
  ...project3 === undefined ? {} : { projectDirectory: project3 },
27017
27427
  force: parsed.flags.has("force")
27018
27428
  });
27019
- yield* platform2.emit(json, { destination: destinations.messageLikeMe, destinations, target, scope: scope5 }, `Installed message-like-me and ensoul skills at ${destinations.messageLikeMe} and ${destinations.ensoul}`);
27429
+ const destinations = exports_Exit.isSuccess(installed.operation) ? installed.operation.value : null;
27430
+ const output = destinations === null ? exports_Exit.asVoid(installed.operation) : yield* exports_Effect.exit(platform2.emit(json, { destination: destinations.messageLikeMe, destinations, target, scope: scope5 }, `Installed message-like-me and ensoul skills at ${destinations.messageLikeMe} and ${destinations.ensoul}`));
27431
+ const warning = skillInstallWarning({ ...installed, operation: exports_Exit.isFailure(output) ? output : installed.operation });
27432
+ const diagnostic = warning === null ? exports_Exit.void : yield* exports_Effect.exit(platform2.stderr(warning));
27433
+ if (exports_Exit.isFailure(output))
27434
+ return yield* output;
27435
+ yield* diagnostic;
27020
27436
  return;
27021
27437
  }
27022
27438
  if (command === "doctor" && subcommand === undefined) {
@@ -18,7 +18,8 @@ failures retain the original error as a cause in the closed `CommandFailure`
18
18
  union. Native adapters translate source-specific failures at the same boundaries
19
19
  as before. The runtime has a narrow compatibility bridge for existing pure
20
20
  validators that throw `CliError`; other defects remain defects. CLI text, stdout
21
- formatting, diagnostics, and exit-code selection are unchanged.
21
+ formatting and exit-code selection are unchanged. The paired skill installer adds
22
+ the bounded residual diagnostics described below.
22
23
 
23
24
  ## Publication and receipt recovery
24
25
 
@@ -60,12 +61,51 @@ Effect and TypeScript do not remove that operating-system limitation. Directory
60
61
  sync failures after removal are reported as `removed-unconfirmed`. The runtime
61
62
  never claims that uncertain cleanup succeeded.
62
63
 
64
+ ## Paired skill installation
65
+
66
+ The existing command runtime directly composes the fixed `message-like-me` and
67
+ `ensoul` installer. Its program owns preflight, exclusive directory lock, private
68
+ transaction directory, copies, backups, both publish renames, recovery, cleanup,
69
+ and output. Both publish acknowledgements commit the pair immediately. Backup
70
+ cleanup, path confirmation, or stdout failure afterward never rolls it back.
71
+ There is no automatic retry or additional runtime. Admitted native work and
72
+ sequential recovery remain masked until the actual filesystem promises settle.
73
+
74
+ Before creating missing installation parents, read-only preflight rejects
75
+ conflicts, observed source/target overlap, symlinked installation parents, and
76
+ nonphysical source entries. Project anchors resolve existing aliases first;
77
+ missing project directories are then created in order beneath the observed
78
+ physical ancestor. Requested paths allow 4,096 UTF-8 bytes and at most 64 missing
79
+ project components. These directories are retained on later failure. Each
80
+ skill inventory permits at most 64 entries, eight levels, 256 KiB per file, and
81
+ 2 MiB total. These bounds also apply to the old directory on forced replacement.
82
+ Copied types, bytes, and digests must match the admitted source before publication.
83
+ Copies explicitly refuse existing entries. A cooperating installer's existing
84
+ lock is never reclaimed; an ambiguous acquisition is left for inspection.
85
+
86
+ Before publication and cleanup the adapter rechecks observed physical parents
87
+ and entry identities. Recovery runs in reverse pair order; independent actions
88
+ continue after cleanup failure. Restoration requires an absent destination and
89
+ the observed backup. Cleanup removes individually observed leaves and then empty
90
+ directories, retaining unknown or substituted entries. Creating missing parent
91
+ directories is not rolled back. This is observation-bound custody, not a kernel
92
+ conditional unlink, cross-process atomic visibility, hostile same-user race
93
+ immunity, or crash recovery.
94
+
95
+ Clean success stdout is unchanged. Residual cleanup or a postcommit output
96
+ failure adds one stderr warning containing only fixed skill, phase, and commit
97
+ state labels. Private cleanup causes remain internal. The original primary
98
+ rejection, including `undefined`, `null`, or `false`, wins over cleanup and a
99
+ failed warning writer. A successful install with failed warning output instead
100
+ reports that output failure; it does not repeat installation.
101
+
63
102
  ## Enforced architecture and distribution
64
103
 
65
104
  `bun run check:effect` uses the repository's TypeScript compiler API and the
66
105
  reviewed policy in `scripts/check-effect-architecture.ts`. Only `commands.ts`
67
106
  may execute a runtime; native calls belong in `command-platform.ts` or
68
- `command-artifacts.ts`. The checker rejects unclassified Effect modules,
107
+ `command-artifacts.ts`, or `skill-install-platform.ts`. The installer model and
108
+ program compose under that same command owner. The checker rejects unclassified Effect modules,
69
109
  floating Effects, erased channels, unsafe assertions, failure-discard shortcuts,
70
110
  suppressions, and selected ambient I/O. Its paired fixtures cover common aliases.
71
111
  It does not prove purity, linear lifetime safety, domain correctness, foreign
@@ -14,6 +14,12 @@ the leased ref move only while the matching App-sourced success is current.
14
14
  No personal access token, deploy key, Vercel token, or repository-administration
15
15
  permission belongs in either workflow.
16
16
 
17
+ Routine production promotion needs no human confirmation. The current-main
18
+ source, public artifacts, complete workflow history, App authority, writer
19
+ denial, expected-old lease, and provider readback are machine gates. An agent
20
+ may perform the independent review and exact dispatch required for a changed
21
+ workflow-control epoch; that review precedes dispatch.
22
+
17
23
  ## Establish the production controls once
18
24
 
19
25
  Apply these controls in order. Record the exact readbacks in the change review.
@@ -59,9 +65,12 @@ for this rollout and do not create a replacement Sites project.
59
65
  GitHub's authenticated repository API and do not substitute a name at the
60
66
  token-mint boundary.
61
67
  5. Create environment `production-ref-writer-key`. Limit deployment branches
62
- to selected branch `main` only. Require reviewer `@0thernet` and leave
63
- prevent-self-review disabled for this owner-operated release path. Do not add
64
- a custom deployment-protection-rule App. Store the private key only as
68
+ to selected branch `main` only. Configure no required reviewers, no wait
69
+ timer, and no custom deployment-protection-rule App. Keep administrator
70
+ bypass disabled (`can_admins_bypass=false`). The checked workflow admits
71
+ only verified releases and revalidates the complete control range before
72
+ reading the key; environment admission does not wait for a person. Store the
73
+ private key only as
65
74
  environment secret `MLM_RELEASE_APP_PRIVATE_KEY`; store checked variables
66
75
  `MLM_RELEASE_APP_CLIENT_ID`, `MLM_RELEASE_APP_ID`,
67
76
  `MLM_RELEASE_APP_INSTALLATION_ID`, and `MLM_RELEASE_APP_SLUG` in that
@@ -88,9 +97,10 @@ for this rollout and do not create a replacement Sites project.
88
97
  8. Keep active no-bypass ruleset `Immutable version tags` scoped exactly to
89
98
  `refs/tags/v*`, with only update and deletion restrictions. It allows a new
90
99
  stable tag to be created but prevents an existing release tag from moving or
91
- disappearing. Read back `current_user_can_bypass=never` before release.
92
- 9. Enable immutable releases for the repository. Immediately before creating
93
- each version tag, use owner-admin access out of band to require the repository
100
+ disappearing. Read back `current_user_can_bypass=never` during the
101
+ administrative controls census below.
102
+ 9. Enable immutable releases for the repository. During that census, use
103
+ owner-admin access out of band to require the repository
94
104
  immutable-releases endpoint to report `enabled=true`; record whether owner
95
105
  policy also reports `enforced_by_owner`. The Actions token cannot perform
96
106
  this administrative read. The workflow must still prove the resulting
@@ -118,9 +128,14 @@ for this rollout and do not create a replacement Sites project.
118
128
  Never retag or reuse `v0.8.0`. The public repository and package must retain
119
129
  automatic npm provenance for every automated release.
120
130
 
121
- After setup, use owner-admin access out of band to read back the exact Vercel
122
- production branch, environment, variables, secret names, complete App
123
- installation, and all GitHub ref rulesets. Never give that administrative
131
+ After setup, use owner-admin access out of band for a complete administrative
132
+ controls census: read back the exact Vercel production branch, environment,
133
+ variables, secret names, complete App installation, immutable-release setting,
134
+ and all GitHub ref rulesets. Refresh this census when control configuration or
135
+ workflow authority changes, when drift is detected, and during interrupted
136
+ authority recovery. Keep the reviewed evidence with that change or incident;
137
+ do not replay the historical bootstrap mutations to refresh it. Ordinary
138
+ releases do not repeat the complete owner-admin census. Never give that administrative
124
139
  credential or evidence collector to the release workflow. Its narrowed App
125
140
  token proves only its own effective identity, repository, permission, and
126
141
  expiry closure. The Message Like Me post-control record must prove all of these
@@ -142,9 +157,9 @@ assertions together:
142
157
  `workflows` authority, and an exhaustive
143
158
  `/installation/repositories` set of exactly
144
159
  `{hraness/message-like-me}` with repository ID `1342143606`;
145
- - `production-ref-writer-key` admits only `main`, requires the expected
146
- reviewer, exposes only the expected key and checked variables, and has no
147
- custom deployment-protection rules;
160
+ - `production-ref-writer-key` admits only `main`, has no required reviewers,
161
+ wait timer, or custom deployment-protection rules, disables administrator
162
+ bypass, and exposes only the expected key and checked variables;
148
163
  - the Vercel project reads back
149
164
  `link.productionBranch=website-production`, while its project root, build,
150
165
  install, Git, domain, environment, and deployment settings remain identical
@@ -158,11 +173,27 @@ dependency, lockfile, or generated documentation.
158
173
 
159
174
  Treat the production and canary ruleset IDs and their complete live readbacks as
160
175
  an external release gate, not as inputs the promotion workflow may administer.
161
- The workflow must not create, replace, patch, disable, or broaden a ruleset. A
162
- release operator revalidates the existing IDs, targets, lifecycle rules,
163
- App-pinned status context, integration ID, enforcement state, and empty bypass
164
- sets before admitting a release. Any drift blocks promotion until it is reviewed
165
- and repaired out of band.
176
+ The workflow must not create, replace, patch, disable, or broaden a ruleset.
177
+ The administrative census establishes the existing IDs, targets, lifecycle
178
+ rules, App-pinned status context, integration ID, enforcement state, and empty
179
+ bypass sets. Every production attempt still validates the exact current
180
+ source and artifact, helper hashes and control epoch, effective App token
181
+ permissions and repository scope, live rules exposed to its scoped token,
182
+ App-sourced status, writer-denial proof, expected-old lease, and provider
183
+ outcome. These checks do not claim an administrator's complete controls view.
184
+ Observed drift blocks promotion until the controls are reviewed, repaired,
185
+ and admitted through a fresh administrative census.
186
+
187
+ For an existing environment that still requires a person, first merge this
188
+ admission policy after independent review and the required checks. Revalidate
189
+ the current workflow and helper hashes, the successful production and writer
190
+ canary evidence, exact ref rules, App scope, and provider state. Then remove
191
+ only the required-reviewer rule through the environment API, preserving its
192
+ main-only branch policy, disabled administrator bypass, key, and variables.
193
+ Read back the complete environment and the unchanged authority controls.
194
+ Existing machine gates remain required, including control-epoch admission and
195
+ interrupted-authority quarantine. An unrelated release failure is not a reason
196
+ to remove or bypass them.
166
197
 
167
198
  ### Review one workflow-control epoch
168
199
 
@@ -258,17 +289,18 @@ When an established protected ref predates reviewed workflow-control changes:
258
289
  the workflow source for a canary review. Do not fetch an unbounded ref
259
290
  namespace, hand-assemble a digest, use a different checkout, or reorder the
260
291
  inventory.
261
- 3. Dispatch one fresh manual attempt 1 from exact current `main` with that exact
292
+ 3. Before dispatching the reviewed control-epoch transition, compare the tag,
293
+ v2 domain, protected ref, old SHA, target SHA, workflow-source SHA, ordered
294
+ inventory, change list, and digest with both the independently reviewed
295
+ failed-run summary and the locally reconstructed receipt. Reject the
296
+ dispatch if any field or ordered inventory row differs. Dispatch one fresh
297
+ manual attempt 1 from exact current `main` with that exact
262
298
  digest. Automatic `workflow_run` events, rerun attempts, already-exact refs,
263
299
  and unchanged-workflow ranges must reject any digest. The gate recomputes the
264
300
  complete inventory and digest before environment admission; any source, tag,
265
301
  ref, target, ancestry, inventory, or digest drift fails closed.
266
- 4. Before approving `production-ref-writer-key`, compare the fresh run title,
267
- tag, v2 domain, protected ref, old SHA, target SHA, workflow-source SHA,
268
- ordered inventory, change list, and digest with both the independently
269
- reviewed failed-run summary and the locally reconstructed receipt. Reject the
270
- environment admission if any field or ordered
271
- inventory row differs. After approval, the hash-pinned helper
302
+ 4. The workflow enters `production-ref-writer-key` after its read-only
303
+ admission jobs succeed, without a human approval. The hash-pinned helper
272
304
  recomputes and revalidates the same transition before reading the key. The
273
305
  normal split-authority sequence then applies unchanged: terminalize the status
274
306
  to the exact App-authored `error`, then prove the writer is denied with one exact
@@ -674,7 +706,7 @@ or decreasing inventory digest fails closed. Because an administrator could
674
706
  delete and recreate evidence between API reads, the owner freeze and
675
707
  before/after admin readbacks remain part of admission.
676
708
 
677
- After the main-only environment approval, the helper repeats the complete
709
+ After the main-only environment admission, the helper repeats the complete
678
710
  snapshot before it may read the private key. The status-only App may then POST
679
711
  only one distinct `error` for the exact failed target, prove that exact status
680
712
  through the combined-status endpoint, and revoke the token through the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/message-like-me",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "A local-first CLI and Agent Skill for studying private messaging history and drafting messages that sound like you.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -85,6 +85,7 @@
85
85
  "devDependencies": {
86
86
  "@types/bun": "1.3.14",
87
87
  "effect": "3.22.1",
88
+ "fast-check": "4.9.0",
88
89
  "sigstore": "4.1.1",
89
90
  "typescript": "6.0.3"
90
91
  }