@multileaf/ai-workflow 0.8.0 → 0.10.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/CHANGELOG.md +27 -0
- package/README.md +12 -1
- package/dist/blocking-reasons.d.ts +67 -0
- package/dist/blocking-reasons.js +181 -0
- package/dist/command-evidence.d.ts +32 -0
- package/dist/command-evidence.js +61 -0
- package/dist/heartbeat.d.ts +61 -0
- package/dist/heartbeat.js +158 -0
- package/dist/hook-installer.d.ts +30 -0
- package/dist/hook-installer.js +52 -0
- package/dist/human-approval.d.ts +16 -1
- package/dist/human-approval.js +69 -5
- package/dist/recommendations.js +16 -1
- package/dist/risk-assess.d.ts +50 -0
- package/dist/risk-assess.js +273 -0
- package/dist/usage-import.d.ts +51 -0
- package/dist/usage-import.js +190 -0
- package/dist/usage-sidecar.d.ts +6 -0
- package/dist/usage-sidecar.js +14 -1
- package/dist/workflow.js +448 -24
- package/docs/README.md +2 -0
- package/docs/architecture.md +50 -0
- package/docs/backlog.md +13 -0
- package/docs/continuation-inventory.md +21 -0
- package/docs/decision-model-evaluation.md +4 -0
- package/docs/opportunities/OPP-001-proportional-ceremony-light-lane.md +62 -0
- package/docs/opportunities/OPP-002-risk-assess-command.md +70 -0
- package/docs/opportunities/OPP-003-adversarial-review-package.md +63 -0
- package/docs/opportunities/OPP-004-exact-continuation-commands.md +59 -0
- package/docs/opportunities/OPP-005-friction-benchmark.md +62 -0
- package/docs/opportunities/OPP-006-emission-usability-test-policy.md +57 -0
- package/docs/opportunities/OPP-007-decision-findings-triage-scope-watch.md +54 -0
- package/docs/opportunities/OPP-008-embedding-covering-tests-judge-context.md +52 -0
- package/docs/opportunities/README.md +33 -0
- package/docs/product-requirements.md +3 -0
- package/docs/product-vision.md +1 -0
- package/docs/quality.md +10 -0
- package/docs/release-process.md +9 -1
- package/docs/semantic-mapping.md +4 -0
- package/docs/token-efficiency.md +53 -0
- package/package.json +1 -1
- package/resources/package.yaml +3 -0
- package/resources/skills/code-review.md +2 -0
- package/resources/skills/review-judge-a.md +24 -0
- package/resources/skills/review-judge-b.md +24 -0
- package/resources/skills/review-refuter.md +28 -0
- package/resources/skills/ticket.md +2 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FileSystem } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code hook installer: wires harness events to `aiw heartbeat`
|
|
4
|
+
* without a scheduler. Project-scoped: writes `.claude/settings.json` in
|
|
5
|
+
* the repository, never the user's global settings.
|
|
6
|
+
*
|
|
7
|
+
* Merge policy: existing hooks the project already declares are preserved
|
|
8
|
+
* untouched; AIW only appends its own entries, identified by the
|
|
9
|
+
* `aiw heartbeat` command, and skips them when already present
|
|
10
|
+
* (idempotent). Any JSON that is not a settings object is a hard conflict.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CLAUDE_HOOK_COMMAND = "npx --yes --package=@multileaf/ai-workflow -- aiw heartbeat";
|
|
13
|
+
export type ClaudeHookEntry = {
|
|
14
|
+
hooks: {
|
|
15
|
+
type: string;
|
|
16
|
+
command: string;
|
|
17
|
+
timeout?: number;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
export type ClaudeSettings = {
|
|
21
|
+
hooks?: Record<string, ClaudeHookEntry[]>;
|
|
22
|
+
} & Record<string, unknown>;
|
|
23
|
+
export declare const AIW_HEARTBEAT_EVENTS: readonly ["Stop", "UserPromptSubmit"];
|
|
24
|
+
export declare function parseClaudeSettings(content: string): ClaudeSettings;
|
|
25
|
+
export declare function installHeartbeatHooks(settings: ClaudeSettings): {
|
|
26
|
+
settings: ClaudeSettings;
|
|
27
|
+
added: string[];
|
|
28
|
+
alreadyPresent: string[];
|
|
29
|
+
};
|
|
30
|
+
export declare function writeClaudeSettings(fs: FileSystem, path: string, settings: ClaudeSettings): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook installer: wires harness events to `aiw heartbeat`
|
|
3
|
+
* without a scheduler. Project-scoped: writes `.claude/settings.json` in
|
|
4
|
+
* the repository, never the user's global settings.
|
|
5
|
+
*
|
|
6
|
+
* Merge policy: existing hooks the project already declares are preserved
|
|
7
|
+
* untouched; AIW only appends its own entries, identified by the
|
|
8
|
+
* `aiw heartbeat` command, and skips them when already present
|
|
9
|
+
* (idempotent). Any JSON that is not a settings object is a hard conflict.
|
|
10
|
+
*/
|
|
11
|
+
export const CLAUDE_HOOK_COMMAND = "npx --yes --package=@multileaf/ai-workflow -- aiw heartbeat";
|
|
12
|
+
export const AIW_HEARTBEAT_EVENTS = ["Stop", "UserPromptSubmit"];
|
|
13
|
+
export function parseClaudeSettings(content) {
|
|
14
|
+
if (!content.trim())
|
|
15
|
+
return {};
|
|
16
|
+
const parsed = JSON.parse(content);
|
|
17
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
18
|
+
throw new Error(".claude/settings.json must be a JSON object.");
|
|
19
|
+
const settings = parsed;
|
|
20
|
+
if (settings.hooks !== undefined) {
|
|
21
|
+
if (typeof settings.hooks !== "object" ||
|
|
22
|
+
settings.hooks === null ||
|
|
23
|
+
Array.isArray(settings.hooks))
|
|
24
|
+
throw new Error(".claude/settings.json hooks must be an object keyed by event name.");
|
|
25
|
+
for (const [event, entries] of Object.entries(settings.hooks))
|
|
26
|
+
if (!Array.isArray(entries))
|
|
27
|
+
throw new Error(`.claude/settings.json hooks.${event} must be a list.`);
|
|
28
|
+
}
|
|
29
|
+
return settings;
|
|
30
|
+
}
|
|
31
|
+
export function installHeartbeatHooks(settings) {
|
|
32
|
+
const hooks = { ...(settings.hooks ?? {}) };
|
|
33
|
+
const added = [];
|
|
34
|
+
const alreadyPresent = [];
|
|
35
|
+
for (const event of AIW_HEARTBEAT_EVENTS) {
|
|
36
|
+
const entries = hooks[event] ? [...hooks[event]] : [];
|
|
37
|
+
const exists = entries.some((entry) => entry.hooks?.some((hook) => hook.command === CLAUDE_HOOK_COMMAND));
|
|
38
|
+
if (exists)
|
|
39
|
+
alreadyPresent.push(event);
|
|
40
|
+
else {
|
|
41
|
+
entries.push({
|
|
42
|
+
hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND, timeout: 15 }],
|
|
43
|
+
});
|
|
44
|
+
added.push(event);
|
|
45
|
+
}
|
|
46
|
+
hooks[event] = entries;
|
|
47
|
+
}
|
|
48
|
+
return { settings: { ...settings, hooks }, added, alreadyPresent };
|
|
49
|
+
}
|
|
50
|
+
export function writeClaudeSettings(fs, path, settings) {
|
|
51
|
+
fs.write(path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
52
|
+
}
|
package/dist/human-approval.d.ts
CHANGED
|
@@ -13,6 +13,9 @@ export type ApprovalLedger = {
|
|
|
13
13
|
schema: 1;
|
|
14
14
|
workflowId: string;
|
|
15
15
|
stages: Partial<Record<HumanApprovalStage, StageApprovalRecord>>;
|
|
16
|
+
/** Light-lane provenance so audits see the rationale instead of missing approvals. */
|
|
17
|
+
lane?: "light";
|
|
18
|
+
laneReason?: string;
|
|
16
19
|
};
|
|
17
20
|
export type WorkflowSession = {
|
|
18
21
|
schema: 1;
|
|
@@ -22,13 +25,25 @@ export type WorkflowSession = {
|
|
|
22
25
|
updatedAt: string;
|
|
23
26
|
ticket?: string;
|
|
24
27
|
title?: string;
|
|
28
|
+
/** Light lane marker; absent means the full lane (REQ-OPP-001). */
|
|
29
|
+
lane?: "light";
|
|
30
|
+
/** Recorded rationale for using the light lane (the resume-test call). */
|
|
31
|
+
laneReason?: string;
|
|
32
|
+
/** Presence means the workflow escalated to the full lane (effective lane: full). */
|
|
33
|
+
laneEscalatedAt?: string;
|
|
34
|
+
laneEscalationReason?: string;
|
|
25
35
|
};
|
|
36
|
+
/** The lane that currently governs stage commands: escalation moves light → full. */
|
|
37
|
+
export declare function effectiveLane(session: WorkflowSession): "full" | "light";
|
|
26
38
|
export declare function validateTaskTicket(ticket: string): void;
|
|
27
39
|
export type ApprovalStageDefinition = {
|
|
28
40
|
optional: boolean;
|
|
29
41
|
requiresQualityGate: boolean;
|
|
30
42
|
};
|
|
31
|
-
export declare function createApprovalLedger(workflowId: string
|
|
43
|
+
export declare function createApprovalLedger(workflowId: string, options?: {
|
|
44
|
+
lane?: "light";
|
|
45
|
+
laneReason?: string;
|
|
46
|
+
}): ApprovalLedger;
|
|
32
47
|
export declare function serializeWorkflowSession(session: WorkflowSession): string;
|
|
33
48
|
export declare function parseWorkflowSession(content: string): WorkflowSession;
|
|
34
49
|
export declare function stageDefinition(stage: HumanApprovalStage): ApprovalStageDefinition;
|
package/dist/human-approval.js
CHANGED
|
@@ -2,6 +2,10 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { parseDocument, stringify } from "yaml";
|
|
3
3
|
import { HUMAN_APPROVAL_STAGES } from "./types.js";
|
|
4
4
|
export const APPROVAL_STAGES = HUMAN_APPROVAL_STAGES;
|
|
5
|
+
/** The lane that currently governs stage commands: escalation moves light → full. */
|
|
6
|
+
export function effectiveLane(session) {
|
|
7
|
+
return session.lane === "light" && !session.laneEscalatedAt ? "light" : "full";
|
|
8
|
+
}
|
|
5
9
|
export function validateTaskTicket(ticket) {
|
|
6
10
|
if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(ticket))
|
|
7
11
|
throw new Error("Task ticket must match TEAM-123, for example PROJ-142.");
|
|
@@ -25,7 +29,7 @@ const APPROVAL_RECORD_KEYS = new Set([
|
|
|
25
29
|
"reason",
|
|
26
30
|
"updatedAt",
|
|
27
31
|
]);
|
|
28
|
-
const LEDGER_KEYS = new Set(["schema", "workflowId", "stages"]);
|
|
32
|
+
const LEDGER_KEYS = new Set(["schema", "workflowId", "stages", "lane", "laneReason"]);
|
|
29
33
|
const SESSION_KEYS = new Set([
|
|
30
34
|
"schema",
|
|
31
35
|
"workflowId",
|
|
@@ -34,10 +38,23 @@ const SESSION_KEYS = new Set([
|
|
|
34
38
|
"updatedAt",
|
|
35
39
|
"ticket",
|
|
36
40
|
"title",
|
|
41
|
+
"lane",
|
|
42
|
+
"laneReason",
|
|
43
|
+
"laneEscalatedAt",
|
|
44
|
+
"laneEscalationReason",
|
|
37
45
|
]);
|
|
38
|
-
export function createApprovalLedger(workflowId) {
|
|
46
|
+
export function createApprovalLedger(workflowId, options = {}) {
|
|
39
47
|
assertWorkflowId(workflowId);
|
|
40
|
-
|
|
48
|
+
if (options.lane === "light" && !options.laneReason)
|
|
49
|
+
throw new Error("Light-lane ledgers require a recorded lane reason.");
|
|
50
|
+
return {
|
|
51
|
+
schema: 1,
|
|
52
|
+
workflowId,
|
|
53
|
+
stages: {},
|
|
54
|
+
...(options.lane === "light" && options.laneReason
|
|
55
|
+
? { lane: "light", laneReason: options.laneReason }
|
|
56
|
+
: {}),
|
|
57
|
+
};
|
|
41
58
|
}
|
|
42
59
|
export function serializeWorkflowSession(session) {
|
|
43
60
|
validateWorkflowSession(session);
|
|
@@ -65,6 +82,20 @@ export function parseWorkflowSession(content) {
|
|
|
65
82
|
throw new Error("Workflow session title must be a string.");
|
|
66
83
|
if (typeof value.ticket === "string")
|
|
67
84
|
validateTaskTicket(value.ticket);
|
|
85
|
+
const lane = value.lane === undefined ? undefined : value.lane;
|
|
86
|
+
if (lane !== undefined && lane !== "light")
|
|
87
|
+
throw new Error("Workflow session lane must be light when present.");
|
|
88
|
+
const laneReason = value.laneReason;
|
|
89
|
+
if (laneReason !== undefined && typeof laneReason !== "string")
|
|
90
|
+
throw new Error("Workflow session lane reason must be a string.");
|
|
91
|
+
if (lane === "light" && !laneReason)
|
|
92
|
+
throw new Error("Light-lane sessions require a lane reason.");
|
|
93
|
+
const laneEscalatedAt = value.laneEscalatedAt;
|
|
94
|
+
if (laneEscalatedAt !== undefined && typeof laneEscalatedAt !== "string")
|
|
95
|
+
throw new Error("Workflow session lane escalation timestamp must be a string.");
|
|
96
|
+
const laneEscalationReason = value.laneEscalationReason;
|
|
97
|
+
if (laneEscalationReason !== undefined && typeof laneEscalationReason !== "string")
|
|
98
|
+
throw new Error("Workflow session lane escalation reason must be a string.");
|
|
68
99
|
const session = {
|
|
69
100
|
schema: 1,
|
|
70
101
|
workflowId: value.workflowId,
|
|
@@ -73,6 +104,10 @@ export function parseWorkflowSession(content) {
|
|
|
73
104
|
updatedAt: value.updatedAt,
|
|
74
105
|
...(value.ticket === undefined ? {} : { ticket: value.ticket }),
|
|
75
106
|
...(value.title === undefined ? {} : { title: value.title }),
|
|
107
|
+
...(lane === undefined ? {} : { lane }),
|
|
108
|
+
...(laneReason === undefined ? {} : { laneReason }),
|
|
109
|
+
...(laneEscalatedAt === undefined ? {} : { laneEscalatedAt }),
|
|
110
|
+
...(laneEscalationReason === undefined ? {} : { laneEscalationReason }),
|
|
76
111
|
};
|
|
77
112
|
validateWorkflowSession(session);
|
|
78
113
|
return session;
|
|
@@ -168,7 +203,14 @@ export function invalidateStageAndDescendants(ledger, stage, updatedAt) {
|
|
|
168
203
|
export function serializeApprovalLedger(ledger) {
|
|
169
204
|
validateLedger(ledger);
|
|
170
205
|
const stages = Object.fromEntries(APPROVAL_STAGES.flatMap((stage) => ledger.stages[stage] ? [[stage, ledger.stages[stage]]] : []));
|
|
171
|
-
return stringify({
|
|
206
|
+
return stringify({
|
|
207
|
+
schema: 1,
|
|
208
|
+
workflowId: ledger.workflowId,
|
|
209
|
+
stages,
|
|
210
|
+
...(ledger.lane === "light" && ledger.laneReason
|
|
211
|
+
? { lane: ledger.lane, laneReason: ledger.laneReason }
|
|
212
|
+
: {}),
|
|
213
|
+
}, { lineWidth: 0 });
|
|
172
214
|
}
|
|
173
215
|
export function parseApprovalLedger(content) {
|
|
174
216
|
const document = parseDocument(content, { uniqueKeys: true, strict: true });
|
|
@@ -191,7 +233,14 @@ export function parseApprovalLedger(content) {
|
|
|
191
233
|
throw new Error(`Approval ledger has unknown workflow stage: ${stageName}.`);
|
|
192
234
|
stages[stageName] = parseStageRecord(stageName, record);
|
|
193
235
|
}
|
|
194
|
-
const ledger = {
|
|
236
|
+
const ledger = {
|
|
237
|
+
schema: 1,
|
|
238
|
+
workflowId: value.workflowId,
|
|
239
|
+
stages,
|
|
240
|
+
...(value.lane === "light" && typeof value.laneReason === "string"
|
|
241
|
+
? { lane: "light", laneReason: value.laneReason }
|
|
242
|
+
: {}),
|
|
243
|
+
};
|
|
195
244
|
validateLedger(ledger);
|
|
196
245
|
return ledger;
|
|
197
246
|
}
|
|
@@ -221,6 +270,17 @@ function validateWorkflowSession(session) {
|
|
|
221
270
|
throw new Error("Workflow session status must be active or complete.");
|
|
222
271
|
assertTimestamp(session.startedAt);
|
|
223
272
|
assertTimestamp(session.updatedAt);
|
|
273
|
+
if (session.lane !== undefined && session.lane !== "light")
|
|
274
|
+
throw new Error("Workflow session lane must be light when present.");
|
|
275
|
+
if (session.lane === "light" && !session.laneReason)
|
|
276
|
+
throw new Error("Light-lane sessions require a lane reason.");
|
|
277
|
+
if (session.laneEscalatedAt !== undefined) {
|
|
278
|
+
assertTimestamp(session.laneEscalatedAt);
|
|
279
|
+
if (session.lane !== "light")
|
|
280
|
+
throw new Error("Lane escalation is only meaningful on light-lane sessions.");
|
|
281
|
+
if (!session.laneEscalationReason)
|
|
282
|
+
throw new Error("Lane escalation requires a reason.");
|
|
283
|
+
}
|
|
224
284
|
}
|
|
225
285
|
function withStage(ledger, stage, record) {
|
|
226
286
|
return {
|
|
@@ -287,6 +347,10 @@ function validateLedger(ledger) {
|
|
|
287
347
|
if (ledger.schema !== 1 || !isRecord(ledger.stages))
|
|
288
348
|
throw new Error("Approval ledger schema must be version 1 with a stages mapping.");
|
|
289
349
|
assertWorkflowId(ledger.workflowId);
|
|
350
|
+
if (ledger.lane !== undefined && ledger.lane !== "light")
|
|
351
|
+
throw new Error("Approval ledger lane must be light when present.");
|
|
352
|
+
if (ledger.lane === "light" && !ledger.laneReason)
|
|
353
|
+
throw new Error("Light-lane ledgers require a lane reason.");
|
|
290
354
|
for (const [stage, record] of Object.entries(ledger.stages)) {
|
|
291
355
|
if (!isApprovalStage(stage))
|
|
292
356
|
throw new Error(`Approval ledger has unknown workflow stage: ${stage}.`);
|
package/dist/recommendations.js
CHANGED
|
@@ -3,6 +3,7 @@ const CATALOG = [
|
|
|
3
3
|
{
|
|
4
4
|
id: "brainstorming",
|
|
5
5
|
provider: "multileaf",
|
|
6
|
+
universal: true,
|
|
6
7
|
rationale: "A project-specific brainstorming flow helps clarify goals and constraints before implementation.",
|
|
7
8
|
permissions: [],
|
|
8
9
|
resources: local([
|
|
@@ -17,6 +18,7 @@ const CATALOG = [
|
|
|
17
18
|
{
|
|
18
19
|
id: "ticket-driven-development",
|
|
19
20
|
provider: "multileaf",
|
|
21
|
+
universal: true,
|
|
20
22
|
rationale: "Work often arrives as a ticket or an already-clear requirement; this entry point starts the SDD flow from the ticket without a brainstorming phase.",
|
|
21
23
|
permissions: [],
|
|
22
24
|
resources: local([
|
|
@@ -30,6 +32,7 @@ const CATALOG = [
|
|
|
30
32
|
{
|
|
31
33
|
id: "requirements-specification",
|
|
32
34
|
provider: "multileaf",
|
|
35
|
+
universal: true,
|
|
33
36
|
rationale: "The project can use explicit requirements and acceptance criteria before changes are planned.",
|
|
34
37
|
permissions: [],
|
|
35
38
|
resources: local([
|
|
@@ -44,6 +47,7 @@ const CATALOG = [
|
|
|
44
47
|
{
|
|
45
48
|
id: "implementation-planning",
|
|
46
49
|
provider: "multileaf",
|
|
50
|
+
universal: true,
|
|
47
51
|
rationale: "A traceable implementation plan can connect project requirements, tests, risks, and validation.",
|
|
48
52
|
permissions: [],
|
|
49
53
|
resources: local([
|
|
@@ -59,6 +63,7 @@ const CATALOG = [
|
|
|
59
63
|
{
|
|
60
64
|
id: "technical-design",
|
|
61
65
|
provider: "multileaf",
|
|
66
|
+
universal: true,
|
|
62
67
|
rationale: "Architecture changes benefit from explicit alternatives, trade-offs, and decision records.",
|
|
63
68
|
permissions: [],
|
|
64
69
|
resources: local([
|
|
@@ -74,6 +79,7 @@ const CATALOG = [
|
|
|
74
79
|
{
|
|
75
80
|
id: "code-review",
|
|
76
81
|
provider: "multileaf",
|
|
82
|
+
universal: true,
|
|
77
83
|
rationale: "A consistent review checklist helps validate changes against this repository's conventions.",
|
|
78
84
|
permissions: [],
|
|
79
85
|
resources: local([
|
|
@@ -164,6 +170,7 @@ const CATALOG = [
|
|
|
164
170
|
{
|
|
165
171
|
id: "verification",
|
|
166
172
|
provider: "multileaf",
|
|
173
|
+
universal: true,
|
|
167
174
|
rationale: "Verification guidance can use the project's detected test runner and quality commands.",
|
|
168
175
|
permissions: [],
|
|
169
176
|
resources: local([
|
|
@@ -241,7 +248,15 @@ const CATALOG = [
|
|
|
241
248
|
];
|
|
242
249
|
export function recommendCapabilities(profile) {
|
|
243
250
|
return CATALOG.flatMap((capability) => {
|
|
244
|
-
const match = capability.matches(profile)
|
|
251
|
+
const match = capability.matches(profile) ??
|
|
252
|
+
(capability.universal
|
|
253
|
+
? {
|
|
254
|
+
evidence: [
|
|
255
|
+
"Workflow capability: applies to any project, with or without detected signals.",
|
|
256
|
+
],
|
|
257
|
+
confidence: 0.9,
|
|
258
|
+
}
|
|
259
|
+
: undefined);
|
|
245
260
|
return match
|
|
246
261
|
? [
|
|
247
262
|
{
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, content-based risk classification for changed paths (OPP-002,
|
|
3
|
+
* REQ-OPP-002). Risk is classified by WHAT changed, never by how much: a
|
|
4
|
+
* documentation-only diff is passive; two lines of authentication code are
|
|
5
|
+
* high. Rules are path-based approximations, deliberately biased toward
|
|
6
|
+
* `high` — a failed or uncertain assessment never implies low risk.
|
|
7
|
+
*
|
|
8
|
+
* The six high-risk items follow the escalation list of the source concept
|
|
9
|
+
* (RDD `review assess`): 1 data/irreversible effects, 2 security,
|
|
10
|
+
* 3 contracts others consume, 4 concurrency, 5 delivery/environment,
|
|
11
|
+
* 6 no test would catch a regression.
|
|
12
|
+
*/
|
|
13
|
+
import type { CommandResult, FileSystem } from "./types.js";
|
|
14
|
+
export type RiskTier = "passive" | "medium" | "high";
|
|
15
|
+
export type RiskCategoryCode = "data_irreversible" | "security" | "consumed_contract" | "concurrency" | "delivery_environment" | "untested_regression";
|
|
16
|
+
export type RiskReason = {
|
|
17
|
+
code: RiskCategoryCode | "agent_escalation";
|
|
18
|
+
path?: string;
|
|
19
|
+
detail?: string;
|
|
20
|
+
};
|
|
21
|
+
export type ChangedFile = {
|
|
22
|
+
path: string;
|
|
23
|
+
};
|
|
24
|
+
export type RiskCategory = {
|
|
25
|
+
item: 1 | 2 | 3 | 4 | 5 | 6;
|
|
26
|
+
code: RiskCategoryCode;
|
|
27
|
+
description: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const RISK_CATEGORIES: readonly RiskCategory[];
|
|
30
|
+
export type AssessOptions = {
|
|
31
|
+
/**
|
|
32
|
+
* Predicate telling whether a changed source file has a covering test.
|
|
33
|
+
* When absent, the untested_regression category is not asserted
|
|
34
|
+
* (unknown is not low; the tier then rests on the other categories).
|
|
35
|
+
*/
|
|
36
|
+
hasCoveringTest?: (path: string) => boolean;
|
|
37
|
+
};
|
|
38
|
+
export type RiskAssessment = {
|
|
39
|
+
tier: RiskTier;
|
|
40
|
+
reasons: RiskReason[];
|
|
41
|
+
changedPaths: string[];
|
|
42
|
+
};
|
|
43
|
+
export declare function assessRisk(changes: readonly ChangedFile[], options?: AssessOptions): RiskAssessment;
|
|
44
|
+
export declare function runRiskAssess(args: string[], root: string, fs: FileSystem): Promise<CommandResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Agent escalation (RDD parity): an agent may raise — never lower — the tier
|
|
47
|
+
* citing one of the six high-risk items. Item must be 1..6; the reason is a
|
|
48
|
+
* non-empty single line of at most 500 bytes.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyEscalation(assessment: RiskAssessment, item: number, reason: string): RiskAssessment;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, content-based risk classification for changed paths (OPP-002,
|
|
3
|
+
* REQ-OPP-002). Risk is classified by WHAT changed, never by how much: a
|
|
4
|
+
* documentation-only diff is passive; two lines of authentication code are
|
|
5
|
+
* high. Rules are path-based approximations, deliberately biased toward
|
|
6
|
+
* `high` — a failed or uncertain assessment never implies low risk.
|
|
7
|
+
*
|
|
8
|
+
* The six high-risk items follow the escalation list of the source concept
|
|
9
|
+
* (RDD `review assess`): 1 data/irreversible effects, 2 security,
|
|
10
|
+
* 3 contracts others consume, 4 concurrency, 5 delivery/environment,
|
|
11
|
+
* 6 no test would catch a regression.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
export const RISK_CATEGORIES = [
|
|
16
|
+
{
|
|
17
|
+
item: 1,
|
|
18
|
+
code: "data_irreversible",
|
|
19
|
+
description: "Data or irreversible effects: migrations, schema, destructive data operations.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
item: 2,
|
|
23
|
+
code: "security",
|
|
24
|
+
description: "Security: authentication, authorization, credentials, secrets, cryptography, sessions, permissions.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
item: 3,
|
|
28
|
+
code: "consumed_contract",
|
|
29
|
+
description: "Contracts others consume: package manifests, public type declarations, API schemas.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
item: 4,
|
|
33
|
+
code: "concurrency",
|
|
34
|
+
description: "Concurrency: workers, pools, locks, queues, and other shared-state primitives.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
item: 5,
|
|
38
|
+
code: "delivery_environment",
|
|
39
|
+
description: "Delivery or environment: CI pipelines, containers, deployment, release and install scripts.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
item: 6,
|
|
43
|
+
code: "untested_regression",
|
|
44
|
+
description: "No test would catch a regression: changed source with no covering test detected.",
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Path rules are intentionally broad: over-flagging costs one review, while
|
|
49
|
+
* under-flagging ships unreviewed risk. Test files are excluded before
|
|
50
|
+
* matching so test-only changes stay out of the high categories.
|
|
51
|
+
*/
|
|
52
|
+
const CATEGORY_RULES = [
|
|
53
|
+
{
|
|
54
|
+
code: "data_irreversible",
|
|
55
|
+
pattern: /(^|\/)(migrations?|db\/migrate)\//i,
|
|
56
|
+
},
|
|
57
|
+
{ code: "data_irreversible", pattern: /migration|schema\.prisma$/i },
|
|
58
|
+
{
|
|
59
|
+
code: "security",
|
|
60
|
+
pattern: /(^|\/)(auth|login|credential|secret|token|password|session|permission|acl)\b|(^|\/)(crypto|ssl|tls|jwt)|\bsecurity\b|\.env/i,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: "consumed_contract",
|
|
64
|
+
pattern: /(^|\/)(package|pyproject|cargo|go)\.(json|toml)$|\.d\.ts$|(^|\/)(api|openapi|swagger)(\/|$)|\.graphql$|\.proto$/i,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
code: "concurrency",
|
|
68
|
+
pattern: /worker|pool|lock|queue|mutex|atomic|parallel|thread|scheduler/i,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
code: "delivery_environment",
|
|
72
|
+
pattern: /(^|\/)\.github\/workflows\/|dockerfile|docker-compose|jenkinsfile|gitlab-ci|(^|\/)(ci|deploy|helm|k8s|terraform)(\/|\.|$)|(^|\/)scripts\/(release|install|deploy)|(^|\/)release-|\.npmrc$|^makefile$/i,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
/** Documentation is passive by its own nature (path approximation). */
|
|
76
|
+
const PASSIVE_PATTERN = /\.(md|rst|adoc|txt)|(^|\/)(docs?|documentation)(\/|$)|(^|\/)(license|licence|changelog|notice|authors|contributors)(\.|$)/i;
|
|
77
|
+
const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$|(^|\/)(__tests__|tests?|e2e)(\/|$)/i;
|
|
78
|
+
const SOURCE_FILE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|sql|sh)$/i;
|
|
79
|
+
/**
|
|
80
|
+
* Workflow-managed state (`.aiw/`) is never authored source: excluding it
|
|
81
|
+
* keeps `aiw`'s own lockfiles, checkpoints, and generated artifacts out of
|
|
82
|
+
* the risk picture (a real-flow defect: `.aiw/lock.yml` matched "lock").
|
|
83
|
+
*/
|
|
84
|
+
const WORKFLOW_STATE_PATTERN = /(^|\/)\.aiw\//;
|
|
85
|
+
export function assessRisk(changes, options = {}) {
|
|
86
|
+
const reasons = [];
|
|
87
|
+
let anyCode = false;
|
|
88
|
+
const isTestCovered = options.hasCoveringTest;
|
|
89
|
+
for (const change of changes) {
|
|
90
|
+
if (WORKFLOW_STATE_PATTERN.test(change.path))
|
|
91
|
+
continue;
|
|
92
|
+
const isPassive = PASSIVE_PATTERN.test(change.path);
|
|
93
|
+
const isTest = TEST_FILE_PATTERN.test(change.path);
|
|
94
|
+
if (!isPassive)
|
|
95
|
+
anyCode = true;
|
|
96
|
+
// Passive documentation never ships executable risk; test files are
|
|
97
|
+
// evidence, not surface. Neither triggers the high-risk categories.
|
|
98
|
+
if (isPassive || isTest)
|
|
99
|
+
continue;
|
|
100
|
+
for (const rule of CATEGORY_RULES) {
|
|
101
|
+
if (rule.pattern.test(change.path))
|
|
102
|
+
reasons.push({ code: rule.code, path: change.path });
|
|
103
|
+
}
|
|
104
|
+
if (isTestCovered && SOURCE_FILE_PATTERN.test(change.path) && !isTestCovered(change.path)) {
|
|
105
|
+
reasons.push({ code: "untested_regression", path: change.path });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const tier = reasons.length > 0 ? "high" : anyCode ? "medium" : "passive";
|
|
109
|
+
return { tier, reasons, changedPaths: changes.map((change) => change.path) };
|
|
110
|
+
}
|
|
111
|
+
const CATEGORY_BY_ITEM = new Map(RISK_CATEGORIES.map((category) => [category.item, category]));
|
|
112
|
+
const RISK_ASSESS_SCHEMA = "aiw.risk-assessment/v1";
|
|
113
|
+
const RISK_ASSESS_USAGE = "Usage: aiw risk assess [--base-ref=ref] [--ticket=ID] [--escalate-item=1-6 --escalate-reason=text] [--json]";
|
|
114
|
+
/** Raw git output lines: no per-line trimming — porcelain columns are fixed-width. */
|
|
115
|
+
function gitRawLines(root, args) {
|
|
116
|
+
try {
|
|
117
|
+
const output = execFileSync("git", args, {
|
|
118
|
+
cwd: root,
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
121
|
+
});
|
|
122
|
+
return output
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((line) => line.replace(/\r$/, ""))
|
|
125
|
+
.filter((line) => line.length > 0);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function gitLines(root, args) {
|
|
132
|
+
return gitRawLines(root, args)?.map((line) => line.trim());
|
|
133
|
+
}
|
|
134
|
+
/** Working-tree changes from `git status --porcelain` (staged, unstaged, untracked). */
|
|
135
|
+
function workingTreeChanges(root) {
|
|
136
|
+
const lines = gitRawLines(root, ["status", "--porcelain", "--untracked-files=all"]);
|
|
137
|
+
if (!lines)
|
|
138
|
+
return undefined;
|
|
139
|
+
return lines.map((line) => {
|
|
140
|
+
const path = line.slice(3).trim();
|
|
141
|
+
const renameArrow = path.indexOf(" -> ");
|
|
142
|
+
return renameArrow === -1 ? path : path.slice(renameArrow + 4);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function committedRangeChanges(root, baseRef) {
|
|
146
|
+
return gitLines(root, ["diff", "--name-only", `${baseRef}...HEAD`]);
|
|
147
|
+
}
|
|
148
|
+
/** Deterministic covering-test probe: sibling *.test.* / *.spec.* variants. */
|
|
149
|
+
function hasCoveringTest(root, fs) {
|
|
150
|
+
return (path) => {
|
|
151
|
+
const directory = path.slice(0, path.lastIndexOf("/"));
|
|
152
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
153
|
+
const dot = base.lastIndexOf(".");
|
|
154
|
+
const stem = dot === -1 ? base : base.slice(0, dot);
|
|
155
|
+
const extension = dot === -1 ? "" : base.slice(dot);
|
|
156
|
+
const candidates = [`${stem}.test${extension}`, `${stem}.spec${extension}`].map((name) => join(root, directory, name));
|
|
157
|
+
return candidates.some((candidate) => fs.exists(candidate));
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function parseRiskAssessArgs(rest) {
|
|
161
|
+
const parsed = {};
|
|
162
|
+
for (const argument of rest) {
|
|
163
|
+
const match = argument.match(/^--([a-z-]+)=(.*)$/);
|
|
164
|
+
if (!match)
|
|
165
|
+
return undefined;
|
|
166
|
+
const [, name, value] = match;
|
|
167
|
+
if (name === "base-ref")
|
|
168
|
+
parsed.baseRef = value;
|
|
169
|
+
else if (name === "ticket")
|
|
170
|
+
parsed.ticket = value;
|
|
171
|
+
else if (name === "escalate-item") {
|
|
172
|
+
if (!/^\d+$/.test(value))
|
|
173
|
+
return undefined;
|
|
174
|
+
parsed.escalateItem = Number(value);
|
|
175
|
+
}
|
|
176
|
+
else if (name === "escalate-reason")
|
|
177
|
+
parsed.escalateReason = value;
|
|
178
|
+
else
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
if ((parsed.escalateItem === undefined) !== (parsed.escalateReason === undefined) ||
|
|
182
|
+
(parsed.ticket !== undefined && parsed.ticket.length === 0))
|
|
183
|
+
return undefined;
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
186
|
+
export async function runRiskAssess(args, root, fs) {
|
|
187
|
+
const jsonRequested = args.includes("--json");
|
|
188
|
+
const parsed = parseRiskAssessArgs(args.filter((argument) => argument !== "--json"));
|
|
189
|
+
if (!parsed)
|
|
190
|
+
return { exitCode: 1, error: RISK_ASSESS_USAGE };
|
|
191
|
+
const insideWorkTree = gitLines(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
192
|
+
if (!insideWorkTree || insideWorkTree[0] !== "true")
|
|
193
|
+
return {
|
|
194
|
+
exitCode: 1,
|
|
195
|
+
error: "risk assess requires a git repository; run it inside one or pass --base-ref for a committed range.",
|
|
196
|
+
};
|
|
197
|
+
const changedPaths = parsed.baseRef
|
|
198
|
+
? committedRangeChanges(root, parsed.baseRef)
|
|
199
|
+
: workingTreeChanges(root);
|
|
200
|
+
if (!changedPaths)
|
|
201
|
+
return {
|
|
202
|
+
exitCode: 1,
|
|
203
|
+
error: `git failed to enumerate changes${parsed.baseRef ? ` for --base-ref=${parsed.baseRef}` : ""}; verify the reference exists.`,
|
|
204
|
+
};
|
|
205
|
+
let assessment = assessRisk(changedPaths.map((path) => ({ path })), { hasCoveringTest: hasCoveringTest(root, fs) });
|
|
206
|
+
if (parsed.escalateItem !== undefined && parsed.escalateReason !== undefined) {
|
|
207
|
+
try {
|
|
208
|
+
assessment = applyEscalation(assessment, parsed.escalateItem, parsed.escalateReason);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const riskDue = assessment.tier === "high";
|
|
215
|
+
const activeWorkflow = fs.exists(join(root, ".aiw", "workflow.yml"));
|
|
216
|
+
const nextCommand = riskDue && !activeWorkflow ? "aiw workflow start" : undefined;
|
|
217
|
+
const nextAction = riskDue && activeWorkflow
|
|
218
|
+
? "Continue the active workflow: gate and approve the stages this change touches."
|
|
219
|
+
: undefined;
|
|
220
|
+
if (jsonRequested) {
|
|
221
|
+
const envelope = {
|
|
222
|
+
schema: RISK_ASSESS_SCHEMA,
|
|
223
|
+
risk: assessment.tier,
|
|
224
|
+
reasons: assessment.reasons,
|
|
225
|
+
changed_paths: assessment.changedPaths.length,
|
|
226
|
+
risk_due: riskDue,
|
|
227
|
+
};
|
|
228
|
+
if (parsed.ticket)
|
|
229
|
+
envelope.ticket = parsed.ticket;
|
|
230
|
+
if (nextCommand)
|
|
231
|
+
envelope.next_command = nextCommand;
|
|
232
|
+
if (nextAction)
|
|
233
|
+
envelope.next_action = nextAction;
|
|
234
|
+
return { exitCode: 0, output: JSON.stringify(envelope, null, 2) };
|
|
235
|
+
}
|
|
236
|
+
const lines = [`risk: ${assessment.tier}`];
|
|
237
|
+
for (const reason of assessment.reasons)
|
|
238
|
+
lines.push(` - ${reason.code}${reason.path ? `: ${reason.path}` : reason.detail ? ` (${reason.detail})` : ""}`);
|
|
239
|
+
const next = nextCommand
|
|
240
|
+
? `risk due: yes -> ${nextCommand}`
|
|
241
|
+
: nextAction
|
|
242
|
+
? `risk due: yes -> ${nextAction}`
|
|
243
|
+
: "risk due: no";
|
|
244
|
+
lines.push(next);
|
|
245
|
+
return { exitCode: 0, output: lines.join("\n") };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Agent escalation (RDD parity): an agent may raise — never lower — the tier
|
|
249
|
+
* citing one of the six high-risk items. Item must be 1..6; the reason is a
|
|
250
|
+
* non-empty single line of at most 500 bytes.
|
|
251
|
+
*/
|
|
252
|
+
export function applyEscalation(assessment, item, reason) {
|
|
253
|
+
if (!Number.isInteger(item) || item < 1 || item > 6)
|
|
254
|
+
throw new Error("Escalation item must be an integer from 1 to 6.");
|
|
255
|
+
if (reason.length === 0)
|
|
256
|
+
throw new Error("Escalation reason must not be empty.");
|
|
257
|
+
if (reason.length > 500)
|
|
258
|
+
throw new Error("Escalation reason must be at most 500 bytes on one line.");
|
|
259
|
+
if (/[\r\n]/.test(reason))
|
|
260
|
+
throw new Error("Escalation reason must be a single line.");
|
|
261
|
+
const category = CATEGORY_BY_ITEM.get(item);
|
|
262
|
+
if (!category)
|
|
263
|
+
throw new Error("Escalation item must be an integer from 1 to 6.");
|
|
264
|
+
const escalation = {
|
|
265
|
+
code: "agent_escalation",
|
|
266
|
+
detail: `item ${item}: ${category.code} — ${reason}`,
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
...assessment,
|
|
270
|
+
tier: "high",
|
|
271
|
+
reasons: [...assessment.reasons, escalation],
|
|
272
|
+
};
|
|
273
|
+
}
|