@ijfw/memory-server 1.5.1 → 1.5.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/package.json +6 -5
- package/src/brain/budget-guard.js +86 -0
- package/src/brain/citation-resolver.js +41 -0
- package/src/brain/context-injection.js +69 -0
- package/src/brain/discovery.js +83 -0
- package/src/brain/dream-pipeline.js +324 -0
- package/src/brain/dump-ingest.js +88 -0
- package/src/brain/entity-collapse.js +28 -0
- package/src/brain/export.js +112 -0
- package/src/brain/extractors/index.js +24 -0
- package/src/brain/extractors/markdown.js +27 -0
- package/src/brain/extractors/pdf.js +31 -0
- package/src/brain/extractors/transcript.js +38 -0
- package/src/brain/first-run-scan.js +61 -0
- package/src/brain/index.js +1 -0
- package/src/brain/layout-sentinel.js +29 -0
- package/src/brain/migrate-facts-internal-once.js +87 -0
- package/src/brain/path-guard.js +103 -0
- package/src/brain/paths.js +26 -0
- package/src/brain/promotion-suggester.js +41 -0
- package/src/brain/stub-detector.js +33 -0
- package/src/brain/tiered-llm.js +83 -0
- package/src/brain/wiki-compiler.js +144 -0
- package/src/brain/wiki-sentinels.js +45 -0
- package/src/brain/wiki-templates.js +94 -0
- package/src/cross-orchestrator-cli.js +132 -5
- package/src/cross-orchestrator.js +2 -2
- package/src/dashboard-server.js +1 -1
- package/src/dream/runner.mjs +21 -0
- package/src/extension-registry.js +2 -2
- package/src/handlers/brain-handler.js +319 -0
- package/src/memory/auto-linker.js +5 -1
- package/src/memory/benchmark.js +4 -3
- package/src/memory/layout-migrations/001-visible-layer.js +131 -0
- package/src/memory/layout-migrations/index.js +50 -0
- package/src/memory/migration-runner.js +31 -2
- package/src/memory/obsidian-parser.js +3 -1
- package/src/memory/reader.js +2 -1
- package/src/memory/search.js +144 -16
- package/src/memory/temporal.js +40 -1
- package/src/orchestrator/agents-md-blackboard.js +114 -1
- package/src/orchestrator/discipline-selector.js +276 -0
- package/src/orchestrator/merge-block-aware.js +15 -5
- package/src/orchestrator/state-sdk.js +42 -4
- package/src/orchestrator/wave-state.js +38 -0
- package/src/recovery/code-fixer.js +1 -1
- package/src/server.js +290 -75
- package/src/update-apply.js +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discipline-selector.js — pure functions to pick and load a project-discipline
|
|
3
|
+
* template body for the DISCIPLINE AGENTS.md marker block.
|
|
4
|
+
*
|
|
5
|
+
* Two exports:
|
|
6
|
+
*
|
|
7
|
+
* selectDisciplineTemplate(projectType)
|
|
8
|
+
* Synchronous. Returns the template body string (utf-8) for the given
|
|
9
|
+
* project type, or an empty string for `unknown` / `mixed`. Throws a
|
|
10
|
+
* TypeError when projectType is null or undefined. Resolves template
|
|
11
|
+
* paths relative to this module, using the same fileURLToPath + dirname
|
|
12
|
+
* pattern as merge-block-aware.js.
|
|
13
|
+
*
|
|
14
|
+
* detectProjectTypeFromRepo(repoRoot)
|
|
15
|
+
* Synchronous, deterministic. Inspects the repo root directory to infer
|
|
16
|
+
* project type. Priority order:
|
|
17
|
+
* (a) .ijfw/memory/brief.md frontmatter `type` key — highest fidelity,
|
|
18
|
+
* set explicitly by the user or brainstorm-LOCK hook.
|
|
19
|
+
* (b) Well-known file/dir signals — cheap existsSync probes, no glob.
|
|
20
|
+
* Returns one of: 'code' | 'narrative' | 'business' | 'design' |
|
|
21
|
+
* 'research' | 'unknown'
|
|
22
|
+
*
|
|
23
|
+
* No deps beyond node:fs and node:path (Node >=18, ESM).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { validateSafeRepoPath } from '../brain/path-guard.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Module-relative template root
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
36
|
+
const __dirname = dirname(__filename);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Root of the ijfw-agents-md templates directory, resolved relative to this
|
|
40
|
+
* module. Same anchor pattern used by merge-block-aware.js for DEFAULT_TEMPLATE.
|
|
41
|
+
*/
|
|
42
|
+
const TEMPLATES_DIR = join(
|
|
43
|
+
__dirname,
|
|
44
|
+
'..', '..', '..', 'claude', 'skills', 'ijfw-agents-md', 'templates',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Valid project types
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** All recognized project type codes (non-empty body returned for these). */
|
|
52
|
+
const TYPED_CODES = new Set(['code', 'narrative', 'business', 'design', 'research']);
|
|
53
|
+
|
|
54
|
+
/** Project types that legitimately produce an empty template body. */
|
|
55
|
+
const EMPTY_BODY_CODES = new Set(['unknown', 'mixed']);
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// selectDisciplineTemplate
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return the template body string for `projectType`.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} projectType one of: code | narrative | business | design |
|
|
65
|
+
* research | unknown | mixed
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {string} [opts.templatesDir] Override the templates directory. Used
|
|
68
|
+
* by tests to exercise the missing-file
|
|
69
|
+
* Error path against a directory that
|
|
70
|
+
* contains no `discipline-*.md` files
|
|
71
|
+
* (W3 D1 / L2-04 follow-up). Default:
|
|
72
|
+
* shipped TEMPLATES_DIR.
|
|
73
|
+
* @returns {string} template body (utf-8). For unknown/mixed (and any
|
|
74
|
+
* unrecognized string), returns an HTML-comment hint
|
|
75
|
+
* body documenting how the user activates a domain.
|
|
76
|
+
* @throws {TypeError} when projectType is null, undefined, or non-string.
|
|
77
|
+
* @throws {Error} when the template file is absent for a typed project.
|
|
78
|
+
*/
|
|
79
|
+
export function selectDisciplineTemplate(projectType, opts = {}) {
|
|
80
|
+
if (projectType === null || projectType === undefined) {
|
|
81
|
+
throw new TypeError(
|
|
82
|
+
'selectDisciplineTemplate: projectType must be a string (got null/undefined)',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (typeof projectType !== 'string') {
|
|
86
|
+
throw new TypeError(
|
|
87
|
+
`selectDisciplineTemplate: projectType must be a string (got ${typeof projectType})`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const type = String(projectType).trim().toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Helpful hint body for "no domain rules to apply" cases. Replaces what
|
|
94
|
+
// was previously an empty marker block (Wave 5B finding L3-03 + L3-04):
|
|
95
|
+
// a labelled-but-empty DISCIPLINE region left users unsure what to do.
|
|
96
|
+
// The hint is an HTML comment so it stays invisible in rendered markdown
|
|
97
|
+
// but visible to anyone reading the raw AGENTS.md, and documents the
|
|
98
|
+
// correction path inline.
|
|
99
|
+
const emptyBodyHint = (label) =>
|
|
100
|
+
`<!-- IJFW: project type is "${label}" -- domain-specific discipline rules not yet activated.\n`
|
|
101
|
+
+ `To activate, set frontmatter \`type: code\` (or narrative | business | design | research)\n`
|
|
102
|
+
+ `in .ijfw/memory/brief.md and re-run the brainstorm-LOCK or plan-LOCK in the IJFW workflow. -->`;
|
|
103
|
+
|
|
104
|
+
if (EMPTY_BODY_CODES.has(type)) {
|
|
105
|
+
return emptyBodyHint(type);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!TYPED_CODES.has(type)) {
|
|
109
|
+
// Unrecognized type string -- treat as 'unknown' for the hint label so
|
|
110
|
+
// the message stays sensible, rather than echoing whatever garbage came
|
|
111
|
+
// in. The graceful-degradation policy is preserved: no throw.
|
|
112
|
+
return emptyBodyHint('unknown');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const templatesDir = (opts && typeof opts.templatesDir === 'string')
|
|
116
|
+
? opts.templatesDir
|
|
117
|
+
: TEMPLATES_DIR;
|
|
118
|
+
const templatePath = join(templatesDir, `discipline-${type}.md`);
|
|
119
|
+
if (!existsSync(templatePath)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`selectDisciplineTemplate: template file missing for type "${type}" at ${templatePath}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return readFileSync(templatePath, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// detectProjectTypeFromRepo -- frontmatter + file-signal fallback
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inline frontmatter parser -- minimal, zero deps.
|
|
134
|
+
* Reads only the `type` key from the first YAML fence (--- ... ---).
|
|
135
|
+
*
|
|
136
|
+
* Mirrors parseFrontmatter in mcp-server/src/memory/reader.js.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} raw file contents
|
|
139
|
+
* @returns {string|null} value of `type` key, or null if absent
|
|
140
|
+
*/
|
|
141
|
+
function extractTypeFromFrontmatter(raw) {
|
|
142
|
+
const stripped = String(raw).replace(/^/, '').replace(/^\s+/, '');
|
|
143
|
+
const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
144
|
+
if (!m) return null;
|
|
145
|
+
for (const line of m[1].split('\n')) {
|
|
146
|
+
const kv = line.match(/^type:\s*(.+)/i);
|
|
147
|
+
if (kv) return kv[1].trim().toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Return true if any direct child entry name of `dir` satisfies `predicate`.
|
|
154
|
+
* Best-effort: returns false on any readdir error.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} dir
|
|
157
|
+
* @param {(name: string) => boolean} predicate
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
function hasDirEntryMatching(dir, predicate) {
|
|
161
|
+
try {
|
|
162
|
+
const entries = readdirSync(dir);
|
|
163
|
+
return entries.some(predicate);
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Return true if `name` is a direct child of `repoRoot` that is a directory
|
|
171
|
+
* AND contains at least one `.md` file. Avoids false-positive on plain files
|
|
172
|
+
* named `chapters` or `manuscript`.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} repoRoot
|
|
175
|
+
* @param {string} name directory name to check
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function isDirWithMd(repoRoot, name) {
|
|
179
|
+
const p = join(repoRoot, name);
|
|
180
|
+
try {
|
|
181
|
+
if (!statSync(p).isDirectory()) return false;
|
|
182
|
+
return readdirSync(p).some((n) => n.endsWith('.md'));
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Detect the project type from a repository root.
|
|
190
|
+
*
|
|
191
|
+
* Priority:
|
|
192
|
+
* (a) .ijfw/memory/brief.md frontmatter `type` key -- set by brainstorm-LOCK.
|
|
193
|
+
* (b) Well-known file/dir signals via existsSync (no glob, cheap).
|
|
194
|
+
*
|
|
195
|
+
* Signal table (first match wins within each tier):
|
|
196
|
+
* code: package.json | tsconfig.json | Cargo.toml | go.mod |
|
|
197
|
+
* *.csproj | pyproject.toml | setup.py | Gemfile
|
|
198
|
+
* narrative: chapters/ dir | manuscript/ dir
|
|
199
|
+
* business: pitch-deck* | business-plan* | *.numbers
|
|
200
|
+
* design: figma-* | *.sketch | design-system/ dir
|
|
201
|
+
* research: research/ dir | notebooks/ dir | *.ipynb
|
|
202
|
+
* unknown: fallback
|
|
203
|
+
*
|
|
204
|
+
* @param {string} repoRoot absolute path to repository root
|
|
205
|
+
* @returns {'code'|'narrative'|'business'|'design'|'research'|'unknown'}
|
|
206
|
+
*/
|
|
207
|
+
export function detectProjectTypeFromRepo(repoRoot) {
|
|
208
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
209
|
+
return 'unknown';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// (a) Brief.md frontmatter -- highest fidelity
|
|
213
|
+
const briefPath = join(repoRoot, '.ijfw', 'memory', 'brief.md');
|
|
214
|
+
if (existsSync(briefPath)) {
|
|
215
|
+
try {
|
|
216
|
+
const guard = validateSafeRepoPath(repoRoot, briefPath);
|
|
217
|
+
if (guard.ok) {
|
|
218
|
+
const briefStat = statSync(briefPath);
|
|
219
|
+
if (briefStat.size <= 256 * 1024) {
|
|
220
|
+
const raw = readFileSync(briefPath, 'utf8');
|
|
221
|
+
const type = extractTypeFromFrontmatter(raw);
|
|
222
|
+
if (type && (TYPED_CODES.has(type) || EMPTY_BODY_CODES.has(type))) {
|
|
223
|
+
return /** @type {any} */ (type);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// brief.md too large -- fall through to file signals
|
|
227
|
+
}
|
|
228
|
+
// symlink outside repo (guard.ok === false) -- skip frontmatter
|
|
229
|
+
} catch {
|
|
230
|
+
// Brief read failure is non-fatal -- fall through to file signals.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// (b) File-signal fallback -- read root entries once, then use cached list.
|
|
235
|
+
// Priority: earlier tier wins. e.g., pyproject.toml beats notebooks/ when both present (code projects may contain notebooks).
|
|
236
|
+
let _entries = null;
|
|
237
|
+
try { _entries = readdirSync(repoRoot); } catch { _entries = []; }
|
|
238
|
+
function has(predicate) { return _entries.some(predicate); }
|
|
239
|
+
|
|
240
|
+
// --- code signals ---
|
|
241
|
+
const CODE_FILES = [
|
|
242
|
+
'package.json', 'tsconfig.json', 'Cargo.toml', 'go.mod',
|
|
243
|
+
'pyproject.toml', 'setup.py', 'Gemfile',
|
|
244
|
+
];
|
|
245
|
+
for (const f of CODE_FILES) {
|
|
246
|
+
if (_entries.includes(f)) return 'code';
|
|
247
|
+
}
|
|
248
|
+
// *.csproj -- project-named file; scan cached root entries.
|
|
249
|
+
if (has((n) => n.endsWith('.csproj'))) return 'code';
|
|
250
|
+
|
|
251
|
+
// --- narrative signals --- (must be a directory containing at least one .md)
|
|
252
|
+
if (isDirWithMd(repoRoot, 'chapters') || isDirWithMd(repoRoot, 'manuscript')) return 'narrative';
|
|
253
|
+
|
|
254
|
+
// --- business signals ---
|
|
255
|
+
if (
|
|
256
|
+
has((n) => n.startsWith('pitch-deck')) ||
|
|
257
|
+
has((n) => n.startsWith('business-plan')) ||
|
|
258
|
+
has((n) => n.endsWith('.numbers'))
|
|
259
|
+
) return 'business';
|
|
260
|
+
|
|
261
|
+
// --- design signals ---
|
|
262
|
+
if (
|
|
263
|
+
has((n) => n.startsWith('figma-')) ||
|
|
264
|
+
has((n) => n.endsWith('.sketch')) ||
|
|
265
|
+
_entries.includes('design-system')
|
|
266
|
+
) return 'design';
|
|
267
|
+
|
|
268
|
+
// --- research signals ---
|
|
269
|
+
if (
|
|
270
|
+
_entries.includes('research') ||
|
|
271
|
+
_entries.includes('notebooks') ||
|
|
272
|
+
has((n) => n.endsWith('.ipynb'))
|
|
273
|
+
) return 'research';
|
|
274
|
+
|
|
275
|
+
return 'unknown';
|
|
276
|
+
}
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*
|
|
35
35
|
* RESERVED BLOCK NAMES
|
|
36
36
|
* Matches the shell script verbatim:
|
|
37
|
-
* MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER
|
|
37
|
+
* MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER | DISCIPLINE
|
|
38
38
|
*
|
|
39
39
|
* ESM, Node ≥18, zero new prod deps.
|
|
40
40
|
*/
|
|
@@ -60,7 +60,7 @@ import { writeAtomic } from '../lib/atomic-io.js';
|
|
|
60
60
|
* name yields `ERR_BAD_BLOCK` (the same exit-2 the shell script returns).
|
|
61
61
|
*/
|
|
62
62
|
export const RESERVED_BLOCKS = Object.freeze([
|
|
63
|
-
'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER',
|
|
63
|
+
'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER', 'DISCIPLINE',
|
|
64
64
|
]);
|
|
65
65
|
|
|
66
66
|
const RESERVED_BLOCK_SET = new Set(RESERVED_BLOCKS);
|
|
@@ -149,7 +149,7 @@ export function mergeBlocks(src, pairs) {
|
|
|
149
149
|
if (typeof block !== 'string' || !RESERVED_BLOCK_SET.has(block)) {
|
|
150
150
|
throw new MergeBlockAwareError(
|
|
151
151
|
'ERR_BAD_BLOCK',
|
|
152
|
-
`mergeBlocks: block
|
|
152
|
+
`mergeBlocks: unknown block ${JSON.stringify(block)} -- reserved set: ${RESERVED_BLOCKS.join(' ')}`,
|
|
153
153
|
);
|
|
154
154
|
}
|
|
155
155
|
const content = (pair.content === undefined || pair.content === null)
|
|
@@ -310,6 +310,18 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
|
|
|
310
310
|
seeded = true;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
const src = readFileSync(abs, 'utf8');
|
|
314
|
+
const next = mergeBlocks(src, pairs);
|
|
315
|
+
|
|
316
|
+
// No-op short-circuit: if content is unchanged and this is not a seed,
|
|
317
|
+
// skip backup rotation and the atomic write entirely. Idempotent calls
|
|
318
|
+
// will no longer accumulate identical-content backups.
|
|
319
|
+
if (!seeded && next === src) {
|
|
320
|
+
return {
|
|
321
|
+
ok: true, path: abs, bytes: Buffer.byteLength(next), seeded: false, noop: true,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
313
325
|
// Backup + retention (best-effort, defaults on). `opts.backups === false`
|
|
314
326
|
// suppresses (used by some tests to keep tmp clean).
|
|
315
327
|
let backup;
|
|
@@ -318,8 +330,6 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
|
|
|
318
330
|
if (rot.taken && rot.path) backup = rot.path;
|
|
319
331
|
}
|
|
320
332
|
|
|
321
|
-
const src = readFileSync(abs, 'utf8');
|
|
322
|
-
const next = mergeBlocks(src, pairs);
|
|
323
333
|
const res = writeAtomic(abs, next, { mode: 0o644, ensureDir: true });
|
|
324
334
|
return {
|
|
325
335
|
ok: true, path: res.path, bytes: res.bytes, backup, seeded,
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
|
-
readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync,
|
|
40
|
+
readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync, realpathSync,
|
|
41
41
|
} from 'node:fs';
|
|
42
|
-
import { join, isAbsolute, dirname, basename } from 'node:path';
|
|
42
|
+
import { join, isAbsolute, isAbsolute as pathIsAbsolute, relative as pathRelative, dirname, basename } from 'node:path';
|
|
43
43
|
import { homedir } from 'node:os';
|
|
44
44
|
import { randomUUID, createHash } from 'node:crypto';
|
|
45
45
|
import { gunzipSync } from 'node:zlib';
|
|
@@ -1159,8 +1159,46 @@ const handlers = {
|
|
|
1159
1159
|
const envLines = Object.keys(inheritedEnv).sort()
|
|
1160
1160
|
.map((k) => ` ${k}=${inheritedEnv[k]}`);
|
|
1161
1161
|
const eventLogPath = resolveEventLogPath(root, waveId, subagentId);
|
|
1162
|
-
|
|
1163
|
-
|
|
1162
|
+
// F5.6: the prior `startsWith(root + '/')` form (a) wired only on POSIX
|
|
1163
|
+
// because the separator is `/`, breaking on Windows where the separator
|
|
1164
|
+
// is `\`, and (b) silently leaked absolute paths into the dispatch brief
|
|
1165
|
+
// on Windows because the startsWith check always returned false. Use
|
|
1166
|
+
// path.relative + isAbsolute so the relative form is computed correctly
|
|
1167
|
+
// cross-platform, and fall back to the absolute path only when the
|
|
1168
|
+
// event log is genuinely outside the repo (rel '..' or different drive).
|
|
1169
|
+
// F-LENS2-13: pre-canonicalize via realpathSync so a Windows 8.3 short-
|
|
1170
|
+
// name (PROGRA~1) or a macOS /tmp -> /private/tmp symlink doesn't make
|
|
1171
|
+
// the path.relative containment check spuriously claim "outside repo".
|
|
1172
|
+
//
|
|
1173
|
+
// Canonicalization MUST be atomic across both sides: realpathSync(root)
|
|
1174
|
+
// can succeed (root exists) while realpathSync(eventLogPath) throws
|
|
1175
|
+
// ENOENT (event log file is about to be created). Independent try/catches
|
|
1176
|
+
// produce ASYMMETRIC results — on macOS /var/folders is a symlink to
|
|
1177
|
+
// /private/var/folders, so canonicalRoot becomes the long form while
|
|
1178
|
+
// canonicalEventLog stays short, path.relative returns a `..`-leading
|
|
1179
|
+
// string, and the containment check spuriously rejects — leaking the
|
|
1180
|
+
// absolute path into the dispatch brief. One try/catch keeps both sides
|
|
1181
|
+
// either canonical or both un-canonical.
|
|
1182
|
+
let canonicalRoot, canonicalEventLog;
|
|
1183
|
+
try {
|
|
1184
|
+
canonicalRoot = realpathSync(root);
|
|
1185
|
+
canonicalEventLog = realpathSync(eventLogPath);
|
|
1186
|
+
} catch {
|
|
1187
|
+
canonicalRoot = root;
|
|
1188
|
+
canonicalEventLog = eventLogPath;
|
|
1189
|
+
}
|
|
1190
|
+
const _rel = pathRelative(canonicalRoot, canonicalEventLog);
|
|
1191
|
+
// F-LENS2-14: when the event log is genuinely outside the repo, the
|
|
1192
|
+
// earlier behaviour leaked the FULL absolute path (often containing
|
|
1193
|
+
// $HOME / $USERPROFILE) into the dispatch brief — which the subagent
|
|
1194
|
+
// sees and may forward to an external LLM provider. Redact to a
|
|
1195
|
+
// pseudo-path so the subagent knows the log exists by basename without
|
|
1196
|
+
// leaking the absolute prefix. The orchestrator still has the real
|
|
1197
|
+
// eventLogPath in its return value for its own I/O.
|
|
1198
|
+
const _basename = (eventLogPath || '').split(/[\\/]/).pop() || 'events.jsonl';
|
|
1199
|
+
const eventLogRel = (_rel === '' || _rel.startsWith('..') || pathIsAbsolute(_rel))
|
|
1200
|
+
? `<external>/${_basename}`
|
|
1201
|
+
: _rel;
|
|
1164
1202
|
const dispatchBrief = [
|
|
1165
1203
|
`# Subagent dispatch — ${subagentId} (wave ${waveId})`,
|
|
1166
1204
|
role ? `Role: ${role}` : null,
|
|
@@ -59,6 +59,27 @@ function loadPopulateBlackboardBlock() {
|
|
|
59
59
|
return _populateBlackboardBlockPromise;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Wave 5B wiring (post-cross-audit W1 fix): same lazy-Promise-singleton
|
|
63
|
+
// pattern as populateBlackboardBlock above. populateDisciplineBlock is
|
|
64
|
+
// idempotent (no-op short-circuit when content unchanged), so firing on
|
|
65
|
+
// every wave checkpoint is free and guarantees the DISCIPLINE marker block
|
|
66
|
+
// in AGENTS.md actually gets populated during a real workflow — closes the
|
|
67
|
+
// "ships as dead code" wiring gap the cross-audit caught.
|
|
68
|
+
let _populateDisciplineBlockPromise = null;
|
|
69
|
+
function loadPopulateDisciplineBlock() {
|
|
70
|
+
if (_populateDisciplineBlockPromise === null) {
|
|
71
|
+
_populateDisciplineBlockPromise = (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const mod = await import('./agents-md-blackboard.js');
|
|
74
|
+
return mod.populateDisciplineBlock ?? null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
}
|
|
80
|
+
return _populateDisciplineBlockPromise;
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
/**
|
|
63
84
|
* Test-only helper: reset the populateBlackboardBlock promise singleton so a
|
|
64
85
|
* test can simulate "first call after process start" semantics. Internal.
|
|
@@ -69,6 +90,14 @@ export function _resetPopulateBlackboardBlockSingleton() {
|
|
|
69
90
|
_populateBlackboardBlockPromise = null;
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Test-only helper: reset the populateDisciplineBlock promise singleton.
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
export function _resetPopulateDisciplineBlockSingleton() {
|
|
98
|
+
_populateDisciplineBlockPromise = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
// ---------------------------------------------------------------------------
|
|
73
102
|
// Internal YAML helpers — flat subset only (string/number/boolean/string[])
|
|
74
103
|
// ---------------------------------------------------------------------------
|
|
@@ -560,5 +589,14 @@ export async function checkpointWave(waveId, projectRoot) {
|
|
|
560
589
|
try { await populateBlackboardBlock(waveId, projectRoot); } catch { /* advisory */ }
|
|
561
590
|
}
|
|
562
591
|
|
|
592
|
+
// Wave 5B wiring (cross-audit W1 fix): populate the DISCIPLINE block too.
|
|
593
|
+
// Same advisory-failure semantics as the BLACKBOARD call above. Auto-detects
|
|
594
|
+
// project type from .ijfw/memory/brief.md frontmatter or repo signals — no
|
|
595
|
+
// explicit projectType passed, the detector handles it.
|
|
596
|
+
const populateDisciplineBlock = await loadPopulateDisciplineBlock();
|
|
597
|
+
if (populateDisciplineBlock) {
|
|
598
|
+
try { await populateDisciplineBlock(projectRoot, undefined, { waveId }); } catch { /* advisory */ }
|
|
599
|
+
}
|
|
600
|
+
|
|
563
601
|
return next;
|
|
564
602
|
}
|
|
@@ -470,7 +470,7 @@ async function applyEdit(filePath, fix) {
|
|
|
470
470
|
// Exactly-one occurrence guarantee — same rule the Edit tool uses.
|
|
471
471
|
const occurrences = before.split(fix.old_string).length - 1;
|
|
472
472
|
if (occurrences > 1 && !fix.replace_all) {
|
|
473
|
-
return { ok: false, evidence: `old_string occurs ${occurrences}
|
|
473
|
+
return { ok: false, evidence: `old_string occurs ${occurrences}x; ambiguous`, before };
|
|
474
474
|
}
|
|
475
475
|
after = fix.replace_all
|
|
476
476
|
? before.split(fix.old_string).join(fix.new_string)
|