@fitlab-ai/agent-infra 0.11.12 → 0.11.13
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/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
- package/dist/lib/internal/sandbox-control.js +1 -81
- package/dist/lib/internal/task-finalization.js +26 -19
- package/dist/lib/internal/task-lifecycle.js +1 -1
- package/dist/lib/platform/github-client.js +33 -3
- package/dist/lib/sandbox/commands/create.js +6 -2
- package/dist/lib/sandbox/commands/ls.js +4 -5
- package/dist/lib/sandbox/commands/rm.js +1 -1
- package/dist/lib/sandbox/control/client.js +4 -64
- package/dist/lib/sandbox/control/executor.js +4 -50
- package/dist/lib/sandbox/control/lifecycle.js +66 -4
- package/dist/lib/sandbox/control/protocol.js +5 -16
- package/dist/lib/sandbox/control/server.js +203 -234
- package/dist/lib/sandbox/control/task-view.js +0 -13
- package/dist/lib/sandbox/mounts.js +7 -0
- package/dist/lib/sandbox/recovery.js +28 -9
- package/dist/lib/sandbox/removal.js +19 -0
- package/dist/lib/sandbox/workspace-identity.js +18 -6
- package/dist/lib/sandbox/workspace-view.js +48 -2
- package/dist/lib/task/commands/ls.js +3 -3
- package/dist/lib/task/control-authority.js +2 -0
- package/dist/lib/task/control-recovery.js +1 -2
- package/dist/lib/task/finalization.js +14 -0
- package/dist/lib/task/lifecycle.js +203 -31
- package/dist/lib/task/orchestration.js +26 -72
- package/dist/lib/task/short-id.js +37 -27
- package/dist/lib/task/verification-engine.js +4 -11
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +20 -23
- package/lib/internal/sandbox-control.ts +0 -97
- package/lib/internal/task-finalization.ts +30 -18
- package/lib/internal/task-lifecycle.ts +1 -1
- package/lib/platform/github-client.ts +34 -3
- package/lib/sandbox/commands/create.ts +6 -1
- package/lib/sandbox/commands/ls.ts +4 -5
- package/lib/sandbox/commands/rm.ts +1 -1
- package/lib/sandbox/control/client.ts +5 -78
- package/lib/sandbox/control/executor.ts +2 -53
- package/lib/sandbox/control/lifecycle.ts +67 -4
- package/lib/sandbox/control/protocol.ts +6 -20
- package/lib/sandbox/control/server.ts +9 -44
- package/lib/sandbox/control/task-view.ts +0 -19
- package/lib/sandbox/mounts.ts +8 -0
- package/lib/sandbox/recovery.ts +35 -16
- package/lib/sandbox/removal.ts +21 -2
- package/lib/sandbox/workspace-identity.ts +16 -6
- package/lib/sandbox/workspace-view.ts +41 -2
- package/lib/task/commands/ls.ts +3 -3
- package/lib/task/control-authority.ts +1 -0
- package/lib/task/control-recovery.ts +2 -3
- package/lib/task/finalization.ts +14 -0
- package/lib/task/lifecycle.ts +180 -27
- package/lib/task/orchestration.ts +30 -68
- package/lib/task/short-id.ts +33 -40
- package/lib/task/verification-engine.ts +3 -9
- package/package.json +1 -1
- package/templates/.agents/rules/README.en.md +1 -0
- package/templates/.agents/rules/README.zh-CN.md +1 -0
- package/templates/.agents/rules/strategy-pattern.en.md +25 -0
- package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
- package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
- package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
- package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
|
@@ -8,7 +8,7 @@ import { validateCurrentTaskContract } from './current-contract.ts';
|
|
|
8
8
|
import { parseReviewSummary } from './review-artifacts.ts';
|
|
9
9
|
import { LEDGER_SECTION_MISSING_CODE, LEDGER_SECTION_MISSING_MESSAGE, parseLedgerDocument, summarizeLedgerStage, validateLedgerRows } from './ledger.ts';
|
|
10
10
|
import type { LedgerDocument } from './ledger.ts';
|
|
11
|
-
import { resolveTaskRef } from './resolve-ref.ts';
|
|
11
|
+
import { resolveTaskRef, TASK_ID_RE } from './resolve-ref.ts';
|
|
12
12
|
import {
|
|
13
13
|
activateDelegation,
|
|
14
14
|
abortActivatedDelegation,
|
|
@@ -75,7 +75,6 @@ type ClientCapabilityRecovery = Readonly<{
|
|
|
75
75
|
baselineEmpty: true;
|
|
76
76
|
receiptCount: 0;
|
|
77
77
|
pendingDelegation: false;
|
|
78
|
-
commitAuthorizationUnused: true;
|
|
79
78
|
completionEvidenceAbsent: true;
|
|
80
79
|
}>;
|
|
81
80
|
resultingStatus: 'running';
|
|
@@ -105,7 +104,6 @@ type OrchestrationRun = Readonly<{
|
|
|
105
104
|
pendingDelegation: DelegationReceipt | null;
|
|
106
105
|
receipts: readonly DelegationReceipt[];
|
|
107
106
|
pause: Readonly<{ code: string; message: string; recoverable: boolean }> | null;
|
|
108
|
-
commitAuthorization: Readonly<{ issuedAt: string | null; consumedAt: string | null }>;
|
|
109
107
|
completionEvidence: CleanCompletionEvidence | null;
|
|
110
108
|
createdAt: string;
|
|
111
109
|
updatedAt: string;
|
|
@@ -249,7 +247,7 @@ function isModelPolicy(value: unknown): value is OrchestrationModelPolicy {
|
|
|
249
247
|
|
|
250
248
|
const RECOVERY_GUARD_KEYS = [
|
|
251
249
|
'stepCount', 'nextStage', 'baselineEmpty', 'receiptCount', 'pendingDelegation',
|
|
252
|
-
'
|
|
250
|
+
'completionEvidenceAbsent'
|
|
253
251
|
] as const;
|
|
254
252
|
|
|
255
253
|
function isRecovery(value: unknown): value is OrchestrationRecovery {
|
|
@@ -269,7 +267,6 @@ function isRecovery(value: unknown): value is OrchestrationRecovery {
|
|
|
269
267
|
&& guards.baselineEmpty === true
|
|
270
268
|
&& guards.receiptCount === 0
|
|
271
269
|
&& guards.pendingDelegation === false
|
|
272
|
-
&& guards.commitAuthorizationUnused === true
|
|
273
270
|
&& guards.completionEvidenceAbsent === true
|
|
274
271
|
&& value.resultingStatus === 'running';
|
|
275
272
|
}
|
|
@@ -291,7 +288,7 @@ export function isCompletionEvidence(value: unknown): value is CleanCompletionEv
|
|
|
291
288
|
const ORCHESTRATION_RUN_KEYS = [
|
|
292
289
|
'taskId', 'runId', 'status', 'nextStage', 'stepCount', 'maxSteps', 'modelPolicy',
|
|
293
290
|
'modelPolicySource', 'recoveryHistory', 'baseline', 'pendingDelegation', 'receipts',
|
|
294
|
-
'pause', '
|
|
291
|
+
'pause', 'completionEvidence', 'createdAt', 'updatedAt'
|
|
295
292
|
] as const;
|
|
296
293
|
|
|
297
294
|
function orchestrationStateReasonCodes(value: unknown): string[] {
|
|
@@ -313,12 +310,6 @@ function orchestrationStateReasonCodes(value: unknown): string[] {
|
|
|
313
310
|
if (!(record.pendingDelegation === null || isDelegationReceipt(record.pendingDelegation))) reasons.push('pendingDelegation');
|
|
314
311
|
if (!Array.isArray(record.receipts) || !record.receipts.every(isDelegationReceipt)) reasons.push('receipts');
|
|
315
312
|
if (!(record.pause === null || isPause(record.pause))) reasons.push('pause');
|
|
316
|
-
if (!hasExactKeys(record.commitAuthorization, ['issuedAt', 'consumedAt'])) {
|
|
317
|
-
reasons.push('commitAuthorization');
|
|
318
|
-
} else {
|
|
319
|
-
if (!nullableText(record.commitAuthorization.issuedAt)) reasons.push('commitAuthorization.issuedAt');
|
|
320
|
-
if (!nullableText(record.commitAuthorization.consumedAt)) reasons.push('commitAuthorization.consumedAt');
|
|
321
|
-
}
|
|
322
313
|
if (!(record.completionEvidence === null || isCompletionEvidence(record.completionEvidence))) reasons.push('completionEvidence');
|
|
323
314
|
if (!exactText(record.createdAt)) reasons.push('createdAt');
|
|
324
315
|
if (!exactText(record.updatedAt)) reasons.push('updatedAt');
|
|
@@ -584,8 +575,6 @@ function canRecoverUnsupportedClientPause(run: OrchestrationRun): boolean {
|
|
|
584
575
|
&& run.baseline === ''
|
|
585
576
|
&& run.pendingDelegation === null
|
|
586
577
|
&& run.receipts.length === 0
|
|
587
|
-
&& run.commitAuthorization?.issuedAt === null
|
|
588
|
-
&& run.commitAuthorization?.consumedAt === null
|
|
589
578
|
&& run.completionEvidence == null
|
|
590
579
|
&& run.recoveryHistory.length === 0;
|
|
591
580
|
}
|
|
@@ -633,7 +622,6 @@ function beginOrResumeOrchestration(taskRef: string, options: OrchestrationOptio
|
|
|
633
622
|
baselineEmpty: true,
|
|
634
623
|
receiptCount: 0,
|
|
635
624
|
pendingDelegation: false,
|
|
636
|
-
commitAuthorizationUnused: true,
|
|
637
625
|
completionEvidenceAbsent: true
|
|
638
626
|
},
|
|
639
627
|
resultingStatus: 'running'
|
|
@@ -686,7 +674,6 @@ function beginOrResumeOrchestration(taskRef: string, options: OrchestrationOptio
|
|
|
686
674
|
pendingDelegation: null,
|
|
687
675
|
receipts: [],
|
|
688
676
|
pause: null,
|
|
689
|
-
commitAuthorization: { issuedAt: null, consumedAt: null },
|
|
690
677
|
completionEvidence: null,
|
|
691
678
|
createdAt: now,
|
|
692
679
|
updatedAt: now
|
|
@@ -1087,56 +1074,28 @@ function dispatchOrchestrationDelegation(
|
|
|
1087
1074
|
}
|
|
1088
1075
|
}
|
|
1089
1076
|
|
|
1077
|
+
type MatchingDelegationsResult =
|
|
1078
|
+
| Readonly<{ ok: true; matches: readonly { taskId: string; run: OrchestrationRun }[] }>
|
|
1079
|
+
| Readonly<{ ok: false; error: OrchestrationResult }>;
|
|
1080
|
+
|
|
1090
1081
|
function matchingDelegations(
|
|
1091
1082
|
predicate: (receipt: DelegationReceipt) => boolean,
|
|
1092
1083
|
options: OrchestrationOptions
|
|
1093
|
-
):
|
|
1094
|
-
const
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
1097
|
-
const run = readRun(taskDir, options);
|
|
1098
|
-
return run && run.status === 'running' && run.pendingDelegation && predicate(run.pendingDelegation)
|
|
1099
|
-
? [{ taskId: options.taskId, run }]
|
|
1100
|
-
: [];
|
|
1084
|
+
): MatchingDelegationsResult {
|
|
1085
|
+
const taskId: unknown = options.taskId;
|
|
1086
|
+
if (taskId === undefined || taskId === '') {
|
|
1087
|
+
return { ok: false, error: failed('ORCHESTRATION_TASK_ID_REQUIRED', 'taskId is required for lifecycle matching') };
|
|
1101
1088
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
emitDiagnostic(options, 'orchestration-active-scan-candidate', {
|
|
1113
|
-
candidateTaskId,
|
|
1114
|
-
candidateTaskDir,
|
|
1115
|
-
orchestrationPath: orchestrationPath(candidateTaskDir)
|
|
1116
|
-
});
|
|
1117
|
-
let run: OrchestrationRun | null;
|
|
1118
|
-
try {
|
|
1119
|
-
run = readRun(candidateTaskDir, options);
|
|
1120
|
-
} catch (error) {
|
|
1121
|
-
emitDiagnostic(options, 'orchestration-active-scan-candidate-failed', {
|
|
1122
|
-
candidateTaskId,
|
|
1123
|
-
candidateTaskDir,
|
|
1124
|
-
errorType: error instanceof Error ? error.name : typeof error,
|
|
1125
|
-
reasonCodes: error instanceof OrchestrationStateError ? error.reasonCodes.join(',') : null
|
|
1126
|
-
});
|
|
1127
|
-
throw error;
|
|
1128
|
-
}
|
|
1129
|
-
if (!run || run.status !== 'running' || !run.pendingDelegation) return [];
|
|
1130
|
-
const receipt = run.pendingDelegation;
|
|
1131
|
-
return predicate(receipt)
|
|
1132
|
-
? [{ taskId: candidateTaskId, run }]
|
|
1133
|
-
: [];
|
|
1134
|
-
});
|
|
1135
|
-
emitDiagnostic(options, 'orchestration-active-scan-finished', {
|
|
1136
|
-
activeRoot,
|
|
1137
|
-
matchingDelegations: matches.length
|
|
1138
|
-
});
|
|
1139
|
-
return matches;
|
|
1089
|
+
if (typeof taskId !== 'string' || !TASK_ID_RE.test(taskId)) {
|
|
1090
|
+
return { ok: false, error: failed('ORCHESTRATION_TASK_ID_INVALID', 'taskId must be a canonical TASK-id') };
|
|
1091
|
+
}
|
|
1092
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
1093
|
+
const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
|
|
1094
|
+
const run = readRun(taskDir, options);
|
|
1095
|
+
const matches = run && run.status === 'running' && run.pendingDelegation && predicate(run.pendingDelegation)
|
|
1096
|
+
? [{ taskId, run }]
|
|
1097
|
+
: [];
|
|
1098
|
+
return { ok: true, matches };
|
|
1140
1099
|
}
|
|
1141
1100
|
|
|
1142
1101
|
function uniqueMatchingDelegation(
|
|
@@ -1144,12 +1103,13 @@ function uniqueMatchingDelegation(
|
|
|
1144
1103
|
predicate: (receipt: DelegationReceipt) => boolean,
|
|
1145
1104
|
options: OrchestrationOptions
|
|
1146
1105
|
): { taskId: string; run: OrchestrationRun } | OrchestrationResult {
|
|
1147
|
-
const
|
|
1106
|
+
const lookup = matchingDelegations(
|
|
1148
1107
|
(receipt) => receipt.client === client && predicate(receipt),
|
|
1149
1108
|
options
|
|
1150
1109
|
);
|
|
1110
|
+
if (!lookup.ok) return lookup.error;
|
|
1111
|
+
const matches = lookup.matches;
|
|
1151
1112
|
if (matches.length === 0) return failed('ORCHESTRATION_DELEGATION_MISSING', 'no matching lifecycle delegation exists');
|
|
1152
|
-
if (matches.length > 1) return failed('ORCHESTRATION_DELEGATION_AMBIGUOUS', 'multiple lifecycle delegations match the native hook event');
|
|
1153
1113
|
return matches[0]!;
|
|
1154
1114
|
}
|
|
1155
1115
|
|
|
@@ -1158,13 +1118,14 @@ function hasActivatableOrchestrationDelegation(
|
|
|
1158
1118
|
childId: string,
|
|
1159
1119
|
options: OrchestrationOptions = {}
|
|
1160
1120
|
): boolean {
|
|
1161
|
-
|
|
1121
|
+
const lookup = matchingDelegations(
|
|
1162
1122
|
(receipt) => receipt.client === client && (
|
|
1163
1123
|
receipt.status === 'prepared'
|
|
1164
1124
|
|| (receipt.childId === childId && ['activated', 'stage-completed'].includes(receipt.status))
|
|
1165
1125
|
),
|
|
1166
1126
|
options
|
|
1167
|
-
)
|
|
1127
|
+
);
|
|
1128
|
+
return lookup.ok && lookup.matches.length > 0;
|
|
1168
1129
|
}
|
|
1169
1130
|
|
|
1170
1131
|
function hasSealableOrchestrationDelegation(
|
|
@@ -1172,12 +1133,13 @@ function hasSealableOrchestrationDelegation(
|
|
|
1172
1133
|
childId: string,
|
|
1173
1134
|
options: OrchestrationOptions = {}
|
|
1174
1135
|
): boolean {
|
|
1175
|
-
|
|
1136
|
+
const lookup = matchingDelegations(
|
|
1176
1137
|
(receipt) => receipt.client === client
|
|
1177
1138
|
&& receipt.childId === childId
|
|
1178
1139
|
&& ['stage-completed', 'sealed'].includes(receipt.status),
|
|
1179
1140
|
options
|
|
1180
|
-
)
|
|
1141
|
+
);
|
|
1142
|
+
return lookup.ok && lookup.matches.length > 0;
|
|
1181
1143
|
}
|
|
1182
1144
|
|
|
1183
1145
|
function activateMatchingOrchestrationDelegation(
|
package/lib/task/short-id.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { parseTypedTaskFrontmatter } from './frontmatter.ts';
|
|
3
4
|
|
|
4
5
|
const REGISTRY_NAME = '.short-ids.json';
|
|
5
6
|
const LOCK_NAME = '.short-ids.json.lock';
|
|
@@ -159,43 +160,35 @@ function resolveShortIdReadOnly(
|
|
|
159
160
|
const taskId = ids[key];
|
|
160
161
|
if (!taskId) {
|
|
161
162
|
const message = Object.keys(ids).length === 0
|
|
162
|
-
? `short id '${key}' not found;
|
|
163
|
-
: `short id '${key}' not found in
|
|
163
|
+
? `short id '${key}' not found; task short-id registry is empty.`
|
|
164
|
+
: `short id '${key}' not found in task short-id registry.`;
|
|
164
165
|
return shortIdFailure('SHORT_ID_NOT_FOUND', message);
|
|
165
166
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
'
|
|
171
|
-
taskId
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
if (!fs.existsSync(taskMdPath)) {
|
|
175
|
-
const remainingActiveEntries = Object.values(ids).filter((candidateTaskId) =>
|
|
176
|
-
fs.existsSync(
|
|
177
|
-
path.join(
|
|
178
|
-
repoRoot,
|
|
179
|
-
'.agents',
|
|
180
|
-
'workspace',
|
|
181
|
-
'active',
|
|
182
|
-
candidateTaskId,
|
|
183
|
-
'task.md'
|
|
184
|
-
)
|
|
185
|
-
)
|
|
186
|
-
);
|
|
187
|
-
const message = remainingActiveEntries.length === 0
|
|
188
|
-
? `short id '${key}' not found; active task registry is empty.`
|
|
189
|
-
: `short id '${key}' not found in active task registry (it may have been cleaned up after archival; check 'task-short-id.js list').`;
|
|
167
|
+
const activeTask = path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md');
|
|
168
|
+
const completedTask = path.join(repoRoot, '.agents', 'workspace', 'completed', taskId, 'task.md');
|
|
169
|
+
let isValidCompletedTask = false;
|
|
170
|
+
try {
|
|
171
|
+
const task = parseTypedTaskFrontmatter(fs.readFileSync(completedTask, 'utf8'));
|
|
172
|
+
isValidCompletedTask = task.id === taskId && task.status === 'completed';
|
|
173
|
+
} catch { /* a mapping is valid only while its task content is present */ }
|
|
174
|
+
if (!fs.existsSync(activeTask) && !isValidCompletedTask) {
|
|
190
175
|
return shortIdFailure(
|
|
191
176
|
'SHORT_ID_STALE',
|
|
192
|
-
|
|
177
|
+
`short id '${key}' points to no active or completed task content.`,
|
|
193
178
|
taskId
|
|
194
179
|
);
|
|
195
180
|
}
|
|
196
181
|
return { ok: true, taskId };
|
|
197
182
|
}
|
|
198
183
|
|
|
184
|
+
function hasRetainedCompletedTask(activeDir: string, taskId: string): boolean {
|
|
185
|
+
try {
|
|
186
|
+
const taskPath = path.join(path.dirname(activeDir), 'completed', taskId, 'task.md');
|
|
187
|
+
const task = parseTypedTaskFrontmatter(fs.readFileSync(taskPath, 'utf8'));
|
|
188
|
+
return task.id === taskId && task.status === 'completed';
|
|
189
|
+
} catch { return false; }
|
|
190
|
+
}
|
|
191
|
+
|
|
199
192
|
type RegistrySchema = {
|
|
200
193
|
version: number;
|
|
201
194
|
ids: Record<string, string>;
|
|
@@ -339,7 +332,8 @@ function mutateShortIdRegistryAt(
|
|
|
339
332
|
const original = JSON.stringify(registry.ids);
|
|
340
333
|
if (pruneOrphans) {
|
|
341
334
|
for (const [key, candidate] of Object.entries(registry.ids)) {
|
|
342
|
-
if (candidate !== taskId && !fs.existsSync(path.join(activeDir, candidate, 'task.md'))
|
|
335
|
+
if (candidate !== taskId && !fs.existsSync(path.join(activeDir, candidate, 'task.md'))
|
|
336
|
+
&& !hasRetainedCompletedTask(activeDir, candidate)) delete registry.ids[key];
|
|
343
337
|
}
|
|
344
338
|
}
|
|
345
339
|
const existing = Object.entries(registry.ids).find(([, candidate]) => candidate === taskId);
|
|
@@ -406,28 +400,27 @@ function executeShortIdCommand(request: ShortIdCommandRequest): ShortIdCommandRe
|
|
|
406
400
|
if (normalized.kind !== 'shortId') {
|
|
407
401
|
throw Object.assign(new Error(`invalid short id format '${request.argument}'`), { code: 'SHORT_ID_FORMAT_INVALID' });
|
|
408
402
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (!taskId) throw Object.assign(new Error(`short id '${normalized.value}' not found in active task registry`), { code: 'SHORT_ID_NOT_FOUND' });
|
|
418
|
-
return { status: changed ? 'applied' : 'no-op', changed, output: taskId, error: null };
|
|
419
|
-
});
|
|
403
|
+
const registry = readRegistryStrict(registryPath, request.shortIdLength);
|
|
404
|
+
const taskId = registry.ids[normalized.value];
|
|
405
|
+
if (!taskId) throw Object.assign(new Error(`short id '${normalized.value}' not found in task short-id registry`), { code: 'SHORT_ID_NOT_FOUND' });
|
|
406
|
+
if (!fs.existsSync(path.join(request.activeDir, taskId, 'task.md'))
|
|
407
|
+
&& !hasRetainedCompletedTask(request.activeDir, taskId)) {
|
|
408
|
+
throw Object.assign(new Error(`short id '${normalized.value}' points to a task without active or completed task content`), { code: 'SHORT_ID_STALE' });
|
|
409
|
+
}
|
|
410
|
+
return { status: 'no-op', changed: false, output: taskId, error: null };
|
|
420
411
|
}
|
|
421
412
|
const registry = readRegistryStrict(registryPath, request.shortIdLength);
|
|
422
413
|
if (request.verify) {
|
|
423
414
|
const active = fs.existsSync(request.activeDir)
|
|
424
415
|
? fs.readdirSync(request.activeDir).filter((entry) => /^TASK-\d{8}-\d{6}$/.test(entry) && fs.existsSync(path.join(request.activeDir, entry, 'task.md')))
|
|
425
416
|
: [];
|
|
417
|
+
const retained = Object.values(registry.ids).filter((taskId) => hasRetainedCompletedTask(request.activeDir, taskId));
|
|
418
|
+
const validTasks = new Set([...active, ...retained]);
|
|
426
419
|
const registered = new Map<string, string[]>();
|
|
427
420
|
for (const [key, taskId] of Object.entries(registry.ids)) registered.set(taskId, [...(registered.get(taskId) ?? []), key]);
|
|
428
421
|
const diff = {
|
|
429
422
|
missing_in_registry: active.filter((taskId) => !registered.has(taskId)).map((taskId) => ({ taskId })),
|
|
430
|
-
orphans_in_registry: Object.entries(registry.ids).filter(([, taskId]) => !
|
|
423
|
+
orphans_in_registry: Object.entries(registry.ids).filter(([, taskId]) => !validTasks.has(taskId)).map(([key, taskId]) => ({ key, taskId })),
|
|
431
424
|
duplicate_registry_keys: [...registered.entries()].filter(([, keys]) => keys.length > 1).map(([taskId, keys]) => ({ taskId, keys }))
|
|
432
425
|
};
|
|
433
426
|
const clean = Object.values(diff).every((items) => items.length === 0);
|
|
@@ -167,12 +167,8 @@ function checkOrchestrationState({ taskDir }: any): any {
|
|
|
167
167
|
return failResult('orchestration-state', 'Paused run requires a stable pause code and message');
|
|
168
168
|
}
|
|
169
169
|
if (run.status === 'completed') {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (evidenceError) return failResult('orchestration-state', evidenceError);
|
|
173
|
-
} else if (run.pendingDelegation !== null || !run.commitAuthorization?.consumedAt) {
|
|
174
|
-
return failResult('orchestration-state', 'Completed run must consume commit authorization and clear pending delegation');
|
|
175
|
-
}
|
|
170
|
+
const evidenceError = validateCleanCompletionEvidence(run);
|
|
171
|
+
if (evidenceError) return failResult('orchestration-state', evidenceError);
|
|
176
172
|
} else if (run.completionEvidence != null) {
|
|
177
173
|
return failResult('orchestration-state', 'Clean completion evidence requires a completed run');
|
|
178
174
|
} else if (run.pendingDelegation?.status === 'sealed') {
|
|
@@ -209,11 +205,9 @@ function validateCleanCompletionEvidence(run: OrchestrationRun): string | null {
|
|
|
209
205
|
if (
|
|
210
206
|
run.status !== 'completed'
|
|
211
207
|
|| run.pendingDelegation !== null
|
|
212
|
-
|| run.commitAuthorization?.issuedAt !== null
|
|
213
|
-
|| run.commitAuthorization?.consumedAt !== null
|
|
214
208
|
|| run.receipts.some((receipt) => receipt.stage === 'commit')
|
|
215
209
|
) {
|
|
216
|
-
return 'Clean completion evidence conflicts with
|
|
210
|
+
return 'Clean completion evidence conflicts with pending delegation or commit receipt state';
|
|
217
211
|
}
|
|
218
212
|
return null;
|
|
219
213
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitlab-ai/agent-infra",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.13",
|
|
4
4
|
"description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Antigravity CLI, and OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@ so you can quickly find "which ones to read" without opening each file.
|
|
|
17
17
|
- [`validation-output.md`](validation-output.md) — User-facing validation summaries, failure diagnostics, soft warnings, and consumer boundaries.
|
|
18
18
|
- [`sync-content-generation.md`](sync-content-generation.md) — Producer-side Markdown constraints for task and lifecycle content synchronized to Issues.
|
|
19
19
|
- [`decision-qualification.md`](decision-qualification.md) — Constraint, candidate, human-confirmation, and six-artifact qualification-audit contract.
|
|
20
|
+
- [`strategy-pattern.md`](strategy-pattern.md) — Identify shared flows and different behaviors, then use interfaces and the Strategy Pattern to isolate implementation differences.
|
|
20
21
|
|
|
21
22
|
## Issue / PR
|
|
22
23
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
- [`validation-output.md`](validation-output.md) — 面向用户的校验状态摘要、失败诊断、软警告及消费边界。
|
|
17
17
|
- [`sync-content-generation.md`](sync-content-generation.md) — 同步到 Issue 的任务和生命周期 Markdown 生成约束。
|
|
18
18
|
- [`decision-qualification.md`](decision-qualification.md) — 约束、候选、人工确认和六类 artifact 资格审计契约。
|
|
19
|
+
- [`strategy-pattern.md`](strategy-pattern.md) — 识别共用流程和不同行为,通过接口与策略模式隔离实现差异。
|
|
19
20
|
|
|
20
21
|
## Issue / PR
|
|
21
22
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Strategy Pattern
|
|
2
|
+
|
|
3
|
+
This rule applies during technical design and implementation. When behavior differs by platform, client, protocol, workflow, business rule, or algorithm, use interfaces to isolate implementations so shared flows can reuse them instead of creating a dedicated path for every scenario.
|
|
4
|
+
|
|
5
|
+
## Design Steps
|
|
6
|
+
|
|
7
|
+
1. **Separate shared flows from different implementations**: identify common steps and the behavior that differs by platform, client, protocol, workflow, business rule, algorithm, or lifecycle.
|
|
8
|
+
2. **Define the interface contract**: specify the inputs, outputs, error semantics, and invocation timing shared by implementations. The common flow depends on the interface, not a concrete implementation.
|
|
9
|
+
3. **Apply the Strategy Pattern**: provide an implementation for each behavior and select a strategy at registration or assembly. The common flow orchestrates shared steps without spreading conditions that identify concrete implementations.
|
|
10
|
+
4. **Verify substitutability**: a new implementation should connect through registration or assembly. Write shared tests for the interface contract and the necessary tests for strategy selection and full assembly.
|
|
11
|
+
|
|
12
|
+
## Implementation Requirements
|
|
13
|
+
|
|
14
|
+
- When the requirement clearly includes multiple behaviors, prefer the Strategy Pattern even if there is currently one implementation; the interface should express their stable shared contract.
|
|
15
|
+
- When adding behavior, prefer adding a strategy implementation and registering or assembling it in one place. Do not copy a full business flow or repeat the same branch across callers.
|
|
16
|
+
- If branches that identify implementations are already scattered across paths, consolidate those decisions at strategy selection and make callers depend on the interface.
|
|
17
|
+
- Reuse existing interfaces and domain terms. Do not rename code to apply a pattern or add interfaces to stable behavior that has no meaningful alternatives.
|
|
18
|
+
- Keep stable, single-purpose behavior direct when there is no evidence it will vary. Do not mechanically add interfaces, configuration, or extension points.
|
|
19
|
+
|
|
20
|
+
## Design Review Questions
|
|
21
|
+
|
|
22
|
+
- Which steps are shared by all implementations, and which behaviors differ by platform, client, or business rule?
|
|
23
|
+
- Does the interface express the contract shared by the implementations?
|
|
24
|
+
- Can a new behavior be added by implementing a strategy and assembling it in one place? If several call paths must change, is centralized strategy selection missing?
|
|
25
|
+
- Can the same interface tests validate the shared contract while strategy differences are tested independently?
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# 策略模式
|
|
2
|
+
|
|
3
|
+
本规则适用于方案设计和代码实现。当平台、客户端、协议、工作流、业务规则或算法存在多种行为时,用接口隔离这些实现,让共用流程能够复用不同实现,避免每种场景都形成一条专用链路。
|
|
4
|
+
|
|
5
|
+
## 设计步骤
|
|
6
|
+
|
|
7
|
+
1. **区分共用流程和不同实现**:找出所有实现共同执行的步骤,以及会因平台、客户端、协议、工作流、业务规则、算法或生命周期而不同的行为。
|
|
8
|
+
2. **定义接口契约**:明确不同实现的输入、输出、错误语义和调用时机。共用流程依赖接口,不直接调用某个具体实现。
|
|
9
|
+
3. **应用策略模式**:为每种行为提供独立实现,在注册或装配处选择策略。共用流程负责编排共同步骤,不散布识别具体实现的条件分支。
|
|
10
|
+
4. **验证实现可替换**:新增实现应能通过注册或装配接入;为接口约定编写共享测试,并为策略选择和整体装配编写必要测试。
|
|
11
|
+
|
|
12
|
+
## 实现要求
|
|
13
|
+
|
|
14
|
+
- 需求明确包含多种行为时,优先使用策略模式,即使当前只有一个实现;接口应表达这些实现之间稳定的共同约定。
|
|
15
|
+
- 新增行为时,优先增加一个策略实现并在统一位置注册或装配,不复制完整业务链,也不在多个调用方重复增加同类分支。
|
|
16
|
+
- 如果识别不同实现的分支已经散落在多条路径,先把这些判断收敛到策略选择处,再让调用方依赖接口。
|
|
17
|
+
- 复用现有接口和领域词汇;不要为了套用模式而改名,也不要为没有不同行为的稳定逻辑增加接口。
|
|
18
|
+
- 对稳定、单一且没有证据表明会变化的逻辑,保持直接实现,不机械添加接口、配置或扩展点。
|
|
19
|
+
|
|
20
|
+
## 设计审查问题
|
|
21
|
+
|
|
22
|
+
- 所有实现共用哪些流程?哪些行为因平台、客户端或业务规则而不同?
|
|
23
|
+
- 接口是否表达了不同实现共同遵守的约定?
|
|
24
|
+
- 新实现能否只新增策略并在一个位置装配?如果必须修改多处调用链,是否缺少统一的策略选择?
|
|
25
|
+
- 共用约定能否通过同一套接口测试验证,不同策略的差异能否独立验证?
|
|
@@ -19,6 +19,10 @@ Follow the `code` step in `.agents/workflows/feature-development.yaml`.
|
|
|
19
19
|
3. **Keep testing continuously**: run the **fast smoke subset** continuously as work progresses (see the `test` skill)
|
|
20
20
|
4. **Keep it simple**: do not add unplanned features
|
|
21
21
|
|
|
22
|
+
## Strategy Pattern
|
|
23
|
+
|
|
24
|
+
Read `.agents/rules/strategy-pattern.md` before implementation. When behavior differs by platform, client, protocol, workflow, business rule, or algorithm, follow the interface selected in the plan and make the core flow depend on that interface rather than concrete classes. Add each behavior as a strategy and select or assemble it in one place. If branches identifying implementations are scattered across paths, centralize strategy selection before editing callers. Keep stable behavior direct when it has no meaningful alternatives; do not add interfaces mechanically.
|
|
25
|
+
|
|
22
26
|
## Run Test Verification
|
|
23
27
|
|
|
24
28
|
During implementation:
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
3. **持续测试**:实现过程中持续运行 **fast smoke 子集**(参见 `test` skill)
|
|
20
20
|
4. **保持简单**:不要加入未计划的功能
|
|
21
21
|
|
|
22
|
+
## 策略模式
|
|
23
|
+
|
|
24
|
+
实现前读取 `.agents/rules/strategy-pattern.md`。当实现涉及平台、客户端、协议、工作流、业务规则或算法的不同实现时,沿用方案确定的接口,让主流程依赖接口而不是具体类;新增行为应作为策略实现,在统一位置选择或装配。若识别不同实现的分支已散落在多条路径,先收敛策略选择,再修改调用方。行为稳定且没有明确替代实现时,保留直接实现,不机械增加接口。
|
|
25
|
+
|
|
22
26
|
## 运行测试验证
|
|
23
27
|
|
|
24
28
|
实现过程中:
|
|
@@ -158,7 +158,7 @@ The core deduplicates the stable `step/code/target` tuple. Callers must not allo
|
|
|
158
158
|
|
|
159
159
|
### 5. Run the Active Pre-completion Hard Gate
|
|
160
160
|
|
|
161
|
-
After platform writes succeed and before moving the directory
|
|
161
|
+
After platform writes succeed and before moving the directory, run:
|
|
162
162
|
|
|
163
163
|
```bash
|
|
164
164
|
agent-infra-internal task-verify {task-id} complete-task.preflight --format text
|
|
@@ -182,32 +182,11 @@ For an active task, finalization first recovers pending platform operations, the
|
|
|
182
182
|
|
|
183
183
|
Finalization runs allowed artifact backfill -> lifecycle -> terminal task comment -> core verification -> warning task-comment update -> summary seal in a fixed order. Every backfill that actually runs must reach a successful terminal result before lifecycle starts; an ordinary replay with a complete receipt and no recovery fact does not rerun backfill, comments, verification, or summary. A completed recovery that may write a managed comment first persists a done summary as pending, then seals it only at the end. Summary seal always reads the durable staging record retained in the task directory; the platform entry point validates marker, owner, and digest, performs any required delete/recreate, and reads back final remote order. `result=completed` means the host safely completed the task from structured results and the receipt; if peripheral warnings or pending steps remain, return `result=completed_with_warnings`, warnings, and pending steps. Use `result=failed` or `result=blocked` only for hard or receipt/capability failures, then fix the cause and retry through the same entry point; do not claim completion or hand-repair partial state. A sandbox must not run `ls completed` or a local terminal verification against its historical mount to re-decide this result.
|
|
184
184
|
|
|
185
|
-
### 7. Handle Finalization
|
|
185
|
+
### 7. Handle the Finalization Result
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
The sandbox-local entry calls the existing finalization prepare/commit flow directly. Before lifecycle transfer, it stops this task's broker from accepting new requests and waits for accepted work to finish through its existing path. Transfer starts only after the broker owner exits. On success, use the structured result and receipt returned by this command. On failure or blocked status, preserve source data and the receipt, follow the returned error, and do not claim completion or manually rewrite terminal state.
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
### Recovering an accepted sandbox-control result
|
|
192
|
-
|
|
193
|
-
When this skill runs in a sandbox and an accepted task-finalization request has
|
|
194
|
-
no terminal result yet, the client automatically recovers under the same
|
|
195
|
-
request ID. The five-minute total budget starts with the first unknown result;
|
|
196
|
-
each recover call is limited to 30 seconds. While a published unknown response
|
|
197
|
-
is present, the client rereads it every 25 ms during that call. When a recover
|
|
198
|
-
call times out with unknown, the client continues immediately without an extra
|
|
199
|
-
wait. The client prints `SANDBOX_CONTROL_REQUEST_ID: <request-id>` on stderr.
|
|
200
|
-
|
|
201
|
-
If no terminal result arrives within five minutes, preserve the receipt and
|
|
202
|
-
request ID and treat the result as pending/unknown. Do not resubmit
|
|
203
|
-
task-finalization. Once the broker is healthy, continue querying only the
|
|
204
|
-
original request ID:
|
|
205
|
-
|
|
206
|
-
```bash
|
|
207
|
-
agent-infra-internal sandbox-control recover <request-id>
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
Do not submit a new request for an accepted task finalization. If the request ID is missing or the receipt cannot be read, keep the result pending and recover the original ID only from stderr, a handoff, or the receipt; do not look it up by task key. The broker's `processing/<request-id>/result.json` is private transport evidence, not a task receipt, and cannot by itself prove completion. The finalization receipt and host completion gate remain authoritative. If recovery with the same ID still has no terminal result, inspect broker and host executor activity. Stop and preserve the original request ID while a request is active, the broker is busy, or status is unknown. Retry with a new request ID only after confirming that the original request never started or its executor has ended; the new request performs the full prepare and commit after broker admission. Only a request rejected before acceptance may be retried directly with a new request ID according to the error's retryability.
|
|
189
|
+
Completion keeps the short-id mapping. Managed sandbox cleanup releases it only after the container is confirmed removed and cleanup succeeds. To clean up, pass the retained short id or full `{task-id}` to `ai sandbox rm`; do not release the short id separately during completion.
|
|
211
190
|
|
|
212
191
|
### 8. Inform User
|
|
213
192
|
|
|
@@ -230,7 +209,7 @@ Deliverables:
|
|
|
230
209
|
Optional: clean up this task's sandbox
|
|
231
210
|
(The task is completed; the sandbox container and per-branch config directory are not reclaimed automatically. Run this if you no longer need them:)
|
|
232
211
|
|
|
233
|
-
ai sandbox rm {task-id}
|
|
212
|
+
ai sandbox rm {task-id} # or use the task's retained short id
|
|
234
213
|
|
|
235
214
|
```
|
|
236
215
|
|
|
@@ -157,7 +157,7 @@ agent-infra-internal task-warning {task-id} add --step complete-task --severity
|
|
|
157
157
|
|
|
158
158
|
### 5. 运行 active 预完成硬门禁
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
平台写入成功后、移动目录前运行:
|
|
161
161
|
|
|
162
162
|
```bash
|
|
163
163
|
agent-infra-internal task-verify {task-id} complete-task.preflight --format text
|
|
@@ -181,29 +181,11 @@ active 任务的 finalization 先恢复账本中的待处理平台操作,并
|
|
|
181
181
|
|
|
182
182
|
finalization 按允许的 artifact backfill → lifecycle → task 评论 → core verification → warning task 评论更新 → summary seal 的固定顺序执行。每项实际运行的 backfill 必须取得成功终态,否则不得进入 lifecycle;完整 receipt 且无恢复事实的普通重入不执行 backfill、评论、验证或 summary。任何可能写入 managed comment 的 completed 恢复会先把 done summary 持久化为 pending,恢复末尾才重新 seal。summary seal 始终读取任务目录中保留的 durable staging record;平台入口负责校验 marker、owner 和 digest,必要时删除并重建摘要,再复读最终远端顺序。`result=completed` 即表示宿主已依据结构化结果和 receipt 安全完成;若还有外围 warning 或 pending step,返回 `result=completed_with_warnings`、warnings 和 pending steps。`result=failed` 或 `result=blocked` 仅用于硬失败或 receipt/capability 失败,修复原因后以同一入口重试,不得宣称完成或手工补写局部状态。沙箱不得从旧挂载执行 `ls completed` 或本地终态校验来重新裁决该结果。
|
|
183
183
|
|
|
184
|
-
### 7. 处理 finalization
|
|
184
|
+
### 7. 处理 finalization 结果
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
sandbox-local 入口直接执行已有 finalization prepare/commit。lifecycle 移动前,它先停止本任务 broker 接收新请求,并等待已接收操作按原路径完成;只有 broker owner 退出后才开始迁移 task 目录。成功时消费本次命令返回的结构化结果和 receipt。失败或阻塞时保留源数据与 receipt,按返回的错误处理,不输出完成消息,也不手工改写终态。
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
### accepted 后的 sandbox-control 结果恢复
|
|
191
|
-
|
|
192
|
-
如果本技能在沙箱内执行,且 task-finalization 请求已经 accepted 但暂时没有
|
|
193
|
-
terminal result,client 会从首次 unknown 开始自动按同一个 request ID 恢复,
|
|
194
|
-
总预算为 5 分钟,每次 recover 最多 30 秒;published unknown 会在当前调用中
|
|
195
|
-
按 25ms 间隔重读,预算用尽后立即继续 recover,不额外等待。client 会在 stderr
|
|
196
|
-
输出 `SANDBOX_CONTROL_REQUEST_ID: <request-id>`。
|
|
197
|
-
|
|
198
|
-
5 分钟后仍没有 terminal result 时,必须保留 receipt 和 request ID,并将结果视为
|
|
199
|
-
pending/unknown;不得重发 task-finalization。broker 恢复健康后,只能继续查询原
|
|
200
|
-
request ID:
|
|
201
|
-
|
|
202
|
-
```bash
|
|
203
|
-
agent-infra-internal sandbox-control recover <request-id>
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
accepted 的 task finalization 不得提交新请求。若 request ID 缺失或 receipt 无法读取,保持 pending;只从 stderr、handoff 或 receipt 中恢复原 ID,不按 task key 查询。broker 的 `processing/<request-id>/result.json` 只是私有 transport evidence,不是 task receipt,单凭它不能证明任务完成;finalization receipt 和宿主完成校验仍是权威。若同 ID 恢复仍没有 terminal result,必须检查 broker 和宿主 executor 的活动状态:活动请求、`busy` 或状态未知时停止并保留原 request ID。只有确认原请求从未启动或 executor 已结束后,才可用新的 request ID 重试;新请求会在 broker 接纳后执行完整 prepare 和 commit。仅当请求在 accepted 之前已被拒绝时,才可直接依据错误的 retryability 决定是否提交新的 request ID.
|
|
188
|
+
complete 保留短号映射。它只会在受管 sandbox 清理确认容器已删除且清理成功后释放。需要清理时可用保留短号或完整 `{task-id}` 调用 `ai sandbox rm`;不要在 complete 中单独释放短号。
|
|
207
189
|
|
|
208
190
|
### 8. 告知用户
|
|
209
191
|
|
|
@@ -226,7 +208,7 @@ accepted 的 task finalization 不得提交新请求。若 request ID 缺失或
|
|
|
226
208
|
可选:清理本任务的沙箱
|
|
227
209
|
(任务已完成,沙箱容器和 per-branch 配置目录不会自动回收。如果不再需要可执行:)
|
|
228
210
|
|
|
229
|
-
ai sandbox rm {task-id}
|
|
211
|
+
ai sandbox rm {task-id} # 或使用本任务仍保留的短号
|
|
230
212
|
|
|
231
213
|
```
|
|
232
214
|
|
|
@@ -91,6 +91,8 @@ Use it to understand:
|
|
|
91
91
|
|
|
92
92
|
### 5. Design the Technical Plan
|
|
93
93
|
|
|
94
|
+
Before designing, read `.agents/rules/strategy-pattern.md`. Identify shared flows and behavior differences by platform, client, protocol, workflow, business rule, or algorithm; make the core flow depend on interfaces and put differences in strategy implementations instead of adding a dedicated branch to every path.
|
|
95
|
+
|
|
94
96
|
Follow the `technical-design` step in `.agents/workflows/feature-development.yaml`:
|
|
95
97
|
|
|
96
98
|
**Required tasks**:
|
|
@@ -105,10 +107,11 @@ When this round introduces a new key design decision, first run `agent-infra-int
|
|
|
105
107
|
|
|
106
108
|
**Design principles**:
|
|
107
109
|
1. **Architectural soundness**: choose the structurally correct approach; diff size is not the primary criterion. Do not pile changes onto an unsound structure just to keep the diff small
|
|
108
|
-
2. **
|
|
109
|
-
3. **
|
|
110
|
-
4. **
|
|
111
|
-
5. **
|
|
110
|
+
2. **Strategy Pattern**: identify shared flows and different behaviors; make the core flow depend on interfaces, not concrete implementations, and select or assemble strategies in one place
|
|
111
|
+
3. **Simplicity**: given a sound architecture, prefer the simplest approach; do not add strategy interfaces mechanically when behavior is stable and has no meaningful alternatives
|
|
112
|
+
4. **Consistency**: follow existing code patterns and conventions
|
|
113
|
+
5. **Testability**: design for straightforward testing
|
|
114
|
+
6. **Reversibility**: prefer changes that are easy to roll back
|
|
112
115
|
|
|
113
116
|
### 6. Output Plan Document
|
|
114
117
|
|