@multileaf/ai-workflow 0.9.0 → 0.11.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 (38) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +12 -1
  3. package/dist/blocking-reasons.d.ts +67 -0
  4. package/dist/blocking-reasons.js +199 -0
  5. package/dist/command-evidence.d.ts +32 -0
  6. package/dist/command-evidence.js +61 -0
  7. package/dist/human-approval.d.ts +19 -2
  8. package/dist/human-approval.js +82 -9
  9. package/dist/recommendations.js +16 -1
  10. package/dist/risk-assess.d.ts +50 -0
  11. package/dist/risk-assess.js +273 -0
  12. package/dist/workflow.js +280 -30
  13. package/docs/README.md +2 -0
  14. package/docs/architecture.md +50 -0
  15. package/docs/backlog.md +13 -0
  16. package/docs/continuation-inventory.md +24 -0
  17. package/docs/decision-model-evaluation.md +4 -0
  18. package/docs/opportunities/OPP-001-proportional-ceremony-light-lane.md +62 -0
  19. package/docs/opportunities/OPP-002-risk-assess-command.md +70 -0
  20. package/docs/opportunities/OPP-003-adversarial-review-package.md +63 -0
  21. package/docs/opportunities/OPP-004-exact-continuation-commands.md +59 -0
  22. package/docs/opportunities/OPP-005-friction-benchmark.md +62 -0
  23. package/docs/opportunities/OPP-006-emission-usability-test-policy.md +57 -0
  24. package/docs/opportunities/OPP-007-decision-findings-triage-scope-watch.md +54 -0
  25. package/docs/opportunities/OPP-008-embedding-covering-tests-judge-context.md +52 -0
  26. package/docs/opportunities/README.md +33 -0
  27. package/docs/product-requirements.md +3 -0
  28. package/docs/product-vision.md +1 -0
  29. package/docs/quality.md +10 -0
  30. package/docs/release-process.md +9 -1
  31. package/docs/semantic-mapping.md +4 -0
  32. package/package.json +1 -1
  33. package/resources/package.yaml +3 -0
  34. package/resources/skills/code-review.md +2 -0
  35. package/resources/skills/review-judge-a.md +24 -0
  36. package/resources/skills/review-judge-b.md +24 -0
  37. package/resources/skills/review-refuter.md +28 -0
  38. package/resources/skills/ticket.md +2 -0
@@ -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
+ }