agile-context-engineering 0.2.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -0
- package/CHANGELOG.md +82 -0
- package/README.md +27 -18
- package/agents/ace-product-owner.md +1 -1
- package/agents/ace-technical-application-architect.md +28 -0
- package/agents/ace-wiki-mapper.md +144 -29
- package/bin/install.js +67 -63
- package/hooks/ace-check-update.js +17 -9
- package/package.json +7 -5
- package/shared/lib/ace-core.js +308 -0
- package/shared/lib/ace-core.test.js +308 -0
- package/shared/lib/ace-github.js +753 -0
- package/shared/lib/ace-story.js +400 -0
- package/shared/lib/ace-story.test.js +250 -0
- package/{agile-context-engineering → shared}/utils/ui-formatting.md +299 -299
- package/skills/execute-story/SKILL.md +110 -0
- package/skills/execute-story/script.js +305 -0
- package/skills/execute-story/script.test.js +261 -0
- package/skills/execute-story/walkthrough-template.xml +255 -0
- package/{agile-context-engineering/workflows/execute-story.xml → skills/execute-story/workflow.xml} +83 -9
- package/skills/help/SKILL.md +69 -0
- package/skills/help/script.js +318 -0
- package/skills/help/script.test.js +183 -0
- package/{agile-context-engineering/workflows/help.xml → skills/help/workflow.xml} +8 -8
- package/skills/init-coding-standards/SKILL.md +72 -0
- package/{agile-context-engineering/templates/wiki/coding-standards.xml → skills/init-coding-standards/coding-standards-template.xml} +38 -0
- package/skills/init-coding-standards/script.js +59 -0
- package/skills/init-coding-standards/script.test.js +70 -0
- package/{agile-context-engineering/workflows/init-coding-standards.xml → skills/init-coding-standards/workflow.xml} +4 -9
- package/skills/map-cross-cutting/SKILL.md +89 -0
- package/skills/map-cross-cutting/workflow.xml +330 -0
- package/skills/map-guide/SKILL.md +89 -0
- package/skills/map-guide/workflow.xml +320 -0
- package/skills/map-pattern/SKILL.md +89 -0
- package/skills/map-pattern/workflow.xml +331 -0
- package/skills/map-story/SKILL.md +127 -0
- package/skills/map-story/templates/guide.xml +137 -0
- package/skills/map-story/templates/pattern.xml +159 -0
- package/skills/map-story/templates/system-cross-cutting.xml +197 -0
- package/skills/map-story/templates/walkthrough.xml +255 -0
- package/{agile-context-engineering/workflows/map-story.xml → skills/map-story/workflow.xml} +258 -9
- package/skills/map-subsystem/SKILL.md +111 -0
- package/skills/map-subsystem/script.js +60 -0
- package/skills/map-subsystem/script.test.js +68 -0
- package/skills/map-subsystem/templates/decizions.xml +115 -0
- package/skills/map-subsystem/templates/guide.xml +137 -0
- package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/module-discovery.xml +3 -3
- package/skills/map-subsystem/templates/pattern.xml +159 -0
- package/skills/map-subsystem/templates/system-cross-cutting.xml +197 -0
- package/skills/map-subsystem/templates/system.xml +381 -0
- package/skills/map-subsystem/templates/walkthrough.xml +255 -0
- package/{agile-context-engineering/workflows/map-subsystem.xml → skills/map-subsystem/workflow.xml} +17 -21
- package/skills/map-sys-doc/SKILL.md +90 -0
- package/skills/map-sys-doc/system.xml +381 -0
- package/skills/map-sys-doc/workflow.xml +336 -0
- package/skills/map-system/SKILL.md +85 -0
- package/skills/map-system/script.js +84 -0
- package/skills/map-system/script.test.js +73 -0
- package/skills/map-system/templates/wiki-readme.xml +297 -0
- package/{agile-context-engineering/workflows/map-system.xml → skills/map-system/workflow.xml} +11 -16
- package/skills/map-walkthrough/SKILL.md +92 -0
- package/skills/map-walkthrough/walkthrough.xml +255 -0
- package/skills/map-walkthrough/workflow.xml +457 -0
- package/skills/plan-backlog/SKILL.md +75 -0
- package/skills/plan-backlog/script.js +136 -0
- package/skills/plan-backlog/script.test.js +83 -0
- package/{agile-context-engineering/workflows/plan-backlog.xml → skills/plan-backlog/workflow.xml} +13 -21
- package/skills/plan-feature/SKILL.md +76 -0
- package/skills/plan-feature/script.js +148 -0
- package/skills/plan-feature/script.test.js +80 -0
- package/{agile-context-engineering/workflows/plan-feature.xml → skills/plan-feature/workflow.xml} +21 -29
- package/skills/plan-product-vision/SKILL.md +75 -0
- package/skills/plan-product-vision/script.js +60 -0
- package/skills/plan-product-vision/script.test.js +69 -0
- package/{agile-context-engineering/workflows/plan-product-vision.xml → skills/plan-product-vision/workflow.xml} +4 -9
- package/skills/plan-story/SKILL.md +116 -0
- package/skills/plan-story/script.js +326 -0
- package/skills/plan-story/script.test.js +240 -0
- package/skills/plan-story/story-template.xml +451 -0
- package/{agile-context-engineering/workflows/plan-story.xml → skills/plan-story/workflow.xml} +1285 -909
- package/skills/research-external-solution/SKILL.md +107 -0
- package/skills/research-external-solution/script.js +238 -0
- package/skills/research-external-solution/script.test.js +134 -0
- package/{agile-context-engineering/workflows/research-external-solution.xml → skills/research-external-solution/workflow.xml} +4 -6
- package/skills/research-integration-solution/SKILL.md +98 -0
- package/{agile-context-engineering/templates/product/story-integration-solution.xml → skills/research-integration-solution/integration-solution-template.xml} +1 -0
- package/skills/research-integration-solution/script.js +231 -0
- package/skills/research-integration-solution/script.test.js +134 -0
- package/{agile-context-engineering/workflows/research-integration-solution.xml → skills/research-integration-solution/workflow.xml} +4 -5
- package/skills/research-story-wiki/SKILL.md +92 -0
- package/skills/research-story-wiki/script.js +231 -0
- package/skills/research-story-wiki/script.test.js +138 -0
- package/{agile-context-engineering/templates/product/story-wiki.xml → skills/research-story-wiki/story-wiki-template.xml} +4 -0
- package/{agile-context-engineering/workflows/research-story-wiki.xml → skills/research-story-wiki/workflow.xml} +5 -6
- package/skills/research-technical-solution/SKILL.md +103 -0
- package/skills/research-technical-solution/script.js +231 -0
- package/skills/research-technical-solution/script.test.js +134 -0
- package/{agile-context-engineering/workflows/research-technical-solution.xml → skills/research-technical-solution/workflow.xml} +4 -5
- package/skills/review-story/SKILL.md +100 -0
- package/skills/review-story/script.js +257 -0
- package/skills/review-story/script.test.js +169 -0
- package/skills/review-story/story-template.xml +451 -0
- package/{agile-context-engineering/workflows/review-story.xml → skills/review-story/workflow.xml} +1 -3
- package/skills/update/SKILL.md +53 -0
- package/{agile-context-engineering/workflows/update.xml → skills/update/workflow.xml} +237 -207
- package/agile-context-engineering/src/ace-tools.js +0 -2881
- package/agile-context-engineering/src/ace-tools.test.js +0 -1089
- package/agile-context-engineering/templates/_command.md +0 -54
- package/agile-context-engineering/templates/_workflow.xml +0 -17
- package/agile-context-engineering/templates/config.json +0 -0
- package/agile-context-engineering/templates/product/integration-solution.xml +0 -0
- package/agile-context-engineering/templates/wiki/wiki-readme.xml +0 -276
- package/commands/ace/execute-story.md +0 -137
- package/commands/ace/help.md +0 -93
- package/commands/ace/init-coding-standards.md +0 -83
- package/commands/ace/map-story.md +0 -156
- package/commands/ace/map-subsystem.md +0 -138
- package/commands/ace/map-system.md +0 -92
- package/commands/ace/plan-backlog.md +0 -83
- package/commands/ace/plan-feature.md +0 -89
- package/commands/ace/plan-product-vision.md +0 -81
- package/commands/ace/plan-story.md +0 -145
- package/commands/ace/research-external-solution.md +0 -138
- package/commands/ace/research-integration-solution.md +0 -135
- package/commands/ace/research-story-wiki.md +0 -116
- package/commands/ace/research-technical-solution.md +0 -147
- package/commands/ace/review-story.md +0 -109
- package/commands/ace/update.md +0 -54
- /package/{agile-context-engineering → shared}/utils/questioning.xml +0 -0
- /package/{agile-context-engineering/templates/product/story.xml → skills/execute-story/story-template.xml} +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-cross-cutting}/system-cross-cutting.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-guide}/guide.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-pattern}/pattern.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/decizions.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/system.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/tech-debt-index.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-architecture.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-structure.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-architecture.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-structure.xml +0 -0
- /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/testing-framework.xml +0 -0
- /package/{agile-context-engineering/templates/product/product-backlog.xml → skills/plan-backlog/product-backlog-template.xml} +0 -0
- /package/{agile-context-engineering/templates/product/feature.xml → skills/plan-feature/feature-template.xml} +0 -0
- /package/{agile-context-engineering/templates/product/product-vision.xml → skills/plan-product-vision/product-vision-template.xml} +0 -0
- /package/{agile-context-engineering/templates/product/external-solution.xml → skills/research-external-solution/external-solution-template.xml} +0 -0
- /package/{agile-context-engineering/templates/product/story-technical-solution.xml → skills/research-technical-solution/technical-solution-template.xml} +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: execute-story
|
|
3
|
+
description: Execute a fully-planned story -- loads AC + Technical Solution, creates execution plan via Plan Mode, implements (solo or agent teams), runs code review, updates state, and triggers wiki mapping
|
|
4
|
+
argument-hint: "story=<file-path|github-url> [--agent-teams-off]"
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
allowed-tools: Read, Bash, Write, Edit, AskUserQuestion, Glob, Grep, Agent, EnterPlanMode, ExitPlanMode
|
|
7
|
+
model: opus
|
|
8
|
+
effort: max
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Execute Story
|
|
12
|
+
|
|
13
|
+
Orchestrate story execution: load a fully-planned story (with AC + Technical Solution), create an execution plan via Claude Code Plan Mode, execute the plan (solo or agent teams), run code review, present results for user verification, update state across all ACE artifacts, and trigger wiki mapping.
|
|
14
|
+
|
|
15
|
+
## When to Use
|
|
16
|
+
|
|
17
|
+
- After `/ace:plan-story` -- when a story has AC + Technical Solution
|
|
18
|
+
- Anytime -- to execute a fully-planned story specification
|
|
19
|
+
- When a story has both Acceptance Criteria AND Technical Solution sections
|
|
20
|
+
- When story status is "Refined" (ready for implementation)
|
|
21
|
+
|
|
22
|
+
## Input
|
|
23
|
+
|
|
24
|
+
### Required
|
|
25
|
+
|
|
26
|
+
- **`story`** -- Story source:
|
|
27
|
+
- **File path**: Path to a fully-planned story markdown file (must have AC + Technical Solution sections)
|
|
28
|
+
- **GitHub URL or issue number**: GitHub story reference
|
|
29
|
+
- Must be a valid, accessible file or GitHub issue.
|
|
30
|
+
- The story MUST have been through `/ace:plan-story` (has AC + Technical Solution).
|
|
31
|
+
|
|
32
|
+
### Flags
|
|
33
|
+
|
|
34
|
+
- **`--agent-teams-off`** -- Force solo execution mode regardless of agent_teams setting. Overrides the agent_teams flag in settings.json. Use when you want single-context execution even if teams are enabled.
|
|
35
|
+
|
|
36
|
+
## Environment Context (preprocessed)
|
|
37
|
+
|
|
38
|
+
!`node "${CLAUDE_SKILL_DIR}/script.js" init "$ARGUMENTS" 2>/dev/null`
|
|
39
|
+
|
|
40
|
+
## Supporting Resources
|
|
41
|
+
|
|
42
|
+
Read ALL of these before starting the workflow:
|
|
43
|
+
|
|
44
|
+
- **Workflow**: Read [workflow.xml](workflow.xml) -- complete orchestration process with all steps
|
|
45
|
+
- **Story template**: Read [story-template.xml](story-template.xml) -- output format for the story specification
|
|
46
|
+
- **Walkthrough template**: Read [walkthrough-template.xml](walkthrough-template.xml) -- walkthrough format for wiki docs
|
|
47
|
+
- **Questioning guide**: Read `${CLAUDE_SKILL_DIR}/../../shared/utils/questioning.xml` -- deep questioning techniques
|
|
48
|
+
- **UI formatting**: Read `${CLAUDE_SKILL_DIR}/../../shared/utils/ui-formatting.md` -- ACE output formatting rules
|
|
49
|
+
|
|
50
|
+
## Process
|
|
51
|
+
|
|
52
|
+
**STRICT WORKFLOW EXECUTION -- Follow the execute-story workflow STEP BY STEP.
|
|
53
|
+
Do NOT skip steps. Do NOT improvise. Do NOT start reading code or planning
|
|
54
|
+
until step 1 (init & validate) is fully complete with the init command output parsed.**
|
|
55
|
+
|
|
56
|
+
Execute the execute-story workflow from [workflow.xml](workflow.xml) end-to-end.
|
|
57
|
+
|
|
58
|
+
The Environment Context above contains the preprocessed INIT JSON -- use it directly instead of running the init script manually. The workflow's step 1 setup can skip the init bash call since that data is already available.
|
|
59
|
+
|
|
60
|
+
**MANDATORY FIRST ACTION: Parse the preprocessed INIT JSON from Environment Context BEFORE doing anything else.
|
|
61
|
+
Do NOT read the story file manually. Do NOT explore the codebase. Do NOT start planning.
|
|
62
|
+
The init output validates the story and provides all paths and context needed.**
|
|
63
|
+
|
|
64
|
+
**CRITICAL REQUIREMENTS:**
|
|
65
|
+
- Story MUST have Acceptance Criteria -- STOP if missing
|
|
66
|
+
- Story MUST have Technical Solution -- STOP if missing
|
|
67
|
+
- NO intermediary commits during implementation
|
|
68
|
+
- ONE single commit per story after user approval (code + state + docs)
|
|
69
|
+
- Code review is MANDATORY -- blockers must be fixed before approval
|
|
70
|
+
- Coding standards violations are BLOCKERS, not warnings
|
|
71
|
+
- Dead code and backwards-compatible shims must be DELETED
|
|
72
|
+
|
|
73
|
+
Two execution modes:
|
|
74
|
+
- **Solo Mode** (default or --agent-teams-off): Single context, plan mode then execute
|
|
75
|
+
- **Agent Teams Mode** (when enabled + plan recommends): Lead + teammates for parallel work
|
|
76
|
+
|
|
77
|
+
## Artifacts
|
|
78
|
+
|
|
79
|
+
Story file updated with Summary & State section and Wiki Updates section.
|
|
80
|
+
Feature file updated with story status.
|
|
81
|
+
Product backlog updated with story (and possibly feature) status.
|
|
82
|
+
Wiki documents updated/created based on implementation changes.
|
|
83
|
+
|
|
84
|
+
## Example Usage
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
# Execute a story from a file path
|
|
88
|
+
/ace:execute-story \
|
|
89
|
+
story=.ace/artifacts/product/e1-auth/f3-oauth/s1-buttons/s1-buttons.md
|
|
90
|
+
|
|
91
|
+
# Execute from a GitHub issue
|
|
92
|
+
/ace:execute-story \
|
|
93
|
+
story=https://github.com/owner/repo/issues/95
|
|
94
|
+
|
|
95
|
+
# Force solo mode (no agent teams)
|
|
96
|
+
/ace:execute-story \
|
|
97
|
+
story=.ace/artifacts/product/e1-auth/f3-oauth/s1-buttons/s1-buttons.md \
|
|
98
|
+
--agent-teams-off
|
|
99
|
+
|
|
100
|
+
# With just an issue number
|
|
101
|
+
/ace:execute-story story=#95
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Next Steps
|
|
105
|
+
|
|
106
|
+
**After this command:**
|
|
107
|
+
- `/ace:execute-story story=...` -- Execute the next story in the feature
|
|
108
|
+
- `/ace:review-story story=...` -- Re-run code review (standalone)
|
|
109
|
+
- `/ace:plan-story story=...` -- Plan the next story
|
|
110
|
+
- `/ace:help` -- Check project status
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* execute-story skill script — Entry point for all ace-tools operations
|
|
5
|
+
* needed by the execute-story skill.
|
|
6
|
+
*
|
|
7
|
+
* Subcommands:
|
|
8
|
+
* init [story-param] Environment detection for execute-story workflow
|
|
9
|
+
* update-state story=X status=Y Update story status across files
|
|
10
|
+
* sync-github repo=X story_file=Y Sync story/feature to GitHub
|
|
11
|
+
* resolve-model <agent-type> Get model for agent based on profile
|
|
12
|
+
*
|
|
13
|
+
* Usage: node script.js <subcommand> [args] [--raw]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
loadConfig, pathExists, safeReadFile, resolveModel,
|
|
21
|
+
loadSettings, execCommand, output, error,
|
|
22
|
+
} = require('../../shared/lib/ace-core');
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
classifyStoryParam, extractStoryMetadata, extractStoryRequirements,
|
|
26
|
+
extractMarkdownSection, extractIssueNumber, extractIssueNumberFromFile,
|
|
27
|
+
computeStoryPaths, updateState,
|
|
28
|
+
} = require('../../shared/lib/ace-story');
|
|
29
|
+
|
|
30
|
+
const { syncStory } = require('../../shared/lib/ace-github');
|
|
31
|
+
|
|
32
|
+
// ─── Runtime Config Dir ─────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detect the runtime config directory name.
|
|
36
|
+
* In the plugin context, the script lives at:
|
|
37
|
+
* <base>/<config-dir>/skills/execute-story/script.js
|
|
38
|
+
* Default to '.claude' since the plugin always runs in Claude Code.
|
|
39
|
+
*/
|
|
40
|
+
function getRuntimeConfigDirName() {
|
|
41
|
+
try {
|
|
42
|
+
const skillDir = __dirname; // <base>/<config-dir>/skills/execute-story
|
|
43
|
+
const skillsDir = path.dirname(skillDir); // <base>/<config-dir>/skills
|
|
44
|
+
const configDir = path.dirname(skillsDir); // <base>/<config-dir>
|
|
45
|
+
const dirName = path.basename(configDir);
|
|
46
|
+
if (dirName === '.opencode' || dirName === '.claude') {
|
|
47
|
+
return dirName;
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return '.claude';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RUNTIME_CONFIG_DIR = getRuntimeConfigDirName();
|
|
54
|
+
|
|
55
|
+
// ─── CLI Dispatch ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const cwd = process.cwd();
|
|
58
|
+
const args = process.argv.slice(2);
|
|
59
|
+
const raw = args.includes('--raw');
|
|
60
|
+
const cmd = args[0];
|
|
61
|
+
|
|
62
|
+
switch (cmd) {
|
|
63
|
+
case 'init':
|
|
64
|
+
cmdInit(cwd, raw, args.slice(1).filter(a => a !== '--raw'));
|
|
65
|
+
break;
|
|
66
|
+
case 'update-state':
|
|
67
|
+
updateState(cwd, raw, args.slice(1).filter(a => a !== '--raw'));
|
|
68
|
+
break;
|
|
69
|
+
case 'sync-github':
|
|
70
|
+
syncStory(cwd, raw, args.slice(1).filter(a => a !== '--raw'));
|
|
71
|
+
break;
|
|
72
|
+
case 'resolve-model': {
|
|
73
|
+
const agentType = args[1];
|
|
74
|
+
if (!agentType) error('resolve-model requires agent-type argument');
|
|
75
|
+
const model = resolveModel(cwd, agentType);
|
|
76
|
+
output({ model, agent: agentType }, raw, model);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
error(`Unknown command: ${cmd}\nAvailable: init, update-state, sync-github, resolve-model`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Init: Execute Story ────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Environment detection for the execute-story workflow.
|
|
87
|
+
*
|
|
88
|
+
* Detects: git, gh CLI, GitHub project, agent teams, story source/content/metadata,
|
|
89
|
+
* acceptance criteria, technical solution, wiki refs, coding standards, computed paths.
|
|
90
|
+
*/
|
|
91
|
+
function cmdInit(cwd, raw, initArgs) {
|
|
92
|
+
const config = loadConfig(cwd);
|
|
93
|
+
|
|
94
|
+
// ── Environment detection ──
|
|
95
|
+
const has_git = pathExists(cwd, '.git');
|
|
96
|
+
const has_gh_cli = (() => {
|
|
97
|
+
try {
|
|
98
|
+
const { execSync } = require('child_process');
|
|
99
|
+
execSync('gh --version', { stdio: 'pipe' });
|
|
100
|
+
return true;
|
|
101
|
+
} catch { return false; }
|
|
102
|
+
})();
|
|
103
|
+
const settings = loadSettings(cwd);
|
|
104
|
+
const github_project = settings.github_project;
|
|
105
|
+
|
|
106
|
+
// ── Agent teams detection (sync from runtime settings) ──
|
|
107
|
+
const claudeSettingsPath = path.join(cwd, RUNTIME_CONFIG_DIR, 'settings.json');
|
|
108
|
+
let agent_teams = settings.agent_teams || false;
|
|
109
|
+
try {
|
|
110
|
+
const claudeRaw = fs.readFileSync(claudeSettingsPath, 'utf-8');
|
|
111
|
+
const claudeSettings = JSON.parse(claudeRaw);
|
|
112
|
+
const val = claudeSettings?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
113
|
+
agent_teams = val === '1' || val === 'true';
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
// ── Parse story param ──
|
|
117
|
+
const storyParam = initArgs.join(' ').trim() || null;
|
|
118
|
+
|
|
119
|
+
// ── Classify the story parameter ──
|
|
120
|
+
const classified = classifyStoryParam(storyParam);
|
|
121
|
+
|
|
122
|
+
// Early exit if invalid
|
|
123
|
+
if (classified.type === null || classified.type === 'invalid') {
|
|
124
|
+
output({
|
|
125
|
+
executor_model: resolveModel(cwd, 'ace-executor'),
|
|
126
|
+
reviewer_model: resolveModel(cwd, 'ace-code-reviewer'),
|
|
127
|
+
commit_docs: config.commit_docs,
|
|
128
|
+
has_git, has_gh_cli, github_project, agent_teams,
|
|
129
|
+
story_source: null,
|
|
130
|
+
story_valid: false,
|
|
131
|
+
story_error: classified.reason || 'No story parameter provided',
|
|
132
|
+
story_content: null,
|
|
133
|
+
story: { id: null, title: null, status: null, size: null },
|
|
134
|
+
feature: { id: null, title: null },
|
|
135
|
+
epic: { id: null, title: null },
|
|
136
|
+
has_acceptance_criteria: false,
|
|
137
|
+
acceptance_criteria_count: 0,
|
|
138
|
+
has_technical_solution: false,
|
|
139
|
+
has_wiki_refs: false,
|
|
140
|
+
has_coding_standards: false,
|
|
141
|
+
paths: null,
|
|
142
|
+
}, raw);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Load story content ──
|
|
147
|
+
let storyContent = null;
|
|
148
|
+
let storySource = classified.type === 'file' ? 'file' : 'github';
|
|
149
|
+
let storyError = null;
|
|
150
|
+
let storyFilePath = null;
|
|
151
|
+
|
|
152
|
+
if (classified.type === 'file') {
|
|
153
|
+
const resolvedPath = path.isAbsolute(classified.filePath)
|
|
154
|
+
? classified.filePath
|
|
155
|
+
: path.join(cwd, classified.filePath);
|
|
156
|
+
if (!pathExists(cwd, classified.filePath)) {
|
|
157
|
+
storyError = `Story file not found: ${classified.filePath}`;
|
|
158
|
+
} else {
|
|
159
|
+
storyContent = safeReadFile(resolvedPath);
|
|
160
|
+
storyFilePath = classified.filePath;
|
|
161
|
+
if (!storyContent) storyError = `Could not read story file: ${classified.filePath}`;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
// github-url or issue-number
|
|
165
|
+
if (!has_gh_cli) {
|
|
166
|
+
storyError = 'GitHub CLI (gh) not installed. Cannot fetch GitHub issues.';
|
|
167
|
+
} else {
|
|
168
|
+
const repo = classified.repo || (github_project.repo || null);
|
|
169
|
+
if (!repo) {
|
|
170
|
+
storyError = 'No repository configured. Provide a full GitHub URL or configure github_project.repo in settings.';
|
|
171
|
+
} else {
|
|
172
|
+
const ghResult = execCommand(
|
|
173
|
+
`gh issue view ${classified.issueNumber} --repo ${repo} --json title,body,labels,state`,
|
|
174
|
+
cwd
|
|
175
|
+
);
|
|
176
|
+
if (!ghResult) {
|
|
177
|
+
storyError = `Could not fetch GitHub issue #${classified.issueNumber} from ${repo}.`;
|
|
178
|
+
} else {
|
|
179
|
+
try {
|
|
180
|
+
const issue = JSON.parse(ghResult);
|
|
181
|
+
storyContent = issue.body || '';
|
|
182
|
+
if (storyContent && !storyContent.match(/^#\s+/m)) {
|
|
183
|
+
storyContent = `# ${issue.title}\n\n${storyContent}`;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
storyError = `Failed to parse GitHub issue response for #${classified.issueNumber}.`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Extract metadata & requirements ──
|
|
194
|
+
const metadata = extractStoryMetadata(storyContent);
|
|
195
|
+
const requirements = extractStoryRequirements(storyContent);
|
|
196
|
+
|
|
197
|
+
// ── Detect key sections ──
|
|
198
|
+
const has_acceptance_criteria = requirements.acceptance_criteria_count > 0;
|
|
199
|
+
const has_technical_solution = storyContent
|
|
200
|
+
? !!extractMarkdownSection(storyContent, 'Technical Solution', 2)
|
|
201
|
+
: false;
|
|
202
|
+
const has_wiki_refs = storyContent
|
|
203
|
+
? !!extractMarkdownSection(storyContent, 'Relevant Wiki', 2)
|
|
204
|
+
: false;
|
|
205
|
+
const has_coding_standards = pathExists(cwd, '.docs/wiki/system-wide/coding-standards.md');
|
|
206
|
+
|
|
207
|
+
// ── Compute paths ──
|
|
208
|
+
let paths = null;
|
|
209
|
+
let has_story_file = false;
|
|
210
|
+
|
|
211
|
+
if (storyFilePath) {
|
|
212
|
+
const resolvedPath = path.isAbsolute(storyFilePath)
|
|
213
|
+
? storyFilePath
|
|
214
|
+
: path.join(cwd, storyFilePath);
|
|
215
|
+
const storyDir = path.dirname(resolvedPath);
|
|
216
|
+
const relStoryDir = path.relative(cwd, storyDir).replace(/\\/g, '/');
|
|
217
|
+
const storySlug = path.basename(storyDir);
|
|
218
|
+
const featureDir = path.dirname(storyDir);
|
|
219
|
+
const relFeatureDir = path.relative(cwd, featureDir).replace(/\\/g, '/');
|
|
220
|
+
const featureSlug = path.basename(featureDir);
|
|
221
|
+
|
|
222
|
+
paths = {
|
|
223
|
+
epic_slug: null,
|
|
224
|
+
feature_slug: featureSlug,
|
|
225
|
+
story_slug: storySlug,
|
|
226
|
+
story_dir: relStoryDir,
|
|
227
|
+
story_file: storyFilePath.replace(/\\/g, '/'),
|
|
228
|
+
external_analysis_file: `${relStoryDir}/external-analysis.md`,
|
|
229
|
+
integration_analysis_file: `${relStoryDir}/integration-analysis.md`,
|
|
230
|
+
feature_dir: relFeatureDir,
|
|
231
|
+
feature_file: `${relFeatureDir}/${featureSlug}.md`,
|
|
232
|
+
product_backlog: '.ace/artifacts/product/product-backlog.md',
|
|
233
|
+
coding_standards: '.docs/wiki/system-wide/coding-standards.md',
|
|
234
|
+
};
|
|
235
|
+
has_story_file = true;
|
|
236
|
+
} else if (metadata.epic.id && metadata.feature.id && metadata.id) {
|
|
237
|
+
const computed = computeStoryPaths(
|
|
238
|
+
metadata.epic.id, metadata.epic.title || '',
|
|
239
|
+
metadata.feature.id, metadata.feature.title || '',
|
|
240
|
+
metadata.id, metadata.title || ''
|
|
241
|
+
);
|
|
242
|
+
if (computed) {
|
|
243
|
+
paths = {
|
|
244
|
+
...computed,
|
|
245
|
+
product_backlog: '.ace/artifacts/product/product-backlog.md',
|
|
246
|
+
coding_standards: '.docs/wiki/system-wide/coding-standards.md',
|
|
247
|
+
};
|
|
248
|
+
has_story_file = pathExists(cwd, paths.story_file);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Extract GitHub issue numbers ──
|
|
253
|
+
const storyIssueNumber = extractIssueNumber(metadata.link);
|
|
254
|
+
const featureIssueNumber = paths ? extractIssueNumberFromFile(cwd, paths.feature_file) : null;
|
|
255
|
+
|
|
256
|
+
// ── Build result ──
|
|
257
|
+
const result = {
|
|
258
|
+
// Models
|
|
259
|
+
executor_model: resolveModel(cwd, 'ace-executor'),
|
|
260
|
+
reviewer_model: resolveModel(cwd, 'ace-code-reviewer'),
|
|
261
|
+
|
|
262
|
+
// Config
|
|
263
|
+
commit_docs: config.commit_docs,
|
|
264
|
+
|
|
265
|
+
// Environment
|
|
266
|
+
has_git, has_gh_cli, github_project, agent_teams,
|
|
267
|
+
|
|
268
|
+
// Story source
|
|
269
|
+
story_source: storySource,
|
|
270
|
+
story_valid: storyContent !== null && storyError === null,
|
|
271
|
+
story_error: storyError,
|
|
272
|
+
|
|
273
|
+
// Raw story content
|
|
274
|
+
story_content: storyContent,
|
|
275
|
+
|
|
276
|
+
// Story metadata
|
|
277
|
+
story: {
|
|
278
|
+
id: metadata.id,
|
|
279
|
+
title: metadata.title,
|
|
280
|
+
status: metadata.status,
|
|
281
|
+
size: metadata.size,
|
|
282
|
+
issue_number: storyIssueNumber,
|
|
283
|
+
},
|
|
284
|
+
feature: {
|
|
285
|
+
...metadata.feature,
|
|
286
|
+
issue_number: featureIssueNumber,
|
|
287
|
+
},
|
|
288
|
+
epic: metadata.epic,
|
|
289
|
+
|
|
290
|
+
// Section detection
|
|
291
|
+
has_acceptance_criteria,
|
|
292
|
+
acceptance_criteria_count: requirements.acceptance_criteria_count,
|
|
293
|
+
has_technical_solution,
|
|
294
|
+
has_wiki_refs,
|
|
295
|
+
has_coding_standards,
|
|
296
|
+
|
|
297
|
+
// Computed paths
|
|
298
|
+
paths,
|
|
299
|
+
|
|
300
|
+
// Artifact existence
|
|
301
|
+
has_story_file,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
output(result, raw);
|
|
305
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
const { describe, it, before, after } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
const SCRIPT = path.join(__dirname, 'script.js');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a minimal ACE project structure in a temp directory.
|
|
12
|
+
*/
|
|
13
|
+
function createTestProject() {
|
|
14
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ace-test-'));
|
|
15
|
+
|
|
16
|
+
// .ace/config.json
|
|
17
|
+
const aceDir = path.join(tmpDir, '.ace');
|
|
18
|
+
fs.mkdirSync(aceDir, { recursive: true });
|
|
19
|
+
fs.writeFileSync(path.join(aceDir, 'config.json'), JSON.stringify({
|
|
20
|
+
version: '0.1.0',
|
|
21
|
+
projectName: 'test-project',
|
|
22
|
+
model_profile: 'quality',
|
|
23
|
+
commit_docs: true,
|
|
24
|
+
github: { enabled: false },
|
|
25
|
+
}, null, 2));
|
|
26
|
+
|
|
27
|
+
// .ace/settings.json
|
|
28
|
+
fs.writeFileSync(path.join(aceDir, 'settings.json'), JSON.stringify({
|
|
29
|
+
model_profile: 'quality',
|
|
30
|
+
commit_docs: true,
|
|
31
|
+
agent_teams: false,
|
|
32
|
+
github_project: { enabled: false, gh_installed: false, repo: '', project_number: null, owner: '' },
|
|
33
|
+
}, null, 2));
|
|
34
|
+
|
|
35
|
+
return tmpDir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a story file in the test project.
|
|
40
|
+
*/
|
|
41
|
+
function createStoryFile(tmpDir, relPath, content) {
|
|
42
|
+
const fullPath = path.join(tmpDir, relPath);
|
|
43
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
44
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
45
|
+
return relPath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runScript(subcommand, args, cwd) {
|
|
49
|
+
return execSync(`node "${SCRIPT}" ${subcommand} ${args}`, {
|
|
50
|
+
cwd,
|
|
51
|
+
encoding: 'utf-8',
|
|
52
|
+
timeout: 10000,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanup(tmpDir) {
|
|
57
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('execute-story script', () => {
|
|
63
|
+
|
|
64
|
+
describe('init', () => {
|
|
65
|
+
let tmpDir;
|
|
66
|
+
|
|
67
|
+
before(() => { tmpDir = createTestProject(); });
|
|
68
|
+
after(() => { cleanup(tmpDir); });
|
|
69
|
+
|
|
70
|
+
it('returns valid JSON with environment detection for a story file', () => {
|
|
71
|
+
const storyContent = [
|
|
72
|
+
'# S1: Add Login Button',
|
|
73
|
+
'**Feature**: F1 User Auth | **Epic**: E1 Platform',
|
|
74
|
+
'**Status**: Refined | **Size**: 3 | **Sprint**: — | **Link**: —',
|
|
75
|
+
'',
|
|
76
|
+
'## User Story',
|
|
77
|
+
'',
|
|
78
|
+
'> As a user,',
|
|
79
|
+
'> I want to click a login button,',
|
|
80
|
+
'> so that I can access my account.',
|
|
81
|
+
'',
|
|
82
|
+
'## Description',
|
|
83
|
+
'',
|
|
84
|
+
'Adds a login button to the header.',
|
|
85
|
+
'',
|
|
86
|
+
'## Acceptance Criteria',
|
|
87
|
+
'',
|
|
88
|
+
'### Scenario: Click login button',
|
|
89
|
+
'',
|
|
90
|
+
'**Given** the user is on the homepage',
|
|
91
|
+
'**When** they click "Login"',
|
|
92
|
+
'**Then** they see the login form',
|
|
93
|
+
'',
|
|
94
|
+
'## Technical Solution',
|
|
95
|
+
'',
|
|
96
|
+
'### Architecture',
|
|
97
|
+
'',
|
|
98
|
+
'Simple button component in the header.',
|
|
99
|
+
'',
|
|
100
|
+
'### Implementation Plan',
|
|
101
|
+
'',
|
|
102
|
+
'1. Create LoginButton component',
|
|
103
|
+
'2. Add to Header',
|
|
104
|
+
].join('\n');
|
|
105
|
+
|
|
106
|
+
const storyPath = createStoryFile(
|
|
107
|
+
tmpDir,
|
|
108
|
+
'.ace/artifacts/product/e1-platform/f1-user-auth/s1-add-login-button/s1-add-login-button.md',
|
|
109
|
+
storyContent
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const result = JSON.parse(runScript('init', storyPath, tmpDir));
|
|
113
|
+
|
|
114
|
+
assert.ok(result.executor_model, 'should have executor_model');
|
|
115
|
+
assert.ok(result.reviewer_model, 'should have reviewer_model');
|
|
116
|
+
assert.strictEqual(result.story_valid, true, 'story should be valid');
|
|
117
|
+
assert.strictEqual(result.story_source, 'file');
|
|
118
|
+
assert.strictEqual(result.story.id, 'S1');
|
|
119
|
+
assert.strictEqual(result.story.title, 'Add Login Button');
|
|
120
|
+
assert.strictEqual(result.story.status, 'Refined');
|
|
121
|
+
assert.strictEqual(result.has_acceptance_criteria, true);
|
|
122
|
+
assert.strictEqual(result.acceptance_criteria_count, 1);
|
|
123
|
+
assert.strictEqual(result.has_technical_solution, true);
|
|
124
|
+
assert.ok(result.paths, 'should have computed paths');
|
|
125
|
+
assert.ok(result.paths.story_file.includes('s1-add-login-button'));
|
|
126
|
+
assert.ok(result.paths.product_backlog);
|
|
127
|
+
assert.ok(result.paths.coding_standards);
|
|
128
|
+
assert.strictEqual(result.has_story_file, true);
|
|
129
|
+
assert.strictEqual(typeof result.commit_docs, 'boolean');
|
|
130
|
+
assert.strictEqual(typeof result.has_git, 'boolean');
|
|
131
|
+
assert.strictEqual(typeof result.agent_teams, 'boolean');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns invalid when story has no AC', () => {
|
|
135
|
+
const storyContent = [
|
|
136
|
+
'# S2: No AC Story',
|
|
137
|
+
'**Feature**: F1 Test Feature | **Epic**: E1 Test Epic',
|
|
138
|
+
'**Status**: Todo | **Size**: 3 | **Sprint**: — | **Link**: —',
|
|
139
|
+
'',
|
|
140
|
+
'## Description',
|
|
141
|
+
'',
|
|
142
|
+
'A story without acceptance criteria.',
|
|
143
|
+
].join('\n');
|
|
144
|
+
|
|
145
|
+
const storyPath = createStoryFile(
|
|
146
|
+
tmpDir,
|
|
147
|
+
'.ace/artifacts/product/e1-test-epic/f1-test-feature/s2-no-ac/s2-no-ac.md',
|
|
148
|
+
storyContent
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const result = JSON.parse(runScript('init', storyPath, tmpDir));
|
|
152
|
+
|
|
153
|
+
assert.strictEqual(result.story_valid, true);
|
|
154
|
+
assert.strictEqual(result.has_acceptance_criteria, false);
|
|
155
|
+
assert.strictEqual(result.acceptance_criteria_count, 0);
|
|
156
|
+
assert.strictEqual(result.has_technical_solution, false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles non-existent story file gracefully', () => {
|
|
160
|
+
const result = JSON.parse(runScript('init', 'nonexistent/story.md', tmpDir));
|
|
161
|
+
|
|
162
|
+
assert.strictEqual(result.story_valid, false);
|
|
163
|
+
assert.ok(result.story_error.includes('not found'));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns invalid with no story parameter', () => {
|
|
167
|
+
const result = JSON.parse(runScript('init', '', tmpDir));
|
|
168
|
+
|
|
169
|
+
assert.strictEqual(result.story_valid, false);
|
|
170
|
+
assert.ok(result.story_error);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('resolve-model', () => {
|
|
175
|
+
let tmpDir;
|
|
176
|
+
|
|
177
|
+
before(() => { tmpDir = createTestProject(); });
|
|
178
|
+
after(() => { cleanup(tmpDir); });
|
|
179
|
+
|
|
180
|
+
it('returns a model string with --raw', () => {
|
|
181
|
+
const result = runScript('resolve-model', 'ace-executor --raw', tmpDir).trim();
|
|
182
|
+
assert.match(result, /^(opus|sonnet|haiku)$/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns JSON without --raw', () => {
|
|
186
|
+
const result = JSON.parse(runScript('resolve-model', 'ace-executor', tmpDir));
|
|
187
|
+
assert.ok(result.model);
|
|
188
|
+
assert.strictEqual(result.agent, 'ace-executor');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns correct model for reviewer', () => {
|
|
192
|
+
const result = runScript('resolve-model', 'ace-code-reviewer --raw', tmpDir).trim();
|
|
193
|
+
assert.match(result, /^(opus|sonnet|haiku)$/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('update-state', () => {
|
|
198
|
+
let tmpDir;
|
|
199
|
+
|
|
200
|
+
before(() => { tmpDir = createTestProject(); });
|
|
201
|
+
after(() => { cleanup(tmpDir); });
|
|
202
|
+
|
|
203
|
+
it('updates story status in the story file', () => {
|
|
204
|
+
const storyContent = [
|
|
205
|
+
'# S1: Test Story',
|
|
206
|
+
'**Feature**: F1 Test Feature | **Epic**: E1 Test Epic',
|
|
207
|
+
'**Status**: Refined | **Size**: 3 | **Sprint**: — | **Link**: —',
|
|
208
|
+
].join('\n');
|
|
209
|
+
|
|
210
|
+
const storyPath = createStoryFile(
|
|
211
|
+
tmpDir,
|
|
212
|
+
'.ace/artifacts/product/e1-test-epic/f1-test-feature/s1-test-story/s1-test-story.md',
|
|
213
|
+
storyContent
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const result = JSON.parse(runScript('update-state', `story=${storyPath} status=Done`, tmpDir));
|
|
217
|
+
|
|
218
|
+
assert.strictEqual(result.story_updated, true);
|
|
219
|
+
assert.strictEqual(result.new_status, 'Done');
|
|
220
|
+
|
|
221
|
+
// Verify file was actually updated
|
|
222
|
+
const updated = fs.readFileSync(path.join(tmpDir, storyPath), 'utf-8');
|
|
223
|
+
assert.ok(updated.includes('**Status**: Done'));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('normalizes InProgress to "In Progress"', () => {
|
|
227
|
+
const storyContent = [
|
|
228
|
+
'# S2: Another Story',
|
|
229
|
+
'**Feature**: F1 Test Feature | **Epic**: E1 Test Epic',
|
|
230
|
+
'**Status**: Refined | **Size**: 2 | **Sprint**: — | **Link**: —',
|
|
231
|
+
].join('\n');
|
|
232
|
+
|
|
233
|
+
const storyPath = createStoryFile(
|
|
234
|
+
tmpDir,
|
|
235
|
+
'.ace/artifacts/product/e1-test-epic/f1-test-feature/s2-another-story/s2-another-story.md',
|
|
236
|
+
storyContent
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const result = JSON.parse(runScript('update-state', `story=${storyPath} status=InProgress`, tmpDir));
|
|
240
|
+
|
|
241
|
+
assert.strictEqual(result.new_status, 'In Progress');
|
|
242
|
+
|
|
243
|
+
const updated = fs.readFileSync(path.join(tmpDir, storyPath), 'utf-8');
|
|
244
|
+
assert.ok(updated.includes('**Status**: In Progress'));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('error handling', () => {
|
|
249
|
+
it('errors on unknown command', () => {
|
|
250
|
+
assert.throws(() => {
|
|
251
|
+
execSync(`node "${SCRIPT}" bogus`, { encoding: 'utf-8', stdio: 'pipe' });
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('errors on resolve-model without agent type', () => {
|
|
256
|
+
assert.throws(() => {
|
|
257
|
+
execSync(`node "${SCRIPT}" resolve-model`, { encoding: 'utf-8', stdio: 'pipe' });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|