wendkeep 0.29.2 → 0.31.0
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 +89 -0
- package/bin/wendkeep.mjs +12 -1
- package/hooks/brain-inject.mjs +17 -10
- package/hooks/change-context.mjs +61 -0
- package/hooks/change-core.mjs +116 -11
- package/hooks/change-guard.mjs +59 -0
- package/hooks/change-nag.mjs +31 -0
- package/hooks/change-warn.mjs +48 -0
- package/hooks/decision-capture.mjs +37 -9
- package/hooks/linked-notes.mjs +3 -3
- package/hooks/plan-capture.mjs +116 -0
- package/hooks/renumber-decisions.mjs +198 -0
- package/hooks/session-stop.mjs +11 -1
- package/hooks/spec-core.mjs +25 -1
- package/hooks/subagent-usage.mjs +3 -1
- package/package.json +1 -1
- package/src/change.mjs +44 -13
- package/src/init.mjs +34 -13
- package/src/renumber.mjs +32 -0
- package/src/skills-seed.mjs +30 -13
- package/src/sync-defs.mjs +8 -0
- package/src/taxonomy.mjs +32 -0
|
@@ -3,16 +3,36 @@
|
|
|
3
3
|
// options, this records the decision — the question, EVERY option (label + description), and the
|
|
4
4
|
// user's choice — as a note in 04-Decisões, wikilinked to the session. Explicit, high-signal
|
|
5
5
|
// decisions get full traceability in the graph (better than heuristic extraction). Fail-open.
|
|
6
|
-
import { existsSync, writeFileSync } from 'fs';
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { pathToFileURL } from 'url';
|
|
9
9
|
import {
|
|
10
10
|
readHookInput, writeHookOutput, getVaultBase, providerMeta, ensureDir, formatDate,
|
|
11
11
|
formatLocalIso, monthFolderRelFromDateStr, slugify, findActiveSessionByTranscript,
|
|
12
|
-
wikilinkFromRel, readControl, toVaultRelative,
|
|
12
|
+
wikilinkFromRel, readControl, toVaultRelative, getNextAdrNumber, derivedContentKey,
|
|
13
13
|
} from './obsidian-common.mjs';
|
|
14
14
|
import { getLocale } from './locale.mjs';
|
|
15
15
|
|
|
16
|
+
// Decision notes follow the ADR naming convention: ADR-NNNN-<slug>, NNNN a 4-digit sequential
|
|
17
|
+
// number assigned in the order decisions are made (getNextAdrNumber scans the whole 04-Decisões).
|
|
18
|
+
export const padAdr = (n) => String(n).padStart(4, '0');
|
|
19
|
+
|
|
20
|
+
// Dedup is content-based now (the filename carries a fresh ADR number, so it can't dedup): a
|
|
21
|
+
// decision already recorded (same content_key) in the target folder is not written again.
|
|
22
|
+
export function decisionKeyExists(dir, key) {
|
|
23
|
+
if (!key) return false;
|
|
24
|
+
let names;
|
|
25
|
+
try { names = readdirSync(dir); } catch { return false; }
|
|
26
|
+
for (const n of names) {
|
|
27
|
+
if (!n.endsWith('.md')) continue;
|
|
28
|
+
try {
|
|
29
|
+
const m = readFileSync(join(dir, n), 'utf8').match(/^content_key:\s*"?(.*?)"?\s*$/m);
|
|
30
|
+
if (m && m[1] === key) return true;
|
|
31
|
+
} catch { /* nota ilegível */ }
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
// The AskUserQuestion tool_output reads: `... "Question"="chosen labels" "Q2"="..."`.
|
|
17
37
|
export function parseAnswers(output) {
|
|
18
38
|
const map = {};
|
|
@@ -25,10 +45,11 @@ export function parseAnswers(output) {
|
|
|
25
45
|
const clean = (s) => String(s || '').replace(/\r/g, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
26
46
|
|
|
27
47
|
// Render the decision note from an AskUserQuestion tool call.
|
|
28
|
-
export function buildDecisionCaptureNote({ questions, answers, dateStr, startedAt, sessionRel, provider, localeId }) {
|
|
48
|
+
export function buildDecisionCaptureNote({ questions, answers, dateStr, startedAt, sessionRel, provider, localeId, adrNum = 0, contentKey = '' }) {
|
|
29
49
|
const en = localeId === 'en';
|
|
30
50
|
const src = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
|
|
31
51
|
const title = clean(questions[0]?.question || (en ? 'User decision' : 'Decisão do usuário')).slice(0, 90);
|
|
52
|
+
const adrLabel = adrNum ? `ADR-${padAdr(adrNum)} — ` : '';
|
|
32
53
|
|
|
33
54
|
const blocks = questions.map((q) => {
|
|
34
55
|
const chosen = (answers[clean(q.question)] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -52,6 +73,7 @@ subtype: user-choice
|
|
|
52
73
|
date: ${dateStr}
|
|
53
74
|
started_at: ${startedAt}
|
|
54
75
|
provider: ${provider.id}
|
|
76
|
+
${adrNum ? `adr: ${adrNum}\n` : ''}content_key: "${contentKey}"
|
|
55
77
|
cssclasses:
|
|
56
78
|
- topic-decision
|
|
57
79
|
tags:
|
|
@@ -60,7 +82,7 @@ tags:
|
|
|
60
82
|
source:${src}
|
|
61
83
|
---
|
|
62
84
|
|
|
63
|
-
# ${title}
|
|
85
|
+
# ${adrLabel}${title}
|
|
64
86
|
|
|
65
87
|
> ${en ? 'Decision captured from an interactive question (options + the user\'s choice).' : 'Decisão capturada de uma pergunta interativa (opções + a escolha do usuário).'}
|
|
66
88
|
|
|
@@ -99,7 +121,7 @@ export function extractProseDecisions(tx) {
|
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
// Write one decision note per extracted prose decision (same shape as the hook capture).
|
|
102
|
-
// Deduped by
|
|
124
|
+
// Deduped by content_key (question). Returns the vault-relative paths written.
|
|
103
125
|
export function captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId }) {
|
|
104
126
|
const written = [];
|
|
105
127
|
const decisions = extractProseDecisions(tx);
|
|
@@ -107,14 +129,17 @@ export function captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, prov
|
|
|
107
129
|
const loc = getLocale(vaultBase);
|
|
108
130
|
const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
|
|
109
131
|
for (const d of decisions) {
|
|
132
|
+
const contentKey = derivedContentKey(d.question);
|
|
110
133
|
ensureDir(dir);
|
|
111
|
-
|
|
134
|
+
if (decisionKeyExists(dir, contentKey)) continue;
|
|
135
|
+
const adrNum = getNextAdrNumber(vaultBase);
|
|
136
|
+
const filePath = join(dir, `ADR-${padAdr(adrNum)}-${slugify(d.question, 'decisao', 50)}.md`);
|
|
112
137
|
if (existsSync(filePath)) continue;
|
|
113
138
|
writeFileSync(filePath, buildDecisionCaptureNote({
|
|
114
139
|
questions: [{ question: d.question, multiSelect: false, options: d.options.map((label) => ({ label, description: '' })) }],
|
|
115
140
|
answers: { [d.question]: d.answer },
|
|
116
141
|
dateStr, startedAt: `${dateStr}T00:00:00`, sessionRel,
|
|
117
|
-
provider: provider || providerMeta(tx?.provider), localeId: localeId || loc.id,
|
|
142
|
+
provider: provider || providerMeta(tx?.provider), localeId: localeId || loc.id, adrNum, contentKey,
|
|
118
143
|
}), 'utf-8');
|
|
119
144
|
written.push(toVaultRelative(vaultBase, filePath));
|
|
120
145
|
}
|
|
@@ -137,12 +162,15 @@ export function captureDecision(vaultBase, input) {
|
|
|
137
162
|
|
|
138
163
|
const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
|
|
139
164
|
ensureDir(dir);
|
|
165
|
+
const contentKey = derivedContentKey(questions[0]?.question || 'decisao');
|
|
166
|
+
if (decisionKeyExists(dir, contentKey)) return { rel: '', skipped: true };
|
|
167
|
+
const adrNum = getNextAdrNumber(vaultBase);
|
|
140
168
|
const slug = slugify(questions[0]?.question || 'decisao', 'decisao', 50);
|
|
141
|
-
const filePath = join(dir,
|
|
169
|
+
const filePath = join(dir, `ADR-${padAdr(adrNum)}-${slug}.md`);
|
|
142
170
|
if (existsSync(filePath)) return { rel: toVaultRelative(vaultBase, filePath), skipped: true };
|
|
143
171
|
|
|
144
172
|
writeFileSync(filePath, buildDecisionCaptureNote({
|
|
145
|
-
questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id,
|
|
173
|
+
questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id, adrNum, contentKey,
|
|
146
174
|
}), 'utf-8');
|
|
147
175
|
return { rel: toVaultRelative(vaultBase, filePath), skipped: false };
|
|
148
176
|
}
|
package/hooks/linked-notes.mjs
CHANGED
|
@@ -38,7 +38,7 @@ function normalizeInline(text, max = 0) {
|
|
|
38
38
|
|
|
39
39
|
function adrFileExistsBySlug(dir, slug) {
|
|
40
40
|
try {
|
|
41
|
-
return readdirSync(dir).find((file) => /^ADR-\d
|
|
41
|
+
return readdirSync(dir).find((file) => /^ADR-\d+-.+\.md$/i.test(file) && file.includes(`-${slug}`));
|
|
42
42
|
} catch {
|
|
43
43
|
return '';
|
|
44
44
|
}
|
|
@@ -333,7 +333,7 @@ export function extractDecisionDetails(tx) {
|
|
|
333
333
|
|
|
334
334
|
export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title), localeId = 'pt-BR') {
|
|
335
335
|
const L = noteLabels(localeId);
|
|
336
|
-
const adrId = `ADR-${String(adrNum).padStart(
|
|
336
|
+
const adrId = `ADR-${String(adrNum).padStart(4, '0')}`;
|
|
337
337
|
return `---
|
|
338
338
|
type: decision
|
|
339
339
|
date: ${dateStr}
|
|
@@ -558,7 +558,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
|
|
|
558
558
|
if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
|
|
559
559
|
const titleSlug = slugify(decisionDetails.title, 'decisao', 40);
|
|
560
560
|
const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
|
|
561
|
-
const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(
|
|
561
|
+
const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(4, '0')}-${titleSlug}.md`;
|
|
562
562
|
const filePath = join(decisionsDir, fileName);
|
|
563
563
|
if (!existsSync(filePath)) {
|
|
564
564
|
const adrNum = Number(fileName.match(/^ADR-(\d+)/i)?.[1]) || getNextAdrNumber(vaultBase);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook (matcher: ExitPlanMode). A ponte determinística entre o plan mode do Claude
|
|
3
|
+
// Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
|
|
4
|
+
// ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
|
|
5
|
+
// corpo, tarefas dos checkboxes). Não depende de a LLM lembrar do processo. Rejeição = no-op.
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { formatDate, getVaultBase, readHookInput, slugify, writeHookOutput } from './obsidian-common.mjs';
|
|
10
|
+
import { activeChange, newChange } from './change-core.mjs';
|
|
11
|
+
import { getLocale } from './locale.mjs';
|
|
12
|
+
|
|
13
|
+
// O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
|
|
14
|
+
// tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
|
|
15
|
+
// citado em "saved to: <path>". Rejeição (ou ausência de sinal de aprovação) → null.
|
|
16
|
+
export function extractPlan(input) {
|
|
17
|
+
const tr = input?.tool_response ?? input?.toolResponse ?? '';
|
|
18
|
+
const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
|
|
19
|
+
if (/doesn'?t want to proceed|user rejected|rejected the plan/i.test(resp)) return null;
|
|
20
|
+
if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
|
|
21
|
+
const direct = input?.tool_input?.plan ?? input?.toolInput?.plan;
|
|
22
|
+
if (direct && String(direct).trim()) return String(direct);
|
|
23
|
+
const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
|
|
24
|
+
if (marker && marker[1].trim()) return marker[1].trim();
|
|
25
|
+
const path = resp.match(/saved to:\s*([^\n]+\.md)/i);
|
|
26
|
+
if (path) { try { return readFileSync(path[1].trim(), 'utf8'); } catch { /* arquivo inacessível */ } }
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function planSlug(plan) {
|
|
31
|
+
const h1 = String(plan || '').match(/^#\s+(.+)$/m);
|
|
32
|
+
return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sectionBody(plan, headings) {
|
|
36
|
+
for (const h of headings) {
|
|
37
|
+
const m = String(plan).match(new RegExp(`^##\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'm'));
|
|
38
|
+
if (m && m[1].trim()) return m[1].trim();
|
|
39
|
+
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Checkboxes do plano viram tarefas numeradas 1.N (estado [x] preservado). [] quando não há.
|
|
44
|
+
export function planTasks(plan) {
|
|
45
|
+
const boxes = [...String(plan || '').matchAll(/^\s*-\s+\[( |x)\]\s+(.+)$/gm)];
|
|
46
|
+
return boxes.map((m, i) => `- [${m[1]}] 1.${i + 1} ${m[2].trim()}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function capturePlan(vaultBase, input) {
|
|
50
|
+
const plan = extractPlan(input);
|
|
51
|
+
if (!plan) return null;
|
|
52
|
+
const loc = getLocale(vaultBase);
|
|
53
|
+
const en = loc.id === 'en';
|
|
54
|
+
const dateStr = formatDate(new Date());
|
|
55
|
+
const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
|
|
56
|
+
|
|
57
|
+
const active = activeChange(vaultBase);
|
|
58
|
+
if (active) {
|
|
59
|
+
const dir = join(vaultBase, loc.folders.changes, active);
|
|
60
|
+
writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
|
|
61
|
+
return {
|
|
62
|
+
slug: active, created: false,
|
|
63
|
+
context: `<plan_captured>\n${en
|
|
64
|
+
? `Approved plan attached to the active change "${active}" (plano-aprovado.md). Sync tarefas.md with the plan's tasks.`
|
|
65
|
+
: `Plano aprovado anexado à change ativa "${active}" (plano-aprovado.md). Sincronize tarefas.md com as tarefas do plano.`}\n</plan_captured>`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const slug = planSlug(plan);
|
|
70
|
+
newChange(vaultBase, slug, { dateStr });
|
|
71
|
+
const dir = join(vaultBase, loc.folders.changes, slug);
|
|
72
|
+
const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
|
|
73
|
+
writeFileSync(join(dir, 'proposta.md'), `---
|
|
74
|
+
type: change
|
|
75
|
+
status: active
|
|
76
|
+
date: ${dateStr}
|
|
77
|
+
cssclasses:
|
|
78
|
+
- topic-change
|
|
79
|
+
tags:
|
|
80
|
+
- mudanca
|
|
81
|
+
source: []
|
|
82
|
+
specs: []
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
# ${slug}
|
|
86
|
+
|
|
87
|
+
${en ? '## Why' : '## Por quê'}
|
|
88
|
+
|
|
89
|
+
${ctx}
|
|
90
|
+
|
|
91
|
+
${en ? '## What changes' : '## O que muda'}
|
|
92
|
+
|
|
93
|
+
${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mode plan).' : 'Ver design.md e plano-aprovado.md (capturados do plano aprovado no plan mode).'}
|
|
94
|
+
`, 'utf8');
|
|
95
|
+
writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
|
|
96
|
+
const tasks = planTasks(plan);
|
|
97
|
+
if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
|
|
98
|
+
writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
|
|
99
|
+
return {
|
|
100
|
+
slug, created: true,
|
|
101
|
+
context: `<plan_captured>\n${en
|
|
102
|
+
? `Approved plan recorded in the vault: change "${slug}" created and set active (${loc.folders.changes}/${slug}/). Review tarefas.md${tasks.length ? '' : ' — the plan had no checkboxes, fill the tasks'} and follow the a2 loop (wk-tdd per task, \`wendkeep change done <id>\`, \`wendkeep verify\`).`
|
|
103
|
+
: `Plano aprovado — change "${slug}" registrada no vault e ativa (${loc.folders.changes}/${slug}/). Revise tarefas.md${tasks.length ? '' : ' — o plano não tinha checkboxes, preencha as tarefas'} e siga o loop a2 (wk-tdd por tarefa, \`wendkeep change done <id>\`, \`wendkeep verify\`).`}\n</plan_captured>`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
108
|
+
try {
|
|
109
|
+
const input = readHookInput();
|
|
110
|
+
const r = capturePlan(getVaultBase(input), input);
|
|
111
|
+
if (!r) { writeHookOutput({}); }
|
|
112
|
+
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
|
|
113
|
+
} catch {
|
|
114
|
+
writeHookOutput({});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Retroactive ADR renumbering (0.30.0). The decision folder accumulated three naming eras:
|
|
3
|
+
// canonical ADR-NNN, dated `YYYY-MM-DD-escolha-<slug>` (hook capture pre-0.30), and hand-written
|
|
4
|
+
// `YYYY-MM-DD-<slug>`. This renumbers EVERY note in 04-Decisões to `ADR-<NNNN>-<slug>` in strict
|
|
5
|
+
// chronological order (the order decisions were made), renames the files in place, updates every
|
|
6
|
+
// wikilink to them across the whole vault, and normalizes each note's `type`/`adr`/H1. Idempotent:
|
|
7
|
+
// running it again on an already-canonical vault is a no-op (same order, same names).
|
|
8
|
+
import { readdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { join, dirname, relative } from 'node:path';
|
|
10
|
+
import { getLocale } from './locale.mjs';
|
|
11
|
+
|
|
12
|
+
export const padAdr = (n) => String(n).padStart(4, '0');
|
|
13
|
+
|
|
14
|
+
// Every .md under 04-Decisões, absolute + vault-relative (posix slashes for wikilinks).
|
|
15
|
+
function walkDecisions(vaultBase, decisionsDir) {
|
|
16
|
+
const out = [];
|
|
17
|
+
const walk = (dir) => {
|
|
18
|
+
let entries;
|
|
19
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
const abs = join(dir, e.name);
|
|
22
|
+
if (e.isDirectory()) walk(abs);
|
|
23
|
+
else if (e.name.endsWith('.md')) {
|
|
24
|
+
out.push({ abs, rel: relative(vaultBase, abs).replaceAll('\\', '/'), base: e.name });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
walk(join(vaultBase, decisionsDir));
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Chronological sort key: date, then time, then existing ADR number, then filename — all derived
|
|
33
|
+
// from (in priority) frontmatter, filename prefix, and the dated folder path.
|
|
34
|
+
export function decisionSortKey({ abs, base, content }) {
|
|
35
|
+
const c = content ?? (existsSync(abs) ? safeRead(abs) : '');
|
|
36
|
+
const fmDate = c.match(/^date:\s*(\d{4}-\d{2}-\d{2})/m);
|
|
37
|
+
const fnDate = base.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
38
|
+
const folderDate = abs.replaceAll('\\', '/').match(/\/(\d{4})\/(\d{2})-[^/]+\/DIA\s+(\d{1,2})\//i);
|
|
39
|
+
let date = '9999-12-31';
|
|
40
|
+
if (fmDate) date = fmDate[1];
|
|
41
|
+
else if (fnDate) date = fnDate[1];
|
|
42
|
+
else if (folderDate) date = `${folderDate[1]}-${folderDate[2]}-${String(folderDate[3]).padStart(2, '0')}`;
|
|
43
|
+
|
|
44
|
+
const fmTime = c.match(/^started_at:\s*\S*T(\d{2}:\d{2}:\d{2})/m);
|
|
45
|
+
const srcTime = c.match(/\[\[[^\]]*\/(\d{2})-(\d{2})-[^\]]*\]\]/); // session wikilink HH-MM prefix
|
|
46
|
+
let time = '00:00:00';
|
|
47
|
+
if (fmTime) time = fmTime[1];
|
|
48
|
+
else if (srcTime) time = `${srcTime[1]}:${srcTime[2]}:00`;
|
|
49
|
+
|
|
50
|
+
const adr = base.match(/^ADR-(\d+)/i);
|
|
51
|
+
const adrNum = adr ? Number(adr[1]) : 999999;
|
|
52
|
+
return `${date}T${time}#${String(adrNum).padStart(6, '0')}#${base}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeRead(abs) {
|
|
56
|
+
try { return readFileSync(abs, 'utf8'); } catch { return ''; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Descriptive slug from any of the three eras' filenames.
|
|
60
|
+
export function slugFromDecisionName(base) {
|
|
61
|
+
let s = base.replace(/\.md$/i, '');
|
|
62
|
+
s = s.replace(/^ADR-\d+-/i, '');
|
|
63
|
+
s = s.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
64
|
+
s = s.replace(/^escolha-/i, '');
|
|
65
|
+
return s || 'decisao';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Normalize one decision note's body: type -> decision, adr: <num>, and an `ADR-NNNN — ` H1 prefix.
|
|
69
|
+
export function normalizeDecisionContent(content, adrNum) {
|
|
70
|
+
let c = String(content || '');
|
|
71
|
+
const label = `ADR-${padAdr(adrNum)}`;
|
|
72
|
+
|
|
73
|
+
// type: decisao|decisão|decision -> decision
|
|
74
|
+
if (/^type:\s*.*$/m.test(c)) c = c.replace(/^type:\s*.*$/m, 'type: decision');
|
|
75
|
+
else c = c.replace(/^---\n/, `---\ntype: decision\n`);
|
|
76
|
+
|
|
77
|
+
// adr: <num> (replace if present, else insert right after the type line)
|
|
78
|
+
if (/^adr:\s*.*$/m.test(c)) c = c.replace(/^adr:\s*.*$/m, `adr: ${adrNum}`);
|
|
79
|
+
else c = c.replace(/^type: decision$/m, `type: decision\nadr: ${adrNum}`);
|
|
80
|
+
|
|
81
|
+
// H1: strip any existing `ADR-\d+ — ` prefix, then prepend the canonical label.
|
|
82
|
+
c = c.replace(/^#\s+(.*)$/m, (_, title) => {
|
|
83
|
+
const bare = title.replace(/^ADR-\d+\s*[—–-]\s*/i, '').trim();
|
|
84
|
+
return `# ${label} — ${bare}`;
|
|
85
|
+
});
|
|
86
|
+
return c;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Replace every wikilink to a renamed note across one file's text. `renames` carries the old/new
|
|
90
|
+
// vault-relative path (no extension) and basename so both full-path and basename links are caught.
|
|
91
|
+
export function rewriteLinks(content, renames) {
|
|
92
|
+
let c = String(content || '');
|
|
93
|
+
for (const r of renames) {
|
|
94
|
+
if (r.oldRelNoExt === r.newRelNoExt) continue;
|
|
95
|
+
c = c.split(`[[${r.oldRelNoExt}]]`).join(`[[${r.newRelNoExt}]]`);
|
|
96
|
+
c = c.split(`[[${r.oldRelNoExt}|`).join(`[[${r.newRelNoExt}|`);
|
|
97
|
+
c = c.split(`[[${r.oldBaseNoExt}]]`).join(`[[${r.newBaseNoExt}]]`);
|
|
98
|
+
c = c.split(`[[${r.oldBaseNoExt}|`).join(`[[${r.newBaseNoExt}|`);
|
|
99
|
+
// Best-effort: refresh a `|ADR-006]]` display alias to the new padded id.
|
|
100
|
+
if (r.oldAdrLabel) c = c.split(`|${r.oldAdrLabel}]]`).join(`|${r.newAdrLabel}]]`);
|
|
101
|
+
}
|
|
102
|
+
return c;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function allVaultMarkdown(vaultBase) {
|
|
106
|
+
const out = [];
|
|
107
|
+
const skip = new Set(['.git', '.obsidian', 'node_modules', '_arquivo']);
|
|
108
|
+
const walk = (dir) => {
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
if (e.name.startsWith('.') && e.name !== '.brain') continue;
|
|
113
|
+
const abs = join(dir, e.name);
|
|
114
|
+
if (e.isDirectory()) { if (!skip.has(e.name)) walk(abs); }
|
|
115
|
+
else if (e.name.endsWith('.md')) out.push(abs);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
walk(vaultBase);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Build the chronological renumber plan. Pure: reads notes, sorts, computes new names. No writes.
|
|
123
|
+
export function planRenumber(vaultBase) {
|
|
124
|
+
const decisionsDir = getLocale(vaultBase).folders.decisions;
|
|
125
|
+
const notes = walkDecisions(vaultBase, decisionsDir)
|
|
126
|
+
.map((n) => ({ ...n, content: safeRead(n.abs) }))
|
|
127
|
+
.sort((a, b) => decisionSortKey(a).localeCompare(decisionSortKey(b)));
|
|
128
|
+
|
|
129
|
+
const renames = [];
|
|
130
|
+
notes.forEach((n, i) => {
|
|
131
|
+
const num = i + 1;
|
|
132
|
+
const slug = slugFromDecisionName(n.base);
|
|
133
|
+
const newBase = `ADR-${padAdr(num)}-${slug}.md`;
|
|
134
|
+
const folderRel = dirname(n.rel);
|
|
135
|
+
const newRel = folderRel === '.' ? newBase : `${folderRel}/${newBase}`;
|
|
136
|
+
const oldAdr = n.base.match(/^ADR-(\d+)/i);
|
|
137
|
+
renames.push({
|
|
138
|
+
num, slug,
|
|
139
|
+
oldAbs: n.abs,
|
|
140
|
+
newAbs: join(dirname(n.abs), newBase),
|
|
141
|
+
oldRelNoExt: n.rel.replace(/\.md$/i, ''),
|
|
142
|
+
newRelNoExt: newRel.replace(/\.md$/i, ''),
|
|
143
|
+
oldBaseNoExt: n.base.replace(/\.md$/i, ''),
|
|
144
|
+
newBaseNoExt: newBase.replace(/\.md$/i, ''),
|
|
145
|
+
oldAdrLabel: oldAdr ? `ADR-${String(Number(oldAdr[1])).padStart(3, '0')}` : '',
|
|
146
|
+
newAdrLabel: `ADR-${padAdr(num)}`,
|
|
147
|
+
renamed: n.base !== newBase,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return renames;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Execute the plan: collision-safe rename (via temp), body normalization, vault-wide link rewrite.
|
|
154
|
+
export function renumberDecisions(vaultBase, { apply = false } = {}) {
|
|
155
|
+
const renames = planRenumber(vaultBase);
|
|
156
|
+
const changed = renames.filter((r) => r.renamed);
|
|
157
|
+
const report = {
|
|
158
|
+
total: renames.length,
|
|
159
|
+
renamed: changed.length,
|
|
160
|
+
normalized: 0,
|
|
161
|
+
linksUpdated: 0,
|
|
162
|
+
filesTouched: 0,
|
|
163
|
+
plan: renames.map((r) => ({ num: r.num, from: r.oldRelNoExt, to: r.newRelNoExt, renamed: r.renamed })),
|
|
164
|
+
applied: apply,
|
|
165
|
+
};
|
|
166
|
+
if (!apply) return report;
|
|
167
|
+
|
|
168
|
+
// Phase A — park every renamed source under a temp name so no target clobbers a not-yet-moved source.
|
|
169
|
+
const temps = new Map();
|
|
170
|
+
changed.forEach((r, i) => {
|
|
171
|
+
const tmp = join(dirname(r.oldAbs), `.wk-renum-${i}.tmp`);
|
|
172
|
+
renameSync(r.oldAbs, tmp);
|
|
173
|
+
temps.set(r, tmp);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Phase B — normalize each note's body, then land it at its final path. For a renamed note we
|
|
177
|
+
// write the normalized content back over its temp and renameSync temp -> final (no delete: the
|
|
178
|
+
// temp file becomes the final file). For an unchanged name we just rewrite in place.
|
|
179
|
+
for (const r of renames) {
|
|
180
|
+
const tmp = temps.get(r);
|
|
181
|
+
if (tmp) {
|
|
182
|
+
writeFileSync(tmp, normalizeDecisionContent(safeRead(tmp), r.num), 'utf8');
|
|
183
|
+
renameSync(tmp, r.newAbs);
|
|
184
|
+
} else {
|
|
185
|
+
writeFileSync(r.newAbs, normalizeDecisionContent(safeRead(r.oldAbs), r.num), 'utf8');
|
|
186
|
+
}
|
|
187
|
+
report.normalized += 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Phase C — rewrite wikilinks to any renamed note across the whole vault.
|
|
191
|
+
const linkRenames = changed;
|
|
192
|
+
for (const abs of allVaultMarkdown(vaultBase)) {
|
|
193
|
+
const before = safeRead(abs);
|
|
194
|
+
const after = rewriteLinks(before, linkRenames);
|
|
195
|
+
if (after !== before) { writeFileSync(abs, after, 'utf8'); report.filesTouched += 1; report.linksUpdated += 1; }
|
|
196
|
+
}
|
|
197
|
+
return report;
|
|
198
|
+
}
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'url';
|
|
|
6
6
|
import { createLinkedNotes } from './linked-notes.mjs';
|
|
7
7
|
import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
|
|
8
8
|
import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
|
|
9
|
-
import { activeChangeLink } from './change-core.mjs';
|
|
9
|
+
import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
|
|
10
10
|
import { getLocale } from './locale.mjs';
|
|
11
11
|
import { upsertSubagentUsage } from './subagent-usage.mjs';
|
|
12
12
|
import {
|
|
@@ -855,12 +855,19 @@ function replacePendingSection(content, pending) {
|
|
|
855
855
|
const end = content.indexOf(closingMarker, start + marker.length);
|
|
856
856
|
if (end === -1) return content;
|
|
857
857
|
|
|
858
|
+
// Preserva seções que outros writers inseriram dentro do span (## Subagents & Workflows,
|
|
859
|
+
// ## Progresso do plano, ## Mudanças…) — só o texto das Pendências em si é regenerado.
|
|
860
|
+
const span = content.slice(start + marker.length, end);
|
|
861
|
+
const innerIdx = span.indexOf('\n## ');
|
|
862
|
+
const preserved = innerIdx === -1 ? '' : span.slice(innerIdx).trimEnd();
|
|
863
|
+
|
|
858
864
|
return [
|
|
859
865
|
content.slice(0, start).trimEnd(),
|
|
860
866
|
'',
|
|
861
867
|
'## Pendências',
|
|
862
868
|
'',
|
|
863
869
|
formatPendingSection(pending),
|
|
870
|
+
...(preserved ? [preserved] : []),
|
|
864
871
|
content.slice(end),
|
|
865
872
|
].join('\n');
|
|
866
873
|
}
|
|
@@ -1135,6 +1142,9 @@ function main() {
|
|
|
1135
1142
|
process.stderr.write(`[wendkeep] brain index/digest falhou: ${error.message}\n`);
|
|
1136
1143
|
}
|
|
1137
1144
|
|
|
1145
|
+
// GC das sentinelas dos hooks de lifecycle (>7 dias) — fail-quiet, nunca derruba o Stop.
|
|
1146
|
+
try { pruneChangeSentinels(vaultBase); } catch { /* bônus */ }
|
|
1147
|
+
|
|
1138
1148
|
pingObsidianVault(input.obsidian_api_key);
|
|
1139
1149
|
writeHookOutput({});
|
|
1140
1150
|
}
|
package/hooks/spec-core.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
|
|
2
2
|
// Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { ensureDir } from './obsidian-common.mjs';
|
|
7
7
|
import { getLocale } from './locale.mjs';
|
|
@@ -87,6 +87,30 @@ export function parseSpecsList(propostaMd) {
|
|
|
87
87
|
return [];
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
|
|
91
|
+
// contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
|
|
92
|
+
export function isPlaceholderDelta(md) {
|
|
93
|
+
const d = parseDelta(md);
|
|
94
|
+
if (d.removed.length) return false;
|
|
95
|
+
const all = [...d.added, ...d.modified];
|
|
96
|
+
if (!all.length) return true;
|
|
97
|
+
return all.every((r) => /^\((?:nome|name)\)$/.test(r.name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Capabilities com delta REAL no disco (<change>/specs/<cap>/spec.md), independente do
|
|
101
|
+
// frontmatter `specs:` da proposta — o scaffold deixa `specs: []` e o buraco engolia deltas
|
|
102
|
+
// preenchidos mas não listados (visto em produção: 07-Specs vazio com delta real no disco).
|
|
103
|
+
export function discoverSpecDeltas(changeDir) {
|
|
104
|
+
let names = [];
|
|
105
|
+
try { names = readdirSync(join(changeDir, 'specs')); } catch { return []; }
|
|
106
|
+
const caps = [];
|
|
107
|
+
for (const cap of names) {
|
|
108
|
+
try { if (!isPlaceholderDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'))) caps.push(cap); }
|
|
109
|
+
catch { /* sem spec.md */ }
|
|
110
|
+
}
|
|
111
|
+
return caps;
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
// Merge each capability's delta (in the change) into the living spec in 07-Specs.
|
|
91
115
|
export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
|
|
92
116
|
const loc = getLocale(vaultBase);
|
package/hooks/subagent-usage.mjs
CHANGED
|
@@ -208,7 +208,9 @@ function upsertSection(content, heading, body) {
|
|
|
208
208
|
const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
209
|
const re = new RegExp(`${esc}\\n[\\s\\S]*?(?=\\n## |$)`);
|
|
210
210
|
if (re.test(content)) return content.replace(re, `${body}\n`);
|
|
211
|
-
|
|
211
|
+
// Âncora ANTES de '## Pendências': o finalize do Stop reescreve o span Pendências→Encerramento
|
|
212
|
+
// inteiro, então qualquer seção inserida lá dentro seria apagada (visto em produção).
|
|
213
|
+
for (const anchor of ['\n## Pendências', '\n## Issues Linear', '\n## Encerramento']) {
|
|
212
214
|
const i = content.indexOf(anchor);
|
|
213
215
|
if (i >= 0) return `${content.slice(0, i)}\n\n${body}\n${content.slice(i)}`;
|
|
214
216
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/change.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
parseTasks,
|
|
9
9
|
setTaskDone,
|
|
10
10
|
archiveChange,
|
|
11
|
+
abandonChange,
|
|
11
12
|
scaffoldPlaceholders,
|
|
12
13
|
} from '../hooks/change-core.mjs';
|
|
13
14
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
@@ -104,8 +105,8 @@ export function runChange(argv) {
|
|
|
104
105
|
const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
|
|
105
106
|
let verdict = null;
|
|
106
107
|
try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
|
|
107
|
-
if (!
|
|
108
|
-
else if (!
|
|
108
|
+
if (!verdict) process.stdout.write(`verdict: ausente — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
|
|
109
|
+
else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
|
|
109
110
|
else {
|
|
110
111
|
const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
|
|
111
112
|
process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
|
|
@@ -156,11 +157,11 @@ export function runChange(argv) {
|
|
|
156
157
|
// Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
|
|
157
158
|
const gate = (dir) => {
|
|
158
159
|
// G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
|
|
159
|
-
// ADR falso.
|
|
160
|
-
//
|
|
160
|
+
// ADR falso. INESCAPÁVEL desde 0.31.0 (--force não pula — visto em produção: change
|
|
161
|
+
// 100% placeholder arquivada via --force mintou ADR falso). Saída legítima: abandon.
|
|
161
162
|
const placeholders = scaffoldPlaceholders(dir);
|
|
162
|
-
if (placeholders.length
|
|
163
|
-
return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas
|
|
163
|
+
if (placeholders.length) {
|
|
164
|
+
return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas antes de arquivar, ou \`wendkeep change abandon ${slug}\` se a change não vai adiante (--force não pula este check)`] };
|
|
164
165
|
}
|
|
165
166
|
let tarefasMd = '';
|
|
166
167
|
try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
|
|
@@ -185,17 +186,38 @@ export function runChange(argv) {
|
|
|
185
186
|
try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
|
|
186
187
|
const s = evaluateGate(evidence, required);
|
|
187
188
|
if (!s.ok) return s;
|
|
188
|
-
//
|
|
189
|
+
// Verdict SEMPRE exigido (0.31.0) — a exigência universal vive AQUI no gate; a semântica
|
|
190
|
+
// reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
|
|
191
|
+
// status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
|
|
189
192
|
let verdict = null;
|
|
190
193
|
try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
const hash = tasksHashOf(tarefasMd);
|
|
195
|
+
if (!verdict) {
|
|
196
|
+
return { ok: false, failing: [reqIds.length
|
|
197
|
+
? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
|
|
198
|
+
: 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
|
|
199
|
+
}
|
|
200
|
+
if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
|
|
201
|
+
if (verdict.tasksHash && verdict.tasksHash !== hash) {
|
|
202
|
+
return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) — re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
|
|
203
|
+
}
|
|
204
|
+
if (reqIds.length) {
|
|
205
|
+
const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash });
|
|
206
|
+
if (!v.ok) {
|
|
207
|
+
if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
|
|
208
|
+
return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
|
|
209
|
+
}
|
|
195
210
|
}
|
|
196
211
|
return { ok: true, failing: [] };
|
|
197
212
|
};
|
|
198
|
-
|
|
213
|
+
// Rastro auditável: forced só quando o --force de fato pulou G1 (tarefa aberta); trivial
|
|
214
|
+
// quando a change não declarou nenhuma prova ([req:]/[sensor:]).
|
|
215
|
+
let tasks = [];
|
|
216
|
+
try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
|
|
217
|
+
const forced = rest.includes('--force') && tasks.some((t) => !t.done);
|
|
218
|
+
const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
|
|
219
|
+
if (trivial) process.stderr.write('aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
|
|
220
|
+
const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
|
|
199
221
|
if (!r.ok) {
|
|
200
222
|
process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
|
|
201
223
|
process.exit(1);
|
|
@@ -206,6 +228,15 @@ export function runChange(argv) {
|
|
|
206
228
|
process.exit(0);
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
|
|
231
|
+
if (sub === 'abandon') {
|
|
232
|
+
const slug = slugArg() || activeChange(vaultBase);
|
|
233
|
+
if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
|
|
234
|
+
const r = abandonChange(vaultBase, slug, { dateStr: today() });
|
|
235
|
+
if (!r.ok) { process.stderr.write(`wendkeep change abandon: ${r.failing.join('; ')}\n`); process.exit(2); }
|
|
236
|
+
process.stdout.write(`abandoned: ${r.archivedRel}\n`);
|
|
237
|
+
process.exit(0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive, abandon.\n`);
|
|
210
241
|
process.exit(2);
|
|
211
242
|
}
|