@jaimevalasek/aioson 1.16.0 → 1.17.3
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/package.json +4 -2
- package/src/cli.js +5 -0
- package/src/commands/chain-audit.js +156 -0
- package/src/commands/runtime.js +27 -0
- package/src/commands/store-system.js +100 -12
- package/src/i18n/messages/en.js +9 -0
- package/src/i18n/messages/es.js +9 -0
- package/src/i18n/messages/fr.js +9 -0
- package/src/i18n/messages/pt-BR.js +9 -0
- package/src/neural-chain-agent-ingest.js +400 -0
- package/src/neural-chain-config.js +95 -0
- package/src/neural-chain-git-ingest.js +280 -0
- package/src/neural-chain-migration.js +61 -0
- package/src/neural-chain-noise-file.js +332 -0
- package/src/neural-chain-sanitize.js +0 -0
- package/src/neural-chain-telemetry.js +90 -0
- package/src/runtime-store.js +2 -0
- package/template/.aioson/agents/analyst.md +1 -1
- package/template/.aioson/agents/briefing.md +3 -1
- package/template/.aioson/agents/copywriter.md +1 -1
- package/template/.aioson/agents/dev.md +2 -2
- package/template/.aioson/agents/deyvin.md +12 -12
- package/template/.aioson/agents/neo.md +94 -72
- package/template/.aioson/agents/product.md +1 -1
- package/template/.aioson/agents/qa.md +3 -3
- package/template/.aioson/agents/sheldon.md +3 -3
- package/template/.aioson/agents/tester.md +1 -1
- package/template/.aioson/docs/briefing/briefing-craft.md +16 -0
- package/template/.aioson/docs/deyvin/runtime-handoffs.md +1 -1
- package/template/.aioson/docs/handoff-persistence.md +7 -7
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Neural Chain — git co-edit ingest helper.
|
|
5
|
+
*
|
|
6
|
+
* Implements the `git_co_edit` edge source for `chain_edges`. Co-edit pairs
|
|
7
|
+
* are derived from `git log --pretty=format:%H|%cI --name-only -n N HEAD`
|
|
8
|
+
* bounded to the last DEFAULT_MAX_COMMITS commits, filtered to the last
|
|
9
|
+
* WINDOW_DAYS days, with `.aioson/` framework state excluded and mega-commits
|
|
10
|
+
* (> MAX_FILES_PER_COMMIT files) skipped to bound the N² pair explosion.
|
|
11
|
+
*
|
|
12
|
+
* Confidence per BR-NC-01: `min(1.0, count / CONFIDENCE_SATURATION)`.
|
|
13
|
+
* Hard cap per BR-NC-08: HARD_CAP_PER_NODE active edges per source_path; oldest
|
|
14
|
+
* by `last_seen_at` is archived (`end_at = now`) before inserting beyond cap.
|
|
15
|
+
*
|
|
16
|
+
* EC-NC-06 (no git history): `runGitIngest` returns `{skipped: true,
|
|
17
|
+
* reason: 'insufficient_history'}` when `git rev-list --count HEAD` < MIN.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent: re-running with the same commit window produces the same active
|
|
20
|
+
* state (UPSERT on the partial UNIQUE index over active rows).
|
|
21
|
+
*
|
|
22
|
+
* Two directional rows are stored per co-edit pair (A→B and B→A) to keep
|
|
23
|
+
* `chain:audit WHERE source_path = X` queries direct without UNION ALL.
|
|
24
|
+
*
|
|
25
|
+
* Internal representation: `Map<source_path, Map<target_path, { count, lastSeen }>>`.
|
|
26
|
+
* Nested map avoids string-separator ambiguity (file paths may contain any
|
|
27
|
+
* character including spaces and NUL bytes).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const { isUnsafePath } = require('./neural-chain-sanitize');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const { execSync } = require('node:child_process');
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MAX_COMMITS = 1000;
|
|
36
|
+
const CONFIDENCE_SATURATION = 10;
|
|
37
|
+
const WINDOW_DAYS = 90;
|
|
38
|
+
const MAX_FILES_PER_COMMIT = 50;
|
|
39
|
+
const HARD_CAP_PER_NODE = 10000;
|
|
40
|
+
const MIN_COMMITS_FOR_INGEST = 50;
|
|
41
|
+
const GIT_LOG_MAX_BUFFER = 32 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
function parseGitLog(rawLog) {
|
|
44
|
+
const commits = [];
|
|
45
|
+
if (!rawLog || typeof rawLog !== 'string') return commits;
|
|
46
|
+
|
|
47
|
+
const lines = rawLog.split(/\r?\n/);
|
|
48
|
+
let current = null;
|
|
49
|
+
const headerPattern = /^([0-9a-f]{7,40})\|(.+)$/;
|
|
50
|
+
|
|
51
|
+
for (const rawLine of lines) {
|
|
52
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
53
|
+
if (!line) {
|
|
54
|
+
if (current) {
|
|
55
|
+
commits.push(current);
|
|
56
|
+
current = null;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const headerMatch = line.match(headerPattern);
|
|
62
|
+
if (headerMatch) {
|
|
63
|
+
if (current) commits.push(current);
|
|
64
|
+
current = {
|
|
65
|
+
commit_hash: headerMatch[1],
|
|
66
|
+
committer_date_iso: headerMatch[2],
|
|
67
|
+
files: []
|
|
68
|
+
};
|
|
69
|
+
} else if (current) {
|
|
70
|
+
current.files.push(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (current) commits.push(current);
|
|
74
|
+
return commits;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function bumpPair(bySource, source, target, isoDate) {
|
|
78
|
+
let inner = bySource.get(source);
|
|
79
|
+
if (!inner) {
|
|
80
|
+
inner = new Map();
|
|
81
|
+
bySource.set(source, inner);
|
|
82
|
+
}
|
|
83
|
+
const existing = inner.get(target);
|
|
84
|
+
if (existing) {
|
|
85
|
+
existing.count += 1;
|
|
86
|
+
if (isoDate > existing.lastSeen) existing.lastSeen = isoDate;
|
|
87
|
+
} else {
|
|
88
|
+
inner.set(target, { count: 1, lastSeen: isoDate });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function computeCoEditPairs(commits, { now = new Date() } = {}) {
|
|
93
|
+
const cutoff = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
94
|
+
const bySource = new Map();
|
|
95
|
+
|
|
96
|
+
for (const commit of commits) {
|
|
97
|
+
const commitDate = new Date(commit.committer_date_iso);
|
|
98
|
+
if (Number.isNaN(commitDate.getTime()) || commitDate < cutoff) continue;
|
|
99
|
+
|
|
100
|
+
// Exclude framework state churn — .aioson/* changes every agent session.
|
|
101
|
+
// SF-NC-01/02 Layer B — also reject unsafe paths (control chars, oversize,
|
|
102
|
+
// empty). POSIX allows LF in filenames; git records them as-is. Without
|
|
103
|
+
// this filter, a crafted commit history could feed the noise-file
|
|
104
|
+
// injection vector identified in @pentester SF-NC-01.
|
|
105
|
+
const files = commit.files.filter(
|
|
106
|
+
(f) =>
|
|
107
|
+
f &&
|
|
108
|
+
!f.startsWith('.aioson/') &&
|
|
109
|
+
!f.startsWith('.aioson\\') &&
|
|
110
|
+
!isUnsafePath(f)
|
|
111
|
+
);
|
|
112
|
+
if (files.length < 2 || files.length > MAX_FILES_PER_COMMIT) continue;
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < files.length; i++) {
|
|
115
|
+
for (let j = 0; j < files.length; j++) {
|
|
116
|
+
if (i === j) continue;
|
|
117
|
+
bumpPair(bySource, files[i], files[j], commit.committer_date_iso);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return bySource;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ingestGitCoEditEdges({ db, pairs, now = new Date() }) {
|
|
126
|
+
if (!db || typeof db.prepare !== 'function') {
|
|
127
|
+
throw new Error('ingestGitCoEditEdges requires an open better-sqlite3 db handle');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const nowIso = now.toISOString();
|
|
131
|
+
const stats = { upserted: 0, archived: 0, capped_inserts: 0 };
|
|
132
|
+
|
|
133
|
+
const upsertStmt = db.prepare(`
|
|
134
|
+
INSERT INTO chain_edges (source_path, target_path, edge_type, confidence, start_at, last_seen_at, hit_count)
|
|
135
|
+
VALUES (?, ?, 'git_co_edit', ?, ?, ?, ?)
|
|
136
|
+
ON CONFLICT(source_path, target_path, edge_type) WHERE end_at IS NULL
|
|
137
|
+
DO UPDATE SET
|
|
138
|
+
confidence = excluded.confidence,
|
|
139
|
+
last_seen_at = excluded.last_seen_at,
|
|
140
|
+
hit_count = excluded.hit_count
|
|
141
|
+
`);
|
|
142
|
+
|
|
143
|
+
const countActiveStmt = db.prepare(`
|
|
144
|
+
SELECT count(*) AS c FROM chain_edges
|
|
145
|
+
WHERE source_path = ? AND end_at IS NULL
|
|
146
|
+
`);
|
|
147
|
+
|
|
148
|
+
const findExistingStmt = db.prepare(`
|
|
149
|
+
SELECT id FROM chain_edges
|
|
150
|
+
WHERE source_path = ? AND target_path = ? AND edge_type = 'git_co_edit' AND end_at IS NULL
|
|
151
|
+
`);
|
|
152
|
+
|
|
153
|
+
const findOldestStmt = db.prepare(`
|
|
154
|
+
SELECT id FROM chain_edges
|
|
155
|
+
WHERE source_path = ? AND end_at IS NULL
|
|
156
|
+
ORDER BY last_seen_at ASC LIMIT 1
|
|
157
|
+
`);
|
|
158
|
+
|
|
159
|
+
const archiveStmt = db.prepare(`UPDATE chain_edges SET end_at = ? WHERE id = ?`);
|
|
160
|
+
|
|
161
|
+
// Flatten nested Map<source, Map<target, {count, lastSeen}>> into items.
|
|
162
|
+
const items = [];
|
|
163
|
+
for (const [source, inner] of pairs.entries()) {
|
|
164
|
+
if (!inner || typeof inner.entries !== 'function') continue;
|
|
165
|
+
for (const [target, value] of inner.entries()) {
|
|
166
|
+
items.push({ source, target, count: value.count, lastSeen: value.lastSeen });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tx = db.transaction((arr) => {
|
|
171
|
+
for (const item of arr) {
|
|
172
|
+
const confidence = Math.min(1.0, item.count / CONFIDENCE_SATURATION);
|
|
173
|
+
|
|
174
|
+
// BR-NC-08 hard cap — only enforce when inserting a NEW edge for this source.
|
|
175
|
+
// Existing-edge updates (re-ingest) don't grow the active set.
|
|
176
|
+
const existingRow = findExistingStmt.get(item.source, item.target);
|
|
177
|
+
if (!existingRow) {
|
|
178
|
+
const { c: activeCount } = countActiveStmt.get(item.source);
|
|
179
|
+
if (activeCount >= HARD_CAP_PER_NODE) {
|
|
180
|
+
const oldest = findOldestStmt.get(item.source);
|
|
181
|
+
if (oldest) {
|
|
182
|
+
archiveStmt.run(nowIso, oldest.id);
|
|
183
|
+
stats.archived += 1;
|
|
184
|
+
stats.capped_inserts += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
upsertStmt.run(item.source, item.target, confidence, nowIso, item.lastSeen, item.count);
|
|
190
|
+
stats.upserted += 1;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
tx(items);
|
|
195
|
+
return stats;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getCommitCount(projectDir) {
|
|
199
|
+
try {
|
|
200
|
+
const output = execSync('git rev-list --count HEAD', {
|
|
201
|
+
cwd: projectDir,
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
204
|
+
});
|
|
205
|
+
const n = parseInt(output.trim(), 10);
|
|
206
|
+
return Number.isFinite(n) ? n : 0;
|
|
207
|
+
} catch (_) {
|
|
208
|
+
return -1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fetchGitLog(projectDir, maxCommits) {
|
|
213
|
+
return execSync(
|
|
214
|
+
`git log --pretty=format:%H|%cI --name-only -n ${Number(maxCommits)} HEAD`,
|
|
215
|
+
{
|
|
216
|
+
cwd: projectDir,
|
|
217
|
+
encoding: 'utf8',
|
|
218
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
219
|
+
maxBuffer: GIT_LOG_MAX_BUFFER
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function runGitIngest({
|
|
225
|
+
db,
|
|
226
|
+
projectDir,
|
|
227
|
+
maxCommits = DEFAULT_MAX_COMMITS,
|
|
228
|
+
now = new Date(),
|
|
229
|
+
fetchLog = fetchGitLog
|
|
230
|
+
} = {}) {
|
|
231
|
+
if (!projectDir) {
|
|
232
|
+
return { skipped: true, reason: 'missing_project_dir' };
|
|
233
|
+
}
|
|
234
|
+
if (!fs.existsSync(path.join(projectDir, '.git'))) {
|
|
235
|
+
return { skipped: true, reason: 'no_git_repo' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const commitCount = getCommitCount(projectDir);
|
|
239
|
+
if (commitCount < 0) {
|
|
240
|
+
return { skipped: true, reason: 'git_unavailable' };
|
|
241
|
+
}
|
|
242
|
+
if (commitCount < MIN_COMMITS_FOR_INGEST) {
|
|
243
|
+
return { skipped: true, reason: 'insufficient_history', commit_count: commitCount };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let rawLog;
|
|
247
|
+
try {
|
|
248
|
+
rawLog = fetchLog(projectDir, maxCommits);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return { skipped: true, reason: 'git_log_failed', error: err && err.message ? err.message : String(err) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const commits = parseGitLog(rawLog);
|
|
254
|
+
const pairs = computeCoEditPairs(commits, { now });
|
|
255
|
+
const stats = ingestGitCoEditEdges({ db, pairs, now });
|
|
256
|
+
|
|
257
|
+
let pairsCount = 0;
|
|
258
|
+
for (const inner of pairs.values()) pairsCount += inner.size;
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
skipped: false,
|
|
262
|
+
commit_count: commitCount,
|
|
263
|
+
commits_parsed: commits.length,
|
|
264
|
+
pairs_computed: pairsCount,
|
|
265
|
+
...stats
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
parseGitLog,
|
|
271
|
+
computeCoEditPairs,
|
|
272
|
+
ingestGitCoEditEdges,
|
|
273
|
+
runGitIngest,
|
|
274
|
+
DEFAULT_MAX_COMMITS,
|
|
275
|
+
CONFIDENCE_SATURATION,
|
|
276
|
+
WINDOW_DAYS,
|
|
277
|
+
MAX_FILES_PER_COMMIT,
|
|
278
|
+
HARD_CAP_PER_NODE,
|
|
279
|
+
MIN_COMMITS_FOR_INGEST
|
|
280
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Neural Chain feature — Phase 1 schema migration.
|
|
5
|
+
*
|
|
6
|
+
* Adds chain_edges table + 3 indexes to .aioson/runtime/aios.sqlite per
|
|
7
|
+
* requirements-neural-chain.md § New entities and fields.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent (IF NOT EXISTS guards on every step). Safe to call on every
|
|
10
|
+
* openRuntimeDb invocation. The 4 statements are all O(1) probes when the
|
|
11
|
+
* schema already exists, so no PRAGMA user_version sentinel is used —
|
|
12
|
+
* coordination with the existing learning-loop migration's user_version=3
|
|
13
|
+
* would require a shared versioning table (deferred until multiple features
|
|
14
|
+
* need migrations).
|
|
15
|
+
*
|
|
16
|
+
* Schema invariants enforced here:
|
|
17
|
+
* - edge_type ∈ {'git_co_edit', 'agent_event'} (BR-NC-01 sources)
|
|
18
|
+
* - 0.0 ≤ confidence ≤ 1.0 (BR-NC-01 formula range)
|
|
19
|
+
* - hit_count > 0 (BR-NC-07 ingest semantics)
|
|
20
|
+
* - source_path, target_path, edge_type uniqueness (active rows only,
|
|
21
|
+
* allowing archive flow per BR-NC-08 hard cap enforcement)
|
|
22
|
+
*
|
|
23
|
+
* Invariants enforced by application code, not schema:
|
|
24
|
+
* - validity-window discipline (start_at always set; end_at NULL in M1)
|
|
25
|
+
* - hard cap 10k per source_path node (audit at ingest time)
|
|
26
|
+
* - confidence formula and combination (max not sum)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const STEPS = [
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS chain_edges (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
source_path TEXT NOT NULL,
|
|
33
|
+
target_path TEXT NOT NULL,
|
|
34
|
+
edge_type TEXT NOT NULL CHECK (edge_type IN ('git_co_edit', 'agent_event')),
|
|
35
|
+
confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0),
|
|
36
|
+
start_at TEXT NOT NULL,
|
|
37
|
+
end_at TEXT,
|
|
38
|
+
hit_count INTEGER NOT NULL DEFAULT 1 CHECK (hit_count > 0),
|
|
39
|
+
last_seen_at TEXT NOT NULL,
|
|
40
|
+
metadata TEXT
|
|
41
|
+
)`,
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_chain_edges_source
|
|
43
|
+
ON chain_edges(source_path, end_at)`,
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS idx_chain_edges_target
|
|
45
|
+
ON chain_edges(target_path, end_at)`,
|
|
46
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS uniq_chain_active
|
|
47
|
+
ON chain_edges(source_path, target_path, edge_type)
|
|
48
|
+
WHERE end_at IS NULL`
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function runMigration(db) {
|
|
52
|
+
if (!db || typeof db.exec !== 'function') {
|
|
53
|
+
throw new Error('runMigration requires an open better-sqlite3 database handle');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const stmt of STEPS) {
|
|
57
|
+
db.exec(stmt);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { runMigration };
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Neural Chain — noise file write / lazy lifecycle (BR-NC-06).
|
|
5
|
+
*
|
|
6
|
+
* Produces `.aioson/context/noises/{feature-slug}-{YYYYMMDD-HHMM}.md` with a
|
|
7
|
+
* YAML frontmatter + body of markdown checkboxes. One file per session in
|
|
8
|
+
* `guarded` autonomy mode (other modes deferred to Slice 6 threshold rules).
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* writeNoiseFile() — create file with `- [ ]` items.
|
|
12
|
+
* readNoiseFileAndRecompute() — re-parse, count `- [x]`, surface stats.
|
|
13
|
+
* maybeDeleteNoiseFile() — unlink when no pending items remain.
|
|
14
|
+
*
|
|
15
|
+
* No file watcher — recompute is lazy, triggered by callers (chain:audit,
|
|
16
|
+
* @neo activation, agent_done hook). Idempotent unlink covers EC-NC-10.
|
|
17
|
+
* Item granularity is file-level only (M1 BR-NC-09); `:symbol` deferred to V2.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { isUnsafePath } = require('./neural-chain-sanitize');
|
|
23
|
+
|
|
24
|
+
const NOISE_DIR_REL = path.join('.aioson', 'context', 'noises');
|
|
25
|
+
|
|
26
|
+
function formatTimestamp(date) {
|
|
27
|
+
const yyyy = date.getUTCFullYear();
|
|
28
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
29
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
30
|
+
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
31
|
+
const min = String(date.getUTCMinutes()).padStart(2, '0');
|
|
32
|
+
return `${yyyy}${mm}${dd}-${hh}${min}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sanitizeSlug(slug) {
|
|
36
|
+
if (slug === null || slug === undefined) return 'unspecified';
|
|
37
|
+
const s = String(slug)
|
|
38
|
+
.trim()
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
41
|
+
.replace(/^-+|-+$/g, '');
|
|
42
|
+
return s || 'unspecified';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildNoiseFilePath({ targetDir, featureSlug, now }) {
|
|
46
|
+
if (!targetDir || typeof targetDir !== 'string') {
|
|
47
|
+
throw new Error('buildNoiseFilePath requires targetDir');
|
|
48
|
+
}
|
|
49
|
+
const slug = sanitizeSlug(featureSlug);
|
|
50
|
+
const ts = formatTimestamp(now instanceof Date ? now : new Date());
|
|
51
|
+
return path.join(targetDir, NOISE_DIR_REL, `${slug}-${ts}.md`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serializeItem(item) {
|
|
55
|
+
const conf =
|
|
56
|
+
typeof item.confidence === 'number' && Number.isFinite(item.confidence)
|
|
57
|
+
? item.confidence.toFixed(2)
|
|
58
|
+
: String(item.confidence ?? '0.00');
|
|
59
|
+
const sourceTag = item.source_file ? ` (source: ${item.source_file})` : '';
|
|
60
|
+
// BR-NC-03: classifier (Slice 6) tags items with `[AUTO-FIXABLE]` or
|
|
61
|
+
// `[AUTO-FIXABLE-BEST-EFFORT]` so the next agent session can execute them
|
|
62
|
+
// mechanically per BR-NC-04 handoff TODO contract.
|
|
63
|
+
const markerTag = item.marker ? ` [${item.marker}]` : '';
|
|
64
|
+
return `- [ ]${markerTag} ${item.target_path} — ${item.edge_type} ${conf}${sourceTag}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildContent({ slug, editAtIso, autonomyMode, sourceFiles, items }) {
|
|
68
|
+
const fm = [
|
|
69
|
+
'---',
|
|
70
|
+
`slug: ${slug}`,
|
|
71
|
+
`edit_at: ${editAtIso}`,
|
|
72
|
+
`autonomy_mode: ${autonomyMode}`,
|
|
73
|
+
`source_files: ${JSON.stringify(sourceFiles)}`,
|
|
74
|
+
`total_items: ${items.length}`,
|
|
75
|
+
`resolved_items: 0`,
|
|
76
|
+
'---'
|
|
77
|
+
].join('\n');
|
|
78
|
+
const heading = '\n\n# Neural Chain — Impact Audit\n';
|
|
79
|
+
const intro =
|
|
80
|
+
'\nThe edits in this session may have impact on the files listed below. Tick each item once verified or addressed; this file is deleted automatically once all items are resolved.\n';
|
|
81
|
+
const body =
|
|
82
|
+
items.length === 0
|
|
83
|
+
? '\n*No impacts detected.*\n'
|
|
84
|
+
: '\n' + items.map(serializeItem).join('\n') + '\n';
|
|
85
|
+
return fm + heading + intro + body;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function flattenAudits(audits) {
|
|
89
|
+
const items = [];
|
|
90
|
+
const sourceFilesSet = new Set();
|
|
91
|
+
let rejectedCount = 0;
|
|
92
|
+
for (const audit of audits || []) {
|
|
93
|
+
if (!audit) continue;
|
|
94
|
+
// SF-NC-01 Layer A — skip the audit's source_file from the frontmatter
|
|
95
|
+
// source_files list when it's unsafe (control chars / too long / empty).
|
|
96
|
+
// Defense in depth: even if Layer B (ingest) is bypassed, the noise
|
|
97
|
+
// file body must never carry attacker-controlled newlines.
|
|
98
|
+
if (audit.source_file && !isUnsafePath(audit.source_file)) {
|
|
99
|
+
sourceFilesSet.add(audit.source_file);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(audit.impacts)) continue;
|
|
102
|
+
for (const impact of audit.impacts) {
|
|
103
|
+
if (!impact || !impact.target_path) continue;
|
|
104
|
+
const targetPath = String(impact.target_path);
|
|
105
|
+
// SF-NC-01 Layer A — drop the entire item when target_path is unsafe.
|
|
106
|
+
// The whole row is poisoned; rendering even a sanitized stub leaves the
|
|
107
|
+
// injection vector partially open. Telemetry-counting the rejection
|
|
108
|
+
// lives at Layer B (ingest); here we silently filter to keep the
|
|
109
|
+
// noise file shape coherent.
|
|
110
|
+
if (isUnsafePath(targetPath)) {
|
|
111
|
+
rejectedCount += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const sourceFile = audit.source_file && !isUnsafePath(audit.source_file)
|
|
115
|
+
? audit.source_file
|
|
116
|
+
: null;
|
|
117
|
+
items.push({
|
|
118
|
+
target_path: targetPath,
|
|
119
|
+
source_file: sourceFile,
|
|
120
|
+
edge_type: impact.edge_type ? String(impact.edge_type) : 'unknown',
|
|
121
|
+
confidence:
|
|
122
|
+
typeof impact.confidence === 'number' && Number.isFinite(impact.confidence)
|
|
123
|
+
? impact.confidence
|
|
124
|
+
: 0,
|
|
125
|
+
// Slice 6: classifier may attach `marker` to impacts before passing
|
|
126
|
+
// them in (BR-NC-02/03). Pass through verbatim — flatten does not
|
|
127
|
+
// re-classify.
|
|
128
|
+
marker: impact.marker || null
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { items, sourceFiles: Array.from(sourceFilesSet), rejected: rejectedCount };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeNoiseFile({
|
|
136
|
+
targetDir,
|
|
137
|
+
featureSlug,
|
|
138
|
+
audits,
|
|
139
|
+
autonomyMode = 'guarded',
|
|
140
|
+
now = new Date()
|
|
141
|
+
}) {
|
|
142
|
+
if (!targetDir || typeof targetDir !== 'string') {
|
|
143
|
+
throw new Error('writeNoiseFile requires targetDir');
|
|
144
|
+
}
|
|
145
|
+
const stamp = now instanceof Date ? now : new Date();
|
|
146
|
+
const { items, sourceFiles } = flattenAudits(audits);
|
|
147
|
+
const slug = sanitizeSlug(featureSlug);
|
|
148
|
+
const filePath = buildNoiseFilePath({ targetDir, featureSlug, now: stamp });
|
|
149
|
+
|
|
150
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
151
|
+
|
|
152
|
+
const content = buildContent({
|
|
153
|
+
slug,
|
|
154
|
+
editAtIso: stamp.toISOString(),
|
|
155
|
+
autonomyMode,
|
|
156
|
+
sourceFiles,
|
|
157
|
+
items
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
path: filePath,
|
|
164
|
+
slug,
|
|
165
|
+
items,
|
|
166
|
+
total_items: items.length,
|
|
167
|
+
source_files: sourceFiles
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseFrontmatter(text) {
|
|
172
|
+
if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) {
|
|
173
|
+
return { ok: false, reason: 'missing_frontmatter', data: null, bodyOffset: 0 };
|
|
174
|
+
}
|
|
175
|
+
const lines = text.split(/\r?\n/);
|
|
176
|
+
let closing = -1;
|
|
177
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
178
|
+
if (lines[i].trim() === '---') {
|
|
179
|
+
closing = i;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (closing === -1) {
|
|
184
|
+
return { ok: false, reason: 'unclosed_frontmatter', data: null, bodyOffset: 0 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const data = {};
|
|
188
|
+
for (let i = 1; i < closing; i += 1) {
|
|
189
|
+
const line = lines[i];
|
|
190
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
191
|
+
const m = line.match(/^([a-zA-Z0-9_]+)\s*:\s*(.*)$/);
|
|
192
|
+
if (!m) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: 'invalid_line',
|
|
196
|
+
data: null,
|
|
197
|
+
bodyOffset: 0,
|
|
198
|
+
badLine: line
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const key = m[1];
|
|
202
|
+
const raw = m[2].trim();
|
|
203
|
+
let val;
|
|
204
|
+
if (raw === '') {
|
|
205
|
+
val = '';
|
|
206
|
+
} else if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
207
|
+
try {
|
|
208
|
+
val = JSON.parse(raw);
|
|
209
|
+
} catch {
|
|
210
|
+
val = [];
|
|
211
|
+
}
|
|
212
|
+
} else if (
|
|
213
|
+
(raw.startsWith('"') && raw.endsWith('"')) ||
|
|
214
|
+
(raw.startsWith("'") && raw.endsWith("'"))
|
|
215
|
+
) {
|
|
216
|
+
val = raw.slice(1, -1);
|
|
217
|
+
} else if (raw === 'true') {
|
|
218
|
+
val = true;
|
|
219
|
+
} else if (raw === 'false') {
|
|
220
|
+
val = false;
|
|
221
|
+
} else if (/^-?\d+$/.test(raw)) {
|
|
222
|
+
val = Number(raw);
|
|
223
|
+
} else if (/^-?\d+\.\d+$/.test(raw)) {
|
|
224
|
+
val = Number(raw);
|
|
225
|
+
} else {
|
|
226
|
+
val = raw;
|
|
227
|
+
}
|
|
228
|
+
data[key] = val;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const headerText = lines.slice(0, closing + 1).join('\n') + '\n';
|
|
232
|
+
return { ok: true, data, bodyOffset: headerText.length };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseItems(body) {
|
|
236
|
+
const items = [];
|
|
237
|
+
const lines = body.split(/\r?\n/);
|
|
238
|
+
// Optional marker bracket between checkbox and target_path, e.g.:
|
|
239
|
+
// - [ ] [AUTO-FIXABLE] src/foo.js — git_co_edit 0.80
|
|
240
|
+
// - [x] src/bar.js — agent_event 0.50
|
|
241
|
+
const re = /^- \[([ xX])\](?: \[([A-Z][A-Z0-9_-]*)\])? (.+?)(?: — (.+))?$/;
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
const m = re.exec(line);
|
|
244
|
+
if (!m) continue;
|
|
245
|
+
items.push({
|
|
246
|
+
checked: m[1] === 'x' || m[1] === 'X',
|
|
247
|
+
marker: m[2] || null,
|
|
248
|
+
target_path: m[3].trim(),
|
|
249
|
+
motivo: m[4] ? m[4].trim() : ''
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return items;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readNoiseFileAndRecompute({ path: filePath }) {
|
|
256
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
257
|
+
throw new Error('readNoiseFileAndRecompute requires path');
|
|
258
|
+
}
|
|
259
|
+
let text;
|
|
260
|
+
try {
|
|
261
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err && err.code === 'ENOENT') {
|
|
264
|
+
return {
|
|
265
|
+
exists: false,
|
|
266
|
+
frontmatter: null,
|
|
267
|
+
frontmatterOk: false,
|
|
268
|
+
frontmatterReason: 'not_found',
|
|
269
|
+
items: [],
|
|
270
|
+
allResolved: false,
|
|
271
|
+
pendingCount: 0,
|
|
272
|
+
resolvedCount: 0
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const fm = parseFrontmatter(text);
|
|
279
|
+
// EC-NC-09: corrupted frontmatter still allows body parsing — readable items preserved.
|
|
280
|
+
const body = fm.ok ? text.slice(fm.bodyOffset) : text;
|
|
281
|
+
const items = parseItems(body);
|
|
282
|
+
const resolved = items.filter((i) => i.checked).length;
|
|
283
|
+
const pending = items.length - resolved;
|
|
284
|
+
|
|
285
|
+
const frontmatter = fm.ok ? { ...fm.data, resolved_items: resolved } : null;
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
exists: true,
|
|
289
|
+
frontmatter,
|
|
290
|
+
frontmatterOk: fm.ok,
|
|
291
|
+
frontmatterReason: fm.ok ? null : fm.reason,
|
|
292
|
+
items,
|
|
293
|
+
allResolved: items.length > 0 && pending === 0,
|
|
294
|
+
pendingCount: pending,
|
|
295
|
+
resolvedCount: resolved
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function maybeDeleteNoiseFile({ path: filePath }) {
|
|
300
|
+
const r = readNoiseFileAndRecompute({ path: filePath });
|
|
301
|
+
if (!r.exists) {
|
|
302
|
+
return { deleted: false, reason: 'not_found' };
|
|
303
|
+
}
|
|
304
|
+
if (r.pendingCount === 0) {
|
|
305
|
+
try {
|
|
306
|
+
fs.unlinkSync(filePath);
|
|
307
|
+
return {
|
|
308
|
+
deleted: true,
|
|
309
|
+
reason: r.items.length === 0 ? 'no_items' : 'all_resolved'
|
|
310
|
+
};
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// EC-NC-10: race between recompute and unlink — idempotent return.
|
|
313
|
+
if (err && err.code === 'ENOENT') {
|
|
314
|
+
return { deleted: false, reason: 'already_deleted' };
|
|
315
|
+
}
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { deleted: false, reason: 'pending_items', pendingCount: r.pendingCount };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = {
|
|
323
|
+
writeNoiseFile,
|
|
324
|
+
readNoiseFileAndRecompute,
|
|
325
|
+
maybeDeleteNoiseFile,
|
|
326
|
+
buildNoiseFilePath,
|
|
327
|
+
sanitizeSlug,
|
|
328
|
+
formatTimestamp,
|
|
329
|
+
parseFrontmatter,
|
|
330
|
+
parseItems,
|
|
331
|
+
NOISE_DIR_REL
|
|
332
|
+
};
|
|
Binary file
|