@agfpd/iapeer-memory-core 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory-core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "iapeer-memory core — host-neutral TypeScript memory primitive: vault schema/taxonomy config, search engine, memoryd, context renderer, role contracts. Consumed by the @agfpd/iapeer-memory facade; version kept in lockstep by its release flow (docs/10-distribution.md).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
29
  import crypto from "node:crypto";
30
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
30
31
 
31
32
  export const FRAGMENT_STEM = "iapeer-memory.md";
32
33
 
@@ -140,11 +141,11 @@ export function writeFragmentAtomic(
140
141
  `.${stem}.${crypto.randomBytes(6).toString("hex")}.tmp`,
141
142
  );
142
143
  try {
143
- fs.writeFileSync(tmp, text, "utf-8");
144
+ guardedWriteFileSync(tmp, text, "utf-8");
144
145
  fs.renameSync(tmp, target);
145
146
  } catch (err) {
146
147
  try {
147
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
148
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
148
149
  } catch {
149
150
  // best effort
150
151
  }
@@ -38,6 +38,7 @@ import path from "node:path";
38
38
  import crypto from "node:crypto";
39
39
  import type { TaxonomyPreset } from "./taxonomy.js";
40
40
  import { DEFAULT_CURATOR_SET } from "./taxonomy.js";
41
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
41
42
 
42
43
  const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
43
44
 
@@ -417,11 +418,11 @@ export function atomicWrite(filePath: string, content: string): void {
417
418
  `.fm-${crypto.randomBytes(6).toString("hex")}.tmp`,
418
419
  );
419
420
  try {
420
- fs.writeFileSync(tmp, content, "utf-8");
421
+ guardedWriteFileSync(tmp, content, "utf-8");
421
422
  fs.renameSync(tmp, filePath);
422
423
  } catch (err) {
423
424
  try {
424
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
425
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
425
426
  } catch {
426
427
  // best effort
427
428
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * FS belt — the file-system half of deny-by-default
3
+ * (iapeer-memory docs/_planning/DENY_BY_DEFAULT_DESIGN.md §4 П4, accepted
4
+ * by boris 11.06).
5
+ *
6
+ * Incident class №2 («лестница рассинхронилась»): the db path ladder lived
7
+ * in TWO copies (core config + package paths) and a drift wrote a sandbox
8
+ * SQLite into the PROD `~/.iapeer/cache`. Path conventions cannot be the
9
+ * only belt — so every raw write/unlink/rm in BOTH src trees goes through
10
+ * the wrappers below, and under an armed test-sandbox env they REFUSE any
11
+ * path under a production anchor, no matter what the ladder computed:
12
+ *
13
+ * ~/.iapeer — ecosystem state/cache/config of the live host
14
+ * ~/.claude, ~/.codex — harness config surfaces of the live fleet
15
+ * ~/Library/Mobile Documents — the iCloud root (the live vault lives there)
16
+ *
17
+ * Outside the sandbox env the wrappers are pass-through: live init/update/
18
+ * migrate write exactly where they always did. The grep invariant (И3) pins
19
+ * the funnel: no raw `fs.writeFileSync`/`Bun.write`/`fs.rmSync`/
20
+ * `fs.unlinkSync` outside this file in either src tree.
21
+ *
22
+ * Deliberately NOT guarded in v1: `mkdirSync` (creates empty dirs — no data
23
+ * loss / no content leak; the write that would fill them refuses) and reads.
24
+ */
25
+
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ /** Both test belts — the ecosystem-wide var survives generic
31
+ * IAPEER_MEMORY_* env-stripping (incident 10.06 №3). The ONE definition
32
+ * for both packages: the package egress hub imports this. */
33
+ export function sandboxEnvArmed(): boolean {
34
+ return (
35
+ process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
36
+ process.env.IAPEER_TEST_SANDBOX === "1"
37
+ );
38
+ }
39
+
40
+ function prodAnchors(): string[] {
41
+ const home = os.homedir();
42
+ return [
43
+ path.join(home, ".iapeer"),
44
+ path.join(home, ".claude"),
45
+ path.join(home, ".codex"),
46
+ path.join(home, "Library", "Mobile Documents"),
47
+ ];
48
+ }
49
+
50
+ /** True when the resolved path sits under a production anchor. Exported for
51
+ * tests: the predicate is checkable without arming any write. */
52
+ export function isUnderProdAnchor(filePath: string): boolean {
53
+ const resolved = path.resolve(filePath);
54
+ return prodAnchors().some(
55
+ (a) => resolved === a || resolved.startsWith(a + path.sep),
56
+ );
57
+ }
58
+
59
+ /** Throws under an armed sandbox env when the path targets a prod anchor.
60
+ * The op name makes the refusal teach: WHICH write was stopped. */
61
+ export function assertSandboxWritablePath(filePath: string, op: string): void {
62
+ if (!sandboxEnvArmed()) return;
63
+ if (isUnderProdAnchor(filePath)) {
64
+ throw new Error(
65
+ `fs-guard: ${op} refused under the test sandbox — "${filePath}" is under a production anchor ` +
66
+ "(~/.iapeer, ~/.claude, ~/.codex or the iCloud root). A test must write " +
67
+ "inside its own tmp root; if the path came from the env ladder, the ladder drifted.",
68
+ );
69
+ }
70
+ }
71
+
72
+ export function guardedWriteFileSync(
73
+ filePath: string,
74
+ data: string | NodeJS.ArrayBufferView,
75
+ options?: Parameters<typeof fs.writeFileSync>[2],
76
+ ): void {
77
+ assertSandboxWritablePath(filePath, "write");
78
+ fs.writeFileSync(filePath, data, options);
79
+ }
80
+
81
+ export function guardedUnlinkSync(filePath: string): void {
82
+ assertSandboxWritablePath(filePath, "unlink");
83
+ fs.unlinkSync(filePath);
84
+ }
85
+
86
+ export function guardedRmSync(
87
+ filePath: string,
88
+ options?: Parameters<typeof fs.rmSync>[1],
89
+ ): void {
90
+ assertSandboxWritablePath(filePath, "rm");
91
+ fs.rmSync(filePath, options);
92
+ }
@@ -28,6 +28,7 @@ import path from "node:path";
28
28
  import crypto from "node:crypto";
29
29
  import type { RankingConfig, TaxonomyPreset } from "./taxonomy.js";
30
30
  import { statusGroup as taxonomyStatusGroup } from "./taxonomy.js";
31
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
31
32
 
32
33
  const WIKILINK_RE = /\[\[([^\]|#]+)/g;
33
34
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
@@ -821,11 +822,11 @@ export function atomicWrite(filePath: string, content: string): void {
821
822
  const dir = path.dirname(filePath) || ".";
822
823
  const tmp = path.join(dir, `.vault-index-${crypto.randomBytes(6).toString("hex")}.tmp`);
823
824
  try {
824
- fs.writeFileSync(tmp, content, "utf-8");
825
+ guardedWriteFileSync(tmp, content, "utf-8");
825
826
  fs.renameSync(tmp, filePath);
826
827
  } catch (err) {
827
828
  try {
828
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
829
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
829
830
  } catch {
830
831
  // best effort
831
832
  }
package/src/index.ts CHANGED
@@ -70,3 +70,12 @@ export { prepareSqliteRuntime, type SqliteRuntime } from "./sqlite-loader.js";
70
70
 
71
71
  // logging
72
72
  export { makeLogger, type Logger } from "./log.js";
73
+
74
+ export {
75
+ sandboxEnvArmed,
76
+ isUnderProdAnchor,
77
+ assertSandboxWritablePath,
78
+ guardedWriteFileSync,
79
+ guardedUnlinkSync,
80
+ guardedRmSync,
81
+ } from "./fs-guard.js";
package/src/memoryd.ts CHANGED
@@ -58,6 +58,14 @@ import {
58
58
  type RenderContext,
59
59
  } from "./index-render.js";
60
60
  import { renderPeerFragment, type FragmentEnv } from "./context-render.js";
61
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
62
+ import {
63
+ isSilentEdit,
64
+ readStampRecord,
65
+ restampUnstamped,
66
+ type SilentZone,
67
+ type StampRecord,
68
+ } from "./silent-edit-detect.js";
61
69
 
62
70
  // ── identity ────────────────────────────────────────────────────────────────
63
71
 
@@ -410,44 +418,78 @@ export async function startMcpHttp(opts: {
410
418
  // baseline survives restarts, atomically (same tmp+rename canon).
411
419
 
412
420
  /** Persisted batch baselines: inbox + permanent + human-inbox snapshots
413
- * (rel/name → smart hash). Survives restarts порт паттерна старых
414
- * мониторов «seen переживает сбой». */
421
+ * (rel/name → smart hash) + the silent-edit stamp baseline (rel →
422
+ * {updated, leb}; the unstamped detector judges stamp movement against
423
+ * it). Survives restarts — порт паттерна старых мониторов «seen
424
+ * переживает сбой». Migration is FIRST-SIGHT by construction: an absent
425
+ * silentStamps key (pre-detector state file) reads as an empty map — one
426
+ * warm-up pass records, never judges. */
415
427
  export type BatchState = {
416
428
  inbox: VaultSnapshot | null;
417
429
  permanent: VaultSnapshot | null;
418
430
  humanInbox: VaultSnapshot | null;
431
+ silentStamps: Map<string, StampRecord> | null;
419
432
  };
420
433
 
421
434
  export function loadBatchState(filePath: string): BatchState {
422
435
  try {
423
436
  const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
424
437
  string,
425
- Record<string, string>
438
+ Record<string, unknown>
426
439
  >;
427
- const toMap = (o: Record<string, string> | undefined): VaultSnapshot | null =>
428
- o ? new Map(Object.entries(o)) : null;
440
+ const toMap = (o: Record<string, unknown> | undefined): VaultSnapshot | null =>
441
+ o
442
+ ? new Map(
443
+ Object.entries(o).filter((e): e is [string, string] => typeof e[1] === "string"),
444
+ )
445
+ : null;
446
+ const toStamps = (
447
+ o: Record<string, unknown> | undefined,
448
+ ): Map<string, StampRecord> | null => {
449
+ if (!o) return null;
450
+ const m = new Map<string, StampRecord>();
451
+ for (const [k, v] of Object.entries(o)) {
452
+ if (v && typeof v === "object" && !Array.isArray(v)) {
453
+ const r = v as { hash?: unknown; updated?: unknown; leb?: unknown };
454
+ if (typeof r.hash !== "string") continue; // schema drift → first-sight
455
+ m.set(k, {
456
+ hash: r.hash,
457
+ updated: typeof r.updated === "string" ? r.updated : null,
458
+ leb: typeof r.leb === "string" ? r.leb : null,
459
+ });
460
+ }
461
+ }
462
+ return m;
463
+ };
429
464
  return {
430
465
  inbox: toMap(raw.inbox),
431
466
  permanent: toMap(raw.permanent),
432
467
  humanInbox: toMap(raw.humanInbox),
468
+ silentStamps: toStamps(raw.silentStamps),
433
469
  };
434
470
  } catch {
435
- return { inbox: null, permanent: null, humanInbox: null };
471
+ return { inbox: null, permanent: null, humanInbox: null, silentStamps: null };
436
472
  }
437
473
  }
438
474
 
439
475
  export function persistBatchState(
440
476
  filePath: string,
441
- state: { inbox: VaultSnapshot; permanent: VaultSnapshot; humanInbox: VaultSnapshot },
477
+ state: {
478
+ inbox: VaultSnapshot;
479
+ permanent: VaultSnapshot;
480
+ humanInbox: VaultSnapshot;
481
+ silentStamps: Map<string, StampRecord>;
482
+ },
442
483
  ): void {
443
484
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
444
485
  const tmp = `${filePath}.tmp`;
445
- fs.writeFileSync(
486
+ guardedWriteFileSync(
446
487
  tmp,
447
488
  JSON.stringify({
448
489
  inbox: Object.fromEntries(state.inbox),
449
490
  permanent: Object.fromEntries(state.permanent),
450
491
  humanInbox: Object.fromEntries(state.humanInbox),
492
+ silentStamps: Object.fromEntries(state.silentStamps),
451
493
  }),
452
494
  "utf-8",
453
495
  );
@@ -470,7 +512,7 @@ export function loadHashState(filePath: string): Map<string, string> {
470
512
  export function persistHashState(filePath: string, map: Map<string, string>): void {
471
513
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
472
514
  const tmp = `${filePath}.tmp`;
473
- fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(map)), "utf-8");
515
+ guardedWriteFileSync(tmp, JSON.stringify(Object.fromEntries(map)), "utf-8");
474
516
  fs.renameSync(tmp, filePath);
475
517
  }
476
518
 
@@ -579,6 +621,10 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
579
621
  let humanInboxBaseline: VaultSnapshot =
580
622
  persistedBatches.humanInbox ?? snapshotFlatFolder(humanInboxDir);
581
623
  const lastSeenHashes = loadHashState(hashStatePath);
624
+ /** Silent-edit stamp baseline (rel → {updated, leb}); absent key in an
625
+ * old state file = empty map = first-sight warm-up (design §4). */
626
+ const silentStamps: Map<string, StampRecord> =
627
+ persistedBatches.silentStamps ?? new Map();
582
628
 
583
629
  function persistBatches(): void {
584
630
  try {
@@ -586,6 +632,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
586
632
  inbox: inboxSnapshot,
587
633
  permanent: permanentBaseline,
588
634
  humanInbox: humanInboxBaseline,
635
+ silentStamps,
589
636
  });
590
637
  } catch (err) {
591
638
  logger.error(`batch-state persist failed: ${String(err)}`);
@@ -729,11 +776,87 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
729
776
  if (decision.action !== "write") return;
730
777
  fs.mkdirSync(path.dirname(tagsMirrorPath), { recursive: true });
731
778
  const tmp = `${tagsMirrorPath}.tmp`;
732
- fs.writeFileSync(tmp, srcContent!, "utf-8");
779
+ guardedWriteFileSync(tmp, srcContent!, "utf-8");
733
780
  fs.renameSync(tmp, tagsMirrorPath);
734
781
  logger.info(`tags mirror updated (${decision.reason})`);
735
782
  }
736
783
 
784
+ /** Zone map of the unstamped detector (design §3): the agent inbox +
785
+ * the six permanent folders (five canonical + agent memory — wider than
786
+ * humanEditPass's getZone, which has no agent-memory notion). */
787
+ function silentZoneOf(absPath: string): SilentZone | null {
788
+ const rel = path.relative(config.vaultPath, absPath);
789
+ if (rel.startsWith("..")) return null;
790
+ const first = rel.split(path.sep)[0];
791
+ const f = taxonomy.folders;
792
+ if (first === f.inbox) return "inbox";
793
+ if (
794
+ first === f.knowledge ||
795
+ first === f.decisions ||
796
+ first === f.projects ||
797
+ first === f.ideas ||
798
+ first === f.lists ||
799
+ first === f.agentMemory
800
+ ) {
801
+ return "permanent";
802
+ }
803
+ return null;
804
+ }
805
+
806
+ /**
807
+ * Unstamped-write detector (design doc, boris-accepted 11.06). Runs
808
+ * BEFORE the inbox emission loop and BEFORE humanEditPass: a re-stamped
809
+ * file then reads `last_edited_by: unstamped` — the curator source
810
+ * filters pass it into the pipeline, and humanEditPass sees a fresh
811
+ * agent stamp (echo-agent skip, no double stamp — order instead of ifs).
812
+ * Candidates carry CHANGED files only (fs.watch set ∪ inbox diff) — the
813
+ * semantic-hash precondition of the rule; service-only echoes never
814
+ * reach here (smart-hash blindness = echo safety of our own re-stamp).
815
+ */
816
+ function silentEditPass(candidatesAbs: Set<string>): void {
817
+ for (const abs of candidatesAbs) {
818
+ const zone = silentZoneOf(abs);
819
+ if (!zone) continue;
820
+ const rel = path.relative(config.vaultPath, abs);
821
+ let content: string;
822
+ try {
823
+ content = fs.readFileSync(abs, "utf-8");
824
+ } catch {
825
+ silentStamps.delete(rel); // deleted mid-debounce — drop the record
826
+ continue;
827
+ }
828
+ const curr = readStampRecord(content);
829
+ const prev = silentStamps.get(rel);
830
+ if (prev === undefined) {
831
+ silentStamps.set(rel, curr); // first sight: record, never judge
832
+ continue;
833
+ }
834
+ if (!isSilentEdit({ prev, curr, zone, nowMs: Date.now(), freshEditWindowS: opts.freshEditWindowS })) {
835
+ silentStamps.set(rel, curr);
836
+ continue;
837
+ }
838
+ const restamped = restampUnstamped(content, Date.now());
839
+ if (restamped === null) {
840
+ silentStamps.set(rel, curr); // bare draft — the fill machinery's job
841
+ continue;
842
+ }
843
+ const tmp = `${abs}.memoryd.tmp`;
844
+ try {
845
+ guardedWriteFileSync(tmp, restamped, "utf-8");
846
+ fs.renameSync(tmp, abs);
847
+ silentStamps.set(rel, readStampRecord(restamped));
848
+ logger.info(`silent edit re-stamped (${zone}): ${rel}`);
849
+ } catch (err) {
850
+ try {
851
+ guardedUnlinkSync(tmp);
852
+ } catch {
853
+ // best effort
854
+ }
855
+ logger.error(`silent-edit re-stamp failed for ${abs}: ${String(err)}`);
856
+ }
857
+ }
858
+ }
859
+
737
860
  function humanEditPass(changedAbs: Set<string>): void {
738
861
  const human = opts.humanName ?? null;
739
862
  if (!human) return; // ⚖7: no human role — detection off
@@ -776,13 +899,13 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
776
899
  }
777
900
  const tmp = `${filePath}.memoryd.tmp`;
778
901
  try {
779
- fs.writeFileSync(tmp, decision.newContent, "utf-8");
902
+ guardedWriteFileSync(tmp, decision.newContent, "utf-8");
780
903
  fs.renameSync(tmp, filePath);
781
904
  lastSeenHashes.set(filePath, decision.recordHash);
782
905
  logger.info(`human-edit ${decision.reason}: ${path.relative(config.vaultPath, filePath)}`);
783
906
  } catch (err) {
784
907
  try {
785
- fs.unlinkSync(tmp);
908
+ guardedUnlinkSync(tmp);
786
909
  } catch {
787
910
  // best effort
788
911
  }
@@ -805,7 +928,20 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
805
928
  // INBOX_NEW — мгновенно: new OR semantically changed drafts (hash-diff
806
929
  // over the WHOLE inbox folder; no dependency on fs.watch filenames).
807
930
  const inboxNext = snapshotInbox(config.vaultPath, taxonomy);
808
- for (const name of diffSnapshots(inboxSnapshot, inboxNext)) {
931
+ const inboxChanged = diffSnapshots(inboxSnapshot, inboxNext);
932
+
933
+ // Unstamped detector FIRST (design §3 order): re-stamps land before the
934
+ // curator suppress below reads leb and before humanEditPass judges.
935
+ // Candidates: fs.watch set (permanent fresh-window branch) ∪ the inbox
936
+ // diff (the diff is semantic — a re-stamp does not invalidate it).
937
+ silentEditPass(
938
+ new Set([
939
+ ...changed,
940
+ ...inboxChanged.map((n) => path.join(config.vaultPath, taxonomy.folders.inbox, n)),
941
+ ]),
942
+ );
943
+
944
+ for (const name of inboxChanged) {
809
945
  const abs = path.join(config.vaultPath, taxonomy.folders.inbox, name);
810
946
  // Source-фильтр кураторов — ПАРИТЕТ с PERMANENT_BATCH (живое эхо
811
947
  // 10.06: стилистическая правка Scriber'а ВО ВРЕМЯ вычитки породила
@@ -905,7 +1041,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
905
1041
  function touchHeartbeat(): void {
906
1042
  try {
907
1043
  fs.mkdirSync(path.dirname(heartbeatPath), { recursive: true });
908
- fs.writeFileSync(heartbeatPath, `${new Date().toISOString()} ${os.hostname()}\n`);
1044
+ guardedWriteFileSync(heartbeatPath, `${new Date().toISOString()} ${os.hostname()}\n`);
909
1045
  } catch (err) {
910
1046
  logger.error(`heartbeat write failed: ${String(err)}`);
911
1047
  }
@@ -1020,7 +1156,7 @@ export async function startMemoryd(opts: MemorydOptions): Promise<MemorydHandle>
1020
1156
  persistQuiet();
1021
1157
  if (mcp) await mcp.close();
1022
1158
  try {
1023
- fs.unlinkSync(heartbeatPath);
1159
+ guardedUnlinkSync(heartbeatPath);
1024
1160
  } catch {
1025
1161
  // best effort
1026
1162
  }
@@ -32,6 +32,7 @@ import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import type { TaxonomyPreset } from "./taxonomy.js";
34
34
  import { yamlSafeScalar } from "./fm-update.js";
35
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
35
36
 
36
37
  /** Source files that are backed up but never copied into the vault. */
37
38
  export const SKIP_FILES: ReadonlySet<string> = new Set(["MEMORY.md"]);
@@ -217,7 +218,7 @@ export function applyMigration(opts: {
217
218
  // 2a. Non-md and SKIP_FILES: backup-only, removed from the source.
218
219
  if (!name.endsWith(".md") || SKIP_FILES.has(name)) {
219
220
  try {
220
- fs.unlinkSync(srcPath);
221
+ guardedUnlinkSync(srcPath);
221
222
  } catch (err) {
222
223
  errors.push(`${name}: unlink after backup failed — ${String(err)}`);
223
224
  }
@@ -229,7 +230,7 @@ export function applyMigration(opts: {
229
230
  if (fs.existsSync(targetFile)) {
230
231
  skipped.push(name);
231
232
  try {
232
- fs.unlinkSync(srcPath);
233
+ guardedUnlinkSync(srcPath);
233
234
  } catch (err) {
234
235
  errors.push(`${name}: unlink (already migrated) failed — ${String(err)}`);
235
236
  }
@@ -261,7 +262,7 @@ export function applyMigration(opts: {
261
262
 
262
263
  try {
263
264
  const tmp = `${targetFile}.tmp`;
264
- fs.writeFileSync(tmp, newText, "utf-8");
265
+ guardedWriteFileSync(tmp, newText, "utf-8");
265
266
  fs.renameSync(tmp, targetFile);
266
267
  } catch (err) {
267
268
  errors.push(`${name}: write failed — ${String(err)}`);
@@ -269,7 +270,7 @@ export function applyMigration(opts: {
269
270
  }
270
271
 
271
272
  try {
272
- fs.unlinkSync(srcPath);
273
+ guardedUnlinkSync(srcPath);
273
274
  migrated.push(name);
274
275
  } catch (err) {
275
276
  errors.push(`${name}: written to target but source unlink failed — ${String(err)}`);
@@ -24,6 +24,7 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import crypto from "node:crypto";
27
+ import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
27
28
 
28
29
  /** `<!-- iapeer-memory doctrine v<version> -->` — machine-checkable. */
29
30
  export function versionMarker(version: string): string {
@@ -93,11 +94,11 @@ export function renderDoctrine(opts: {
93
94
  `.IAPEER.md.${crypto.randomBytes(6).toString("hex")}.tmp`,
94
95
  );
95
96
  try {
96
- fs.writeFileSync(tmp, rendered, "utf-8");
97
+ guardedWriteFileSync(tmp, rendered, "utf-8");
97
98
  fs.renameSync(tmp, target);
98
99
  } catch (err) {
99
100
  try {
100
- if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
101
+ if (fs.existsSync(tmp)) guardedUnlinkSync(tmp);
101
102
  } catch {
102
103
  // best effort
103
104
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Silent-edit detector — the unstamped-write belt
3
+ * (docs/_planning/UNSTAMPED_EDIT_DETECTOR_DESIGN.md, accepted by boris
4
+ * 11.06; precedent: the Index's catch — a Bash-heredoc edit of a canon
5
+ * note 52 s after a curator stamp was swallowed FOREVER by the human-edit
6
+ * echo window, masquerading as the curator for the source filters).
7
+ *
8
+ * A vault write that bypasses the PostToolUse hook (Bash, python-heredoc,
9
+ * any non-Write/Edit tool) leaves the stamp untouched. The discriminator
10
+ * is the SEMANTIC hash (smart-hash: frontmatter minus service fields +
11
+ * body) — the battle-proven anti-echo of permanent-detect: a hook echo
12
+ * changes ONLY service fields (semantic hash still), a silent edit moves
13
+ * the semantic hash while the stamp stands still. The same property makes
14
+ * our response re-stamp echo-safe BY CONSTRUCTION: a re-stamp touches only
15
+ * service fields, so it never re-triggers the detector or the batch diffs.
16
+ *
17
+ * ZONED rule (the humanEditPass intersection, §3a of the design):
18
+ * permanent — ONLY the fresh-window case (stamp ≤ FRESH_EDIT_WINDOW_S):
19
+ * that is exactly the echo-window swallow; STALE cases stay with
20
+ * humanEditPass (human attribution — the Obsidian main case; a wider
21
+ * rule would regress «Артур правит кураторскую заметку»);
22
+ * inbox — UNCONDITIONAL: humanEditPass does not cover the agent inbox at
23
+ * all, human edits there are marginal, and the curator-masquerade
24
+ * (memoryd's 822-suppress) plus the silent author edit are both closed
25
+ * by one branch.
26
+ *
27
+ * Attribution token: `unstamped` — NEUTRAL on purpose (the mechanism
28
+ * cannot tell a Bash agent from a human outside Obsidian; a false «agent»
29
+ * on the human's edit is worse than an honest «don't know» — the
30
+ * author-guard symmetry: a visible anomaly beats a silently assigned
31
+ * identity). The Index resolves it by context; needs_review rides along.
32
+ *
33
+ * Pure decision core — no I/O; the memoryd shell owns reading, the atomic
34
+ * write and the persisted stamp baseline (first-sight warm-up: a file
35
+ * without a baseline record is recorded, never judged — the 0.1.8 guard).
36
+ */
37
+
38
+ import {
39
+ formatStamp,
40
+ parseUpdated,
41
+ upsertField,
42
+ DEFAULT_FRESH_EDIT_WINDOW_S,
43
+ } from "./human-edit-detect.js";
44
+ import { smartHash } from "./smart-hash.js";
45
+
46
+ export const UNSTAMPED_TOKEN = "unstamped";
47
+
48
+ export type SilentZone = "permanent" | "inbox";
49
+
50
+ /** The per-file baseline record (design §4): the SEMANTIC hash (the BASE
51
+ * precondition — without it an iCloud mtime-echo inside the fresh window
52
+ * would false-trigger a re-stamp on identical content) + the stamp pair
53
+ * compared verbatim between passes. */
54
+ export type StampRecord = {
55
+ hash: string;
56
+ updated: string | null;
57
+ leb: string | null;
58
+ };
59
+
60
+ const FM_RE = /^---[^\S\n]*\n([\s\S]*?)\n---[^\S\n]*(?:\n|$)/;
61
+
62
+ /** Read the baseline record from note content. No frontmatter → null stamps. */
63
+ export function readStampRecord(content: string): StampRecord {
64
+ const fm = FM_RE.exec(content);
65
+ const hash = smartHash(new TextEncoder().encode(content));
66
+ if (!fm) return { hash, updated: null, leb: null };
67
+ const upd = /^updated\s*:\s*(.+?)\s*$/m.exec(fm[1]);
68
+ const leb = /^last_edited_by\s*:\s*(.+?)\s*$/m.exec(fm[1]);
69
+ return {
70
+ hash,
71
+ updated: upd ? upd[1].trim() : null,
72
+ leb: leb ? leb[1].trim() : null,
73
+ };
74
+ }
75
+
76
+ export type DecideSilentInput = {
77
+ /** Baseline record (the PREVIOUS pass). */
78
+ prev: StampRecord;
79
+ curr: StampRecord;
80
+ zone: SilentZone;
81
+ nowMs: number;
82
+ freshEditWindowS?: number;
83
+ };
84
+
85
+ /**
86
+ * BASE: the semantic hash MOVED ∧ the stamp pair did not move verbatim.
87
+ * Zone branch: permanent → only when the standing stamp is FRESH (the
88
+ * echo-window swallow); inbox → unconditional. A service-only change
89
+ * (hook echo, our own re-stamp) keeps the semantic hash still → never
90
+ * silent; an mtime-only event keeps content identical → never silent.
91
+ */
92
+ export function isSilentEdit(input: DecideSilentInput): boolean {
93
+ if (input.prev.hash === input.curr.hash) return false; // BASE: no semantic move
94
+ const stampUnmoved =
95
+ input.prev.updated === input.curr.updated && input.prev.leb === input.curr.leb;
96
+ if (!stampUnmoved) return false;
97
+ if (input.zone === "inbox") return true;
98
+ const windowS = input.freshEditWindowS ?? DEFAULT_FRESH_EDIT_WINDOW_S;
99
+ const editAt = parseUpdated(input.curr.updated);
100
+ return editAt !== null && (input.nowMs - editAt) / 1000 < windowS;
101
+ }
102
+
103
+ /**
104
+ * The response re-stamp: `last_edited_by: unstamped` + fresh `updated` +
105
+ * `needs_review: true`. Content without frontmatter → null (a bare draft
106
+ * is the fill machinery's job, not ours).
107
+ *
108
+ * SURGICAL splice: only the captured frontmatter block is replaced — the
109
+ * rest of the file (the `---` fences, the blank line after them, the body)
110
+ * stays byte-exact. That is what makes the re-stamp service-fields-only
111
+ * and therefore echo-safe (a reassembly once ate the post-fence blank
112
+ * line, moved the semantic hash and broke the no-loop property — caught
113
+ * by the test, fixed by construction).
114
+ */
115
+ export function restampUnstamped(content: string, nowMs: number): string | null {
116
+ const fm = FM_RE.exec(content);
117
+ if (!fm) return null;
118
+ let block = fm[1];
119
+ block = upsertField(block, "last_edited_by", UNSTAMPED_TOKEN);
120
+ block = upsertField(block, "updated", formatStamp(new Date(nowMs)));
121
+ block = upsertField(block, "needs_review", "true");
122
+ block = block.replace(/\n$/, ""); // the capture carries no trailing \n
123
+ const blockStart = fm[0].indexOf("\n") + 1; // right after the opening fence line
124
+ const blockEnd = blockStart + fm[1].length;
125
+ return content.slice(0, blockStart) + block + content.slice(blockEnd);
126
+ }