@fonlan/dsh-gitmemo 0.3.2 → 0.4.1

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/lib/mem.d.ts CHANGED
@@ -35,6 +35,14 @@ export interface SearchHit {
35
35
  date: string;
36
36
  summary: string;
37
37
  keywords: string[];
38
+ /** Entry kind: `task` (task-outcome record) or `topic` (aggregated topic page). */
39
+ kind: "task" | "topic";
40
+ /**
41
+ * Relevance = distinct matched keywords + a mild recency bonus (at most
42
+ * +0.5, linearly decaying to 0 between 30 and 180 days of age). The bonus
43
+ * never outweighs an extra matched keyword, so it only reorders entries
44
+ * with equal match counts.
45
+ */
38
46
  score: number;
39
47
  matched_keywords: string[];
40
48
  }
@@ -65,6 +73,12 @@ export interface WriteInput {
65
73
  content: string;
66
74
  related_branches?: string[];
67
75
  related_paths?: string[];
76
+ /**
77
+ * Entry kind. `task` (default) is a task-outcome record; `topic` is an
78
+ * aggregated topic page (MOC) whose content links evidence entries via
79
+ * `[[<commit-hash>]]` and is evolved with mem_replace, not rewritten per task.
80
+ */
81
+ kind?: "task" | "topic";
68
82
  }
69
83
  /** One-version compatibility shape accepted by the engine, not model-facing tools. */
