@multileaf/ai-workflow 0.1.1 → 0.1.3
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 +17 -1
- package/README.md +6 -12
- package/dist/adapter.js +13 -13
- package/dist/{bootstrap-resources.d.ts → bundled-resources.d.ts} +1 -1
- package/dist/{bootstrap-resources.js → bundled-resources.js} +1 -1
- package/dist/generator.js +13 -3
- package/dist/recommendations.d.ts +7 -0
- package/dist/recommendations.js +141 -10
- package/dist/workflow.js +176 -46
- package/docs/package-model.md +2 -0
- package/docs/product-requirements.md +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project are documented in this file. The format foll
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.3] - 2026-09-12
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Invoke AI Workflow through `npx` in the generated `ai-init` instructions so fresh projects do not need a globally installed `aiw` binary.
|
|
12
|
+
|
|
13
|
+
## [0.1.2] - 2026-09-12
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Keep first install limited to the `ai-init` bootstrap and neutral AI Workflow state; install optional capabilities only after the project scan and explicit user selection.
|
|
18
|
+
- Materialize only selected skills, rules, agents, hooks, and templates, and personalize supported rules with detected project commands.
|
|
19
|
+
- Allow non-interactive recommendation previews without silently selecting or installing capabilities.
|
|
20
|
+
|
|
7
21
|
## [0.1.1] - 2026-09-12
|
|
8
22
|
|
|
9
23
|
### Fixed
|
|
@@ -36,6 +50,8 @@ All notable changes to this project are documented in this file. The format foll
|
|
|
36
50
|
|
|
37
51
|
- Path confinement, symlink protection, explicit permission gates, secret redaction, strict parsers, atomic migrations, and immutable execution checkpoints.
|
|
38
52
|
|
|
39
|
-
[Unreleased]: https://github.com/MultiLeaf/aiw/compare/v0.1.
|
|
53
|
+
[Unreleased]: https://github.com/MultiLeaf/aiw/compare/v0.1.3...HEAD
|
|
54
|
+
[0.1.3]: https://github.com/MultiLeaf/aiw/compare/v0.1.2...v0.1.3
|
|
55
|
+
[0.1.2]: https://github.com/MultiLeaf/aiw/compare/v0.1.1...v0.1.2
|
|
40
56
|
[0.1.1]: https://github.com/MultiLeaf/aiw/releases/tag/v0.1.1
|
|
41
57
|
[0.1.0]: https://github.com/MultiLeaf/aiw/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -25,13 +25,15 @@ The published package supports Node.js 18 or newer. Full development checks requ
|
|
|
25
25
|
npx @multileaf/ai-workflow install --target codex
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Then run `/ai-init` in Codex. The bootstrap scans the repository, explains project-specific recommendations, and waits for the user's selections before installing optional resources.
|
|
29
|
+
|
|
28
30
|
Supported targets are `codex`, `claude`, `cursor`, `gemini`, `copilot`, and `universal`. Every command uses the same entry point:
|
|
29
31
|
|
|
30
32
|
```bash
|
|
31
33
|
npx @multileaf/ai-workflow <command> <options>
|
|
32
34
|
```
|
|
33
35
|
|
|
34
|
-
The initial installation creates workflow state and a target-specific `ai-init` skill. Run it in a repository that does not already use the reserved paths shown below: first installation currently writes the base ADR index and target `ai-init` file at those paths. Reinstallation detects existing AI Workflow state and preserves it.
|
|
36
|
+
The initial installation creates workflow state and a target-specific `ai-init` skill. Run it in a repository that does not already use the reserved bootstrap paths shown below: first installation currently writes the base ADR index and target `ai-init` file at those paths. Reinstallation detects existing AI Workflow state and preserves it.
|
|
35
37
|
|
|
36
38
|
## What gets created
|
|
37
39
|
|
|
@@ -40,15 +42,7 @@ For a Codex installation, the base layout is:
|
|
|
40
42
|
```text
|
|
41
43
|
.agents/
|
|
42
44
|
└── skills/
|
|
43
|
-
|
|
44
|
-
├── brainstorming/SKILL.md
|
|
45
|
-
├── requirements-specification/SKILL.md
|
|
46
|
-
├── tdd-development/SKILL.md
|
|
47
|
-
└── ...
|
|
48
|
-
.agents/agents/ # specialist agent definitions
|
|
49
|
-
.agents/hooks/ # lifecycle guidance and compatibility resources
|
|
50
|
-
.agents/rules/ # quality, security, and project rules
|
|
51
|
-
.agents/templates/ # SDD artifact templates
|
|
45
|
+
└── ai-init/SKILL.md
|
|
52
46
|
.context/
|
|
53
47
|
└── adrs/
|
|
54
48
|
└── INDEX.md
|
|
@@ -65,9 +59,9 @@ For a Codex installation, the base layout is:
|
|
|
65
59
|
└── profile.yml
|
|
66
60
|
```
|
|
67
61
|
|
|
68
|
-
`.aiw/` stores the provider-neutral manifest, profile, lockfile, checkpoints, and generated working artifacts. ADRs are stored under `.context/adrs/`. The
|
|
62
|
+
`.aiw/` stores the provider-neutral manifest, profile, lockfile, checkpoints, bootstrap skill, selected resources, and generated working artifacts. ADRs are stored under `.context/adrs/`. The installer intentionally adds only `ai-init` and the base project state.
|
|
69
63
|
|
|
70
|
-
|
|
64
|
+
Run `/ai-init` in the selected coding agent to scan the project and review evidence-based recommendations for skills, rules, agents, hooks, and templates. AI Workflow installs only the resources the user selects, then generates project-specific quality guidance from the detected stack and tools. Existing user files are preserved; conflicts are reported instead of overwritten. Hook resources are rendered according to each adapter's native or compatibility support.
|
|
71
65
|
|
|
72
66
|
The installer does not modify `.gitignore`. Add `.aiw/generated/` yourself if generated specifications, plans, and artifacts should remain local. Versioned files under `.aiw/checkpoints/` are designed to remain reviewable project history.
|
|
73
67
|
|
package/dist/adapter.js
CHANGED
|
@@ -36,26 +36,26 @@ description: Initialize AI Workflow and run its specification-driven development
|
|
|
36
36
|
|
|
37
37
|
# AI Workflow Initialization
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Analyze the repository safely, confirm uncertain facts, and recommend project-specific capabilities.
|
|
39
|
+
This skill is the bootstrap for AI Workflow. The installer intentionally installs only this skill and the provider-neutral project state. Do not copy or activate the full resource package automatically.
|
|
42
40
|
|
|
43
41
|
## First-run setup
|
|
44
42
|
|
|
45
|
-
1. Inspect the repository
|
|
46
|
-
2. Run \`aiw scan
|
|
47
|
-
3. Run \`aiw recommend\`. Explain each
|
|
48
|
-
4.
|
|
49
|
-
5.
|
|
50
|
-
6.
|
|
43
|
+
1. Inspect the repository's existing AI instructions and project structure. Preserve user-authored files and do not expose secrets.
|
|
44
|
+
2. Run \`npx --yes --package=@multileaf/ai-workflow -- aiw scan\`. Explain the detected facts and their evidence; ask the user to confirm uncertain inferences before they influence generated content.
|
|
45
|
+
3. Run \`npx --yes --package=@multileaf/ai-workflow -- aiw recommend\`, then read \`.aiw/recommendations.yml\` and \`.aiw/profile.yml\`. Explain why each suggested skill, rule, agent, hook, or template fits and identify external permissions. Ask which capabilities to install, and ask separately before granting an external permission such as \`network:external\`.
|
|
46
|
+
4. Record the user's choices with \`npx --yes --package=@multileaf/ai-workflow -- aiw recommend --select=id,id\`; use \`npx --yes --package=@multileaf/ai-workflow -- aiw recommend --select=\` when all recommendations are declined. If at least one capability is selected, run \`npx --yes --package=@multileaf/ai-workflow -- aiw sync\`; when the user separately approved external network access, add \`--allow=network:external\`. Sync installs only selected resources and fills supported project rules with detected stack and quality-tool details. Do not add unselected resources or overwrite existing files.
|
|
47
|
+
5. Review the installed resource list and the generated project-specific files with the user. Ask before changing project policy or tailoring any other skill/rule.
|
|
48
|
+
6. Introduce the selected workflow gates: brainstorming → specification → architecture decision when needed → implementation plan → implementation with tests → verification → traceability → review. Use only gates supported by the resources the user chose.
|
|
49
|
+
|
|
50
|
+
If the user declines all recommendations, leave the project with only the bootstrap skill and scan state. Do not treat scanning or recommendation as consent to install.
|
|
51
51
|
|
|
52
|
-
## For each feature request
|
|
52
|
+
## For each feature request after setup
|
|
53
53
|
|
|
54
54
|
- Start with the brainstorming skill when scope, users, constraints, or assumptions are unclear.
|
|
55
|
-
- Create and complete a specification with stable requirement IDs and Given/When/Then acceptance criteria; pass \`aiw gate specification\` before planning.
|
|
56
|
-
- Record consequential architecture choices as ADRs. Create an implementation plan that links tasks to requirements, tests, risks, and validation commands; pass \`aiw gate plan\` before coding.
|
|
55
|
+
- Create and complete a specification with stable requirement IDs and Given/When/Then acceptance criteria; pass \`npx --yes --package=@multileaf/ai-workflow -- aiw gate specification\` before planning.
|
|
56
|
+
- Record consequential architecture choices as ADRs. Create an implementation plan that links tasks to requirements, tests, risks, and validation commands; pass \`npx --yes --package=@multileaf/ai-workflow -- aiw gate plan\` before coding.
|
|
57
57
|
- Follow the TDD policy and project quality rules. Use specialist agents for requirements, architecture, implementation, tests, security, documentation, and review when useful.
|
|
58
|
-
- Run the declared checks, then \`aiw verify\`, \`aiw trace\`, and the review gate. Do not claim completion without evidence.
|
|
58
|
+
- Run the declared checks, then \`npx --yes --package=@multileaf/ai-workflow -- aiw verify\`, \`npx --yes --package=@multileaf/ai-workflow -- aiw trace\`, and the review gate. Do not claim completion without evidence.
|
|
59
59
|
|
|
60
60
|
## Language policy
|
|
61
61
|
|
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { renderResourcePath } from "./adapter.js";
|
|
5
5
|
import { RESOURCE_TYPES, validatePackageContract } from "./package-contract.js";
|
|
6
6
|
const resourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../resources");
|
|
7
|
-
export function
|
|
7
|
+
export function loadBundledResources(target) {
|
|
8
8
|
const manifestPath = join(resourceRoot, "package.yaml");
|
|
9
9
|
const manifest = readFileSync(manifestPath, "utf8");
|
|
10
10
|
const contract = validatePackageContract(manifest, (path) => {
|
package/dist/generator.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { resolveGeneratedPath } from "./drift.js";
|
|
2
2
|
export function generateProjectResources(profile, selected, write) {
|
|
3
|
-
const linterCommand = profile.quality.linter?.command;
|
|
4
|
-
const formatterCommand = profile.quality.formatter?.command;
|
|
5
|
-
const testCommand = profile.testing?.command;
|
|
3
|
+
const linterCommand = safeInlineValue(profile.quality.linter?.command);
|
|
4
|
+
const formatterCommand = safeInlineValue(profile.quality.formatter?.command);
|
|
5
|
+
const testCommand = safeInlineValue(profile.testing?.command);
|
|
6
6
|
if (selected.includes("typescript-quality"))
|
|
7
7
|
write(resolveGeneratedPath("rules/project-quality.md"), `# Project Quality Rules\n\n- Use ${profile.runtime.languages.map((language) => (language === "typescript" ? "TypeScript" : language)).join(", ")} with strict typing.\n${linterCommand ? `- Run \`${linterCommand}\` before completion.\n` : ""}${formatterCommand ? `- Run \`${formatterCommand}\` before review.\n` : ""}- Keep all generated AI Workflow artifacts in English.\n`);
|
|
8
8
|
if (selected.includes("tdd-development"))
|
|
@@ -14,3 +14,13 @@ export function generateProjectResources(profile, selected, write) {
|
|
|
14
14
|
write(resolveGeneratedPath("hooks/quality-check.md"), `# Quality Check Hook\n\nRun the available project quality commands before completion.${linterCommand ? `\n\n- Linter: \`${linterCommand}\`` : ""}${formatterCommand ? `\n- Formatter: \`${formatterCommand}\`` : ""}${testCommand ? `\n- Tests: \`${testCommand}\`` : ""}\n`);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
function safeInlineValue(value) {
|
|
18
|
+
return value
|
|
19
|
+
?.split("")
|
|
20
|
+
.map((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || character === "`"
|
|
21
|
+
? " "
|
|
22
|
+
: character)
|
|
23
|
+
.join("")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.slice(0, 180);
|
|
26
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ProjectProfile } from "./profile.js";
|
|
2
|
+
import type { ResourceType } from "./package-contract.js";
|
|
3
|
+
export type RecommendedResource = {
|
|
4
|
+
type: ResourceType;
|
|
5
|
+
id: string;
|
|
6
|
+
};
|
|
2
7
|
export type CapabilityRecommendation = {
|
|
3
8
|
id: string;
|
|
4
9
|
provider: string;
|
|
@@ -7,6 +12,8 @@ export type CapabilityRecommendation = {
|
|
|
7
12
|
evidence: string[];
|
|
8
13
|
permissions: string[];
|
|
9
14
|
conflicts: string[];
|
|
15
|
+
resources: RecommendedResource[];
|
|
10
16
|
};
|
|
11
17
|
export declare function recommendCapabilities(profile: ProjectProfile): CapabilityRecommendation[];
|
|
18
|
+
export declare function resourcesForCapabilities(ids: string[]): RecommendedResource[];
|
|
12
19
|
export declare function serializeRecommendations(items: CapabilityRecommendation[], selected?: string[]): string;
|
package/dist/recommendations.js
CHANGED
|
@@ -1,8 +1,73 @@
|
|
|
1
|
+
const local = (resources) => resources;
|
|
1
2
|
const CATALOG = [
|
|
3
|
+
{
|
|
4
|
+
id: "brainstorming",
|
|
5
|
+
provider: "multileaf",
|
|
6
|
+
rationale: "A project-specific brainstorming flow helps clarify goals and constraints before implementation.",
|
|
7
|
+
permissions: [],
|
|
8
|
+
resources: local([
|
|
9
|
+
{ type: "skills", id: "brainstorming" },
|
|
10
|
+
{ type: "rules", id: "language-policy" },
|
|
11
|
+
{ type: "templates", id: "brainstorm" },
|
|
12
|
+
]),
|
|
13
|
+
matches: (profile) => hasProjectSignals(profile)
|
|
14
|
+
? { evidence: projectEvidence(profile), confidence: 0.92 }
|
|
15
|
+
: undefined,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "requirements-specification",
|
|
19
|
+
provider: "multileaf",
|
|
20
|
+
rationale: "The project can use explicit requirements and acceptance criteria before changes are planned.",
|
|
21
|
+
permissions: [],
|
|
22
|
+
resources: local([
|
|
23
|
+
{ type: "skills", id: "requirements-specification" },
|
|
24
|
+
{ type: "agents", id: "requirements-analyst" },
|
|
25
|
+
{ type: "templates", id: "specification" },
|
|
26
|
+
]),
|
|
27
|
+
matches: (profile) => hasProjectSignals(profile)
|
|
28
|
+
? { evidence: projectEvidence(profile), confidence: 0.9 }
|
|
29
|
+
: undefined,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "implementation-planning",
|
|
33
|
+
provider: "multileaf",
|
|
34
|
+
rationale: "A traceable implementation plan can connect project requirements, tests, risks, and validation.",
|
|
35
|
+
permissions: [],
|
|
36
|
+
resources: local([
|
|
37
|
+
{ type: "skills", id: "implementation-planning" },
|
|
38
|
+
{ type: "agents", id: "technical-architect" },
|
|
39
|
+
{ type: "templates", id: "implementation-plan" },
|
|
40
|
+
]),
|
|
41
|
+
matches: (profile) => hasProjectSignals(profile)
|
|
42
|
+
? { evidence: projectEvidence(profile), confidence: 0.88 }
|
|
43
|
+
: undefined,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "code-review",
|
|
47
|
+
provider: "multileaf",
|
|
48
|
+
rationale: "A consistent review checklist helps validate changes against this repository's conventions.",
|
|
49
|
+
permissions: [],
|
|
50
|
+
resources: local([
|
|
51
|
+
{ type: "skills", id: "code-review" },
|
|
52
|
+
{ type: "agents", id: "quality-reviewer" },
|
|
53
|
+
]),
|
|
54
|
+
matches: (profile) => profile.runtime.languages.length
|
|
55
|
+
? { evidence: projectEvidence(profile), confidence: 0.86 }
|
|
56
|
+
: undefined,
|
|
57
|
+
},
|
|
2
58
|
{
|
|
3
59
|
id: "tdd-development",
|
|
4
60
|
provider: "multileaf",
|
|
5
|
-
rationale: "The project uses automated tests and
|
|
61
|
+
rationale: "The project uses automated tests and can apply test-first development with its existing runner.",
|
|
62
|
+
permissions: [],
|
|
63
|
+
resources: local([
|
|
64
|
+
{ type: "skills", id: "tdd-development" },
|
|
65
|
+
{ type: "rules", id: "tdd-policy" },
|
|
66
|
+
{ type: "agents", id: "test-engineer" },
|
|
67
|
+
{ type: "hooks", id: "pre-implementation" },
|
|
68
|
+
{ type: "hooks", id: "post-implementation" },
|
|
69
|
+
{ type: "templates", id: "test-plan" },
|
|
70
|
+
]),
|
|
6
71
|
matches: (profile) => profile.testing
|
|
7
72
|
? { evidence: [`testing:${profile.testing.name}`], confidence: 1 }
|
|
8
73
|
: undefined,
|
|
@@ -10,7 +75,12 @@ const CATALOG = [
|
|
|
10
75
|
{
|
|
11
76
|
id: "typescript-quality",
|
|
12
77
|
provider: "multileaf",
|
|
13
|
-
rationale: "TypeScript is
|
|
78
|
+
rationale: "TypeScript is detected; add its specialist and tailor quality guidance to the repository's tools.",
|
|
79
|
+
permissions: [],
|
|
80
|
+
resources: local([
|
|
81
|
+
{ type: "agents", id: "typescript-engineer" },
|
|
82
|
+
{ type: "rules", id: "quality-gates" },
|
|
83
|
+
]),
|
|
14
84
|
matches: (profile) => profile.runtime.languages.includes("typescript")
|
|
15
85
|
? { evidence: ["language:typescript"], confidence: 1 }
|
|
16
86
|
: undefined,
|
|
@@ -18,7 +88,14 @@ const CATALOG = [
|
|
|
18
88
|
{
|
|
19
89
|
id: "vitest-testing",
|
|
20
90
|
provider: "multileaf",
|
|
21
|
-
rationale: "Vitest is configured and can be
|
|
91
|
+
rationale: "Vitest is configured and can be used by the verification skill and test specialist.",
|
|
92
|
+
permissions: [],
|
|
93
|
+
resources: local([
|
|
94
|
+
{ type: "skills", id: "verification" },
|
|
95
|
+
{ type: "agents", id: "test-engineer" },
|
|
96
|
+
{ type: "templates", id: "test-plan" },
|
|
97
|
+
{ type: "templates", id: "verification-report" },
|
|
98
|
+
]),
|
|
22
99
|
matches: (profile) => profile.testing?.name === "vitest"
|
|
23
100
|
? { evidence: ["testing:vitest"], confidence: 1 }
|
|
24
101
|
: undefined,
|
|
@@ -26,7 +103,14 @@ const CATALOG = [
|
|
|
26
103
|
{
|
|
27
104
|
id: "react-best-practices",
|
|
28
105
|
provider: "vercel-skills",
|
|
29
|
-
rationale: "React is detected; framework
|
|
106
|
+
rationale: "React is detected; framework guidance can improve component design and performance.",
|
|
107
|
+
permissions: ["network:external"],
|
|
108
|
+
resources: local([
|
|
109
|
+
{ type: "skills", id: "technical-design" },
|
|
110
|
+
{ type: "agents", id: "technical-architect" },
|
|
111
|
+
{ type: "rules", id: "architecture-policy" },
|
|
112
|
+
{ type: "templates", id: "technical-design" },
|
|
113
|
+
]),
|
|
30
114
|
matches: (profile) => profile.frameworks.includes("react")
|
|
31
115
|
? { evidence: ["framework:react"], confidence: 1 }
|
|
32
116
|
: undefined,
|
|
@@ -34,11 +118,34 @@ const CATALOG = [
|
|
|
34
118
|
{
|
|
35
119
|
id: "nextjs-development",
|
|
36
120
|
provider: "multileaf",
|
|
37
|
-
rationale: "Next.js is detected;
|
|
121
|
+
rationale: "Next.js is detected; architecture guidance can follow the project's framework conventions.",
|
|
122
|
+
permissions: [],
|
|
123
|
+
resources: local([
|
|
124
|
+
{ type: "skills", id: "technical-design" },
|
|
125
|
+
{ type: "agents", id: "technical-architect" },
|
|
126
|
+
{ type: "rules", id: "architecture-policy" },
|
|
127
|
+
{ type: "templates", id: "technical-design" },
|
|
128
|
+
]),
|
|
38
129
|
matches: (profile) => profile.frameworks.includes("nextjs")
|
|
39
130
|
? { evidence: ["framework:nextjs"], confidence: 1 }
|
|
40
131
|
: undefined,
|
|
41
132
|
},
|
|
133
|
+
{
|
|
134
|
+
id: "security-review",
|
|
135
|
+
provider: "multileaf",
|
|
136
|
+
rationale: "The detected stack and package tooling benefit from project-aware security and dependency review.",
|
|
137
|
+
permissions: [],
|
|
138
|
+
resources: local([
|
|
139
|
+
{ type: "skills", id: "security-review" },
|
|
140
|
+
{ type: "rules", id: "security-policy" },
|
|
141
|
+
{ type: "rules", id: "dependency-policy" },
|
|
142
|
+
{ type: "agents", id: "security-reviewer" },
|
|
143
|
+
{ type: "templates", id: "security-review" },
|
|
144
|
+
]),
|
|
145
|
+
matches: (profile) => hasProjectSignals(profile)
|
|
146
|
+
? { evidence: projectEvidence(profile), confidence: 0.83 }
|
|
147
|
+
: undefined,
|
|
148
|
+
},
|
|
42
149
|
];
|
|
43
150
|
export function recommendCapabilities(profile) {
|
|
44
151
|
return CATALOG.flatMap((capability) => {
|
|
@@ -46,18 +153,42 @@ export function recommendCapabilities(profile) {
|
|
|
46
153
|
return match
|
|
47
154
|
? [
|
|
48
155
|
{
|
|
49
|
-
|
|
50
|
-
provider: capability.provider,
|
|
156
|
+
...capability,
|
|
51
157
|
confidence: match.confidence,
|
|
52
|
-
rationale: capability.rationale,
|
|
53
158
|
evidence: match.evidence,
|
|
54
|
-
permissions: capability.permissions ?? [],
|
|
55
159
|
conflicts: [],
|
|
56
160
|
},
|
|
57
161
|
]
|
|
58
162
|
: [];
|
|
59
163
|
});
|
|
60
164
|
}
|
|
165
|
+
export function resourcesForCapabilities(ids) {
|
|
166
|
+
const selected = new Set(ids);
|
|
167
|
+
const resources = CATALOG.filter(({ id }) => selected.has(id)).flatMap(({ resources }) => resources);
|
|
168
|
+
return [
|
|
169
|
+
...new Map(resources.map((resource) => [`${resource.type}/${resource.id}`, resource])).values(),
|
|
170
|
+
];
|
|
171
|
+
}
|
|
61
172
|
export function serializeRecommendations(items, selected = []) {
|
|
62
|
-
return `schema: 1\nrecommendations:\n${items
|
|
173
|
+
return `schema: 1\nrecommendations:\n${items
|
|
174
|
+
.map((item) => ` - id: ${item.id}\n provider: ${item.provider}\n confidence: ${item.confidence}\n selected: ${selected.includes(item.id)}\n resources: [${item.resources.map(({ type, id }) => `${type}/${id}`).join(", ")}]\n evidence: [${item.evidence.map((evidence) => `"${evidence}"`).join(", ")}]\n rationale: ${item.rationale}`)
|
|
175
|
+
.join("\n")}\n`;
|
|
176
|
+
}
|
|
177
|
+
function hasProjectSignals(profile) {
|
|
178
|
+
return Boolean(profile.runtime.languages.length ||
|
|
179
|
+
profile.frameworks.length ||
|
|
180
|
+
profile.packageManager !== "unknown" ||
|
|
181
|
+
profile.testing ||
|
|
182
|
+
profile.quality.linter ||
|
|
183
|
+
profile.ci.length);
|
|
184
|
+
}
|
|
185
|
+
function projectEvidence(profile) {
|
|
186
|
+
return [
|
|
187
|
+
...profile.runtime.languages.map((language) => `language:${language}`),
|
|
188
|
+
...profile.frameworks.map((framework) => `framework:${framework}`),
|
|
189
|
+
...(profile.packageManager === "unknown" ? [] : [`package-manager:${profile.packageManager}`]),
|
|
190
|
+
...(profile.testing ? [`testing:${profile.testing.name}`] : []),
|
|
191
|
+
...(profile.quality.linter ? [`linter:${profile.quality.linter.name}`] : []),
|
|
192
|
+
...(profile.ci.length ? profile.ci.map((system) => `ci:${system}`) : []),
|
|
193
|
+
];
|
|
63
194
|
}
|
package/dist/workflow.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { BASE_DIRECTORIES, WORKFLOW_DIRECTORY } from "./constants.js";
|
|
3
3
|
import { detectTarget, isTarget, renderAiInit, renderAiInitPath, renderResourcePath, } from "./adapter.js";
|
|
4
|
-
import {
|
|
4
|
+
import { loadBundledResources } from "./bundled-resources.js";
|
|
5
5
|
import { assertSafeProjectPath, withSafeProjectWrites, writeProjectFilesAtomically, } from "./files.js";
|
|
6
6
|
import { parseProjectProfile, profileProject } from "./profile.js";
|
|
7
7
|
import { serializeOverrides } from "./confirmation.js";
|
|
8
8
|
import { applyOverrides, parseOverrides } from "./confirmation.js";
|
|
9
9
|
import { mergeFacts } from "./intelligence.js";
|
|
10
10
|
import { createInterpretationRequest } from "./interpreter.js";
|
|
11
|
-
import { recommendCapabilities, serializeRecommendations } from "./recommendations.js";
|
|
11
|
+
import { recommendCapabilities, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
|
|
12
12
|
import { selectRecommendations } from "./selection.js";
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
14
|
import { stdin, stdout } from "node:process";
|
|
@@ -124,28 +124,26 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
124
124
|
output: `AI Workflow is already installed for target: ${active}. Existing state preserved.`,
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
|
-
const
|
|
128
|
-
const
|
|
127
|
+
const aiInitPath = join(root, renderAiInitPath(target));
|
|
128
|
+
const neutralAiInitPath = join(aiw, "resources/skills/ai-init.md");
|
|
129
|
+
const aiInitContent = renderAiInit(target);
|
|
130
|
+
const desiredResources = new Map([[aiInitPath, aiInitContent]]);
|
|
129
131
|
const ownershipMetadata = new Map();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
ownershipMetadata.set(aiInitPath, {
|
|
133
|
+
kind: "ai-init",
|
|
134
|
+
target,
|
|
135
|
+
resourceType: "skills",
|
|
136
|
+
resourceId: "ai-init",
|
|
137
|
+
});
|
|
138
|
+
if (neutralAiInitPath !== aiInitPath) {
|
|
139
|
+
desiredResources.set(neutralAiInitPath, aiInitContent);
|
|
140
|
+
ownershipMetadata.set(neutralAiInitPath, {
|
|
136
141
|
kind: "neutral-resource",
|
|
137
142
|
target: "universal",
|
|
138
|
-
resourceType:
|
|
139
|
-
resourceId:
|
|
143
|
+
resourceType: "skills",
|
|
144
|
+
resourceId: "ai-init",
|
|
140
145
|
});
|
|
141
|
-
|
|
142
|
-
ownershipMetadata.set(targetPath, {
|
|
143
|
-
kind: "target-resource",
|
|
144
|
-
target,
|
|
145
|
-
resourceType: type,
|
|
146
|
-
resourceId: id,
|
|
147
|
-
});
|
|
148
|
-
});
|
|
146
|
+
}
|
|
149
147
|
const conflicts = [];
|
|
150
148
|
const pendingWrites = new Map();
|
|
151
149
|
const inspectDesiredFile = (path, content, allowExisting = false) => {
|
|
@@ -161,19 +159,6 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
161
159
|
};
|
|
162
160
|
for (const [path, content] of desiredResources)
|
|
163
161
|
inspectDesiredFile(path, content);
|
|
164
|
-
if (conflicts.length)
|
|
165
|
-
return {
|
|
166
|
-
exitCode: 1,
|
|
167
|
-
error: `Installation found existing target resources: ${conflicts.join(", ")}. Review and move them before retrying.`,
|
|
168
|
-
};
|
|
169
|
-
const aiInitPath = join(root, renderAiInitPath(target));
|
|
170
|
-
ownershipMetadata.set(aiInitPath, {
|
|
171
|
-
kind: "ai-init",
|
|
172
|
-
target,
|
|
173
|
-
resourceType: "skills",
|
|
174
|
-
resourceId: "ai-init",
|
|
175
|
-
});
|
|
176
|
-
inspectDesiredFile(aiInitPath, renderAiInit(target));
|
|
177
162
|
if (conflicts.length)
|
|
178
163
|
return {
|
|
179
164
|
exitCode: 1,
|
|
@@ -195,7 +180,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
195
180
|
exitCode: 1,
|
|
196
181
|
error: `Installation found conflicting project state: ${conflicts.join(", ")}. Review and move it before retrying.`,
|
|
197
182
|
};
|
|
198
|
-
const ownedPaths = new Set(
|
|
183
|
+
const ownedPaths = new Set(desiredResources.keys());
|
|
199
184
|
if (pendingWrites.has(adrIndex))
|
|
200
185
|
ownedPaths.add(adrIndex);
|
|
201
186
|
const ownedFiles = [...ownedPaths]
|
|
@@ -207,7 +192,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
207
192
|
writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
|
|
208
193
|
return {
|
|
209
194
|
exitCode: 0,
|
|
210
|
-
output: `AI Workflow
|
|
195
|
+
output: `AI Workflow initialized for target: ${target} with only the ai-init skill. Run /ai-init to scan the project and choose which resources to install.`,
|
|
211
196
|
};
|
|
212
197
|
}
|
|
213
198
|
if (command === "telemetry") {
|
|
@@ -409,7 +394,10 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
409
394
|
diagnostics.push({ path: ".aiw/resources/skills/ai-init.md", reason: aiInitDiagnostic });
|
|
410
395
|
const targetAiPath = targetPath;
|
|
411
396
|
resourceDestinations.add(targetAiPath);
|
|
412
|
-
|
|
397
|
+
if (targetAiPath === neutralAiInit)
|
|
398
|
+
fileChanges.set(targetAiPath, aiInitBytes);
|
|
399
|
+
else
|
|
400
|
+
inspectMigrationDestination(targetAiPath, aiInitBytes);
|
|
413
401
|
const previousTargetEntries = previousTarget === "universal"
|
|
414
402
|
? []
|
|
415
403
|
: inventory.filter((entry) => (entry.kind === "target-resource" || entry.kind === "ai-init") &&
|
|
@@ -667,18 +655,25 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
667
655
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
668
656
|
const profile = await (services.profiler?.profile(root) ?? profileProject(root));
|
|
669
657
|
const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
658
|
+
const selectedOption = args.find((arg) => arg.startsWith("--select="));
|
|
659
|
+
const selectedArg = selectedOption?.slice("--select=".length) ?? "";
|
|
660
|
+
let selection;
|
|
661
|
+
if (selectedOption !== undefined)
|
|
662
|
+
selection = await selectRecommendations(recommendations, async () => "n", selectedArg ? selectedArg.split(",") : []);
|
|
663
|
+
else if (!stdin.isTTY)
|
|
664
|
+
selection = [];
|
|
665
|
+
else {
|
|
666
|
+
const terminal = createInterface({ input: stdin, output: stdout });
|
|
667
|
+
selection = await selectRecommendations(recommendations, (question) => terminal.question(question));
|
|
668
|
+
terminal.close();
|
|
676
669
|
}
|
|
677
|
-
const terminal = createInterface({ input: stdin, output: stdout });
|
|
678
|
-
const selection = await selectRecommendations(recommendations, (question) => terminal.question(question), selectedArg?.split(","));
|
|
679
|
-
terminal.close();
|
|
680
670
|
fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(recommendations, selection));
|
|
681
|
-
return {
|
|
671
|
+
return {
|
|
672
|
+
exitCode: 0,
|
|
673
|
+
output: selectedOption === undefined && !stdin.isTTY
|
|
674
|
+
? `${recommendations.length} recommendations generated without selection; review .aiw/recommendations.yml, then record user choices with --select=id,id.`
|
|
675
|
+
: `${recommendations.length} recommendations generated.`,
|
|
676
|
+
};
|
|
682
677
|
}
|
|
683
678
|
if (command === "skills") {
|
|
684
679
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -770,6 +765,12 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
770
765
|
await installVercelSkill(services.externalSkills ?? nodeCommandExecutor, "vercel-labs/agent-skills", "vercel-react-best-practices", target);
|
|
771
766
|
writeVercelSkillLock(fs, root, aiw, "vercel-labs/agent-skills", "vercel-react-best-practices");
|
|
772
767
|
}
|
|
768
|
+
if (command === "sync") {
|
|
769
|
+
const result = syncSelectedResources(fs, root, aiw, profile, selected);
|
|
770
|
+
return result.error
|
|
771
|
+
? { exitCode: 1, error: result.error }
|
|
772
|
+
: { exitCode: 0, output: result.output };
|
|
773
|
+
}
|
|
773
774
|
const conflicts = [];
|
|
774
775
|
generateProjectResources(profile, selected, (path, content) => {
|
|
775
776
|
const destination = join(aiw, path);
|
|
@@ -1532,6 +1533,135 @@ function containsCredential(value, credential, visited = new Set()) {
|
|
|
1532
1533
|
visited.add(value);
|
|
1533
1534
|
return Object.values(value).some((entry) => containsCredential(entry, credential, visited));
|
|
1534
1535
|
}
|
|
1536
|
+
function syncSelectedResources(fs, root, aiw, profile, selected) {
|
|
1537
|
+
const target = parseManifest(fs.read(join(aiw, "manifest.yml"))).target.active;
|
|
1538
|
+
const resourceSelections = resourcesForCapabilities(selected);
|
|
1539
|
+
const bundled = loadBundledResources(target);
|
|
1540
|
+
const bundleByKey = new Map(bundled.map((resource) => [`${resource.type}/${resource.id}`, resource]));
|
|
1541
|
+
const desired = new Map();
|
|
1542
|
+
const generatedFiles = new Map();
|
|
1543
|
+
const addOwned = (path, content, metadata) => {
|
|
1544
|
+
const previous = desired.get(path);
|
|
1545
|
+
if (previous && previous.content !== content)
|
|
1546
|
+
throw new Error(`Selected resources render conflicting content at ${path}`);
|
|
1547
|
+
desired.set(path, { content, metadata });
|
|
1548
|
+
};
|
|
1549
|
+
const addResource = (type, id, content) => {
|
|
1550
|
+
const neutralPath = join(root, renderResourcePath("universal", type, id));
|
|
1551
|
+
const targetPath = join(root, renderResourcePath(target, type, id));
|
|
1552
|
+
addOwned(neutralPath, content, {
|
|
1553
|
+
kind: "neutral-resource",
|
|
1554
|
+
target: "universal",
|
|
1555
|
+
resourceType: type,
|
|
1556
|
+
resourceId: id,
|
|
1557
|
+
});
|
|
1558
|
+
if (targetPath !== neutralPath)
|
|
1559
|
+
addOwned(targetPath, content, {
|
|
1560
|
+
kind: "target-resource",
|
|
1561
|
+
target,
|
|
1562
|
+
resourceType: type,
|
|
1563
|
+
resourceId: id,
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
for (const reference of resourceSelections) {
|
|
1567
|
+
const key = `${reference.type}/${reference.id}`;
|
|
1568
|
+
const resource = bundleByKey.get(key);
|
|
1569
|
+
if (!resource)
|
|
1570
|
+
return { error: `Bundled resource is unavailable: ${key}` };
|
|
1571
|
+
addResource(reference.type, reference.id, personalizeBundledResource(reference.type, reference.id, resource.content, profile));
|
|
1572
|
+
}
|
|
1573
|
+
generateProjectResources(profile, selected, (path, content) => {
|
|
1574
|
+
generatedFiles.set(join(aiw, path), content);
|
|
1575
|
+
const rule = path.match(/^generated\/rules\/(project-quality|tdd-project-policy)\.md$/);
|
|
1576
|
+
if (rule)
|
|
1577
|
+
addResource("rules", rule[1], content);
|
|
1578
|
+
});
|
|
1579
|
+
const ownershipPath = join(aiw, "ownership.yml");
|
|
1580
|
+
if (fs.pathType?.(ownershipPath) !== "file")
|
|
1581
|
+
return {
|
|
1582
|
+
error: "AI Workflow ownership inventory is missing or unsafe; reinstall before syncing.",
|
|
1583
|
+
};
|
|
1584
|
+
const inventory = parseOwnership(fs.read(ownershipPath));
|
|
1585
|
+
const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
1586
|
+
const changes = [];
|
|
1587
|
+
const conflicts = [];
|
|
1588
|
+
for (const [path, { content, metadata }] of desired) {
|
|
1589
|
+
assertSafeProjectPath(fs, root, path);
|
|
1590
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
1591
|
+
const type = fs.pathType?.(path);
|
|
1592
|
+
const owned = nextInventory.get(relativePath);
|
|
1593
|
+
if (type === "missing") {
|
|
1594
|
+
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1595
|
+
nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
if (type !== "file" || !owned) {
|
|
1599
|
+
conflicts.push(relativePath);
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
const actual = fs.read(path);
|
|
1603
|
+
if (createOwnedFile(relativePath, actual).checksum !== owned.checksum) {
|
|
1604
|
+
conflicts.push(relativePath);
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (actual !== content)
|
|
1608
|
+
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1609
|
+
nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
|
|
1610
|
+
}
|
|
1611
|
+
for (const [path, content] of generatedFiles) {
|
|
1612
|
+
assertSafeProjectPath(fs, root, path);
|
|
1613
|
+
const type = fs.pathType?.(path);
|
|
1614
|
+
if (type === "missing")
|
|
1615
|
+
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1616
|
+
else if (type !== "file" || fs.read(path) !== content)
|
|
1617
|
+
conflicts.push(relative(root, path).split(sep).join("/"));
|
|
1618
|
+
}
|
|
1619
|
+
if (conflicts.length)
|
|
1620
|
+
return {
|
|
1621
|
+
error: `Sync preserved existing or modified project resources: ${[...new Set(conflicts)].sort().join(", ")}`,
|
|
1622
|
+
};
|
|
1623
|
+
const nextOwnership = serializeOwnership([...nextInventory.values()]);
|
|
1624
|
+
if (fs.read(ownershipPath) !== nextOwnership)
|
|
1625
|
+
changes.push({ path: ownershipPath, content: Buffer.from(nextOwnership, "utf8") });
|
|
1626
|
+
if (changes.length)
|
|
1627
|
+
applyProjectFileChanges(fs, root, changes);
|
|
1628
|
+
return {
|
|
1629
|
+
output: `Synchronized ${resourceSelections.length} selected bundled resources for ${target} and generated ${generatedFiles.size} project-specific files.`,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
function personalizeBundledResource(type, id, content, profile) {
|
|
1633
|
+
if (type !== "rules" || !["quality-gates", "tdd-policy", "dependency-policy"].includes(id))
|
|
1634
|
+
return content;
|
|
1635
|
+
const signals = [
|
|
1636
|
+
profile.runtime.languages.length ? `- Languages: ${profile.runtime.languages.join(", ")}` : "",
|
|
1637
|
+
profile.frameworks.length ? `- Frameworks: ${profile.frameworks.join(", ")}` : "",
|
|
1638
|
+
profile.testing ? `- Test runner: ${profile.testing.name}` : "",
|
|
1639
|
+
profile.testing?.command
|
|
1640
|
+
? `- Test command: \`${safeProjectSignal(profile.testing.command)}\``
|
|
1641
|
+
: "",
|
|
1642
|
+
profile.quality.linter ? `- Linter: ${profile.quality.linter.name}` : "",
|
|
1643
|
+
profile.quality.linter?.command
|
|
1644
|
+
? `- Lint command: \`${safeProjectSignal(profile.quality.linter.command)}\``
|
|
1645
|
+
: "",
|
|
1646
|
+
profile.quality.formatter ? `- Formatter: ${profile.quality.formatter.name}` : "",
|
|
1647
|
+
profile.quality.formatter?.command
|
|
1648
|
+
? `- Format command: \`${safeProjectSignal(profile.quality.formatter.command)}\``
|
|
1649
|
+
: "",
|
|
1650
|
+
].filter(Boolean);
|
|
1651
|
+
return signals.length
|
|
1652
|
+
? `${content.trimEnd()}\n\n## Detected project context\n\n${signals.join("\n")}\n`
|
|
1653
|
+
: content;
|
|
1654
|
+
}
|
|
1655
|
+
function safeProjectSignal(value) {
|
|
1656
|
+
return value
|
|
1657
|
+
.split("")
|
|
1658
|
+
.map((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || character === "`"
|
|
1659
|
+
? " "
|
|
1660
|
+
: character)
|
|
1661
|
+
.join("")
|
|
1662
|
+
.replace(/\s+/g, " ")
|
|
1663
|
+
.slice(0, 180);
|
|
1664
|
+
}
|
|
1535
1665
|
function readSelectedRecommendations(fs, aiw) {
|
|
1536
1666
|
if (!fs.exists(join(aiw, "recommendations.yml")))
|
|
1537
1667
|
return [];
|
package/docs/package-model.md
CHANGED
|
@@ -26,6 +26,8 @@ package/
|
|
|
26
26
|
|
|
27
27
|
Packages may come from local paths, Git repositories, npm-compatible providers, Vercel Skills, or a future Multileaf registry.
|
|
28
28
|
|
|
29
|
+
The package distributed by AI Workflow is a catalog, not an automatic project install. `aiw install` writes only the target-specific `ai-init` bootstrap and neutral state. `/ai-init` runs CLI commands through `npx --yes --package=@multileaf/ai-workflow -- aiw <command>` because installation does not add a global `aiw` binary or a dependency to the consuming project. It scans the project, recommends matching capabilities, asks which to activate, and syncs only the selected resources. Supported rules and project-quality guidance are personalized from detected project tools; existing or user-edited files are preserved and reported as conflicts.
|
|
30
|
+
|
|
29
31
|
Local directories and Git repositories are resolved with `aiw resolve --source=<source>`. A local source may be a package directory or its `package.yaml`; relative paths are resolved from the project. Git sources support `git+https`, HTTPS, SSH, and `file://` URLs. Git packages are cloned into an isolated temporary checkout, validated before the lockfile is changed, and removed after resolution. The lockfile records the selected provider and normalized source rather than trusting those fields from the fetched manifest. The legacy `--package=<path>` form remains available for direct manifest resolution.
|
|
30
32
|
|
|
31
33
|
Vercel Skills is exposed through `aiw skills search`, `inspect`, `install`, `check`, and `update`. AIW delegates to the official `npx skills` CLI with argument arrays, requires explicit `network:external` permission for network-mutating/check operations, and records installed skills and upstream integrity in `.aiw/lock.yml`. Update output is inspected for upstream failure text because some CLI releases may report failures with a successful process exit code.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
### Installation
|
|
6
6
|
|
|
7
7
|
- The CLI must install with `npx @multileaf/ai-workflow install`.
|
|
8
|
+
- Initial installation must add only the target-specific `ai-init` bootstrap and neutral state; optional skills, rules, agents, hooks, and templates must not be activated before project analysis and user selection.
|
|
8
9
|
- Installation must support Codex, Claude Code, Cursor, Gemini CLI, GitHub Copilot, and a universal target over time.
|
|
9
10
|
- Installation must be idempotent and preserve user overrides.
|
|
10
11
|
- Installation must create a neutral `.aiw/` source of truth.
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
- Recommendations must explain their evidence, capabilities, permissions, and conflicts.
|
|
22
23
|
- The user must select which capabilities to install.
|
|
23
24
|
- Selected resources must be customized using confirmed project facts.
|
|
25
|
+
- `/ai-init` must orchestrate scan → recommendations → user selection → selected-resource sync and summarize what it added.
|
|
26
|
+
- The bootstrap must invoke CLI operations through the package runner; it cannot assume `aiw` is installed globally or in the consuming project's dependencies.
|
|
24
27
|
|
|
25
28
|
### Development workflow
|
|
26
29
|
|