@gajae-code/coding-agent 0.12.1 → 0.12.2
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/CHANGELOG.md +30 -0
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -3
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +1 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +9 -0
- package/dist/types/modes/theme/theme.d.ts +3 -1
- package/dist/types/modes/types.d.ts +32 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -2
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +22 -3
- package/dist/types/task/index.d.ts +4 -0
- package/dist/types/task/output-manager.d.ts +1 -0
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/package.json +8 -8
- package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-acp-conformance.ts +5 -0
- package/src/async/job-manager.ts +22 -20
- package/src/config/settings-schema.ts +2 -2
- package/src/debug/index.ts +42 -17
- package/src/defaults/gjc/skills/ralplan/SKILL.md +9 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
- package/src/gjc-runtime/launch-worktree.ts +103 -1
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
- package/src/gjc-runtime/ralplan-runtime.ts +80 -10
- package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
- package/src/gjc-runtime/ultragoal-runtime.ts +236 -3
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +17 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/DESIGN.md +53 -0
- package/src/modes/components/irc-sidebar.ts +30 -1
- package/src/modes/components/tool-status-header.ts +0 -1
- package/src/modes/controllers/command-controller.ts +38 -34
- package/src/modes/controllers/event-controller.ts +27 -16
- package/src/modes/controllers/extension-ui-controller.ts +186 -34
- package/src/modes/controllers/selector-controller.ts +115 -22
- package/src/modes/interactive-mode.ts +189 -62
- package/src/modes/jobs-observer.ts +1 -1
- package/src/modes/theme/theme.ts +11 -9
- package/src/modes/types.ts +88 -0
- package/src/sdk/broker/lifecycle.ts +18 -13
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +3 -2
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +1 -0
- package/src/session/agent-session.ts +22 -1
- package/src/session/internal/managed-session-scope.ts +158 -107
- package/src/session/internal/managed-session-storage.ts +445 -57
- package/src/session/session-storage.ts +29 -44
- package/src/task/index.ts +257 -31
- package/src/task/output-manager.ts +24 -14
- package/src/task/receipt.ts +11 -1
- package/src/tools/index.ts +2 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed ralplan review conflicts and dispositions (#2902).
|
|
3
|
+
*
|
|
4
|
+
* Architect and Critic findings remain free-form in their stage markdown, but
|
|
5
|
+
* the join/revision path can record machine-checkable findings against stable
|
|
6
|
+
* plan targets. Incompatible actions on the same target produce open conflicts
|
|
7
|
+
* that block a clean join until an explicit disposition is recorded.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const RALPLAN_REVIEW_CONFLICTS_SCHEMA = "ralplan.review_conflicts.v1" as const;
|
|
11
|
+
|
|
12
|
+
export type ReviewAction = "add" | "remove" | "change" | "clarify";
|
|
13
|
+
export type ReviewRole = "architect" | "critic";
|
|
14
|
+
export type ReviewSeverity = "info" | "watch" | "block";
|
|
15
|
+
export type DispositionChoice = "accept_architect" | "accept_critic" | "synthesize" | "defer_user" | "reject_both";
|
|
16
|
+
|
|
17
|
+
export interface ReviewSourceReceipt {
|
|
18
|
+
stage: "architect" | "critic";
|
|
19
|
+
stageN: number;
|
|
20
|
+
path: string;
|
|
21
|
+
sha256: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReviewFinding {
|
|
25
|
+
findingId: string;
|
|
26
|
+
targetId: string;
|
|
27
|
+
action: ReviewAction;
|
|
28
|
+
severity: ReviewSeverity;
|
|
29
|
+
evidence: string;
|
|
30
|
+
sourceRole: ReviewRole;
|
|
31
|
+
sourceReceipt: ReviewSourceReceipt;
|
|
32
|
+
proposedOwner?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReviewConflict {
|
|
36
|
+
conflictId: string;
|
|
37
|
+
targetId: string;
|
|
38
|
+
findingIds: [string, string];
|
|
39
|
+
actions: [ReviewAction, ReviewAction];
|
|
40
|
+
sourceRoles: [ReviewRole, ReviewRole];
|
|
41
|
+
status: "open" | "dispositioned";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ConflictDisposition {
|
|
45
|
+
conflictId: string;
|
|
46
|
+
choice: DispositionChoice;
|
|
47
|
+
rationale: string;
|
|
48
|
+
decisionOwner: string;
|
|
49
|
+
affectedSections: string[];
|
|
50
|
+
dispositionedAt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReviewConflictDocument {
|
|
54
|
+
schema: typeof RALPLAN_REVIEW_CONFLICTS_SCHEMA;
|
|
55
|
+
plannerStageN: number;
|
|
56
|
+
findings: ReviewFinding[];
|
|
57
|
+
conflicts: ReviewConflict[];
|
|
58
|
+
dispositions: ConflictDisposition[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface JoinGateResult {
|
|
62
|
+
ok: boolean;
|
|
63
|
+
openConflictIds: string[];
|
|
64
|
+
missingDispositionIds: string[];
|
|
65
|
+
orphanDispositionIds: string[];
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ACTIONS = new Set<ReviewAction>(["add", "remove", "change", "clarify"]);
|
|
70
|
+
const ROLES = new Set<ReviewRole>(["architect", "critic"]);
|
|
71
|
+
const SEVERITIES = new Set<ReviewSeverity>(["info", "watch", "block"]);
|
|
72
|
+
const DISPOSITIONS = new Set<DispositionChoice>([
|
|
73
|
+
"accept_architect",
|
|
74
|
+
"accept_critic",
|
|
75
|
+
"synthesize",
|
|
76
|
+
"defer_user",
|
|
77
|
+
"reject_both",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** Pairs of actions that cannot both stand for the same plan target. */
|
|
81
|
+
const INCOMPATIBLE_ACTION_PAIRS = new Set<string>([
|
|
82
|
+
pairKey("add", "remove"),
|
|
83
|
+
pairKey("remove", "add"),
|
|
84
|
+
pairKey("remove", "change"),
|
|
85
|
+
pairKey("change", "remove"),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function pairKey(left: ReviewAction, right: ReviewAction): string {
|
|
89
|
+
return `${left}\u0000${right}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function nonEmptyString(value: unknown, field: string): string {
|
|
97
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
98
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
99
|
+
}
|
|
100
|
+
return value.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function positiveInt(value: unknown, field: string): number {
|
|
104
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
105
|
+
throw new Error(`${field} must be an integer >= 1`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseAction(value: unknown, field: string): ReviewAction {
|
|
111
|
+
const action = nonEmptyString(value, field);
|
|
112
|
+
if (!ACTIONS.has(action as ReviewAction)) {
|
|
113
|
+
throw new Error(`${field} must be one of: ${[...ACTIONS].join(", ")}`);
|
|
114
|
+
}
|
|
115
|
+
return action as ReviewAction;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseRole(value: unknown, field: string): ReviewRole {
|
|
119
|
+
const role = nonEmptyString(value, field);
|
|
120
|
+
if (!ROLES.has(role as ReviewRole)) {
|
|
121
|
+
throw new Error(`${field} must be one of: ${[...ROLES].join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
return role as ReviewRole;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseSeverity(value: unknown, field: string): ReviewSeverity {
|
|
127
|
+
const severity = nonEmptyString(value, field);
|
|
128
|
+
if (!SEVERITIES.has(severity as ReviewSeverity)) {
|
|
129
|
+
throw new Error(`${field} must be one of: ${[...SEVERITIES].join(", ")}`);
|
|
130
|
+
}
|
|
131
|
+
return severity as ReviewSeverity;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseDispositionChoice(value: unknown, field: string): DispositionChoice {
|
|
135
|
+
const choice = nonEmptyString(value, field);
|
|
136
|
+
if (!DISPOSITIONS.has(choice as DispositionChoice)) {
|
|
137
|
+
throw new Error(`${field} must be one of: ${[...DISPOSITIONS].join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
return choice as DispositionChoice;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseSourceReceipt(value: unknown, field: string): ReviewSourceReceipt {
|
|
143
|
+
if (!isObject(value)) throw new Error(`${field} must be an object`);
|
|
144
|
+
const stage = nonEmptyString(value.stage, `${field}.stage`);
|
|
145
|
+
if (stage !== "architect" && stage !== "critic") {
|
|
146
|
+
throw new Error(`${field}.stage must be architect or critic`);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
stage,
|
|
150
|
+
stageN: positiveInt(value.stageN ?? value.stage_n, `${field}.stageN`),
|
|
151
|
+
path: nonEmptyString(value.path, `${field}.path`),
|
|
152
|
+
sha256: nonEmptyString(value.sha256, `${field}.sha256`),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseFinding(value: unknown, index: number): ReviewFinding {
|
|
157
|
+
if (!isObject(value)) throw new Error(`findings[${index}] must be an object`);
|
|
158
|
+
const finding: ReviewFinding = {
|
|
159
|
+
findingId: nonEmptyString(value.findingId ?? value.finding_id, `findings[${index}].findingId`),
|
|
160
|
+
targetId: nonEmptyString(value.targetId ?? value.target_id, `findings[${index}].targetId`),
|
|
161
|
+
action: parseAction(value.action, `findings[${index}].action`),
|
|
162
|
+
severity: parseSeverity(value.severity, `findings[${index}].severity`),
|
|
163
|
+
evidence: nonEmptyString(value.evidence, `findings[${index}].evidence`),
|
|
164
|
+
sourceRole: parseRole(value.sourceRole ?? value.source_role, `findings[${index}].sourceRole`),
|
|
165
|
+
sourceReceipt: parseSourceReceipt(
|
|
166
|
+
value.sourceReceipt ?? value.source_receipt,
|
|
167
|
+
`findings[${index}].sourceReceipt`,
|
|
168
|
+
),
|
|
169
|
+
};
|
|
170
|
+
const proposedOwner = value.proposedOwner ?? value.proposed_owner;
|
|
171
|
+
if (proposedOwner !== undefined) {
|
|
172
|
+
finding.proposedOwner = nonEmptyString(proposedOwner, `findings[${index}].proposedOwner`);
|
|
173
|
+
}
|
|
174
|
+
return finding;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseDisposition(value: unknown, index: number): ConflictDisposition {
|
|
178
|
+
if (!isObject(value)) throw new Error(`dispositions[${index}] must be an object`);
|
|
179
|
+
const affectedRaw = value.affectedSections ?? value.affected_sections;
|
|
180
|
+
if (!Array.isArray(affectedRaw) || affectedRaw.length === 0) {
|
|
181
|
+
throw new Error(`dispositions[${index}].affectedSections must be a non-empty string array`);
|
|
182
|
+
}
|
|
183
|
+
const affectedSections = affectedRaw.map((entry, i) =>
|
|
184
|
+
nonEmptyString(entry, `dispositions[${index}].affectedSections[${i}]`),
|
|
185
|
+
);
|
|
186
|
+
const disposition: ConflictDisposition = {
|
|
187
|
+
conflictId: nonEmptyString(value.conflictId ?? value.conflict_id, `dispositions[${index}].conflictId`),
|
|
188
|
+
choice: parseDispositionChoice(value.choice, `dispositions[${index}].choice`),
|
|
189
|
+
rationale: nonEmptyString(value.rationale, `dispositions[${index}].rationale`),
|
|
190
|
+
decisionOwner: nonEmptyString(
|
|
191
|
+
value.decisionOwner ?? value.decision_owner,
|
|
192
|
+
`dispositions[${index}].decisionOwner`,
|
|
193
|
+
),
|
|
194
|
+
affectedSections,
|
|
195
|
+
};
|
|
196
|
+
const at = value.dispositionedAt ?? value.dispositioned_at;
|
|
197
|
+
if (at !== undefined) {
|
|
198
|
+
disposition.dispositionedAt = nonEmptyString(at, `dispositions[${index}].dispositionedAt`);
|
|
199
|
+
}
|
|
200
|
+
return disposition;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** True when two actions on the same target cannot both remain. */
|
|
204
|
+
export function actionsAreIncompatible(left: ReviewAction, right: ReviewAction): boolean {
|
|
205
|
+
if (left === right) return false;
|
|
206
|
+
return INCOMPATIBLE_ACTION_PAIRS.has(pairKey(left, right));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Derive open conflicts from typed findings. Only cross-role incompatible pairs
|
|
211
|
+
* on the same targetId are conflicts (Architect remove vs Critic add, etc.).
|
|
212
|
+
*/
|
|
213
|
+
export function detectReviewConflicts(findings: readonly ReviewFinding[]): ReviewConflict[] {
|
|
214
|
+
const byTarget = new Map<string, ReviewFinding[]>();
|
|
215
|
+
for (const finding of findings) {
|
|
216
|
+
const list = byTarget.get(finding.targetId) ?? [];
|
|
217
|
+
list.push(finding);
|
|
218
|
+
byTarget.set(finding.targetId, list);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const conflicts: ReviewConflict[] = [];
|
|
222
|
+
for (const [targetId, group] of byTarget) {
|
|
223
|
+
for (let i = 0; i < group.length; i++) {
|
|
224
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
225
|
+
const a = group[i]!;
|
|
226
|
+
const b = group[j]!;
|
|
227
|
+
if (a.sourceRole === b.sourceRole) continue;
|
|
228
|
+
if (!actionsAreIncompatible(a.action, b.action)) continue;
|
|
229
|
+
const [left, right] = a.findingId <= b.findingId ? [a, b] : [b, a];
|
|
230
|
+
conflicts.push({
|
|
231
|
+
conflictId: `conflict:${targetId}:${left.findingId}:${right.findingId}`,
|
|
232
|
+
targetId,
|
|
233
|
+
findingIds: [left.findingId, right.findingId],
|
|
234
|
+
actions: [left.action, right.action],
|
|
235
|
+
sourceRoles: [left.sourceRole, right.sourceRole],
|
|
236
|
+
status: "open",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return conflicts.sort((x, y) => x.conflictId.localeCompare(y.conflictId));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Mark conflicts dispositioned when a matching disposition exists. */
|
|
245
|
+
export function applyDispositions(
|
|
246
|
+
conflicts: readonly ReviewConflict[],
|
|
247
|
+
dispositions: readonly ConflictDisposition[],
|
|
248
|
+
): ReviewConflict[] {
|
|
249
|
+
const disposed = new Set(dispositions.map(d => d.conflictId));
|
|
250
|
+
return conflicts.map(conflict =>
|
|
251
|
+
disposed.has(conflict.conflictId) ? { ...conflict, status: "dispositioned" } : { ...conflict, status: "open" },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Join gate: clean only when every derived conflict has an explicit disposition
|
|
257
|
+
* with rationale and decision owner, and no orphan dispositions reference unknown
|
|
258
|
+
* conflicts.
|
|
259
|
+
*/
|
|
260
|
+
export function evaluateReviewJoinGate(
|
|
261
|
+
findings: readonly ReviewFinding[],
|
|
262
|
+
dispositions: readonly ConflictDisposition[],
|
|
263
|
+
precomputedConflicts?: readonly ReviewConflict[],
|
|
264
|
+
): JoinGateResult {
|
|
265
|
+
const derived = precomputedConflicts ? precomputedConflicts.map(c => ({ ...c })) : detectReviewConflicts(findings);
|
|
266
|
+
const withStatus = applyDispositions(derived, dispositions);
|
|
267
|
+
const knownIds = new Set(withStatus.map(c => c.conflictId));
|
|
268
|
+
const openConflictIds = withStatus.filter(c => c.status === "open").map(c => c.conflictId);
|
|
269
|
+
const disposedIds = new Set(dispositions.map(d => d.conflictId));
|
|
270
|
+
const missingDispositionIds = openConflictIds.filter(id => !disposedIds.has(id));
|
|
271
|
+
const orphanDispositionIds = dispositions.map(d => d.conflictId).filter(id => !knownIds.has(id));
|
|
272
|
+
|
|
273
|
+
const ok = missingDispositionIds.length === 0 && orphanDispositionIds.length === 0;
|
|
274
|
+
let message: string;
|
|
275
|
+
if (ok && withStatus.length === 0) {
|
|
276
|
+
message = "No typed review conflicts; join is clean.";
|
|
277
|
+
} else if (ok) {
|
|
278
|
+
message = `All ${withStatus.length} typed review conflict(s) are dispositioned.`;
|
|
279
|
+
} else if (missingDispositionIds.length > 0) {
|
|
280
|
+
message = `Join blocked: ${missingDispositionIds.length} open conflict(s) lack disposition: ${missingDispositionIds.join(", ")}.`;
|
|
281
|
+
} else {
|
|
282
|
+
message = `Join blocked: disposition(s) reference unknown conflict id(s): ${orphanDispositionIds.join(", ")}.`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { ok, openConflictIds, missingDispositionIds, orphanDispositionIds, message };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Strip optional markdown fence and parse JSON. */
|
|
289
|
+
export function parseReviewConflictJson(raw: string): unknown {
|
|
290
|
+
const trimmed = raw.trim();
|
|
291
|
+
const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/u);
|
|
292
|
+
const body = fenced ? fenced[1]!.trim() : trimmed;
|
|
293
|
+
try {
|
|
294
|
+
return JSON.parse(body);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
throw new Error(`disposition artifact must be JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Authoritative provenance required by `gjc ralplan --write --stage disposition`.
|
|
302
|
+
*
|
|
303
|
+
* Without this, a disposition document can claim arbitrary path/hash strings and
|
|
304
|
+
* join the wrong Architect/Critic pass (#3013 adversarial review).
|
|
305
|
+
*/
|
|
306
|
+
export interface IndexedReviewArtifact {
|
|
307
|
+
path: string;
|
|
308
|
+
sha256: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface DispositionProvenanceContext {
|
|
312
|
+
/** CLI `--stage_n` for this disposition write; must equal `plannerStageN`. */
|
|
313
|
+
expectedStageN: number;
|
|
314
|
+
/**
|
|
315
|
+
* Persisted Architect/Critic (and other) stage artifacts from this run's
|
|
316
|
+
* `index.jsonl`, keyed by `${stage}\u0000${stageN}`.
|
|
317
|
+
*/
|
|
318
|
+
indexedArtifacts: ReadonlyMap<string, IndexedReviewArtifact>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Stable map key for a staged artifact identity in the run index. */
|
|
322
|
+
export function reviewArtifactIndexKey(stage: string, stageN: number): string {
|
|
323
|
+
return `${stage}\u0000${stageN}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Cross-check every finding's source receipt against the CLI stage number and
|
|
328
|
+
* the run's persisted Architect/Critic artifact index. Fail closed on mismatch
|
|
329
|
+
* or spoofed path/hash attestations.
|
|
330
|
+
*/
|
|
331
|
+
export function assertDispositionProvenance(
|
|
332
|
+
doc: ReviewConflictDocument,
|
|
333
|
+
provenance: DispositionProvenanceContext,
|
|
334
|
+
): void {
|
|
335
|
+
if (doc.plannerStageN !== provenance.expectedStageN) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`disposition provenance: plannerStageN=${doc.plannerStageN} does not match CLI --stage_n=${provenance.expectedStageN}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < doc.findings.length; i++) {
|
|
342
|
+
const finding = doc.findings[i]!;
|
|
343
|
+
const field = `findings[${i}]`;
|
|
344
|
+
const receipt = finding.sourceReceipt;
|
|
345
|
+
|
|
346
|
+
if (receipt.stage !== finding.sourceRole) {
|
|
347
|
+
throw new Error(`${field}.sourceReceipt.stage=${receipt.stage} must equal sourceRole=${finding.sourceRole}`);
|
|
348
|
+
}
|
|
349
|
+
if (receipt.stageN !== doc.plannerStageN) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`${field}.sourceReceipt.stageN=${receipt.stageN} must equal plannerStageN=${doc.plannerStageN} (same-pass join)`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const indexed = provenance.indexedArtifacts.get(reviewArtifactIndexKey(receipt.stage, receipt.stageN));
|
|
356
|
+
if (!indexed) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`${field}.sourceReceipt: no persisted ${receipt.stage} stage ${receipt.stageN} artifact in run index`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (indexed.path !== receipt.path) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`${field}.sourceReceipt.path does not match indexed ${receipt.stage} stage ${receipt.stageN} path`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (indexed.sha256 !== receipt.sha256) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`${field}.sourceReceipt.sha256 does not match indexed ${receipt.stage} stage ${receipt.stageN} sha256`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Parse and validate a disposition-stage document. Re-derives conflicts from
|
|
376
|
+
* findings when omitted, then fails closed unless every conflict is dispositioned
|
|
377
|
+
* (or findings produce zero conflicts and dispositions are empty).
|
|
378
|
+
*
|
|
379
|
+
* When `provenance` is provided (CLI write path), also enforces authoritative
|
|
380
|
+
* same-pass receipt checks against the run index.
|
|
381
|
+
*/
|
|
382
|
+
export function parseReviewConflictDocument(
|
|
383
|
+
raw: string | unknown,
|
|
384
|
+
provenance?: DispositionProvenanceContext,
|
|
385
|
+
): ReviewConflictDocument {
|
|
386
|
+
const value = typeof raw === "string" ? parseReviewConflictJson(raw) : raw;
|
|
387
|
+
if (!isObject(value)) throw new Error("disposition document must be a JSON object");
|
|
388
|
+
|
|
389
|
+
const schema = nonEmptyString(value.schema, "schema");
|
|
390
|
+
if (schema !== RALPLAN_REVIEW_CONFLICTS_SCHEMA) {
|
|
391
|
+
throw new Error(`schema must be ${RALPLAN_REVIEW_CONFLICTS_SCHEMA}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const plannerStageN = positiveInt(value.plannerStageN ?? value.planner_stage_n, "plannerStageN");
|
|
395
|
+
if (!Array.isArray(value.findings)) throw new Error("findings must be an array");
|
|
396
|
+
const findings = value.findings.map((entry, i) => parseFinding(entry, i));
|
|
397
|
+
const findingIds = new Set(findings.map(f => f.findingId));
|
|
398
|
+
if (findingIds.size !== findings.length) throw new Error("findingId values must be unique");
|
|
399
|
+
|
|
400
|
+
// Structural role/stage alignment is always required (even without index).
|
|
401
|
+
for (let i = 0; i < findings.length; i++) {
|
|
402
|
+
const f = findings[i]!;
|
|
403
|
+
if (f.sourceReceipt.stage !== f.sourceRole) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`findings[${i}].sourceReceipt.stage=${f.sourceReceipt.stage} must equal sourceRole=${f.sourceRole}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (f.sourceReceipt.stageN !== plannerStageN) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`findings[${i}].sourceReceipt.stageN=${f.sourceReceipt.stageN} must equal plannerStageN=${plannerStageN}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const dispositions = Array.isArray(value.dispositions)
|
|
416
|
+
? value.dispositions.map((entry, i) => parseDisposition(entry, i))
|
|
417
|
+
: [];
|
|
418
|
+
const dispositionConflictIds = new Set(dispositions.map(d => d.conflictId));
|
|
419
|
+
if (dispositionConflictIds.size !== dispositions.length) {
|
|
420
|
+
throw new Error("disposition conflictId values must be unique");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const derived = detectReviewConflicts(findings);
|
|
424
|
+
const provided = Array.isArray(value.conflicts) ? value.conflicts : undefined;
|
|
425
|
+
let conflicts: ReviewConflict[];
|
|
426
|
+
if (provided === undefined) {
|
|
427
|
+
conflicts = applyDispositions(derived, dispositions);
|
|
428
|
+
} else {
|
|
429
|
+
// Accept provided conflict ids only when they match derived pairs.
|
|
430
|
+
const derivedById = new Map(derived.map(c => [c.conflictId, c]));
|
|
431
|
+
conflicts = provided.map((entry, i) => {
|
|
432
|
+
if (!isObject(entry)) throw new Error(`conflicts[${i}] must be an object`);
|
|
433
|
+
const conflictId = nonEmptyString(entry.conflictId ?? entry.conflict_id, `conflicts[${i}].conflictId`);
|
|
434
|
+
const derivedConflict = derivedById.get(conflictId);
|
|
435
|
+
if (!derivedConflict) {
|
|
436
|
+
throw new Error(`conflicts[${i}] ${conflictId} is not derived from findings`);
|
|
437
|
+
}
|
|
438
|
+
return derivedConflict;
|
|
439
|
+
});
|
|
440
|
+
// Include any derived conflicts omitted from the payload so join cannot skip them.
|
|
441
|
+
for (const derivedConflict of derived) {
|
|
442
|
+
if (!conflicts.some(c => c.conflictId === derivedConflict.conflictId)) {
|
|
443
|
+
conflicts.push(derivedConflict);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
conflicts = applyDispositions(conflicts, dispositions);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const gate = evaluateReviewJoinGate(findings, dispositions, conflicts);
|
|
450
|
+
if (!gate.ok) {
|
|
451
|
+
throw new Error(gate.message);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const doc: ReviewConflictDocument = {
|
|
455
|
+
schema: RALPLAN_REVIEW_CONFLICTS_SCHEMA,
|
|
456
|
+
plannerStageN,
|
|
457
|
+
findings,
|
|
458
|
+
conflicts,
|
|
459
|
+
dispositions,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
if (provenance) {
|
|
463
|
+
assertDispositionProvenance(doc, provenance);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return doc;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Canonical JSON serialization for disposition-stage artifacts. */
|
|
470
|
+
export function serializeReviewConflictDocument(doc: ReviewConflictDocument): string {
|
|
471
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
472
|
+
}
|
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
type RalplanIndexRow,
|
|
14
14
|
summarizeRalplanIndex,
|
|
15
15
|
} from "./ledger-event-renderer";
|
|
16
|
+
import {
|
|
17
|
+
type IndexedReviewArtifact,
|
|
18
|
+
parseReviewConflictDocument,
|
|
19
|
+
reviewArtifactIndexKey,
|
|
20
|
+
serializeReviewConflictDocument,
|
|
21
|
+
} from "./ralplan-review-conflicts";
|
|
16
22
|
import {
|
|
17
23
|
assertCwdMatchesRepositoryBinding,
|
|
18
24
|
assertPathUnderRepositoryBinding,
|
|
@@ -37,7 +43,6 @@ import {
|
|
|
37
43
|
import { probeGjcTeamAvailability } from "./team-runtime";
|
|
38
44
|
import { assertSafePathComponent, CommandError, flagValue, hasFlag } from "./workflow-cli-common";
|
|
39
45
|
import { getSkillManifest } from "./workflow-manifest";
|
|
40
|
-
|
|
41
46
|
/**
|
|
42
47
|
* Native implementation of `gjc ralplan`.
|
|
43
48
|
*
|
|
@@ -53,9 +58,11 @@ import { getSkillManifest } from "./workflow-manifest";
|
|
|
53
58
|
* 2. **Artifact write**: `gjc ralplan --write --stage <type> --stage_n <N>
|
|
54
59
|
* (--artifact <path-or-string> | --artifact-env GJC_RALPLAN_ARTIFACT)
|
|
55
60
|
* [--run-id <id>] [--session-id <id>] [--lane-verdict <token>] [--json]` persists Planner / Architect
|
|
56
|
-
* / Critic / revision / post-interview / ADR / final
|
|
57
|
-
* an `index.jsonl` audit log, copies `final`
|
|
58
|
-
* the HUD chip to reflect the latest
|
|
61
|
+
* / Critic / disposition / revision / post-interview / ADR / final artifacts under
|
|
62
|
+
* `.gjc/plans/ralplan/<run-id>/`, maintains an `index.jsonl` audit log, copies `final`
|
|
63
|
+
* stages to `pending-approval.md`, and advances the HUD chip to reflect the latest
|
|
64
|
+
* persisted stage. Disposition stage artifacts are fail-closed JSON documents that
|
|
65
|
+
* record typed review conflicts with authoritative same-pass source receipts (#2902).
|
|
59
66
|
*/
|
|
60
67
|
|
|
61
68
|
export interface RalplanCommandResult {
|
|
@@ -64,7 +71,16 @@ export interface RalplanCommandResult {
|
|
|
64
71
|
stderr?: string;
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
const KNOWN_STAGES = [
|
|
74
|
+
const KNOWN_STAGES = [
|
|
75
|
+
"planner",
|
|
76
|
+
"architect",
|
|
77
|
+
"critic",
|
|
78
|
+
"disposition",
|
|
79
|
+
"revision",
|
|
80
|
+
"post-interview",
|
|
81
|
+
"adr",
|
|
82
|
+
"final",
|
|
83
|
+
] as const;
|
|
68
84
|
type RalplanStage = (typeof KNOWN_STAGES)[number];
|
|
69
85
|
/** Default consensus iterations (planner + revision openers) per run. Matches SKILL.md re-review cap. */
|
|
70
86
|
export const RALPLAN_DEFAULT_MAX_ITERATIONS = 5;
|
|
@@ -1851,6 +1867,54 @@ async function buildRalplanHud(options: {
|
|
|
1851
1867
|
});
|
|
1852
1868
|
}
|
|
1853
1869
|
|
|
1870
|
+
/**
|
|
1871
|
+
* Disposition-stage artifacts are machine-checkable JSON. Validate, enforce
|
|
1872
|
+
* authoritative same-pass provenance against the run index, and re-serialize to
|
|
1873
|
+
* canonical form so join gates and re-review share one shape (#2902 / #3013).
|
|
1874
|
+
*/
|
|
1875
|
+
function normalizeDispositionArtifact(raw: string, expectedStageN: number, indexText: string | undefined): string {
|
|
1876
|
+
try {
|
|
1877
|
+
const indexedArtifacts = buildIndexedReviewArtifacts(indexText);
|
|
1878
|
+
const doc = parseReviewConflictDocument(raw, {
|
|
1879
|
+
expectedStageN,
|
|
1880
|
+
indexedArtifacts,
|
|
1881
|
+
});
|
|
1882
|
+
return serializeReviewConflictDocument(doc);
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
throw new RalplanCommandError(
|
|
1885
|
+
2,
|
|
1886
|
+
`invalid ralplan disposition artifact: ${error instanceof Error ? error.message : String(error)}`,
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/** Parse complete path/sha256 rows from a run `index.jsonl` snapshot for provenance. */
|
|
1892
|
+
function buildIndexedReviewArtifacts(indexText: string | undefined): Map<string, IndexedReviewArtifact> {
|
|
1893
|
+
const map = new Map<string, IndexedReviewArtifact>();
|
|
1894
|
+
if (indexText === undefined) return map;
|
|
1895
|
+
for (const line of indexText.split(/\r?\n/)) {
|
|
1896
|
+
const trimmed = line.trim();
|
|
1897
|
+
if (!trimmed) continue;
|
|
1898
|
+
let row: unknown;
|
|
1899
|
+
try {
|
|
1900
|
+
row = JSON.parse(trimmed);
|
|
1901
|
+
} catch {
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
1905
|
+
const record = row as Record<string, unknown>;
|
|
1906
|
+
if (typeof record.stage !== "string") continue;
|
|
1907
|
+
if (typeof record.stage_n !== "number" || !Number.isInteger(record.stage_n)) continue;
|
|
1908
|
+
if (typeof record.path !== "string" || typeof record.sha256 !== "string") continue;
|
|
1909
|
+
// Last complete row for an identity wins (matches findExistingStageArtifact).
|
|
1910
|
+
map.set(reviewArtifactIndexKey(record.stage, record.stage_n), {
|
|
1911
|
+
path: record.path,
|
|
1912
|
+
sha256: record.sha256,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
return map;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1854
1918
|
async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
|
|
1855
1919
|
const resolved = await resolveArtifactArgs(args, cwd);
|
|
1856
1920
|
const persistedRoleState = parsePersistedRoleStateArgs(args, resolved.stage);
|
|
@@ -1873,14 +1937,20 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
|
|
|
1873
1937
|
// Non-file / missing path is inline content; resolveArtifactContent already handled it.
|
|
1874
1938
|
}
|
|
1875
1939
|
}
|
|
1876
|
-
const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
|
|
1877
|
-
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
1878
1940
|
|
|
1879
|
-
// Read the ledger once before persistence. The dedupe guard
|
|
1880
|
-
// consume this same snapshot so no additional ledger
|
|
1881
|
-
// evaluation and persistence.
|
|
1941
|
+
// Read the ledger once before persistence. The dedupe guard, both gates, and
|
|
1942
|
+
// disposition provenance consume this same snapshot so no additional ledger
|
|
1943
|
+
// read can slip between gate evaluation and persistence.
|
|
1882
1944
|
const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
|
|
1883
1945
|
|
|
1946
|
+
// Disposition stage: fail-closed parse/normalize with authoritative receipts (#2902).
|
|
1947
|
+
const normalizedArtifact =
|
|
1948
|
+
resolved.stage === "disposition"
|
|
1949
|
+
? normalizeDispositionArtifact(resolved.artifact, resolved.stageN, indexLoad.rawText)
|
|
1950
|
+
: resolved.artifact;
|
|
1951
|
+
const content = normalizedArtifact.endsWith("\n") ? normalizedArtifact : `${normalizedArtifact}\n`;
|
|
1952
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
1953
|
+
|
|
1884
1954
|
// Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
|
|
1885
1955
|
// silently clobber the artifact or append a duplicate ledger row. Classify before any
|
|
1886
1956
|
// state mutation so a conflict never regresses run-state phase.
|