@ijfw/memory-server 1.3.0 → 1.4.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/fixtures/team/book.json +47 -0
- package/fixtures/team/business.json +47 -0
- package/fixtures/team/content.json +47 -0
- package/fixtures/team/design.json +47 -0
- package/fixtures/team/mixed.json +59 -0
- package/fixtures/team/research.json +47 -0
- package/fixtures/team/software.json +47 -0
- package/package.json +1 -9
- package/src/active-extension-writer.js +116 -0
- package/src/blackboard.js +360 -0
- package/src/cli-run.js +91 -0
- package/src/codex-agents.js +177 -0
- package/src/compute/extract.js +3 -0
- package/src/compute/fts5.js +4 -4
- package/src/compute/graph-lock.js +0 -2
- package/src/compute/migrations/003-tier-semantic.js +3 -3
- package/src/compute/runner.js +44 -15
- package/src/compute/schema.sql +1 -1
- package/src/cross-orchestrator-cli.js +974 -13
- package/src/cross-orchestrator.js +9 -1
- package/src/dashboard-client.html +144 -1
- package/src/dashboard-server.js +75 -2
- package/src/design-intelligence.js +721 -0
- package/src/dispatch/colon-syntax.js +31 -3
- package/src/dispatch/domain-manifest.js +251 -0
- package/src/dispatch/extension.js +404 -0
- package/src/dispatch/override.js +221 -0
- package/src/dispatch-planner.js +1 -0
- package/src/dream/runner.mjs +3 -3
- package/src/extension-installer.js +1230 -0
- package/src/extension-manifest-schema.js +301 -0
- package/src/extension-signer.js +740 -0
- package/src/gate-result-formatter.js +95 -0
- package/src/gate-result-schema.js +274 -0
- package/src/gate-result.js +195 -0
- package/src/intent-router.js +2 -0
- package/src/lib/npm-view.js +1 -0
- package/src/memory/fts5.js +3 -3
- package/src/memory/migrations/002-tier-semantic.js +2 -2
- package/src/memory/staleness.js +1 -1
- package/src/memory/tier-promotion.js +6 -6
- package/src/memory/tokenize.js +1 -1
- package/src/memory-feedback.js +188 -0
- package/src/override-manifest-schema.js +146 -0
- package/src/override-resolver.js +699 -0
- package/src/override-use-registry.js +307 -0
- package/src/overrides/presets/academic.md +101 -0
- package/src/overrides/presets/book.md +87 -0
- package/src/overrides/presets/campaign.md +95 -0
- package/src/overrides/presets/screenplay.md +99 -0
- package/src/recovery/checkpoint.js +191 -0
- package/src/redactor.js +2 -0
- package/src/runtime-mediator.js +178 -0
- package/src/sandbox.js +17 -3
- package/src/server.js +94 -2
- package/src/swarm/dispatch-prompt.js +154 -0
- package/src/swarm/planner.js +399 -0
- package/src/swarm/review.js +136 -0
- package/src/swarm/worktree.js +239 -0
- package/src/team/generator.js +119 -0
- package/src/team/schemas.js +341 -0
- package/src/trident/dispatch.js +47 -0
- package/src/update-check.js +1 -1
- 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
|
+
}
|
package/src/intent-router.js
CHANGED
|
@@ -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,
|
package/src/lib/npm-view.js
CHANGED
|
@@ -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
|
|
package/src/memory/fts5.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/memory/staleness.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
*/
|