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
|
@@ -1,498 +1,263 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/assets/scripts/hooks/json-helper.cjs
|
|
4
|
-
// Provides jq-equivalent operations for hooks when jq is not installed
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Provides jq-equivalent operations for hooks when jq is not installed, and the
|
|
5
|
+
// learning ops the Learning agent runs from the project root.
|
|
6
|
+
// SECURITY: This is a local CLI helper invoked only by shell hooks and the
|
|
7
|
+
// Learning agent with controlled arguments. No operation takes a file path: a
|
|
8
|
+
// file's content arrives on stdin (json-parse redirects it), and the learning ops
|
|
9
|
+
// build every path from the current directory.
|
|
7
10
|
// Usage: node json-helper.cjs <operation> [args...]
|
|
8
11
|
//
|
|
9
12
|
// Operations:
|
|
10
13
|
// get-field <field> [default] Read field from stdin JSON
|
|
11
|
-
// get-field
|
|
12
|
-
// validate Exit 0 if stdin is valid JSON, 1 otherwise
|
|
13
|
-
// compact Compact stdin JSON to single line
|
|
14
|
-
// construct <json-template> [--arg k v] Build JSON object with args
|
|
15
|
-
// update-field <field> <value> [--json] Set field on stdin JSON (--json parses value)
|
|
16
|
-
// update-fields <json-patches> Apply multiple field updates from stdin JSON
|
|
14
|
+
// get-string-field <field> Read field from stdin JSON only when it is a string
|
|
17
15
|
// extract-cwd-field <field> Extract cwd + arbitrary field, SOH-byte delimited
|
|
18
|
-
// extract-text-messages Extract text content from Claude message format
|
|
19
|
-
// merge-evidence Flatten, dedupe, limit to 10 from stdin JSON
|
|
20
|
-
// slurp-sort <file> <field> [limit] Read JSONL, sort by field desc, limit results
|
|
21
|
-
// slurp-cap <file> <field> <limit> Read JSONL, sort by field desc, output limit lines
|
|
22
|
-
// array-length <path> Get length of array at dotted path in stdin JSON
|
|
23
|
-
// array-item <path> <index> Get item at index from array at path in stdin JSON
|
|
24
16
|
// session-output <context> Build SessionStart output envelope
|
|
25
17
|
// prompt-output <context> Build UserPromptSubmit output envelope
|
|
26
18
|
// backup-construct Build pre-compact backup JSON from --arg pairs
|
|
27
|
-
// assign-anchor <
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
19
|
+
// assign-anchor <decision|pitfall> <obs_id>
|
|
20
|
+
// Promote a v2 observation to the next ADR/PF number,
|
|
21
|
+
// skipping numbers tracked files cite; re-renders
|
|
22
|
+
// retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated>
|
|
23
|
+
// Make an entry inactive with the one JSON object on
|
|
24
|
+
// stdin its status takes; re-renders
|
|
25
|
+
// restore-anchor <anchor> Make an inactive entry active again and due for
|
|
26
|
+
// maintenance again, ordered after integrity problems and
|
|
27
|
+
// legacy entries (among them if it is one); re-renders
|
|
28
|
+
// refresh-anchor <anchor>... [--verified]
|
|
29
|
+
// Re-project active v2 entries from the log, or stamp
|
|
30
|
+
// them verified today; re-renders
|
|
31
|
+
// rotate-observations Archive unreferenced observations idle 30+ days
|
|
32
|
+
// put-observation --create|--update|--reinforce
|
|
33
|
+
// Store one observation from one JSON object on
|
|
34
|
+
// stdin; re-projects and re-renders its entries
|
|
35
|
+
// list Read-only: print the ledger and the log by section
|
|
36
|
+
// show <anchor|obs_id> Read-only: print one entry as pretty JSON
|
|
37
|
+
// claim-due Hand out the entries due for maintenance, leased
|
|
38
|
+
// for a day, after the ref their claims are checked at
|
|
39
|
+
// claim-queue Claim the learning queue for this run; prints
|
|
40
|
+
// claimed <token>[ takeover] | busy | none
|
|
41
|
+
// release-claim <token> Release the claim the token owns; prints
|
|
42
|
+
// released | not-owner | gone
|
|
43
|
+
//
|
|
44
|
+
// Every learning op above except claim-queue and release-claim first refreshes
|
|
45
|
+
// the mtime of an existing queue claim — the heartbeat (D-OWNED-CLAIM).
|
|
36
46
|
|
|
37
47
|
'use strict';
|
|
38
48
|
|
|
39
49
|
const fs = require('fs');
|
|
40
|
-
const
|
|
41
|
-
const { execFileSync } = require('child_process');
|
|
50
|
+
const { constants: { MAX_STRING_LENGTH } } = require('buffer');
|
|
42
51
|
|
|
43
52
|
const op = process.argv[2];
|
|
44
53
|
const args = process.argv.slice(3);
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
getDecisionsUsagePath,
|
|
49
|
-
getDecisionsLockDir,
|
|
50
|
-
getDecisionsLedgerPath,
|
|
51
|
-
getDecisionsLogPath,
|
|
52
|
-
getDecisionsArchivePath,
|
|
53
|
-
getObservationsLockDir,
|
|
54
|
-
} = require('./lib/project-paths.cjs');
|
|
55
|
-
const {
|
|
56
|
-
initDecisionsContent,
|
|
57
|
-
toLedgerRow,
|
|
58
|
-
} = require('./lib/decisions-format.cjs');
|
|
59
|
-
const {
|
|
60
|
-
renderAndWriteAll,
|
|
61
|
-
parseLedger,
|
|
62
|
-
} = require('./lib/render-decisions.cjs');
|
|
63
|
-
const { acquireMkdirLock, releaseLock } = require('./lib/mkdir-lock.cjs');
|
|
64
|
-
|
|
65
|
-
function readStdin() {
|
|
66
|
-
try {
|
|
67
|
-
return fs.readFileSync('/dev/stdin', 'utf8').trim();
|
|
68
|
-
} catch {
|
|
69
|
-
return '';
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function getNestedField(obj, field) {
|
|
74
|
-
const parts = field.split('.');
|
|
75
|
-
let current = obj;
|
|
76
|
-
for (const part of parts) {
|
|
77
|
-
if (current == null || typeof current !== 'object') return undefined;
|
|
78
|
-
current = current[part];
|
|
79
|
-
}
|
|
80
|
-
return current;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function parseJsonl(file) {
|
|
84
|
-
const lines = fs.readFileSync(safePath(file), 'utf8').trim().split('\n').filter(Boolean);
|
|
85
|
-
return lines.map(l => {
|
|
86
|
-
try { return JSON.parse(l); } catch { return null; }
|
|
87
|
-
}).filter(Boolean);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Strip leading YAML frontmatter from content that the model may have included
|
|
92
|
-
* despite being told not to. Belt-and-suspenders defense against duplicate frontmatter.
|
|
93
|
-
*/
|
|
94
|
-
function stripLeadingFrontmatter(text) {
|
|
95
|
-
if (!text) return '';
|
|
96
|
-
const trimmed = text.replace(/^\s*\n/, '');
|
|
97
|
-
if (!trimmed.startsWith('---')) return text;
|
|
98
|
-
const match = trimmed.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
|
|
99
|
-
return match ? trimmed.slice(match[0].length) : text;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Write `tmp` with O_EXCL (wx flag) so the kernel rejects the open if a file or
|
|
104
|
-
* symlink already exists at that path, preventing TOCTOU symlink-follow attacks.
|
|
105
|
-
* On EEXIST (stale or attacker-placed .tmp) we unlink and retry once.
|
|
106
|
-
* @param {string} tmp - Path to the temporary file.
|
|
107
|
-
* @param {string} content - Content to write.
|
|
108
|
-
*/
|
|
109
|
-
function writeExclusive(tmp, content) {
|
|
110
|
-
try {
|
|
111
|
-
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
112
|
-
} catch (err) {
|
|
113
|
-
if (err.code !== 'EEXIST') throw err;
|
|
114
|
-
// Stale or attacker-placed .tmp — remove it and retry once.
|
|
115
|
-
try { fs.unlinkSync(tmp); } catch { /* race — already removed */ }
|
|
116
|
-
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function writeJsonlAtomic(file, entries) {
|
|
121
|
-
// PID-scope the tmp name so concurrent writers from different processes
|
|
122
|
-
// never collide on the same .tmp path. mirrors fs-atomic.ts and proxy-log.ts.
|
|
123
|
-
const tmp = file + '.tmp.' + process.pid;
|
|
124
|
-
const content = entries.length > 0
|
|
125
|
-
? entries.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
126
|
-
: '';
|
|
127
|
-
writeExclusive(tmp, content);
|
|
128
|
-
fs.renameSync(tmp, file);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Atomically write a text file via a .tmp sibling and rename. */
|
|
132
|
-
function writeFileAtomic(file, content) {
|
|
133
|
-
// PID-scope the tmp name so concurrent writers from different processes
|
|
134
|
-
// never collide on the same .tmp path. mirrors fs-atomic.ts and proxy-log.ts.
|
|
135
|
-
const tmp = file + '.tmp.' + process.pid;
|
|
136
|
-
writeExclusive(tmp, content);
|
|
137
|
-
fs.renameSync(tmp, file);
|
|
138
|
-
}
|
|
55
|
+
/** The learning modules, once loaded; see learning(). */
|
|
56
|
+
let learningModules = null;
|
|
139
57
|
|
|
140
58
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
59
|
+
* The learning store, loaded on first use and memoized. The generic ops never
|
|
60
|
+
* call it, so a hook that falls back from jq to node never pays for loading it,
|
|
61
|
+
* and the store loads the renderer only when an op renders.
|
|
144
62
|
*
|
|
145
|
-
* @
|
|
146
|
-
* @param {'decision'|'pitfall'} type
|
|
147
|
-
* @returns {{ anchorId: string, nextN: string }}
|
|
63
|
+
* @returns {{ store: object }}
|
|
148
64
|
*/
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
let maxN = 0;
|
|
153
|
-
for (const row of ledgerRows) {
|
|
154
|
-
if (!row.anchor_id || !prefixRe.test(row.anchor_id)) continue;
|
|
155
|
-
const m = row.anchor_id.match(/(\d+)$/);
|
|
156
|
-
if (m) {
|
|
157
|
-
const n = parseInt(m[1], 10);
|
|
158
|
-
if (n > maxN) maxN = n;
|
|
159
|
-
}
|
|
65
|
+
function learning() {
|
|
66
|
+
if (learningModules === null) {
|
|
67
|
+
learningModules = { store: require('./lib/learning-store.cjs') };
|
|
160
68
|
}
|
|
161
|
-
|
|
162
|
-
return { anchorId: `${prefix}-${nextN}`, nextN };
|
|
69
|
+
return learningModules;
|
|
163
70
|
}
|
|
164
71
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
// A design doc can cite a design-local number ("PF-017") in tracked source
|
|
169
|
-
// before the ledger ever mints that same number for an unrelated entry — the
|
|
170
|
-
// two silently collide and nothing catches it until a human notices the text
|
|
171
|
-
// doesn't match. This scans the project tree for a whole-word citation of the
|
|
172
|
-
// candidate id BEFORE assign-anchor writes it, and refuses to mint over a hit.
|
|
173
|
-
// The consumer-side counterpart lives in mdl's scripts/verify-ledger-citations.mjs.
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
72
|
+
/** The bytes each read of stdin asks for. */
|
|
73
|
+
const STDIN_READ_BYTES = 64 * 1024;
|
|
175
74
|
|
|
176
|
-
/**
|
|
177
|
-
const
|
|
75
|
+
/** How long a read of stdin sleeps when a non-blocking descriptor has no input yet. */
|
|
76
|
+
const STDIN_WAIT_MS = 10;
|
|
178
77
|
|
|
179
|
-
/**
|
|
180
|
-
const
|
|
78
|
+
/** The most sleeps one read of stdin takes: 10 s in all, long after any writer the helper's callers use has written. */
|
|
79
|
+
const STDIN_MAX_WAITS = 1000;
|
|
181
80
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
* the ledger's own files (`.devflow/learning/**`, self-citation is expected,
|
|
185
|
-
* not a collision) or any of the excluded directory segments.
|
|
186
|
-
*
|
|
187
|
-
* @param {string} relPath - path relative to the project root, either separator style
|
|
188
|
-
* @returns {boolean}
|
|
189
|
-
*/
|
|
190
|
-
function isCollisionScanExcluded(relPath) {
|
|
191
|
-
const norm = relPath.split(path.sep).join('/');
|
|
192
|
-
if (norm === '.devflow/learning' || norm.startsWith('.devflow/learning/')) return true;
|
|
193
|
-
return norm.split('/').some(seg => COLLISION_SCAN_EXCLUDED_SEGMENTS.has(seg));
|
|
194
|
-
}
|
|
81
|
+
/** What Atomics.wait sleeps on: nothing notifies it, so each wait runs its full time. */
|
|
82
|
+
const STDIN_WAIT_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
195
83
|
|
|
196
84
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* callers fall back to `listFsWalkFiles`.
|
|
85
|
+
* Read stdin to its end, keeping at most `maxBytes`. Every op that reads stdin
|
|
86
|
+
* reads it here, so the generic ops and the learning ops cannot read it two ways.
|
|
87
|
+
* Never throws: a failure is a refusal.
|
|
201
88
|
*
|
|
202
|
-
* D-
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
89
|
+
* D-STDIN-FD0: stdin is read from file descriptor 0 itself, in reads repeated
|
|
90
|
+
* until one returns no bytes — never by opening /dev/stdin, and never by the size
|
|
91
|
+
* fstat reports. Reason: Linux opens /dev/stdin through /proc/self/fd/0, which
|
|
92
|
+
* refuses a socket with ENXIO, and a node parent's 'pipe' hands its child a
|
|
93
|
+
* socket; and for a pipe or a socket fstat reports only what is buffered so far,
|
|
94
|
+
* 0 on Linux, not what the writer has still to send.
|
|
206
95
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
214
|
-
});
|
|
215
|
-
return out.toString('utf8').split('\0').filter(Boolean);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Bounded, non-recursing-into-excluded-dirs fs walk — fallback for a project
|
|
220
|
-
* root that is not a git working tree. The walk is bounded by construction:
|
|
221
|
-
* it only descends into directories actually present on disk, and never
|
|
222
|
-
* descends into an excluded directory at all (E4).
|
|
96
|
+
* A read of a non-blocking descriptor with no input yet fails with EAGAIN (Linux
|
|
97
|
+
* and macOS give EWOULDBLOCK the same number, which node reports as EAGAIN). That
|
|
98
|
+
* is not the end of the input: the reader sleeps STDIN_WAIT_MS and reads again,
|
|
99
|
+
* at most STDIN_MAX_WAITS times in all. The loop is bounded: each pass takes at
|
|
100
|
+
* least one byte, ends it, or spends one of those waits, and it stops once it
|
|
101
|
+
* holds one byte more than `maxBytes`, so it never reads past that byte.
|
|
223
102
|
*
|
|
224
|
-
* @param {
|
|
225
|
-
* @returns {string
|
|
103
|
+
* @param {number} maxBytes
|
|
104
|
+
* @returns {{ ok: true, value: string } | { ok: false, error: { kind: 'too-large' | 'unreadable', message: string } }}
|
|
105
|
+
* the text; or `too-large` when stdin holds more than `maxBytes`, `unreadable` when a read fails
|
|
226
106
|
*/
|
|
227
|
-
function
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
let
|
|
107
|
+
function readStdinUpTo(maxBytes) {
|
|
108
|
+
const buf = Buffer.alloc(Math.min(STDIN_READ_BYTES, maxBytes + 1));
|
|
109
|
+
const chunks = [];
|
|
110
|
+
let total = 0;
|
|
111
|
+
let waits = 0;
|
|
112
|
+
while (total <= maxBytes) {
|
|
113
|
+
let read;
|
|
234
114
|
try {
|
|
235
|
-
|
|
236
|
-
} catch {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
for (const entry of entries) {
|
|
240
|
-
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
241
|
-
if (isCollisionScanExcluded(relPath)) continue;
|
|
242
|
-
if (entry.isDirectory()) {
|
|
243
|
-
stack.push(relPath);
|
|
244
|
-
} else if (entry.isFile()) {
|
|
245
|
-
results.push(relPath);
|
|
115
|
+
read = fs.readSync(0, buf, 0, Math.min(buf.length, maxBytes + 1 - total), null);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (!err || err.code !== 'EAGAIN') {
|
|
118
|
+
return { ok: false, error: { kind: 'unreadable', message: `stdin could not be read: ${err && err.message ? err.message : String(err)}` } };
|
|
246
119
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return results;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Scan the project tree for a whole-word citation of `id` (e.g. `ADR-042`),
|
|
254
|
-
* excluding the ledger's own files and common vendored/build directories.
|
|
255
|
-
* Prefers tracked files (`git ls-files`) when the project root is a git
|
|
256
|
-
* working tree; falls back to a bounded fs walk otherwise. Best-effort:
|
|
257
|
-
* unreadable, binary, or oversized files are skipped rather than failing
|
|
258
|
-
* the scan.
|
|
259
|
-
*
|
|
260
|
-
* @param {string} projectRoot
|
|
261
|
-
* @param {string} id - e.g. 'ADR-042' or 'PF-017'
|
|
262
|
-
* @returns {{ file: string, line: number }[]} hits, empty when no collision
|
|
263
|
-
*/
|
|
264
|
-
function scanForAnchorCollision(projectRoot, id) {
|
|
265
|
-
let files;
|
|
266
|
-
try {
|
|
267
|
-
files = listGitTrackedFiles(projectRoot);
|
|
268
|
-
} catch {
|
|
269
|
-
files = listFsWalkFiles(projectRoot);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
273
|
-
const pattern = new RegExp(`\\b${escaped}\\b`);
|
|
274
|
-
const hits = [];
|
|
275
|
-
for (const relPath of files) {
|
|
276
|
-
if (isCollisionScanExcluded(relPath)) continue;
|
|
277
|
-
const absPath = path.join(projectRoot, relPath);
|
|
278
|
-
let stat;
|
|
279
|
-
try {
|
|
280
|
-
stat = fs.statSync(absPath);
|
|
281
|
-
} catch {
|
|
282
|
-
continue; // race: listed then removed — best-effort scan, skip
|
|
283
|
-
}
|
|
284
|
-
if (!stat.isFile() || stat.size > COLLISION_SCAN_MAX_FILE_BYTES) continue;
|
|
285
|
-
let content;
|
|
286
|
-
try {
|
|
287
|
-
content = fs.readFileSync(absPath, 'utf8');
|
|
288
|
-
} catch {
|
|
289
|
-
continue; // unreadable or invalid utf8 — best-effort scan, skip
|
|
290
|
-
}
|
|
291
|
-
if (content.includes('\u0000')) continue; // binary heuristic
|
|
292
|
-
const lines = content.split('\n');
|
|
293
|
-
for (let i = 0; i < lines.length; i++) {
|
|
294
|
-
if (pattern.test(lines[i])) {
|
|
295
|
-
hits.push({ file: relPath, line: i + 1 });
|
|
120
|
+
if (waits === STDIN_MAX_WAITS) {
|
|
121
|
+
return { ok: false, error: { kind: 'unreadable', message: `stdin could not be read: it had not ended after ${STDIN_MAX_WAITS * STDIN_WAIT_MS} ms of waiting for input` } };
|
|
296
122
|
}
|
|
123
|
+
waits += 1;
|
|
124
|
+
Atomics.wait(STDIN_WAIT_CELL, 0, 0, STDIN_WAIT_MS);
|
|
125
|
+
continue;
|
|
297
126
|
}
|
|
127
|
+
if (read === 0) break;
|
|
128
|
+
chunks.push(Buffer.from(buf.subarray(0, read)));
|
|
129
|
+
total += read;
|
|
298
130
|
}
|
|
299
|
-
return
|
|
131
|
+
if (total > maxBytes) return { ok: false, error: { kind: 'too-large', message: `stdin holds more than ${maxBytes} bytes` } };
|
|
132
|
+
return { ok: true, value: Buffer.concat(chunks, total).toString('utf8') };
|
|
300
133
|
}
|
|
301
134
|
|
|
302
135
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* @param {{ file: string, line: number }[]} hits
|
|
307
|
-
* @returns {string}
|
|
136
|
+
* The most stdin a generic op reads: the longest string node can build. A hook's
|
|
137
|
+
* input, a Stop hook's last assistant message among it, has no limit of its own,
|
|
138
|
+
* so a generic op refuses only what it could not hold as text.
|
|
308
139
|
*/
|
|
309
|
-
|
|
310
|
-
return hits.map(h => ` ${h.file}:${h.line}`).join('\n');
|
|
311
|
-
}
|
|
140
|
+
const STDIN_TEXT_MAX_BYTES = MAX_STRING_LENGTH;
|
|
312
141
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
*/
|
|
318
|
-
function readUsageFile(projectRoot) {
|
|
319
|
-
const filePath = getDecisionsUsagePath(projectRoot);
|
|
320
|
-
try {
|
|
321
|
-
const raw = fs.readFileSync(filePath, 'utf8');
|
|
322
|
-
const data = JSON.parse(raw);
|
|
323
|
-
if (data && data.version === 1 && typeof data.entries === 'object') return data;
|
|
324
|
-
} catch { /* ENOENT or malformed — return default */ }
|
|
325
|
-
return { version: 1, entries: {} };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Write .decisions-usage.json atomically.
|
|
330
|
-
* @param {string} projectRoot - Path to project root (cwd)
|
|
331
|
-
* @param {{version: number, entries: Object}} data
|
|
332
|
-
*/
|
|
333
|
-
function writeUsageFile(projectRoot, data) {
|
|
334
|
-
writeFileAtomic(getDecisionsUsagePath(projectRoot), JSON.stringify(data, null, 2) + '\n');
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Register an entry in .decisions-usage.json with initial cite count.
|
|
339
|
-
* @param {string} projectRoot - Path to project root (cwd)
|
|
340
|
-
* @param {string} anchorId - e.g. 'ADR-001' or 'PF-003'
|
|
341
|
-
*/
|
|
342
|
-
function registerUsageEntry(projectRoot, anchorId) {
|
|
343
|
-
const data = readUsageFile(projectRoot);
|
|
344
|
-
if (!data.entries[anchorId]) {
|
|
345
|
-
data.entries[anchorId] = {
|
|
346
|
-
cites: 0,
|
|
347
|
-
last_cited: null,
|
|
348
|
-
created: new Date().toISOString(),
|
|
349
|
-
};
|
|
350
|
-
writeUsageFile(projectRoot, data);
|
|
351
|
-
}
|
|
142
|
+
/** A generic op's stdin, trimmed: '' when it cannot be read, so the op fails as it does on empty input. */
|
|
143
|
+
function readStdin() {
|
|
144
|
+
const text = readStdinUpTo(STDIN_TEXT_MAX_BYTES);
|
|
145
|
+
return text.ok ? text.value.trim() : '';
|
|
352
146
|
}
|
|
353
147
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
* @param {string} archivePath - Path to decisions-log.archive.jsonl
|
|
361
|
-
* @param {number} nowMs - Current time as epoch ms (injectable for tests)
|
|
362
|
-
* @returns {number} count of rotated rows
|
|
363
|
-
*/
|
|
364
|
-
function rotateObservations(logPath, archivePath, nowMs) {
|
|
365
|
-
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
366
|
-
const cutoffMs = nowMs - THIRTY_DAYS_MS;
|
|
367
|
-
|
|
368
|
-
let logEntries = [];
|
|
369
|
-
if (fs.existsSync(logPath)) {
|
|
370
|
-
logEntries = parseLedger(logPath);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const kept = [];
|
|
374
|
-
const stale = [];
|
|
375
|
-
|
|
376
|
-
for (const row of logEntries) {
|
|
377
|
-
// Only move 'observing' rows without anchor_id (unanchored)
|
|
378
|
-
if (row.status !== 'observing' || row.anchor_id) {
|
|
379
|
-
kept.push(row);
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
// Check age using last_seen if present, else first_seen
|
|
383
|
-
const tsField = row.last_seen || row.first_seen;
|
|
384
|
-
if (!tsField) {
|
|
385
|
-
kept.push(row);
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
const rowMs = new Date(tsField).getTime();
|
|
389
|
-
if (isNaN(rowMs) || rowMs > cutoffMs) {
|
|
390
|
-
kept.push(row);
|
|
391
|
-
} else {
|
|
392
|
-
stale.push(row);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (stale.length === 0) return 0;
|
|
397
|
-
|
|
398
|
-
// D003: Dedup stale rows against the existing archive by id before appending.
|
|
399
|
-
// An interrupt-then-retry (process killed after archive write but before log
|
|
400
|
-
// rewrite) would re-classify the same rows as stale and attempt to archive
|
|
401
|
-
// them a second time. Reading existing archive IDs into a Set and filtering
|
|
402
|
-
// prevents duplicate rows in the archive. Cost is O(archive) on retry; O(1)
|
|
403
|
-
// on the normal path when the archive is absent.
|
|
404
|
-
//
|
|
405
|
-
// True append (appendFileSync) is used instead of read-entire-archive+rewrite
|
|
406
|
-
// so cost is O(stale) rather than O(archive) on the write path. The archive
|
|
407
|
-
// is gitignored/recovery-only, so an incomplete final newline on ENOENT is
|
|
408
|
-
// safe — parseLedger handles trailing-newline variance.
|
|
409
|
-
const existingArchiveIds = new Set();
|
|
410
|
-
if (fs.existsSync(archivePath)) {
|
|
411
|
-
const existingRows = parseLedger(archivePath);
|
|
412
|
-
for (const r of existingRows) {
|
|
413
|
-
if (r.id) existingArchiveIds.add(r.id);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const newStale = stale.filter(r => !existingArchiveIds.has(r.id));
|
|
418
|
-
if (newStale.length > 0) {
|
|
419
|
-
// True append — O(newStale), not O(archive)
|
|
420
|
-
const appendContent = newStale.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
421
|
-
fs.appendFileSync(archivePath, appendContent, 'utf8');
|
|
148
|
+
function getNestedField(obj, field) {
|
|
149
|
+
const parts = field.split('.');
|
|
150
|
+
let current = obj;
|
|
151
|
+
for (const part of parts) {
|
|
152
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
153
|
+
current = current[part];
|
|
422
154
|
}
|
|
423
|
-
|
|
424
|
-
// Write remaining rows back to log
|
|
425
|
-
writeJsonlAtomic(logPath, kept);
|
|
426
|
-
|
|
427
|
-
return stale.length;
|
|
155
|
+
return current;
|
|
428
156
|
}
|
|
429
157
|
|
|
430
158
|
function parseArgs(argList) {
|
|
431
159
|
const result = {};
|
|
432
|
-
const jsonArgs = {};
|
|
433
160
|
for (let i = 0; i < argList.length; i++) {
|
|
434
161
|
if (argList[i] === '--arg' && i + 2 < argList.length) {
|
|
435
162
|
result[argList[i + 1]] = argList[i + 2];
|
|
436
163
|
i += 2;
|
|
437
|
-
} else if (argList[i] === '--argjson' && i + 2 < argList.length) {
|
|
438
|
-
try {
|
|
439
|
-
jsonArgs[argList[i + 1]] = JSON.parse(argList[i + 2]);
|
|
440
|
-
} catch {
|
|
441
|
-
jsonArgs[argList[i + 1]] = argList[i + 2];
|
|
442
|
-
}
|
|
443
|
-
i += 2;
|
|
444
164
|
}
|
|
445
165
|
}
|
|
446
|
-
return
|
|
166
|
+
return result;
|
|
447
167
|
}
|
|
448
168
|
|
|
449
169
|
// ---------------------------------------------------------------------------
|
|
450
|
-
//
|
|
451
|
-
// retire-anchor, refresh-anchor). rotate-observations uses a DIFFERENT lock
|
|
452
|
-
// (.observations.lock) and keeps its own scaffold (avoids over-generalising).
|
|
170
|
+
// Learning-op adapter
|
|
453
171
|
// ---------------------------------------------------------------------------
|
|
454
172
|
|
|
455
|
-
/** Acquire-timeout for .decisions.lock (ms). Named to avoid magic numbers (COMP-4). */
|
|
456
|
-
const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
|
|
457
|
-
/** Stale-break threshold for .decisions.lock (ms). Named to avoid magic numbers (COMP-4). */
|
|
458
|
-
const LOCK_STALE_MS = 60000;
|
|
459
|
-
|
|
460
173
|
/**
|
|
461
|
-
*
|
|
174
|
+
* Print a learning op's Result: `format(value)` and a newline on stdout, or the
|
|
175
|
+
* error message and a newline on stderr. Returns the exit code for the op to set
|
|
176
|
+
* as process.exitCode, so the process exits once, after the op has returned and
|
|
177
|
+
* every lock it took is released.
|
|
462
178
|
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
179
|
+
* @param {{ ok: true, value: unknown } | { ok: false, error: { message: string } }} result
|
|
180
|
+
* @param {(value: any) => string} format - the stdout text for the value
|
|
181
|
+
* @returns {0|1}
|
|
182
|
+
*/
|
|
183
|
+
function emit(result, format) {
|
|
184
|
+
if (result.ok) {
|
|
185
|
+
process.stdout.write(`${format(result.value)}\n`);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
process.stderr.write(`${result.error.message}\n`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Refuse a malformed command line: print `<op>: usage: <usage>` on stderr and exit 1,
|
|
194
|
+
* before the op takes any lock.
|
|
466
195
|
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
196
|
+
* @param {string} usage - the usage text, starting with the op's name
|
|
197
|
+
* @returns {never}
|
|
198
|
+
*/
|
|
199
|
+
function exitWithUsage(usage) {
|
|
200
|
+
process.stderr.write(`${op}: usage: ${usage}\n`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The most stdin a learning op reads: far above any valid input, so a runaway writer is refused, not parsed. */
|
|
205
|
+
const STDIN_JSON_MAX_BYTES = 64 * 1024;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A learning op's stdin as one JSON object: the one way text reaches a learning
|
|
209
|
+
* op, so no field of it ever passes through argv or a shell word. Never throws.
|
|
469
210
|
*
|
|
470
|
-
* @param {string} opName -
|
|
471
|
-
* @
|
|
472
|
-
* @param {() => unknown} fn - body to execute under the lock
|
|
211
|
+
* @param {string} opName - for the message
|
|
212
|
+
* @returns {{ ok: true, value: object } | { ok: false, error: { kind: 'invalid-input', message: string } }}
|
|
473
213
|
*/
|
|
474
|
-
function
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
process.stderr.write(`${opName}: timeout acquiring lock at ${lockDir}\n`);
|
|
480
|
-
process.exit(1);
|
|
214
|
+
function readStdinJson(opName) {
|
|
215
|
+
const refuse = message => ({ ok: false, error: { kind: 'invalid-input', message: `${opName}: ${message}` } });
|
|
216
|
+
const text = readStdinUpTo(STDIN_JSON_MAX_BYTES);
|
|
217
|
+
if (!text.ok) {
|
|
218
|
+
return refuse(text.error.kind === 'too-large' ? `${text.error.message}; it must hold one JSON object` : text.error.message);
|
|
481
219
|
}
|
|
482
|
-
|
|
220
|
+
let value;
|
|
221
|
+
try {
|
|
222
|
+
value = JSON.parse(text.value);
|
|
223
|
+
} catch {
|
|
224
|
+
return refuse('stdin must hold one JSON object');
|
|
225
|
+
}
|
|
226
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return refuse('stdin must hold one JSON object');
|
|
227
|
+
return { ok: true, value };
|
|
483
228
|
}
|
|
484
229
|
|
|
230
|
+
/** put-observation's flags and the store modes they name. */
|
|
231
|
+
const PUT_MODES = new Map([['--create', 'create'], ['--update', 'update'], ['--reinforce', 'reinforce']]);
|
|
232
|
+
|
|
233
|
+
/** The entry types assign-anchor takes. */
|
|
234
|
+
const ENTRY_TYPES = new Set(['decision', 'pitfall']);
|
|
235
|
+
|
|
485
236
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* @param {object[]} rows
|
|
490
|
-
* @returns {string}
|
|
237
|
+
* The learning ops whose run sends the claim heartbeat first (D-OWNED-CLAIM).
|
|
238
|
+
* claim-queue and release-claim manage the claim themselves, and the generic ops
|
|
239
|
+
* never touch it. A new learning op joins this set.
|
|
491
240
|
*/
|
|
492
|
-
const
|
|
241
|
+
const LEARNING_OPS = new Set([
|
|
242
|
+
'assign-anchor', 'retire-anchor', 'restore-anchor', 'refresh-anchor', 'rotate-observations',
|
|
243
|
+
'put-observation', 'list', 'show', 'claim-due',
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/** Send the claim heartbeat; a failure is reported on stderr and never stops the op. */
|
|
247
|
+
function heartbeat(root) {
|
|
248
|
+
const beat = learning().store.touchClaim(root);
|
|
249
|
+
if (!beat.ok) process.stderr.write(`${beat.error.message}\n`);
|
|
250
|
+
}
|
|
493
251
|
|
|
252
|
+
// The learning ops run from the project root and take no path to a learning file:
|
|
253
|
+
// each builds its paths from the current directory. Every op that writes takes the
|
|
254
|
+
// store's one learning lock through withDecisionsLock (D-ONE-LEARNING-LOCK) and
|
|
255
|
+
// refuses, creating nothing, when .devflow/learning/ is absent (D-NO-STRAY-TREE)
|
|
256
|
+
// or when it, or .devflow, is a symbolic link (D-NO-LINKED-TREE).
|
|
257
|
+
// A locked body returns its Result; emit prints it once the lock is released.
|
|
494
258
|
if (require.main === module) {
|
|
495
259
|
try {
|
|
260
|
+
if (LEARNING_OPS.has(op)) heartbeat(process.cwd());
|
|
496
261
|
switch (op) {
|
|
497
262
|
case 'get-field': {
|
|
498
263
|
const input = JSON.parse(readStdin());
|
|
@@ -503,65 +268,15 @@ try {
|
|
|
503
268
|
break;
|
|
504
269
|
}
|
|
505
270
|
|
|
506
|
-
case 'get-field
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const val = getNestedField(input, field);
|
|
513
|
-
console.log(val != null ? String(val) : def);
|
|
514
|
-
break;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
case 'validate': {
|
|
518
|
-
try {
|
|
519
|
-
const text = readStdin();
|
|
520
|
-
if (!text) process.exit(1);
|
|
521
|
-
JSON.parse(text);
|
|
522
|
-
process.exit(0);
|
|
523
|
-
} catch {
|
|
524
|
-
process.exit(1);
|
|
525
|
-
}
|
|
526
|
-
break;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
case 'compact': {
|
|
530
|
-
const input = JSON.parse(readStdin());
|
|
531
|
-
console.log(JSON.stringify(input));
|
|
532
|
-
break;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
case 'construct': {
|
|
536
|
-
// Build JSON from --arg/--argjson pairs
|
|
537
|
-
const template = parseArgs(args);
|
|
538
|
-
console.log(JSON.stringify(template));
|
|
539
|
-
break;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
case 'update-field': {
|
|
543
|
-
const input = JSON.parse(readStdin());
|
|
544
|
-
const field = args[0];
|
|
545
|
-
const value = args[1];
|
|
546
|
-
const isJson = args[2] === '--json';
|
|
547
|
-
input[field] = isJson ? JSON.parse(value) : value;
|
|
548
|
-
console.log(JSON.stringify(input));
|
|
549
|
-
break;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
case 'update-fields': {
|
|
553
|
-
// Read stdin JSON, apply field updates from args: field1=val1 field2=val2
|
|
271
|
+
case 'get-string-field': {
|
|
272
|
+
// The typed read: get-field stringifies, so the number 42 and the string
|
|
273
|
+
// "42" are the same to it. Here a JSON string prints byte-exact (no
|
|
274
|
+
// trailing newline added, so the caller sees the value's own) and every
|
|
275
|
+
// other type, an absent field, or a string holding a NUL (no shell
|
|
276
|
+
// variable can carry one) prints nothing.
|
|
554
277
|
const input = JSON.parse(readStdin());
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if (eqIdx > 0) {
|
|
558
|
-
const key = arg.slice(0, eqIdx);
|
|
559
|
-
const val = arg.slice(eqIdx + 1);
|
|
560
|
-
// Try to parse as JSON, fall back to string
|
|
561
|
-
try { input[key] = JSON.parse(val); } catch { input[key] = val; }
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
console.log(JSON.stringify(input));
|
|
278
|
+
const val = getNestedField(input, args[0]);
|
|
279
|
+
if (typeof val === 'string' && !val.includes('\0')) process.stdout.write(val);
|
|
565
280
|
break;
|
|
566
281
|
}
|
|
567
282
|
|
|
@@ -577,77 +292,6 @@ try {
|
|
|
577
292
|
break;
|
|
578
293
|
}
|
|
579
294
|
|
|
580
|
-
case 'extract-text-messages': {
|
|
581
|
-
const input = JSON.parse(readStdin());
|
|
582
|
-
const content = input?.message?.content;
|
|
583
|
-
if (typeof content === 'string') {
|
|
584
|
-
console.log(content);
|
|
585
|
-
break;
|
|
586
|
-
}
|
|
587
|
-
if (!Array.isArray(content)) {
|
|
588
|
-
console.log('');
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
591
|
-
const texts = content
|
|
592
|
-
.filter(c => c.type === 'text')
|
|
593
|
-
.map(c => c.text);
|
|
594
|
-
console.log(texts.join('\n'));
|
|
595
|
-
break;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
case 'merge-evidence': {
|
|
599
|
-
const input = JSON.parse(readStdin());
|
|
600
|
-
// input is [[old_evidence], [new_evidence]] — flatten, dedupe, limit
|
|
601
|
-
const flat = input.flat();
|
|
602
|
-
const unique = [...new Set(flat)];
|
|
603
|
-
console.log(JSON.stringify(unique.slice(0, 10)));
|
|
604
|
-
break;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
case 'slurp-sort': {
|
|
608
|
-
const file = args[0];
|
|
609
|
-
const field = args[1];
|
|
610
|
-
const limit = parseInt(args[2]) || 30;
|
|
611
|
-
const parsed = parseJsonl(file);
|
|
612
|
-
parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
|
|
613
|
-
console.log(JSON.stringify(parsed.slice(0, limit)));
|
|
614
|
-
break;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
case 'slurp-cap': {
|
|
618
|
-
// Read JSONL, sort by field desc, output top N as JSONL (one per line)
|
|
619
|
-
const file = args[0];
|
|
620
|
-
const field = args[1];
|
|
621
|
-
const limit = parseInt(args[2]) || 100;
|
|
622
|
-
const parsed = parseJsonl(file);
|
|
623
|
-
parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
|
|
624
|
-
for (const item of parsed.slice(0, limit)) {
|
|
625
|
-
console.log(JSON.stringify(item));
|
|
626
|
-
}
|
|
627
|
-
break;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
case 'array-length': {
|
|
631
|
-
const input = JSON.parse(readStdin());
|
|
632
|
-
const dotPath = args[0];
|
|
633
|
-
const arr = getNestedField(input, dotPath);
|
|
634
|
-
console.log(Array.isArray(arr) ? arr.length : 0);
|
|
635
|
-
break;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
case 'array-item': {
|
|
639
|
-
const input = JSON.parse(readStdin());
|
|
640
|
-
const dotPath = args[0];
|
|
641
|
-
const index = parseInt(args[1]);
|
|
642
|
-
const arr = getNestedField(input, dotPath);
|
|
643
|
-
if (Array.isArray(arr) && index >= 0 && index < arr.length) {
|
|
644
|
-
console.log(JSON.stringify(arr[index]));
|
|
645
|
-
} else {
|
|
646
|
-
console.log('null');
|
|
647
|
-
}
|
|
648
|
-
break;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
295
|
case 'session-output': {
|
|
652
296
|
const ctx = args[0];
|
|
653
297
|
console.log(JSON.stringify({
|
|
@@ -687,422 +331,195 @@ try {
|
|
|
687
331
|
}
|
|
688
332
|
|
|
689
333
|
// -------------------------------------------------------------------------
|
|
690
|
-
// assign-anchor <
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
// whole word somewhere in tracked source (a pre-mint collision — see
|
|
697
|
-
// scanForAnchorCollision above). --allow-collision skips the scan and
|
|
698
|
-
// mints anyway, for the human-ruled case where the citation should be
|
|
699
|
-
// superseded by the ledger's number.
|
|
700
|
-
//
|
|
701
|
-
// Locking discipline: holds ONLY .decisions.lock (never .observations.lock).
|
|
702
|
-
// O(anchored) — single pass for max numeric suffix (AC-P2).
|
|
334
|
+
// assign-anchor <decision|pitfall> <obs_id>
|
|
335
|
+
// Promote a v2 observation no ledger row carries to the next entry of its
|
|
336
|
+
// type, skipping each number a tracked file cites (assignAnchor,
|
|
337
|
+
// learning-store.cjs: D-LEDGER-REGISTRY, D-E4-SKIP). It never writes the log.
|
|
338
|
+
// stdout: the anchor; stderr: `assign-anchor: skipped <anchor>, cited in
|
|
339
|
+
// <path>:<line>` for each number skipped
|
|
703
340
|
// -------------------------------------------------------------------------
|
|
704
341
|
case 'assign-anchor': {
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if (aaUnknownFlags.length > 0) {
|
|
709
|
-
process.stderr.write(`assign-anchor: unknown flag(s): ${aaUnknownFlags.join(', ')}\n`);
|
|
710
|
-
process.exit(1);
|
|
342
|
+
const { store } = learning();
|
|
343
|
+
if (args.length !== 2 || !ENTRY_TYPES.has(args[0]) || !store.OBS_ID_RE.test(args[1])) {
|
|
344
|
+
exitWithUsage('assign-anchor <decision|pitfall> <obs_id> (run from the project root)');
|
|
711
345
|
}
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const assignObsId = aaPositional[1];
|
|
717
|
-
|
|
718
|
-
if (!assignType || !assignObsId) {
|
|
719
|
-
process.stderr.write('assign-anchor: usage: assign-anchor <type> <obs_id> [--allow-collision]\n');
|
|
720
|
-
process.exit(1);
|
|
721
|
-
}
|
|
722
|
-
if (assignType !== 'decision' && assignType !== 'pitfall') {
|
|
723
|
-
process.stderr.write(`assign-anchor: type must be 'decision' or 'pitfall', got '${assignType}'\n`);
|
|
724
|
-
process.exit(1);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
const aaProjectRoot = process.cwd();
|
|
728
|
-
const aaLedgerPath = getDecisionsLedgerPath(aaProjectRoot);
|
|
729
|
-
const aaLogPath = getDecisionsLogPath(aaProjectRoot);
|
|
730
|
-
|
|
731
|
-
withDecisionsLock('assign-anchor', aaProjectRoot, () => {
|
|
732
|
-
// Read existing ledger (absent = empty)
|
|
733
|
-
const aaLedgerRows = parseLedger(aaLedgerPath);
|
|
734
|
-
|
|
735
|
-
// Compute next anchor — O(anchored), single pass
|
|
736
|
-
const { anchorId: aaAnchorId } = nextAnchorFromLedger(aaLedgerRows, assignType);
|
|
737
|
-
|
|
738
|
-
// E4: pre-mint collision guard — refuse if the candidate id is already
|
|
739
|
-
// cited (as a whole word) somewhere in tracked source with a different
|
|
740
|
-
// meaning, before any ledger write. Never auto-skip to the next free
|
|
741
|
-
// number — the collision is a human call (rename the citation, or
|
|
742
|
-
// rerun with --allow-collision to mint over it deliberately).
|
|
743
|
-
if (!aaAllowCollision) {
|
|
744
|
-
const aaCollisionHits = scanForAnchorCollision(aaProjectRoot, aaAnchorId);
|
|
745
|
-
if (aaCollisionHits.length > 0) {
|
|
746
|
-
throw new Error(
|
|
747
|
-
`assign-anchor: '${aaAnchorId}' is already cited in source with a different ` +
|
|
748
|
-
`meaning; resolve the collision before minting (or pass --allow-collision):\n` +
|
|
749
|
-
formatCollisionHits(aaCollisionHits)
|
|
750
|
-
);
|
|
751
|
-
}
|
|
346
|
+
const result = store.assignAnchor(process.cwd(), args[0], args[1]);
|
|
347
|
+
if (result.ok) {
|
|
348
|
+
for (const skip of result.value.skipped) {
|
|
349
|
+
process.stderr.write(`assign-anchor: skipped ${skip.anchor_id}, cited in ${store.singleLine(skip.file)}:${skip.line}\n`);
|
|
752
350
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
let aaLogEntries = parseLedger(aaLogPath);
|
|
756
|
-
const aaObsIdx = aaLogEntries.findIndex(e => e.id === assignObsId);
|
|
757
|
-
if (aaObsIdx === -1) {
|
|
758
|
-
throw new Error(`assign-anchor: obs_id '${assignObsId}' not found in ${aaLogPath}`);
|
|
759
|
-
}
|
|
760
|
-
const aaObs = aaLogEntries[aaObsIdx];
|
|
761
|
-
|
|
762
|
-
// Precondition assertions — both checked under the lock so they are
|
|
763
|
-
// race-free against concurrent assign-anchor callers (avoids silent
|
|
764
|
-
// ledger corruption; assert-preconditions per reliability rule).
|
|
765
|
-
//
|
|
766
|
-
// (a) The newly computed anchor_id must not already appear in the ledger.
|
|
767
|
-
// nextAnchorFromLedger is deterministic-monotone, so this should
|
|
768
|
-
// never fire in normal operation — it guards against double-assign
|
|
769
|
-
// bugs (e.g. assign called twice for the same obs_id in a crash loop).
|
|
770
|
-
if (aaLedgerRows.some(r => r.anchor_id === aaAnchorId)) {
|
|
771
|
-
throw new Error(
|
|
772
|
-
`assign-anchor: anchor_id '${aaAnchorId}' already present in ledger — ` +
|
|
773
|
-
`possible double-assign; refusing to overwrite committed entry`
|
|
774
|
-
);
|
|
775
|
-
}
|
|
776
|
-
//
|
|
777
|
-
// (b) The target observation must not already have an anchor_id set.
|
|
778
|
-
// Re-anchoring an already-anchored obs would mint a duplicate number
|
|
779
|
-
// (the old anchor would remain in the ledger AND the new one would
|
|
780
|
-
// be added), corrupting the committed source of truth.
|
|
781
|
-
if (aaObs.anchor_id) {
|
|
782
|
-
throw new Error(
|
|
783
|
-
`assign-anchor: obs_id '${assignObsId}' is already anchored as '${aaObs.anchor_id}'; ` +
|
|
784
|
-
`use retire-anchor to change its status instead`
|
|
785
|
-
);
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// Build canonical committed-ledger row via toLedgerRow projector.
|
|
789
|
-
// Whitelists only the canonical fields — excludes all observation-lifecycle
|
|
790
|
-
// state (evidence, confidence, quality_ok, count, first_seen, last_seen, …)
|
|
791
|
-
// that must stay in the log only. applies ADR-008.
|
|
792
|
-
const aaDate = new Date().toISOString().slice(0, 10);
|
|
793
|
-
const aaActiveStatus = assignType === 'decision' ? 'Accepted' : 'Active';
|
|
794
|
-
// Date stamped on ALL entry types (decisions + pitfalls). Prefer the
|
|
795
|
-
// date from the observation (content authority per ADR-022); fall back
|
|
796
|
-
// to today. Both types carry a date so refresh-anchor can re-project
|
|
797
|
-
// them correctly (pattern refreshes too — consumers match anchor headings, never titles, per ADR-022).
|
|
798
|
-
const aaEntryDate = aaObs.date || aaDate;
|
|
799
|
-
const aaLedgerRow = toLedgerRow(aaObs, {
|
|
800
|
-
anchorId: aaAnchorId,
|
|
801
|
-
status: aaActiveStatus,
|
|
802
|
-
date: aaEntryDate,
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
// Append anchored row to ledger (atomic temp+rename).
|
|
806
|
-
//
|
|
807
|
-
// D002: Crash window — if the process is killed between this write and
|
|
808
|
-
// renderAndWriteAll below, the ledger will be ahead of decisions.md /
|
|
809
|
-
// pitfalls.md. This is git-recoverable: the ledger is the source of
|
|
810
|
-
// truth and `render-decisions.cjs render <worktree>` re-renders the
|
|
811
|
-
// .md files. The render is kept as the FINAL write under the lock so
|
|
812
|
-
// the window is as narrow as possible.
|
|
813
|
-
const aaNewLedgerRows = [...aaLedgerRows, aaLedgerRow];
|
|
814
|
-
writeFileAtomic(aaLedgerPath, serializeLedger(aaNewLedgerRows));
|
|
815
|
-
|
|
816
|
-
// Mark log row as created and stamp anchor_id so guard (b) fires on
|
|
817
|
-
// any subsequent assign-anchor call for the same obs_id. Without this
|
|
818
|
-
// write-back the guard is dead: aaObs.anchor_id would be undefined on
|
|
819
|
-
// a re-read and a second assign would silently mint a duplicate number.
|
|
820
|
-
// applies ADR-022 (log is content authority; anchor_id written back to arm guard).
|
|
821
|
-
aaLogEntries[aaObsIdx] = Object.assign({}, aaObs, { status: 'created', anchor_id: aaAnchorId });
|
|
822
|
-
writeJsonlAtomic(aaLogPath, aaLogEntries);
|
|
823
|
-
|
|
824
|
-
// Register usage entry
|
|
825
|
-
registerUsageEntry(aaProjectRoot, aaAnchorId);
|
|
826
|
-
|
|
827
|
-
// Re-render both .md files (lock-free — we already hold .decisions.lock).
|
|
828
|
-
// This is the FINAL write in the lock scope — see D002 above.
|
|
829
|
-
renderAndWriteAll(aaProjectRoot, aaNewLedgerRows);
|
|
830
|
-
|
|
831
|
-
// Print assigned anchor id to stdout
|
|
832
|
-
process.stdout.write(aaAnchorId + '\n');
|
|
833
|
-
});
|
|
351
|
+
}
|
|
352
|
+
process.exitCode = emit(result, assigned => assigned.anchor_id);
|
|
834
353
|
break;
|
|
835
354
|
}
|
|
836
355
|
|
|
837
356
|
// -------------------------------------------------------------------------
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
357
|
+
// retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated>
|
|
358
|
+
// Make an active entry inactive with the one JSON object on stdin its status
|
|
359
|
+
// takes: { reason } for Retired and Deprecated, { by } for Superseded, and
|
|
360
|
+
// { at, quote } for Encoded, the quote checked at the verify ref
|
|
361
|
+
// (retireAnchor, learning-store.cjs: D-ENCODED-QUOTE).
|
|
362
|
+
// stdout: the new status in lower case and the anchor, then `repointed
|
|
363
|
+
// <anchor>` for each entry re-pointed to the successor
|
|
843
364
|
// -------------------------------------------------------------------------
|
|
844
|
-
case '
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
process.stderr.write('next-anchor: usage: next-anchor <type>\n');
|
|
849
|
-
process.exit(1);
|
|
850
|
-
}
|
|
851
|
-
if (naType !== 'decision' && naType !== 'pitfall') {
|
|
852
|
-
process.stderr.write(`next-anchor: type must be 'decision' or 'pitfall', got '${naType}'\n`);
|
|
853
|
-
process.exit(1);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
const naProjectRoot = process.cwd();
|
|
857
|
-
const naLedgerRows = parseLedger(getDecisionsLedgerPath(naProjectRoot));
|
|
858
|
-
const { anchorId: naAnchorId } = nextAnchorFromLedger(naLedgerRows, naType);
|
|
859
|
-
const naHits = scanForAnchorCollision(naProjectRoot, naAnchorId);
|
|
860
|
-
|
|
861
|
-
process.stdout.write(naAnchorId + '\n');
|
|
862
|
-
if (naHits.length > 0) {
|
|
863
|
-
process.stderr.write(
|
|
864
|
-
`next-anchor: '${naAnchorId}' is already cited in source — collision hits:\n` +
|
|
865
|
-
formatCollisionHits(naHits) + '\n'
|
|
866
|
-
);
|
|
867
|
-
process.exit(1);
|
|
365
|
+
case 'retire-anchor': {
|
|
366
|
+
const { store } = learning();
|
|
367
|
+
if (args.length !== 2 || !store.ANCHOR_ID_RE.test(args[0]) || !store.INACTIVE_STATUSES.includes(args[1])) {
|
|
368
|
+
exitWithUsage('retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated> (one JSON object on stdin; run from the project root)');
|
|
868
369
|
}
|
|
370
|
+
const input = readStdinJson('retire-anchor');
|
|
371
|
+
const result = input.ok ? store.retireAnchor(process.cwd(), args[0], args[1], input.value) : input;
|
|
372
|
+
process.exitCode = emit(result, retired => [
|
|
373
|
+
`${retired.status.toLowerCase()} ${retired.anchor_id}`,
|
|
374
|
+
...retired.repointed.map(anchorId => `repointed ${anchorId}`),
|
|
375
|
+
].join('\n'));
|
|
869
376
|
break;
|
|
870
377
|
}
|
|
871
378
|
|
|
872
379
|
// -------------------------------------------------------------------------
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
380
|
+
// restore-anchor <anchor>
|
|
381
|
+
// Make an inactive entry active again, its notes, last_verified and
|
|
382
|
+
// last_attempt cleared so it is due for maintenance again, ordered after
|
|
383
|
+
// integrity problems and legacy entries, or among them when it is one
|
|
384
|
+
// (restoreAnchor, learning-store.cjs: D-DUE-ORDER).
|
|
385
|
+
// stdout: restored <anchor>
|
|
879
386
|
// -------------------------------------------------------------------------
|
|
880
|
-
case '
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const RETIRE_STATUSES = new Set(['Deprecated', 'Superseded', 'Retired']);
|
|
885
|
-
|
|
886
|
-
if (!retireAnchorId || !retireStatus) {
|
|
887
|
-
process.stderr.write('retire-anchor: usage: retire-anchor <anchor_id> <status>\n');
|
|
888
|
-
process.exit(1);
|
|
889
|
-
}
|
|
890
|
-
if (!RETIRE_STATUSES.has(retireStatus)) {
|
|
891
|
-
process.stderr.write(`retire-anchor: status must be Deprecated|Superseded|Retired, got '${retireStatus}'\n`);
|
|
892
|
-
process.exit(1);
|
|
387
|
+
case 'restore-anchor': {
|
|
388
|
+
const { store } = learning();
|
|
389
|
+
if (args.length !== 1 || !store.ANCHOR_ID_RE.test(args[0])) {
|
|
390
|
+
exitWithUsage('restore-anchor <anchor> (run from the project root)');
|
|
893
391
|
}
|
|
894
|
-
|
|
895
|
-
const raProjectRoot = process.cwd();
|
|
896
|
-
const raLedgerPath = getDecisionsLedgerPath(raProjectRoot);
|
|
897
|
-
|
|
898
|
-
withDecisionsLock('retire-anchor', raProjectRoot, () => {
|
|
899
|
-
const raRows = parseLedger(raLedgerPath);
|
|
900
|
-
const raIdx = raRows.findIndex(r => r.anchor_id === retireAnchorId);
|
|
901
|
-
if (raIdx === -1) {
|
|
902
|
-
throw new Error(`retire-anchor: anchor_id '${retireAnchorId}' not found in ledger`);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// Idempotent: if already set to same status, still write (no-op equivalent)
|
|
906
|
-
raRows[raIdx] = Object.assign({}, raRows[raIdx], { decisions_status: retireStatus });
|
|
907
|
-
writeFileAtomic(raLedgerPath, serializeLedger(raRows));
|
|
908
|
-
|
|
909
|
-
// Re-render both .md (lock-free — we already hold .decisions.lock)
|
|
910
|
-
renderAndWriteAll(raProjectRoot, raRows);
|
|
911
|
-
|
|
912
|
-
// Echo anchor_id to stdout matching the other three ops (CON-P1).
|
|
913
|
-
process.stdout.write(retireAnchorId + '\n');
|
|
914
|
-
});
|
|
392
|
+
process.exitCode = emit(store.restoreAnchor(process.cwd(), args[0]), restored => `restored ${restored.anchor_id}`);
|
|
915
393
|
break;
|
|
916
394
|
}
|
|
917
395
|
|
|
918
396
|
// -------------------------------------------------------------------------
|
|
919
|
-
// refresh-anchor <
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
// ONE lock acquisition, ONE ledger parse, ONE log parse, and ONE render
|
|
925
|
-
// (PERF-1: collapses N agent turns into 1, N re-renders into 1).
|
|
926
|
-
//
|
|
927
|
-
// All-or-nothing semantics: every anchor is validated before any write;
|
|
928
|
-
// a throw on any anchor leaves the ledger and .md files untouched.
|
|
929
|
-
//
|
|
930
|
-
// Algorithm:
|
|
931
|
-
// 1. Read ledger and log ONCE (outside the per-anchor loop).
|
|
932
|
-
// 2. For each anchor: locate ledger row, run precondition checks, run
|
|
933
|
-
// REG-1 details divergence guard (ADR-022: consumers match anchor headings not
|
|
934
|
-
// titles so pattern replacement is sanctioned; only details containment is enforced),
|
|
935
|
-
// re-project via toLedgerRow (which carries PF-023 sink validation for pattern/raw_body/type).
|
|
936
|
-
// 3. Assert row count unchanged (REL-6 — bounds parseLedger silent-drop exposure).
|
|
937
|
-
// 4. Write ledger once, render once, echo all ids to stdout (one per line).
|
|
938
|
-
//
|
|
939
|
-
// Locking discipline: holds ONLY .decisions.lock.
|
|
397
|
+
// refresh-anchor <anchor> [<anchor>...] [--verified]
|
|
398
|
+
// Re-project active v2 entries from their log rows, or under --verified stamp
|
|
399
|
+
// them verified today; all or nothing (refreshAnchors, learning-store.cjs).
|
|
400
|
+
// stdout: `reprojected <anchor>` or `unchanged <anchor>` per anchor, or
|
|
401
|
+
// `verified <anchor>` under --verified, in the order given
|
|
940
402
|
// -------------------------------------------------------------------------
|
|
941
403
|
case 'refresh-anchor': {
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
404
|
+
const { store } = learning();
|
|
405
|
+
const verifiedFlags = args.filter(arg => arg === '--verified').length;
|
|
406
|
+
const anchors = args.filter(arg => arg !== '--verified');
|
|
407
|
+
if (verifiedFlags > 1 || anchors.length === 0 || !anchors.every(arg => store.ANCHOR_ID_RE.test(arg))) {
|
|
408
|
+
exitWithUsage('refresh-anchor <anchor> [<anchor>...] [--verified] (run from the project root)');
|
|
947
409
|
}
|
|
410
|
+
const result = store.refreshAnchors(process.cwd(), anchors, { verified: verifiedFlags === 1 });
|
|
411
|
+
process.exitCode = emit(result, ({ refreshed }) => refreshed.map(entry => `${entry.state} ${entry.anchor_id}`).join('\n'));
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
948
414
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
415
|
+
// -------------------------------------------------------------------------
|
|
416
|
+
// rotate-observations
|
|
417
|
+
// Archive the log rows no ledger row carries once 30 days have passed since
|
|
418
|
+
// their last activity, and delete the usage telemetry's leftovers
|
|
419
|
+
// (D-ROTATE-UNREFERENCED, learning-store.cjs). Takes no argument: the log and
|
|
420
|
+
// the archive are the project root's.
|
|
421
|
+
// stdout: rotated <N> observations
|
|
422
|
+
// -------------------------------------------------------------------------
|
|
423
|
+
case 'rotate-observations': {
|
|
424
|
+
if (args.length > 0) exitWithUsage('rotate-observations (no arguments; run from the project root)');
|
|
425
|
+
const result = learning().store.rotateObservations(process.cwd());
|
|
426
|
+
process.exitCode = emit(result, ({ rotated }) => `rotated ${rotated} observations`);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
952
429
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
430
|
+
// -------------------------------------------------------------------------
|
|
431
|
+
// put-observation --create|--update|--reinforce
|
|
432
|
+
// Store one observation from the JSON object on stdin (D-PUT-NOT-MERGE,
|
|
433
|
+
// D-PUT-REPROJECTS, learning-store.cjs). Exactly one mode flag; no other argv.
|
|
434
|
+
// stdout: created <id> | updated <id> | unchanged <id> | reinforced <id> <n>,
|
|
435
|
+
// then one `reprojected <anchor>` line per entry re-projected
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
case 'put-observation': {
|
|
438
|
+
const mode = args.length === 1 ? PUT_MODES.get(args[0]) : undefined;
|
|
439
|
+
if (mode === undefined) {
|
|
440
|
+
exitWithUsage('put-observation --create|--update|--reinforce (one JSON object on stdin; run from the project root)');
|
|
962
441
|
}
|
|
442
|
+
const input = readStdinJson('put-observation');
|
|
443
|
+
const result = input.ok ? learning().store.putObservation(process.cwd(), mode, input.value) : input;
|
|
444
|
+
process.exitCode = emit(result, put => [
|
|
445
|
+
put.outcome === 'reinforced' ? `reinforced ${put.id} ${put.observations}` : `${put.outcome} ${put.id}`,
|
|
446
|
+
...put.reprojected.map(anchorId => `reprojected ${anchorId}`),
|
|
447
|
+
].join('\n'));
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
963
450
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const rfLedgerIdx = rfLedgerRows.findIndex(r => r.anchor_id === anchorId);
|
|
976
|
-
if (rfLedgerIdx === -1) {
|
|
977
|
-
throw new Error(
|
|
978
|
-
`refresh-anchor: anchor_id '${anchorId}' not found in ledger — ` +
|
|
979
|
-
`cannot refresh a row that was never committed`
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
const rfExistingRow = rfLedgerRows[rfLedgerIdx];
|
|
984
|
-
|
|
985
|
-
// Precondition assertions — checked under the lock (assert-preconditions
|
|
986
|
-
// per reliability rule). Mirrors assign-anchor's pattern.
|
|
987
|
-
// (a) Ledger row must have an id — undefined===undefined would bind the wrong log row.
|
|
988
|
-
if (!rfExistingRow.id) {
|
|
989
|
-
throw new Error(
|
|
990
|
-
`refresh-anchor: ledger row '${anchorId}' has no id — ` +
|
|
991
|
-
`cannot resolve its log observation`
|
|
992
|
-
);
|
|
993
|
-
}
|
|
994
|
-
// (b) Ledger row must have decisions_status — toLedgerRow passes it through;
|
|
995
|
-
// absent would cause JSON.stringify to drop the key from the projected row.
|
|
996
|
-
if (!rfExistingRow.decisions_status) {
|
|
997
|
-
throw new Error(
|
|
998
|
-
`refresh-anchor: ledger row '${anchorId}' has no decisions_status — ` +
|
|
999
|
-
`refusing to project a row that would drop it`
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// Locate the log obs by the LEDGER ROW's id field (content authority, ADR-022).
|
|
1004
|
-
// Matching on id (not anchor_id) covers pre-existing obs written before
|
|
1005
|
-
// assign-anchor added anchor_id write-back to the log (avoids PF-041).
|
|
1006
|
-
const rfObs = rfLogEntries.find(r => r.id === rfExistingRow.id);
|
|
1007
|
-
if (!rfObs) {
|
|
1008
|
-
throw new Error(
|
|
1009
|
-
`refresh-anchor: log obs with id '${rfExistingRow.id}' ` +
|
|
1010
|
-
`(for anchor ${anchorId}) not found in log`
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
// (c) Type must match the committed anchor — re-projecting across types would move
|
|
1015
|
-
// a PF-NNN into decisions.md (or vice versa) and corrupt the rendered corpus.
|
|
1016
|
-
// This check also satisfies toLedgerRow's expectType guard (PF-023 sink);
|
|
1017
|
-
// both fire with their respective messages — this one fires first.
|
|
1018
|
-
if (rfObs.type !== rfExistingRow.type) {
|
|
1019
|
-
throw new Error(
|
|
1020
|
-
`refresh-anchor: log obs '${rfObs.id}' type '${rfObs.type}' does not match committed anchor ` +
|
|
1021
|
-
`${anchorId} type '${rfExistingRow.type}' — refusing to re-project across entry types`
|
|
1022
|
-
);
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// REG-1 (avoids PF-044): divergence guard — refuse to silently overwrite
|
|
1026
|
-
// ledger-only curation content. Applies to DETAILS only: pattern replacement
|
|
1027
|
-
// is sanctioned (ADR-022 — consumers match '## (ADR|PF)-NNN:' anchors, never
|
|
1028
|
-
// titles, so a sharpened log pattern may update the rendered heading).
|
|
1029
|
-
// raw_body is handled by isSafeRawBody inside toLedgerRow (PF-023 sink).
|
|
1030
|
-
const rfNormWS = (/** @type {unknown} */ s) =>
|
|
1031
|
-
typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '';
|
|
1032
|
-
const rfLedgerDetails = rfNormWS(rfExistingRow.details);
|
|
1033
|
-
const rfLogDetails = rfNormWS(rfObs.details);
|
|
1034
|
-
if (rfLedgerDetails && !rfLogDetails.includes(rfLedgerDetails)) {
|
|
1035
|
-
throw new Error(
|
|
1036
|
-
`refresh-anchor: ledger row '${anchorId}' carries content absent from log obs ` +
|
|
1037
|
-
`'${rfExistingRow.id}' (details: ledger ${rfLedgerDetails.length}B / log ${rfLogDetails.length}B). ` +
|
|
1038
|
-
`Reconcile the log row first — re-projecting would discard curated content (avoids PF-044).`
|
|
1039
|
-
);
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// Re-project via toLedgerRow (strict canonical projection — ADR-022).
|
|
1043
|
-
// Preserve decisions_status and date from the ledger (ledger-owned fields).
|
|
1044
|
-
// expectType passed for PF-023 sink validation (redundant with the check above,
|
|
1045
|
-
// but ensures the guard holds even if future callers bypass the outer check).
|
|
1046
|
-
rfLedgerRows[rfLedgerIdx] = toLedgerRow(rfObs, {
|
|
1047
|
-
anchorId,
|
|
1048
|
-
status: rfExistingRow.decisions_status,
|
|
1049
|
-
date: rfExistingRow.date,
|
|
1050
|
-
expectType: rfExistingRow.type,
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// (3) REL-6: assert row count unchanged — bounds parseLedger silent-drop
|
|
1055
|
-
// exposure. A whole-file rewrite that shrank the corpus is always a bug.
|
|
1056
|
-
if (rfLedgerRows.length !== rfExpectedRowCount) {
|
|
1057
|
-
throw new Error(
|
|
1058
|
-
`refresh-anchor: ledger row count changed during re-projection ` +
|
|
1059
|
-
`(${rfExpectedRowCount} → ${rfLedgerRows.length}) — refusing to write a lossy rewrite`
|
|
1060
|
-
);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// (4) Write once and render once (PERF-1 — N anchors, one I/O round-trip).
|
|
1064
|
-
writeFileAtomic(rfLedgerPath, serializeLedger(rfLedgerRows));
|
|
1065
|
-
renderAndWriteAll(rfProjectRoot, rfLedgerRows);
|
|
1066
|
-
|
|
1067
|
-
// Echo all refreshed ids to stdout — one per line, mirrors assign-anchor's
|
|
1068
|
-
// contract; callers can confirm which rows were refreshed without parsing stderr.
|
|
1069
|
-
process.stdout.write(refreshAnchorIds.join('\n') + '\n');
|
|
1070
|
-
});
|
|
451
|
+
// -------------------------------------------------------------------------
|
|
452
|
+
// list
|
|
453
|
+
// Print the ledger and the log by section, read-only (readListing and
|
|
454
|
+
// formatListing, learning-store.cjs). Takes no argument.
|
|
455
|
+
// stdout: the ACTIVE, INACTIVE, OBSERVATIONS and INTEGRITY sections, then
|
|
456
|
+
// MALFORMED when lines were skipped
|
|
457
|
+
// -------------------------------------------------------------------------
|
|
458
|
+
case 'list': {
|
|
459
|
+
if (args.length > 0) exitWithUsage('list (no arguments; run from the project root)');
|
|
460
|
+
const { store } = learning();
|
|
461
|
+
process.exitCode = emit(store.readListing(process.cwd()), store.formatListing);
|
|
1071
462
|
break;
|
|
1072
463
|
}
|
|
1073
464
|
|
|
1074
465
|
// -------------------------------------------------------------------------
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// Default paths derived from cwd. Accepts explicit log/archive paths as args.
|
|
1081
|
-
// For testability, _now_ is injectable via the _nowMs parameter in the
|
|
1082
|
-
// internal function; CLI always uses Date.now().
|
|
466
|
+
// show <anchor|obs_id>
|
|
467
|
+
// Print one entry, read-only (showByKey, learning-store.cjs).
|
|
468
|
+
// stdout: pretty JSON { key, ledger, log, history_versions, flags }, plus
|
|
469
|
+
// malformed when lines were skipped
|
|
1083
470
|
// -------------------------------------------------------------------------
|
|
1084
|
-
case '
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
471
|
+
case 'show': {
|
|
472
|
+
const { store } = learning();
|
|
473
|
+
if (args.length !== 1 || !(store.ANCHOR_ID_RE.test(args[0]) || store.OBS_ID_RE.test(args[0]))) {
|
|
474
|
+
exitWithUsage('show <anchor|obs_id> (run from the project root)');
|
|
475
|
+
}
|
|
476
|
+
process.exitCode = emit(store.showByKey(process.cwd(), args[0]), shown => JSON.stringify(shown, null, 2));
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
1090
479
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
480
|
+
// -------------------------------------------------------------------------
|
|
481
|
+
// claim-due
|
|
482
|
+
// Hand out the entries due for maintenance and lease each for a day
|
|
483
|
+
// (claimDue, learning-store.cjs: D-DUE-ORDER, D-VERIFY-REF). Takes no argument.
|
|
484
|
+
// stdout: ref <origin/HEAD|HEAD> <sha12>, or ref none; then one
|
|
485
|
+
// `<anchor> <reason> <bytes>` line per entry handed out, or due none
|
|
486
|
+
// -------------------------------------------------------------------------
|
|
487
|
+
case 'claim-due': {
|
|
488
|
+
if (args.length > 0) exitWithUsage('claim-due (no arguments; run from the project root)');
|
|
489
|
+
const { store } = learning();
|
|
490
|
+
process.exitCode = emit(store.claimDue(process.cwd()), ({ ref, due }) => [
|
|
491
|
+
ref === null ? 'ref none' : `ref ${ref.ref} ${ref.commit.slice(0, 12)}`,
|
|
492
|
+
...(due.length > 0 ? due.map(entry => `${store.singleLine(entry.anchor_id)} ${entry.reason} ${entry.bytes}`) : ['due none']),
|
|
493
|
+
].join('\n'));
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
1094
496
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
497
|
+
// -------------------------------------------------------------------------
|
|
498
|
+
// claim-queue
|
|
499
|
+
// Claim the learning queue for this run (D-OWNED-CLAIM, learning-store.cjs).
|
|
500
|
+
// Takes no argument; mints the token itself.
|
|
501
|
+
// stdout: claimed <token> | claimed <token> takeover | busy | none
|
|
502
|
+
// -------------------------------------------------------------------------
|
|
503
|
+
case 'claim-queue': {
|
|
504
|
+
if (args.length > 0) exitWithUsage('claim-queue (no arguments; run from the project root)');
|
|
505
|
+
const result = learning().store.claimQueue(process.cwd());
|
|
506
|
+
process.exitCode = emit(result, claim => (claim.state === 'claimed'
|
|
507
|
+
? `claimed ${claim.token}${claim.takeover ? ' takeover' : ''}`
|
|
508
|
+
: claim.state));
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
1099
511
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
// release-claim <token>
|
|
514
|
+
// Release the claim the token owns (D-OWNED-CLAIM, learning-store.cjs).
|
|
515
|
+
// stdout: released | not-owner | gone
|
|
516
|
+
// -------------------------------------------------------------------------
|
|
517
|
+
case 'release-claim': {
|
|
518
|
+
const { store } = learning();
|
|
519
|
+
if (args.length !== 1 || !store.CLAIM_TOKEN_RE.test(args[0])) {
|
|
520
|
+
exitWithUsage('release-claim <token> (the 16 hex characters claim-queue printed)');
|
|
1105
521
|
}
|
|
522
|
+
process.exitCode = emit(store.releaseClaim(process.cwd(), args[0]), release => release.state);
|
|
1106
523
|
break;
|
|
1107
524
|
}
|
|
1108
525
|
|
|
@@ -1115,19 +532,3 @@ try {
|
|
|
1115
532
|
process.exit(1);
|
|
1116
533
|
}
|
|
1117
534
|
} // end if (require.main === module)
|
|
1118
|
-
|
|
1119
|
-
// Expose helpers for unit testing (only when required as a module, not run as CLI)
|
|
1120
|
-
if (typeof module !== 'undefined' && module.exports) {
|
|
1121
|
-
module.exports = {
|
|
1122
|
-
readUsageFile,
|
|
1123
|
-
writeUsageFile,
|
|
1124
|
-
registerUsageEntry,
|
|
1125
|
-
writeFileAtomic,
|
|
1126
|
-
writeJsonlAtomic,
|
|
1127
|
-
initDecisionsContent,
|
|
1128
|
-
nextAnchorFromLedger,
|
|
1129
|
-
rotateObservations,
|
|
1130
|
-
scanForAnchorCollision,
|
|
1131
|
-
isCollisionScanExcluded,
|
|
1132
|
-
};
|
|
1133
|
-
}
|