project-iris 0.0.8 → 0.0.11
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/README.md +294 -264
- package/dist/bridge/agent-runner.js +190 -0
- package/dist/bridge/connector-factory.js +4 -0
- package/dist/bridge/connectors/in-process-connector.js +29 -0
- package/dist/bridge/filesystem-connector.js +5 -0
- package/dist/cli.js +10 -2
- package/dist/commands/ask.js +150 -23
- package/dist/commands/bridge.js +8 -0
- package/dist/commands/flow.js +301 -0
- package/dist/commands/framework.js +273 -0
- package/dist/commands/generate.js +59 -0
- package/dist/commands/install.js +72 -29
- package/dist/commands/pack.js +7 -1
- package/dist/commands/run.js +195 -13
- package/dist/commands/status.js +9 -0
- package/dist/commands/uninstall.js +3 -1
- package/dist/commands/use.js +20 -0
- package/dist/commands/validate.js +80 -65
- package/dist/framework/framework-loader.js +97 -0
- package/dist/framework/framework-paths.js +48 -0
- package/dist/framework/framework-types.js +15 -0
- package/dist/iris/artifacts/config.js +68 -0
- package/dist/iris/artifacts/generator.js +88 -0
- package/dist/iris/artifacts/types.js +1 -0
- package/dist/iris/bundle.js +44 -0
- package/dist/iris/doctrine/collector.js +124 -0
- package/dist/iris/fixer.js +28 -22
- package/dist/iris/flows/manifest.js +124 -0
- package/dist/iris/framework-context.js +49 -0
- package/dist/iris/framework-manager.js +215 -0
- package/dist/iris/fs/atomic.js +22 -0
- package/dist/iris/importers/index.js +9 -0
- package/dist/iris/importers/types.js +8 -0
- package/dist/iris/importers/writer.js +139 -0
- package/dist/iris/installer.js +105 -40
- package/dist/iris/interactive/env.js +21 -0
- package/dist/iris/interactive/intent-interview.js +345 -0
- package/dist/iris/interactive/intent-schema.js +28 -0
- package/dist/iris/interactive/interview-io.js +22 -0
- package/dist/iris/interview/config.js +71 -0
- package/dist/iris/interview/types.js +16 -0
- package/dist/iris/interview/utils.js +38 -0
- package/dist/iris/packer.js +69 -47
- package/dist/iris/parsers/unit-parser.js +43 -0
- package/dist/iris/paths.js +18 -0
- package/dist/iris/policy.js +122 -17
- package/dist/iris/proc.js +56 -0
- package/dist/iris/resolver.js +3 -0
- package/dist/iris/routes.js +180 -11
- package/dist/iris/run-state.js +3 -0
- package/dist/iris/state.js +37 -9
- package/dist/iris/templates.js +70 -0
- package/dist/iris/tmp.js +24 -0
- package/dist/iris/uninstaller.js +24 -9
- package/dist/iris/utils/interpolate.js +42 -0
- package/dist/iris/validator.js +72 -10
- package/dist/iris/workflow/config.js +51 -0
- package/dist/iris/workflow/engine.js +129 -0
- package/dist/iris/workflow/steps.js +448 -0
- package/dist/iris/workflow/types.js +1 -0
- package/dist/utils/logo.js +17 -0
- package/dist/workflows/intent-inception.js +87 -65
- package/package.json +8 -6
- package/src/iris_bundle/.iris/aidlc/README.md +0 -16
- package/src/iris_bundle/.iris/aidlc/agents/iris-construction-agent.md +0 -35
- package/src/iris_bundle/.iris/aidlc/agents/iris-inception-agent.md +0 -30
- package/src/iris_bundle/.iris/aidlc/agents/iris-master-agent.md +0 -35
- package/src/iris_bundle/.iris/aidlc/agents/iris-operations-agent.md +0 -29
- package/src/iris_bundle/.iris/aidlc/commands/iris-construction-agent.md +0 -18
- package/src/iris_bundle/.iris/aidlc/commands/iris-inception-agent.md +0 -18
- package/src/iris_bundle/.iris/aidlc/commands/iris-master-agent.md +0 -18
- package/src/iris_bundle/.iris/aidlc/commands/iris-operations-agent.md +0 -18
- package/src/iris_bundle/.iris/aidlc/context/context-map.md +0 -25
- package/src/iris_bundle/.iris/aidlc/context/exclusion-rules.md +0 -13
- package/src/iris_bundle/.iris/aidlc/context/load-order.md +0 -25
- package/src/iris_bundle/.iris/aidlc/memory/intent-rules.md +0 -9
- package/src/iris_bundle/.iris/aidlc/memory/log-rules.md +0 -5
- package/src/iris_bundle/.iris/aidlc/memory/memory-bank.yaml +0 -39
- package/src/iris_bundle/.iris/aidlc/memory/unit-rules.md +0 -9
- package/src/iris_bundle/.iris/aidlc/quick-start.md +0 -24
- package/src/iris_bundle/.iris/aidlc/skills/execution/implementation.md +0 -14
- package/src/iris_bundle/.iris/aidlc/skills/execution/refactoring.md +0 -13
- package/src/iris_bundle/.iris/aidlc/skills/execution/scaffold-generation.md +0 -15
- package/src/iris_bundle/.iris/aidlc/skills/governance/escalation.md +0 -13
- package/src/iris_bundle/.iris/aidlc/skills/governance/quality-gates.md +0 -14
- package/src/iris_bundle/.iris/aidlc/skills/governance/stop-conditions.md +0 -11
- package/src/iris_bundle/.iris/aidlc/skills/reasoning/decomposition.md +0 -23
- package/src/iris_bundle/.iris/aidlc/skills/reasoning/risk-analysis.md +0 -14
- package/src/iris_bundle/.iris/aidlc/skills/reasoning/verification.md +0 -21
- package/src/iris_bundle/.iris/aidlc/standards/artifacts-registry.md +0 -38
- package/src/iris_bundle/.iris/aidlc/standards/decision-logging.md +0 -16
- package/src/iris_bundle/.iris/aidlc/standards/doctrine-structure.md +0 -31
- package/src/iris_bundle/.iris/aidlc/standards/documentation-rules.md +0 -15
- package/src/iris_bundle/.iris/aidlc/standards/file-structure.md +0 -21
- package/src/iris_bundle/.iris/aidlc/standards/naming-conventions.md +0 -18
- package/src/iris_bundle/.iris/aidlc/standards/phases-and-gates.md +0 -25
- package/src/iris_bundle/.iris/aidlc/standards/routes-and-routing.md +0 -35
- package/src/iris_bundle/.iris/aidlc/standards/tool-wrappers.md +0 -32
- package/src/iris_bundle/.iris/aidlc/templates/bolt.md +0 -23
- package/src/iris_bundle/.iris/aidlc/templates/doctrine-doc-template.md +0 -33
- package/src/iris_bundle/.iris/aidlc/templates/intent.md +0 -23
- package/src/iris_bundle/.iris/aidlc/templates/log.md +0 -24
- package/src/iris_bundle/.iris/aidlc/templates/review.md +0 -21
- package/src/iris_bundle/.iris/aidlc/templates/unit.md +0 -31
- package/src/iris_bundle/.iris/aidlc/validation/failure-modes.md +0 -16
- package/src/iris_bundle/.iris/aidlc/validation/phase-preconditions.md +0 -21
- package/src/iris_bundle/.iris/aidlc/validation/quality-checklist.md +0 -20
- package/src/iris_bundle/.iris/policy.yaml +0 -27
- package/src/iris_bundle/.iris/routes.yaml +0 -98
- package/src/iris_bundle/.iris/state.yaml +0 -7
- package/src/iris_bundle/.iris/tools/claude/.claude/claude.md +0 -9
- package/src/iris_bundle/.iris/tools/claude/.claude/commands/compare-specs.md +0 -203
- package/src/iris_bundle/.iris/tools/claude/.claude/commands/iris-construction-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/claude/.claude/commands/iris-inception-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/claude/.claude/commands/iris-master-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/claude/.claude/commands/iris-operations-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/codex/AGENTS.md +0 -15
- package/src/iris_bundle/.iris/tools/cursor/.cursor/commands/iris-construction-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/cursor/.cursor/commands/iris-inception-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/cursor/.cursor/commands/iris-master-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/cursor/.cursor/commands/iris-operations-agent.md +0 -25
- package/src/iris_bundle/.iris/tools/gemini/.gemini/commands/iris-construction-agent.toml +0 -29
- package/src/iris_bundle/.iris/tools/gemini/.gemini/commands/iris-inception-agent.toml +0 -29
- package/src/iris_bundle/.iris/tools/gemini/.gemini/commands/iris-master-agent.toml +0 -29
- package/src/iris_bundle/.iris/tools/gemini/.gemini/commands/iris-operations-agent.toml +0 -29
package/dist/iris/installer.js
CHANGED
|
@@ -4,8 +4,9 @@ import kleur from "kleur";
|
|
|
4
4
|
import inquirer from "inquirer";
|
|
5
5
|
import { ensureDir, sanitizeFolderName, spawnAsync } from "../lib.js";
|
|
6
6
|
import { updateManifest } from "./manifest.js";
|
|
7
|
-
|
|
8
|
-
const
|
|
7
|
+
import { getBundleRoot, getBundledFrameworksDir } from "./bundle.js";
|
|
8
|
+
export const TOOLS = ["claude", "cursor", "gemini", "antigravity", "codex", "auto"];
|
|
9
|
+
const BUNDLE_ROOT = getBundleRoot();
|
|
9
10
|
/**
|
|
10
11
|
* Ensure package.json exists in the target directory.
|
|
11
12
|
* If missing, create a minimal one.
|
|
@@ -63,10 +64,11 @@ export function detectTools(root) {
|
|
|
63
64
|
gemini: fs.existsSync(path.join(root, ".gemini")),
|
|
64
65
|
antigravity: fs.existsSync(path.join(root, ".antigravity")),
|
|
65
66
|
codex: fs.existsSync(path.join(root, "AGENTS.md")),
|
|
67
|
+
auto: false,
|
|
66
68
|
};
|
|
67
69
|
return TOOLS.filter(t => checks[t]);
|
|
68
70
|
}
|
|
69
|
-
export async function installIris(root, tools) {
|
|
71
|
+
export async function installIris(root, tools, options = {}) {
|
|
70
72
|
// Step 0: Ensure package.json exists (for empty folder support)
|
|
71
73
|
ensurePackageJson(root);
|
|
72
74
|
const installedPaths = [];
|
|
@@ -79,49 +81,112 @@ export async function installIris(root, tools) {
|
|
|
79
81
|
const targetIris = path.join(root, ".iris");
|
|
80
82
|
const targetDoctrine = path.join(targetIris, "aidlc");
|
|
81
83
|
const targetTools = path.join(targetIris, "tools");
|
|
82
|
-
// Step C:
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
// Step C: Framework & Core Configuration
|
|
85
|
+
// New (Step 3): Install iris-core framework as the Single Source of Truth
|
|
86
|
+
// Then populate legacy mirrors (.iris/aidlc, policy.yaml, etc) from it.
|
|
87
|
+
const bundledFrameworksDir = getBundledFrameworksDir();
|
|
88
|
+
const sourceIrisCore = path.join(bundledFrameworksDir, "iris-core");
|
|
89
|
+
const frameworksDir = path.join(targetIris, "frameworks");
|
|
90
|
+
const targetIrisCore = path.join(frameworksDir, "iris-core");
|
|
91
|
+
ensureDir(frameworksDir);
|
|
92
|
+
// 1. Install Framework (Idempotent: Skip if exists unless --force)
|
|
93
|
+
let installedFramework = false;
|
|
94
|
+
if (fs.existsSync(sourceIrisCore)) {
|
|
95
|
+
if (fs.existsSync(targetIrisCore) && !options.force) {
|
|
96
|
+
// Skip
|
|
97
|
+
// console.log(kleur.gray("Skipped iris-core (exists)"));
|
|
98
|
+
// Check for version mismatch (UX enhancement)
|
|
99
|
+
try {
|
|
100
|
+
const targetManifestPath = path.join(targetIrisCore, "framework.yaml");
|
|
101
|
+
const sourceManifestPath = path.join(sourceIrisCore, "framework.yaml");
|
|
102
|
+
if (fs.existsSync(targetManifestPath) && fs.existsSync(sourceManifestPath)) {
|
|
103
|
+
const targetManifest = JSON.parse(fs.readFileSync(targetManifestPath, 'utf8')); // Yaml is subset of json? No, need yaml parser or simple regex for version.
|
|
104
|
+
// We don't have yaml parser imported here easily (maybe?) inquirer or kleur doesn't help.
|
|
105
|
+
// Let's use simple regex since we control the manifest format mostly.
|
|
106
|
+
const getVer = (content) => {
|
|
107
|
+
const m = content.match(/version:\s*["']?([\w.-]+)["']?/);
|
|
108
|
+
return m ? m[1] : null;
|
|
109
|
+
};
|
|
110
|
+
const targetVer = getVer(fs.readFileSync(targetManifestPath, 'utf8'));
|
|
111
|
+
const sourceVer = getVer(fs.readFileSync(sourceManifestPath, 'utf8'));
|
|
112
|
+
if (targetVer && sourceVer && targetVer !== sourceVer) {
|
|
113
|
+
console.log(kleur.yellow(`Warning: Installed iris-core is v${targetVer}, but bundled is v${sourceVer}.`));
|
|
114
|
+
console.log(kleur.yellow(`Run 'iris install --force' to update (will overwrite changes).`));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Install / Overwrite
|
|
124
|
+
if (fs.existsSync(targetIrisCore)) {
|
|
125
|
+
// Force overwrite implies we can clobber.
|
|
126
|
+
// Simple recursive copy over top works for updating files.
|
|
93
127
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
ensureDir(targetIrisCore);
|
|
129
|
+
copyDirRec(sourceIrisCore, targetIrisCore, [], root, true);
|
|
130
|
+
installedFramework = true;
|
|
131
|
+
installedPaths.push({
|
|
132
|
+
path: ".iris/frameworks/iris-core",
|
|
133
|
+
type: "directory",
|
|
134
|
+
status: "installed"
|
|
135
|
+
});
|
|
100
136
|
}
|
|
101
|
-
// else keep existing
|
|
102
137
|
}
|
|
103
|
-
|
|
104
|
-
|
|
138
|
+
// 2. Compatibility Mirror: .iris/aidlc
|
|
139
|
+
// Populated FROM the valid target iris-core framework
|
|
140
|
+
// This ensures legacy tools see the same templates as the framework.
|
|
141
|
+
const targetAidlc = path.join(targetIris, "aidlc");
|
|
142
|
+
ensureDir(targetAidlc);
|
|
143
|
+
// Mirror Templates
|
|
144
|
+
const frameworkTemplates = path.join(targetIrisCore, "templates");
|
|
145
|
+
const aidlcTemplates = path.join(targetAidlc, "templates");
|
|
146
|
+
// Only mirror if framework templates exist
|
|
147
|
+
if (fs.existsSync(frameworkTemplates)) {
|
|
148
|
+
// If aidlc/templates exists, only overwrite if force?
|
|
149
|
+
// Or always align mirror to framework?
|
|
150
|
+
// "Keep it read-only in spirit". Aligning is safer.
|
|
151
|
+
// But if user modified aidlc, we clobber?
|
|
152
|
+
// Let's protect user edits in aidlc too unless force.
|
|
153
|
+
if (!fs.existsSync(aidlcTemplates) || options.force) {
|
|
154
|
+
ensureDir(aidlcTemplates);
|
|
155
|
+
copyDirRec(frameworkTemplates, aidlcTemplates, [], root, true);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Legacy Config Mirrors (policy.yaml, routes.yaml)
|
|
159
|
+
// Copy from framework -> .iris/ root IF MISSING (don't overwrite config by default)
|
|
160
|
+
// We do NOT use --force to overwrite these configs usually, as they are user-owned once installed.
|
|
161
|
+
// Spec: "Default: Skip if exists (preserve user edits). If --force: Overwrite."
|
|
162
|
+
// So we respect that for these files too if we consider them part of "install".
|
|
163
|
+
const frameworkPolicy = path.join(targetIrisCore, "policy.yaml");
|
|
164
|
+
const targetPolicy = path.join(targetIris, "policy.yaml");
|
|
165
|
+
if (fs.existsSync(frameworkPolicy) && (!fs.existsSync(targetPolicy) || options.force)) {
|
|
166
|
+
fs.copyFileSync(frameworkPolicy, targetPolicy);
|
|
167
|
+
installedPaths.push({ path: ".iris/policy.yaml", type: "file", status: "installed" });
|
|
168
|
+
}
|
|
169
|
+
const frameworkRoutes = path.join(targetIrisCore, "routes.yaml");
|
|
170
|
+
const targetRoutes = path.join(targetIris, "routes.yaml");
|
|
171
|
+
if (fs.existsSync(frameworkRoutes) && (!fs.existsSync(targetRoutes) || options.force)) {
|
|
172
|
+
fs.copyFileSync(frameworkRoutes, targetRoutes);
|
|
173
|
+
installedPaths.push({ path: ".iris/routes.yaml", type: "file", status: "installed" });
|
|
174
|
+
}
|
|
175
|
+
// Add README to aidlc
|
|
176
|
+
const mirrorReadme = path.join(targetAidlc, "README.md");
|
|
177
|
+
if (!fs.existsSync(mirrorReadme)) {
|
|
178
|
+
fs.writeFileSync(mirrorReadme, "# Legacy Mirror\n\nThis directory is a compatibility mirror of `.iris/frameworks/iris-core`.\nDo not edit files here; they may be overwritten by system updates.\nEdit the framework files instead.\n");
|
|
105
179
|
}
|
|
106
|
-
// 2. Policy & Routes (Overwrite or keep? Usually overwrite core config if missing, but maybe prompt if exists?)
|
|
107
|
-
// Spec says: "Install .iris/policy.yaml... .iris/routes.yaml"
|
|
108
|
-
// Let's safe install: only if missing? Or overwrite?
|
|
109
|
-
// Spec doesn't explicitly say prompt for policy.
|
|
110
|
-
// "Install IRIS core... .iris/policy.yaml"
|
|
111
|
-
// Let's assume non-destructive for config if it exists, to preserve user edits.
|
|
112
|
-
safeCopy(path.join(bundleIris, "policy.yaml"), path.join(targetIris, "policy.yaml"), installedPaths, root);
|
|
113
|
-
safeCopy(path.join(bundleIris, "routes.yaml"), path.join(targetIris, "routes.yaml"), installedPaths, root);
|
|
114
180
|
// 3. State (only if missing)
|
|
115
181
|
if (!fs.existsSync(path.join(targetIris, "state.yaml"))) {
|
|
116
|
-
fs.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
});
|
|
182
|
+
if (fs.existsSync(path.join(bundleIris, "state.yaml"))) {
|
|
183
|
+
fs.copyFileSync(path.join(bundleIris, "state.yaml"), path.join(targetIris, "state.yaml"));
|
|
184
|
+
installedPaths.push({
|
|
185
|
+
path: ".iris/state.yaml",
|
|
186
|
+
type: "file",
|
|
187
|
+
status: "installed"
|
|
188
|
+
});
|
|
189
|
+
}
|
|
125
190
|
}
|
|
126
191
|
// Step D: Memory Bank Skeleton
|
|
127
192
|
const memoryDirs = ["intents", "units", "bolts", "standards", "logs"];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function checkInteractiveMode(options) {
|
|
2
|
+
// 1. Force Interactive overrides all
|
|
3
|
+
if (options.forceInteractive) {
|
|
4
|
+
return { isInteractive: true, reason: "Forced by user flag" };
|
|
5
|
+
}
|
|
6
|
+
// 2. Explicit Non-Interactive
|
|
7
|
+
if (options.nonInteractive) {
|
|
8
|
+
return { isInteractive: false, reason: "User requested non-interactive mode" };
|
|
9
|
+
}
|
|
10
|
+
// 3. CI Detection
|
|
11
|
+
// Common CI env vars: CI, GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, TRAVIS
|
|
12
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) {
|
|
13
|
+
return { isInteractive: false, reason: "CI environment detected" };
|
|
14
|
+
}
|
|
15
|
+
// 4. TTY Detection
|
|
16
|
+
// process.stdin.isTTY is undefined or false if piped/redirected
|
|
17
|
+
if (!process.stdin.isTTY) {
|
|
18
|
+
return { isInteractive: false, reason: "No TTY detected (piped input)" };
|
|
19
|
+
}
|
|
20
|
+
return { isInteractive: true };
|
|
21
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { writeJsonAtomic } from "../fs/atomic.js";
|
|
4
|
+
import { InquirerIO } from "./interview-io.js";
|
|
5
|
+
import { ensureDir } from "../../lib.js";
|
|
6
|
+
import { getInboxPath, getHistoryDraftPath, getLatestDraftPath, getLegacyDraftPath } from "../paths.js";
|
|
7
|
+
import { createEmptyDraft, validateDraft } from "./intent-schema.js";
|
|
8
|
+
// --- Rubric Logic Moved to Schema/Shared or kept here? ---
|
|
9
|
+
// The prompt asked for logic in schema.ts? No, prompt asked for SCHEMA definition in schema.ts.
|
|
10
|
+
// Let's keep logic in schema.ts or here.
|
|
11
|
+
// Actually, `calculateConfidence` is better in `intent-schema.ts` if we want to test it easily without inquirer deps.
|
|
12
|
+
// But my previous `intent-schema.ts` didn't have `calculateConfidence`.
|
|
13
|
+
// I should probably move `calculateConfidence` into `intent-schema.ts` OR just import the type and keep logic here.
|
|
14
|
+
// To minimize changes to `intent-schema.ts` which I already wrote, I will keep `calculateConfidence` here
|
|
15
|
+
// BUT I need to make sure I use the types correctly.
|
|
16
|
+
// WAIT, I ALREADY WROTE `intent-schema.ts` WITHOUT `calculateConfidence`.
|
|
17
|
+
// So I will implement `calculateConfidence` here in this file, extending the one in schema if needed or just standalone.
|
|
18
|
+
// Re-defining locally to match the file I tried to write before
|
|
19
|
+
// ACTUALLY, checking `intent-schema.ts` content from Step 193... it does NOT have calculateConfidence.
|
|
20
|
+
// So I must implement it here.
|
|
21
|
+
export function formatIntentDraft(draft) {
|
|
22
|
+
return `
|
|
23
|
+
# Intent: ${draft.goal}
|
|
24
|
+
|
|
25
|
+
## Context
|
|
26
|
+
- **Target User:** ${draft.userType}
|
|
27
|
+
- **Project Phase:** ${draft.projectPhase}
|
|
28
|
+
- **Confidence:** ${draft.confidence.score}
|
|
29
|
+
|
|
30
|
+
## Success Criteria
|
|
31
|
+
${draft.successCriteria.length > 0 ? draft.successCriteria.map(c => `- ${c}`).join("\n") : "(None provided)"}
|
|
32
|
+
|
|
33
|
+
## Technical Constraints
|
|
34
|
+
${draft.constraints.map(c => `- ${c}`).join("\n")}
|
|
35
|
+
|
|
36
|
+
## Tooling Stack
|
|
37
|
+
${draft.tools.map(t => `- ${t}`).join("\n")}
|
|
38
|
+
|
|
39
|
+
## Additional Details
|
|
40
|
+
${draft.nonGoals.length > 0 ? `\n### Non-Goals\n${draft.nonGoals.map(x => `- ${x}`).join("\n")}` : ""}
|
|
41
|
+
${draft.acceptanceTests.length > 0 ? `\n### Acceptance Tests\n${draft.acceptanceTests.map(x => `- ${x}`).join("\n")}` : ""}
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
*Generated via IRIS Interactive Interview*
|
|
45
|
+
`.trim();
|
|
46
|
+
}
|
|
47
|
+
function calcConfidenceScore(draft) {
|
|
48
|
+
let score = 0;
|
|
49
|
+
const explanation = [];
|
|
50
|
+
// 1. Goal Clarity
|
|
51
|
+
if (draft.goal && draft.goal.split(" ").length > 3) {
|
|
52
|
+
score += 0.2;
|
|
53
|
+
explanation.push("+0.2 for clear goal statement");
|
|
54
|
+
}
|
|
55
|
+
else if (draft.goal) {
|
|
56
|
+
score += 0.1;
|
|
57
|
+
explanation.push("+0.1 for goal presence");
|
|
58
|
+
}
|
|
59
|
+
const successLen = draft.successCriteria?.length || 0;
|
|
60
|
+
if (successLen > 0) {
|
|
61
|
+
score += 0.2;
|
|
62
|
+
explanation.push(`+0.2 for ${successLen} success criteria`);
|
|
63
|
+
}
|
|
64
|
+
const acceptLen = draft.acceptanceTests?.length || 0;
|
|
65
|
+
if (acceptLen > 0) {
|
|
66
|
+
score += 0.1;
|
|
67
|
+
explanation.push(`+0.1 for ${acceptLen} acceptance tests`);
|
|
68
|
+
}
|
|
69
|
+
if (draft.constraints && draft.constraints.length > 0) {
|
|
70
|
+
score += 0.1;
|
|
71
|
+
explanation.push("+0.1 for identified constraints");
|
|
72
|
+
}
|
|
73
|
+
if (draft.tools && draft.tools.length > 0) {
|
|
74
|
+
score += 0.1;
|
|
75
|
+
explanation.push("+0.1 for tool selection");
|
|
76
|
+
}
|
|
77
|
+
if (draft.nonGoals && draft.nonGoals.length > 0) {
|
|
78
|
+
score += 0.1;
|
|
79
|
+
explanation.push("+0.1 for explicit non-goals");
|
|
80
|
+
}
|
|
81
|
+
if (draft.userType && draft.projectPhase) {
|
|
82
|
+
score += 0.1;
|
|
83
|
+
explanation.push("+0.1 for user context & phase");
|
|
84
|
+
}
|
|
85
|
+
score = Math.min(1.0, Math.max(0, Math.round(score * 10) / 10));
|
|
86
|
+
return { score, explanation };
|
|
87
|
+
}
|
|
88
|
+
// Export for tests if needed, but the main export is runIntentInterview
|
|
89
|
+
export { calcConfidenceScore as calculateConfidence };
|
|
90
|
+
// --- Interview Logic ---
|
|
91
|
+
export async function runIntentInterview(initialIntent, config, existingDraft, io = new InquirerIO()) {
|
|
92
|
+
io.header("\n🎤 Interactive Intent Interview");
|
|
93
|
+
if (config?.guidance?.systemNotes) {
|
|
94
|
+
io.print(kleur.dim(config.guidance.systemNotes));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
io.print(kleur.dim("Let's clarify your goals before we start building.\n"));
|
|
98
|
+
}
|
|
99
|
+
const draftPartial = existingDraft ? { ...existingDraft } : createEmptyDraft();
|
|
100
|
+
// If we have an existing draft, we assume we are resuming/refining, so we don't overwrite unless intent is new?
|
|
101
|
+
// Actually if initialIntent is provided on resume, it might be the "follow up prompt" context, which isn't a draft field.
|
|
102
|
+
// But draftPartial.goal is the high level goal.
|
|
103
|
+
if (!existingDraft && initialIntent) {
|
|
104
|
+
draftPartial.goal = initialIntent;
|
|
105
|
+
}
|
|
106
|
+
// 1. Goal (Skip if exists)
|
|
107
|
+
if (!draftPartial.goal) {
|
|
108
|
+
const { goal } = await io.ask([
|
|
109
|
+
{
|
|
110
|
+
type: "input",
|
|
111
|
+
name: "goal",
|
|
112
|
+
message: config?.guidance?.starterQuestions?.[0] || "What are you trying to build?",
|
|
113
|
+
validate: (input) => input.trim().length > 0 || "Please provide a goal."
|
|
114
|
+
}
|
|
115
|
+
]);
|
|
116
|
+
draftPartial.goal = goal;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// If resuming or passed in arg
|
|
120
|
+
io.print(`${kleur.cyan("? ")}Goal: ${kleur.cyan(draftPartial.goal || "")}`);
|
|
121
|
+
}
|
|
122
|
+
// 2. User & Phase
|
|
123
|
+
const context = await io.ask([
|
|
124
|
+
{
|
|
125
|
+
type: "list",
|
|
126
|
+
name: "userType",
|
|
127
|
+
message: "Who is the primary user?",
|
|
128
|
+
choices: [
|
|
129
|
+
"Individual Developer",
|
|
130
|
+
"Team / Connectors",
|
|
131
|
+
"Enterprise / Organization",
|
|
132
|
+
"End Consumers"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: "list",
|
|
137
|
+
name: "projectPhase",
|
|
138
|
+
message: "What phase is this project in?",
|
|
139
|
+
choices: [
|
|
140
|
+
{ name: "Inception (New Idea)", value: "Inception" },
|
|
141
|
+
{ name: "Construction (Active Dev)", value: "Construction" },
|
|
142
|
+
{ name: "Operations (Maintenance)", value: "Operations" }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
]);
|
|
146
|
+
Object.assign(draftPartial, context);
|
|
147
|
+
// 3. Success Criteria (Required)
|
|
148
|
+
io.print("\n" + kleur.bold("Success Criteria"));
|
|
149
|
+
// Loop until we have at least one criteria (Enforcement)
|
|
150
|
+
while (true) {
|
|
151
|
+
io.print(kleur.dim("What must be true for this to be considered done? (Enter empty line to finish)"));
|
|
152
|
+
const criteria = [];
|
|
153
|
+
if (draftPartial.successCriteria)
|
|
154
|
+
criteria.push(...draftPartial.successCriteria);
|
|
155
|
+
while (true) {
|
|
156
|
+
const { item } = await io.ask([{
|
|
157
|
+
type: "input",
|
|
158
|
+
name: "item",
|
|
159
|
+
message: `Criterion ${criteria.length + 1}:`
|
|
160
|
+
}]);
|
|
161
|
+
if (!item.trim())
|
|
162
|
+
break;
|
|
163
|
+
criteria.push(item.trim());
|
|
164
|
+
}
|
|
165
|
+
draftPartial.successCriteria = criteria;
|
|
166
|
+
if (criteria.length === 0) {
|
|
167
|
+
io.warn("⚠ At least one Success Criterion is required.");
|
|
168
|
+
const { retry } = await io.ask([{
|
|
169
|
+
type: "confirm",
|
|
170
|
+
name: "retry",
|
|
171
|
+
message: "Try adding criteria again?",
|
|
172
|
+
default: true
|
|
173
|
+
}]);
|
|
174
|
+
if (!retry) {
|
|
175
|
+
io.warn("Cannot proceed without success criteria. Interview cancelled.");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// 4. Constraints (Multi-select)
|
|
184
|
+
const { constraints } = await io.ask([
|
|
185
|
+
{
|
|
186
|
+
type: "checkbox",
|
|
187
|
+
name: "constraints",
|
|
188
|
+
message: "What constraints matter most?",
|
|
189
|
+
choices: [
|
|
190
|
+
"Time (Speed to Market)",
|
|
191
|
+
"Quality (Robustness)",
|
|
192
|
+
"Compliance / Security",
|
|
193
|
+
"Performance",
|
|
194
|
+
"Cost / Budget",
|
|
195
|
+
"Simplicity"
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
]);
|
|
199
|
+
draftPartial.constraints = constraints;
|
|
200
|
+
// 5. Tools
|
|
201
|
+
const { tools } = await io.ask([
|
|
202
|
+
{
|
|
203
|
+
type: "checkbox",
|
|
204
|
+
name: "tools",
|
|
205
|
+
message: "Which tools/platforms are involved?",
|
|
206
|
+
choices: [
|
|
207
|
+
{ name: "Node.js / TypeScript", checked: true },
|
|
208
|
+
"Python",
|
|
209
|
+
"React / Frontend",
|
|
210
|
+
"Docker / K8s",
|
|
211
|
+
"AWS / Cloud",
|
|
212
|
+
"Database (SQL/NoSQL)"
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
]);
|
|
216
|
+
draftPartial.tools = tools;
|
|
217
|
+
// 6. Optional Fields
|
|
218
|
+
const { addMore } = await io.ask([{
|
|
219
|
+
type: "confirm",
|
|
220
|
+
name: "addMore",
|
|
221
|
+
message: "Add non-goals, risks, or acceptance tests? (Optional)",
|
|
222
|
+
default: false
|
|
223
|
+
}]);
|
|
224
|
+
if (addMore) {
|
|
225
|
+
// Non Goals
|
|
226
|
+
io.print("\n" + kleur.bold("Non-Goals (What are we NOT doing?)"));
|
|
227
|
+
while (true) {
|
|
228
|
+
const { item } = await io.ask([{ type: "input", name: "item", message: ">" }]);
|
|
229
|
+
if (!item.trim())
|
|
230
|
+
break;
|
|
231
|
+
if (!draftPartial.nonGoals)
|
|
232
|
+
draftPartial.nonGoals = [];
|
|
233
|
+
draftPartial.nonGoals.push(item.trim());
|
|
234
|
+
}
|
|
235
|
+
// Acceptance Tests
|
|
236
|
+
io.print("\n" + kleur.bold("Acceptance Tests (How will we verify?)"));
|
|
237
|
+
while (true) {
|
|
238
|
+
const { item } = await io.ask([{ type: "input", name: "item", message: ">" }]);
|
|
239
|
+
if (!item.trim())
|
|
240
|
+
break;
|
|
241
|
+
if (!draftPartial.acceptanceTests)
|
|
242
|
+
draftPartial.acceptanceTests = [];
|
|
243
|
+
draftPartial.acceptanceTests.push(item.trim());
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Calculate Confidence
|
|
247
|
+
const rubric = calcConfidenceScore(draftPartial);
|
|
248
|
+
// Assemble final draft
|
|
249
|
+
const draft = {
|
|
250
|
+
...createEmptyDraft(),
|
|
251
|
+
...draftPartial,
|
|
252
|
+
confidence: rubric,
|
|
253
|
+
// Ensure successCriteria is array
|
|
254
|
+
successCriteria: draftPartial.successCriteria || [],
|
|
255
|
+
createdAt: new Date().toISOString()
|
|
256
|
+
};
|
|
257
|
+
// Summary
|
|
258
|
+
io.divider();
|
|
259
|
+
io.header("📋 Intent Summary");
|
|
260
|
+
io.print(`Goal: ${draft.goal}`);
|
|
261
|
+
io.print(`Context: ${draft.userType} / ${draft.projectPhase}`);
|
|
262
|
+
io.print(`Criteria: ${draft.successCriteria.length} items`);
|
|
263
|
+
io.print(`Tools: ${draft.tools.join(", ")}`);
|
|
264
|
+
io.print("");
|
|
265
|
+
const scoreColor = draft.confidence.score > 0.7 ? kleur.green : draft.confidence.score > 0.4 ? kleur.yellow : kleur.red;
|
|
266
|
+
io.print(`Confidence Score: ${scoreColor(draft.confidence.score.toFixed(2) + " / 1.0")}`);
|
|
267
|
+
draft.confidence.explanation.forEach(exp => io.print(kleur.dim(` ${exp}`)));
|
|
268
|
+
io.divider();
|
|
269
|
+
// Validation check
|
|
270
|
+
const val = validateDraft(draft);
|
|
271
|
+
if (!val.valid) {
|
|
272
|
+
io.warn(kleur.red("Draft validation failed:"));
|
|
273
|
+
val.errors.forEach(e => io.print(`- ${e}`));
|
|
274
|
+
io.print("Please restart or fix inputs.");
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const { confirm } = await io.ask([
|
|
278
|
+
{
|
|
279
|
+
type: "confirm",
|
|
280
|
+
name: "confirm",
|
|
281
|
+
message: "Do you want to generate intent artifacts now?",
|
|
282
|
+
default: true
|
|
283
|
+
}
|
|
284
|
+
]);
|
|
285
|
+
if (!confirm) {
|
|
286
|
+
io.warn("Interview cancelled. No artifacts generated.");
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return draft;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Saves intent draft enforcing schema.
|
|
293
|
+
* @param writeLegacy If true, also writes .iris/inbox/intent-draft.json (deprecated)
|
|
294
|
+
*/
|
|
295
|
+
export function saveIntentDraft(root, draft, writeLegacy = false) {
|
|
296
|
+
const inboxDir = getInboxPath(root);
|
|
297
|
+
ensureDir(inboxDir);
|
|
298
|
+
// Enforce Schema Defaults & Versioning
|
|
299
|
+
const fullDraft = {
|
|
300
|
+
...createEmptyDraft(),
|
|
301
|
+
...draft,
|
|
302
|
+
createdAt: draft.createdAt || new Date().toISOString()
|
|
303
|
+
};
|
|
304
|
+
// Recalculate confidence if missing
|
|
305
|
+
if (!draft.confidence) {
|
|
306
|
+
fullDraft.confidence = calcConfidenceScore(fullDraft);
|
|
307
|
+
}
|
|
308
|
+
// 1. Save History
|
|
309
|
+
const historyFile = getHistoryDraftPath(root, new Date(fullDraft.createdAt));
|
|
310
|
+
// 2. Save Latest (Canonical)
|
|
311
|
+
const latestFile = getLatestDraftPath(root);
|
|
312
|
+
writeJsonAtomic(historyFile, fullDraft);
|
|
313
|
+
writeJsonAtomic(latestFile, fullDraft);
|
|
314
|
+
// 3. Optional Legacy
|
|
315
|
+
if (writeLegacy) {
|
|
316
|
+
const legacyFile = getLegacyDraftPath(root);
|
|
317
|
+
writeJsonAtomic(legacyFile, fullDraft);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Migrates legacy intent-draft.json to intent-draft.latest.json.
|
|
322
|
+
*/
|
|
323
|
+
export function migrateLegacyDraft(root) {
|
|
324
|
+
const legacyFile = getLegacyDraftPath(root);
|
|
325
|
+
const latestFile = getLatestDraftPath(root);
|
|
326
|
+
if (fs.existsSync(legacyFile) && !fs.existsSync(latestFile)) {
|
|
327
|
+
try {
|
|
328
|
+
const content = JSON.parse(fs.readFileSync(legacyFile, "utf-8"));
|
|
329
|
+
// Enforce schema by saving via normalized function
|
|
330
|
+
// Treat content as partial
|
|
331
|
+
if (!content.successCriteria) {
|
|
332
|
+
content.successCriteria = ["(Migrated from legacy draft)"];
|
|
333
|
+
}
|
|
334
|
+
// Re-save (generates latest + history)
|
|
335
|
+
// Do NOT write legacy again (avoid loop), so writeLegacy=false
|
|
336
|
+
saveIntentDraft(root, content, false);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
console.error(kleur.red(`Failed to migrate legacy draft: ${e}`));
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const CURRENT_SCHEMA_VERSION = "1";
|
|
2
|
+
export function createEmptyDraft() {
|
|
3
|
+
return {
|
|
4
|
+
version: CURRENT_SCHEMA_VERSION,
|
|
5
|
+
createdAt: new Date().toISOString(),
|
|
6
|
+
goal: "",
|
|
7
|
+
userType: "",
|
|
8
|
+
projectPhase: "",
|
|
9
|
+
constraints: [],
|
|
10
|
+
tools: [],
|
|
11
|
+
successCriteria: [],
|
|
12
|
+
nonGoals: [],
|
|
13
|
+
assumptions: [],
|
|
14
|
+
risks: [],
|
|
15
|
+
acceptanceTests: [],
|
|
16
|
+
confidence: { score: 0, explanation: [] }
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function validateDraft(draft) {
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (!draft.goal)
|
|
22
|
+
errors.push("Goal is required");
|
|
23
|
+
if (!draft.successCriteria || draft.successCriteria.length === 0) {
|
|
24
|
+
errors.push("At least one Success Criterion is required");
|
|
25
|
+
}
|
|
26
|
+
// We can add more strict checks here if needed
|
|
27
|
+
return { valid: errors.length === 0, errors };
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import kleur from "kleur";
|
|
3
|
+
export class InquirerIO {
|
|
4
|
+
async ask(questions) {
|
|
5
|
+
// map Question interface to inquirer question
|
|
6
|
+
// Inquirer types are slightly different, but compatible enough for pass-through usually
|
|
7
|
+
const answers = await inquirer.prompt(questions);
|
|
8
|
+
return answers;
|
|
9
|
+
}
|
|
10
|
+
print(message) {
|
|
11
|
+
console.log(message);
|
|
12
|
+
}
|
|
13
|
+
header(text) {
|
|
14
|
+
console.log(kleur.bold(text));
|
|
15
|
+
}
|
|
16
|
+
divider() {
|
|
17
|
+
console.log(kleur.dim("=========================================="));
|
|
18
|
+
}
|
|
19
|
+
warn(message) {
|
|
20
|
+
console.log(kleur.yellow(message));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import yaml from "js-yaml";
|
|
3
|
+
import kleur from "kleur";
|
|
4
|
+
import { KNOWN_INTENT_FIELDS } from "./types.js";
|
|
5
|
+
export const DEFAULT_INTERVIEW_CONFIG = {
|
|
6
|
+
schemaVersion: 1,
|
|
7
|
+
required: ["successCriteria", "user", "problem"],
|
|
8
|
+
optional: ["nonGoals", "risks", "acceptanceTests"],
|
|
9
|
+
guidance: {
|
|
10
|
+
systemNotes: "You are a Product Manager / Business Analyst. Ask clarifying questions.",
|
|
11
|
+
starterQuestions: [
|
|
12
|
+
"Who is the user and what problem are we solving?",
|
|
13
|
+
"What does success look like (measurable)?",
|
|
14
|
+
"What are the constraints (time, budget, tech, legal)?"
|
|
15
|
+
],
|
|
16
|
+
redFlags: ["Ambiguous scope", "No success metric"]
|
|
17
|
+
},
|
|
18
|
+
weights: {
|
|
19
|
+
successCriteria: 3,
|
|
20
|
+
risks: 2
|
|
21
|
+
},
|
|
22
|
+
fieldPrompts: {
|
|
23
|
+
successCriteria: "What does success look like? How will we measure it?",
|
|
24
|
+
user: "Who is the specific user for this feature?",
|
|
25
|
+
problem: "What core problem are we solving?",
|
|
26
|
+
risks: "Are there any risks or non-goals we should state?",
|
|
27
|
+
constraints: "Are there any technical or budget constraints?"
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export function loadEffectiveInterviewConfig(framework) {
|
|
31
|
+
if (!framework || !framework.files.interview || !fs.existsSync(framework.files.interview)) {
|
|
32
|
+
return DEFAULT_INTERVIEW_CONFIG;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const content = fs.readFileSync(framework.files.interview, "utf8");
|
|
36
|
+
const raw = yaml.load(content);
|
|
37
|
+
// 1. Schema Version Check
|
|
38
|
+
if (raw.schemaVersion !== 1) {
|
|
39
|
+
console.error(kleur.yellow(`IRIS_WARNING: Unsupported interview schema version ${raw.schemaVersion}. Using default.`));
|
|
40
|
+
return DEFAULT_INTERVIEW_CONFIG;
|
|
41
|
+
}
|
|
42
|
+
// 2. Strict Field Validation
|
|
43
|
+
const allFields = [...(raw.required || []), ...(raw.optional || [])];
|
|
44
|
+
const unknownFields = allFields.filter(f => !KNOWN_INTENT_FIELDS.has(f));
|
|
45
|
+
if (unknownFields.length > 0) {
|
|
46
|
+
console.error(kleur.yellow(`IRIS_WARNING: Invalid interview configuration. Unknown fields: ${unknownFields.join(", ")}. Using default.`));
|
|
47
|
+
return DEFAULT_INTERVIEW_CONFIG;
|
|
48
|
+
}
|
|
49
|
+
// 3. Merge with minimal defaults (or just return raw if we trust it completely?)
|
|
50
|
+
// The plan says "If invalid/missing return DEFAULT".
|
|
51
|
+
// But if valid, we return the parsed object.
|
|
52
|
+
// Should we merge fieldPrompts if missing?
|
|
53
|
+
// Best to provide safety defaults for prompts if not overridden.
|
|
54
|
+
return {
|
|
55
|
+
schemaVersion: raw.schemaVersion,
|
|
56
|
+
required: raw.required || [],
|
|
57
|
+
optional: raw.optional || [],
|
|
58
|
+
guidance: {
|
|
59
|
+
systemNotes: raw.guidance?.systemNotes || DEFAULT_INTERVIEW_CONFIG.guidance.systemNotes,
|
|
60
|
+
starterQuestions: raw.guidance?.starterQuestions || DEFAULT_INTERVIEW_CONFIG.guidance.starterQuestions,
|
|
61
|
+
redFlags: raw.guidance?.redFlags || DEFAULT_INTERVIEW_CONFIG.guidance.redFlags
|
|
62
|
+
},
|
|
63
|
+
weights: raw.weights || {},
|
|
64
|
+
fieldPrompts: { ...DEFAULT_INTERVIEW_CONFIG.fieldPrompts, ...raw.fieldPrompts }
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
console.error(kleur.yellow(`IRIS_WARNING: Failed to parse interview.yaml: ${e.message}. Using default.`));
|
|
69
|
+
return DEFAULT_INTERVIEW_CONFIG;
|
|
70
|
+
}
|
|
71
|
+
}
|