devflow-kit 3.0.1 → 3.2.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 +49 -0
- package/README.md +1 -1
- package/dist/agents/git.md +2 -2
- package/dist/cli/agents-view/index.js +1 -1
- package/dist/cli/agents-view/render.js +71 -17
- package/dist/cli/agents-view/state.js +42 -16
- package/dist/cli/agents-view/terminal.js +5 -5
- package/dist/cli/commands/agents.js +142 -51
- package/dist/cli/commands/ambient.js +1 -1
- package/dist/cli/commands/attribution-prompts.js +8 -8
- package/dist/cli/commands/capture.js +1 -1
- package/dist/cli/commands/compliance-prompts.js +8 -8
- package/dist/cli/commands/compliance.js +8 -7
- package/dist/cli/commands/flags.js +33 -31
- package/dist/cli/commands/hud.js +1 -1
- package/dist/cli/commands/init-seed.js +9 -9
- package/dist/cli/commands/init.js +162 -85
- package/dist/cli/commands/install-report.js +10 -10
- package/dist/cli/commands/learning.js +302 -136
- package/dist/cli/commands/memory.js +36 -15
- package/dist/cli/commands/proxy.js +23 -23
- package/dist/cli/commands/rules.js +6 -5
- package/dist/cli/commands/tracker-prompts.js +6 -6
- package/dist/cli/commands/tracker.js +9 -9
- package/dist/cli/commands/uninstall.js +183 -59
- package/dist/cli/flags-view/render.js +5 -5
- package/dist/cli/flags-view/state.js +9 -9
- package/dist/cli/flags-view/terminal.js +4 -4
- package/dist/cli/tui/cells.js +1 -1
- package/dist/cli/tui/terminal.js +6 -6
- package/dist/commands/code-review.md +0 -2
- package/dist/commands/debug.md +14 -11
- package/dist/commands/dynamic-build.md +51 -47
- package/dist/commands/dynamic-plan.md +27 -7
- package/dist/commands/dynamic-profile.md +17 -3
- package/dist/commands/dynamic-tickets.md +18 -4
- package/dist/commands/explore.md +9 -3
- package/dist/commands/implement.md +20 -16
- package/dist/commands/plan.md +13 -9
- package/dist/commands/release.md +23 -3
- package/dist/commands/research.md +9 -3
- package/dist/commands/resolve.md +9 -12
- package/dist/commands/self-review.md +0 -2
- package/dist/core/agent-frontmatter.js +28 -3
- package/dist/core/agent-models.js +204 -42
- package/dist/core/agent-state.js +28 -6
- package/dist/core/ansi.js +2 -2
- package/dist/core/assets.js +1 -1
- package/dist/core/cache.js +7 -8
- package/dist/core/codex-auth-inspect.js +4 -4
- package/dist/core/compliance-compose.js +3 -3
- package/dist/core/compliance.js +3 -4
- package/dist/core/evidence-policy.js +14 -13
- package/dist/core/external-models.js +1 -1
- package/dist/core/feature-config.js +71 -13
- package/dist/core/feature-switch.js +3 -3
- package/dist/core/flags.js +49 -25
- package/dist/core/fs-atomic.js +6 -7
- package/dist/core/learning-queue-cleanup.js +16 -81
- package/dist/core/learning-store.js +61 -0
- package/dist/core/linked-path.js +46 -0
- package/dist/core/manifest.js +5 -5
- package/dist/core/mds-variants.js +13 -13
- package/dist/core/model-discovery.js +8 -8
- package/dist/core/observations.js +17 -101
- package/dist/core/orphan-sweep.js +4 -4
- package/dist/core/plugins.js +13 -8
- package/dist/core/project-paths.js +9 -13
- package/dist/core/proxy-log.js +8 -8
- package/dist/core/proxy-state.js +3 -3
- package/dist/core/queue-drain.js +31 -0
- package/dist/core/reference-sweep.js +6 -6
- package/dist/core/teammate-mode-cleanup.js +1 -1
- package/dist/core/tracker.js +14 -14
- package/dist/hud/colors.js +2 -2
- package/dist/hud/components/learning-counts.js +54 -22
- package/dist/hud/components/version-badge.js +1 -1
- package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
- package/dist/skills/git/references/tracker/github/create-release.md +2 -2
- package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
- package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
- package/dist/targets/claude-code/compliance-install.js +17 -15
- package/dist/targets/claude-code/hooks.js +2 -2
- package/dist/targets/claude-code/installer.js +59 -32
- package/dist/targets/claude-code/legacy.js +1 -1
- package/dist/targets/claude-code/post-install.js +135 -45
- package/dist/targets/claude-code/tracker-install.js +2 -2
- package/package.json +1 -1
- package/src/assets/agents/code.md +15 -21
- package/src/assets/agents/design.md +4 -2
- package/src/assets/agents/diagnose.md +3 -1
- package/src/assets/agents/evaluate.md +4 -0
- package/src/assets/agents/git.mds +2 -2
- package/src/assets/agents/knowledge.md +5 -3
- package/src/assets/agents/learning.md +281 -196
- package/src/assets/agents/research.md +3 -1
- package/src/assets/agents/review.md +5 -3
- package/src/assets/agents/scrutinize.md +5 -1
- package/src/assets/agents/simplify.md +4 -0
- package/src/assets/agents/skim.md +4 -2
- package/src/assets/agents/synthesize.md +6 -0
- package/src/assets/agents/test.md +18 -10
- package/src/assets/agents/triage.md +11 -9
- package/src/assets/agents/validate.md +14 -10
- package/src/assets/commands/_partials/_decisions.mds +8 -3
- package/src/assets/commands/_partials/_docs_root.mds +3 -3
- package/src/assets/commands/_partials/_engine.mds +16 -32
- package/src/assets/commands/_partials/_knowledge.mds +0 -2
- package/src/assets/commands/_partials/_preamble.mds +6 -2
- package/src/assets/commands/_partials/_settings.mds +2 -2
- package/src/assets/commands/_partials/_tracker.mds +1 -1
- package/src/assets/commands/code-review.mds +0 -2
- package/src/assets/commands/debug.mds +13 -8
- package/src/assets/commands/dynamic-build.mds +18 -12
- package/src/assets/commands/dynamic-plan.mds +10 -4
- package/src/assets/commands/dynamic-profile.mds +1 -1
- package/src/assets/commands/dynamic-tickets.mds +2 -2
- package/src/assets/commands/explore.mds +9 -1
- package/src/assets/commands/implement.mds +19 -13
- package/src/assets/commands/plan.mds +12 -8
- package/src/assets/commands/release.md +23 -3
- package/src/assets/commands/research.mds +9 -3
- package/src/assets/commands/resolve.mds +9 -10
- package/src/assets/mds/git/_pr.mds +3 -3
- package/src/assets/mds/tracker/_common.mds +1 -1
- package/src/assets/mds/tracker/_github.mds +3 -3
- package/src/assets/mds/tracker/_jira.mds +3 -3
- package/src/assets/mds/tracker/_linear.mds +3 -3
- package/src/assets/mds/tracker/_mcp.mds +6 -5
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -2
- package/src/assets/scripts/hooks/background-memory-update +97 -33
- package/src/assets/scripts/hooks/capture-prompt +4 -3
- package/src/assets/scripts/hooks/capture-question +4 -3
- package/src/assets/scripts/hooks/capture-turn +5 -20
- package/src/assets/scripts/hooks/ensure-devflow-init +14 -2
- package/src/assets/scripts/hooks/ensure-proxy +5 -6
- package/src/assets/scripts/hooks/ensure-root-gitignore +123 -11
- package/src/assets/scripts/hooks/git-marker +71 -0
- package/src/assets/scripts/hooks/is-hex-sha +1 -1
- package/src/assets/scripts/hooks/json-helper.cjs +345 -944
- package/src/assets/scripts/hooks/json-parse +25 -129
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
- package/src/assets/scripts/hooks/lib/learning-store.cjs +3207 -0
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
- package/src/assets/scripts/hooks/memory-worker +10 -0
- package/src/assets/scripts/hooks/pre-compact-memory +66 -14
- package/src/assets/scripts/hooks/preamble +9 -1
- package/src/assets/scripts/hooks/queue-append +55 -23
- package/src/assets/scripts/hooks/resolve-project-root +3 -4
- package/src/assets/scripts/hooks/session-start-context +146 -45
- package/src/assets/scripts/hooks/session-start-memory +33 -11
- package/src/assets/scripts/lib/project-config.cjs +2 -2
- package/src/assets/scripts/pr-evidence.cjs +3 -3
- package/src/assets/scripts/redact-secrets.cjs +20 -20
- package/src/assets/scripts/release-trace.cjs +1 -1
- package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
- package/src/assets/scripts/resolve-settings.cjs +3 -3
- package/src/assets/scripts/verify-evidence.cjs +2 -2
- package/src/assets/skills/apply-decisions/SKILL.md +37 -17
- package/src/assets/skills/docs-framework/SKILL.md +2 -2
- package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
- package/src/assets/skills/test-driven-development/SKILL.md +6 -4
- package/dist/core/observation-io.js +0 -50
- package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
|
@@ -6,30 +6,33 @@
|
|
|
6
6
|
// DESIGN: Idempotent, clock-free render from anchored ledger rows. No timestamps
|
|
7
7
|
// in output — render is a pure function of the ledger rows. Two consumers:
|
|
8
8
|
// 1. renderDecisionsFile(rows, kind) — exported pure function for testing
|
|
9
|
-
// 2. CLI: `render <worktree>` and `--check <worktree>` subcommands
|
|
9
|
+
// 2. CLI: `render <worktree>` and `--check <worktree>` subcommands, run from the
|
|
10
|
+
// project root: <worktree> names the current directory
|
|
10
11
|
//
|
|
11
12
|
// Filtering rules (must match AC-F3):
|
|
12
13
|
// - anchor_id must be set (unanchored observing rows are excluded)
|
|
13
14
|
// - type must match kind: 'decision' rows → decisions.md; 'pitfall' rows → pitfalls.md
|
|
14
|
-
// -
|
|
15
|
-
// '
|
|
15
|
+
// - an active row (isActive from learning-store.cjs, the one status list) renders
|
|
16
|
+
// its body; an inactive one is listed in the file's Inactive table instead
|
|
16
17
|
//
|
|
17
|
-
// Row shape:
|
|
18
|
+
// Row shape: a v2 ledger row (schema 2) or a v1 one; see learning-store.cjs.
|
|
18
19
|
// Ledger file: .devflow/learning/decisions-ledger.jsonl (anchored rows only).
|
|
19
20
|
// If absent, treat as empty corpus.
|
|
20
21
|
//
|
|
21
|
-
// Byte-compat: formatDecisionBody / formatPitfallBody /
|
|
22
|
-
// initDecisionsContent — all from
|
|
22
|
+
// Byte-compat: formatDecisionBody / formatPitfallBody / formatEntryBodyV2 /
|
|
23
|
+
// formatInactiveTable / buildTldrLine / initDecisionsContent — all from
|
|
24
|
+
// decisions-format.cjs (single source of truth).
|
|
23
25
|
|
|
24
26
|
'use strict';
|
|
25
27
|
|
|
26
28
|
const fs = require('fs');
|
|
27
|
-
const path = require('path');
|
|
28
29
|
|
|
29
30
|
const {
|
|
30
31
|
initDecisionsContent,
|
|
31
32
|
formatDecisionBody,
|
|
32
33
|
formatPitfallBody,
|
|
34
|
+
formatEntryBodyV2,
|
|
35
|
+
formatInactiveTable,
|
|
33
36
|
buildTldrLine,
|
|
34
37
|
buildIndexContent,
|
|
35
38
|
} = require('./decisions-format.cjs');
|
|
@@ -38,51 +41,32 @@ const {
|
|
|
38
41
|
getDecisionsFilePath,
|
|
39
42
|
getPitfallsFilePath,
|
|
40
43
|
getDecisionsIndexPath,
|
|
41
|
-
|
|
44
|
+
getDecisionsLedgerPath,
|
|
42
45
|
} = require('./project-paths.cjs');
|
|
43
|
-
const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
|
|
44
46
|
const { safePath } = require('./safe-path.cjs');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/** Ledger filename relative to .devflow/learning/ */
|
|
54
|
-
const LEDGER_FILENAME = 'decisions-ledger.jsonl';
|
|
47
|
+
const {
|
|
48
|
+
isActive,
|
|
49
|
+
isV2,
|
|
50
|
+
readJsonl,
|
|
51
|
+
hasLearningDir,
|
|
52
|
+
withDecisionsLock,
|
|
53
|
+
writeFileAtomic,
|
|
54
|
+
} = require('./learning-store.cjs');
|
|
55
55
|
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
57
57
|
// Ledger parsing
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* Read a JSONL ledger file leniently: its rows, skipping any line that is not one
|
|
62
|
+
* JSON object, or [] when the file is absent. Read-only: a skipped line is never
|
|
63
|
+
* quarantined (the store's readJsonl reports it for a caller that counts them).
|
|
63
64
|
*
|
|
64
65
|
* @param {string} ledgerPath
|
|
65
66
|
* @returns {object[]}
|
|
66
67
|
*/
|
|
67
68
|
function parseLedger(ledgerPath) {
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
71
|
-
} catch (err) {
|
|
72
|
-
if (err.code === 'ENOENT') return [];
|
|
73
|
-
throw err;
|
|
74
|
-
}
|
|
75
|
-
const rows = [];
|
|
76
|
-
for (const line of raw.split('\n')) {
|
|
77
|
-
const trimmed = line.trim();
|
|
78
|
-
if (!trimmed) continue;
|
|
79
|
-
try {
|
|
80
|
-
rows.push(JSON.parse(trimmed));
|
|
81
|
-
} catch {
|
|
82
|
-
// Skip malformed lines
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return rows;
|
|
69
|
+
return readJsonl(ledgerPath).rows;
|
|
86
70
|
}
|
|
87
71
|
|
|
88
72
|
// ---------------------------------------------------------------------------
|
|
@@ -90,19 +74,7 @@ function parseLedger(ledgerPath) {
|
|
|
90
74
|
// ---------------------------------------------------------------------------
|
|
91
75
|
|
|
92
76
|
/**
|
|
93
|
-
*
|
|
94
|
-
* Active = decisions_status is undefined OR is one of 'Accepted'/'Active'.
|
|
95
|
-
*
|
|
96
|
-
* @param {object} row
|
|
97
|
-
* @returns {boolean}
|
|
98
|
-
*/
|
|
99
|
-
function isActive(row) {
|
|
100
|
-
if (!row.decisions_status) return true;
|
|
101
|
-
return !INACTIVE_STATUSES.has(row.decisions_status);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Extract the numeric suffix from an anchor_id like "ADR-016" or "PF-007".
|
|
77
|
+
* Extract the numeric suffix from an anchor_id like "ADR-NNN" or "PF-NNN".
|
|
106
78
|
* Returns Infinity for unparseable values so they sort to the end.
|
|
107
79
|
*
|
|
108
80
|
* @param {string} anchorId
|
|
@@ -114,6 +86,26 @@ function anchorNumeric(anchorId) {
|
|
|
114
86
|
return m ? parseInt(m[0], 10) : Infinity;
|
|
115
87
|
}
|
|
116
88
|
|
|
89
|
+
/** The ledger row type a rendered file of `kind` holds. */
|
|
90
|
+
function rowTypeOf(kind) {
|
|
91
|
+
return kind === 'decisions' ? 'decision' : 'pitfall';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The anchored rows of `kind` whose activity is `active`, sorted by numeric anchor.
|
|
96
|
+
*
|
|
97
|
+
* @param {object[]} rows - all rows from the ledger (unfiltered)
|
|
98
|
+
* @param {'decisions'|'pitfalls'} kind
|
|
99
|
+
* @param {boolean} active
|
|
100
|
+
* @returns {object[]}
|
|
101
|
+
*/
|
|
102
|
+
function selectRows(rows, kind, active) {
|
|
103
|
+
const type = rowTypeOf(kind);
|
|
104
|
+
return rows
|
|
105
|
+
.filter(r => r.type === type && r.anchor_id && isActive(r) === active)
|
|
106
|
+
.sort((a, b) => anchorNumeric(a.anchor_id) - anchorNumeric(b.anchor_id));
|
|
107
|
+
}
|
|
108
|
+
|
|
117
109
|
/**
|
|
118
110
|
* Select active rows of a given kind from the ledger, sorted by numeric anchor.
|
|
119
111
|
* Exported so callers (renderAndWriteAll, migrations) can build the index without
|
|
@@ -124,10 +116,19 @@ function anchorNumeric(anchorId) {
|
|
|
124
116
|
* @returns {object[]} filtered + sorted active rows
|
|
125
117
|
*/
|
|
126
118
|
function selectActiveRows(rows, kind) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
119
|
+
return selectRows(rows, kind, true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Select the inactive rows of a given kind, sorted by numeric anchor: the rows a
|
|
124
|
+
* file lists in its Inactive table instead of rendering their bodies.
|
|
125
|
+
*
|
|
126
|
+
* @param {object[]} rows - all rows from the ledger (unfiltered)
|
|
127
|
+
* @param {'decisions'|'pitfalls'} kind
|
|
128
|
+
* @returns {object[]} filtered + sorted inactive rows
|
|
129
|
+
*/
|
|
130
|
+
function selectInactiveRows(rows, kind) {
|
|
131
|
+
return selectRows(rows, kind, false);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
/**
|
|
@@ -135,8 +136,16 @@ function selectActiveRows(rows, kind) {
|
|
|
135
136
|
* Each block starts with a leading newline (matching the format contract).
|
|
136
137
|
*
|
|
137
138
|
* Per-row content:
|
|
138
|
-
* -
|
|
139
|
-
* -
|
|
139
|
+
* - a v2 row (schema 2) → formatEntryBodyV2 from its fields
|
|
140
|
+
* - a v1 row with a truthy raw_body → raw_body verbatim (migrated entries)
|
|
141
|
+
* - any other v1 row → formatDecisionBody / formatPitfallBody from details
|
|
142
|
+
*
|
|
143
|
+
* D-V1-BYTE-STABLE: a v1 ledger row — any row without schema 2 — renders byte for
|
|
144
|
+
* byte as it did before v2: its raw_body verbatim when truthy, else
|
|
145
|
+
* formatDecisionBody or formatPitfallBody, and its index line keeps the v1 shape;
|
|
146
|
+
* only a schema-2 row takes the v2 body and index line. Reason: the ledger stays
|
|
147
|
+
* v1 until each entry is rewritten, and a render that moved v1 bytes would show
|
|
148
|
+
* every untouched entry as changed and alter text nobody revised.
|
|
140
149
|
*
|
|
141
150
|
* Extracted so renderAndWriteAll can compute blocks once and reuse them
|
|
142
151
|
* for both the body files and buildIndexContent, avoiding a second full
|
|
@@ -148,6 +157,7 @@ function selectActiveRows(rows, kind) {
|
|
|
148
157
|
*/
|
|
149
158
|
function buildBodyBlocks(activeRows, kind) {
|
|
150
159
|
return activeRows.map(row => {
|
|
160
|
+
if (isV2(row)) return formatEntryBodyV2(row);
|
|
151
161
|
if (row.raw_body) {
|
|
152
162
|
// Migrated entry: emit verbatim. raw_body must start with \n## so
|
|
153
163
|
// it fits seamlessly after the header preamble.
|
|
@@ -160,44 +170,27 @@ function buildBodyBlocks(activeRows, kind) {
|
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/**
|
|
163
|
-
* Assemble the full file content
|
|
173
|
+
* Assemble the full file content: the header with the file's TL;DR line, the
|
|
174
|
+
* pre-computed active blocks, then the Inactive table.
|
|
164
175
|
* Internal helper — avoids re-computing blocks when the caller already has them.
|
|
165
176
|
*
|
|
166
177
|
* @param {object[]} activeRows - already-filtered + sorted active rows
|
|
167
178
|
* @param {string[]} blocks - pre-rendered per-row blocks (from buildBodyBlocks)
|
|
168
179
|
* @param {'decisions'|'pitfalls'} kind
|
|
180
|
+
* @param {object[]} inactiveRows - already-filtered + sorted inactive rows
|
|
169
181
|
* @returns {string} complete file content
|
|
170
182
|
*/
|
|
171
|
-
function buildFileFromBlocks(activeRows, blocks, kind) {
|
|
172
|
-
//
|
|
183
|
+
function buildFileFromBlocks(activeRows, blocks, kind, inactiveRows) {
|
|
184
|
+
// The TL;DR line counts the active rows.
|
|
173
185
|
const tldr = buildTldrLine(kind, activeRows);
|
|
174
186
|
|
|
175
|
-
// Build header: replace
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
const initKind = kind === 'decisions' ? 'decision' : 'pitfall';
|
|
179
|
-
const headerWithPlaceholder = initDecisionsContent(initKind);
|
|
187
|
+
// Build header: replace the zero-count TL;DR line that opens the init content
|
|
188
|
+
// ("<!-- TL;DR: 0 {kind} -->\n...") with the real one.
|
|
189
|
+
const headerWithPlaceholder = initDecisionsContent(rowTypeOf(kind));
|
|
180
190
|
// Replace only the first line (the TL;DR comment)
|
|
181
191
|
const header = headerWithPlaceholder.replace(/^<!-- TL;DR:[^\n]*-->/, tldr);
|
|
182
192
|
|
|
183
|
-
return header + blocks.join('');
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Build the full file content from already-filtered + sorted active rows.
|
|
188
|
-
* Internal helper — callers that have already run selectActiveRows can pass
|
|
189
|
-
* the result here directly to avoid re-filtering the ledger.
|
|
190
|
-
*
|
|
191
|
-
* Per-row content:
|
|
192
|
-
* - If row.raw_body is truthy → emit verbatim (migrated entries)
|
|
193
|
-
* - Otherwise → formatDecisionBody / formatPitfallBody from details
|
|
194
|
-
*
|
|
195
|
-
* @param {object[]} activeRows - already-filtered + sorted active rows
|
|
196
|
-
* @param {'decisions'|'pitfalls'} kind
|
|
197
|
-
* @returns {string} complete file content
|
|
198
|
-
*/
|
|
199
|
-
function renderBodyFromActive(activeRows, kind) {
|
|
200
|
-
return buildFileFromBlocks(activeRows, buildBodyBlocks(activeRows, kind), kind);
|
|
193
|
+
return header + blocks.join('') + formatInactiveTable(inactiveRows);
|
|
201
194
|
}
|
|
202
195
|
|
|
203
196
|
/**
|
|
@@ -207,13 +200,14 @@ function renderBodyFromActive(activeRows, kind) {
|
|
|
207
200
|
* Filtering:
|
|
208
201
|
* - row.type must match kind ('decision' → decisions.md, 'pitfall' → pitfalls.md)
|
|
209
202
|
* - row.anchor_id must be set
|
|
210
|
-
* - row
|
|
203
|
+
* - an active row (isActive) renders its body; an inactive one is listed in the
|
|
204
|
+
* Inactive table
|
|
211
205
|
*
|
|
212
206
|
* Output structure:
|
|
213
207
|
* TL;DR line (line 1)
|
|
214
208
|
* File header body (title + preamble)
|
|
215
209
|
* Per-row blocks (sorted by numeric anchor ASC)
|
|
216
|
-
* (
|
|
210
|
+
* Inactive table (sorted by numeric anchor ASC; omitted when empty)
|
|
217
211
|
*
|
|
218
212
|
* Idempotent and clock-free: no timestamps in output.
|
|
219
213
|
*
|
|
@@ -222,54 +216,27 @@ function renderBodyFromActive(activeRows, kind) {
|
|
|
222
216
|
* @returns {string} complete file content
|
|
223
217
|
*/
|
|
224
218
|
function renderDecisionsFile(rows, kind) {
|
|
225
|
-
|
|
219
|
+
const activeRows = selectActiveRows(rows, kind);
|
|
220
|
+
return buildFileFromBlocks(activeRows, buildBodyBlocks(activeRows, kind), kind, selectInactiveRows(rows, kind));
|
|
226
221
|
}
|
|
227
222
|
|
|
228
223
|
// ---------------------------------------------------------------------------
|
|
229
|
-
//
|
|
224
|
+
// Render and write
|
|
230
225
|
// ---------------------------------------------------------------------------
|
|
231
226
|
|
|
232
227
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* @param {string} content
|
|
238
|
-
*/
|
|
239
|
-
function writeAtomic(filePath, content) {
|
|
240
|
-
const tmp = filePath + '.tmp';
|
|
241
|
-
try {
|
|
242
|
-
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
243
|
-
} catch (err) {
|
|
244
|
-
if (err.code !== 'EEXIST') throw err;
|
|
245
|
-
try { fs.unlinkSync(tmp); } catch { /* race */ }
|
|
246
|
-
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
247
|
-
}
|
|
248
|
-
fs.renameSync(tmp, filePath);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ---------------------------------------------------------------------------
|
|
252
|
-
// Lock-free render+write helper (for callers that already hold .decisions.lock)
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Render both decisions.md and pitfalls.md from the given ledger rows and write
|
|
257
|
-
* them atomically. Does NOT acquire any lock — callers (assign-anchor, retire-anchor,
|
|
258
|
-
* refresh-anchor) must already hold .decisions.lock. The standalone `render` CLI takes
|
|
259
|
-
* the lock before calling this function.
|
|
260
|
-
*
|
|
261
|
-
* Creates the decisionsDir if it does not exist.
|
|
228
|
+
* Render decisions.md, pitfalls.md and index.md from the ledger rows, in memory,
|
|
229
|
+
* in the order they are written: the body files first, the index last. `render`
|
|
230
|
+
* and the learning store's writers write exactly these contents, and `--check`
|
|
231
|
+
* compares exactly these.
|
|
262
232
|
*
|
|
263
233
|
* @param {string} worktreePath - Absolute path to the worktree root.
|
|
264
234
|
* @param {object[]} rows - All rows from the ledger (unfiltered).
|
|
235
|
+
* @returns {Array<{ path: string, content: string }>}
|
|
265
236
|
*/
|
|
266
|
-
function
|
|
267
|
-
const decisionsDir = path.join(worktreePath, '.devflow', 'learning');
|
|
268
|
-
fs.mkdirSync(decisionsDir, { recursive: true });
|
|
269
|
-
|
|
237
|
+
function renderLearningFiles(worktreePath, rows) {
|
|
270
238
|
const decisionsFilePath = getDecisionsFilePath(worktreePath);
|
|
271
239
|
const pitfallsFilePath = getPitfallsFilePath(worktreePath);
|
|
272
|
-
const indexFilePath = getDecisionsIndexPath(worktreePath);
|
|
273
240
|
|
|
274
241
|
// Hoist active-row selection: computed once per kind, reused for both the
|
|
275
242
|
// body render and the index build — avoids two redundant selectActiveRows passes.
|
|
@@ -281,31 +248,53 @@ function renderAndWriteAll(worktreePath, rows) {
|
|
|
281
248
|
const decisionBlocks = buildBodyBlocks(activeDecisionRows, 'decisions');
|
|
282
249
|
const pitfallBlocks = buildBodyBlocks(activePitfallRows, 'pitfalls');
|
|
283
250
|
|
|
284
|
-
|
|
285
|
-
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
path: decisionsFilePath,
|
|
254
|
+
content: buildFileFromBlocks(activeDecisionRows, decisionBlocks, 'decisions', selectInactiveRows(rows, 'decisions')),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
path: pitfallsFilePath,
|
|
258
|
+
content: buildFileFromBlocks(activePitfallRows, pitfallBlocks, 'pitfalls', selectInactiveRows(rows, 'pitfalls')),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
// Compact index: a write-time artifact consumed via plain Read.
|
|
262
|
+
path: getDecisionsIndexPath(worktreePath),
|
|
263
|
+
content: buildIndexContent(activeDecisionRows, activePitfallRows, {
|
|
264
|
+
decisionsFilePath,
|
|
265
|
+
pitfallsFilePath,
|
|
266
|
+
decisionBlocks,
|
|
267
|
+
pitfallBlocks,
|
|
268
|
+
}) + '\n',
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Render decisions.md, pitfalls.md and index.md from the given ledger rows and
|
|
275
|
+
* write them atomically. It takes no lock: the caller holds .decisions.lock
|
|
276
|
+
* (D-ONE-LEARNING-LOCK). It writes only into an existing `.devflow/learning/`:
|
|
277
|
+
* without one it throws before writing anything (D-NO-STRAY-TREE).
|
|
278
|
+
*
|
|
279
|
+
* @param {string} worktreePath - Absolute path to the worktree root.
|
|
280
|
+
* @param {object[]} rows - All rows from the ledger (unfiltered).
|
|
281
|
+
* @throws when `<worktreePath>/.devflow/learning/` does not exist, or a write fails
|
|
282
|
+
*/
|
|
283
|
+
function renderAndWriteAll(worktreePath, rows) {
|
|
284
|
+
if (!hasLearningDir(worktreePath)) {
|
|
285
|
+
throw new Error(`renderAndWriteAll: no .devflow/learning/ under ${worktreePath}`);
|
|
286
|
+
}
|
|
287
|
+
const [decisions, pitfalls, index] = renderLearningFiles(worktreePath, rows);
|
|
286
288
|
|
|
287
289
|
// Write body files first; index last. On a crash between body writes and the
|
|
288
290
|
// index write: on the FIRST render the index is absent (reader falls back to
|
|
289
291
|
// (none)); on a RE-render the index is stale — one generation behind the new
|
|
290
292
|
// body files — never corrupt. Both cases are benign and self-heal on the next
|
|
291
293
|
// successful render.
|
|
292
|
-
|
|
293
|
-
writeAtomic(pitfallsFilePath, pitfallsContent);
|
|
294
|
-
|
|
295
|
-
// Build and write compact index (write-time artifact; consumed via plain Read)
|
|
296
|
-
// Reuses the pre-computed active rows and pre-rendered blocks — no additional
|
|
297
|
-
// selectActiveRows or format pass.
|
|
298
|
-
const indexContent = buildIndexContent(activeDecisionRows, activePitfallRows, {
|
|
299
|
-
decisionsFilePath,
|
|
300
|
-
pitfallsFilePath,
|
|
301
|
-
decisionBlocks,
|
|
302
|
-
pitfallBlocks,
|
|
303
|
-
});
|
|
304
|
-
const indexLine = indexContent + '\n';
|
|
305
|
-
writeAtomic(indexFilePath, indexLine);
|
|
294
|
+
for (const file of [decisions, pitfalls, index]) writeFileAtomic(file.path, file.content);
|
|
306
295
|
|
|
307
296
|
process.stderr.write(
|
|
308
|
-
`[render-decisions] wrote decisions.md (${Buffer.byteLength(
|
|
297
|
+
`[render-decisions] wrote decisions.md (${Buffer.byteLength(decisions.content)}B) + pitfalls.md (${Buffer.byteLength(pitfalls.content)}B) + index.md (${Buffer.byteLength(index.content)}B)\n`
|
|
309
298
|
);
|
|
310
299
|
}
|
|
311
300
|
|
|
@@ -313,106 +302,142 @@ function renderAndWriteAll(worktreePath, rows) {
|
|
|
313
302
|
// CLI entry point
|
|
314
303
|
// ---------------------------------------------------------------------------
|
|
315
304
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
305
|
+
/** The name the CLI's messages carry; the lock reports it as the op. */
|
|
306
|
+
const CLI_NAME = 'render-decisions';
|
|
307
|
+
|
|
308
|
+
const USAGE =
|
|
309
|
+
'Usage (run from the project root; <worktree> names the current directory, e.g. "."):\n' +
|
|
310
|
+
' render-decisions.cjs render <worktree> Write decisions.md, pitfalls.md and index.md\n' +
|
|
311
|
+
' render-decisions.cjs --check <worktree> Compare without writing; exit 1 on drift\n';
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The project root the CLI works in: the current directory, with symlinks
|
|
315
|
+
* resolved. The CLI runs from the project root, as the json-helper ops do, and
|
|
316
|
+
* `<worktree>` must name that same directory once resolved (safePath refuses a NUL
|
|
317
|
+
* byte). The argument is only compared: every path the CLI reads or writes is
|
|
318
|
+
* built from the current directory, never from argv.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} arg - the `<worktree>` argument
|
|
321
|
+
* @returns {{ ok: true, value: string } | { ok: false, error: { kind: 'invalid-root', message: string } }}
|
|
322
|
+
*/
|
|
323
|
+
function resolveCliRoot(arg) {
|
|
324
|
+
const invalid = message => ({ ok: false, error: { kind: 'invalid-root', message: `${CLI_NAME}: ${message}` } });
|
|
325
|
+
let cwd;
|
|
326
|
+
let named;
|
|
327
|
+
try {
|
|
328
|
+
cwd = fs.realpathSync(process.cwd());
|
|
329
|
+
named = fs.realpathSync(safePath(arg));
|
|
330
|
+
} catch (err) {
|
|
331
|
+
return invalid(`invalid worktree path: ${err.message}`);
|
|
332
|
+
}
|
|
333
|
+
if (named !== cwd) {
|
|
334
|
+
return invalid(`${named} is not the current directory ${cwd} — run from the project root`);
|
|
337
335
|
}
|
|
336
|
+
return { ok: true, value: cwd };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Report on stderr how many ledger lines were not one JSON object, when any were. */
|
|
340
|
+
function reportMalformed(count, ledgerPath) {
|
|
341
|
+
if (count === 0) return;
|
|
342
|
+
process.stderr.write(
|
|
343
|
+
`[render-decisions] MALFORMED: ${count} ledger line${count === 1 ? '' : 's'} skipped (${ledgerPath})\n`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
338
346
|
|
|
339
|
-
|
|
340
|
-
|
|
347
|
+
/** The ledger's rows, reporting its malformed lines on stderr. Read-only. */
|
|
348
|
+
function readLedgerRows(root) {
|
|
349
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
350
|
+
const { rows, rejected } = readJsonl(ledgerPath);
|
|
351
|
+
reportMalformed(rejected.length, ledgerPath);
|
|
352
|
+
return rows;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The file's content, or null when it does not exist. */
|
|
356
|
+
function readIfPresent(file) {
|
|
341
357
|
try {
|
|
342
|
-
|
|
358
|
+
return fs.readFileSync(file, 'utf8');
|
|
343
359
|
} catch (err) {
|
|
344
|
-
|
|
345
|
-
|
|
360
|
+
if (err && err.code === 'ENOENT') return null;
|
|
361
|
+
throw err;
|
|
346
362
|
}
|
|
363
|
+
}
|
|
347
364
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const activeDecisionRows = selectActiveRows(rows, 'decisions');
|
|
367
|
-
const activePitfallRows = selectActiveRows(rows, 'pitfalls');
|
|
368
|
-
const decisionsContent = renderBodyFromActive(activeDecisionRows, 'decisions');
|
|
369
|
-
const pitfallsContent = renderBodyFromActive(activePitfallRows, 'pitfalls');
|
|
370
|
-
const indexContent = buildIndexContent(activeDecisionRows, activePitfallRows, {
|
|
371
|
-
decisionsFilePath,
|
|
372
|
-
pitfallsFilePath,
|
|
373
|
-
}) + '\n';
|
|
374
|
-
|
|
375
|
-
let drift = false;
|
|
376
|
-
let existingDecisions = '';
|
|
377
|
-
let existingPitfalls = '';
|
|
378
|
-
let existingIndex = '';
|
|
379
|
-
try { existingDecisions = fs.readFileSync(decisionsFilePath, 'utf8'); } catch { drift = true; }
|
|
380
|
-
try { existingPitfalls = fs.readFileSync(pitfallsFilePath, 'utf8'); } catch { drift = true; }
|
|
381
|
-
// index.md missing is a drift condition (it should always be present after a render)
|
|
382
|
-
try { existingIndex = fs.readFileSync(indexFilePath, 'utf8'); } catch { drift = true; }
|
|
383
|
-
|
|
384
|
-
if (!drift) {
|
|
385
|
-
if (existingDecisions !== decisionsContent) {
|
|
386
|
-
process.stderr.write(`[render-decisions] DRIFT: ${decisionsFilePath}\n`);
|
|
387
|
-
drift = true;
|
|
388
|
-
}
|
|
389
|
-
if (existingPitfalls !== pitfallsContent) {
|
|
390
|
-
process.stderr.write(`[render-decisions] DRIFT: ${pitfallsFilePath}\n`);
|
|
391
|
-
drift = true;
|
|
392
|
-
}
|
|
393
|
-
if (existingIndex !== indexContent) {
|
|
394
|
-
process.stderr.write(`[render-decisions] DRIFT: ${indexFilePath}\n`);
|
|
395
|
-
drift = true;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
365
|
+
/**
|
|
366
|
+
* `render`: read the ledger under .decisions.lock and write the three files from
|
|
367
|
+
* what it read (D-ONE-LEARNING-LOCK), so a ledger write that lands while it waits
|
|
368
|
+
* is rendered rather than overwritten. Refuses without the learning directory
|
|
369
|
+
* (D-NO-STRAY-TREE), and when it or `.devflow` is a symbolic link (D-NO-LINKED-TREE).
|
|
370
|
+
*
|
|
371
|
+
* @param {string} root
|
|
372
|
+
* @returns {number} exit code
|
|
373
|
+
*/
|
|
374
|
+
function renderCommand(root) {
|
|
375
|
+
const result = withDecisionsLock(CLI_NAME, root, () => {
|
|
376
|
+
renderAndWriteAll(root, readLedgerRows(root));
|
|
377
|
+
return { ok: true, value: null };
|
|
378
|
+
});
|
|
379
|
+
if (result.ok) return 0;
|
|
380
|
+
process.stderr.write(`${result.error.message}\n`);
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
398
383
|
|
|
399
|
-
|
|
384
|
+
/**
|
|
385
|
+
* `--check`: render in memory and compare with the files on disk, naming each
|
|
386
|
+
* file that differs or is missing; exit 1 on any drift. It writes nothing and
|
|
387
|
+
* takes no lock — taking it would create the lock directory — and refuses
|
|
388
|
+
* without the learning directory (D-NO-STRAY-TREE).
|
|
389
|
+
*
|
|
390
|
+
* @param {string} root
|
|
391
|
+
* @returns {number} exit code
|
|
392
|
+
*/
|
|
393
|
+
function checkCommand(root) {
|
|
394
|
+
if (!hasLearningDir(root)) {
|
|
395
|
+
process.stderr.write(`${CLI_NAME}: no .devflow/learning/ under ${root} — run from the project root\n`);
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
let drift = false;
|
|
399
|
+
for (const file of renderLearningFiles(root, readLedgerRows(root))) {
|
|
400
|
+
const onDisk = readIfPresent(file.path);
|
|
401
|
+
if (onDisk !== file.content) {
|
|
402
|
+
process.stderr.write(`[render-decisions] DRIFT: ${file.path}${onDisk === null ? ' (missing)' : ''}\n`);
|
|
403
|
+
drift = true;
|
|
404
|
+
}
|
|
400
405
|
}
|
|
406
|
+
return drift ? 1 : 0;
|
|
407
|
+
}
|
|
401
408
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Run the CLI on its arguments and return the exit code. Neither mode creates the
|
|
411
|
+
* learning directory (D-NO-STRAY-TREE).
|
|
412
|
+
*
|
|
413
|
+
* @param {string[]} argv - the arguments after the script path
|
|
414
|
+
* @returns {number}
|
|
415
|
+
*/
|
|
416
|
+
function runCli(argv) {
|
|
417
|
+
const mode = argv[0] === 'render' || argv[0] === '--check' ? argv[0] : null;
|
|
418
|
+
if (mode === null || argv.length !== 2 || !argv[1]) {
|
|
419
|
+
process.stderr.write(USAGE);
|
|
420
|
+
return 1;
|
|
406
421
|
}
|
|
422
|
+
const root = resolveCliRoot(argv[1]);
|
|
423
|
+
if (!root.ok) {
|
|
424
|
+
process.stderr.write(`${root.error.message}\n`);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
return mode === 'render' ? renderCommand(root.value) : checkCommand(root.value);
|
|
428
|
+
}
|
|
407
429
|
|
|
430
|
+
if (require.main === module) {
|
|
431
|
+
// The one exit, outside every lock: withDecisionsLock has released its lock
|
|
432
|
+
// before runCli returns or throws.
|
|
433
|
+
let exitCode;
|
|
408
434
|
try {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
435
|
+
exitCode = runCli(process.argv.slice(2));
|
|
436
|
+
} catch (err) {
|
|
437
|
+
process.stderr.write(`${CLI_NAME}: ${err && err.message ? err.message : String(err)}\n`);
|
|
438
|
+
exitCode = 1;
|
|
413
439
|
}
|
|
414
|
-
|
|
415
|
-
process.exit(0);
|
|
440
|
+
process.exit(exitCode);
|
|
416
441
|
}
|
|
417
442
|
|
|
418
443
|
// ---------------------------------------------------------------------------
|
|
@@ -421,8 +446,10 @@ if (require.main === module) {
|
|
|
421
446
|
|
|
422
447
|
module.exports = {
|
|
423
448
|
renderDecisionsFile,
|
|
449
|
+
renderLearningFiles,
|
|
424
450
|
renderAndWriteAll,
|
|
425
451
|
selectActiveRows,
|
|
452
|
+
selectInactiveRows,
|
|
426
453
|
parseLedger,
|
|
427
454
|
isActive,
|
|
428
455
|
anchorNumeric,
|
|
@@ -64,6 +64,16 @@ source "$SCRIPT_DIR/get-mtime" || { echo "memory-worker: failed to source get-mt
|
|
|
64
64
|
# BEFORE spawning to prevent a second concurrent Stop hook from double-spawning
|
|
65
65
|
# within the same 120s window.
|
|
66
66
|
TRIGGER_FILE="$MEMORY_DIR/.working-memory-last-trigger"
|
|
67
|
+
|
|
68
|
+
# D-HOOKS-NO-SYMLINK (git-marker): the stamp below is a `touch`, which follows
|
|
69
|
+
# a symbolic link, so a linked stamp or memory folder is never stamped, and with no
|
|
70
|
+
# stamp to throttle it no worker is spawned either.
|
|
71
|
+
if ! df_no_symlink_below "$PROJECT_ROOT" "$TRIGGER_FILE"; then
|
|
72
|
+
log "SKIP: a symbolic link sits on the path to $TRIGGER_FILE; nothing written, worker not spawned"
|
|
73
|
+
dbg "EXIT: symbolic link on the throttle path"
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
67
77
|
NOW=$(date +%s)
|
|
68
78
|
LAST_TRIGGER=0
|
|
69
79
|
if [ -f "$TRIGGER_FILE" ]; then
|