@ijfw/memory-server 1.3.0 → 1.4.1

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.
Files changed (68) hide show
  1. package/README.md +67 -0
  2. package/fixtures/team/book.json +47 -0
  3. package/fixtures/team/business.json +47 -0
  4. package/fixtures/team/content.json +47 -0
  5. package/fixtures/team/design.json +47 -0
  6. package/fixtures/team/mixed.json +59 -0
  7. package/fixtures/team/research.json +47 -0
  8. package/fixtures/team/software.json +47 -0
  9. package/package.json +1 -9
  10. package/src/.registry-meta-key.pem +3 -0
  11. package/src/active-extension-writer.js +142 -0
  12. package/src/blackboard.js +360 -0
  13. package/src/cli-run.js +91 -0
  14. package/src/codex-agents.js +177 -0
  15. package/src/compute/extract.js +3 -0
  16. package/src/compute/fts5.js +4 -4
  17. package/src/compute/graph-lock.js +0 -2
  18. package/src/compute/migrations/003-tier-semantic.js +3 -3
  19. package/src/compute/runner.js +44 -15
  20. package/src/compute/schema.sql +1 -1
  21. package/src/cross-orchestrator-cli.js +974 -13
  22. package/src/cross-orchestrator.js +9 -1
  23. package/src/dashboard-client.html +353 -1
  24. package/src/dashboard-server.js +318 -2
  25. package/src/design-intelligence.js +721 -0
  26. package/src/dispatch/colon-syntax.js +31 -3
  27. package/src/dispatch/domain-manifest.js +251 -0
  28. package/src/dispatch/extension.js +637 -0
  29. package/src/dispatch/override.js +221 -0
  30. package/src/dispatch-planner.js +1 -0
  31. package/src/dream/runner.mjs +3 -3
  32. package/src/extension-installer.js +1269 -0
  33. package/src/extension-manifest-schema.js +301 -0
  34. package/src/extension-permission-check.mjs +79 -0
  35. package/src/extension-registry.js +619 -0
  36. package/src/extension-signer.js +905 -0
  37. package/src/gate-result-formatter.js +95 -0
  38. package/src/gate-result-schema.js +274 -0
  39. package/src/gate-result.js +195 -0
  40. package/src/intent-router.js +2 -0
  41. package/src/lib/npm-view.js +1 -0
  42. package/src/memory/fts5.js +3 -3
  43. package/src/memory/migrations/002-tier-semantic.js +2 -2
  44. package/src/memory/staleness.js +1 -1
  45. package/src/memory/tier-promotion.js +6 -6
  46. package/src/memory/tokenize.js +1 -1
  47. package/src/memory-feedback.js +372 -0
  48. package/src/override-manifest-schema.js +146 -0
  49. package/src/override-resolver.js +699 -0
  50. package/src/override-use-registry.js +307 -0
  51. package/src/overrides/presets/academic.md +101 -0
  52. package/src/overrides/presets/book.md +87 -0
  53. package/src/overrides/presets/campaign.md +95 -0
  54. package/src/overrides/presets/screenplay.md +99 -0
  55. package/src/recovery/checkpoint.js +191 -0
  56. package/src/redactor.js +2 -0
  57. package/src/runtime-mediator.js +207 -0
  58. package/src/sandbox.js +17 -3
  59. package/src/server.js +94 -2
  60. package/src/swarm/dispatch-prompt.js +154 -0
  61. package/src/swarm/planner.js +399 -0
  62. package/src/swarm/review.js +136 -0
  63. package/src/swarm/worktree.js +239 -0
  64. package/src/team/generator.js +119 -0
  65. package/src/team/schemas.js +341 -0
  66. package/src/trident/dispatch.js +47 -0
  67. package/src/update-check.js +1 -1
  68. package/src/vectors.js +7 -8
