@maintainabilityai/research-runner 0.1.20 → 0.1.23
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/dist/cli.js +38 -0
- package/dist/runner/court-recorder.d.ts +52 -0
- package/dist/runner/court-recorder.js +77 -0
- package/dist/runner/hatters-tag-builder.d.ts +31 -0
- package/dist/runner/hatters-tag-builder.js +12 -0
- package/dist/runner/skills.d.ts +20 -0
- package/dist/runner/skills.js +838 -0
- package/dist/search/arxiv-client.js +6 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -48,6 +48,7 @@ const fs = __importStar(require("node:fs"));
|
|
|
48
48
|
const path = __importStar(require("node:path"));
|
|
49
49
|
const archeologist_1 = require("./runner/archeologist");
|
|
50
50
|
const prd_1 = require("./runner/prd");
|
|
51
|
+
const skills_1 = require("./runner/skills");
|
|
51
52
|
const PKG = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
52
53
|
function parseFlags(argv) {
|
|
53
54
|
const flags = {};
|
|
@@ -191,17 +192,54 @@ async function prdCmd(argv) {
|
|
|
191
192
|
});
|
|
192
193
|
}
|
|
193
194
|
function help() {
|
|
195
|
+
const skillNames = Object.keys(skills_1.SKILLS).map(n => `skill-${n}`).sort().join('\n ');
|
|
194
196
|
process.stdout.write(`research-runner v${PKG.version}
|
|
195
197
|
|
|
196
198
|
Usage:
|
|
197
199
|
research-runner archeologist --brief "<topic>" --scope-level <platform|bar> --scope-id ID [--path research|archaeology] [...]
|
|
198
200
|
research-runner prd --research-pr <url|path> --scope-level <platform|bar> --scope-id ID [...]
|
|
201
|
+
research-runner skill-<name> # one-shot skill subcommand; reads JSON from stdin, writes JSON to stdout
|
|
202
|
+
|
|
203
|
+
Skills (called by .agent.md tools: declarations under \$MESH_PATH):
|
|
204
|
+
${skillNames}
|
|
199
205
|
|
|
200
206
|
See README.md for the full flag surface.
|
|
201
207
|
`);
|
|
202
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* skill-* dispatcher. Reads a JSON object from stdin, calls runSkill,
|
|
211
|
+
* writes the result as JSON to stdout. Exits non-zero on `ok: false` so
|
|
212
|
+
* the calling agent (or shell wrapper) can detect failure via exit code
|
|
213
|
+
* in addition to the structured `{ok: false, reason}` payload.
|
|
214
|
+
*/
|
|
215
|
+
async function skillCmd(skillName) {
|
|
216
|
+
if (!(0, skills_1.isSkillName)(skillName)) {
|
|
217
|
+
process.stdout.write(JSON.stringify({ ok: false, reason: `unknown-skill: ${skillName}` }) + '\n');
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
const stdinRaw = await (0, skills_1.readStdin)();
|
|
221
|
+
let input = {};
|
|
222
|
+
if (stdinRaw.trim().length > 0) {
|
|
223
|
+
try {
|
|
224
|
+
input = JSON.parse(stdinRaw);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
process.stdout.write(JSON.stringify({ ok: false, reason: `bad-stdin-json: ${err.message}` }) + '\n');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const result = await (0, skills_1.runSkill)(skillName, input);
|
|
232
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
233
|
+
if (result.ok === false) {
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
203
237
|
async function main() {
|
|
204
238
|
const [, , subcommand, ...rest] = process.argv;
|
|
239
|
+
if (subcommand && subcommand.startsWith('skill-')) {
|
|
240
|
+
await skillCmd(subcommand.slice('skill-'.length));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
205
243
|
switch (subcommand) {
|
|
206
244
|
case 'archeologist':
|
|
207
245
|
await archeologistCmd(rest);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strictly the fields a CloudEvents v1.0 envelope requires. Optional
|
|
3
|
+
* fields per the spec (datacontenttype, dataschema, subject, time) are
|
|
4
|
+
* folded in via `CloudEventsOptional`.
|
|
5
|
+
*
|
|
6
|
+
* See https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
|
|
7
|
+
*/
|
|
8
|
+
export interface CloudEventsEnvelope<T = Record<string, unknown>> {
|
|
9
|
+
/** CloudEvents spec version. Fixed for our emitter. */
|
|
10
|
+
specversion: '1.0';
|
|
11
|
+
/** Reverse-DNS event-source identifier — e.g. `maintainabilityai/research-runner`. */
|
|
12
|
+
source: string;
|
|
13
|
+
/** Event type — maps to `audit-emitter` event_kind plus an `mai.` prefix. */
|
|
14
|
+
type: string;
|
|
15
|
+
/** Unique id — UUID v4 generated by the emitter. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** ISO timestamp when the event was emitted. */
|
|
18
|
+
time: string;
|
|
19
|
+
/** MIME type of `data`. Always `application/json` for our emitter. */
|
|
20
|
+
datacontenttype: 'application/json';
|
|
21
|
+
/** OKR phase that contextualizes this event. */
|
|
22
|
+
subject: string;
|
|
23
|
+
/** The raw audit-emitter event payload. */
|
|
24
|
+
data: T;
|
|
25
|
+
}
|
|
26
|
+
export interface CourtRecorderInput<T> {
|
|
27
|
+
/** Reverse-DNS source identifier — e.g. `maintainabilityai/research-runner`. */
|
|
28
|
+
source: string;
|
|
29
|
+
/** Event kind from `audit-emitter` — wrapped into `mai.<kind>` for CE `type`. */
|
|
30
|
+
eventKind: string;
|
|
31
|
+
/** OKR phase (`why` | `how` | `what` | `setup` etc.) → goes in `subject`. */
|
|
32
|
+
phase: string;
|
|
33
|
+
/** The original audit-emitter event payload. Becomes `data`. */
|
|
34
|
+
payload: T;
|
|
35
|
+
/** Override timestamp (defaults to `new Date().toISOString()`). */
|
|
36
|
+
time?: string;
|
|
37
|
+
/** Override id (defaults to `crypto.randomUUID()`). */
|
|
38
|
+
id?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build a single CloudEvents v1.0 envelope around an audit-event payload.
|
|
42
|
+
* Stateless + deterministic given an injected `id` + `time` — handy for
|
|
43
|
+
* unit tests.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildCloudEventsEnvelope<T>(input: CourtRecorderInput<T>): CloudEventsEnvelope<T>;
|
|
46
|
+
/**
|
|
47
|
+
* Serialize an envelope to a single JSONL line (NO trailing newline; the
|
|
48
|
+
* caller appends `\n` when writing). Throws if the envelope can't be
|
|
49
|
+
* round-tripped through JSON.stringify — that signals a non-serializable
|
|
50
|
+
* payload, which is a programmer bug, not a runtime condition.
|
|
51
|
+
*/
|
|
52
|
+
export declare function serializeCloudEventsEnvelope<T>(env: CloudEventsEnvelope<T>): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.buildCloudEventsEnvelope = buildCloudEventsEnvelope;
|
|
37
|
+
exports.serializeCloudEventsEnvelope = serializeCloudEventsEnvelope;
|
|
38
|
+
/**
|
|
39
|
+
* Court Recorder — CloudEvents v1.0 envelope emitter for the agentic-SDLC
|
|
40
|
+
* audit chain. Wraps `audit-emitter`'s raw event payload in a CloudEvents
|
|
41
|
+
* 1.0 envelope so the resulting JSONL is SIEM-compatible without further
|
|
42
|
+
* transformation.
|
|
43
|
+
*
|
|
44
|
+
* Phase B-PR4 ships the emitter shape; agents will adopt it in their
|
|
45
|
+
* `audit-emit-event` Skill backend (B-PR1a). Each line of
|
|
46
|
+
* `okrs/<id>/audit/events/<run-id>.jsonl` is a fully-valid CloudEvents
|
|
47
|
+
* envelope whose `data` property carries the original audit-event JSON.
|
|
48
|
+
*
|
|
49
|
+
* Spec: vscode-extension/design/agentic-sdlc.md §11 (Court Recorder).
|
|
50
|
+
*/
|
|
51
|
+
const crypto = __importStar(require("node:crypto"));
|
|
52
|
+
/**
|
|
53
|
+
* Build a single CloudEvents v1.0 envelope around an audit-event payload.
|
|
54
|
+
* Stateless + deterministic given an injected `id` + `time` — handy for
|
|
55
|
+
* unit tests.
|
|
56
|
+
*/
|
|
57
|
+
function buildCloudEventsEnvelope(input) {
|
|
58
|
+
return {
|
|
59
|
+
specversion: '1.0',
|
|
60
|
+
source: input.source,
|
|
61
|
+
type: `mai.${input.eventKind}`,
|
|
62
|
+
id: input.id ?? crypto.randomUUID(),
|
|
63
|
+
time: input.time ?? new Date().toISOString(),
|
|
64
|
+
datacontenttype: 'application/json',
|
|
65
|
+
subject: input.phase,
|
|
66
|
+
data: input.payload,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Serialize an envelope to a single JSONL line (NO trailing newline; the
|
|
71
|
+
* caller appends `\n` when writing). Throws if the envelope can't be
|
|
72
|
+
* round-tripped through JSON.stringify — that signals a non-serializable
|
|
73
|
+
* payload, which is a programmer bug, not a runtime condition.
|
|
74
|
+
*/
|
|
75
|
+
function serializeCloudEventsEnvelope(env) {
|
|
76
|
+
return JSON.stringify(env);
|
|
77
|
+
}
|
|
@@ -55,6 +55,35 @@ export interface HattersTagAttestation {
|
|
|
55
55
|
security?: number | null;
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Evidence-mode block (v4 §11.1.7) — forces authors to be honest about
|
|
60
|
+
* whether the artifact's citations came from a fresh provider search or
|
|
61
|
+
* from cached/reused sources.
|
|
62
|
+
*
|
|
63
|
+
* Why this exists: the first round of agentic runs (run #21) loaded the
|
|
64
|
+
* SKILL.md context but had no live skill backends, so the agent silently
|
|
65
|
+
* fell back to reading repo files and produced an updated artifact that
|
|
66
|
+
* LOOKED grounded but cited zero live signals. The validator workflow
|
|
67
|
+
* (B-PR1c) cross-checks `fresh_provider_search_performed === true`
|
|
68
|
+
* against the audit JSONL's `skill_call` events for the four search
|
|
69
|
+
* providers — mismatch ⇒ `degraded-evidence` label ⇒ governance-pass
|
|
70
|
+
* promotion blocked.
|
|
71
|
+
*
|
|
72
|
+
* live — every cited source came from a fresh provider call this run
|
|
73
|
+
* cached — agent reused prior research without re-running providers
|
|
74
|
+
* mixed — some cited sources are fresh, some carried forward
|
|
75
|
+
*
|
|
76
|
+
* `degraded_reason` is REQUIRED on `cached` / `mixed` so the gate can
|
|
77
|
+
* surface a human-readable cause (e.g. "tavily-skill-backend-missing",
|
|
78
|
+
* "rate-limited", "rerun-after-review").
|
|
79
|
+
*/
|
|
80
|
+
export interface HattersTagEvidence {
|
|
81
|
+
evidence_mode: 'live' | 'cached' | 'mixed';
|
|
82
|
+
/** True iff at least one of the four search providers (tavily/arxiv/uspto/hackernews) was successfully called this run. */
|
|
83
|
+
fresh_provider_search_performed: boolean;
|
|
84
|
+
/** Free-text cause when evidence_mode !== 'live'. */
|
|
85
|
+
degraded_reason?: string;
|
|
86
|
+
}
|
|
58
87
|
export interface HattersTagInput {
|
|
59
88
|
run_id: string;
|
|
60
89
|
/** Git SHA of the mesh repo at run start. */
|
|
@@ -94,6 +123,8 @@ export interface HattersTagInput {
|
|
|
94
123
|
okr?: HattersTagOkrContext;
|
|
95
124
|
/** v4: Phase B+ agent runs populate this; legacy runs omit it. */
|
|
96
125
|
attestation?: HattersTagAttestation;
|
|
126
|
+
/** v4 §11.1.7: agentic runs populate this; legacy CI runs omit it. */
|
|
127
|
+
evidence?: HattersTagEvidence;
|
|
97
128
|
}
|
|
98
129
|
/** Build the full Hatter's Tag block, including heading + fenced YAML. */
|
|
99
130
|
export declare function buildHattersTag(input: HattersTagInput): string;
|
|
@@ -75,6 +75,18 @@ function buildHattersTag(input) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
if (input.evidence) {
|
|
79
|
+
lines.push('evidence:');
|
|
80
|
+
lines.push(` evidence_mode: ${input.evidence.evidence_mode}`);
|
|
81
|
+
lines.push(` fresh_provider_search_performed: ${input.evidence.fresh_provider_search_performed}`);
|
|
82
|
+
if (input.evidence.degraded_reason) {
|
|
83
|
+
// Escape any colons / hashes that would break the bare YAML scalar.
|
|
84
|
+
const escaped = /[:#]/.test(input.evidence.degraded_reason)
|
|
85
|
+
? JSON.stringify(input.evidence.degraded_reason)
|
|
86
|
+
: input.evidence.degraded_reason;
|
|
87
|
+
lines.push(` degraded_reason: ${escaped}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
78
90
|
lines.push('audit:');
|
|
79
91
|
lines.push(` event_count: ${input.audit.event_count}`);
|
|
80
92
|
lines.push(` chain_root_hash: ${input.audit.chain_root_hash}`);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape every skill returns. Tagged union so the agent can branch on `ok`.
|
|
3
|
+
* Handlers MUST NOT throw — they return `{ok: false, reason}` instead so
|
|
4
|
+
* the calling agent can keep going (per SKILL.md error contracts).
|
|
5
|
+
*/
|
|
6
|
+
export type SkillResult = ({
|
|
7
|
+
ok: true;
|
|
8
|
+
} & Record<string, unknown>) | {
|
|
9
|
+
ok: false;
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
export type SkillHandler = (input: unknown) => Promise<SkillResult>;
|
|
13
|
+
export declare const SKILLS: Record<string, SkillHandler>;
|
|
14
|
+
export declare function isSkillName(name: string): boolean;
|
|
15
|
+
export declare function runSkill(name: string, input: unknown): Promise<SkillResult>;
|
|
16
|
+
/**
|
|
17
|
+
* Read all of stdin as a UTF-8 string. Returns '' immediately on TTY
|
|
18
|
+
* (no piped input) — handlers will reject via zod with a helpful message.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readStdin(): Promise<string>;
|
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SKILLS = void 0;
|
|
37
|
+
exports.isSkillName = isSkillName;
|
|
38
|
+
exports.runSkill = runSkill;
|
|
39
|
+
exports.readStdin = readStdin;
|
|
40
|
+
/**
|
|
41
|
+
* skills — CLI subcommand backends for the agentic-SDLC Skills surface
|
|
42
|
+
* declared in `vscode-extension/code-templates/skills/<name>/SKILL.md`.
|
|
43
|
+
*
|
|
44
|
+
* Each skill is a one-shot, stateless handler: read JSON from stdin →
|
|
45
|
+
* validate with zod → do the work → write JSON to stdout. Exits 1 on
|
|
46
|
+
* error with `{ok: false, reason}` payload. This shape mirrors the SKILL.md
|
|
47
|
+
* "Error contract" sections so the calling agent can branch deterministically
|
|
48
|
+
* on `parsed.ok === false`.
|
|
49
|
+
*
|
|
50
|
+
* Why a single file: the registry is small (~12 handlers), each handler is
|
|
51
|
+
* thin (validation + delegate to existing nodes/readers), and keeping
|
|
52
|
+
* them together makes the dispatcher / capability map obvious. If a handler
|
|
53
|
+
* grows past ~150 lines, lift it into its own file under `skills/`.
|
|
54
|
+
*
|
|
55
|
+
* Mesh path resolution: handlers that read mesh state honor `$MESH_PATH`
|
|
56
|
+
* (set by `okr-bus.yml` when it shells out to the agent). Defaults to
|
|
57
|
+
* `process.cwd()` for local dev.
|
|
58
|
+
*
|
|
59
|
+
* Audit event format: `skill-audit-emit-event` writes a new event taxonomy
|
|
60
|
+
* (event_kind: skill_call | llm_call | artifact_written | review_received |
|
|
61
|
+
* state_transition | human_gate) to `okrs/<id>/audit/events/<run>.jsonl`,
|
|
62
|
+
* distinct from the pipeline runner's `node_kind` events. This is the
|
|
63
|
+
* canonical agentic-SDLC audit format per design §11.1.6.
|
|
64
|
+
*/
|
|
65
|
+
const node_crypto_1 = require("node:crypto");
|
|
66
|
+
const fs = __importStar(require("node:fs"));
|
|
67
|
+
const path = __importStar(require("node:path"));
|
|
68
|
+
const yaml = __importStar(require("js-yaml"));
|
|
69
|
+
const zod_1 = require("zod");
|
|
70
|
+
const tavily_search_1 = require("./nodes/tavily-search");
|
|
71
|
+
const arxiv_search_1 = require("./nodes/arxiv-search");
|
|
72
|
+
const hackernews_search_1 = require("./nodes/hackernews-search");
|
|
73
|
+
const uspto_search_1 = require("./nodes/uspto-search");
|
|
74
|
+
const dedupe_and_rank_1 = require("./nodes/dedupe-and-rank");
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
// Mesh path resolution
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
78
|
+
function meshPath() {
|
|
79
|
+
return process.env.MESH_PATH || process.cwd();
|
|
80
|
+
}
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Knowledge skills — read mesh state, return structured JSON
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
84
|
+
const KnowledgeOkrInput = zod_1.z.object({ okrId: zod_1.z.string().min(1) });
|
|
85
|
+
/**
|
|
86
|
+
* `knowledge-okr` — read `okrs/<id>/okr.yaml` and return the parsed card.
|
|
87
|
+
* Matches OKRService.readRaw shape. We DO NOT enforce the full BTABoK
|
|
88
|
+
* schema here — agents need the data even when the schema is a few keys
|
|
89
|
+
* behind. They can validate downstream if needed.
|
|
90
|
+
*/
|
|
91
|
+
const handleKnowledgeOkr = async (input) => {
|
|
92
|
+
const parsed = KnowledgeOkrInput.safeParse(input);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
95
|
+
}
|
|
96
|
+
const yamlPath = path.join(meshPath(), 'okrs', parsed.data.okrId, 'okr.yaml');
|
|
97
|
+
if (!fs.existsSync(yamlPath)) {
|
|
98
|
+
return { ok: false, reason: 'okr-not-found' };
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const card = yaml.load(fs.readFileSync(yamlPath, 'utf8'));
|
|
102
|
+
return { ok: true, card };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return { ok: false, reason: `yaml-parse-failed: ${err.message}` };
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const KnowledgeMeshBarInput = zod_1.z.object({ barId: zod_1.z.string().min(1) });
|
|
109
|
+
/**
|
|
110
|
+
* Walk platforms/<p>/bars/* looking for an app.yaml whose application.id
|
|
111
|
+
* matches. Cheap on small portfolios. Returns null when not found.
|
|
112
|
+
*/
|
|
113
|
+
function findBarDir(mesh, barId) {
|
|
114
|
+
const platformsDir = path.join(mesh, 'platforms');
|
|
115
|
+
if (!fs.existsSync(platformsDir)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
for (const p of fs.readdirSync(platformsDir, { withFileTypes: true })) {
|
|
119
|
+
if (!p.isDirectory()) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const barsDir = path.join(platformsDir, p.name, 'bars');
|
|
123
|
+
if (!fs.existsSync(barsDir)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const b of fs.readdirSync(barsDir, { withFileTypes: true })) {
|
|
127
|
+
if (!b.isDirectory()) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const candidate = path.join(barsDir, b.name);
|
|
131
|
+
try {
|
|
132
|
+
const app = yaml.load(fs.readFileSync(path.join(candidate, 'app.yaml'), 'utf8'));
|
|
133
|
+
if (app?.application?.id === barId) {
|
|
134
|
+
return { barDir: candidate, platformSlug: p.name };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch { /* ignore non-yaml entries */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function readYaml(p) {
|
|
143
|
+
try {
|
|
144
|
+
return yaml.load(fs.readFileSync(p, 'utf8'));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function readJson(p) {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function readDirShallow(p) {
|
|
159
|
+
try {
|
|
160
|
+
return fs.readdirSync(p);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* `knowledge-mesh-bar` — return CALM model + threats + ADRs + app.yaml for
|
|
168
|
+
* one BAR. Per the SKILL.md output contract:
|
|
169
|
+
* { id, name, platformId, calmModel, appYaml, repos, adrs, threats,
|
|
170
|
+
* controls, fitnessFunctions, qualityAttributes }
|
|
171
|
+
*/
|
|
172
|
+
const handleKnowledgeMeshBar = async (input) => {
|
|
173
|
+
const parsed = KnowledgeMeshBarInput.safeParse(input);
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
176
|
+
}
|
|
177
|
+
const found = findBarDir(meshPath(), parsed.data.barId);
|
|
178
|
+
if (!found) {
|
|
179
|
+
return { ok: false, reason: 'bar-not-found' };
|
|
180
|
+
}
|
|
181
|
+
const appYaml = readYaml(path.join(found.barDir, 'app.yaml')) ?? {};
|
|
182
|
+
const calmModel = readJson(path.join(found.barDir, 'architecture', 'bar.arch.json'));
|
|
183
|
+
const threatModel = readYaml(path.join(found.barDir, 'architecture', 'threat-model.yaml'));
|
|
184
|
+
const controls = readYaml(path.join(found.barDir, 'security', 'security-controls.yaml'));
|
|
185
|
+
const fitnessFunctions = readYaml(path.join(found.barDir, 'architecture', 'fitness-functions.yaml'));
|
|
186
|
+
const qualityAttributes = readYaml(path.join(found.barDir, 'architecture', 'quality-attributes.yaml'));
|
|
187
|
+
const adrDir = path.join(found.barDir, 'architecture', 'ADRs');
|
|
188
|
+
const adrs = [];
|
|
189
|
+
for (const name of readDirShallow(adrDir)) {
|
|
190
|
+
if (!name.endsWith('.md')) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const body = fs.readFileSync(path.join(adrDir, name), 'utf8');
|
|
195
|
+
const titleMatch = body.match(/^#\s+(.+)/m);
|
|
196
|
+
adrs.push({
|
|
197
|
+
id: name.replace(/\.md$/, ''),
|
|
198
|
+
title: titleMatch?.[1] ?? name,
|
|
199
|
+
body,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch { /* skip unreadable */ }
|
|
203
|
+
}
|
|
204
|
+
const app = appYaml.application ?? {};
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
bar: {
|
|
208
|
+
id: app.id ?? parsed.data.barId,
|
|
209
|
+
name: app.name ?? parsed.data.barId,
|
|
210
|
+
platformId: found.platformSlug,
|
|
211
|
+
calmModel,
|
|
212
|
+
appYaml,
|
|
213
|
+
repos: Array.isArray(app.repos) ? app.repos : [],
|
|
214
|
+
adrs,
|
|
215
|
+
threats: threatModel,
|
|
216
|
+
controls,
|
|
217
|
+
fitnessFunctions,
|
|
218
|
+
qualityAttributes,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
const KnowledgeMeshPlatformInput = zod_1.z.object({ platformId: zod_1.z.string().min(1) });
|
|
223
|
+
/**
|
|
224
|
+
* `knowledge-mesh-platform` — read platform.arch.json + platform.yaml +
|
|
225
|
+
* platform.decisions.yaml + list of child BARs.
|
|
226
|
+
*
|
|
227
|
+
* Platform id resolution: callers pass either the slug (e.g. "imdb") or
|
|
228
|
+
* the PLT-prefixed id (e.g. "PLT-IMDB"). We try both forms.
|
|
229
|
+
*/
|
|
230
|
+
const handleKnowledgeMeshPlatform = async (input) => {
|
|
231
|
+
const parsed = KnowledgeMeshPlatformInput.safeParse(input);
|
|
232
|
+
if (!parsed.success) {
|
|
233
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
234
|
+
}
|
|
235
|
+
const mesh = meshPath();
|
|
236
|
+
const platformsDir = path.join(mesh, 'platforms');
|
|
237
|
+
if (!fs.existsSync(platformsDir)) {
|
|
238
|
+
return { ok: false, reason: 'platform-not-found' };
|
|
239
|
+
}
|
|
240
|
+
const requested = parsed.data.platformId;
|
|
241
|
+
const slug = requested.toLowerCase().replace(/^plt-/, '');
|
|
242
|
+
const platformDir = path.join(platformsDir, slug);
|
|
243
|
+
if (!fs.existsSync(platformDir)) {
|
|
244
|
+
return { ok: false, reason: 'platform-not-found' };
|
|
245
|
+
}
|
|
246
|
+
const platformYaml = readYaml(path.join(platformDir, 'platform.yaml')) ?? {};
|
|
247
|
+
const calmModel = readJson(path.join(platformDir, 'platform.arch.json'));
|
|
248
|
+
const decisions = readYaml(path.join(platformDir, 'platform.decisions.yaml'));
|
|
249
|
+
const bars = [];
|
|
250
|
+
for (const b of readDirShallow(path.join(platformDir, 'bars'))) {
|
|
251
|
+
const appYaml = readYaml(path.join(platformDir, 'bars', b, 'app.yaml'));
|
|
252
|
+
const app = appYaml?.application;
|
|
253
|
+
if (app?.id) {
|
|
254
|
+
bars.push({ id: app.id, name: app.name ?? app.id });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
platform: {
|
|
260
|
+
id: platformYaml.id ?? `PLT-${slug.toUpperCase()}`,
|
|
261
|
+
slug,
|
|
262
|
+
name: platformYaml.name ?? slug,
|
|
263
|
+
calmModel,
|
|
264
|
+
decisions,
|
|
265
|
+
bars,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
const KnowledgeMeshThreatsInput = zod_1.z.object({
|
|
270
|
+
concern: zod_1.z.string().min(1),
|
|
271
|
+
maxResults: zod_1.z.number().int().positive().optional(),
|
|
272
|
+
});
|
|
273
|
+
/**
|
|
274
|
+
* Walk every `<bar>/architecture/threat-model.yaml` AND any top-level
|
|
275
|
+
* `threats/` library, collect entries, return those whose tags / category /
|
|
276
|
+
* description match the concern keyword (case-insensitive substring).
|
|
277
|
+
*/
|
|
278
|
+
const handleKnowledgeMeshThreats = async (input) => {
|
|
279
|
+
const parsed = KnowledgeMeshThreatsInput.safeParse(input);
|
|
280
|
+
if (!parsed.success) {
|
|
281
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
282
|
+
}
|
|
283
|
+
const mesh = meshPath();
|
|
284
|
+
const concern = parsed.data.concern.toLowerCase();
|
|
285
|
+
const maxResults = parsed.data.maxResults ?? 20;
|
|
286
|
+
const out = [];
|
|
287
|
+
// Top-level threats library (optional convention)
|
|
288
|
+
const libDir = path.join(mesh, 'threats');
|
|
289
|
+
for (const name of readDirShallow(libDir)) {
|
|
290
|
+
if (!name.endsWith('.yaml') && !name.endsWith('.yml')) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const data = readYaml(path.join(libDir, name));
|
|
294
|
+
const list = Array.isArray(data) ? data : data?.threats;
|
|
295
|
+
if (Array.isArray(list)) {
|
|
296
|
+
out.push(...list);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Per-BAR threat models
|
|
300
|
+
const platformsDir = path.join(mesh, 'platforms');
|
|
301
|
+
for (const p of readDirShallow(platformsDir)) {
|
|
302
|
+
const barsDir = path.join(platformsDir, p, 'bars');
|
|
303
|
+
for (const b of readDirShallow(barsDir)) {
|
|
304
|
+
const tm = readYaml(path.join(barsDir, b, 'architecture', 'threat-model.yaml'));
|
|
305
|
+
if (Array.isArray(tm?.threats)) {
|
|
306
|
+
out.push(...tm.threats);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const filtered = out.filter(t => {
|
|
311
|
+
const hay = JSON.stringify(t).toLowerCase();
|
|
312
|
+
return hay.includes(concern);
|
|
313
|
+
}).slice(0, maxResults);
|
|
314
|
+
return { ok: true, threats: filtered };
|
|
315
|
+
};
|
|
316
|
+
const KnowledgeMeshAdrsInput = zod_1.z.object({
|
|
317
|
+
concern: zod_1.z.string().min(1),
|
|
318
|
+
scope: zod_1.z.object({
|
|
319
|
+
platformId: zod_1.z.string().optional(),
|
|
320
|
+
barIds: zod_1.z.array(zod_1.z.string()).optional(),
|
|
321
|
+
}).optional(),
|
|
322
|
+
maxResults: zod_1.z.number().int().positive().optional(),
|
|
323
|
+
});
|
|
324
|
+
/**
|
|
325
|
+
* Walk every `<bar>/architecture/ADRs/*.md`, optionally filtered by
|
|
326
|
+
* platform / BAR scope, return entries whose title/body matches the
|
|
327
|
+
* concern (case-insensitive substring).
|
|
328
|
+
*/
|
|
329
|
+
const handleKnowledgeMeshAdrs = async (input) => {
|
|
330
|
+
const parsed = KnowledgeMeshAdrsInput.safeParse(input);
|
|
331
|
+
if (!parsed.success) {
|
|
332
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
333
|
+
}
|
|
334
|
+
const mesh = meshPath();
|
|
335
|
+
const concern = parsed.data.concern.toLowerCase();
|
|
336
|
+
const maxResults = parsed.data.maxResults ?? 20;
|
|
337
|
+
const barFilter = parsed.data.scope?.barIds ? new Set(parsed.data.scope.barIds) : null;
|
|
338
|
+
const platformFilter = parsed.data.scope?.platformId?.toLowerCase().replace(/^plt-/, '') ?? null;
|
|
339
|
+
const out = [];
|
|
340
|
+
const platformsDir = path.join(mesh, 'platforms');
|
|
341
|
+
for (const p of readDirShallow(platformsDir)) {
|
|
342
|
+
if (platformFilter && p.toLowerCase() !== platformFilter) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const barsDir = path.join(platformsDir, p, 'bars');
|
|
346
|
+
for (const b of readDirShallow(barsDir)) {
|
|
347
|
+
const appYaml = readYaml(path.join(barsDir, b, 'app.yaml'));
|
|
348
|
+
const barId = appYaml?.application?.id ?? b;
|
|
349
|
+
if (barFilter && !barFilter.has(barId)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const adrDir = path.join(barsDir, b, 'architecture', 'ADRs');
|
|
353
|
+
for (const name of readDirShallow(adrDir)) {
|
|
354
|
+
if (!name.endsWith('.md')) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const body = fs.readFileSync(path.join(adrDir, name), 'utf8');
|
|
359
|
+
if (!body.toLowerCase().includes(concern)) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const titleMatch = body.match(/^#\s+(.+)/m);
|
|
363
|
+
const statusMatch = body.match(/^##\s+Status\s*\n+\s*(\S+)/im);
|
|
364
|
+
out.push({
|
|
365
|
+
id: name.replace(/\.md$/, ''),
|
|
366
|
+
title: (titleMatch?.[1] ?? name).trim(),
|
|
367
|
+
status: (statusMatch?.[1] ?? 'unknown').trim(),
|
|
368
|
+
tags: [],
|
|
369
|
+
body,
|
|
370
|
+
barId,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch { /* skip */ }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { ok: true, adrs: out.slice(0, maxResults) };
|
|
378
|
+
};
|
|
379
|
+
const KnowledgeResearchInput = zod_1.z.object({ okrId: zod_1.z.string().min(1) });
|
|
380
|
+
/**
|
|
381
|
+
* `knowledge-research` — read `okrs/<id>/why/research-doc.md` and surface
|
|
382
|
+
* the parsed structure (R-N findings + Whitespace + References).
|
|
383
|
+
*
|
|
384
|
+
* Parse strategy: the synthesis prompt-pack writes deterministic section
|
|
385
|
+
* headings. We extract by regex; if the doc doesn't follow the schema,
|
|
386
|
+
* we return the raw body so the PRD agent can still reason about it.
|
|
387
|
+
*/
|
|
388
|
+
const handleKnowledgeResearch = async (input) => {
|
|
389
|
+
const parsed = KnowledgeResearchInput.safeParse(input);
|
|
390
|
+
if (!parsed.success) {
|
|
391
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
392
|
+
}
|
|
393
|
+
const docPath = path.join(meshPath(), 'okrs', parsed.data.okrId, 'why', 'research-doc.md');
|
|
394
|
+
if (!fs.existsSync(docPath)) {
|
|
395
|
+
return { ok: false, reason: 'research-not-merged-yet' };
|
|
396
|
+
}
|
|
397
|
+
const body = fs.readFileSync(docPath, 'utf8');
|
|
398
|
+
/**
|
|
399
|
+
* Split by R-N headings rather than regex-capture the block — JS regex
|
|
400
|
+
* lacks `\Z` and the lookahead-for-end-of-input dance is error-prone.
|
|
401
|
+
* Walk line-by-line, accumulate into the current finding's block.
|
|
402
|
+
*/
|
|
403
|
+
const findings = [];
|
|
404
|
+
const lines = body.split('\n');
|
|
405
|
+
let current = null;
|
|
406
|
+
const flush = () => {
|
|
407
|
+
if (!current) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const blockText = current.block.join('\n');
|
|
411
|
+
const supporting = [...blockText.matchAll(/^\s*-\s*(?:Supporting|S):\s*(.+)$/gm)].map(x => x[1].trim());
|
|
412
|
+
const contradicting = [...blockText.matchAll(/^\s*-\s*(?:Contradicting|C):\s*(.+)$/gm)].map(x => x[1].trim());
|
|
413
|
+
const confidenceMatch = blockText.match(/Confidence:\s*(HIGH|MEDIUM|LOW)/i);
|
|
414
|
+
findings.push({
|
|
415
|
+
id: current.id,
|
|
416
|
+
title: current.title,
|
|
417
|
+
supporting,
|
|
418
|
+
contradicting,
|
|
419
|
+
confidence: confidenceMatch?.[1].toUpperCase() ?? 'MEDIUM',
|
|
420
|
+
});
|
|
421
|
+
current = null;
|
|
422
|
+
};
|
|
423
|
+
for (const line of lines) {
|
|
424
|
+
const startMatch = line.match(/^###\s+(R-\d+)\s+(.+?)\s*$/);
|
|
425
|
+
if (startMatch) {
|
|
426
|
+
flush();
|
|
427
|
+
current = { id: startMatch[1], title: startMatch[2].trim(), block: [] };
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (/^##\s/.test(line)) {
|
|
431
|
+
flush();
|
|
432
|
+
}
|
|
433
|
+
if (current) {
|
|
434
|
+
current.block.push(line);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
flush();
|
|
438
|
+
/** Pull bullets out of a labelled `## Section` until the next `## ` or EOF. */
|
|
439
|
+
const pullBullets = (sectionName) => {
|
|
440
|
+
const out = [];
|
|
441
|
+
let inSection = false;
|
|
442
|
+
for (const line of lines) {
|
|
443
|
+
if (new RegExp(`^##\\s+${sectionName}\\b`, 'i').test(line)) {
|
|
444
|
+
inSection = true;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (inSection && /^##\s/.test(line)) {
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
if (!inSection) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const bullet = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
454
|
+
if (bullet) {
|
|
455
|
+
out.push(bullet[1]);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
};
|
|
460
|
+
const whitespace = pullBullets('Whitespace');
|
|
461
|
+
const references = pullBullets('References');
|
|
462
|
+
return { ok: true, findings, whitespace, references, rawBody: body };
|
|
463
|
+
};
|
|
464
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
465
|
+
// Search skills — thin wrappers over the existing search nodes
|
|
466
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
467
|
+
const SearchQueriesInput = zod_1.z.object({
|
|
468
|
+
queries: zod_1.z.array(zod_1.z.string().min(1)).min(1),
|
|
469
|
+
maxResults: zod_1.z.number().int().positive().optional(),
|
|
470
|
+
});
|
|
471
|
+
/**
|
|
472
|
+
* Decide whether a per-query envelope set means "the provider was reachable
|
|
473
|
+
* at least once" (=> `ok: true`) or "every single query failed" (=> `ok:
|
|
474
|
+
* false, reason: all-queries-failed`).
|
|
475
|
+
*
|
|
476
|
+
* Why this matters: previously the handlers returned `ok: true` even when
|
|
477
|
+
* 100% of queries failed (because `runTavilySearch` etc. use
|
|
478
|
+
* `Promise.allSettled` and never throw). That made `result_count: 0`
|
|
479
|
+
* ambiguous — could be "API reached, no matches" OR "firewall blocked
|
|
480
|
+
* every call." The agentic-SDLC evidence-honesty gate (§11.1.7) counts
|
|
481
|
+
* ok=true as a successful provider call; this fix is what makes that
|
|
482
|
+
* count actually meaningful.
|
|
483
|
+
*
|
|
484
|
+
* Returns `null` when at least one query reached the provider (the
|
|
485
|
+
* skill returns ok:true). Otherwise returns the failure reason string
|
|
486
|
+
* the skill should surface in `reason`.
|
|
487
|
+
*/
|
|
488
|
+
function detectAllQueriesFailed(envelopes, skill) {
|
|
489
|
+
if (envelopes.length === 0) {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
const allErrored = envelopes.every(e => e.error !== undefined && e.error.length > 0);
|
|
493
|
+
if (!allErrored) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const firstError = envelopes[0].error ?? 'unknown';
|
|
497
|
+
// `all-queries-failed:` prefix is load-bearing for the audit-validate gate's
|
|
498
|
+
// pattern matching of firewall-block vs query-quality failures.
|
|
499
|
+
return `all-queries-failed: ${skill} — ${firstError}`;
|
|
500
|
+
}
|
|
501
|
+
const handleTavilySearch = async (input) => {
|
|
502
|
+
const parsed = SearchQueriesInput.safeParse(input);
|
|
503
|
+
if (!parsed.success) {
|
|
504
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
505
|
+
}
|
|
506
|
+
const apiKey = process.env.TAVILY_API_KEY;
|
|
507
|
+
if (!apiKey) {
|
|
508
|
+
return { ok: false, reason: 'tavily-api-key-missing' };
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const res = await (0, tavily_search_1.runTavilySearch)({
|
|
512
|
+
apiKey,
|
|
513
|
+
queries: parsed.data.queries,
|
|
514
|
+
maxResultsPerQuery: parsed.data.maxResults,
|
|
515
|
+
});
|
|
516
|
+
const failure = detectAllQueriesFailed(res.envelopes, 'tavily-search');
|
|
517
|
+
if (failure) {
|
|
518
|
+
return { ok: false, reason: failure, envelopes: res.envelopes };
|
|
519
|
+
}
|
|
520
|
+
return { ok: true, envelopes: res.envelopes, results: res.results };
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
return { ok: false, reason: `tavily-failed: ${err.message}` };
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
const handleArxivSearch = async (input) => {
|
|
527
|
+
const parsed = SearchQueriesInput.safeParse(input);
|
|
528
|
+
if (!parsed.success) {
|
|
529
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const res = await (0, arxiv_search_1.runArxivSearch)({
|
|
533
|
+
queries: parsed.data.queries,
|
|
534
|
+
maxResultsPerQuery: parsed.data.maxResults,
|
|
535
|
+
});
|
|
536
|
+
const failure = detectAllQueriesFailed(res.envelopes, 'arxiv-search');
|
|
537
|
+
if (failure) {
|
|
538
|
+
return { ok: false, reason: failure, envelopes: res.envelopes };
|
|
539
|
+
}
|
|
540
|
+
return { ok: true, envelopes: res.envelopes, results: res.results };
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
return { ok: false, reason: `arxiv-failed: ${err.message}` };
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
const handleUsptoSearch = async (input) => {
|
|
547
|
+
const parsed = SearchQueriesInput.safeParse(input);
|
|
548
|
+
if (!parsed.success) {
|
|
549
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
550
|
+
}
|
|
551
|
+
const apiKey = process.env.USPTO_API_KEY;
|
|
552
|
+
if (!apiKey) {
|
|
553
|
+
return { ok: false, reason: 'uspto-api-key-missing' };
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const res = await (0, uspto_search_1.runUsptoSearch)({
|
|
557
|
+
apiKey,
|
|
558
|
+
queries: parsed.data.queries,
|
|
559
|
+
maxResultsPerQuery: parsed.data.maxResults,
|
|
560
|
+
});
|
|
561
|
+
const failure = detectAllQueriesFailed(res.envelopes, 'uspto-search');
|
|
562
|
+
if (failure) {
|
|
563
|
+
return { ok: false, reason: failure, envelopes: res.envelopes };
|
|
564
|
+
}
|
|
565
|
+
return { ok: true, envelopes: res.envelopes, results: res.results };
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
return { ok: false, reason: `uspto-failed: ${err.message}` };
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const handleHackerNewsSearch = async (input) => {
|
|
572
|
+
const parsed = SearchQueriesInput.safeParse(input);
|
|
573
|
+
if (!parsed.success) {
|
|
574
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const res = await (0, hackernews_search_1.runHackerNewsSearch)({
|
|
578
|
+
queries: parsed.data.queries,
|
|
579
|
+
hitsPerQuery: parsed.data.maxResults,
|
|
580
|
+
});
|
|
581
|
+
const failure = detectAllQueriesFailed(res.envelopes, 'hackernews-search');
|
|
582
|
+
if (failure) {
|
|
583
|
+
return { ok: false, reason: failure, envelopes: res.envelopes };
|
|
584
|
+
}
|
|
585
|
+
return { ok: true, envelopes: res.envelopes, results: res.results };
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
return { ok: false, reason: `hackernews-failed: ${err.message}` };
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
592
|
+
// Pure skills — dedupe + format
|
|
593
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
594
|
+
const ProviderResultSchema = zod_1.z.object({
|
|
595
|
+
provider: zod_1.z.string(),
|
|
596
|
+
fromQuery: zod_1.z.string(),
|
|
597
|
+
title: zod_1.z.string(),
|
|
598
|
+
url: zod_1.z.string(),
|
|
599
|
+
content: zod_1.z.string(),
|
|
600
|
+
score: zod_1.z.number(),
|
|
601
|
+
publishedDate: zod_1.z.string().optional(),
|
|
602
|
+
authors: zod_1.z.array(zod_1.z.string()).optional(),
|
|
603
|
+
});
|
|
604
|
+
const DedupeAndRankInput = zod_1.z.object({
|
|
605
|
+
results: zod_1.z.array(zod_1.z.array(ProviderResultSchema)),
|
|
606
|
+
topN: zod_1.z.number().int().positive().optional(),
|
|
607
|
+
});
|
|
608
|
+
const handleDedupeAndRank = async (input) => {
|
|
609
|
+
const parsed = DedupeAndRankInput.safeParse(input);
|
|
610
|
+
if (!parsed.success) {
|
|
611
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
612
|
+
}
|
|
613
|
+
const flat = parsed.data.results.flat();
|
|
614
|
+
const ranked = (0, dedupe_and_rank_1.dedupeAndRank)({ results: flat, topN: parsed.data.topN ?? 50 });
|
|
615
|
+
const providerCounts = {};
|
|
616
|
+
for (const r of ranked) {
|
|
617
|
+
providerCounts[r.provider] = (providerCounts[r.provider] ?? 0) + 1;
|
|
618
|
+
}
|
|
619
|
+
return { ok: true, rankedSources: ranked, providerCounts };
|
|
620
|
+
};
|
|
621
|
+
const RankedSourceSchema = zod_1.z.object({
|
|
622
|
+
id: zod_1.z.string(),
|
|
623
|
+
provider: zod_1.z.string(),
|
|
624
|
+
title: zod_1.z.string(),
|
|
625
|
+
url: zod_1.z.string(),
|
|
626
|
+
retrieved_at: zod_1.z.string(),
|
|
627
|
+
salience_score: zod_1.z.number(),
|
|
628
|
+
excerpt: zod_1.z.string(),
|
|
629
|
+
published_at: zod_1.z.string().optional(),
|
|
630
|
+
authors: zod_1.z.array(zod_1.z.string()).optional(),
|
|
631
|
+
});
|
|
632
|
+
const FormatIssueUpdateInput = zod_1.z.object({
|
|
633
|
+
topic: zod_1.z.string(),
|
|
634
|
+
runId: zod_1.z.string(),
|
|
635
|
+
rankedSources: zod_1.z.array(RankedSourceSchema),
|
|
636
|
+
providerCounts: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
|
|
637
|
+
gapSignals: zod_1.z.array(zod_1.z.string()).optional(),
|
|
638
|
+
meshContext: zod_1.z.object({
|
|
639
|
+
platformId: zod_1.z.string().optional(),
|
|
640
|
+
barIds: zod_1.z.array(zod_1.z.string()).optional(),
|
|
641
|
+
}),
|
|
642
|
+
});
|
|
643
|
+
const COMMENT_BYTE_CAP = 60_000;
|
|
644
|
+
/**
|
|
645
|
+
* `format-research-issue-update` — render the OKR issue comment that the
|
|
646
|
+
* market-research-agent posts after each iteration. Pure markdown; no LLM.
|
|
647
|
+
* Truncates with a footer when over 60kB (GitHub issue cap is ~65k).
|
|
648
|
+
*/
|
|
649
|
+
const handleFormatResearchIssueUpdate = async (input) => {
|
|
650
|
+
const parsed = FormatIssueUpdateInput.safeParse(input);
|
|
651
|
+
if (!parsed.success) {
|
|
652
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
653
|
+
}
|
|
654
|
+
const { topic, runId, rankedSources, providerCounts, gapSignals = [], meshContext } = parsed.data;
|
|
655
|
+
const lines = [];
|
|
656
|
+
lines.push(`## 🔍 Market research update — ${topic}`);
|
|
657
|
+
lines.push('');
|
|
658
|
+
lines.push(`Run \`${runId}\` — platform \`${meshContext.platformId ?? '—'}\`, BARs \`${(meshContext.barIds ?? []).join(', ') || '—'}\`.`);
|
|
659
|
+
lines.push('');
|
|
660
|
+
lines.push('| Provider | Ranked |');
|
|
661
|
+
lines.push('|---|---:|');
|
|
662
|
+
for (const [provider, count] of Object.entries(providerCounts)) {
|
|
663
|
+
lines.push(`| ${provider} | ${count} |`);
|
|
664
|
+
}
|
|
665
|
+
lines.push('');
|
|
666
|
+
if (gapSignals.length > 0) {
|
|
667
|
+
lines.push('### Gap signals');
|
|
668
|
+
lines.push('');
|
|
669
|
+
for (const g of gapSignals) {
|
|
670
|
+
lines.push(`- \`${g}\``);
|
|
671
|
+
}
|
|
672
|
+
lines.push('');
|
|
673
|
+
}
|
|
674
|
+
lines.push('### Top-ranked sources');
|
|
675
|
+
lines.push('');
|
|
676
|
+
for (const s of rankedSources) {
|
|
677
|
+
const date = s.published_at ? ` _(${s.published_at.slice(0, 10)})_` : '';
|
|
678
|
+
lines.push(`- \`${s.id}\` **[${s.title}](${s.url})** — ${s.provider}, score ${s.salience_score.toFixed(2)}${date}`);
|
|
679
|
+
if (s.excerpt) {
|
|
680
|
+
lines.push(` > ${s.excerpt.replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
let markdown = lines.join('\n');
|
|
684
|
+
let byteCount = Buffer.byteLength(markdown, 'utf8');
|
|
685
|
+
if (byteCount > COMMENT_BYTE_CAP) {
|
|
686
|
+
markdown = markdown.slice(0, COMMENT_BYTE_CAP) + '\n\n> _Truncated — original exceeded GitHub issue-comment byte cap._';
|
|
687
|
+
byteCount = Buffer.byteLength(markdown, 'utf8');
|
|
688
|
+
}
|
|
689
|
+
return { ok: true, markdown, byteCount };
|
|
690
|
+
};
|
|
691
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
692
|
+
// Audit skill — hash-chained JSONL append, cross-process-safe
|
|
693
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
694
|
+
const AuditEmitInput = zod_1.z.object({
|
|
695
|
+
okrId: zod_1.z.string().min(1),
|
|
696
|
+
runId: zod_1.z.string().min(1),
|
|
697
|
+
eventKind: zod_1.z.enum(['skill_call', 'llm_call', 'artifact_written', 'review_received', 'state_transition', 'human_gate']),
|
|
698
|
+
payload: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
699
|
+
phase: zod_1.z.enum(['why', 'how', 'what']),
|
|
700
|
+
intentThreadUuid: zod_1.z.string().min(1),
|
|
701
|
+
});
|
|
702
|
+
const LOCK_RETRY_LIMIT = 3;
|
|
703
|
+
const LOCK_RETRY_BASE_MS = 50;
|
|
704
|
+
/** Recursive key-sorted JSON stringify so the event hash is canonical. */
|
|
705
|
+
function canonicalStringify(value) {
|
|
706
|
+
if (value === null || typeof value !== 'object') {
|
|
707
|
+
return JSON.stringify(value);
|
|
708
|
+
}
|
|
709
|
+
if (Array.isArray(value)) {
|
|
710
|
+
return '[' + value.map(canonicalStringify).join(',') + ']';
|
|
711
|
+
}
|
|
712
|
+
const obj = value;
|
|
713
|
+
const keys = Object.keys(obj).sort();
|
|
714
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(obj[k])).join(',') + '}';
|
|
715
|
+
}
|
|
716
|
+
function sha256(text) {
|
|
717
|
+
return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
|
|
718
|
+
}
|
|
719
|
+
async function sleep(ms) {
|
|
720
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* `audit-emit-event` — append one hash-chained event to
|
|
724
|
+
* `<mesh>/okrs/<id>/audit/events/<runId>.jsonl`.
|
|
725
|
+
*
|
|
726
|
+
* Cross-process serialization: we use an exclusive-create lock file
|
|
727
|
+
* (`<jsonl>.lock`) with bounded retries. Each call reads the existing
|
|
728
|
+
* tail, computes prev_event_hash + event_id, writes the new line, then
|
|
729
|
+
* releases the lock. On terminal contention returns `{ok: false,
|
|
730
|
+
* reason: 'audit-write-failed-after-retries'}` per the SKILL.md
|
|
731
|
+
* contract — agents treat this as non-blocking.
|
|
732
|
+
*/
|
|
733
|
+
const handleAuditEmitEvent = async (input) => {
|
|
734
|
+
const parsed = AuditEmitInput.safeParse(input);
|
|
735
|
+
if (!parsed.success) {
|
|
736
|
+
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
737
|
+
}
|
|
738
|
+
const { okrId, runId, eventKind, payload, phase, intentThreadUuid } = parsed.data;
|
|
739
|
+
const dir = path.join(meshPath(), 'okrs', okrId, 'audit', 'events');
|
|
740
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
741
|
+
const filePath = path.join(dir, `${runId}.jsonl`);
|
|
742
|
+
const lockPath = `${filePath}.lock`;
|
|
743
|
+
for (let attempt = 0; attempt < LOCK_RETRY_LIMIT; attempt++) {
|
|
744
|
+
let lockFd = null;
|
|
745
|
+
try {
|
|
746
|
+
lockFd = fs.openSync(lockPath, 'wx');
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
if (err.code === 'EEXIST') {
|
|
750
|
+
await sleep(LOCK_RETRY_BASE_MS * (attempt + 1));
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
return { ok: false, reason: `audit-lock-failed: ${err.message}` };
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
let prevHash = null;
|
|
757
|
+
let nextEventId = 1;
|
|
758
|
+
if (fs.existsSync(filePath)) {
|
|
759
|
+
const existing = fs.readFileSync(filePath, 'utf8').split('\n').filter(l => l.trim().length > 0);
|
|
760
|
+
if (existing.length > 0) {
|
|
761
|
+
const last = JSON.parse(existing[existing.length - 1]);
|
|
762
|
+
prevHash = last.event_hash;
|
|
763
|
+
nextEventId = last.event_id + 1;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const draft = {
|
|
767
|
+
event_id: nextEventId,
|
|
768
|
+
ts: new Date().toISOString(),
|
|
769
|
+
okr_id: okrId,
|
|
770
|
+
run_id: runId,
|
|
771
|
+
intent_thread_uuid: intentThreadUuid,
|
|
772
|
+
phase,
|
|
773
|
+
event_kind: eventKind,
|
|
774
|
+
payload,
|
|
775
|
+
prev_event_hash: prevHash,
|
|
776
|
+
event_hash: '',
|
|
777
|
+
};
|
|
778
|
+
const hash = sha256(canonicalStringify(draft));
|
|
779
|
+
const finalEvent = { ...draft, event_hash: hash };
|
|
780
|
+
fs.appendFileSync(filePath, JSON.stringify(finalEvent) + '\n', 'utf8');
|
|
781
|
+
return { ok: true, chainHead: hash, eventId: nextEventId };
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
if (lockFd !== null) {
|
|
785
|
+
fs.closeSync(lockFd);
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
fs.unlinkSync(lockPath);
|
|
789
|
+
}
|
|
790
|
+
catch { /* lock already gone */ }
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return { ok: false, reason: 'audit-write-failed-after-retries' };
|
|
794
|
+
};
|
|
795
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
796
|
+
// Registry + dispatcher
|
|
797
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
798
|
+
exports.SKILLS = {
|
|
799
|
+
'knowledge-okr': handleKnowledgeOkr,
|
|
800
|
+
'knowledge-mesh-bar': handleKnowledgeMeshBar,
|
|
801
|
+
'knowledge-mesh-platform': handleKnowledgeMeshPlatform,
|
|
802
|
+
'knowledge-mesh-threats': handleKnowledgeMeshThreats,
|
|
803
|
+
'knowledge-mesh-adrs': handleKnowledgeMeshAdrs,
|
|
804
|
+
'knowledge-research': handleKnowledgeResearch,
|
|
805
|
+
'tavily-search': handleTavilySearch,
|
|
806
|
+
'arxiv-search': handleArxivSearch,
|
|
807
|
+
'uspto-search': handleUsptoSearch,
|
|
808
|
+
'hackernews-search': handleHackerNewsSearch,
|
|
809
|
+
'dedupe-and-rank': handleDedupeAndRank,
|
|
810
|
+
'format-research-issue-update': handleFormatResearchIssueUpdate,
|
|
811
|
+
'audit-emit-event': handleAuditEmitEvent,
|
|
812
|
+
};
|
|
813
|
+
function isSkillName(name) {
|
|
814
|
+
return Object.prototype.hasOwnProperty.call(exports.SKILLS, name);
|
|
815
|
+
}
|
|
816
|
+
async function runSkill(name, input) {
|
|
817
|
+
const handler = exports.SKILLS[name];
|
|
818
|
+
if (!handler) {
|
|
819
|
+
return { ok: false, reason: `unknown-skill: ${name}` };
|
|
820
|
+
}
|
|
821
|
+
return handler(input);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Read all of stdin as a UTF-8 string. Returns '' immediately on TTY
|
|
825
|
+
* (no piped input) — handlers will reject via zod with a helpful message.
|
|
826
|
+
*/
|
|
827
|
+
async function readStdin() {
|
|
828
|
+
if (process.stdin.isTTY) {
|
|
829
|
+
return '';
|
|
830
|
+
}
|
|
831
|
+
return new Promise((resolve, reject) => {
|
|
832
|
+
let data = '';
|
|
833
|
+
process.stdin.setEncoding('utf8');
|
|
834
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
835
|
+
process.stdin.on('end', () => resolve(data));
|
|
836
|
+
process.stdin.on('error', reject);
|
|
837
|
+
});
|
|
838
|
+
}
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.arxivSearch = arxivSearch;
|
|
16
16
|
exports.parseArxivAtom = parseArxivAtom;
|
|
17
|
-
|
|
17
|
+
// HTTPS — arXiv has supported it for years. Using plain HTTP previously
|
|
18
|
+
// caused agentic-SDLC runs to be blocked by the Copilot Coding Agent
|
|
19
|
+
// firewall, which allow-lists `https://export.arxiv.org/` (the canonical
|
|
20
|
+
// HTTPS form); a plain-http GET against the same host is a protocol-
|
|
21
|
+
// mismatch and rejected as `http block`. See B-PR1f forensics.
|
|
22
|
+
const DEFAULT_ENDPOINT = 'https://export.arxiv.org/api/query';
|
|
18
23
|
async function arxivSearch(opts) {
|
|
19
24
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
20
25
|
const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maintainabilityai/research-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Research + PRD agent runner — orchestrates the Archeologist and PRD pipelines for the MaintainabilityAI governance mesh",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "MaintainabilityAI",
|