agentic-orchestrator 0.1.22 → 0.1.24
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 +9 -9
- package/agentic/orchestrator/defaults/policy.defaults.yaml +4 -0
- package/agentic/orchestrator/policy.yaml +4 -0
- package/agentic/orchestrator/prompts/planner.system.md +2 -2
- package/agentic/orchestrator/schemas/gates.schema.json +1 -1
- package/agentic/orchestrator/schemas/plan.schema.json +3 -4
- package/agentic/orchestrator/schemas/policy.schema.json +20 -0
- package/agentic/orchestrator/schemas/state.schema.json +1 -1
- package/apps/control-plane/src/application/services/gate-selection-service.ts +267 -0
- package/apps/control-plane/src/application/services/gate-service.ts +11 -17
- package/apps/control-plane/src/application/services/patch-service.ts +10 -3
- package/apps/control-plane/src/application/services/plan-service.ts +24 -7
- package/apps/control-plane/src/cli/cleanup-command-handler.ts +8 -0
- package/apps/control-plane/src/cli/help-command-handler.ts +5 -0
- package/apps/control-plane/src/core/kernel.ts +57 -1
- package/apps/control-plane/src/supervisor/runtime.ts +0 -1
- package/apps/control-plane/src/supervisor/worker-decision-loop.ts +0 -1
- package/apps/control-plane/test/cleanup-command.spec.ts +97 -0
- package/apps/control-plane/test/gate-selection-service.spec.ts +125 -0
- package/apps/control-plane/test/kernel.spec.ts +40 -0
- package/apps/control-plane/test/mcp.spec.ts +16 -0
- package/apps/control-plane/test/patch-service.spec.ts +61 -2
- package/apps/control-plane/test/plan-service.spec.ts +42 -0
- package/apps/control-plane/test/supervisor.unit.spec.ts +0 -1
- package/apps/control-plane/test/worker-decision-loop.spec.ts +6 -0
- package/config/agentic/orchestrator/agents.yaml +1 -1
- package/config/agentic/orchestrator/prompts/planner.system.md +2 -2
- package/dist/apps/control-plane/application/services/gate-selection-service.d.ts +35 -0
- package/dist/apps/control-plane/application/services/gate-selection-service.js +205 -0
- package/dist/apps/control-plane/application/services/gate-selection-service.js.map +1 -0
- package/dist/apps/control-plane/application/services/gate-service.d.ts +1 -0
- package/dist/apps/control-plane/application/services/gate-service.js +8 -13
- package/dist/apps/control-plane/application/services/gate-service.js.map +1 -1
- package/dist/apps/control-plane/application/services/patch-service.d.ts +1 -0
- package/dist/apps/control-plane/application/services/patch-service.js +8 -3
- package/dist/apps/control-plane/application/services/patch-service.js.map +1 -1
- package/dist/apps/control-plane/application/services/plan-service.d.ts +2 -0
- package/dist/apps/control-plane/application/services/plan-service.js +16 -7
- package/dist/apps/control-plane/application/services/plan-service.js.map +1 -1
- package/dist/apps/control-plane/cli/cleanup-command-handler.js +8 -0
- package/dist/apps/control-plane/cli/cleanup-command-handler.js.map +1 -1
- package/dist/apps/control-plane/cli/help-command-handler.js +4 -0
- package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
- package/dist/apps/control-plane/core/kernel.d.ts +3 -0
- package/dist/apps/control-plane/core/kernel.js +44 -1
- package/dist/apps/control-plane/core/kernel.js.map +1 -1
- package/dist/apps/control-plane/supervisor/runtime.js +0 -1
- package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
- package/dist/apps/control-plane/supervisor/worker-decision-loop.js +0 -1
- package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
- package/package.json +1 -1
- package/spec-files/outstanding/agentic_orchestrator_cli_shell_tab_completion_spec.md +382 -0
- package/spec-files/outstanding/agentic_orchestrator_deterministic_gate_selection_spec.md +417 -0
- package/spec-files/outstanding/agentic_orchestrator_persistent_worker_runtime_execution_checklist.md +308 -0
- package/spec-files/outstanding/agentic_orchestrator_persistent_worker_runtime_spec.md +596 -0
- package/spec-files/outstanding/agentic_orchestrator_worker_runtime_watchdog_resilience_spec.md +1074 -179
- package/spec-files/progress.md +140 -1
package/README.md
CHANGED
|
@@ -636,15 +636,15 @@ Files the runtime creates/maintains:
|
|
|
636
636
|
|
|
637
637
|
### Schema mapping
|
|
638
638
|
|
|
639
|
-
| Runtime file | Schema file | Required/expected essentials
|
|
640
|
-
| -------------------------------------------------- | -------------------------------------------------------- |
|
|
641
|
-
| `.aop/features/<feature_id>/plan.json` | `agentic/orchestrator/schemas/plan.schema.json` | `feature_id`, `plan_version`, `summary`, `allowed_areas`, `forbidden_areas`, `base_ref`, `files.{create,modify,delete}`, `contracts.{openapi,events,db}`, `acceptance_criteria`, `
|
|
642
|
-
| `.aop/features/<feature_id>/state.md` front matter | `agentic/orchestrator/schemas/state.schema.json` | `feature_id`, `version`, `branch`, `worktree_path`, `status`, `gate_profile`, `gates`, `locks.held`, `collisions`, `cluster`, `role_status`, `last_updated`
|
|
643
|
-
| `.aop/features/index.json` | `agentic/orchestrator/schemas/index.schema.json` | `version`, `active`, `blocked`, `merged`, `locks`, `lock_leases`, `blocked_queue`, `runtime_sessions.{run_id,orchestrator_session_id,owner_instance_id,lease_expires_at,feature_sessions}`
|
|
644
|
-
| `config/agentic/orchestrator/gates.yaml` | `agentic/orchestrator/schemas/gates.schema.json` | `version`, `profiles.<profile>.modes.<mode>[{name,cmd,...}]`, optional parser/threshold metadata
|
|
645
|
-
| `config/agentic/orchestrator/policy.yaml` | `agentic/orchestrator/schemas/policy.schema.json` | commit/merge policy, patch policy, lock config, collision policy, path rules, execution policy, RBAC, Nx/Vitest implementation constraints
|
|
646
|
-
| `config/agentic/orchestrator/agents.yaml` | `agentic/orchestrator/schemas/agents.schema.json` | `version`, `roles`, `missing_prompt_behavior`, optional runtime defaults
|
|
647
|
-
| `.aop/features/<feature_id>/qa_test_index.json` | `agentic/orchestrator/schemas/qa_test_index.schema.json` | `feature_id`, `version`, `source_diff_ref`, `items[]` with path/hunks/required_tests/status
|
|
639
|
+
| Runtime file | Schema file | Required/expected essentials |
|
|
640
|
+
| -------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
641
|
+
| `.aop/features/<feature_id>/plan.json` | `agentic/orchestrator/schemas/plan.schema.json` | `feature_id`, `plan_version`, `summary`, `allowed_areas`, `forbidden_areas`, `base_ref`, `files.{create,modify,delete}`, `contracts.{openapi,events,db}`, `acceptance_criteria`, optional `gate_targets` (planner intent) |
|
|
642
|
+
| `.aop/features/<feature_id>/state.md` front matter | `agentic/orchestrator/schemas/state.schema.json` | `feature_id`, `version`, `branch`, `worktree_path`, `status`, `gate_profile`, `gates`, `locks.held`, `collisions`, `cluster`, `role_status`, `last_updated` |
|
|
643
|
+
| `.aop/features/index.json` | `agentic/orchestrator/schemas/index.schema.json` | `version`, `active`, `blocked`, `merged`, `locks`, `lock_leases`, `blocked_queue`, `runtime_sessions.{run_id,orchestrator_session_id,owner_instance_id,lease_expires_at,feature_sessions}` |
|
|
644
|
+
| `config/agentic/orchestrator/gates.yaml` | `agentic/orchestrator/schemas/gates.schema.json` | `version`, `profiles.<profile>.modes.<mode>[{name,cmd,...}]`, optional parser/threshold metadata |
|
|
645
|
+
| `config/agentic/orchestrator/policy.yaml` | `agentic/orchestrator/schemas/policy.schema.json` | commit/merge policy, patch policy, lock config, collision policy, gate selection defaults/healing toggles, path rules, execution policy, RBAC, Nx/Vitest implementation constraints |
|
|
646
|
+
| `config/agentic/orchestrator/agents.yaml` | `agentic/orchestrator/schemas/agents.schema.json` | `version`, `roles`, `missing_prompt_behavior`, optional runtime defaults |
|
|
647
|
+
| `.aop/features/<feature_id>/qa_test_index.json` | `agentic/orchestrator/schemas/qa_test_index.schema.json` | `feature_id`, `version`, `source_diff_ref`, `items[]` with path/hunks/required_tests/status |
|
|
648
648
|
|
|
649
649
|
### Important operational notes
|
|
650
650
|
|
|
@@ -32,6 +32,10 @@ required_modes:
|
|
|
32
32
|
- fast
|
|
33
33
|
- full
|
|
34
34
|
required_merge_mode: merge
|
|
35
|
+
gate_selection:
|
|
36
|
+
default_profile: default
|
|
37
|
+
auto_heal_invalid_state_profile: true
|
|
38
|
+
legacy_mode_profile_compat: true
|
|
35
39
|
collision_policy: reject
|
|
36
40
|
config_precedence:
|
|
37
41
|
policy_hard_constraints:
|
|
@@ -32,6 +32,10 @@ required_modes:
|
|
|
32
32
|
- fast
|
|
33
33
|
- full
|
|
34
34
|
required_merge_mode: merge
|
|
35
|
+
gate_selection:
|
|
36
|
+
default_profile: default
|
|
37
|
+
auto_heal_invalid_state_profile: true
|
|
38
|
+
legacy_mode_profile_compat: true
|
|
35
39
|
collision_policy: reject
|
|
36
40
|
config_precedence:
|
|
37
41
|
policy_hard_constraints:
|
|
@@ -19,11 +19,11 @@ Every `PLAN_SUBMISSION` must include ALL of the following fields in `plan_json`:
|
|
|
19
19
|
| `files` | object | `{ "create": [...], "modify": [...], "delete": [...] }` — all three arrays required, may be empty |
|
|
20
20
|
| `contracts` | object | `{ "openapi": "none"\|"modify", "events": "none"\|"modify", "db": "none"\|"migration" }` |
|
|
21
21
|
| `acceptance_criteria` | string[] (min 1) | Conditions that must all be met before merge |
|
|
22
|
-
| `gate_profile` | string | Gate profile name (e.g. `"fast"` or `"full"`) |
|
|
23
22
|
|
|
24
23
|
Optional fields (with types):
|
|
25
24
|
|
|
26
25
|
- `gate_targets`: `string[]` — explicit gate mode names to run
|
|
26
|
+
- `gate_profile`: `string` — deprecated compatibility field; runtime owns profile selection and may ignore or rewrite this value
|
|
27
27
|
- `risk`: **`string[]`** — list of risk statements (e.g. `["Schema migration may require downtime"]`). **Must be an array, never a plain string.**
|
|
28
28
|
- `revision_of`: `integer` — plan_version this revises
|
|
29
29
|
- `revision_reason`: `string` — why the plan was revised
|
|
@@ -48,7 +48,7 @@ Optional fields (with types):
|
|
|
48
48
|
},
|
|
49
49
|
"contracts": { "openapi": "none", "events": "none", "db": "none" },
|
|
50
50
|
"acceptance_criteria": ["All tests pass at ≥90% coverage", "npm run lint passes"],
|
|
51
|
-
"
|
|
51
|
+
"gate_targets": ["fast", "full"]
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
```
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"profiles": {
|
|
14
14
|
"type": "object",
|
|
15
|
-
"description": "Named gate profiles.
|
|
15
|
+
"description": "Named gate profiles. Runtime gate execution resolves the active profile from state.gate_profile (system-owned), with deterministic policy fallback.",
|
|
16
16
|
"additionalProperties": {
|
|
17
17
|
"type": "object",
|
|
18
18
|
"description": "A named gate profile containing one or more execution modes.",
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"base_ref",
|
|
14
14
|
"files",
|
|
15
15
|
"contracts",
|
|
16
|
-
"acceptance_criteria"
|
|
17
|
-
"gate_profile"
|
|
16
|
+
"acceptance_criteria"
|
|
18
17
|
],
|
|
19
18
|
"properties": {
|
|
20
19
|
"feature_id": {
|
|
@@ -121,7 +120,7 @@
|
|
|
121
120
|
"gate_profile": {
|
|
122
121
|
"type": "string",
|
|
123
122
|
"minLength": 1,
|
|
124
|
-
"description": "
|
|
123
|
+
"description": "Deprecated planner compatibility field. Planner-supplied gate_profile is advisory only and may be ignored/re-written by runtime canonicalization."
|
|
125
124
|
},
|
|
126
125
|
"gate_targets": {
|
|
127
126
|
"type": "array",
|
|
@@ -130,7 +129,7 @@
|
|
|
130
129
|
"minLength": 1
|
|
131
130
|
},
|
|
132
131
|
"minItems": 1,
|
|
133
|
-
"description": "Optional
|
|
132
|
+
"description": "Optional planner-authored list of gate mode names to prioritize. Runtime profile selection remains system-owned via state.gate_profile."
|
|
134
133
|
},
|
|
135
134
|
"risk": {
|
|
136
135
|
"type": "array",
|
|
@@ -166,6 +166,26 @@
|
|
|
166
166
|
"type": "string",
|
|
167
167
|
"description": "Additional gate mode that must have passed immediately before merge (typically 'merge'), on top of required_modes."
|
|
168
168
|
},
|
|
169
|
+
"gate_selection": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"description": "Deterministic gate profile selection policy. Runtime owns profile resolution from state/policy rather than planner input.",
|
|
172
|
+
"additionalProperties": false,
|
|
173
|
+
"properties": {
|
|
174
|
+
"default_profile": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"minLength": 1,
|
|
177
|
+
"description": "Preferred default gate profile key from gates.profiles. If invalid/missing at runtime, deterministic fallback is used."
|
|
178
|
+
},
|
|
179
|
+
"auto_heal_invalid_state_profile": {
|
|
180
|
+
"type": "boolean",
|
|
181
|
+
"description": "When true, invalid or missing state.gate_profile values are auto-healed to resolved profile on feature touch paths."
|
|
182
|
+
},
|
|
183
|
+
"legacy_mode_profile_compat": {
|
|
184
|
+
"type": "boolean",
|
|
185
|
+
"description": "When true, planner legacy gate_profile mode tokens (fast/full/merge) are canonicalized into gate_targets for compatibility."
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
169
189
|
"collision_policy": {
|
|
170
190
|
"type": "string",
|
|
171
191
|
"description": "'reject' immediately fails a colliding plan submission; 'block' queues the plan in index.blocked_queue until the conflicting feature merges.",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"gate_profile": {
|
|
45
45
|
"type": "string",
|
|
46
|
-
"description": "
|
|
46
|
+
"description": "System-resolved gates profile for this feature. Runtime resolves and heals this from policy/gates config; planner plan input is not authoritative."
|
|
47
47
|
},
|
|
48
48
|
"gates": {
|
|
49
49
|
"type": "object",
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ERROR_CODES } from '../../core/error-codes.js';
|
|
2
|
+
import { fail } from '../../core/response.js';
|
|
3
|
+
|
|
4
|
+
type AnyRecord = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
function asRecord(value: unknown): AnyRecord {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as AnyRecord) : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function asNonEmptyString(value: unknown): string | null {
|
|
11
|
+
if (typeof value !== 'string') {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function asStringArray(value: unknown): string[] {
|
|
19
|
+
if (!Array.isArray(value)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const normalized: string[] = [];
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const item of value) {
|
|
25
|
+
const next = asNonEmptyString(item);
|
|
26
|
+
if (!next || seen.has(next)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
normalized.push(next);
|
|
30
|
+
seen.add(next);
|
|
31
|
+
}
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GateSelectionServicePort {
|
|
36
|
+
getGatesConfig(): AnyRecord;
|
|
37
|
+
getPolicySnapshot(): AnyRecord;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GateSelectionFinding {
|
|
41
|
+
code: string;
|
|
42
|
+
message: string;
|
|
43
|
+
details?: AnyRecord;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ResolvedGateProfile {
|
|
47
|
+
profileName: string;
|
|
48
|
+
source: 'state' | 'policy_default' | 'fallback';
|
|
49
|
+
findings: GateSelectionFinding[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CanonicalizedPlanInput {
|
|
53
|
+
plan: AnyRecord;
|
|
54
|
+
findings: GateSelectionFinding[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class GateSelectionService {
|
|
58
|
+
private readonly port: GateSelectionServicePort;
|
|
59
|
+
|
|
60
|
+
constructor(port: GateSelectionServicePort) {
|
|
61
|
+
this.port = port;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private readProfiles(): Record<string, AnyRecord> {
|
|
65
|
+
const gatesConfig = this.port.getGatesConfig();
|
|
66
|
+
const profiles = gatesConfig['profiles'];
|
|
67
|
+
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
return profiles as Record<string, AnyRecord>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private readGateSelectionPolicy(): AnyRecord {
|
|
74
|
+
const policy = this.port.getPolicySnapshot();
|
|
75
|
+
return asRecord(asRecord(policy)['gate_selection']);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isAutoHealInvalidStateProfileEnabled(): boolean {
|
|
79
|
+
const policy = this.readGateSelectionPolicy();
|
|
80
|
+
const value = policy['auto_heal_invalid_state_profile'];
|
|
81
|
+
return typeof value === 'boolean' ? value : true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
isLegacyModeProfileCompatEnabled(): boolean {
|
|
85
|
+
const policy = this.readGateSelectionPolicy();
|
|
86
|
+
const value = policy['legacy_mode_profile_compat'];
|
|
87
|
+
return typeof value === 'boolean' ? value : true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private unknownGateProfileOrModeError(details: AnyRecord): never {
|
|
91
|
+
throw {
|
|
92
|
+
normalizedResponse: fail(
|
|
93
|
+
ERROR_CODES.UNKNOWN_GATE_PROFILE_OR_MODE,
|
|
94
|
+
'Unknown gate profile or mode',
|
|
95
|
+
{
|
|
96
|
+
retryable: false,
|
|
97
|
+
requires_human: true,
|
|
98
|
+
...details,
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private resolveFallbackProfile(
|
|
105
|
+
profiles: Record<string, AnyRecord>,
|
|
106
|
+
findings: GateSelectionFinding[],
|
|
107
|
+
): ResolvedGateProfile {
|
|
108
|
+
const profileNames = Object.keys(profiles).sort((a, b) => a.localeCompare(b));
|
|
109
|
+
if (profileNames.length === 0) {
|
|
110
|
+
this.unknownGateProfileOrModeError({
|
|
111
|
+
profile: null,
|
|
112
|
+
available_profiles: [],
|
|
113
|
+
reason: 'no_profiles_configured',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const policy = this.readGateSelectionPolicy();
|
|
118
|
+
const configuredDefault = asNonEmptyString(policy['default_profile']);
|
|
119
|
+
if (configuredDefault && Object.prototype.hasOwnProperty.call(profiles, configuredDefault)) {
|
|
120
|
+
return {
|
|
121
|
+
profileName: configuredDefault,
|
|
122
|
+
source: 'policy_default',
|
|
123
|
+
findings,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (configuredDefault) {
|
|
128
|
+
findings.push({
|
|
129
|
+
code: 'policy_default_profile_invalid',
|
|
130
|
+
message: 'Configured default profile is not defined in gates profiles',
|
|
131
|
+
details: {
|
|
132
|
+
configured_default_profile: configuredDefault,
|
|
133
|
+
available_profiles: profileNames,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fallback = Object.prototype.hasOwnProperty.call(profiles, 'default')
|
|
139
|
+
? 'default'
|
|
140
|
+
: profileNames[0];
|
|
141
|
+
return {
|
|
142
|
+
profileName: fallback,
|
|
143
|
+
source: 'fallback',
|
|
144
|
+
findings,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
resolveProfileFromState(stateGateProfile: unknown): ResolvedGateProfile {
|
|
149
|
+
const profiles = this.readProfiles();
|
|
150
|
+
const findings: GateSelectionFinding[] = [];
|
|
151
|
+
const normalized = asNonEmptyString(stateGateProfile);
|
|
152
|
+
|
|
153
|
+
if (normalized && Object.prototype.hasOwnProperty.call(profiles, normalized)) {
|
|
154
|
+
return {
|
|
155
|
+
profileName: normalized,
|
|
156
|
+
source: 'state',
|
|
157
|
+
findings,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (normalized) {
|
|
162
|
+
findings.push({
|
|
163
|
+
code: 'state_gate_profile_invalid',
|
|
164
|
+
message: 'State gate_profile is not a configured profile and will be healed to fallback',
|
|
165
|
+
details: {
|
|
166
|
+
state_gate_profile: normalized,
|
|
167
|
+
available_profiles: Object.keys(profiles).sort((a, b) => a.localeCompare(b)),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return this.resolveFallbackProfile(profiles, findings);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
resolveProfile(profileName: unknown): AnyRecord {
|
|
176
|
+
const normalized = asNonEmptyString(profileName);
|
|
177
|
+
const profiles = this.readProfiles();
|
|
178
|
+
if (normalized && Object.prototype.hasOwnProperty.call(profiles, normalized)) {
|
|
179
|
+
return profiles[normalized];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.unknownGateProfileOrModeError({
|
|
183
|
+
profile: normalized,
|
|
184
|
+
available_profiles: Object.keys(profiles).sort((a, b) => a.localeCompare(b)),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
resolveProfileAndMode(profileName: unknown, mode: unknown): AnyRecord {
|
|
189
|
+
const normalizedMode = asNonEmptyString(mode);
|
|
190
|
+
const profile = this.resolveProfile(profileName);
|
|
191
|
+
const modes = asRecord(profile['modes']);
|
|
192
|
+
if (normalizedMode && Array.isArray(modes[normalizedMode])) {
|
|
193
|
+
return profile;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const normalizedProfile = asNonEmptyString(profileName);
|
|
197
|
+
this.unknownGateProfileOrModeError({
|
|
198
|
+
profile: normalizedProfile,
|
|
199
|
+
mode: normalizedMode,
|
|
200
|
+
available_modes: Object.keys(modes).sort((a, b) => a.localeCompare(b)),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private listKnownModes(): Set<string> {
|
|
205
|
+
const knownModes = new Set<string>();
|
|
206
|
+
const profiles = this.readProfiles();
|
|
207
|
+
for (const profile of Object.values(profiles)) {
|
|
208
|
+
const modes = asRecord(profile['modes']);
|
|
209
|
+
for (const mode of Object.keys(modes)) {
|
|
210
|
+
knownModes.add(mode);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return knownModes;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
canonicalizePlannerPlanInput(
|
|
217
|
+
planInput: AnyRecord,
|
|
218
|
+
resolvedProfileName: string,
|
|
219
|
+
): CanonicalizedPlanInput {
|
|
220
|
+
const canonicalPlan: AnyRecord = structuredClone(planInput);
|
|
221
|
+
const findings: GateSelectionFinding[] = [];
|
|
222
|
+
const plannerProfile = asNonEmptyString(canonicalPlan['gate_profile']);
|
|
223
|
+
const gateTargets = asStringArray(canonicalPlan['gate_targets']);
|
|
224
|
+
const knownModes = this.listKnownModes();
|
|
225
|
+
const profiles = this.readProfiles();
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
plannerProfile &&
|
|
229
|
+
this.isLegacyModeProfileCompatEnabled() &&
|
|
230
|
+
!Object.prototype.hasOwnProperty.call(profiles, plannerProfile) &&
|
|
231
|
+
knownModes.has(plannerProfile) &&
|
|
232
|
+
!gateTargets.includes(plannerProfile)
|
|
233
|
+
) {
|
|
234
|
+
gateTargets.push(plannerProfile);
|
|
235
|
+
findings.push({
|
|
236
|
+
code: 'legacy_mode_profile_compat_applied',
|
|
237
|
+
message: 'Planner gate_profile matched a mode and was promoted into gate_targets',
|
|
238
|
+
details: {
|
|
239
|
+
promoted_mode: plannerProfile,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (plannerProfile && plannerProfile !== resolvedProfileName) {
|
|
245
|
+
findings.push({
|
|
246
|
+
code: 'planner_gate_profile_ignored',
|
|
247
|
+
message: 'Planner gate_profile is non-authoritative and was replaced by system profile',
|
|
248
|
+
details: {
|
|
249
|
+
planner_gate_profile: plannerProfile,
|
|
250
|
+
resolved_gate_profile: resolvedProfileName,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
canonicalPlan['gate_profile'] = resolvedProfileName;
|
|
256
|
+
if (gateTargets.length > 0) {
|
|
257
|
+
canonicalPlan['gate_targets'] = gateTargets;
|
|
258
|
+
} else {
|
|
259
|
+
delete canonicalPlan['gate_targets'];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
plan: canonicalPlan,
|
|
264
|
+
findings,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -7,6 +7,7 @@ import { ERROR_CODES } from '../../core/error-codes.js';
|
|
|
7
7
|
import { fail } from '../../core/response.js';
|
|
8
8
|
import { GATE_RESULT, STATUS } from '../../core/constants.js';
|
|
9
9
|
import { interpolateGateCommands, isIncrementalMode } from './gate-interpolation-service.js';
|
|
10
|
+
import { GateSelectionService } from './gate-selection-service.js';
|
|
10
11
|
|
|
11
12
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
13
|
type AnyRecord = Record<string, any>;
|
|
@@ -70,9 +71,11 @@ export interface GateServicePort {
|
|
|
70
71
|
|
|
71
72
|
export class GateService {
|
|
72
73
|
private readonly port: GateServicePort;
|
|
74
|
+
private readonly gateSelectionService: GateSelectionService;
|
|
73
75
|
|
|
74
76
|
constructor(port: GateServicePort) {
|
|
75
77
|
this.port = port;
|
|
78
|
+
this.gateSelectionService = new GateSelectionService(port);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
gatesList(profileName: string | null = null): { data: AnyRecord } {
|
|
@@ -94,22 +97,7 @@ export class GateService {
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
gateProfileAndMode(profileName: string, mode: string): AnyRecord {
|
|
97
|
-
|
|
98
|
-
if (!profile || !profile.modes?.[mode]) {
|
|
99
|
-
throw {
|
|
100
|
-
normalizedResponse: fail(
|
|
101
|
-
ERROR_CODES.UNKNOWN_GATE_PROFILE_OR_MODE,
|
|
102
|
-
'Unknown gate profile or mode',
|
|
103
|
-
{
|
|
104
|
-
profile: profileName,
|
|
105
|
-
mode,
|
|
106
|
-
retryable: false,
|
|
107
|
-
requires_human: true,
|
|
108
|
-
},
|
|
109
|
-
),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
return profile;
|
|
100
|
+
return this.gateSelectionService.resolveProfileAndMode(profileName, mode);
|
|
113
101
|
}
|
|
114
102
|
|
|
115
103
|
async gatesRun(
|
|
@@ -128,7 +116,12 @@ export class GateService {
|
|
|
128
116
|
evidence: { log_path: string };
|
|
129
117
|
}> {
|
|
130
118
|
const state = await this.port.readState(featureId);
|
|
131
|
-
const
|
|
119
|
+
const resolvedProfile = this.gateSelectionService.resolveProfileFromState(
|
|
120
|
+
state.frontMatter.gate_profile,
|
|
121
|
+
);
|
|
122
|
+
const explicitProfileName =
|
|
123
|
+
typeof profileName === 'string' && profileName.trim().length > 0 ? profileName.trim() : null;
|
|
124
|
+
const effectiveProfileName = explicitProfileName ?? resolvedProfile.profileName;
|
|
132
125
|
const profile = this.gateProfileAndMode(effectiveProfileName, mode);
|
|
133
126
|
const repoRoot = this.port.getRepoRoot();
|
|
134
127
|
|
|
@@ -178,6 +171,7 @@ export class GateService {
|
|
|
178
171
|
Promise.resolve({
|
|
179
172
|
frontMatter: {
|
|
180
173
|
status: nextStateStatus,
|
|
174
|
+
gate_profile: resolvedProfile.profileName,
|
|
181
175
|
status_reason:
|
|
182
176
|
runResult.overall === 'fail' ? `Gate mode ${mode} failed` : frontMatter.status_reason,
|
|
183
177
|
gates: {
|
|
@@ -10,6 +10,7 @@ import { runGit } from '../../core/git.js';
|
|
|
10
10
|
import { buildQaIndex, makeRequiredTests } from '../../core/qa-index.js';
|
|
11
11
|
import { ERROR_CODES } from '../../core/error-codes.js';
|
|
12
12
|
import { fail } from '../../core/response.js';
|
|
13
|
+
import { GateSelectionService } from './gate-selection-service.js';
|
|
13
14
|
|
|
14
15
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
16
|
type AnyRecord = Record<string, any>;
|
|
@@ -64,9 +65,11 @@ export interface PatchServicePort {
|
|
|
64
65
|
|
|
65
66
|
export class PatchService {
|
|
66
67
|
private readonly port: PatchServicePort;
|
|
68
|
+
private readonly gateSelectionService: GateSelectionService;
|
|
67
69
|
|
|
68
70
|
constructor(port: PatchServicePort) {
|
|
69
71
|
this.port = port;
|
|
72
|
+
this.gateSelectionService = new GateSelectionService(port);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
async loadAcceptedPlan(featureId: string): Promise<AnyRecord | null> {
|
|
@@ -208,6 +211,11 @@ export class PatchService {
|
|
|
208
211
|
};
|
|
209
212
|
}> {
|
|
210
213
|
const plan = await this.loadAcceptedPlan(featureId);
|
|
214
|
+
const state = await this.port.readState(featureId);
|
|
215
|
+
const resolvedProfile = this.gateSelectionService.resolveProfileFromState(
|
|
216
|
+
state.frontMatter.gate_profile,
|
|
217
|
+
);
|
|
218
|
+
const profile = this.gateSelectionService.resolveProfile(resolvedProfile.profileName);
|
|
211
219
|
const parsedDiff = parseUnifiedDiff(unifiedDiff);
|
|
212
220
|
|
|
213
221
|
await this.validatePatchPaths(featureId, parsedDiff, plan);
|
|
@@ -248,9 +256,7 @@ export class PatchService {
|
|
|
248
256
|
.filter(Boolean);
|
|
249
257
|
|
|
250
258
|
const previousQaIndex = await readJson(this.port.qaIndexPath(featureId), null);
|
|
251
|
-
const
|
|
252
|
-
const profile = this.port.getGatesConfig().profiles[profileName];
|
|
253
|
-
const requiredTests = makeRequiredTests(profileName, profile);
|
|
259
|
+
const requiredTests = makeRequiredTests(resolvedProfile.profileName, profile);
|
|
254
260
|
const qaIndex = buildQaIndex(featureId, parsedDiff, requiredTests, previousQaIndex);
|
|
255
261
|
|
|
256
262
|
const qaValidation = await this.port.validateSchema('qa_test_index.schema.json', qaIndex);
|
|
@@ -270,6 +276,7 @@ export class PatchService {
|
|
|
270
276
|
Promise.resolve({
|
|
271
277
|
frontMatter: {
|
|
272
278
|
status: frontMatter.status,
|
|
279
|
+
gate_profile: resolvedProfile.profileName,
|
|
273
280
|
gates: frontMatter.gates,
|
|
274
281
|
},
|
|
275
282
|
body,
|
|
@@ -4,6 +4,7 @@ import { sortResourcesDeterministically } from '../../core/path-rules.js';
|
|
|
4
4
|
import { ERROR_CODES } from '../../core/error-codes.js';
|
|
5
5
|
import { fail } from '../../core/response.js';
|
|
6
6
|
import { GATE_RESULT, STATUS } from '../../core/constants.js';
|
|
7
|
+
import { GateSelectionService } from './gate-selection-service.js';
|
|
7
8
|
import {
|
|
8
9
|
getUnresolvedDeps,
|
|
9
10
|
detectCircularDependency,
|
|
@@ -42,6 +43,7 @@ function readHeldLocks(frontMatter: AnyRecord): string[] {
|
|
|
42
43
|
|
|
43
44
|
export interface PlanServicePort {
|
|
44
45
|
getPolicySnapshot(): AnyRecord;
|
|
46
|
+
getGatesConfig(): AnyRecord;
|
|
45
47
|
planPath(featureId: string): string;
|
|
46
48
|
featureDiscoverSpecs(): Promise<{ data: { specs: Array<{ feature_id: string }> } }>;
|
|
47
49
|
readState(featureId: string): Promise<StateReadResult>;
|
|
@@ -59,9 +61,11 @@ export interface PlanServicePort {
|
|
|
59
61
|
|
|
60
62
|
export class PlanService {
|
|
61
63
|
private readonly port: PlanServicePort;
|
|
64
|
+
private readonly gateSelectionService: GateSelectionService;
|
|
62
65
|
|
|
63
66
|
constructor(port: PlanServicePort) {
|
|
64
67
|
this.port = port;
|
|
68
|
+
this.gateSelectionService = new GateSelectionService(port);
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
async planGet(featureId: string): Promise<{ data: { feature_id: string; plan: unknown } }> {
|
|
@@ -355,19 +359,32 @@ export class PlanService {
|
|
|
355
359
|
};
|
|
356
360
|
}
|
|
357
361
|
|
|
358
|
-
await this.
|
|
359
|
-
|
|
362
|
+
const state = await this.port.readState(featureId);
|
|
363
|
+
const resolvedProfile = this.gateSelectionService.resolveProfileFromState(
|
|
364
|
+
state.frontMatter.gate_profile,
|
|
365
|
+
);
|
|
366
|
+
const canonicalizedPlan = this.gateSelectionService.canonicalizePlannerPlanInput(
|
|
367
|
+
plan,
|
|
368
|
+
resolvedProfile.profileName,
|
|
369
|
+
);
|
|
370
|
+
const planVersion =
|
|
371
|
+
typeof canonicalizedPlan.plan.plan_version === 'number'
|
|
372
|
+
? canonicalizedPlan.plan.plan_version
|
|
373
|
+
: 0;
|
|
374
|
+
|
|
375
|
+
await this.validatePlanSchema(canonicalizedPlan.plan);
|
|
376
|
+
await this.assertPlanLocksHeld(featureId, canonicalizedPlan.plan);
|
|
360
377
|
|
|
361
378
|
// N4: Dependency-aware scheduling — check depends_on before accepting plan
|
|
362
379
|
await this.checkDependencies(featureId);
|
|
363
380
|
|
|
364
381
|
const existing = await readJson(this.port.planPath(featureId), null);
|
|
365
|
-
this.validatePlanRevisionRules(existing, plan, expectedVersion);
|
|
382
|
+
this.validatePlanRevisionRules(existing, canonicalizedPlan.plan, expectedVersion);
|
|
366
383
|
|
|
367
|
-
await this.checkPlanCollision(featureId, plan);
|
|
384
|
+
await this.checkPlanCollision(featureId, canonicalizedPlan.plan);
|
|
368
385
|
|
|
369
386
|
await this.port.withFeatureLock(featureId, async () => {
|
|
370
|
-
await atomicWriteJson(this.port.planPath(featureId), plan);
|
|
387
|
+
await atomicWriteJson(this.port.planPath(featureId), canonicalizedPlan.plan);
|
|
371
388
|
});
|
|
372
389
|
|
|
373
390
|
await this.port.updateState(featureId, null, (frontMatter, body) => {
|
|
@@ -377,7 +394,7 @@ export class PlanService {
|
|
|
377
394
|
: frontMatter.status;
|
|
378
395
|
return Promise.resolve({
|
|
379
396
|
frontMatter: {
|
|
380
|
-
gate_profile:
|
|
397
|
+
gate_profile: resolvedProfile.profileName,
|
|
381
398
|
gates: {
|
|
382
399
|
...frontMatter.gates,
|
|
383
400
|
plan: GATE_RESULT.PASS,
|
|
@@ -397,7 +414,7 @@ export class PlanService {
|
|
|
397
414
|
data: {
|
|
398
415
|
feature_id: featureId,
|
|
399
416
|
accepted: true,
|
|
400
|
-
plan_version:
|
|
417
|
+
plan_version: planVersion,
|
|
401
418
|
},
|
|
402
419
|
};
|
|
403
420
|
}
|
|
@@ -80,6 +80,7 @@ export class CleanupCommandHandler {
|
|
|
80
80
|
async execute(options: CliOptions): Promise<unknown> {
|
|
81
81
|
const dryRun = options.dry_run ?? false;
|
|
82
82
|
const yes = options.yes ?? false;
|
|
83
|
+
const force = options.force ?? false;
|
|
83
84
|
|
|
84
85
|
const indexPath = path.join(this.repoRoot, '.aop', 'features', 'index.json');
|
|
85
86
|
let indexData: FeatureIndex = {};
|
|
@@ -123,6 +124,13 @@ export class CleanupCommandHandler {
|
|
|
123
124
|
continue;
|
|
124
125
|
}
|
|
125
126
|
}
|
|
127
|
+
if (force && runLeaseInactive) {
|
|
128
|
+
eligible.push({
|
|
129
|
+
feature_id: featureId,
|
|
130
|
+
reason: `forced: expired lease, status: ${fm.status ?? 'unknown'}`,
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
126
134
|
skipped.push({ feature_id: featureId, reason: 'not eligible' });
|
|
127
135
|
} catch {
|
|
128
136
|
if (
|
|
@@ -89,6 +89,11 @@ const COMMAND_HELP: Record<CliCommand, CommandHelp> = {
|
|
|
89
89
|
},
|
|
90
90
|
{ flag: '--dry-run', description: 'Preview what would be cleaned without making changes' },
|
|
91
91
|
{ flag: '--yes', description: 'Skip confirmation prompts' },
|
|
92
|
+
{
|
|
93
|
+
flag: '--force',
|
|
94
|
+
description:
|
|
95
|
+
'Force cleanup of features with expired leases regardless of status (e.g. after a timeout)',
|
|
96
|
+
},
|
|
92
97
|
],
|
|
93
98
|
},
|
|
94
99
|
[CliCommand.Init]: {
|