@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +12 -1
  3. package/dist/blocking-reasons.d.ts +67 -0
  4. package/dist/blocking-reasons.js +181 -0
  5. package/dist/command-evidence.d.ts +32 -0
  6. package/dist/command-evidence.js +61 -0
  7. package/dist/heartbeat.d.ts +61 -0
  8. package/dist/heartbeat.js +158 -0
  9. package/dist/hook-installer.d.ts +30 -0
  10. package/dist/hook-installer.js +52 -0
  11. package/dist/human-approval.d.ts +16 -1
  12. package/dist/human-approval.js +69 -5
  13. package/dist/recommendations.js +16 -1
  14. package/dist/risk-assess.d.ts +50 -0
  15. package/dist/risk-assess.js +273 -0
  16. package/dist/usage-import.d.ts +51 -0
  17. package/dist/usage-import.js +190 -0
  18. package/dist/usage-sidecar.d.ts +6 -0
  19. package/dist/usage-sidecar.js +14 -1
  20. package/dist/workflow.js +448 -24
  21. package/docs/README.md +2 -0
  22. package/docs/architecture.md +50 -0
  23. package/docs/backlog.md +13 -0
  24. package/docs/continuation-inventory.md +21 -0
  25. package/docs/decision-model-evaluation.md +4 -0
  26. package/docs/opportunities/OPP-001-proportional-ceremony-light-lane.md +62 -0
  27. package/docs/opportunities/OPP-002-risk-assess-command.md +70 -0
  28. package/docs/opportunities/OPP-003-adversarial-review-package.md +63 -0
  29. package/docs/opportunities/OPP-004-exact-continuation-commands.md +59 -0
  30. package/docs/opportunities/OPP-005-friction-benchmark.md +62 -0
  31. package/docs/opportunities/OPP-006-emission-usability-test-policy.md +57 -0
  32. package/docs/opportunities/OPP-007-decision-findings-triage-scope-watch.md +54 -0
  33. package/docs/opportunities/OPP-008-embedding-covering-tests-judge-context.md +52 -0
  34. package/docs/opportunities/README.md +33 -0
  35. package/docs/product-requirements.md +3 -0
  36. package/docs/product-vision.md +1 -0
  37. package/docs/quality.md +10 -0
  38. package/docs/release-process.md +9 -1
  39. package/docs/semantic-mapping.md +4 -0
  40. package/docs/token-efficiency.md +53 -0
  41. package/package.json +1 -1
  42. package/resources/package.yaml +3 -0
  43. package/resources/skills/code-review.md +2 -0
  44. package/resources/skills/review-judge-a.md +24 -0
  45. package/resources/skills/review-judge-b.md +24 -0
  46. package/resources/skills/review-refuter.md +28 -0
  47. 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
+ }
@@ -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): ApprovalLedger;
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;
@@ -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
- return { schema: 1, workflowId, stages: {} };
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({ schema: 1, workflowId: ledger.workflowId, stages }, { lineWidth: 0 });
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 = { schema: 1, workflowId: value.workflowId, stages };
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}.`);
@@ -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
+ }