@dev-loops/core 0.5.0 → 0.7.1
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/package.json +4 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/asset-generation.mjs +26 -0
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +167 -97
- package/src/config/extension-defaults.yaml +0 -11
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/async-start-contract.mjs +9 -2
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +92 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +236 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +94 -237
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +51 -7
- package/src/loop/queue-board-sync.mjs +61 -2
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/ui-e2e-scoping.mjs +162 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { DebtSignalSchema } from "./debt-signal.mjs";
|
|
3
|
-
import { loadDevLoopConfig } from "../config/config.mjs";
|
|
4
|
-
|
|
5
|
-
// ============================================================================
|
|
6
|
-
// Flag phrase inventory — derived from personas.deep prompt in defaults.yaml
|
|
7
|
-
//
|
|
8
|
-
// Confidence values use the canonical DebtSignalSchema 0..1 range (0.9 = 90%).
|
|
9
|
-
// This matches the schema default: z.number().min(0).max(1).default(1).
|
|
10
|
-
// ============================================================================
|
|
11
|
-
|
|
12
|
-
/** @type {Array<{ phrase: RegExp, category: string, severity: string, confidence: number }>} */
|
|
13
|
-
const FLAG_PATTERNS = [
|
|
14
|
-
{
|
|
15
|
-
phrase: /(?:crossing|crossed|exceeds?)\s+(?:1000|1,000)\+?\s+lines/i,
|
|
16
|
-
category: "file_size",
|
|
17
|
-
severity: "high",
|
|
18
|
-
confidence: 0.9,
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
phrase: /conditionals?\s+bolted\s+onto\s+unrelated\s+paths/i,
|
|
22
|
-
category: "spaghetti_branching",
|
|
23
|
-
severity: "high",
|
|
24
|
-
confidence: 0.9,
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
phrase: /\bspaghetti\b/i,
|
|
28
|
-
category: "spaghetti_branching",
|
|
29
|
-
severity: "high",
|
|
30
|
-
confidence: 0.9,
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
phrase: /thin\s+wrapper/i,
|
|
34
|
-
category: "thin_wrapper",
|
|
35
|
-
severity: "medium",
|
|
36
|
-
confidence: 0.9,
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
phrase: /re-export\s*[- ]?only/i,
|
|
40
|
-
category: "thin_wrapper",
|
|
41
|
-
severity: "medium",
|
|
42
|
-
confidence: 0.9,
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
phrase: /identity\s+abstraction/i,
|
|
46
|
-
category: "thin_wrapper",
|
|
47
|
-
severity: "medium",
|
|
48
|
-
confidence: 0.9,
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
phrase: /feature\s+logic\s+leaking\s+into/i,
|
|
52
|
-
category: "leaky_feature_logic",
|
|
53
|
-
severity: "high",
|
|
54
|
-
confidence: 0.9,
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
phrase: /leaking\s+into\s+shared/i,
|
|
58
|
-
category: "leaky_feature_logic",
|
|
59
|
-
severity: "high",
|
|
60
|
-
confidence: 0.9,
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
phrase: /cast[- ]?heavy/i,
|
|
64
|
-
category: "weak_contract",
|
|
65
|
-
severity: "medium",
|
|
66
|
-
confidence: 0.9,
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
phrase: /optionality[- ]?heavy/i,
|
|
70
|
-
category: "weak_contract",
|
|
71
|
-
severity: "medium",
|
|
72
|
-
confidence: 0.9,
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
phrase: /any[- ]?typed\s+contract/i,
|
|
76
|
-
category: "weak_contract",
|
|
77
|
-
severity: "medium",
|
|
78
|
-
confidence: 0.9,
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
phrase: /code\s+judo/i,
|
|
82
|
-
category: "simplification_opportunity",
|
|
83
|
-
severity: "medium",
|
|
84
|
-
confidence: 0.9,
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
phrase: /prefer\s+deletion\s+over\s+addition/i,
|
|
88
|
-
category: "simplification_opportunity",
|
|
89
|
-
severity: "medium",
|
|
90
|
-
confidence: 0.9,
|
|
91
|
-
},
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
const FILE_PATH_RE = /[\w/\-.]+\.(?:m?js|ts|tsx|jsx|mjs)/i;
|
|
95
|
-
|
|
96
|
-
// ============================================================================
|
|
97
|
-
// Helpers
|
|
98
|
-
// ============================================================================
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Match a comment body against known deep-persona flag phrases.
|
|
102
|
-
* Returns the match when a specific pattern matches, or null when no
|
|
103
|
-
* patterns match the body.
|
|
104
|
-
*
|
|
105
|
-
* @param {string} body
|
|
106
|
-
* @returns {{ category: string, severity: string, confidence: number, matchedPhrase: string|null }|null}
|
|
107
|
-
*/
|
|
108
|
-
function matchDeepPersonaFlags(body) {
|
|
109
|
-
for (const pattern of FLAG_PATTERNS) {
|
|
110
|
-
if (pattern.phrase.test(body)) {
|
|
111
|
-
return {
|
|
112
|
-
category: pattern.category,
|
|
113
|
-
severity: pattern.severity,
|
|
114
|
-
confidence: pattern.confidence,
|
|
115
|
-
matchedPhrase: pattern.phrase.source,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Extract a file path from a comment body, or return an empty string if none found.
|
|
125
|
-
*
|
|
126
|
-
* @param {string} body
|
|
127
|
-
* @returns {string}
|
|
128
|
-
*/
|
|
129
|
-
function extractFilePath(body) {
|
|
130
|
-
const match = body.match(FILE_PATH_RE);
|
|
131
|
-
return match ? match[0] : "";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Build a debt_signal object from a matched deep-persona comment.
|
|
136
|
-
*
|
|
137
|
-
* @param {object} comment - Normalized comment from parseReviewThreads output
|
|
138
|
-
* @param {string} category - Inferred category
|
|
139
|
-
* @param {string} severity - Severity hint
|
|
140
|
-
* @param {number} confidence - Confidence score (0..1)
|
|
141
|
-
* @param {string|null} matchedPhrase - The regex source that matched
|
|
142
|
-
* @param {{ prNumber: string|number, prUrl: string }} prMeta
|
|
143
|
-
* @returns {object}
|
|
144
|
-
*/
|
|
145
|
-
function buildDebtSignal(comment, category, severity, confidence, matchedPhrase, prMeta) {
|
|
146
|
-
const filePath = extractFilePath(comment.body);
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
id: randomUUID(),
|
|
150
|
-
sourceType: "pr_review_deep_persona",
|
|
151
|
-
signalKind: category,
|
|
152
|
-
location: filePath ? { filePath } : {},
|
|
153
|
-
severityHint: severity,
|
|
154
|
-
timestamp: new Date().toISOString(),
|
|
155
|
-
confidence,
|
|
156
|
-
rawPayload: {
|
|
157
|
-
description: comment.body,
|
|
158
|
-
metadata: {
|
|
159
|
-
prNumber: String(prMeta.prNumber),
|
|
160
|
-
prUrl: prMeta.prUrl,
|
|
161
|
-
commentId: comment.id,
|
|
162
|
-
threadId: comment.threadId,
|
|
163
|
-
isResolved: comment.isResolved ?? false,
|
|
164
|
-
category,
|
|
165
|
-
matchedPhrase,
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ============================================================================
|
|
172
|
-
// Public API
|
|
173
|
-
// ============================================================================
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Extract deep-persona debt_signal artifacts from normalized review-thread JSON.
|
|
177
|
-
*
|
|
178
|
-
* Accepts the output of `parseReviewThreads()` (the `comments` array with
|
|
179
|
-
* normalized `{ id, threadId, author, body, isActionable }` entries).
|
|
180
|
-
*
|
|
181
|
-
* Filters to only bot-authored comments that match known deep-persona flag
|
|
182
|
-
* phrases. Bots are identified by `author.isBot === true` or `author.type === "Bot"`.
|
|
183
|
-
*
|
|
184
|
-
* @param {{ comments: Array<{ id: string, threadId: string, author: { login: string, type: string, isBot: boolean }, body: string, isActionable?: boolean, isResolved?: boolean }>, threads?: Array<{ id: string, isResolved: boolean }> }} parsedOutput - parseReviewThreads() output
|
|
185
|
-
* @param {{ prNumber: string|number, prUrl: string }} prMeta
|
|
186
|
-
* @returns {Array<object>} Array of debt_signal objects compatible with DebtSignalSchema
|
|
187
|
-
*/
|
|
188
|
-
export function extractDeepPersonaSignals(parsedOutput, prMeta) {
|
|
189
|
-
if (!parsedOutput || !Array.isArray(parsedOutput.comments)) {
|
|
190
|
-
throw new Error("Invalid parsed output: expected { comments: [...] } from parseReviewThreads()");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Build a thread-id → isResolved map for fast lookup
|
|
194
|
-
const threadResolved = new Map();
|
|
195
|
-
if (Array.isArray(parsedOutput.threads)) {
|
|
196
|
-
for (const thread of parsedOutput.threads) {
|
|
197
|
-
threadResolved.set(thread.id, Boolean(thread.isResolved));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const signals = [];
|
|
202
|
-
|
|
203
|
-
for (const comment of parsedOutput.comments) {
|
|
204
|
-
// Only process bot-authored comments (all Copilot personas emit as bots)
|
|
205
|
-
if (!comment.author || (!comment.author.isBot && comment.author.type !== "Bot")) {
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (!comment.body || comment.body.trim().length === 0) {
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const match = matchDeepPersonaFlags(comment.body);
|
|
214
|
-
if (!match) {
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Enrich comment with isResolved from thread data
|
|
219
|
-
const isResolved = threadResolved.has(comment.threadId)
|
|
220
|
-
? threadResolved.get(comment.threadId)
|
|
221
|
-
: (comment.isResolved ?? false);
|
|
222
|
-
|
|
223
|
-
const signal = buildDebtSignal(
|
|
224
|
-
{ ...comment, isResolved },
|
|
225
|
-
match.category,
|
|
226
|
-
match.severity,
|
|
227
|
-
match.confidence,
|
|
228
|
-
match.matchedPhrase,
|
|
229
|
-
prMeta,
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
// Validate against canonical schema — throw on regression
|
|
233
|
-
signals.push(DebtSignalSchema.parse(signal));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return signals;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Return the known deep-persona flag phrase regex sources for inspection.
|
|
241
|
-
*
|
|
242
|
-
* @returns {Array<string>}
|
|
243
|
-
*/
|
|
244
|
-
export function getDeepPersonaFlagPhrases() {
|
|
245
|
-
return FLAG_PATTERNS.map((p) => p.phrase.source);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Verify that all known deep-persona flag phrase regex patterns match
|
|
250
|
-
* the loaded deep persona prompt text. Returns an array of regex sources
|
|
251
|
-
* whose patterns did not find any match in the prompt (empty = all match).
|
|
252
|
-
*
|
|
253
|
-
* @returns {Promise<Array<string>>}
|
|
254
|
-
*/
|
|
255
|
-
export async function verifyPromptStability() {
|
|
256
|
-
const { config, errors } = await loadDevLoopConfig();
|
|
257
|
-
if (errors.length > 0) {
|
|
258
|
-
throw new Error("Cannot verify prompt stability: config load errors: " +
|
|
259
|
-
errors.map(e => e.message).join("; "));
|
|
260
|
-
}
|
|
261
|
-
const deepPrompt = config?.personas?.deep?.prompt ?? "";
|
|
262
|
-
|
|
263
|
-
return FLAG_PATTERNS
|
|
264
|
-
.filter((p) => !p.phrase.test(deepPrompt))
|
|
265
|
-
.map((p) => p.phrase.source);
|
|
266
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
import { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Create the Claude Code extension-surface adapter.
|
|
7
|
-
*
|
|
8
|
-
* Implements the same `ExtensionHarnessAdapter` interface as the Pi adapter so
|
|
9
|
-
* `executeDevLoopsCommand` and the extension wiring run unchanged under Claude.
|
|
10
|
-
*
|
|
11
|
-
* - `exec` shells out via `bash -lc` (Claude has no native exec API in core).
|
|
12
|
-
* - lifecycle `on(...)` registrations are stored in `listeners` for hook-driven
|
|
13
|
-
* dispatch (wired in CA4 / #773); they are not auto-fired here.
|
|
14
|
-
* - `registerCommand(...)` registrations are stored in `commands`.
|
|
15
|
-
* - Claude core has no interactive widget/status surface, so the default
|
|
16
|
-
* `HarnessContext` reports `hasUI: false` and routes `ui` calls to a sink.
|
|
17
|
-
*
|
|
18
|
-
* @param {Object} [options]
|
|
19
|
-
* @param {string} [options.cwd] - Default cwd for exec and contexts (default: process.cwd()).
|
|
20
|
-
* @param {NodeJS.ProcessEnv} [options.env] - Env for exec (default: process.env).
|
|
21
|
-
* @param {(message: string, level: string) => void} [options.onNotify] - Optional sink for
|
|
22
|
-
* `ui.notify` (e.g. console). Defaults to a no-op.
|
|
23
|
-
* @returns {import("./extension-adapter.mjs").ExtensionHarnessAdapter & {
|
|
24
|
-
* listeners: Map<string, Function>,
|
|
25
|
-
* commands: Map<string, import("./extension-adapter.mjs").HarnessCommandConfig>,
|
|
26
|
-
* makeContext: (overrides?: {cwd?: string}) => import("./extension-adapter.mjs").HarnessContext,
|
|
27
|
-
* }}
|
|
28
|
-
*/
|
|
29
|
-
export function createClaudeExtensionAdapter({
|
|
30
|
-
cwd = process.cwd(),
|
|
31
|
-
env = process.env,
|
|
32
|
-
onNotify = () => {},
|
|
33
|
-
} = {}) {
|
|
34
|
-
const listeners = new Map();
|
|
35
|
-
const commands = new Map();
|
|
36
|
-
|
|
37
|
-
function exec(command, options = {}) {
|
|
38
|
-
return new Promise((resolve) => {
|
|
39
|
-
execFile(
|
|
40
|
-
"bash",
|
|
41
|
-
["-lc", command],
|
|
42
|
-
{
|
|
43
|
-
cwd: options.cwd ?? cwd,
|
|
44
|
-
env,
|
|
45
|
-
timeout: options.timeout ?? 0,
|
|
46
|
-
encoding: "utf8",
|
|
47
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
48
|
-
},
|
|
49
|
-
(error, stdout, stderr) => {
|
|
50
|
-
if (error) {
|
|
51
|
-
// Match the documented HarnessExecResult contract: `code` is undefined when
|
|
52
|
-
// the process was killed (e.g. timeout). Consumers branch on `killed` first.
|
|
53
|
-
const killed = Boolean(error.killed);
|
|
54
|
-
resolve({
|
|
55
|
-
code: killed ? undefined : (typeof error.code === "number" ? error.code : 1),
|
|
56
|
-
stdout: stdout ?? "",
|
|
57
|
-
stderr: stderr ?? "",
|
|
58
|
-
killed,
|
|
59
|
-
});
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
resolve({ code: 0, stdout: stdout ?? "", stderr: stderr ?? "", killed: false });
|
|
63
|
-
},
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function makeContext({ cwd: ctxCwd } = {}) {
|
|
69
|
-
return {
|
|
70
|
-
cwd: ctxCwd ?? cwd,
|
|
71
|
-
hasUI: false,
|
|
72
|
-
ui: {
|
|
73
|
-
notify(message, level = "info") {
|
|
74
|
-
onNotify(message, level);
|
|
75
|
-
},
|
|
76
|
-
setWidget() {},
|
|
77
|
-
setStatus() {},
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const adapter = createExtensionHarnessAdapter({
|
|
83
|
-
exec,
|
|
84
|
-
on(event, handler) {
|
|
85
|
-
listeners.set(event, handler);
|
|
86
|
-
},
|
|
87
|
-
registerCommand(name, config) {
|
|
88
|
-
commands.set(name, config);
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// Freeze the composite so the validated interface guarantee from the factory carries
|
|
93
|
-
// through to the returned object (which also exposes the #773 dispatch registries).
|
|
94
|
-
return Object.freeze({
|
|
95
|
-
exec: adapter.exec,
|
|
96
|
-
on: adapter.on,
|
|
97
|
-
registerCommand: adapter.registerCommand,
|
|
98
|
-
listeners,
|
|
99
|
-
commands,
|
|
100
|
-
makeContext,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// AC/DoD matrix item — one row in the refinement coverage matrix
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
export const AC_DOD_ITEM_TYPE = Object.freeze({
|
|
8
|
-
AC: "AC",
|
|
9
|
-
DOD: "DoD",
|
|
10
|
-
NON_GOAL: "Non-goal",
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
export const AC_DOD_ITEM_STATUS = Object.freeze({
|
|
14
|
-
MET: "Met",
|
|
15
|
-
PARTIAL: "Partial",
|
|
16
|
-
UNMET: "Unmet",
|
|
17
|
-
UNVERIFIED: "Unverified",
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A single row in the AC/DoD coverage matrix.
|
|
22
|
-
* Matches the refiner persona's required table output shape.
|
|
23
|
-
*/
|
|
24
|
-
export const AcDodMatrixItemSchema = z.strictObject({
|
|
25
|
-
/** Exact item text from the source issue/plan/spec */
|
|
26
|
-
item: z.string().trim().min(1),
|
|
27
|
-
/** Type classification */
|
|
28
|
-
type: z.enum(Object.values(AC_DOD_ITEM_TYPE)),
|
|
29
|
-
/** Verification status */
|
|
30
|
-
status: z.enum(Object.values(AC_DOD_ITEM_STATUS)),
|
|
31
|
-
/** Reference to supporting evidence (file, test, doc path, or URL) */
|
|
32
|
-
evidence: z.string(),
|
|
33
|
-
/** Additional context or caveats */
|
|
34
|
-
notes: z.string(),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The full AC/DoD/Non-goal coverage matrix.
|
|
39
|
-
* Emitted by the refiner during issue refinement, consumed by implementation
|
|
40
|
-
* agents via the handoff envelope as a structured contract.
|
|
41
|
-
*/
|
|
42
|
-
export const AcDodMatrixSchema = z.strictObject({
|
|
43
|
-
/** Schema identifier for dispatch and validation */
|
|
44
|
-
schema: z.literal("ac-dod-matrix/v1"),
|
|
45
|
-
/** Ordered list of matrix items */
|
|
46
|
-
items: z.array(AcDodMatrixItemSchema).min(1),
|
|
47
|
-
/** Source reference (issue URL, plan-doc path, etc.) */
|
|
48
|
-
source: z.string().trim().min(1).optional(),
|
|
49
|
-
/** ISO 8601 timestamp of matrix generation */
|
|
50
|
-
generatedAt: z.string().datetime(),
|
|
51
|
-
/**
|
|
52
|
-
* True when every item has status "Met" — the contract is fully satisfied.
|
|
53
|
-
* Implementation agents use this to gate merge-readiness.
|
|
54
|
-
*/
|
|
55
|
-
isComplete: z.boolean(),
|
|
56
|
-
}).refine(
|
|
57
|
-
(data) => isMatrixComplete(data) === data.isComplete,
|
|
58
|
-
{
|
|
59
|
-
message: "isComplete must be true when (and only when) every item has status 'Met'",
|
|
60
|
-
path: ["isComplete"],
|
|
61
|
-
}
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// Convenience helpers
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Check whether a matrix is fully satisfied (all items Met).
|
|
70
|
-
* Pure function — does not require a parsed Zod result.
|
|
71
|
-
*/
|
|
72
|
-
export function isMatrixComplete(matrix) {
|
|
73
|
-
if (!matrix || !Array.isArray(matrix.items) || matrix.items.length === 0) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
return matrix.items.every((item) => item.status === AC_DOD_ITEM_STATUS.MET);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Collect all items that are not "Met" — the outstanding work contract.
|
|
81
|
-
*/
|
|
82
|
-
export function outstandingItems(matrix) {
|
|
83
|
-
if (!matrix || !Array.isArray(matrix.items)) {
|
|
84
|
-
return [];
|
|
85
|
-
}
|
|
86
|
-
return matrix.items.filter((item) => item.status !== AC_DOD_ITEM_STATUS.MET);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Validate raw data against the AC/DoD matrix schema.
|
|
91
|
-
* Returns a Zod safeParse result.
|
|
92
|
-
*/
|
|
93
|
-
export function validateAcDodMatrix(data) {
|
|
94
|
-
return AcDodMatrixSchema.safeParse(data);
|
|
95
|
-
}
|