agentic-orchestrator 0.1.23 → 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.
Files changed (57) hide show
  1. package/README.md +9 -9
  2. package/agentic/orchestrator/defaults/policy.defaults.yaml +4 -0
  3. package/agentic/orchestrator/policy.yaml +4 -0
  4. package/agentic/orchestrator/prompts/planner.system.md +2 -2
  5. package/agentic/orchestrator/schemas/gates.schema.json +1 -1
  6. package/agentic/orchestrator/schemas/plan.schema.json +3 -4
  7. package/agentic/orchestrator/schemas/policy.schema.json +20 -0
  8. package/agentic/orchestrator/schemas/state.schema.json +1 -1
  9. package/apps/control-plane/src/application/services/gate-selection-service.ts +267 -0
  10. package/apps/control-plane/src/application/services/gate-service.ts +11 -17
  11. package/apps/control-plane/src/application/services/patch-service.ts +10 -3
  12. package/apps/control-plane/src/application/services/plan-service.ts +24 -7
  13. package/apps/control-plane/src/cli/cleanup-command-handler.ts +8 -0
  14. package/apps/control-plane/src/cli/help-command-handler.ts +5 -0
  15. package/apps/control-plane/src/core/kernel.ts +57 -1
  16. package/apps/control-plane/src/supervisor/runtime.ts +0 -1
  17. package/apps/control-plane/src/supervisor/worker-decision-loop.ts +0 -1
  18. package/apps/control-plane/test/cleanup-command.spec.ts +97 -0
  19. package/apps/control-plane/test/gate-selection-service.spec.ts +125 -0
  20. package/apps/control-plane/test/kernel.spec.ts +40 -0
  21. package/apps/control-plane/test/mcp.spec.ts +16 -0
  22. package/apps/control-plane/test/patch-service.spec.ts +61 -2
  23. package/apps/control-plane/test/plan-service.spec.ts +42 -0
  24. package/apps/control-plane/test/supervisor.unit.spec.ts +0 -1
  25. package/apps/control-plane/test/worker-decision-loop.spec.ts +6 -0
  26. package/config/agentic/orchestrator/agents.yaml +1 -1
  27. package/config/agentic/orchestrator/prompts/planner.system.md +2 -2
  28. package/dist/apps/control-plane/application/services/gate-selection-service.d.ts +35 -0
  29. package/dist/apps/control-plane/application/services/gate-selection-service.js +205 -0
  30. package/dist/apps/control-plane/application/services/gate-selection-service.js.map +1 -0
  31. package/dist/apps/control-plane/application/services/gate-service.d.ts +1 -0
  32. package/dist/apps/control-plane/application/services/gate-service.js +8 -13
  33. package/dist/apps/control-plane/application/services/gate-service.js.map +1 -1
  34. package/dist/apps/control-plane/application/services/patch-service.d.ts +1 -0
  35. package/dist/apps/control-plane/application/services/patch-service.js +8 -3
  36. package/dist/apps/control-plane/application/services/patch-service.js.map +1 -1
  37. package/dist/apps/control-plane/application/services/plan-service.d.ts +2 -0
  38. package/dist/apps/control-plane/application/services/plan-service.js +16 -7
  39. package/dist/apps/control-plane/application/services/plan-service.js.map +1 -1
  40. package/dist/apps/control-plane/cli/cleanup-command-handler.js +8 -0
  41. package/dist/apps/control-plane/cli/cleanup-command-handler.js.map +1 -1
  42. package/dist/apps/control-plane/cli/help-command-handler.js +4 -0
  43. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  44. package/dist/apps/control-plane/core/kernel.d.ts +3 -0
  45. package/dist/apps/control-plane/core/kernel.js +44 -1
  46. package/dist/apps/control-plane/core/kernel.js.map +1 -1
  47. package/dist/apps/control-plane/supervisor/runtime.js +0 -1
  48. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  49. package/dist/apps/control-plane/supervisor/worker-decision-loop.js +0 -1
  50. package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
  51. package/package.json +1 -1
  52. package/spec-files/outstanding/agentic_orchestrator_cli_shell_tab_completion_spec.md +382 -0
  53. package/spec-files/outstanding/agentic_orchestrator_deterministic_gate_selection_spec.md +417 -0
  54. package/spec-files/outstanding/agentic_orchestrator_persistent_worker_runtime_execution_checklist.md +308 -0
  55. package/spec-files/outstanding/agentic_orchestrator_persistent_worker_runtime_spec.md +596 -0
  56. package/spec-files/outstanding/agentic_orchestrator_worker_runtime_watchdog_resilience_spec.md +1074 -179
  57. 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`, `gate_profile` |
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
- "gate_profile": "fast"
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. A feature's active profile is recorded in plan.gate_profile and referenced by the gate service when running checks.",
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": "Name of the gates profile to apply to this feature. Written to state.gate_profile on plan acceptance."
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 explicit list of gate mode names to run. When omitted the profile's full mode set is used."
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": "Name of the gates profile to use for this feature. Written from plan.gate_profile on plan submission."
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
- const profile = this.port.getGatesConfig().profiles[profileName];
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 effectiveProfileName = profileName ?? state.frontMatter.gate_profile ?? 'default';
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 profileName = plan?.gate_profile ?? 'default';
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.validatePlanSchema(plan);
359
- await this.assertPlanLocksHeld(featureId, plan);
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: plan.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: plan.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]: {