70
84
  export interface LegacyWriteInput {
@@ -85,11 +99,41 @@ export interface WriteResult {
85
99
  file: string;
86
100
  legacy?: boolean;
87
101
  }
102
+ /** Options for {@link GitMemo.read}. */
103
+ export interface ReadOptions {
104
+ /**
105
+ * Resolve `[[<commit-hash>]]` links found in the entry content one hop to
106
+ * their target entries. Link targets automatically follow forward to the
107
+ * ACTIVE version of the linked entry file, so a topic page link survives
108
+ * its targets being replaced; the original link hash stays recorded.
109
+ */
110
+ expand?: boolean;
111
+ }
112
+ /** One-hop resolution of a `[[<commit-hash>]]` link inside an entry. */
113
+ export interface LinkedEntryResolution {
114
+ /** Hash exactly as written inside the [[...]] link (lowercased). */
115
+ link_hash: string;
116
+ /** Hash actually read: the active version of the linked entry file ("" when unresolvable). */
117
+ resolved_hash: string;
118
+ /** True when the link hash still IS the active version (false = superseded or dangling). */
119
+ active: boolean;
120
+ file?: string;
121
+ title?: string;
122
+ summary?: string;
123
+ keywords?: string[];
124
+ kind?: "task" | "topic";
125
+ /** Present instead of title/summary when the link target cannot be resolved. */
126
+ error?: string;
127
+ }
88
128
  export interface ReadResult {
89
129
  hash: string;
90
130
  file: string;
91
131
  content: string;
132
+ /** Entry kind parsed from the create/replace commit (legacy entries read as "task"). */
133
+ kind: "task" | "topic";
92
134
  legacy?: boolean;
135
+ /** Present only when the read was requested with `expand: true`. */
136
+ links?: LinkedEntryResolution[];
93
137
  }
94
138
  export interface DeleteInput {
95
139
  commit_hash: string;
@@ -104,6 +148,8 @@ export interface ParsedCommitMessage {
104
148
  summary: string;
105
149
  type: "add" | "replace" | "delete" | "unknown";
106
150
  keywords: string[];
151
+ /** Entry kind from the `GitMemo-Kind` trailer; absence means "task". */
152
+ kind: "task" | "topic";
107
153
  digest?: string;
108
154
  deletes?: string;
109
155
  replaces?: string;
@@ -176,6 +222,7 @@ export interface ValidatedWriteInput {
176
222
  content: string;
177
223
  related_branches: string[];
178
224
  related_paths: string[];
225
+ kind: "task" | "topic";
179
226
  }
180
227
  /**
181
228
  * Validate a write/replace payload (plan 5.3). `legacy` relaxes the keyword
@@ -190,7 +237,7 @@ export declare function validateWriteInput(input: WriteInput, legacy?: boolean):
190
237
  * an exact duplicate because of when/where it was written, and a withdrawn
191
238
  * conclusion can be re-written later.
192
239
  */
193
- export declare function computeDigest(title: string, summary: string, keywords: string[], content: string): string;
240
+ export declare function computeDigest(title: string, summary: string, keywords: string[], content: string, kind?: "task" | "topic"): string;
194
241
  /** UTC millisecond timestamp for filenames: `20260823T113612.123Z`. */
195
242
  export declare function utcMs(date: Date): string;
196
243
  /** ISO date for a committer timestamp; falls back to the epoch for out-of-range values. */
@@ -205,18 +252,30 @@ export declare function createEntryFile(memDir: string, baseName: string, conten
205
252
  export declare function buildEntryMarkdown(input: ValidatedWriteInput, ctx: CodeContext & {
206
253
  relatedBranches: string[];
207
254
  }, digest: string, date: Date): string;
255
+ /**
256
+ * Recency bonus for search scoring: +0.5 for entries at most 30 days old,
257
+ * linearly decaying to 0 at 180 days, 0 beyond. Bounded below one whole
258
+ * matched keyword, so freshness only reorders equal-match entries.
259
+ */
260
+ export declare function recencyBonus(committerTime: number, nowMs?: number): number;
208
261
  /** ADD commit message (plan 4.1). */
209
- export declare function buildAddMessage(title: string, summary: string, keywords: string[], digest: string, legacy?: boolean): string;
262
+ export declare function buildAddMessage(title: string, summary: string, keywords: string[], digest: string, legacy?: boolean, kind?: "task" | "topic"): string;
210
263
  /** DELETE commit message (plan 4.2). */
211
264
  export declare function buildDeleteMessage(entryName: string, reason: string, deletesHash: string): string;
212
265
  /** REPLACE commit message (plan 4.3). */
213
- export declare function buildReplaceMessage(title: string, summary: string, keywords: string[], digest: string, replacesHash: string): string;
266
+ export declare function buildReplaceMessage(title: string, summary: string, keywords: string[], digest: string, replacesHash: string, kind?: "task" | "topic"): string;
214
267
  /**
215
268
  * Parse a commit message into logical search fields. The trailer block is the
216
269
  * contiguous run at the end; everything between subject and the block is the
217
270
  * summary. Malformed input degrades to `type: "unknown"` (legacy commits).
218
271
  */
219
272
  export declare function parseCommitMessage(raw: string): ParsedCommitMessage;
273
+ /**
274
+ * Extract unique `[[<commit-hash>]]` wiki links from an entry body, in order
275
+ * of first appearance, capped at 32 per entry. Anything that is not a plain
276
+ * hex hash inside double brackets is ignored.
277
+ */
278
+ export declare function extractEntryLinks(content: string): string[];
220
279
  /**
221
280
  * Parse the NUL-delimited log stream
222
281
  * (`--format=%x00%H%x00%ct%x00%s%x00%B%x00 --name-status --no-renames`).
@@ -299,9 +358,20 @@ export declare class GitMemo {
299
358
  /**
300
359
  * Read one memory entry by create/replace commit hash. Historical entries
301
360
  * (deleted or replaced) stay readable for audit; `mem_read` accepts legacy
302
- * hashes too.
361
+ * hashes too. With `options.expand`, `[[<commit-hash>]]` links in the entry
362
+ * body are resolved one hop to their target entries (each target follows
363
+ * forward to the active version of its entry file).
364
+ */
365
+ read(commitHash: string, options?: ReadOptions): Promise<ReadResult>;
366
+ /**
367
+ * One-hop resolution of `[[<commit-hash>]]` links (caller must hold the
368
+ * operation lock). A link whose entry was later REPLACED is followed
369
+ * forward along the `GitMemo-Replaces` chain to the active version, so a
370
+ * topic page's links survive their targets being refreshed; withdrawn or
371
+ * dangling links come back with `active: false` (plus an `error` field
372
+ * when the link hash cannot be resolved at all).
303
373
  */
304
- read(commitHash: string): Promise<ReadResult>;
374
+ private resolveEntryLinks;
305
375
  /**
306
376
  * Write a memory entry (plan 5.3). Entries are immutable: each write
307
377
  * creates a brand-new file `entries/<utc-ms>-<digest-prefix>-<slug>.md`
package/lib/mem.js CHANGED
@@ -8,8 +8,8 @@
8
8
  * ADD commit; corrections use `replace` (one commit deleting the old file
9
9
  * and adding the new one), withdrawal uses `delete`.
10
10
  * - Commit messages are structured: subject = title, body = summary, then a
11
- * contiguous trailer block (`GitMemo-Type/Keyword/Digest/Deletes/Replaces/
12
- * Search-Text/Legacy`). Search only reads commit messages via
11
+ * contiguous trailer block (`GitMemo-Type/Kind/Keyword/Digest/Deletes/
12
+ * Replaces/Search-Text/Legacy`). Search only reads commit messages via
13
13
  * `git log --grep --fixed-strings`; entry bodies are never scanned.
14
14
  * - All state transitions (init/write/delete/replace/migrate) run under a
15
15
  * cross-process lock (sibling lock file + in-process mutex) with a short
@@ -306,7 +306,10 @@ export function validateWriteInput(input, legacy = false) {
306
306
  }
307
307
  related_paths = dedupe(related_paths);
308
308
  }
309
- return { title, summary, keywords, content, related_branches, related_paths };
309
+ if (input.kind !== undefined && input.kind !== "task" && input.kind !== "topic") {
310
+ throw new GitMemoError('gitmemo: kind must be "task" or "topic"');
311
+ }
312
+ return { title, summary, keywords, content, related_branches, related_paths, kind: input.kind ?? "task" };
310
313
  }
311
314
  async function adaptLegacyWriteInput(input) {
312
315
  const legacyInput = input;
@@ -342,9 +345,9 @@ async function adaptLegacyWriteInput(input) {
342
345
  * an exact duplicate because of when/where it was written, and a withdrawn
343
346
  * conclusion can be re-written later.
344
347
  */
345
- export function computeDigest(title, summary, keywords, content) {
348
+ export function computeDigest(title, summary, keywords, content, kind = "task") {
346
349
  const normalizedKeywords = [...new Set(keywords.map((k) => normalizeKeyword(k)))].sort();
347
- const payload = JSON.stringify({ title, summary, keywords: normalizedKeywords, content });
350
+ const payload = JSON.stringify({ title, summary, keywords: normalizedKeywords, content, kind });
348
351
  return createHash("sha256").update(payload, "utf8").digest("hex");
349
352
  }
350
353
  /** UTC millisecond timestamp for filenames: `20260823T113612.123Z`. */
@@ -455,6 +458,7 @@ export function buildEntryMarkdown(input, ctx, digest, date) {
455
458
  "---",
456
459
  "gitmemo_version: " + yamlJson(GITMEMO_FORMAT_VERSION),
457
460
  "date: " + yamlJson(date.toISOString()),
461
+ "kind: " + yamlJson(input.kind),
458
462
  "code_branch: " + yamlJson(ctx.branch),
459
463
  "code_commit: " + yamlJson(ctx.commit),
460
464
  "related_branches: " + yamlArray(ctx.relatedBranches),
@@ -478,9 +482,26 @@ export function buildEntryMarkdown(input, ctx, digest, date) {
478
482
  function searchTexts(title, summary, keywords) {
479
483
  return [normalizeText(title), normalizeText(summary.replace(/\s+/g, " ")), ...keywords.map((k) => normalizeText(k))];
480
484
  }
485
+ /**
486
+ * Recency bonus for search scoring: +0.5 for entries at most 30 days old,
487
+ * linearly decaying to 0 at 180 days, 0 beyond. Bounded below one whole
488
+ * matched keyword, so freshness only reorders equal-match entries.
489
+ */
490
+ export function recencyBonus(committerTime, nowMs = Date.now()) {
491
+ const ageDays = Math.max(0, (nowMs / 1000 - committerTime) / 86400);
492
+ if (ageDays <= 30)
493
+ return 0.5;
494
+ if (ageDays >= 180)
495
+ return 0;
496
+ return Math.round(((0.5 * (180 - ageDays)) / 150) * 100) / 100;
497
+ }
498
+ /** Commit trailer emitted only for topic pages (absence means "task"). */
499
+ const KIND_TRAILER_TOPIC = "GitMemo-Kind: topic";
481
500
  /** ADD commit message (plan 4.1). */
482
- export function buildAddMessage(title, summary, keywords, digest, legacy = false) {
501
+ export function buildAddMessage(title, summary, keywords, digest, legacy = false, kind = "task") {
483
502
  const lines = [title, "", summary, "", "GitMemo-Type: add"];
503
+ if (kind === "topic")
504
+ lines.push(KIND_TRAILER_TOPIC);
484
505
  if (legacy)
485
506
  lines.push("GitMemo-Legacy: true");
486
507
  for (const kw of keywords)
@@ -502,8 +523,10 @@ export function buildDeleteMessage(entryName, reason, deletesHash) {
502
523
  ].join("\n") + "\n";
503
524
  }
504
525
  /** REPLACE commit message (plan 4.3). */
505
- export function buildReplaceMessage(title, summary, keywords, digest, replacesHash) {
526
+ export function buildReplaceMessage(title, summary, keywords, digest, replacesHash, kind = "task") {
506
527
  const lines = [title, "", summary, "", "GitMemo-Type: replace", "GitMemo-Replaces: " + replacesHash];
528
+ if (kind === "topic")
529
+ lines.push(KIND_TRAILER_TOPIC);
507
530
  for (const kw of keywords)
508
531
  lines.push("GitMemo-Keyword: " + kw);
509
532
  lines.push("GitMemo-Digest: sha256:" + digest);
@@ -553,12 +576,32 @@ export function parseCommitMessage(raw) {
553
576
  summary,
554
577
  type,
555
578
  keywords: trailers.get("Keyword") ?? [],
579
+ kind: trailers.get("Kind")?.[0] === "topic" ? "topic" : "task",
556
580
  digest: trailers.get("Digest")?.[0]?.replace(/^sha256:/, "") || undefined,
557
581
  deletes: trailers.get("Deletes")?.[0] || undefined,
558
582
  replaces: trailers.get("Replaces")?.[0] || undefined,
559
583
  legacy: trailers.get("Legacy")?.[0] === "true"
560
584
  };
561
585
  }
586
+ /**
587
+ * Extract unique `[[<commit-hash>]]` wiki links from an entry body, in order
588
+ * of first appearance, capped at 32 per entry. Anything that is not a plain
589
+ * hex hash inside double brackets is ignored.
590
+ */
591
+ export function extractEntryLinks(content) {
592
+ const links = [];
593
+ const seen = new Set();
594
+ for (const match of content.matchAll(/\[\[([0-9a-fA-F]{7,64})\]\]/g)) {
595
+ const hash = match[1].toLowerCase();
596
+ if (seen.has(hash))
597
+ continue;
598
+ seen.add(hash);
599
+ links.push(hash);
600
+ if (links.length >= 32)
601
+ break;
602
+ }
603
+ return links;
604
+ }
562
605
  // ---------------------------------------------------------------------------
563
606
  // git log parsing
564
607
  // ---------------------------------------------------------------------------
@@ -1339,6 +1382,7 @@ export class GitMemo {
1339
1382
  const hits = [];
1340
1383
  const diagnostics = [...(legacySnapshot?.diagnostics ?? [])];
1341
1384
  const seenFiles = new Set();
1385
+ const nowMs = Date.now();
1342
1386
  for (const record of candidates) {
1343
1387
  if (record.added.length !== 1) {
1344
1388
  if (record.added.length > 1)
@@ -1371,20 +1415,24 @@ export class GitMemo {
1371
1415
  date: safeIsoDate(record.committerTime),
1372
1416
  summary: record.message.summary,
1373
1417
  keywords: record.message.keywords,
1374
- score: matched.length,
1418
+ kind: record.message.kind,
1419
+ score: Math.round((matched.length + recencyBonus(record.committerTime, nowMs)) * 100) / 100,
1375
1420
  matched_keywords: matched
1376
1421
  });
1377
1422
  }
1378
- // plan 6.6: score desc, committer time desc, full hash desc (deterministic)
1423
+ // plan 6.6: score desc (matches + mild recency bonus), committer time desc,
1424
+ // full hash desc (deterministic)
1379
1425
  hits.sort((a, b) => b.score - a.score || b.date.localeCompare(a.date) || (a.hash < b.hash ? 1 : a.hash > b.hash ? -1 : 0));
1380
1426
  return { hits, diagnostics };
1381
1427
  }
1382
1428
  /**
1383
1429
  * Read one memory entry by create/replace commit hash. Historical entries
1384
1430
  * (deleted or replaced) stay readable for audit; `mem_read` accepts legacy
1385
- * hashes too.
1431
+ * hashes too. With `options.expand`, `[[<commit-hash>]]` links in the entry
1432
+ * body are resolved one hop to their target entries (each target follows
1433
+ * forward to the active version of its entry file).
1386
1434
  */
1387
- async read(commitHash) {
1435
+ async read(commitHash, options = {}) {
1388
1436
  if (!/^[0-9a-f]{7,64}$/.test(commitHash))
1389
1437
  throw new GitMemoError("gitmemo: read requires a commit hash");
1390
1438
  return withLock(this.root, this.config, "read", async () => {
@@ -1401,9 +1449,123 @@ export class GitMemo {
1401
1449
  const body = await git(this.memDir, ["log", "-1", "--format=%B", full], this.config.gitTimeoutMs);
1402
1450
  const message = parseCommitMessage(body);
1403
1451
  const legacy = message.type === "unknown";
1404
- return { hash: full, file: added[0], content, ...(legacy ? { legacy: true } : {}) };
1452
+ const result = {
1453
+ hash: full,
1454
+ file: added[0],
1455
+ content,
1456
+ kind: message.kind,
1457
+ ...(legacy ? { legacy: true } : {})
1458
+ };
1459
+ if (options.expand === true) {
1460
+ result.links = await this.resolveEntryLinks(content);
1461
+ }
1462
+ return result;
1405
1463
  });
1406
1464
  }
1465
+ /**
1466
+ * One-hop resolution of `[[<commit-hash>]]` links (caller must hold the
1467
+ * operation lock). A link whose entry was later REPLACED is followed
1468
+ * forward along the `GitMemo-Replaces` chain to the active version, so a
1469
+ * topic page's links survive their targets being refreshed; withdrawn or
1470
+ * dangling links come back with `active: false` (plus an `error` field
1471
+ * when the link hash cannot be resolved at all).
1472
+ */
1473
+ async resolveEntryLinks(content) {
1474
+ const linkHashes = extractEntryLinks(content);
1475
+ if (linkHashes.length === 0)
1476
+ return [];
1477
+ let snapshot = "";
1478
+ try {
1479
+ snapshot = (await git(this.memDir, ["rev-parse", "--verify", "refs/heads/main^{commit}"], this.config.gitTimeoutMs)).trim();
1480
+ }
1481
+ catch {
1482
+ try {
1483
+ snapshot = (await git(this.memDir, ["rev-parse", "--verify", "HEAD^{commit}"], this.config.gitTimeoutMs)).trim();
1484
+ }
1485
+ catch {
1486
+ snapshot = "";
1487
+ }
1488
+ }
1489
+ const resolutions = [];
1490
+ for (const link of linkHashes) {
1491
+ try {
1492
+ const full = await resolveCommit(this.memDir, link, this.config.gitTimeoutMs);
1493
+ const added = await addedFilesOf(this.memDir, full, this.config.gitTimeoutMs);
1494
+ if (added.length !== 1) {
1495
+ resolutions.push({
1496
+ link_hash: link,
1497
+ resolved_hash: full,
1498
+ active: false,
1499
+ error: `commit ${link} maps to ${added.length} entry files — cannot resolve a single entry`
1500
+ });
1501
+ continue;
1502
+ }
1503
+ const activeHash = snapshot.length > 0
1504
+ ? (await git(this.memDir, ["log", "-1", "--format=%H", snapshot, "--", added[0]], this.config.gitTimeoutMs)).trim()
1505
+ : "";
1506
+ let resolved = full;
1507
+ let resolvedFile = added[0];
1508
+ let isActive = activeHash === full;
1509
+ if (!isActive) {
1510
+ // The linked entry is not the active version of its file — either
1511
+ // replaced (the last commit touching the old file is its replace
1512
+ // commit, or the file is gone from the active tree entirely) or
1513
+ // withdrawn. Follow the GitMemo-Replaces chain forward to the
1514
+ // active successor; when there is none the chain ends at the link.
1515
+ for (let hop = 0; hop < 16; hop += 1) {
1516
+ const successorOut = await git(this.memDir, [
1517
+ "log",
1518
+ ...(snapshot.length > 0 ? [snapshot] : []),
1519
+ "--fixed-strings",
1520
+ "--grep=GitMemo-Replaces: " + resolved,
1521
+ "--format=%H"
1522
+ ], this.config.gitTimeoutMs).catch(() => "");
1523
+ const successors = successorOut.split("\n").map((h) => h.trim()).filter((h) => COMMIT_RE.test(h));
1524
+ let next = null;
1525
+ for (const candidate of successors) {
1526
+ const candidateFiles = await addedFilesOf(this.memDir, candidate, this.config.gitTimeoutMs);
1527
+ if (candidateFiles.length !== 1)
1528
+ continue;
1529
+ const candidateActive = snapshot.length > 0
1530
+ ? (await git(this.memDir, ["log", "-1", "--format=%H", snapshot, "--", candidateFiles[0]], this.config.gitTimeoutMs)).trim()
1531
+ : "";
1532
+ if (candidateActive === candidate) {
1533
+ next = { hash: candidate, file: candidateFiles[0] };
1534
+ break;
1535
+ }
1536
+ }
1537
+ if (next === null)
1538
+ break;
1539
+ resolved = next.hash;
1540
+ resolvedFile = next.file;
1541
+ // isActive stays false: the LINK hash is not the active version —
1542
+ // only resolved_hash carries it (the "superseded" case).
1543
+ }
1544
+ }
1545
+ const body = await git(this.memDir, ["log", "-1", "--format=%B", resolved], this.config.gitTimeoutMs);
1546
+ const message = parseCommitMessage(body);
1547
+ resolutions.push({
1548
+ link_hash: link,
1549
+ resolved_hash: resolved,
1550
+ active: isActive,
1551
+ file: resolvedFile,
1552
+ title: message.subject,
1553
+ summary: message.summary,
1554
+ keywords: message.keywords,
1555
+ kind: message.kind
1556
+ });
1557
+ }
1558
+ catch (error) {
1559
+ resolutions.push({
1560
+ link_hash: link,
1561
+ resolved_hash: "",
1562
+ active: false,
1563
+ error: String(error instanceof GitMemoError ? error.message : error).replace(/^gitmemo: /, "")
1564
+ });
1565
+ }
1566
+ }
1567
+ return resolutions;
1568
+ }
1407
1569
  /**
1408
1570
  * Write a memory entry (plan 5.3). Entries are immutable: each write
1409
1571
  * creates a brand-new file `entries/<utc-ms>-<digest-prefix>-<slug>.md`
@@ -1413,7 +1575,7 @@ export class GitMemo {
1413
1575
  async write(input, legacy = false) {
1414
1576
  const adapted = legacy ? await adaptLegacyWriteInput(input) : input;
1415
1577
  const validated = validateWriteInput(adapted, legacy);
1416
- const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1578
+ const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content, validated.kind);
1417
1579
  return withLock(this.root, this.config, "write", async () => {
1418
1580
  await this.ensureWritable();
1419
1581
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
@@ -1445,7 +1607,7 @@ export class GitMemo {
1445
1607
  try {
1446
1608
  await git(this.memDir, ["add", "--", file], this.config.gitTimeoutMs);
1447
1609
  await assertStagedExactly(this.memDir, [{ status: "A", path: file }], this.config.gitTimeoutMs);
1448
- const hash = await commitWithIdentity(this.memDir, buildAddMessage(validated.title, validated.summary, validated.keywords, digest, legacy), this.config.gitTimeoutMs);
1610
+ const hash = await commitWithIdentity(this.memDir, buildAddMessage(validated.title, validated.summary, validated.keywords, digest, legacy, validated.kind), this.config.gitTimeoutMs);
1449
1611
  await rm(journalPath(this.memDir), { force: true });
1450
1612
  return { hash, file, ...(legacy ? { legacy: true } : {}) };
1451
1613
  }
@@ -1519,8 +1681,6 @@ export class GitMemo {
1519
1681
  * legal; colliding with any OTHER active entry is rejected.
1520
1682
  */
1521
1683
  async replace(input) {
1522
- const validated = validateWriteInput(input);
1523
- const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1524
1684
  return withLock(this.root, this.config, "replace", async () => {
1525
1685
  await this.ensureWritable();
1526
1686
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
@@ -1529,7 +1689,11 @@ export class GitMemo {
1529
1689
  const ctx = await codeContext(this.root, this.config.gitTimeoutMs);
1530
1690
  const baseHead = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
1531
1691
  const full = await resolveCommit(this.memDir, input.commit_hash, this.config.gitTimeoutMs);
1532
- const { file: oldFile } = await activeEntryForHash(this.memDir, full, baseHead, this.config.gitTimeoutMs);
1692
+ const { file: oldFile, message: oldMessage } = await activeEntryForHash(this.memDir, full, baseHead, this.config.gitTimeoutMs);
1693
+ // kind inherits the replaced entry's kind unless explicitly overridden,
1694
+ // so refreshing a topic page never silently demotes it to a task record.
1695
+ const validated = validateWriteInput({ ...input, kind: input.kind ?? oldMessage.kind });
1696
+ const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content, validated.kind);
1533
1697
  const { active, records, claimed } = await activeSnapshot(this.memDir, baseHead, this.config.gitTimeoutMs);
1534
1698
  void active;
1535
1699
  for (const [file, entry] of claimed) {
@@ -1560,7 +1724,7 @@ export class GitMemo {
1560
1724
  { status: "D", path: oldFile },
1561
1725
  { status: "A", path: newFile }
1562
1726
  ], this.config.gitTimeoutMs);
1563
- const hash = await commitWithIdentity(this.memDir, buildReplaceMessage(validated.title, validated.summary, validated.keywords, digest, full), this.config.gitTimeoutMs);
1727
+ const hash = await commitWithIdentity(this.memDir, buildReplaceMessage(validated.title, validated.summary, validated.keywords, digest, full, validated.kind), this.config.gitTimeoutMs);
1564
1728
  await rm(journalPath(this.memDir), { force: true });
1565
1729
  return { hash, file: newFile };
1566
1730
  }
package/lib/migrate.js CHANGED
@@ -490,7 +490,8 @@ async function migrateApplyUnlocked(root, baseline, options = {}) {
490
490
  keywords: entry.keywords,
491
491
  content: entry.content,
492
492
  related_branches: entry.relatedBranches,
493
- related_paths: entry.relatedPaths
493
+ related_paths: entry.relatedPaths,
494
+ kind: "task"
494
495
  }, { branch: entry.codeBranch, commit: entry.codeCommit, relatedBranches: entry.relatedBranches }, entry.digest, entryDate);
495
496
  const file = await createEntryFile(tmp, baseName, markdown);
496
497
  await git(tmp, ["add", "--", file], gitTimeoutMs);