opennori 0.1.0
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/.opennori/protocol.md +272 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/nori.js +13 -0
- package/examples/opennori-self.json +298 -0
- package/package.json +36 -0
- package/src/cli.js +2083 -0
- package/src/core.js +971 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const PROTOCOL_VERSION = "opennori/v1";
|
|
5
|
+
|
|
6
|
+
export const VALID_STATUSES = new Set(["unknown", "failing", "passing", "blocked", "waived"]);
|
|
7
|
+
export const VALID_EVIDENCE_RESULTS = new Set(["failing", "passing", "blocked", "waived"]);
|
|
8
|
+
export const VALID_PROFILE_STRENGTHS = new Set(["must", "prefer", "avoid"]);
|
|
9
|
+
export const VALID_PROFILE_ITEM_TYPES = new Set(["skill", "stack", "constraint"]);
|
|
10
|
+
export const VALID_PROFILE_RESULTS = new Set(["satisfied", "violated", "waived"]);
|
|
11
|
+
export const STRONG_EVIDENCE_KINDS = new Set([
|
|
12
|
+
"test-summary",
|
|
13
|
+
"screenshot",
|
|
14
|
+
"artifact",
|
|
15
|
+
"review-result",
|
|
16
|
+
"human-confirmation",
|
|
17
|
+
"protocol-v1"
|
|
18
|
+
]);
|
|
19
|
+
export const STRONG_CONFIDENCE = new Set(["verified", "reviewed", "human-confirmed"]);
|
|
20
|
+
|
|
21
|
+
const PLAN_FIELD_NAMES = new Set(["plan", "steps", "tasks", "todos", "next_steps", "implementation_plan"]);
|
|
22
|
+
const FORBIDDEN_USER_STORY_TERMS = [
|
|
23
|
+
"acceptance.json",
|
|
24
|
+
"evidence.json",
|
|
25
|
+
"plan.md",
|
|
26
|
+
"json",
|
|
27
|
+
"schema",
|
|
28
|
+
"script",
|
|
29
|
+
"field",
|
|
30
|
+
"file exists",
|
|
31
|
+
"字段",
|
|
32
|
+
"脚本",
|
|
33
|
+
"命令",
|
|
34
|
+
"计划",
|
|
35
|
+
"实现步骤"
|
|
36
|
+
];
|
|
37
|
+
const USER_OPERATION_TERMS = [
|
|
38
|
+
"运行",
|
|
39
|
+
"打开",
|
|
40
|
+
"查看",
|
|
41
|
+
"选择",
|
|
42
|
+
"阅读",
|
|
43
|
+
"询问",
|
|
44
|
+
"确认",
|
|
45
|
+
"比较",
|
|
46
|
+
"检查",
|
|
47
|
+
"审查",
|
|
48
|
+
"预览",
|
|
49
|
+
"安装",
|
|
50
|
+
"卸载",
|
|
51
|
+
"归档",
|
|
52
|
+
"添加",
|
|
53
|
+
"修改",
|
|
54
|
+
"提出",
|
|
55
|
+
"触发",
|
|
56
|
+
"执行",
|
|
57
|
+
"创建",
|
|
58
|
+
"run",
|
|
59
|
+
"open",
|
|
60
|
+
"view",
|
|
61
|
+
"select",
|
|
62
|
+
"read",
|
|
63
|
+
"ask",
|
|
64
|
+
"confirm",
|
|
65
|
+
"compare",
|
|
66
|
+
"review",
|
|
67
|
+
"preview",
|
|
68
|
+
"install",
|
|
69
|
+
"uninstall",
|
|
70
|
+
"archive",
|
|
71
|
+
"add",
|
|
72
|
+
"update",
|
|
73
|
+
"check"
|
|
74
|
+
];
|
|
75
|
+
const USER_OUTCOME_TERMS = [
|
|
76
|
+
"能",
|
|
77
|
+
"看到",
|
|
78
|
+
"显示",
|
|
79
|
+
"输出",
|
|
80
|
+
"返回",
|
|
81
|
+
"包含",
|
|
82
|
+
"结果",
|
|
83
|
+
"状态",
|
|
84
|
+
"缺口",
|
|
85
|
+
"反馈",
|
|
86
|
+
"报告",
|
|
87
|
+
"摘要",
|
|
88
|
+
"入口",
|
|
89
|
+
"建议",
|
|
90
|
+
"知道",
|
|
91
|
+
"判断",
|
|
92
|
+
"确认",
|
|
93
|
+
"区分",
|
|
94
|
+
"理解",
|
|
95
|
+
"提示",
|
|
96
|
+
"展示",
|
|
97
|
+
"保留",
|
|
98
|
+
"标明",
|
|
99
|
+
"说明",
|
|
100
|
+
"指出",
|
|
101
|
+
"回答",
|
|
102
|
+
"可复查",
|
|
103
|
+
"可执行",
|
|
104
|
+
"不需要",
|
|
105
|
+
"不会",
|
|
106
|
+
"不能",
|
|
107
|
+
"不创建",
|
|
108
|
+
"can",
|
|
109
|
+
"see",
|
|
110
|
+
"show",
|
|
111
|
+
"output",
|
|
112
|
+
"include",
|
|
113
|
+
"result",
|
|
114
|
+
"status",
|
|
115
|
+
"gap",
|
|
116
|
+
"report",
|
|
117
|
+
"summary",
|
|
118
|
+
"entry",
|
|
119
|
+
"action",
|
|
120
|
+
"return",
|
|
121
|
+
"understand",
|
|
122
|
+
"decide",
|
|
123
|
+
"confirm",
|
|
124
|
+
"distinguish",
|
|
125
|
+
"explain",
|
|
126
|
+
"answer",
|
|
127
|
+
"review"
|
|
128
|
+
];
|
|
129
|
+
const IMPLEMENTATION_ONLY_PHRASES = [
|
|
130
|
+
"文件存在",
|
|
131
|
+
"字段存在",
|
|
132
|
+
"命令执行成功",
|
|
133
|
+
"测试通过",
|
|
134
|
+
"用例通过",
|
|
135
|
+
"模块实现",
|
|
136
|
+
"接口实现",
|
|
137
|
+
"函数实现",
|
|
138
|
+
"组件实现",
|
|
139
|
+
"schema 校验通过",
|
|
140
|
+
"json 字段",
|
|
141
|
+
"manifest 字段",
|
|
142
|
+
"file exists",
|
|
143
|
+
"field exists",
|
|
144
|
+
"tests pass",
|
|
145
|
+
"test passes",
|
|
146
|
+
"module implemented",
|
|
147
|
+
"function implemented",
|
|
148
|
+
"schema passes"
|
|
149
|
+
];
|
|
150
|
+
const NEGATION_TERMS = ["不能", "不应", "不是", "不得", "避免", "cannot", "must not", "should not", "reject"];
|
|
151
|
+
|
|
152
|
+
function includesAny(text, terms) {
|
|
153
|
+
const lowered = String(text || "").toLowerCase();
|
|
154
|
+
return terms.some((term) => lowered.includes(term.toLowerCase()));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isImplementationOnly(text) {
|
|
158
|
+
const value = String(text || "");
|
|
159
|
+
if (includesAny(value, NEGATION_TERMS)) return false;
|
|
160
|
+
if (includesAny(value, USER_OPERATION_TERMS) && includesAny(value, USER_OUTCOME_TERMS)) return false;
|
|
161
|
+
return includesAny(value, IMPLEMENTATION_ONLY_PHRASES);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function inferCriterionLayer(id) {
|
|
165
|
+
if (String(id).startsWith("AC-P-")) return "protocol";
|
|
166
|
+
if (String(id).startsWith("AC-O-")) return "operator";
|
|
167
|
+
if (String(id).startsWith("AC-Z-")) return "productization";
|
|
168
|
+
return "acceptance";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function nowIso() {
|
|
172
|
+
return new Date().toISOString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function ok(data = {}, artifacts = [], warnings = [], nextActions = []) {
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
data,
|
|
179
|
+
artifacts,
|
|
180
|
+
warnings,
|
|
181
|
+
next_actions: nextActions
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function fail(type, message, fix) {
|
|
186
|
+
const error = { type, message };
|
|
187
|
+
if (fix) error.fix = fix;
|
|
188
|
+
return { ok: false, error };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function readJson(filePath) {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === "ENOENT") {
|
|
196
|
+
throw new Error(`File not found: ${filePath}`);
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`File must be JSON: ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function writeJson(filePath, payload) {
|
|
203
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
204
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function slugify(input) {
|
|
208
|
+
const slug = String(input || "")
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
|
211
|
+
.replace(/^-+|-+$/g, "");
|
|
212
|
+
return slug || "acceptance";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function pathsForGoal(rootDir, goalId) {
|
|
216
|
+
const activeDir = path.join(rootDir, ".opennori", "active");
|
|
217
|
+
return {
|
|
218
|
+
acceptancePath: path.join(activeDir, `${goalId}.acceptance.md`),
|
|
219
|
+
evidencePath: path.join(activeDir, `${goalId}.evidence.json`),
|
|
220
|
+
reportPath: path.join(rootDir, ".opennori", "reports", `${goalId}.report.md`)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function findActivePairs(rootDir) {
|
|
225
|
+
const activeDir = path.join(rootDir, ".opennori", "active");
|
|
226
|
+
if (!fs.existsSync(activeDir)) return [];
|
|
227
|
+
return fs.readdirSync(activeDir)
|
|
228
|
+
.filter((fileName) => fileName.endsWith(".evidence.json"))
|
|
229
|
+
.map((fileName) => {
|
|
230
|
+
const goalId = fileName.replace(/\.evidence\.json$/, "");
|
|
231
|
+
return {
|
|
232
|
+
goalId,
|
|
233
|
+
acceptancePath: path.join(activeDir, `${goalId}.acceptance.md`),
|
|
234
|
+
evidencePath: path.join(activeDir, fileName),
|
|
235
|
+
reportPath: path.join(rootDir, ".opennori", "reports", `${goalId}.report.md`)
|
|
236
|
+
};
|
|
237
|
+
})
|
|
238
|
+
.filter((pair) => fs.existsSync(pair.acceptancePath))
|
|
239
|
+
.sort((left, right) => left.goalId.localeCompare(right.goalId));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function buildContractFromBrief(brief) {
|
|
243
|
+
const goal = String(brief.goal || "").trim();
|
|
244
|
+
const goalId = slugify(brief.goal_id || goal.slice(0, 60));
|
|
245
|
+
const criteria = (brief.criteria || []).map((criterion, index) => ({
|
|
246
|
+
id: String(criterion.id || `AC-${index + 1}`),
|
|
247
|
+
layer: String(criterion.layer || inferCriterionLayer(criterion.id || `AC-${index + 1}`)),
|
|
248
|
+
user_story: String(criterion.user_story || "").trim(),
|
|
249
|
+
measurement: String(criterion.measurement || "").trim(),
|
|
250
|
+
threshold: String(criterion.threshold || "").trim(),
|
|
251
|
+
required: criterion.required !== false,
|
|
252
|
+
risk: criterion.risk || "medium"
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
protocol_version: PROTOCOL_VERSION,
|
|
257
|
+
goal_id: goalId,
|
|
258
|
+
goal,
|
|
259
|
+
created_at: nowIso(),
|
|
260
|
+
acceptance_basis: brief.acceptance_basis || { status: "draft" },
|
|
261
|
+
criteria
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function buildEvidenceLedger(contract) {
|
|
266
|
+
const criteria = {};
|
|
267
|
+
for (const criterion of contract.criteria) {
|
|
268
|
+
criteria[criterion.id] = {
|
|
269
|
+
status: "unknown",
|
|
270
|
+
confidence: "none",
|
|
271
|
+
required: criterion.required !== false,
|
|
272
|
+
risk: criterion.risk || "medium",
|
|
273
|
+
evidence: []
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
protocol_version: PROTOCOL_VERSION,
|
|
279
|
+
goal_id: contract.goal_id,
|
|
280
|
+
status: "active",
|
|
281
|
+
updated_at: nowIso(),
|
|
282
|
+
criteria,
|
|
283
|
+
capability_profile: {
|
|
284
|
+
items: [],
|
|
285
|
+
evidence: []
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function addProfileItem(ledger, item) {
|
|
291
|
+
if (!ledger.capability_profile) {
|
|
292
|
+
ledger.capability_profile = { items: [], evidence: [] };
|
|
293
|
+
}
|
|
294
|
+
const type = item.type || "constraint";
|
|
295
|
+
const strength = item.strength || "prefer";
|
|
296
|
+
if (!VALID_PROFILE_ITEM_TYPES.has(type)) {
|
|
297
|
+
throw new Error(`Invalid profile item type: ${type}`);
|
|
298
|
+
}
|
|
299
|
+
if (!VALID_PROFILE_STRENGTHS.has(strength)) {
|
|
300
|
+
throw new Error(`Invalid profile strength: ${strength}`);
|
|
301
|
+
}
|
|
302
|
+
const id = item.id || slugify(`${type}-${item.name}`);
|
|
303
|
+
const existingIndex = ledger.capability_profile.items.findIndex((entry) => entry.id === id);
|
|
304
|
+
const entry = {
|
|
305
|
+
id,
|
|
306
|
+
type,
|
|
307
|
+
name: String(item.name || "").trim(),
|
|
308
|
+
strength,
|
|
309
|
+
purpose: String(item.purpose || "").trim(),
|
|
310
|
+
scope: String(item.scope || "").trim(),
|
|
311
|
+
install_policy: item.install_policy || "ask_before_install",
|
|
312
|
+
evidence: []
|
|
313
|
+
};
|
|
314
|
+
if (!entry.name) throw new Error("--name is required");
|
|
315
|
+
if (existingIndex === -1) {
|
|
316
|
+
ledger.capability_profile.items.push(entry);
|
|
317
|
+
} else {
|
|
318
|
+
ledger.capability_profile.items[existingIndex] = {
|
|
319
|
+
...ledger.capability_profile.items[existingIndex],
|
|
320
|
+
...entry,
|
|
321
|
+
evidence: ledger.capability_profile.items[existingIndex].evidence || []
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return ledger;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function addProfileEvidence(ledger, itemId, evidence) {
|
|
328
|
+
if (!ledger.capability_profile) {
|
|
329
|
+
ledger.capability_profile = { items: [], evidence: [] };
|
|
330
|
+
}
|
|
331
|
+
const item = ledger.capability_profile.items.find((entry) => entry.id === itemId);
|
|
332
|
+
if (!item) throw new Error(`Capability profile item not found: ${itemId}`);
|
|
333
|
+
if (!VALID_PROFILE_RESULTS.has(evidence.result)) {
|
|
334
|
+
throw new Error(`Invalid profile evidence result: ${evidence.result}`);
|
|
335
|
+
}
|
|
336
|
+
const entry = {
|
|
337
|
+
item_id: itemId,
|
|
338
|
+
result: evidence.result,
|
|
339
|
+
summary: evidence.summary,
|
|
340
|
+
path: evidence.path,
|
|
341
|
+
created_at: nowIso()
|
|
342
|
+
};
|
|
343
|
+
item.evidence = [...(item.evidence || []), entry];
|
|
344
|
+
ledger.capability_profile.evidence.push(entry);
|
|
345
|
+
ledger.updated_at = nowIso();
|
|
346
|
+
return ledger;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function basisForEvidenceKind(kind) {
|
|
350
|
+
if (kind === "human-confirmation") return "human-confirmation";
|
|
351
|
+
if (kind === "test-summary" || kind === "review-result") return "tool-observation";
|
|
352
|
+
if (kind === "screenshot" || kind === "artifact") return "artifact-review";
|
|
353
|
+
if (kind === "protocol-v1") return "protocol-check";
|
|
354
|
+
return "agent-observation";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function normalizeEvidenceSource(source) {
|
|
358
|
+
if (source === null || source === undefined) return null;
|
|
359
|
+
if (typeof source === "string") {
|
|
360
|
+
const label = source.trim();
|
|
361
|
+
return label ? { type: "reference", label } : null;
|
|
362
|
+
}
|
|
363
|
+
if (typeof source !== "object" || Array.isArray(source)) return null;
|
|
364
|
+
|
|
365
|
+
const entry = {};
|
|
366
|
+
for (const [key, value] of Object.entries(source)) {
|
|
367
|
+
if (value !== undefined && value !== null && String(value).trim() !== "") {
|
|
368
|
+
entry[key] = value;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(entry).length === 0) return null;
|
|
372
|
+
if (!entry.type) entry.type = "reference";
|
|
373
|
+
if (!entry.label) {
|
|
374
|
+
entry.label = entry.path || entry.url || entry.command || entry.summary || entry.type;
|
|
375
|
+
}
|
|
376
|
+
return entry;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeEvidence(evidence) {
|
|
380
|
+
const sources = (Array.isArray(evidence.sources) ? evidence.sources : [])
|
|
381
|
+
.map((source) => normalizeEvidenceSource(source))
|
|
382
|
+
.filter(Boolean);
|
|
383
|
+
if (evidence.path && !sources.some((source) => source.path === evidence.path)) {
|
|
384
|
+
sources.push({ type: "artifact", label: evidence.path, path: evidence.path });
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
...evidence,
|
|
388
|
+
kind: evidence.kind || "manual",
|
|
389
|
+
basis: evidence.basis || basisForEvidenceKind(evidence.kind || "manual"),
|
|
390
|
+
sources,
|
|
391
|
+
reviewability: evidence.reviewability || (sources.length > 0 ? "source-provided" : "summary-only"),
|
|
392
|
+
limitations: evidence.limitations || ""
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function profileCompliance(ledger) {
|
|
397
|
+
const items = ledger.capability_profile?.items || [];
|
|
398
|
+
const statuses = items.map((item) => {
|
|
399
|
+
const latest = item.evidence?.at(-1);
|
|
400
|
+
let status = "unknown";
|
|
401
|
+
if (latest?.result === "satisfied") status = "satisfied";
|
|
402
|
+
if (latest?.result === "waived") status = "waived";
|
|
403
|
+
if (latest?.result === "violated") status = "violated";
|
|
404
|
+
return {
|
|
405
|
+
id: item.id,
|
|
406
|
+
type: item.type,
|
|
407
|
+
name: item.name,
|
|
408
|
+
strength: item.strength,
|
|
409
|
+
purpose: item.purpose,
|
|
410
|
+
status,
|
|
411
|
+
summary: latest?.summary || "<none>"
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
const blocking = statuses.filter((item) => item.strength === "must" && (item.status === "unknown" || item.status === "violated"));
|
|
415
|
+
const avoidedViolations = statuses.filter((item) => item.strength === "avoid" && item.status === "violated");
|
|
416
|
+
return {
|
|
417
|
+
required: items.length > 0,
|
|
418
|
+
complete: blocking.length === 0 && avoidedViolations.length === 0,
|
|
419
|
+
blocking: [...blocking, ...avoidedViolations],
|
|
420
|
+
statuses
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function renderAcceptanceMarkdown(contract, ledger) {
|
|
425
|
+
const lines = [
|
|
426
|
+
`# ${contract.goal_id} Acceptance Contract`,
|
|
427
|
+
"",
|
|
428
|
+
"## Goal",
|
|
429
|
+
"",
|
|
430
|
+
contract.goal,
|
|
431
|
+
"",
|
|
432
|
+
"## Acceptance Basis",
|
|
433
|
+
"",
|
|
434
|
+
`Status: ${contract.acceptance_basis?.status || "draft"}`,
|
|
435
|
+
contract.acceptance_basis?.summary ? `Summary: ${contract.acceptance_basis.summary}` : "Summary: <none>",
|
|
436
|
+
"",
|
|
437
|
+
"## Nori Profile",
|
|
438
|
+
"",
|
|
439
|
+
...renderProfileLines(ledger),
|
|
440
|
+
"",
|
|
441
|
+
"## User Acceptance Criteria",
|
|
442
|
+
"",
|
|
443
|
+
"| ID | Layer | User acceptance criterion | Measurement | Passing threshold | Status |",
|
|
444
|
+
"| --- | --- | --- | --- | --- | --- |"
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
for (const criterion of contract.criteria) {
|
|
448
|
+
const status = ledger.criteria[criterion.id]?.status || "unknown";
|
|
449
|
+
lines.push(`| ${criterion.id} | ${criterion.layer || inferCriterionLayer(criterion.id)} | ${criterion.user_story} | ${criterion.measurement} | ${criterion.threshold} | ${status} |`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
lines.push(
|
|
453
|
+
"",
|
|
454
|
+
"## Rule",
|
|
455
|
+
"",
|
|
456
|
+
"Progress is determined by acceptance evidence, not by implementation steps."
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
return `${lines.join("\n")}\n`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function syncAcceptanceMarkdown(acceptancePath, contract, ledger) {
|
|
463
|
+
fs.writeFileSync(acceptancePath, renderAcceptanceMarkdown(contract, ledger));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function renderProfileLines(ledger) {
|
|
467
|
+
const compliance = profileCompliance(ledger);
|
|
468
|
+
if (!compliance.required) return ["<none>"];
|
|
469
|
+
return [
|
|
470
|
+
"| ID | Type | Name | Strength | Compliance | Purpose |",
|
|
471
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
472
|
+
...compliance.statuses.map((item) => `| ${item.id} | ${item.type} | ${item.name} | ${item.strength} | ${item.status} | ${item.purpose || "<none>"} |`)
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function parseAcceptanceMarkdown(markdown) {
|
|
477
|
+
const goalMatch = markdown.match(/## Goal\s+([\s\S]*?)(?:\n## Acceptance Basis|\n## User Acceptance Criteria)/);
|
|
478
|
+
const tableMatch = markdown.match(/## User Acceptance Criteria\s+([\s\S]*?)(?:\n## |\n$)/);
|
|
479
|
+
const goal = goalMatch ? goalMatch[1].trim() : "";
|
|
480
|
+
const criteria = [];
|
|
481
|
+
|
|
482
|
+
if (tableMatch) {
|
|
483
|
+
for (const line of tableMatch[1].split("\n")) {
|
|
484
|
+
const trimmed = line.trim();
|
|
485
|
+
if (!trimmed.startsWith("|") || trimmed.includes("---") || trimmed.includes("User acceptance criterion")) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const cells = trimmed.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
489
|
+
if (cells.length >= 6) {
|
|
490
|
+
criteria.push({
|
|
491
|
+
id: cells[0],
|
|
492
|
+
layer: cells[1],
|
|
493
|
+
user_story: cells[2],
|
|
494
|
+
measurement: cells[3],
|
|
495
|
+
threshold: cells[4],
|
|
496
|
+
status: cells[5]
|
|
497
|
+
});
|
|
498
|
+
} else if (cells.length >= 5) {
|
|
499
|
+
criteria.push({
|
|
500
|
+
id: cells[0],
|
|
501
|
+
layer: inferCriterionLayer(cells[0]),
|
|
502
|
+
user_story: cells[1],
|
|
503
|
+
measurement: cells[2],
|
|
504
|
+
threshold: cells[3],
|
|
505
|
+
status: cells[4]
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { goal, criteria };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function validateContract(contract, ledger = null) {
|
|
515
|
+
const issues = [];
|
|
516
|
+
|
|
517
|
+
if (contract.protocol_version !== PROTOCOL_VERSION) {
|
|
518
|
+
issues.push({ path: "protocol_version", message: `Must be ${PROTOCOL_VERSION}` });
|
|
519
|
+
}
|
|
520
|
+
if (!contract.goal) {
|
|
521
|
+
issues.push({ path: "goal", message: "Goal is required" });
|
|
522
|
+
}
|
|
523
|
+
for (const field of Object.keys(contract)) {
|
|
524
|
+
if (PLAN_FIELD_NAMES.has(field)) {
|
|
525
|
+
issues.push({ path: field, message: "OpenNori contract must not expose process-plan fields" });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
|
529
|
+
issues.push({ path: "criteria", message: "At least one user acceptance criterion is required" });
|
|
530
|
+
return issues;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const ids = new Set();
|
|
534
|
+
contract.criteria.forEach((criterion, index) => {
|
|
535
|
+
const prefix = `criteria[${index}]`;
|
|
536
|
+
if (!criterion.id) {
|
|
537
|
+
issues.push({ path: `${prefix}.id`, message: "Criterion id is required" });
|
|
538
|
+
} else if (ids.has(criterion.id)) {
|
|
539
|
+
issues.push({ path: `${prefix}.id`, message: `Duplicate criterion id: ${criterion.id}` });
|
|
540
|
+
}
|
|
541
|
+
ids.add(criterion.id);
|
|
542
|
+
|
|
543
|
+
for (const field of ["user_story", "measurement", "threshold"]) {
|
|
544
|
+
if (!criterion[field]) {
|
|
545
|
+
issues.push({ path: `${prefix}.${field}`, message: `${field} is required` });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const userStory = String(criterion.user_story || "");
|
|
550
|
+
if (userStory && !userStory.startsWith("作为用户") && !userStory.toLowerCase().startsWith("as a user")) {
|
|
551
|
+
issues.push({
|
|
552
|
+
path: `${prefix}.user_story`,
|
|
553
|
+
message: "Acceptance criterion must be written from the user's perspective"
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const lowered = userStory.toLowerCase();
|
|
558
|
+
const terms = FORBIDDEN_USER_STORY_TERMS.filter((term) => lowered.includes(term.toLowerCase()));
|
|
559
|
+
if (terms.length > 0) {
|
|
560
|
+
issues.push({
|
|
561
|
+
path: `${prefix}.user_story`,
|
|
562
|
+
message: "Implementation detail appears in user acceptance criterion",
|
|
563
|
+
terms
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const measurement = String(criterion.measurement || "");
|
|
568
|
+
if (measurement && !includesAny(measurement, USER_OPERATION_TERMS)) {
|
|
569
|
+
issues.push({
|
|
570
|
+
path: `${prefix}.measurement`,
|
|
571
|
+
message: "Measurement must describe a user operation or review action"
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const threshold = String(criterion.threshold || "");
|
|
576
|
+
if (threshold && !includesAny(threshold, USER_OUTCOME_TERMS)) {
|
|
577
|
+
issues.push({
|
|
578
|
+
path: `${prefix}.threshold`,
|
|
579
|
+
message: "Passing threshold must describe a user-observable outcome or judgment"
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
for (const [field, value] of Object.entries({ measurement, threshold })) {
|
|
584
|
+
if (isImplementationOnly(value)) {
|
|
585
|
+
issues.push({
|
|
586
|
+
path: `${prefix}.${field}`,
|
|
587
|
+
message: "Implementation-only completion condition is not a user acceptance criterion"
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (ledger && !ledger.criteria?.[criterion.id]) {
|
|
593
|
+
issues.push({ path: `ledger.criteria.${criterion.id}`, message: "Evidence ledger is missing this criterion" });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (ledger) {
|
|
598
|
+
if (ledger.protocol_version !== PROTOCOL_VERSION) {
|
|
599
|
+
issues.push({ path: "ledger.protocol_version", message: `Must be ${PROTOCOL_VERSION}` });
|
|
600
|
+
}
|
|
601
|
+
for (const [criterionId, state] of Object.entries(ledger.criteria || {})) {
|
|
602
|
+
if (!ids.has(criterionId)) {
|
|
603
|
+
issues.push({ path: `ledger.criteria.${criterionId}`, message: "Evidence ledger has an unknown criterion" });
|
|
604
|
+
}
|
|
605
|
+
if (!VALID_STATUSES.has(state.status)) {
|
|
606
|
+
issues.push({ path: `ledger.criteria.${criterionId}.status`, message: `Invalid status: ${state.status}` });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
for (const item of ledger.capability_profile?.items || []) {
|
|
610
|
+
if (!VALID_PROFILE_ITEM_TYPES.has(item.type)) {
|
|
611
|
+
issues.push({ path: `ledger.capability_profile.items.${item.id}.type`, message: `Invalid profile item type: ${item.type}` });
|
|
612
|
+
}
|
|
613
|
+
if (!VALID_PROFILE_STRENGTHS.has(item.strength)) {
|
|
614
|
+
issues.push({ path: `ledger.capability_profile.items.${item.id}.strength`, message: `Invalid profile strength: ${item.strength}` });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return issues;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function addEvidence(contract, ledger, criterionId, evidence) {
|
|
623
|
+
const criterion = contract.criteria.find((item) => item.id === criterionId);
|
|
624
|
+
if (!criterion) {
|
|
625
|
+
throw new Error(`Criterion not found: ${criterionId}`);
|
|
626
|
+
}
|
|
627
|
+
if (!VALID_EVIDENCE_RESULTS.has(evidence.result)) {
|
|
628
|
+
throw new Error(`Invalid evidence result: ${evidence.result}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const state = ledger.criteria[criterionId];
|
|
632
|
+
const normalized = normalizeEvidence(evidence);
|
|
633
|
+
const gated = applyRiskGate(criterion, normalized);
|
|
634
|
+
state.evidence.push({
|
|
635
|
+
kind: normalized.kind,
|
|
636
|
+
basis: normalized.basis,
|
|
637
|
+
summary: normalized.summary,
|
|
638
|
+
result: gated.result,
|
|
639
|
+
confidence: gated.confidence,
|
|
640
|
+
path: normalized.path,
|
|
641
|
+
sources: normalized.sources,
|
|
642
|
+
reviewability: normalized.reviewability,
|
|
643
|
+
limitations: normalized.limitations,
|
|
644
|
+
gate: gated.gate,
|
|
645
|
+
created_at: nowIso()
|
|
646
|
+
});
|
|
647
|
+
state.status = gated.result;
|
|
648
|
+
state.confidence = gated.confidence;
|
|
649
|
+
ledger.updated_at = nowIso();
|
|
650
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
651
|
+
return ledger;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function applyRiskGate(criterion, evidence) {
|
|
655
|
+
const requestedResult = evidence.result;
|
|
656
|
+
const confidence = evidence.confidence || confidenceForEvidence(criterion.risk, requestedResult);
|
|
657
|
+
if (requestedResult !== "passing" || criterion.risk !== "high") {
|
|
658
|
+
return { result: requestedResult, confidence, gate: "accepted" };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (STRONG_EVIDENCE_KINDS.has(evidence.kind) || STRONG_CONFIDENCE.has(evidence.confidence)) {
|
|
662
|
+
return {
|
|
663
|
+
result: "passing",
|
|
664
|
+
confidence: evidence.confidence || "verified",
|
|
665
|
+
gate: "accepted"
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
result: "failing",
|
|
671
|
+
confidence: "strong-evidence-required",
|
|
672
|
+
gate: "downgraded_high_risk_requires_strong_evidence"
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function confidenceForEvidence(risk, result) {
|
|
677
|
+
if (result !== "passing") return "evidence";
|
|
678
|
+
if (risk === "low") return "agent";
|
|
679
|
+
if (risk === "medium") return "verified";
|
|
680
|
+
if (risk === "high") return "review-required";
|
|
681
|
+
return "human-required";
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function recomputeWorkflowStatus(contract, ledger) {
|
|
685
|
+
const requiredStates = contract.criteria
|
|
686
|
+
.filter((criterion) => criterion.required !== false)
|
|
687
|
+
.map((criterion) => ledger.criteria[criterion.id]?.status || "unknown");
|
|
688
|
+
const approved = contract.acceptance_basis?.status === "approved";
|
|
689
|
+
const compliance = profileCompliance(ledger);
|
|
690
|
+
|
|
691
|
+
if (requiredStates.some((status) => status === "blocked")) {
|
|
692
|
+
ledger.status = "blocked";
|
|
693
|
+
} else if (compliance.required && !compliance.complete) {
|
|
694
|
+
ledger.status = "blocked";
|
|
695
|
+
} else if (approved && requiredStates.length > 0 && requiredStates.every((status) => status === "passing" || status === "waived")) {
|
|
696
|
+
ledger.status = "complete";
|
|
697
|
+
} else {
|
|
698
|
+
ledger.status = "active";
|
|
699
|
+
}
|
|
700
|
+
ledger.updated_at = nowIso();
|
|
701
|
+
return ledger;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function currentGap(contract, ledger) {
|
|
705
|
+
if (contract.acceptance_basis?.status !== "approved") {
|
|
706
|
+
return {
|
|
707
|
+
id: "ACCEPTANCE-BASIS",
|
|
708
|
+
user_story: "作为用户,我需要先确认或修改验收标准,才能让 agent 判断任务完成。",
|
|
709
|
+
status: "unknown",
|
|
710
|
+
reason: "Acceptance criteria have not been approved by the user yet."
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const compliance = profileCompliance(ledger);
|
|
715
|
+
if (compliance.required && !compliance.complete) {
|
|
716
|
+
const item = compliance.blocking[0];
|
|
717
|
+
return {
|
|
718
|
+
id: `PROFILE-${item.id}`,
|
|
719
|
+
user_story: `作为用户,我需要 agent 遵守能力偏好:${item.name}。`,
|
|
720
|
+
status: item.status === "violated" ? "failing" : "blocked",
|
|
721
|
+
reason: `Capability profile item ${item.name} is ${item.status}.`
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const priority = ["failing", "blocked", "unknown"];
|
|
726
|
+
for (const status of priority) {
|
|
727
|
+
for (const criterion of contract.criteria) {
|
|
728
|
+
const state = ledger.criteria[criterion.id];
|
|
729
|
+
if (criterion.required === false) continue;
|
|
730
|
+
if ((state?.status || "unknown") === status) {
|
|
731
|
+
return {
|
|
732
|
+
id: criterion.id,
|
|
733
|
+
user_story: criterion.user_story,
|
|
734
|
+
status,
|
|
735
|
+
reason: gapReason(status)
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function intervention(contract, ledger) {
|
|
744
|
+
const compliance = profileCompliance(ledger);
|
|
745
|
+
if (compliance.required && !compliance.complete) {
|
|
746
|
+
const item = compliance.blocking[0];
|
|
747
|
+
return {
|
|
748
|
+
required: true,
|
|
749
|
+
criterion: `PROFILE-${item.id}`,
|
|
750
|
+
user_story: `作为用户,我需要确认 agent 是否必须遵守能力偏好:${item.name}。`,
|
|
751
|
+
action: item.status === "violated"
|
|
752
|
+
? `Capability profile item ${item.name} was violated. Waive it or revise the work.`
|
|
753
|
+
: `Provide evidence that Nori Profile item ${item.name} was satisfied, or waive it.`
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
for (const criterion of contract.criteria) {
|
|
758
|
+
const state = ledger.criteria[criterion.id];
|
|
759
|
+
if (state?.status === "blocked") {
|
|
760
|
+
const latest = state.evidence?.at(-1);
|
|
761
|
+
return {
|
|
762
|
+
required: true,
|
|
763
|
+
criterion: criterion.id,
|
|
764
|
+
user_story: criterion.user_story,
|
|
765
|
+
action: latest?.summary || "Provide the decision, permission, input, or external condition needed to unblock this criterion."
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
required: false,
|
|
772
|
+
action: "No user intervention is currently required."
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function completionAnswer(contract, ledger) {
|
|
777
|
+
const gap = currentGap(contract, ledger);
|
|
778
|
+
if (!gap && ledger.status === "complete") {
|
|
779
|
+
return {
|
|
780
|
+
complete: true,
|
|
781
|
+
answer: "Complete: all required acceptance criteria have passing or waived evidence."
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
complete: false,
|
|
786
|
+
answer: `Not complete: ${gap ? `${gap.id} is ${gap.status}. ${gap.reason}` : `workflow status is ${ledger.status}.`}`
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export function nextRecommendation(contract, ledger) {
|
|
791
|
+
const gap = currentGap(contract, ledger);
|
|
792
|
+
const needed = intervention(contract, ledger);
|
|
793
|
+
|
|
794
|
+
if (needed.required) {
|
|
795
|
+
return {
|
|
796
|
+
status: "user-intervention-required",
|
|
797
|
+
focus: needed.criterion,
|
|
798
|
+
summary: `${needed.criterion} needs user input before the agent continues.`,
|
|
799
|
+
actions: [
|
|
800
|
+
needed.action,
|
|
801
|
+
"After the decision or external condition is available, record evidence and rerun OpenNori status."
|
|
802
|
+
]
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (gap) {
|
|
807
|
+
if (gap.id === "ACCEPTANCE-BASIS") {
|
|
808
|
+
return {
|
|
809
|
+
status: "acceptance-approval-required",
|
|
810
|
+
focus: gap.id,
|
|
811
|
+
summary: "Acceptance criteria need user approval or revision before implementation work counts as complete.",
|
|
812
|
+
actions: [
|
|
813
|
+
"Ask the user to approve or revise the acceptance criteria before implementation work continues."
|
|
814
|
+
]
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
status: "work-on-current-gap",
|
|
820
|
+
focus: gap.id,
|
|
821
|
+
summary: `Continue with ${gap.id}: ${gap.user_story}`,
|
|
822
|
+
actions: [
|
|
823
|
+
`Create or collect reviewable evidence for ${gap.id}.`,
|
|
824
|
+
`Record the result for ${gap.id}, then rerun OpenNori status.`
|
|
825
|
+
]
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (ledger.status === "complete") {
|
|
830
|
+
return {
|
|
831
|
+
status: "ready-for-next-loop",
|
|
832
|
+
focus: null,
|
|
833
|
+
summary: "This OpenNori goal is complete. If the user has asked to continue, start the next acceptance loop without waiting for another next-step prompt.",
|
|
834
|
+
actions: [
|
|
835
|
+
"Report the completion evidence briefly.",
|
|
836
|
+
"Select the next human-facing project goal from the current context, draft acceptance criteria, and continue the OpenNori loop."
|
|
837
|
+
]
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
status: "reconcile-workflow-state",
|
|
843
|
+
focus: null,
|
|
844
|
+
summary: `No current gap was found, but workflow status is ${ledger.status}.`,
|
|
845
|
+
actions: [
|
|
846
|
+
"Run OpenNori evaluate and doctor, then inspect the report before continuing."
|
|
847
|
+
]
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function gapReason(status) {
|
|
852
|
+
if (status === "failing") return "Existing evidence shows this user acceptance criterion is not satisfied.";
|
|
853
|
+
if (status === "blocked") return "This user acceptance criterion needs a user decision or external condition.";
|
|
854
|
+
return "This user acceptance criterion has no user-understandable evidence yet.";
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export function evidenceView(evidence) {
|
|
858
|
+
if (!evidence) return null;
|
|
859
|
+
const sources = Array.isArray(evidence.sources) ? evidence.sources : [];
|
|
860
|
+
return {
|
|
861
|
+
kind: evidence.kind || "manual",
|
|
862
|
+
basis: evidence.basis || basisForEvidenceKind(evidence.kind || "manual"),
|
|
863
|
+
summary: evidence.summary || "<none>",
|
|
864
|
+
result: evidence.result || "unknown",
|
|
865
|
+
confidence: evidence.confidence,
|
|
866
|
+
sources,
|
|
867
|
+
reviewability: evidence.reviewability || (sources.length > 0 || evidence.path ? "source-provided" : "summary-only"),
|
|
868
|
+
limitations: evidence.limitations || "",
|
|
869
|
+
path: evidence.path,
|
|
870
|
+
gate: evidence.gate,
|
|
871
|
+
created_at: evidence.created_at
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export function criterionStatusRows(contract, ledger) {
|
|
876
|
+
return contract.criteria.map((criterion) => {
|
|
877
|
+
const state = ledger.criteria[criterion.id] || {};
|
|
878
|
+
return {
|
|
879
|
+
id: criterion.id,
|
|
880
|
+
layer: criterion.layer || inferCriterionLayer(criterion.id),
|
|
881
|
+
user_story: criterion.user_story,
|
|
882
|
+
status: state.status || "unknown",
|
|
883
|
+
confidence: state.confidence || "none",
|
|
884
|
+
latest_evidence: evidenceView(state.evidence?.at(-1))
|
|
885
|
+
};
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function escapeTableCell(value) {
|
|
890
|
+
return String(value || "")
|
|
891
|
+
.replace(/\|/g, "\\|")
|
|
892
|
+
.replace(/\r?\n/g, " ");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function formatEvidenceValue(value) {
|
|
896
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
897
|
+
return String(value);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function formatEvidenceSource(source) {
|
|
901
|
+
if (!source) return "<none>";
|
|
902
|
+
if (typeof source === "string") return source;
|
|
903
|
+
const preferredKeys = ["type", "label", "command", "path", "url", "outcome", "summary"];
|
|
904
|
+
const parts = [];
|
|
905
|
+
for (const key of preferredKeys) {
|
|
906
|
+
if (source[key]) parts.push(`${key}=${formatEvidenceValue(source[key])}`);
|
|
907
|
+
}
|
|
908
|
+
for (const [key, value] of Object.entries(source)) {
|
|
909
|
+
if (!preferredKeys.includes(key) && value !== undefined && value !== null && String(value).trim() !== "") {
|
|
910
|
+
parts.push(`${key}=${formatEvidenceValue(value)}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return parts.join(", ") || "<none>";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function formatEvidenceSources(evidence) {
|
|
917
|
+
const view = evidenceView(evidence);
|
|
918
|
+
if (!view) return "<none>";
|
|
919
|
+
if (view.sources.length === 0) return view.path || "<none>";
|
|
920
|
+
return view.sources.map((source) => formatEvidenceSource(source)).join("; ");
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
export function renderReport(contract, ledger) {
|
|
924
|
+
const gap = currentGap(contract, ledger);
|
|
925
|
+
const needed = intervention(contract, ledger);
|
|
926
|
+
const completion = completionAnswer(contract, ledger);
|
|
927
|
+
const lines = [
|
|
928
|
+
`# ${contract.goal_id} Acceptance Report`,
|
|
929
|
+
"",
|
|
930
|
+
"## Decision Summary",
|
|
931
|
+
"",
|
|
932
|
+
`Completion: ${completion.answer}`,
|
|
933
|
+
`Current gap: ${gap ? `${gap.id} - ${gap.reason}` : "None. All required acceptance criteria have passing or waived evidence."}`,
|
|
934
|
+
`User intervention: ${needed.required ? `${needed.criterion} - ${needed.action}` : needed.action}`,
|
|
935
|
+
`Recommended next action: ${nextRecommendation(contract, ledger).summary}`,
|
|
936
|
+
`Workflow status: ${ledger.status}`,
|
|
937
|
+
"",
|
|
938
|
+
"## Goal",
|
|
939
|
+
"",
|
|
940
|
+
contract.goal,
|
|
941
|
+
"",
|
|
942
|
+
"## Acceptance Basis",
|
|
943
|
+
"",
|
|
944
|
+
`Status: ${contract.acceptance_basis?.status || "draft"}`,
|
|
945
|
+
contract.acceptance_basis?.summary ? `Summary: ${contract.acceptance_basis.summary}` : "Summary: <none>",
|
|
946
|
+
"",
|
|
947
|
+
"## Nori Profile",
|
|
948
|
+
"",
|
|
949
|
+
...renderProfileLines(ledger),
|
|
950
|
+
"",
|
|
951
|
+
"## Acceptance Status",
|
|
952
|
+
"",
|
|
953
|
+
"| ID | Layer | User acceptance criterion | Status | Confidence | Evidence summary | Basis | Sources | Reviewability | Limitations |",
|
|
954
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
|
|
955
|
+
];
|
|
956
|
+
|
|
957
|
+
for (const criterion of contract.criteria) {
|
|
958
|
+
const state = ledger.criteria[criterion.id] || {};
|
|
959
|
+
const latest = state.evidence?.at(-1);
|
|
960
|
+
const view = evidenceView(latest);
|
|
961
|
+
const evidence = view ? `${view.kind}: ${view.summary}` : "<none>";
|
|
962
|
+
lines.push(`| ${criterion.id} | ${criterion.layer || inferCriterionLayer(criterion.id)} | ${escapeTableCell(criterion.user_story)} | ${state.status || "unknown"} | ${state.confidence || "none"} | ${escapeTableCell(evidence)} | ${view?.basis || "<none>"} | ${escapeTableCell(formatEvidenceSources(latest))} | ${view?.reviewability || "<none>"} | ${escapeTableCell(view?.limitations || "<none>")} |`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
lines.push("", "## Current Acceptance Gap", "");
|
|
966
|
+
lines.push(gap ? `${gap.id} - ${gap.reason}` : "None. All required acceptance criteria have passing or waived evidence.");
|
|
967
|
+
lines.push("", "## User Intervention", "");
|
|
968
|
+
lines.push(needed.required ? `${needed.criterion} - ${needed.action}` : needed.action);
|
|
969
|
+
lines.push("", "## Conclusion", "", `Current status: ${ledger.status}`);
|
|
970
|
+
return `${lines.join("\n")}\n`;
|
|
971
|
+
}
|