@@ -0,0 +1,95 @@
1
+ /**
2
+ * gate-result-formatter.js
3
+ *
4
+ * IJFW v1.4.0 Wave 0 / R2 — Gate-Result Formatter
5
+ *
6
+ * Utility that guarantees the gate-result block is the LAST emitted content
7
+ * from a gate. For code-based gates (Trident, preflight) the formatter is
8
+ * called deterministically before return. For skill-based gates (plan-check,
9
+ * cross-audit, swarm-review) it exposes a markdown snippet the skill pastes
10
+ * at end of its SKILL.md output contract (the model then echoes the block
11
+ * as its last line).
12
+ *
13
+ * Companion to `gate-result-schema.js` (t1).
14
+ */
15
+
16
+ import { formatGateResult } from './gate-result-schema.js';
17
+
18
+ const GATE_RESULT_FENCE = /```gate-result\s*\n[\s\S]*?\n```\s*$/;
19
+
20
+ /**
21
+ * appendGateResult(outputString, gateResult)
22
+ *
23
+ * Append the fenced gate-result block to `outputString`. Idempotent: if
24
+ * `outputString` already ends with a gate-result fence, replace it rather
25
+ * than append a duplicate.
26
+ *
27
+ * @param {string} outputString
28
+ * @param {object} gateResult — validated gate-result object
29
+ * @returns {string}
30
+ */
31
+ export function appendGateResult(outputString, gateResult) {
32
+ const base = typeof outputString === 'string' ? outputString : '';
33
+ const block = formatGateResult(gateResult);
34
+ const trimmed = base.replace(/\s+$/, '');
35
+
36
+ if (GATE_RESULT_FENCE.test(trimmed)) {
37
+ return trimmed.replace(GATE_RESULT_FENCE, block);
38
+ }
39
+
40
+ return trimmed + (trimmed.length > 0 ? '\n\n' : '') + block;
41
+ }
42
+
43
+ /**
44
+ * gateResultBlockOnly(gateResult)
45
+ *
46
+ * Return just the fenced gate-result block. Same as `formatGateResult` but
47
+ * lives in this module so callers depend on `gate-result-formatter` only.
48
+ *
49
+ * @param {object} gateResult
50
+ * @returns {string}
51
+ */
52
+ export function gateResultBlockOnly(gateResult) {
53
+ return formatGateResult(gateResult);
54
+ }
55
+
56
+ /**
57
+ * gateResultTemplate(gate)
58
+ *
59
+ * Returns a markdown snippet skills paste at the end of their SKILL.md
60
+ * output contract. The snippet instructs the model to emit a gate-result
61
+ * block as the LAST line of its output, with the supplied `gate` name.
62
+ *
63
+ * @param {string} gate
64
+ * @returns {string}
65
+ */
66
+ export function gateResultTemplate(gate) {
67
+ return [
68
+ '## Output contract',
69
+ '',
70
+ `Emit a gate-result block as the LAST line of your output. Use \`gate="${gate}"\`.`,
71
+ 'Statuses: `PASS | CONDITIONAL | WARN | FLAG | FAIL`.',
72
+ '',
73
+ 'Format:',
74
+ '',
75
+ '```gate-result',
76
+ '{',
77
+ ' "schema_version": "1.0",',
78
+ ` "gate": "${gate}",`,
79
+ ' "status": "PASS",',
80
+ ' "project_type": "<from project-type-detector>",',
81
+ ' "lenses": [],',
82
+ ' "affected_artifacts": [],',
83
+ ' "accounting": {"duration_ms": 0, "lenses_invoked": 0, "cost_usd": null},',
84
+ ' "remediation": [],',
85
+ ' "receipts_ref": null,',
86
+ ' "supersedes": null,',
87
+ ` "gate_id": "${gate.replace(/:/g, '-')}-<ts>-<rand4>",`,
88
+ ' "emitted_at": "<ISO-8601>"',
89
+ '}',
90
+ '```',
91
+ '',
92
+ 'The block MUST be the last content you emit; nothing after it.',
93
+ '',
94
+ ].join('\n');
95
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * gate-result-schema.js
3
+ *
4
+ * IJFW v1.4.0 Wave 0 / t1 — Gate-Result Schema
5
+ *
6
+ * A "gate-result" is the canonical contract emitted by every quality gate
7
+ * in IJFW (Trident, preflight, plan-check, swarm-review, cross-audit,
8
+ * override-audit, extension-install). All gates speak this shape so that
9
+ * downstream consumers (blackboard, receipts, dashboards, remediation
10
+ * router) can read any gate uniformly.
11
+ *
12
+ * Locked decisions (do not relitigate without an ADR):
13
+ * - FLAG status is required (it matches Trident's existing 5-level hierarchy).
14
+ * - project_type is required at the top level — IJFW is project-agnostic.
15
+ * Artifact ref types include book/content concepts, not just `file`.
16
+ * - `lenses` is empty for single-model gates (preflight, audit-ci); only
17
+ * multi-model gates (Trident, swarm-review, cross-audit) populate it.
18
+ * - `remediation` is schema-ready and partially auto-routed in v1.4.0 via
19
+ * `memory-feedback.js` (W7/B3), which surfaces pattern hints in the
20
+ * prelude when N+ recent gates fail on the same artifact type. Full
21
+ * auto-dispatch beyond pattern hints (e.g. cross-skill correlation,
22
+ * time-series detection) remains v1.5.0+.
23
+ * - `cost_usd` may be null (some gates run locally, free).
24
+ * - `gate_id` collapses any `:` in namespaced gates to `-` to keep ids
25
+ * filesystem-friendly across all 14 supported platforms.
26
+ *
27
+ * Format: gate-result objects are wire-encoded as a fenced
28
+ * ```gate-result
29
+ * <json>
30
+ * ```
31
+ * block. This lets receipts grep gate-result blocks without parsing
32
+ * surrounding markdown and lets blackboard collators round-trip them.
33
+ *
34
+ * Hand-rolled validator. No joi/zod/ajv — zero new prod dependencies.
35
+ */
36
+
37
+ export const SCHEMA_VERSION = '1.0';
38
+
39
+ /**
40
+ * Gate name pattern.
41
+ * - lowercase alpha-numeric with dashes
42
+ * - optional single `:`-delimited namespace (e.g. `preflight:audit-ci`)
43
+ * - must start with an alpha char in each segment
44
+ */
45
+ // eslint-disable-next-line security/detect-unsafe-regex -- anchored kebab + optional `:`-namespaced kebab; non-overlapping branches
46
+ export const GATE_NAME_PATTERN = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$/;
47
+
48
+ export const VALID_STATUSES = Object.freeze([
49
+ 'PASS',
50
+ 'CONDITIONAL',
51
+ 'WARN',
52
+ 'FLAG',
53
+ 'FAIL',
54
+ ]);
55
+
56
+ export const VALID_PROJECT_TYPES = Object.freeze([
57
+ 'software',
58
+ 'book',
59
+ 'content',
60
+ 'business',
61
+ 'design',
62
+ 'mixed',
63
+ 'unknown',
64
+ ]);
65
+
66
+ export const VALID_ARTIFACT_TYPES = Object.freeze([
67
+ 'file',
68
+ 'chapter',
69
+ 'section',
70
+ 'asset',
71
+ 'persona',
72
+ 'decision',
73
+ 'component',
74
+ ]);
75
+
76
+ const ISO8601_PATTERN =
77
+ // eslint-disable-next-line security/detect-unsafe-regex -- fixed-length anchored ISO 8601 shape; optional fractional + tz are non-overlapping
78
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
79
+
80
+ /**
81
+ * makeGateId(gate) — build a `<gate>-<timestamp>-<rand4>` id.
82
+ * Namespaced gates collapse `:` to `-` for filesystem safety.
83
+ *
84
+ * @param {string} gate
85
+ * @returns {string}
86
+ */
87
+ export function makeGateId(gate) {
88
+ if (typeof gate !== 'string' || !GATE_NAME_PATTERN.test(gate)) {
89
+ throw new TypeError(
90
+ `makeGateId: invalid gate name "${gate}" — must match ${GATE_NAME_PATTERN}`,
91
+ );
92
+ }
93
+ const safe = gate.replace(/:/g, '-');
94
+ const ts = Date.now();
95
+ // 4-hex random suffix. Math.random is fine here — gate_id collision risk
96
+ // is operational, not security-critical (Trident audit is the trust gate).
97
+ const rand4 = Math.floor(Math.random() * 0x10000)
98
+ .toString(16)
99
+ .padStart(4, '0');
100
+ return `${safe}-${ts}-${rand4}`;
101
+ }
102
+
103
+
104
+ function isString(v) {
105
+ return typeof v === 'string';
106
+ }
107
+
108
+ function isNonNullObject(v) {
109
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
110
+ }
111
+
112
+ /**
113
+ * validateGateResult(obj) — hand-rolled validator.
114
+ *
115
+ * @param {unknown} obj
116
+ * @returns {{valid: boolean, errors: string[]}}
117
+ */
118
+ export function validateGateResult(obj) {
119
+ const errors = [];
120
+
121
+ if (!isNonNullObject(obj)) {
122
+ return { valid: false, errors: ['root: must be an object'] };
123
+ }
124
+
125
+ // schema_version
126
+ if (obj.schema_version !== SCHEMA_VERSION) {
127
+ errors.push(
128
+ `schema_version: must equal "${SCHEMA_VERSION}", got ${JSON.stringify(obj.schema_version)}`,
129
+ );
130
+ }
131
+
132
+ // gate
133
+ if (!isString(obj.gate)) {
134
+ errors.push('gate: must be a string');
135
+ } else if (!GATE_NAME_PATTERN.test(obj.gate)) {
136
+ errors.push(
137
+ `gate: "${obj.gate}" does not match ${GATE_NAME_PATTERN}`,
138
+ );
139
+ }
140
+
141
+ // status
142
+ if (!VALID_STATUSES.includes(obj.status)) {
143
+ errors.push(
144
+ `status: must be one of ${VALID_STATUSES.join('|')}, got ${JSON.stringify(obj.status)}`,
145
+ );
146
+ }
147
+
148
+ // project_type
149
+ if (!VALID_PROJECT_TYPES.includes(obj.project_type)) {
150
+ errors.push(
151
+ `project_type: must be one of ${VALID_PROJECT_TYPES.join('|')}, got ${JSON.stringify(obj.project_type)}`,
152
+ );
153
+ }
154
+
155
+ // lenses
156
+ if (!Array.isArray(obj.lenses)) {
157
+ errors.push('lenses: must be an array (empty for single-model gates)');
158
+ } else {
159
+ obj.lenses.forEach((lens, i) => {
160
+ if (!isNonNullObject(lens)) {
161
+ errors.push(`lenses[${i}]: must be an object`);
162
+ return;
163
+ }
164
+ if (!isString(lens.model)) errors.push(`lenses[${i}].model: must be a string`);
165
+ if (!VALID_STATUSES.includes(lens.verdict)) {
166
+ errors.push(
167
+ `lenses[${i}].verdict: must be one of ${VALID_STATUSES.join('|')}`,
168
+ );
169
+ }
170
+ if (typeof lens.confidence !== 'number' || lens.confidence < 0 || lens.confidence > 1) {
171
+ errors.push(`lenses[${i}].confidence: must be number in [0,1]`);
172
+ }
173
+ if (!isString(lens.summary)) errors.push(`lenses[${i}].summary: must be a string`);
174
+ });
175
+ }
176
+
177
+ // affected_artifacts
178
+ if (!Array.isArray(obj.affected_artifacts)) {
179
+ errors.push('affected_artifacts: must be an array');
180
+ } else {
181
+ obj.affected_artifacts.forEach((a, i) => {
182
+ if (!isNonNullObject(a)) {
183
+ errors.push(`affected_artifacts[${i}]: must be an object`);
184
+ return;
185
+ }
186
+ if (!VALID_ARTIFACT_TYPES.includes(a.type)) {
187
+ errors.push(
188
+ `affected_artifacts[${i}].type: must be one of ${VALID_ARTIFACT_TYPES.join('|')}`,
189
+ );
190
+ }
191
+ if (!isString(a.ref)) errors.push(`affected_artifacts[${i}].ref: must be a string`);
192
+ if (!isString(a.role)) errors.push(`affected_artifacts[${i}].role: must be a string`);
193
+ });
194
+ }
195
+
196
+ // accounting
197
+ if (!isNonNullObject(obj.accounting)) {
198
+ errors.push('accounting: must be an object');
199
+ } else {
200
+ const a = obj.accounting;
201
+ if (typeof a.duration_ms !== 'number' || a.duration_ms < 0) {
202
+ errors.push('accounting.duration_ms: must be a non-negative number');
203
+ }
204
+ if (typeof a.lenses_invoked !== 'number' || a.lenses_invoked < 0 || !Number.isInteger(a.lenses_invoked)) {
205
+ errors.push('accounting.lenses_invoked: must be a non-negative integer');
206
+ }
207
+ if (a.cost_usd !== null && (typeof a.cost_usd !== 'number' || a.cost_usd < 0)) {
208
+ errors.push('accounting.cost_usd: must be null or non-negative number');
209
+ }
210
+ }
211
+
212
+ // remediation
213
+ if (!Array.isArray(obj.remediation)) {
214
+ errors.push('remediation: must be an array (may be empty)');
215
+ } else {
216
+ obj.remediation.forEach((r, i) => {
217
+ if (!isNonNullObject(r)) {
218
+ errors.push(`remediation[${i}]: must be an object`);
219
+ return;
220
+ }
221
+ if (!isString(r.action)) errors.push(`remediation[${i}].action: must be a string`);
222
+ if (!isString(r.target)) errors.push(`remediation[${i}].target: must be a string`);
223
+ if (!isString(r.agent_recommended)) {
224
+ errors.push(`remediation[${i}].agent_recommended: must be a string`);
225
+ }
226
+ if (typeof r.confidence !== 'number' || r.confidence < 0 || r.confidence > 1) {
227
+ errors.push(`remediation[${i}].confidence: must be number in [0,1]`);
228
+ }
229
+ });
230
+ }
231
+
232
+ // receipts_ref
233
+ if (obj.receipts_ref !== null && !isString(obj.receipts_ref)) {
234
+ errors.push('receipts_ref: must be a string or null');
235
+ }
236
+
237
+ // supersedes
238
+ if (obj.supersedes !== null && !isString(obj.supersedes)) {
239
+ errors.push('supersedes: must be a string or null');
240
+ }
241
+
242
+ // gate_id
243
+ if (!isString(obj.gate_id) || obj.gate_id.length === 0) {
244
+ errors.push('gate_id: must be a non-empty string');
245
+ } else if (isString(obj.gate)) {
246
+ // Soft-check that gate_id starts with the (colon-collapsed) gate name.
247
+ const safeGate = obj.gate.replace(/:/g, '-');
248
+ if (!obj.gate_id.startsWith(safeGate + '-')) {
249
+ errors.push(
250
+ `gate_id: expected to start with "${safeGate}-" (colon-collapsed gate name)`,
251
+ );
252
+ }
253
+ }
254
+
255
+ // emitted_at
256
+ if (!isString(obj.emitted_at) || !ISO8601_PATTERN.test(obj.emitted_at)) {
257
+ errors.push('emitted_at: must be ISO-8601 string');
258
+ }
259
+
260
+ return { valid: errors.length === 0, errors };
261
+ }
262
+
263
+ /**
264
+ * formatGateResult(obj) — render a gate-result object as a fenced
265
+ * ```gate-result\n<json>\n```
266
+ * block. Does NOT validate first — call validateGateResult() yourself.
267
+ *
268
+ * @param {object} obj
269
+ * @returns {string}
270
+ */
271
+ export function formatGateResult(obj) {
272
+ const json = JSON.stringify(obj, null, 2);
273
+ return '```gate-result\n' + json + '\n```';
274
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * gate-result.js
3
+ *
4
+ * IJFW v1.4.0 Wave 1 / t5 — Gate-Result Emitter
5
+ *
6
+ * Consumer surface every quality gate calls to emit a canonical gate-result.
7
+ * Composes the W0 schema (validation) + formatter modules, fills in defaults,
8
+ * detects project_type when omitted, and writes a fire-and-forget JSON
9
+ * receipt under .ijfw/memory/gate-receipts/.
10
+ *
11
+ * Discipline:
12
+ * - ESM only; built-in Node.js modules only (no new prod deps).
13
+ * - ASCII only in strings.
14
+ * - Receipt writes MUST NOT throw — the gate's hot path is the priority.
15
+ */
16
+
17
+ import { mkdir, writeFile } from 'node:fs/promises';
18
+ import { basename, dirname, join } from 'node:path';
19
+
20
+ import {
21
+ GATE_NAME_PATTERN,
22
+ SCHEMA_VERSION,
23
+ VALID_STATUSES,
24
+ formatGateResult,
25
+ makeGateId,
26
+ validateGateResult,
27
+ } from './gate-result-schema.js';
28
+ import { detect as detectProjectType } from './project-type-detector.js';
29
+
30
+ // Re-exports so consumers can import everything they need from this module.
31
+ export {
32
+ GATE_NAME_PATTERN,
33
+ SCHEMA_VERSION,
34
+ VALID_STATUSES,
35
+ formatGateResult,
36
+ makeGateId,
37
+ validateGateResult,
38
+ };
39
+
40
+ /**
41
+ * emitGateResult(gateOpts, context) — build, validate, and format a gate
42
+ * result. Returns the fenced ```gate-result\n<json>\n``` block.
43
+ *
44
+ * @param {{
45
+ * gate: string,
46
+ * status: string,
47
+ * lenses?: Array,
48
+ * affected_artifacts?: Array,
49
+ * accounting: {duration_ms: number, lenses_invoked: number, cost_usd: number|null},
50
+ * remediation?: Array,
51
+ * receipts_ref?: string|null,
52
+ * supersedes?: string|null,
53
+ * }} gateOpts
54
+ * @param {{projectRoot?: string, project_type?: string}} [context]
55
+ * @returns {Promise<string>}
56
+ */
57
+ export async function emitGateResult(gateOpts, context = {}) {
58
+ if (gateOpts === null || typeof gateOpts !== 'object') {
59
+ throw new TypeError('emitGateResult: gateOpts must be an object');
60
+ }
61
+
62
+ const projectType =
63
+ typeof context.project_type === 'string' && context.project_type.length > 0
64
+ ? context.project_type
65
+ : await resolveProjectType(context.projectRoot);
66
+
67
+ const result = {
68
+ schema_version: SCHEMA_VERSION,
69
+ gate_id: makeGateId(gateOpts.gate),
70
+ gate: gateOpts.gate,
71
+ status: gateOpts.status,
72
+ project_type: projectType,
73
+ lenses: Array.isArray(gateOpts.lenses) ? gateOpts.lenses : [],
74
+ affected_artifacts: Array.isArray(gateOpts.affected_artifacts)
75
+ ? gateOpts.affected_artifacts
76
+ : [],
77
+ accounting: gateOpts.accounting,
78
+ remediation: Array.isArray(gateOpts.remediation) ? gateOpts.remediation : [],
79
+ receipts_ref: gateOpts.receipts_ref === undefined ? null : gateOpts.receipts_ref,
80
+ supersedes: gateOpts.supersedes === undefined ? null : gateOpts.supersedes,
81
+ emitted_at: new Date().toISOString(),
82
+ };
83
+
84
+ const { valid, errors } = validateGateResult(result);
85
+ if (!valid) {
86
+ throw new Error(
87
+ `emitGateResult: invalid gate-result — ${errors.join('; ')}`,
88
+ );
89
+ }
90
+
91
+ // Fire-and-forget receipt write. The gate's hot path must NOT block on
92
+ // disk I/O — makeReceipt swallows its own errors and resolves undefined
93
+ // on failure. The trailing .catch is belt-and-braces in case a future
94
+ // refactor lets something escape makeReceipt's try/catch.
95
+ makeReceipt(result, {
96
+ projectRoot:
97
+ typeof context.projectRoot === 'string' && context.projectRoot.length > 0
98
+ ? context.projectRoot
99
+ : process.cwd(),
100
+ }).catch(() => {});
101
+
102
+ return formatGateResult(result);
103
+ }
104
+
105
+ /**
106
+ * makeReceipt(gateResult) — fire-and-forget JSON receipt write.
107
+ *
108
+ * Writes `.ijfw/memory/gate-receipts/<gate_id>.json` (creating parent dirs
109
+ * as needed). Disk errors are swallowed and logged to stderr; this never
110
+ * throws so the gate's hot path is never blocked by receipt issues.
111
+ *
112
+ * @param {object} gateResult — validated gate-result object (NOT the
113
+ * fenced block string). Caller is responsible for passing the object;
114
+ * if you only have the block string, JSON.parse the body first.
115
+ * @param {{projectRoot?: string}} [opts]
116
+ * @returns {Promise<void>}
117
+ */
118
+ export async function makeReceipt(gateResult, opts = {}) {
119
+ try {
120
+ if (!gateResult || typeof gateResult !== 'object') return;
121
+ const gateId = typeof gateResult.gate_id === 'string' ? gateResult.gate_id : null;
122
+ if (!gateId) return;
123
+
124
+ // Defence-in-depth against path traversal via a poisoned gate_id.
125
+ // makeGateId() produces ids matching `<gate>-<ts>-<rand4>` where <gate>
126
+ // has colons collapsed to hyphens — i.e. lowercase alphanumerics + hyphen
127
+ // only, starting with a letter. Anything else (e.g. "../" segments,
128
+ // absolute paths, alternate separators) is rejected outright.
129
+ if (!RECEIPT_GATE_ID_PATTERN.test(gateId)) {
130
+ try {
131
+ process.stderr.write(
132
+ `ijfw: makeReceipt rejected unsafe gate_id "${gateId}"\n`,
133
+ );
134
+ } catch {
135
+ /* even stderr write can fail in odd environments; nothing to do */
136
+ }
137
+ return;
138
+ }
139
+ // Belt-and-braces: strip any directory component that slipped past the
140
+ // regex (e.g. odd Unicode tricks, alternate path separators on Windows).
141
+ const safeId = basename(gateId);
142
+
143
+ const root = typeof opts.projectRoot === 'string' && opts.projectRoot.length > 0
144
+ ? opts.projectRoot
145
+ : process.cwd();
146
+
147
+ const receiptPath = join(
148
+ root,
149
+ '.ijfw',
150
+ 'memory',
151
+ 'gate-receipts',
152
+ `${safeId}.json`,
153
+ );
154
+
155
+ await mkdir(dirname(receiptPath), { recursive: true });
156
+ const body = JSON.stringify(gateResult, null, 2) + '\n';
157
+ await writeFile(receiptPath, body, 'utf8');
158
+ } catch (err) {
159
+ // Fire-and-forget: log and move on. The gate hot path must not fail.
160
+ const msg = err && err.message ? err.message : String(err);
161
+ try {
162
+ process.stderr.write(`ijfw: gate-result receipt write failed: ${msg}\n`);
163
+ } catch {
164
+ /* even stderr write can fail in odd environments; nothing to do */
165
+ }
166
+ }
167
+ }
168
+
169
+ // --- Internals -------------------------------------------------------------
170
+
171
+ // Strict gate_id shape: lowercase alphanumerics + hyphen, must start with a
172
+ // letter. Matches what makeGateId() produces after colon-collapse + ts/rand4
173
+ // suffix. Used by makeReceipt() to refuse poisoned gate_id values before any
174
+ // filesystem call.
175
+ const RECEIPT_GATE_ID_PATTERN = /^[a-z][a-z0-9-]+$/;
176
+
177
+ async function resolveProjectType(projectRoot) {
178
+ try {
179
+ const root = typeof projectRoot === 'string' && projectRoot.length > 0
180
+ ? projectRoot
181
+ : process.cwd();
182
+ // detect() is synchronous; we call it inside an async function so any
183
+ // throw is captured by the catch below and falls back to 'unknown'.
184
+ const detected = detectProjectType(root);
185
+ if (detected && typeof detected.primary_type === 'string' && detected.primary_type.length > 0) {
186
+ return detected.primary_type;
187
+ }
188
+ if (detected && typeof detected.type === 'string' && detected.type.length > 0) {
189
+ return detected.type;
190
+ }
191
+ return 'unknown';
192
+ } catch {
193
+ return 'unknown';
194
+ }
195
+ }
@@ -132,7 +132,9 @@ const INTENTS = [
132
132
  priority: 10,
133
133
  patterns: [
134
134
  /\bcross[- ]?audit(?:\s|ing)?\b/i,
135
+ // eslint-disable-next-line security/detect-unsafe-regex -- intent checks run on one user prompt string; alternations are small and token-bounded.
135
136
  /\b(?:get|need)\s+(?:a\s+)?second opinion\b/i,
137
+ // eslint-disable-next-line security/detect-unsafe-regex -- intent checks run on one user prompt string; alternations are small and token-bounded.
136
138
  /\b(?:have|ask)\s+(?:codex|gemini|opencode|aider|copilot)\s+(?:to\s+)?(?:review|audit|check)\b/i,
137
139
  /\bsecond[- ]model (?:review|opinion|audit)\b/i,
138
140
  /\b(?:peer|adversarial)[- ]?(?:review|audit)\b/i,
@@ -7,6 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
8
8
  import { rotateLogIfNeeded, redactUrl } from './atomic-io.js';
9
9
 
10
+ // eslint-disable-next-line security/detect-unsafe-regex -- npm version output is a short scalar string and is retried/truncated by caller.
10
11
  const VERSION_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
11
12
  const PKG = '@ijfw/install';
12
13
 
@@ -10,7 +10,7 @@
10
10
  // - PRAGMA busy_timeout = 5000 + BEGIN IMMEDIATE for racing writers
11
11
  // - PRAGMA quick_check post-write enforces integrity
12
12
  //
13
- // Security model (D-PILLAR-SPEC §12, real fix-wave C3):
13
+ // Security model (D-PILLAR-SPEC section 12, real fix-wave C3):
14
14
  // indexEntry runs `redactSecrets()` over `entry.body` AND `entry.source`
15
15
  // BEFORE the INSERT. The scrub is the security gate that ensures secret-
16
16
  // shaped tokens are never persisted in `memory_entries`, the FTS index,
@@ -37,7 +37,7 @@ import {
37
37
  import { autoIndexGraphFromMemoryBody } from '../compute/graph-auto-index.js';
38
38
  import { redactSecrets } from '../redactor.js';
39
39
 
40
- // D-PILLAR-SPEC §12 ingest scrub gate. Default-on; the only escape hatch
40
+ // D-PILLAR-SPEC section 12 ingest scrub gate. Default-on; the only escape hatch
41
41
  // is the IJFW_INGEST_SCRUB=0 env var, used for local debugging only and
42
42
  // never a shipping posture. Read on every indexEntry so test harnesses
43
43
  // can flip it without re-importing.
@@ -196,7 +196,7 @@ export function indexEntry(db, entry) {
196
196
  if (typeof entry.body !== 'string' || entry.body.length === 0) {
197
197
  throw new MemoryDbError('indexEntry: entry.body must be a non-empty string.');
198
198
  }
199
- // D-PILLAR-SPEC §12 ingest scrub gate. Replace `body` and `source`
199
+ // D-PILLAR-SPEC section 12 ingest scrub gate. Replace `body` and `source`
200
200
  // with their redacted forms BEFORE the INSERT, so the FTS index, the
201
201
  // graph auto-index pass below, and any downstream reader only ever
202
202
  // see scrubbed text. After this branch `entry` is unchanged for the
@@ -1,6 +1,6 @@
1
1
  // IJFW v1.3.0 -- memory migration 002: 4-tier semantic axis (D1).
2
2
  //
3
- // Source authority: PRD-v2 §9 Pillar D D1 + .planning/1.3.0/D-PILLAR-SPEC.md §1
3
+ // Source authority: PRD-v2 section 9 Pillar D D1 + .planning/1.3.0/D-PILLAR-SPEC.md section 1
4
4
  // (tier promotion rules).
5
5
  //
6
6
  // Adds `tier_semantic` column ORTHOGONAL to existing tier_access (hot/warm/cold
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // ADD-ONLY semantics. Default 'working' protects every legacy row -- existing
12
12
  // memory_entries pre-D1 are by definition session-bound observations, which is
13
- // the exact definition of Working in D-PILLAR-SPEC §1. Promotion to other
13
+ // the exact definition of Working in D-PILLAR-SPEC section 1. Promotion to other
14
14
  // tiers happens via tier-promotion.js (separate module, not this migration).
15
15
  //
16
16
  // CREATE INDEX on (tier_semantic, created_at) so the search filter
@@ -43,7 +43,7 @@ const DEFAULT_STALE_VALUE = 1;
43
43
  * propagateStaleMemory(memDb, computeDb, supersededNodeId, options) -> envelope
44
44
  *
45
45
  * Walk the COMPUTE kg graph from `supersededNodeId` (BFS, weight + depth
46
- * gated per D-PILLAR-SPEC §2). For every reachable node (excluding the
46
+ * gated per D-PILLAR-SPEC section 2). For every reachable node (excluding the
47
47
  * start node by default), find `memory_entries` rows whose `body` mentions
48
48
  * the node's name and flip their `stale_candidate` column to `staleValue`.
49
49
  *
@@ -1,6 +1,6 @@
1
1
  // IJFW v1.3.0 -- D1 tier promotion logic.
2
2
  //
3
- // Source authority: .planning/1.3.0/D-PILLAR-SPEC.md §1 (tier promotion rules).
3
+ // Source authority: .planning/1.3.0/D-PILLAR-SPEC.md section 1 (tier promotion rules).
4
4
  //
5
5
  // Implements the four promotion edges defined in the spec:
6
6
  //
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { tokenizeBody, jaccardSimilarity } from './tokenize.js';
34
34
 
35
- // Constants from D-PILLAR-SPEC §1.
35
+ // Constants from D-PILLAR-SPEC section 1.
36
36
  const JACCARD_THRESHOLD = 0.7;
37
37
  const PROCEDURAL_MIN_DURATION_MS = 5 * 60 * 1000;
38
38
  const PROCEDURAL_PATTERN_MIN_CHAINS = 3;
@@ -51,7 +51,7 @@ export const TIERS = Object.freeze({
51
51
 
52
52
  /**
53
53
  * Promote Working tier observations from the just-ended session into a
54
- * single Episodic summary record. Per D-PILLAR-SPEC §1, this is invoked
54
+ * single Episodic summary record. Per D-PILLAR-SPEC section 1, this is invoked
55
55
  * at SessionEnd boundary by the D3 hook; it is idempotent per session
56
56
  * (a session that's already been consolidated is skipped).
57
57
  *
@@ -124,7 +124,7 @@ export function promoteWorkingToEpisodic(db, opts = {}) {
124
124
  /**
125
125
  * Promote Episodic records to Semantic when supersession criteria are met.
126
126
  *
127
- * Per D-PILLAR-SPEC §1 trigger A (explicit) + trigger B (supersession):
127
+ * Per D-PILLAR-SPEC section 1 trigger A (explicit) + trigger B (supersession):
128
128
  * A. If an Episodic record's `source` carries the literal substring
129
129
  * `promote:semantic` (set by user via slash command or skill), promote.
130
130
  * B. If two Episodic records have token-set Jaccard similarity > 0.7,
@@ -243,7 +243,7 @@ export function promoteEpisodicToSemantic(db) {
243
243
 
244
244
  /**
245
245
  * Promote Working tier observations into a procedural_candidate (and on
246
- * pattern match, into Procedural) per D-PILLAR-SPEC §1.
246
+ * pattern match, into Procedural) per D-PILLAR-SPEC section 1.
247
247
  *
248
248
  * Caller provides a TaskUpdate event:
249
249
  * { task_id, status, start_ts, end_ts, body, session_id, commit_tags? }
@@ -360,7 +360,7 @@ export function promoteWorkingToProcedural(db, taskUpdate = {}) {
360
360
  // --- Semantic -> archived (alpha no-op) ------------------------------------
361
361
 
362
362
  /**
363
- * Per D-PILLAR-SPEC §1: no promotion in alpha. Function exists so callers
363
+ * Per D-PILLAR-SPEC section 1: no promotion in alpha. Function exists so callers
364
364
  * that wire up the four-edge state machine don't get an undefined-import
365
365
  * error; returns a no-op shape consistent with the others.
366
366
  */