@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/README.md +105 -9
- package/README.zh.md +77 -9
- package/lib/client.js +721 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +158 -15
- package/lib/index.js +414 -17
- package/lib/mem.d.ts +75 -5
- package/lib/mem.js +182 -18
- package/lib/migrate.js +2 -1
- package/lib/systemone.d.ts +213 -0
- package/lib/systemone.js +283 -0
- package/package.json +33 -5
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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);
|