edsger 0.45.1 → 0.47.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/settings.local.json +3 -23
- package/dist/api/__tests__/app-store.test.d.ts +7 -0
- package/dist/api/__tests__/app-store.test.js +60 -0
- package/dist/api/__tests__/intelligence.test.d.ts +11 -0
- package/dist/api/__tests__/intelligence.test.js +315 -0
- package/dist/api/features/__tests__/feature-utils.test.d.ts +4 -0
- package/dist/api/features/__tests__/feature-utils.test.js +370 -0
- package/dist/api/features/__tests__/status-updater.test.d.ts +4 -0
- package/dist/api/features/__tests__/status-updater.test.js +88 -0
- package/dist/commands/build/__tests__/build.test.d.ts +5 -0
- package/dist/commands/build/__tests__/build.test.js +206 -0
- package/dist/commands/build/__tests__/detect-project.test.d.ts +6 -0
- package/dist/commands/build/__tests__/detect-project.test.js +160 -0
- package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
- package/dist/commands/build/__tests__/run-build.test.js +433 -0
- package/dist/commands/intelligence/__tests__/command.test.d.ts +4 -0
- package/dist/commands/intelligence/__tests__/command.test.js +48 -0
- package/dist/commands/run-sheet/index.js +6 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +5 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.js +316 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +397 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.js +384 -0
- package/dist/commands/workflow/executors/phase-executor.js +3 -1
- package/dist/commands/workflow/phase-orchestrator.js +1 -2
- package/dist/config/__tests__/config.test.d.ts +4 -0
- package/dist/config/__tests__/config.test.js +286 -0
- package/dist/config/__tests__/feature-status.test.d.ts +4 -0
- package/dist/config/__tests__/feature-status.test.js +111 -0
- package/dist/errors/__tests__/index.test.d.ts +4 -0
- package/dist/errors/__tests__/index.test.js +349 -0
- package/dist/index.js +0 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.js +142 -0
- package/dist/phases/app-store-generation/__tests__/context.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/context.test.js +284 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.js +122 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +826 -0
- package/dist/phases/app-store-generation/index.js +1 -2
- package/dist/phases/branch-planning/index.js +1 -2
- package/dist/phases/bug-fixing/analyzer.js +1 -2
- package/dist/phases/code-implementation/index.js +1 -2
- package/dist/phases/code-refine/index.js +1 -2
- package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
- package/dist/phases/code-review/index.js +1 -2
- package/dist/phases/code-testing/analyzer.js +1 -2
- package/dist/phases/feature-analysis/index.js +1 -2
- package/dist/phases/functional-testing/analyzer.js +1 -2
- package/dist/phases/growth-analysis/index.js +1 -2
- package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/context.test.js +192 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +13 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.js +154 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +5 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +378 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +33 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.js +303 -0
- package/dist/phases/pr-execution/index.js +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +157 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
- package/dist/phases/pr-resolve/__tests__/types.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/types.test.js +43 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
- package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.js +110 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
- package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
- package/dist/phases/pr-splitting/index.js +1 -2
- package/dist/phases/release-sync/github.d.ts +12 -0
- package/dist/phases/release-sync/github.js +39 -0
- package/dist/phases/release-sync/snapshot.js +0 -1
- package/dist/phases/run-sheet/index.d.ts +15 -0
- package/dist/phases/run-sheet/index.js +161 -29
- package/dist/phases/run-sheet/render.d.ts +23 -5
- package/dist/phases/run-sheet/render.js +195 -31
- package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
- package/dist/phases/smoke-test/__tests__/agent.test.js +84 -0
- package/dist/phases/smoke-test/__tests__/github.test.d.ts +9 -0
- package/dist/phases/smoke-test/__tests__/github.test.js +120 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +8 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.js +93 -0
- package/dist/phases/smoke-test/agent.js +2 -4
- package/dist/phases/smoke-test/github.d.ts +54 -0
- package/dist/phases/smoke-test/github.js +101 -0
- package/dist/phases/smoke-test/index.js +11 -6
- package/dist/phases/smoke-test/snapshot.d.ts +27 -0
- package/dist/phases/smoke-test/snapshot.js +157 -0
- package/dist/phases/technical-design/index.js +1 -2
- package/dist/phases/test-cases-analysis/index.js +1 -2
- package/dist/phases/user-stories-analysis/index.js +1 -2
- package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-agent.test.js +74 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.js +59 -0
- package/dist/services/coaching/__tests__/self-rating.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/self-rating.test.js +188 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +133 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +336 -0
- package/dist/services/lifecycle-agent/index.d.ts +24 -0
- package/dist/services/lifecycle-agent/index.js +25 -0
- package/dist/services/lifecycle-agent/phase-criteria.d.ts +57 -0
- package/dist/services/lifecycle-agent/phase-criteria.js +335 -0
- package/dist/services/lifecycle-agent/transition-rules.d.ts +60 -0
- package/dist/services/lifecycle-agent/transition-rules.js +184 -0
- package/dist/services/lifecycle-agent/types.d.ts +190 -0
- package/dist/services/lifecycle-agent/types.js +12 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +122 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.js +321 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.js +261 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +158 -0
- package/dist/services/video/__tests__/video-pipeline.test.d.ts +6 -0
- package/dist/services/video/__tests__/video-pipeline.test.js +249 -0
- package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
- package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
- package/dist/workspace/workspace-manager.js +17 -4
- package/package.json +1 -1
- package/.env.local +0 -12
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle Agent Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the AI-powered lifecycle agent that manages
|
|
5
|
+
* product feature lifecycle decisions: when to advance to the next phase,
|
|
6
|
+
* when to re-run the current phase, and when to escalate to a human.
|
|
7
|
+
*
|
|
8
|
+
* The lifecycle agent evaluates phase outputs against quality criteria
|
|
9
|
+
* and makes transition decisions, reducing reliance on manual human
|
|
10
|
+
* intervention while preserving the ability to escalate when needed.
|
|
11
|
+
*/
|
|
12
|
+
import type { FeatureStatus } from '../../types/index.js';
|
|
13
|
+
import type { ExecutionMode } from '../../types/pipeline.js';
|
|
14
|
+
/**
|
|
15
|
+
* Decision the lifecycle agent can make after evaluating a phase
|
|
16
|
+
*
|
|
17
|
+
* - advance: Phase output meets quality criteria, proceed to next phase
|
|
18
|
+
* - rerun: Phase output needs improvement, re-run with generated feedback
|
|
19
|
+
* - escalate: Agent is uncertain or output requires human judgment
|
|
20
|
+
*/
|
|
21
|
+
export type LifecycleDecision = 'advance' | 'rerun' | 'escalate';
|
|
22
|
+
/**
|
|
23
|
+
* Result of evaluating a single quality criterion
|
|
24
|
+
*/
|
|
25
|
+
export interface CriterionEvaluation {
|
|
26
|
+
/** Criterion identifier matching a CriterionDefinition.id */
|
|
27
|
+
readonly criterionId: string;
|
|
28
|
+
/** Score from 0-100 for this criterion */
|
|
29
|
+
readonly score: number;
|
|
30
|
+
/** Whether the criterion meets its minimum threshold */
|
|
31
|
+
readonly passed: boolean;
|
|
32
|
+
/** Explanation of the evaluation result */
|
|
33
|
+
readonly reasoning: string;
|
|
34
|
+
/** Specific issues found, if any */
|
|
35
|
+
readonly issues?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Complete evaluation result for a phase execution
|
|
39
|
+
* This is the primary output of the lifecycle agent's evaluation step
|
|
40
|
+
*/
|
|
41
|
+
export interface PhaseEvaluationResult {
|
|
42
|
+
/** The phase that was evaluated */
|
|
43
|
+
readonly phase: string;
|
|
44
|
+
/** Feature being evaluated */
|
|
45
|
+
readonly featureId: string;
|
|
46
|
+
/** Overall quality score (weighted average of criteria scores, 0-100) */
|
|
47
|
+
readonly qualityScore: number;
|
|
48
|
+
/** Decision: advance, rerun, or escalate */
|
|
49
|
+
readonly decision: LifecycleDecision;
|
|
50
|
+
/** Agent's confidence in its decision (0-100) */
|
|
51
|
+
readonly confidence: number;
|
|
52
|
+
/** Individual criterion evaluations */
|
|
53
|
+
readonly criteria: readonly CriterionEvaluation[];
|
|
54
|
+
/** Summary reasoning for the decision */
|
|
55
|
+
readonly reasoning: string;
|
|
56
|
+
/** If decision is 'rerun', suggested feedbacks to inject */
|
|
57
|
+
readonly suggestedFeedbacks?: readonly SuggestedFeedback[];
|
|
58
|
+
/** How many times this phase has been re-run in the current cycle */
|
|
59
|
+
readonly rerunCount: number;
|
|
60
|
+
/** Timestamp of the evaluation */
|
|
61
|
+
readonly evaluatedAt: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Feedback suggested by the lifecycle agent when deciding to re-run a phase
|
|
65
|
+
* These mirror the existing Feedback structure so they can be injected
|
|
66
|
+
* into the feedback system seamlessly
|
|
67
|
+
*/
|
|
68
|
+
export interface SuggestedFeedback {
|
|
69
|
+
/** Type of feedback, matching existing FeedbackType */
|
|
70
|
+
readonly feedbackType: 'requirement' | 'constraint' | 'preference' | 'context' | 'quality_criteria' | 'issue' | 'suggestion';
|
|
71
|
+
/** Short title describing the feedback */
|
|
72
|
+
readonly title: string;
|
|
73
|
+
/** Detailed content of the feedback */
|
|
74
|
+
readonly content: string;
|
|
75
|
+
/** Priority from 1-10 (higher = more important) */
|
|
76
|
+
readonly priority: number;
|
|
77
|
+
/** Which phase this feedback targets */
|
|
78
|
+
readonly phase: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Definition of a quality criterion for phase evaluation
|
|
82
|
+
* Each phase has its own set of criteria that the agent uses to evaluate outputs
|
|
83
|
+
*/
|
|
84
|
+
export interface CriterionDefinition {
|
|
85
|
+
/** Unique identifier for this criterion */
|
|
86
|
+
readonly id: string;
|
|
87
|
+
/** Human-readable name */
|
|
88
|
+
readonly name: string;
|
|
89
|
+
/** Description of what this criterion measures */
|
|
90
|
+
readonly description: string;
|
|
91
|
+
/** Relative importance weight (0-1, all weights for a phase should sum to ~1) */
|
|
92
|
+
readonly weight: number;
|
|
93
|
+
/** Minimum score (0-100) needed to pass this criterion */
|
|
94
|
+
readonly minimumScore: number;
|
|
95
|
+
/** Prompt instructions for how the AI should evaluate this criterion */
|
|
96
|
+
readonly evaluationGuidance: string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Quality criteria configuration for a specific phase
|
|
100
|
+
* Defines the thresholds and criteria used to evaluate that phase's outputs
|
|
101
|
+
*/
|
|
102
|
+
export interface PhaseQualityCriteria {
|
|
103
|
+
/** Phase name (underscore format, e.g., 'user_stories_analysis') */
|
|
104
|
+
readonly phase: string;
|
|
105
|
+
/** Minimum overall quality score needed to auto-advance (0-100) */
|
|
106
|
+
readonly advanceThreshold: number;
|
|
107
|
+
/** Overall score below which the agent should escalate to human (0-100) */
|
|
108
|
+
readonly escalateThreshold: number;
|
|
109
|
+
/** Maximum number of automatic re-runs before escalating */
|
|
110
|
+
readonly maxAutoRetries: number;
|
|
111
|
+
/** Individual quality criteria for this phase */
|
|
112
|
+
readonly criteria: readonly CriterionDefinition[];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A transition rule that maps phase evaluation outcomes to actions
|
|
116
|
+
*/
|
|
117
|
+
export interface TransitionRule {
|
|
118
|
+
/** Phase this rule applies to (underscore format) */
|
|
119
|
+
readonly fromPhase: string;
|
|
120
|
+
/** Target phase if advancing */
|
|
121
|
+
readonly toPhase: string;
|
|
122
|
+
/** Execution mode to use when advancing to the target phase */
|
|
123
|
+
readonly executionMode: ExecutionMode;
|
|
124
|
+
/** Feature status to set when advancing */
|
|
125
|
+
readonly targetStatus: FeatureStatus;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Configuration for the lifecycle agent
|
|
129
|
+
* Controls behavior and thresholds for automated lifecycle management
|
|
130
|
+
*/
|
|
131
|
+
export interface LifecycleAgentConfig {
|
|
132
|
+
/** Whether the lifecycle agent is enabled for this product */
|
|
133
|
+
readonly enabled: boolean;
|
|
134
|
+
/** Allow agent to auto-advance phases without human approval */
|
|
135
|
+
readonly autoAdvanceEnabled: boolean;
|
|
136
|
+
/** Allow agent to auto-rerun phases with generated feedback */
|
|
137
|
+
readonly autoRerunEnabled: boolean;
|
|
138
|
+
/** Global maximum auto-retries (overrides per-phase if lower) */
|
|
139
|
+
readonly maxAutoRetries: number;
|
|
140
|
+
/** When confidence is below this threshold, always escalate (0-100) */
|
|
141
|
+
readonly confidenceThreshold: number;
|
|
142
|
+
/** Per-phase quality criteria overrides (falls back to defaults if not set) */
|
|
143
|
+
readonly phaseOverrides?: Partial<Record<string, Partial<PhaseQualityCriteria>>>;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Runtime state for the lifecycle agent managing a specific feature
|
|
147
|
+
* Tracks re-run counts and evaluation history
|
|
148
|
+
*/
|
|
149
|
+
export interface LifecycleAgentState {
|
|
150
|
+
/** Feature being managed */
|
|
151
|
+
readonly featureId: string;
|
|
152
|
+
/** Number of re-runs per phase in current cycle */
|
|
153
|
+
readonly rerunCounts: Readonly<Record<string, number>>;
|
|
154
|
+
/** History of evaluation results */
|
|
155
|
+
readonly evaluationHistory: readonly PhaseEvaluationResult[];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Input to the lifecycle agent's evaluate function
|
|
159
|
+
* Contains all context needed to evaluate a phase's output
|
|
160
|
+
*/
|
|
161
|
+
export interface PhaseEvaluationInput {
|
|
162
|
+
/** Feature ID */
|
|
163
|
+
readonly featureId: string;
|
|
164
|
+
/** Phase that was just executed */
|
|
165
|
+
readonly phase: string;
|
|
166
|
+
/** Current feature status */
|
|
167
|
+
readonly currentStatus: FeatureStatus;
|
|
168
|
+
/** The phase execution result data (varies by phase) */
|
|
169
|
+
readonly phaseOutput: unknown;
|
|
170
|
+
/** Quality criteria to evaluate against */
|
|
171
|
+
readonly qualityCriteria: PhaseQualityCriteria;
|
|
172
|
+
/** Current re-run count for this phase */
|
|
173
|
+
readonly rerunCount: number;
|
|
174
|
+
/** Agent configuration */
|
|
175
|
+
readonly config: LifecycleAgentConfig;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Output from the lifecycle agent's decide function
|
|
179
|
+
* Contains the decision and all information needed to execute it
|
|
180
|
+
*/
|
|
181
|
+
export interface LifecycleDecisionResult {
|
|
182
|
+
/** The evaluation that led to this decision */
|
|
183
|
+
readonly evaluation: PhaseEvaluationResult;
|
|
184
|
+
/** The transition to execute if advancing */
|
|
185
|
+
readonly transition?: TransitionRule;
|
|
186
|
+
/** Execution mode to use (for both advance and rerun scenarios) */
|
|
187
|
+
readonly executionMode?: ExecutionMode;
|
|
188
|
+
/** Feature status to set */
|
|
189
|
+
readonly targetStatus?: FeatureStatus;
|
|
190
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle Agent Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the AI-powered lifecycle agent that manages
|
|
5
|
+
* product feature lifecycle decisions: when to advance to the next phase,
|
|
6
|
+
* when to re-run the current phase, and when to escalate to a human.
|
|
7
|
+
*
|
|
8
|
+
* The lifecycle agent evaluates phase outputs against quality criteria
|
|
9
|
+
* and makes transition decisions, reducing reliance on manual human
|
|
10
|
+
* intervention while preserving the ability to escalate when needed.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { afterEach, describe, it } from 'node:test';
|
|
3
|
+
import { cacheBindings, clearBindingsCache, getBindingsForPhase, getCachedBindings, } from '../bindings-fetcher.js';
|
|
4
|
+
function makeBinding(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'hook-1',
|
|
7
|
+
product_id: 'prod-1',
|
|
8
|
+
phase: 'technical-design',
|
|
9
|
+
hook_point: 'after',
|
|
10
|
+
plugin_name: 'payload-cms',
|
|
11
|
+
skill_name: 'validate-schema',
|
|
12
|
+
on_failure: 'block',
|
|
13
|
+
config: {},
|
|
14
|
+
sort_order: 0,
|
|
15
|
+
enabled: true,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// ---- getBindingsForPhase ----
|
|
20
|
+
void describe('getBindingsForPhase', () => {
|
|
21
|
+
void it('filters by exact phase match', () => {
|
|
22
|
+
const bindings = [
|
|
23
|
+
makeBinding({ phase: 'technical-design', hook_point: 'after' }),
|
|
24
|
+
makeBinding({
|
|
25
|
+
id: '2',
|
|
26
|
+
phase: 'code-implementation',
|
|
27
|
+
hook_point: 'after',
|
|
28
|
+
}),
|
|
29
|
+
];
|
|
30
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'after');
|
|
31
|
+
assert.strictEqual(result.length, 1);
|
|
32
|
+
assert.strictEqual(result[0].phase, 'technical-design');
|
|
33
|
+
});
|
|
34
|
+
void it('matches wildcard phase "*"', () => {
|
|
35
|
+
const bindings = [
|
|
36
|
+
makeBinding({ phase: '*', hook_point: 'after', skill_name: 'notify' }),
|
|
37
|
+
];
|
|
38
|
+
const result = getBindingsForPhase(bindings, 'code-review', 'after');
|
|
39
|
+
assert.strictEqual(result.length, 1);
|
|
40
|
+
assert.strictEqual(result[0].skill_name, 'notify');
|
|
41
|
+
});
|
|
42
|
+
void it('combines exact match and wildcard', () => {
|
|
43
|
+
const bindings = [
|
|
44
|
+
makeBinding({
|
|
45
|
+
id: '1',
|
|
46
|
+
phase: 'technical-design',
|
|
47
|
+
hook_point: 'after',
|
|
48
|
+
sort_order: 1,
|
|
49
|
+
}),
|
|
50
|
+
makeBinding({ id: '2', phase: '*', hook_point: 'after', sort_order: 0 }),
|
|
51
|
+
];
|
|
52
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'after');
|
|
53
|
+
assert.strictEqual(result.length, 2);
|
|
54
|
+
// Wildcard (sort_order 0) comes first
|
|
55
|
+
assert.strictEqual(result[0].id, '2');
|
|
56
|
+
assert.strictEqual(result[1].id, '1');
|
|
57
|
+
});
|
|
58
|
+
void it('filters by hook_point', () => {
|
|
59
|
+
const bindings = [
|
|
60
|
+
makeBinding({ hook_point: 'before' }),
|
|
61
|
+
makeBinding({ id: '2', hook_point: 'after' }),
|
|
62
|
+
];
|
|
63
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'before');
|
|
64
|
+
assert.strictEqual(result.length, 1);
|
|
65
|
+
assert.strictEqual(result[0].hook_point, 'before');
|
|
66
|
+
});
|
|
67
|
+
void it('excludes disabled bindings', () => {
|
|
68
|
+
const bindings = [makeBinding({ enabled: false })];
|
|
69
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'after');
|
|
70
|
+
assert.strictEqual(result.length, 0);
|
|
71
|
+
});
|
|
72
|
+
void it('sorts by sort_order ascending', () => {
|
|
73
|
+
const bindings = [
|
|
74
|
+
makeBinding({ id: 'c', sort_order: 10 }),
|
|
75
|
+
makeBinding({ id: 'a', sort_order: 1 }),
|
|
76
|
+
makeBinding({ id: 'b', sort_order: 5 }),
|
|
77
|
+
];
|
|
78
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'after');
|
|
79
|
+
assert.deepStrictEqual(result.map((b) => b.id), ['a', 'b', 'c']);
|
|
80
|
+
});
|
|
81
|
+
void it('returns empty array when no bindings match', () => {
|
|
82
|
+
const bindings = [
|
|
83
|
+
makeBinding({ phase: 'code-review', hook_point: 'before' }),
|
|
84
|
+
];
|
|
85
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'after');
|
|
86
|
+
assert.strictEqual(result.length, 0);
|
|
87
|
+
});
|
|
88
|
+
void it('handles on_error hook point', () => {
|
|
89
|
+
const bindings = [makeBinding({ hook_point: 'on_error' })];
|
|
90
|
+
const result = getBindingsForPhase(bindings, 'technical-design', 'on_error');
|
|
91
|
+
assert.strictEqual(result.length, 1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// ---- Cache management ----
|
|
95
|
+
void describe('bindings cache', () => {
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
clearBindingsCache('test-feature-1');
|
|
98
|
+
clearBindingsCache('test-feature-2');
|
|
99
|
+
});
|
|
100
|
+
void it('returns null for uncached feature', () => {
|
|
101
|
+
assert.strictEqual(getCachedBindings('nonexistent'), null);
|
|
102
|
+
});
|
|
103
|
+
void it('round-trips cache/get/clear', () => {
|
|
104
|
+
const bindings = [makeBinding()];
|
|
105
|
+
cacheBindings('test-feature-1', 'prod-1', bindings);
|
|
106
|
+
const cached = getCachedBindings('test-feature-1');
|
|
107
|
+
assert.ok(cached);
|
|
108
|
+
assert.strictEqual(cached.productId, 'prod-1');
|
|
109
|
+
assert.strictEqual(cached.bindings.length, 1);
|
|
110
|
+
assert.ok(cached.fetchedAt > 0);
|
|
111
|
+
clearBindingsCache('test-feature-1');
|
|
112
|
+
assert.strictEqual(getCachedBindings('test-feature-1'), null);
|
|
113
|
+
});
|
|
114
|
+
void it('isolates cache by featureId', () => {
|
|
115
|
+
cacheBindings('test-feature-1', 'prod-1', [makeBinding({ id: 'a' })]);
|
|
116
|
+
cacheBindings('test-feature-2', 'prod-2', [makeBinding({ id: 'b' })]);
|
|
117
|
+
const cached1 = getCachedBindings('test-feature-1');
|
|
118
|
+
const cached2 = getCachedBindings('test-feature-2');
|
|
119
|
+
assert.strictEqual(cached1?.bindings[0].id, 'a');
|
|
120
|
+
assert.strictEqual(cached2?.bindings[0].id, 'b');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { buildHookPrompt, executeHook, parseHookResponse, } from '../hook-executor.js';
|
|
4
|
+
function makeBinding(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'hook-1',
|
|
7
|
+
product_id: 'prod-1',
|
|
8
|
+
phase: 'technical-design',
|
|
9
|
+
hook_point: 'after',
|
|
10
|
+
plugin_name: 'payload-cms',
|
|
11
|
+
skill_name: 'validate-schema',
|
|
12
|
+
on_failure: 'block',
|
|
13
|
+
config: {},
|
|
14
|
+
sort_order: 0,
|
|
15
|
+
enabled: true,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const defaultSkillFile = {
|
|
20
|
+
frontmatter: { model: 'sonnet', maxTurns: 10 },
|
|
21
|
+
body: 'You are a schema validator.',
|
|
22
|
+
};
|
|
23
|
+
function makeContext(overrides = {}) {
|
|
24
|
+
return {
|
|
25
|
+
featureId: 'feat-1',
|
|
26
|
+
phase: 'technical-design',
|
|
27
|
+
hookPoint: 'after',
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// ---- buildHookPrompt ----
|
|
32
|
+
void describe('buildHookPrompt', () => {
|
|
33
|
+
void it('includes feature ID and phase name', () => {
|
|
34
|
+
const ctx = {
|
|
35
|
+
featureId: 'feat-123',
|
|
36
|
+
phase: 'technical-design',
|
|
37
|
+
hookPoint: 'after',
|
|
38
|
+
};
|
|
39
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
40
|
+
assert.ok(prompt.includes('feat-123'));
|
|
41
|
+
assert.ok(prompt.includes('technical-design'));
|
|
42
|
+
});
|
|
43
|
+
void it('includes plugin and skill names', () => {
|
|
44
|
+
const ctx = {
|
|
45
|
+
featureId: 'feat-1',
|
|
46
|
+
phase: 'code-review',
|
|
47
|
+
hookPoint: 'before',
|
|
48
|
+
};
|
|
49
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
50
|
+
assert.ok(prompt.includes('payload-cms'));
|
|
51
|
+
assert.ok(prompt.includes('validate-schema'));
|
|
52
|
+
});
|
|
53
|
+
void it('includes hook point', () => {
|
|
54
|
+
const ctx = {
|
|
55
|
+
featureId: 'feat-1',
|
|
56
|
+
phase: 'code-review',
|
|
57
|
+
hookPoint: 'before',
|
|
58
|
+
};
|
|
59
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
60
|
+
assert.ok(prompt.includes('before'));
|
|
61
|
+
});
|
|
62
|
+
void it('includes phase result for after hooks', () => {
|
|
63
|
+
const ctx = {
|
|
64
|
+
featureId: 'feat-1',
|
|
65
|
+
phase: 'technical-design',
|
|
66
|
+
hookPoint: 'after',
|
|
67
|
+
phaseResult: { status: 'success', iterations: 3 },
|
|
68
|
+
};
|
|
69
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
70
|
+
assert.ok(prompt.includes('Phase Result'));
|
|
71
|
+
assert.ok(prompt.includes('"iterations": 3'));
|
|
72
|
+
});
|
|
73
|
+
void it('does not include phase result for before hooks', () => {
|
|
74
|
+
const ctx = {
|
|
75
|
+
featureId: 'feat-1',
|
|
76
|
+
phase: 'technical-design',
|
|
77
|
+
hookPoint: 'before',
|
|
78
|
+
};
|
|
79
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
80
|
+
assert.ok(!prompt.includes('Phase Result'));
|
|
81
|
+
});
|
|
82
|
+
void it('includes error for on_error hooks', () => {
|
|
83
|
+
const ctx = {
|
|
84
|
+
featureId: 'feat-1',
|
|
85
|
+
phase: 'code-implementation',
|
|
86
|
+
hookPoint: 'on_error',
|
|
87
|
+
error: new Error('Out of memory'),
|
|
88
|
+
};
|
|
89
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
90
|
+
assert.ok(prompt.includes('Phase Error'));
|
|
91
|
+
assert.ok(prompt.includes('Out of memory'));
|
|
92
|
+
});
|
|
93
|
+
void it('includes binding config when present', () => {
|
|
94
|
+
const binding = makeBinding({
|
|
95
|
+
config: { severity: 'high', threshold: 0.8 },
|
|
96
|
+
});
|
|
97
|
+
const ctx = {
|
|
98
|
+
featureId: 'feat-1',
|
|
99
|
+
phase: 'technical-design',
|
|
100
|
+
hookPoint: 'after',
|
|
101
|
+
};
|
|
102
|
+
const prompt = buildHookPrompt(binding, ctx);
|
|
103
|
+
assert.ok(prompt.includes('Hook Configuration'));
|
|
104
|
+
assert.ok(prompt.includes('"severity": "high"'));
|
|
105
|
+
});
|
|
106
|
+
void it('omits config section when config is empty', () => {
|
|
107
|
+
const ctx = {
|
|
108
|
+
featureId: 'feat-1',
|
|
109
|
+
phase: 'technical-design',
|
|
110
|
+
hookPoint: 'after',
|
|
111
|
+
};
|
|
112
|
+
const prompt = buildHookPrompt(makeBinding(), ctx);
|
|
113
|
+
assert.ok(!prompt.includes('Hook Configuration'));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// ---- parseHookResponse ----
|
|
117
|
+
void describe('parseHookResponse', () => {
|
|
118
|
+
void it('parses JSON in fenced code block', () => {
|
|
119
|
+
const text = 'Some text\n```json\n{"status":"success","message":"All good","data":{"count":5}}\n```\nMore text';
|
|
120
|
+
const result = parseHookResponse(text);
|
|
121
|
+
assert.strictEqual(result.status, 'success');
|
|
122
|
+
assert.strictEqual(result.message, 'All good');
|
|
123
|
+
assert.deepStrictEqual(result.data, { count: 5 });
|
|
124
|
+
});
|
|
125
|
+
void it('parses raw JSON text', () => {
|
|
126
|
+
const text = '{"status":"error","message":"Schema mismatch"}';
|
|
127
|
+
const result = parseHookResponse(text);
|
|
128
|
+
assert.strictEqual(result.status, 'error');
|
|
129
|
+
assert.strictEqual(result.message, 'Schema mismatch');
|
|
130
|
+
});
|
|
131
|
+
void it('treats non-JSON text as success', () => {
|
|
132
|
+
const text = 'Everything looks fine, no issues found.';
|
|
133
|
+
const result = parseHookResponse(text);
|
|
134
|
+
assert.strictEqual(result.status, 'success');
|
|
135
|
+
assert.ok(result.message.includes('Everything looks fine'));
|
|
136
|
+
});
|
|
137
|
+
void it('truncates long non-JSON text', () => {
|
|
138
|
+
const text = 'x'.repeat(1000);
|
|
139
|
+
const result = parseHookResponse(text);
|
|
140
|
+
assert.strictEqual(result.message.length, 500);
|
|
141
|
+
});
|
|
142
|
+
void it('handles empty text', () => {
|
|
143
|
+
const result = parseHookResponse('');
|
|
144
|
+
assert.strictEqual(result.status, 'success');
|
|
145
|
+
assert.ok(result.message.includes('no structured output'));
|
|
146
|
+
});
|
|
147
|
+
void it('handles malformed JSON in code block gracefully', () => {
|
|
148
|
+
const text = '```json\n{invalid json}\n```';
|
|
149
|
+
const result = parseHookResponse(text);
|
|
150
|
+
assert.strictEqual(result.status, 'success');
|
|
151
|
+
});
|
|
152
|
+
void it('defaults message when JSON has no message field', () => {
|
|
153
|
+
const text = '{"status":"success"}';
|
|
154
|
+
const result = parseHookResponse(text);
|
|
155
|
+
assert.strictEqual(result.message, 'Hook completed');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
// ---- executeHook (with injected deps) ----
|
|
159
|
+
void describe('executeHook', () => {
|
|
160
|
+
/** Create a mock queryFn that yields the given messages */
|
|
161
|
+
function mockQuery(messages
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
) {
|
|
164
|
+
// Return a function that returns an async iterable
|
|
165
|
+
return () => ({
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
167
|
+
async *[Symbol.asyncIterator]() {
|
|
168
|
+
for (const msg of messages) {
|
|
169
|
+
yield msg;
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function makeDeps(overrides = {}) {
|
|
175
|
+
return {
|
|
176
|
+
loadSkillFile: () => Promise.resolve(defaultSkillFile),
|
|
177
|
+
queryFn: mockQuery([
|
|
178
|
+
{
|
|
179
|
+
type: 'result',
|
|
180
|
+
subtype: 'success',
|
|
181
|
+
result: '```json\n{"status":"success","message":"Schema valid"}\n```',
|
|
182
|
+
},
|
|
183
|
+
]),
|
|
184
|
+
...overrides,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
void it('returns skipped when skill file not found', async () => {
|
|
188
|
+
const deps = makeDeps({ loadSkillFile: () => Promise.resolve(null) });
|
|
189
|
+
const result = await executeHook(makeBinding(), makeContext(), false, deps);
|
|
190
|
+
assert.strictEqual(result.status, 'skipped');
|
|
191
|
+
assert.ok(result.message.includes('not found'));
|
|
192
|
+
});
|
|
193
|
+
void it('executes successfully with result message from query', async () => {
|
|
194
|
+
const deps = makeDeps();
|
|
195
|
+
const result = await executeHook(makeBinding(), makeContext(), false, deps);
|
|
196
|
+
assert.strictEqual(result.status, 'success');
|
|
197
|
+
assert.strictEqual(result.message, 'Schema valid');
|
|
198
|
+
assert.ok(result.duration_ms >= 0);
|
|
199
|
+
assert.strictEqual(result.hookId, 'hook-1');
|
|
200
|
+
});
|
|
201
|
+
void it('returns error status when skill returns error JSON', async () => {
|
|
202
|
+
const deps = makeDeps({
|
|
203
|
+
queryFn: mockQuery([
|
|
204
|
+
{
|
|
205
|
+
type: 'result',
|
|
206
|
+
subtype: 'success',
|
|
207
|
+
result: '{"status":"error","message":"3 issues found"}',
|
|
208
|
+
},
|
|
209
|
+
]),
|
|
210
|
+
});
|
|
211
|
+
const result = await executeHook(makeBinding(), makeContext(), false, deps);
|
|
212
|
+
assert.strictEqual(result.status, 'error');
|
|
213
|
+
assert.strictEqual(result.message, '3 issues found');
|
|
214
|
+
});
|
|
215
|
+
void it('collects text from assistant messages', async () => {
|
|
216
|
+
const deps = makeDeps({
|
|
217
|
+
queryFn: mockQuery([
|
|
218
|
+
{
|
|
219
|
+
type: 'assistant',
|
|
220
|
+
message: {
|
|
221
|
+
content: [{ type: 'text', text: '```json\n{"status":"success",' }],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
type: 'assistant',
|
|
226
|
+
message: {
|
|
227
|
+
content: [
|
|
228
|
+
{ type: 'text', text: '"message":"from streaming"}\n```' },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{ type: 'result', subtype: 'success', result: '' },
|
|
233
|
+
]),
|
|
234
|
+
});
|
|
235
|
+
const result = await executeHook(makeBinding(), makeContext(), false, deps);
|
|
236
|
+
assert.strictEqual(result.status, 'success');
|
|
237
|
+
assert.strictEqual(result.message, 'from streaming');
|
|
238
|
+
});
|
|
239
|
+
void it('prefers result message over streamed text', async () => {
|
|
240
|
+
const deps = makeDeps({
|
|
241
|
+
queryFn: mockQuery([
|
|
242
|
+
{
|
|
243
|
+
type: 'assistant',
|
|
244
|
+
message: { content: [{ type: 'text', text: 'streamed partial' }] },
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: 'result',
|
|
248
|
+
subtype: 'success',
|
|
249
|
+
result: '{"status":"success","message":"final result"}',
|
|
250
|
+
},
|
|
251
|
+
]),
|
|
252
|
+
});
|
|
253
|
+
const result = await executeHook(makeBinding(), makeContext(), false, deps);
|
|
254
|
+
assert.strictEqual(result.message, 'final result');
|
|
255
|
+
});
|
|
256
|
+
void it('returns error when query throws', async () => {
|
|
257
|
+
const deps = makeDeps({
|
|
258
|
+
queryFn: (() => {
|
|
259
|
+
throw new Error('Network timeout');
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
const result = await executeHook(makeBinding(), makeContext(), false, deps);
|
|
264
|
+
assert.strictEqual(result.status, 'error');
|
|
265
|
+
assert.ok(result.message.includes('Network timeout'));
|
|
266
|
+
});
|
|
267
|
+
void it('passes correct model and maxTurns from frontmatter', async () => {
|
|
268
|
+
let capturedOptions = {};
|
|
269
|
+
const deps = makeDeps({
|
|
270
|
+
loadSkillFile: () => Promise.resolve({
|
|
271
|
+
frontmatter: { model: 'haiku', maxTurns: 5 },
|
|
272
|
+
body: 'Test prompt',
|
|
273
|
+
}),
|
|
274
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
275
|
+
queryFn: ((opts) => {
|
|
276
|
+
capturedOptions = opts.options;
|
|
277
|
+
return {
|
|
278
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
279
|
+
async *[Symbol.asyncIterator]() {
|
|
280
|
+
yield {
|
|
281
|
+
type: 'result',
|
|
282
|
+
subtype: 'success',
|
|
283
|
+
result: '{"status":"success","message":"ok"}',
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
await executeHook(makeBinding(), makeContext(), false, deps);
|
|
291
|
+
assert.strictEqual(capturedOptions.model, 'haiku');
|
|
292
|
+
assert.strictEqual(capturedOptions.maxTurns, 5);
|
|
293
|
+
});
|
|
294
|
+
void it('uses DEFAULT_MODEL when frontmatter has no model', async () => {
|
|
295
|
+
let capturedOptions = {};
|
|
296
|
+
const deps = makeDeps({
|
|
297
|
+
loadSkillFile: () => Promise.resolve({
|
|
298
|
+
frontmatter: {},
|
|
299
|
+
body: 'Test prompt',
|
|
300
|
+
}),
|
|
301
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
302
|
+
queryFn: ((opts) => {
|
|
303
|
+
capturedOptions = opts.options;
|
|
304
|
+
return {
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
306
|
+
async *[Symbol.asyncIterator]() {
|
|
307
|
+
yield {
|
|
308
|
+
type: 'result',
|
|
309
|
+
subtype: 'success',
|
|
310
|
+
result: '{"status":"success","message":"ok"}',
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
await executeHook(makeBinding(), makeContext(), false, deps);
|
|
318
|
+
// DEFAULT_MODEL is 'opus'
|
|
319
|
+
assert.strictEqual(capturedOptions.model, 'opus');
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|