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/cli.js
ADDED
|
@@ -0,0 +1,2083 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
addEvidence,
|
|
7
|
+
addProfileEvidence,
|
|
8
|
+
addProfileItem,
|
|
9
|
+
buildContractFromBrief,
|
|
10
|
+
buildEvidenceLedger,
|
|
11
|
+
completionAnswer,
|
|
12
|
+
criterionStatusRows,
|
|
13
|
+
currentGap,
|
|
14
|
+
fail,
|
|
15
|
+
findActivePairs,
|
|
16
|
+
intervention,
|
|
17
|
+
nextRecommendation,
|
|
18
|
+
ok,
|
|
19
|
+
pathsForGoal,
|
|
20
|
+
profileCompliance,
|
|
21
|
+
PROTOCOL_VERSION,
|
|
22
|
+
readJson,
|
|
23
|
+
recomputeWorkflowStatus,
|
|
24
|
+
renderAcceptanceMarkdown,
|
|
25
|
+
renderReport,
|
|
26
|
+
slugify,
|
|
27
|
+
syncAcceptanceMarkdown,
|
|
28
|
+
validateContract,
|
|
29
|
+
writeJson
|
|
30
|
+
} from "./core.js";
|
|
31
|
+
|
|
32
|
+
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
33
|
+
const MANIFEST_SCHEMA_VERSION = "opennori/manifest-v1";
|
|
34
|
+
const REQUIRED_NORI_DIRS = ["active", "completed", "blocked", "reports", "brainstorms"];
|
|
35
|
+
const NORI_CAPABILITIES = [
|
|
36
|
+
"acceptance-contract",
|
|
37
|
+
"evidence-ledger",
|
|
38
|
+
"reviewable-evidence",
|
|
39
|
+
"skill-pack",
|
|
40
|
+
"brainstorm",
|
|
41
|
+
"capability-profile",
|
|
42
|
+
"profile-check",
|
|
43
|
+
"archive",
|
|
44
|
+
"report",
|
|
45
|
+
"doctor",
|
|
46
|
+
"upgrade",
|
|
47
|
+
"context-export"
|
|
48
|
+
];
|
|
49
|
+
const WRITING_INSTALL_ACTIONS = new Set(["create", "overwrite", "update"]);
|
|
50
|
+
const WRITING_UNINSTALL_ACTIONS = new Set(["delete", "delete-tree"]);
|
|
51
|
+
const WRITING_UPGRADE_ACTIONS = new Set(["update", "overwrite"]);
|
|
52
|
+
|
|
53
|
+
function sameStringSet(left, right) {
|
|
54
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
55
|
+
const leftSet = new Set(left);
|
|
56
|
+
const rightSet = new Set(right);
|
|
57
|
+
if (leftSet.size !== rightSet.size) return false;
|
|
58
|
+
return [...leftSet].every((item) => rightSet.has(item));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function installActionReason(action, kind) {
|
|
62
|
+
if (action === "create") return `Missing OpenNori ${kind} will be created.`;
|
|
63
|
+
if (action === "exists") return `Required OpenNori ${kind} already exists.`;
|
|
64
|
+
if (action === "skip") return `Existing OpenNori ${kind} is not overwritten without --force.`;
|
|
65
|
+
if (action === "overwrite") return `Existing OpenNori ${kind} will be overwritten because --force was provided.`;
|
|
66
|
+
if (action === "update") return `OpenNori ${kind} will be refreshed from current project state.`;
|
|
67
|
+
return `OpenNori ${kind} action: ${action}.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function enrichInstallAction(root, action, { dryRun = false } = {}) {
|
|
71
|
+
const wouldWrite = WRITING_INSTALL_ACTIONS.has(action.action);
|
|
72
|
+
return {
|
|
73
|
+
path: relativeTo(root, action.path),
|
|
74
|
+
kind: action.kind || "file",
|
|
75
|
+
action: action.action,
|
|
76
|
+
managed: action.managed !== false,
|
|
77
|
+
would_write: wouldWrite,
|
|
78
|
+
will_write: wouldWrite && !dryRun,
|
|
79
|
+
destructive: action.action === "overwrite",
|
|
80
|
+
reason: action.reason || installActionReason(action.action, action.kind || "file")
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function summarizeInstallPlan(actions) {
|
|
85
|
+
const byAction = {};
|
|
86
|
+
for (const action of actions) {
|
|
87
|
+
byAction[action.action] = (byAction[action.action] || 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
total: actions.length,
|
|
91
|
+
by_action: byAction,
|
|
92
|
+
would_write: actions.filter((action) => action.would_write).length,
|
|
93
|
+
will_write: actions.filter((action) => action.will_write).length,
|
|
94
|
+
destructive: actions.filter((action) => action.destructive).length,
|
|
95
|
+
managed: actions.filter((action) => action.managed).length
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildInstallPlan(root, actions, { dryRun = false, force = false, requestedSkill = false } = {}) {
|
|
100
|
+
const enrichedActions = actions.map((action) => enrichInstallAction(root, action, { dryRun }));
|
|
101
|
+
return {
|
|
102
|
+
schema_version: "opennori/install-plan-v1",
|
|
103
|
+
root,
|
|
104
|
+
dry_run: dryRun,
|
|
105
|
+
force,
|
|
106
|
+
requested_skill: requestedSkill,
|
|
107
|
+
summary: summarizeInstallPlan(enrichedActions),
|
|
108
|
+
actions: enrichedActions
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function uninstallActionReason(action, kind) {
|
|
113
|
+
if (action === "delete") return `Existing OpenNori ${kind} will be removed.`;
|
|
114
|
+
if (action === "delete-tree") return `Existing OpenNori ${kind} and its contents will be removed.`;
|
|
115
|
+
if (action === "absent") return `OpenNori ${kind} is already absent.`;
|
|
116
|
+
if (action === "preserve") return `OpenNori ${kind} is preserved by default.`;
|
|
117
|
+
return `OpenNori ${kind} action: ${action}.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function plannedDelete(root, relativePath, kind, { recursive = false, reason = undefined } = {}) {
|
|
121
|
+
const target = path.join(root, relativePath);
|
|
122
|
+
const exists = fs.existsSync(target);
|
|
123
|
+
return {
|
|
124
|
+
path: target,
|
|
125
|
+
kind,
|
|
126
|
+
action: exists ? (recursive ? "delete-tree" : "delete") : "absent",
|
|
127
|
+
managed: true,
|
|
128
|
+
recursive,
|
|
129
|
+
reason
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function plannedPreserve(root, relativePath, kind, reason) {
|
|
134
|
+
return {
|
|
135
|
+
path: path.join(root, relativePath),
|
|
136
|
+
kind,
|
|
137
|
+
action: "preserve",
|
|
138
|
+
managed: true,
|
|
139
|
+
recursive: false,
|
|
140
|
+
reason
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function enrichUninstallAction(root, action, { dryRun = false } = {}) {
|
|
145
|
+
const wouldWrite = WRITING_UNINSTALL_ACTIONS.has(action.action);
|
|
146
|
+
return {
|
|
147
|
+
path: relativeTo(root, action.path),
|
|
148
|
+
kind: action.kind || "file",
|
|
149
|
+
action: action.action,
|
|
150
|
+
managed: action.managed !== false,
|
|
151
|
+
would_write: wouldWrite,
|
|
152
|
+
will_write: wouldWrite && !dryRun,
|
|
153
|
+
destructive: wouldWrite,
|
|
154
|
+
recursive: Boolean(action.recursive),
|
|
155
|
+
reason: action.reason || uninstallActionReason(action.action, action.kind || "file")
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function summarizeUninstallPlan(actions) {
|
|
160
|
+
const byAction = {};
|
|
161
|
+
for (const action of actions) {
|
|
162
|
+
byAction[action.action] = (byAction[action.action] || 0) + 1;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
total: actions.length,
|
|
166
|
+
by_action: byAction,
|
|
167
|
+
would_write: actions.filter((action) => action.would_write).length,
|
|
168
|
+
will_write: actions.filter((action) => action.will_write).length,
|
|
169
|
+
destructive: actions.filter((action) => action.destructive).length,
|
|
170
|
+
preserved: actions.filter((action) => action.action === "preserve").length,
|
|
171
|
+
managed: actions.filter((action) => action.managed).length
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function upgradeActionReason(action, kind) {
|
|
176
|
+
if (action === "current") return `OpenNori ${kind} is already current.`;
|
|
177
|
+
if (action === "update") return `OpenNori ${kind} will be refreshed to the current CLI version.`;
|
|
178
|
+
if (action === "overwrite") return `OpenNori ${kind} will be overwritten to refresh generated OpenNori assets.`;
|
|
179
|
+
if (action === "missing") return `OpenNori ${kind} is missing; run install before upgrade.`;
|
|
180
|
+
return `OpenNori ${kind} action: ${action}.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function enrichUpgradeAction(root, action, { dryRun = false } = {}) {
|
|
184
|
+
const wouldWrite = WRITING_UPGRADE_ACTIONS.has(action.action);
|
|
185
|
+
return {
|
|
186
|
+
path: relativeTo(root, action.path),
|
|
187
|
+
kind: action.kind || "file",
|
|
188
|
+
action: action.action,
|
|
189
|
+
managed: action.managed !== false,
|
|
190
|
+
would_write: wouldWrite,
|
|
191
|
+
will_write: wouldWrite && !dryRun,
|
|
192
|
+
destructive: action.action === "overwrite",
|
|
193
|
+
from_version: action.from_version,
|
|
194
|
+
to_version: action.to_version,
|
|
195
|
+
reason: action.reason || upgradeActionReason(action.action, action.kind || "file")
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function summarizeUpgradePlan(actions) {
|
|
200
|
+
const byAction = {};
|
|
201
|
+
for (const action of actions) {
|
|
202
|
+
byAction[action.action] = (byAction[action.action] || 0) + 1;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
total: actions.length,
|
|
206
|
+
by_action: byAction,
|
|
207
|
+
would_write: actions.filter((action) => action.would_write).length,
|
|
208
|
+
will_write: actions.filter((action) => action.will_write).length,
|
|
209
|
+
destructive: actions.filter((action) => action.destructive).length,
|
|
210
|
+
managed: actions.filter((action) => action.managed).length
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildUpgradePlan(root, actions, { dryRun = false, requestedSkill = false } = {}) {
|
|
215
|
+
const enrichedActions = actions.map((action) => enrichUpgradeAction(root, action, { dryRun }));
|
|
216
|
+
return {
|
|
217
|
+
schema_version: "opennori/upgrade-plan-v1",
|
|
218
|
+
root,
|
|
219
|
+
dry_run: dryRun,
|
|
220
|
+
requested_skill: requestedSkill,
|
|
221
|
+
summary: summarizeUpgradePlan(enrichedActions),
|
|
222
|
+
actions: enrichedActions
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function applyUpgradeActions(actions) {
|
|
227
|
+
for (const action of actions) {
|
|
228
|
+
if (WRITING_UPGRADE_ACTIONS.has(action.action) && action.write) action.write();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildContextExport(root, pair) {
|
|
233
|
+
const payload = readJson(pair.evidencePath);
|
|
234
|
+
const contract = payload.contract;
|
|
235
|
+
const ledger = payload.ledger;
|
|
236
|
+
const reportPath = pathsForGoal(root, contract.goal_id).reportPath;
|
|
237
|
+
const recommendation = nextRecommendation(contract, ledger);
|
|
238
|
+
return {
|
|
239
|
+
schema_version: "opennori/context-export-v1",
|
|
240
|
+
exported_at: new Date().toISOString(),
|
|
241
|
+
root,
|
|
242
|
+
goal_id: contract.goal_id,
|
|
243
|
+
goal: contract.goal,
|
|
244
|
+
acceptance_basis: contract.acceptance_basis || { status: "draft" },
|
|
245
|
+
workflow_status: ledger.status,
|
|
246
|
+
current_gap: currentGap(contract, ledger),
|
|
247
|
+
completion: completionAnswer(contract, ledger),
|
|
248
|
+
intervention: intervention(contract, ledger),
|
|
249
|
+
next_recommendation: recommendation,
|
|
250
|
+
criteria: criterionStatusRows(contract, ledger),
|
|
251
|
+
capability_profile: ledger.capability_profile || { items: [], evidence: [] },
|
|
252
|
+
capability_compliance: profileCompliance(ledger),
|
|
253
|
+
paths: {
|
|
254
|
+
acceptance: relativeTo(root, pair.acceptancePath),
|
|
255
|
+
evidence: relativeTo(root, pair.evidencePath),
|
|
256
|
+
report: relativeTo(root, reportPath),
|
|
257
|
+
report_exists: fs.existsSync(reportPath),
|
|
258
|
+
manifest: relativeTo(root, manifestPath(root))
|
|
259
|
+
},
|
|
260
|
+
manifest: safeReadManifest(root)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildUninstallActions(root, { includeState = false } = {}) {
|
|
265
|
+
const actions = SKILL_PACK.map((skill) => plannedDelete(root, `.agents/skills/${skill.name}/SKILL.md`, "skill"));
|
|
266
|
+
|
|
267
|
+
if (includeState) {
|
|
268
|
+
actions.push(plannedDelete(root, ".opennori", "state-directory", {
|
|
269
|
+
recursive: true,
|
|
270
|
+
reason: "Full OpenNori state removal was requested with --include-state."
|
|
271
|
+
}));
|
|
272
|
+
return actions;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
actions.push(
|
|
276
|
+
plannedDelete(root, ".opennori/manifest.json", "manifest"),
|
|
277
|
+
plannedPreserve(root, ".opennori/protocol.md", "protocol", "Protocol is preserved unless --include-state is provided."),
|
|
278
|
+
plannedPreserve(root, ".opennori/active", "active-goals", "Active goals and evidence are preserved unless --include-state is provided."),
|
|
279
|
+
plannedPreserve(root, ".opennori/reports", "reports", "Acceptance reports are preserved unless --include-state is provided."),
|
|
280
|
+
plannedPreserve(root, ".opennori/completed", "completed-archive", "Completed archives are preserved unless --include-state is provided."),
|
|
281
|
+
plannedPreserve(root, ".opennori/blocked", "blocked-archive", "Blocked archives are preserved unless --include-state is provided."),
|
|
282
|
+
plannedPreserve(root, ".opennori/brainstorms", "brainstorms", "Brainstorms are preserved unless --include-state is provided.")
|
|
283
|
+
);
|
|
284
|
+
return actions;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function buildUninstallPlan(root, actions, { dryRun = false, includeState = false } = {}) {
|
|
288
|
+
const enrichedActions = actions.map((action) => enrichUninstallAction(root, action, { dryRun }));
|
|
289
|
+
return {
|
|
290
|
+
schema_version: "opennori/uninstall-plan-v1",
|
|
291
|
+
root,
|
|
292
|
+
dry_run: dryRun,
|
|
293
|
+
include_state: includeState,
|
|
294
|
+
summary: summarizeUninstallPlan(enrichedActions),
|
|
295
|
+
actions: enrichedActions
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function applyUninstallActions(actions) {
|
|
300
|
+
for (const action of actions) {
|
|
301
|
+
if (action.action === "delete") {
|
|
302
|
+
fs.rmSync(action.path, { force: true });
|
|
303
|
+
}
|
|
304
|
+
if (action.action === "delete-tree") {
|
|
305
|
+
fs.rmSync(action.path, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const BRAINSTORM_CANDIDATES = [
|
|
311
|
+
{
|
|
312
|
+
id: "A",
|
|
313
|
+
title: "目标澄清型",
|
|
314
|
+
user_value: "用户能把模糊想法收敛成一个明确目标和少量可观察验收方向。",
|
|
315
|
+
suggested_goal_template: "让用户从模糊想法中选择一个明确、可验收的目标。",
|
|
316
|
+
acceptance_directions: [
|
|
317
|
+
"作为用户,我能在候选方向中看出每个方向解决的用户价值。",
|
|
318
|
+
"作为用户,我能选择一个方向进入 OpenNori draft,或要求改写方向。",
|
|
319
|
+
"作为用户,我能判断候选方向没有要求我阅读技术说明。"
|
|
320
|
+
],
|
|
321
|
+
risks: ["目标仍然太泛,无法生成可验收 AC。"]
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "B",
|
|
325
|
+
title: "方案取舍型",
|
|
326
|
+
user_value: "用户能比较几种产品形态,并选择哪一种进入正式验收。",
|
|
327
|
+
suggested_goal_template: "让用户比较多个可验收产品形态,并选择一个进入执行。",
|
|
328
|
+
acceptance_directions: [
|
|
329
|
+
"作为用户,我能看到每个方向对应的使用入口和判断方式。",
|
|
330
|
+
"作为用户,我能比较方向之间的取舍,而不是阅读实现计划。",
|
|
331
|
+
"作为用户,我能选择一个方向作为正式 OpenNori draft 的来源。"
|
|
332
|
+
],
|
|
333
|
+
risks: ["候选项可能变成技术方案比较,需要退回用户价值和验收方式。"]
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "C",
|
|
337
|
+
title: "风险识别型",
|
|
338
|
+
user_value: "用户能先看见哪些验收点需要强证据、人工确认或外部条件。",
|
|
339
|
+
suggested_goal_template: "让用户识别完成判断中的高风险验收点。",
|
|
340
|
+
acceptance_directions: [
|
|
341
|
+
"作为用户,我能看到哪些方向需要更强证据才能说完成。",
|
|
342
|
+
"作为用户,我能知道哪些风险需要人工确认或外部条件。",
|
|
343
|
+
"作为用户,我能决定先验证风险还是直接进入 draft。"
|
|
344
|
+
],
|
|
345
|
+
risks: ["风险讨论可能扩散成过程计划,需要保持在完成判断和证据强度上。"]
|
|
346
|
+
}
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
const DEFAULT_CRITERIA = [
|
|
350
|
+
{
|
|
351
|
+
id: "AC-1",
|
|
352
|
+
user_story: "作为用户,我使用目标系统完成核心操作后,能判断目标结果是否已经达成。",
|
|
353
|
+
measurement: "用户执行核心操作并查看结果。",
|
|
354
|
+
threshold: "结果能被用户直接判断为达成或未达成;不需要阅读实现说明。",
|
|
355
|
+
risk: "medium"
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: "AC-2",
|
|
359
|
+
user_story: "作为用户,我查看结果状态后,能知道还缺什么或我需要做什么。",
|
|
360
|
+
measurement: "用户查看状态、报告或界面反馈。",
|
|
361
|
+
threshold: "反馈说明当前缺口或人类动作,不把过程步骤当作完成依据。",
|
|
362
|
+
risk: "medium"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: "AC-3",
|
|
366
|
+
user_story: "作为用户,我重新打开项目或会话后,能继续从同一个验收状态推进。",
|
|
367
|
+
measurement: "用户恢复任务并查看当前验收状态。",
|
|
368
|
+
threshold: "恢复信息包含目标、当前状态、当前缺口和可继续的入口。",
|
|
369
|
+
risk: "high"
|
|
370
|
+
}
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
function printJson(payload) {
|
|
374
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function argValue(args, name, fallback = undefined) {
|
|
378
|
+
const index = args.indexOf(name);
|
|
379
|
+
if (index === -1 || index + 1 >= args.length) return fallback;
|
|
380
|
+
return args[index + 1];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function hasFlag(args, name) {
|
|
384
|
+
return args.includes(name);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const TOP_LEVEL_USAGE = "nori <doctor|install|upgrade|uninstall|brainstorm|draft|init|list|check|approve|criterion|profile|resume|next|evidence|evaluate|status|report|context|changes|archive|skill>";
|
|
388
|
+
|
|
389
|
+
function wantsHelp(args) {
|
|
390
|
+
return args.includes("--help") || args.includes("-h");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function usageFor(args) {
|
|
394
|
+
const [command, subcommand] = args;
|
|
395
|
+
if (!command || command === "--help" || command === "-h") return TOP_LEVEL_USAGE;
|
|
396
|
+
if (command === "install") return "nori install --root <project> [--skill] [--dry-run] [--force] [--confirm] [--json]";
|
|
397
|
+
if (command === "upgrade") return "nori upgrade --root <project> [--skill] [--dry-run] [--confirm] [--json]";
|
|
398
|
+
if (command === "uninstall") return "nori uninstall --root <project> [--include-state] [--dry-run] [--confirm] [--json]";
|
|
399
|
+
if (command === "doctor") return "nori doctor --root <project> [--json]";
|
|
400
|
+
if (command === "brainstorm") return "nori brainstorm --idea \"<idea>\" --root <project> [--id <id>] [--json]";
|
|
401
|
+
if (command === "draft") return "nori draft --goal \"<goal>\" --root <project> [--goal-id <id>] [--json]";
|
|
402
|
+
if (command === "init") return "nori init <brief.json> --root <project> [--json]";
|
|
403
|
+
if (command === "criterion" && subcommand === "update") return "nori criterion update --root <project> --criterion <id> --user-story ... --measurement ... --threshold ... [--json]";
|
|
404
|
+
if (command === "profile" && subcommand === "add") return "nori profile add --root <project> --type <skill|stack|constraint> --name <name> --strength <must|prefer|avoid> --purpose <purpose> [--json]";
|
|
405
|
+
if (command === "profile" && subcommand === "evidence") return "nori profile evidence --root <project> --item <item-id> --result <satisfied|violated|waived> --summary <summary> [--json]";
|
|
406
|
+
if (command === "profile") return "nori profile <add|evidence|show|check> --root <project> [--json]";
|
|
407
|
+
if (command === "evidence" && subcommand === "add") return "nori evidence add --root <project> --criterion <id> --kind <kind> --summary <summary> --result <passing|failing|blocked|waived> [--json]";
|
|
408
|
+
if (command === "evidence") return "nori evidence add --root <project> --criterion <id> --kind <kind> --summary <summary> --result <passing|failing|blocked|waived> [--json]";
|
|
409
|
+
if (command === "context" && subcommand === "export") return "nori context export --root <project> [--json]";
|
|
410
|
+
if (command === "context") return "nori context export --root <project> [--json]";
|
|
411
|
+
if (command === "skill" && subcommand === "export") return "nori skill export [--pack] [--json]";
|
|
412
|
+
if (command === "skill") return "nori skill export [--pack] [--json]";
|
|
413
|
+
if (["list", "check", "approve", "resume", "next", "evaluate", "status", "report", "changes", "archive"].includes(command)) {
|
|
414
|
+
return `nori ${command} --root <project> [--goal <goal-id>] [--json]`;
|
|
415
|
+
}
|
|
416
|
+
return TOP_LEVEL_USAGE;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function argValues(args, name) {
|
|
420
|
+
const values = [];
|
|
421
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
422
|
+
if (args[index] === name && index + 1 < args.length) {
|
|
423
|
+
values.push(args[index + 1]);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return values;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function resolveRoot(args) {
|
|
430
|
+
return path.resolve(argValue(args, "--root", process.cwd()));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function relativeTo(root, filePath) {
|
|
434
|
+
return path.relative(root, filePath) || ".";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseEvidenceSource(value) {
|
|
438
|
+
const raw = String(value || "").trim();
|
|
439
|
+
if (!raw) return null;
|
|
440
|
+
if (raw.startsWith("{")) {
|
|
441
|
+
try {
|
|
442
|
+
return JSON.parse(raw);
|
|
443
|
+
} catch {
|
|
444
|
+
return { type: "reference", label: raw };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return { type: "reference", label: raw };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function evidenceSourcesFromArgs(args) {
|
|
451
|
+
const sources = argValues(args, "--source").map((source) => parseEvidenceSource(source)).filter(Boolean);
|
|
452
|
+
for (const command of argValues(args, "--source-command")) {
|
|
453
|
+
sources.push({ type: "command", label: command, command });
|
|
454
|
+
}
|
|
455
|
+
for (const sourcePath of argValues(args, "--source-path")) {
|
|
456
|
+
sources.push({ type: "artifact", label: sourcePath, path: sourcePath });
|
|
457
|
+
}
|
|
458
|
+
for (const url of argValues(args, "--source-url")) {
|
|
459
|
+
sources.push({ type: "url", label: url, url });
|
|
460
|
+
}
|
|
461
|
+
return sources;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const SKILL_PACK = [
|
|
465
|
+
{
|
|
466
|
+
name: "nori",
|
|
467
|
+
description: "Route OpenNori work through user-centered acceptance criteria, evidence, project health, and reporting Skills.",
|
|
468
|
+
body: [
|
|
469
|
+
"## When to use",
|
|
470
|
+
"Use when the user mentions OpenNori, asks to use OpenNori for a task, continue OpenNori, check completion, inspect project health, define acceptance criteria, record evidence, manage capability preferences, or produce an OpenNori report.",
|
|
471
|
+
"",
|
|
472
|
+
"## Route",
|
|
473
|
+
"- Goal, brainstorm, approval, or AC revision -> use `nori-acceptance`.",
|
|
474
|
+
"- Verification, evidence sufficiency, human confirmation, waiver, or why an AC is passing -> use `nori-evidence`.",
|
|
475
|
+
"- Required Skills, preferred stacks, avoided tools, or install policy -> use `nori-capability-profile`.",
|
|
476
|
+
"- Install, uninstall, doctor, manifest, Skill sync, or project recoverability -> use `nori-project-health`.",
|
|
477
|
+
"- Status, report, current gap, completion answer, user intervention, or change summary -> use `nori-reporting`.",
|
|
478
|
+
"",
|
|
479
|
+
"## Baseline",
|
|
480
|
+
"At the start of each OpenNori turn, run `nori resume --root <repo> --json` or `nori status --root <repo> --json` unless the task is only install/doctor/uninstall.",
|
|
481
|
+
"Use `next_recommendation` and top-level `next_actions` to continue the OpenNori loop; do not make the user repeatedly ask what the next step is.",
|
|
482
|
+
"If `nori` is not on PATH, use the installed package binary such as `node ./node_modules/opennori/bin/nori.js` or this repository's `node ./bin/nori.js` with the same arguments.",
|
|
483
|
+
"",
|
|
484
|
+
"## Rule",
|
|
485
|
+
"Progress is determined by acceptance evidence, not implementation steps.",
|
|
486
|
+
"Do not make the user remember CLI syntax or internal Skill names.",
|
|
487
|
+
"Do not answer complete while the acceptance basis is draft or required AC/profile evidence is missing."
|
|
488
|
+
]
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: "nori-acceptance",
|
|
492
|
+
description: "Create, review, approve, and revise OpenNori human-centered acceptance criteria from natural language goals.",
|
|
493
|
+
body: [
|
|
494
|
+
"## When to use",
|
|
495
|
+
"Use when the user gives a goal, wants to brainstorm acceptance directions, approves criteria, revises completion criteria, or says the AC is wrong.",
|
|
496
|
+
"",
|
|
497
|
+
"## Commands",
|
|
498
|
+
"- Fuzzy idea or discussion: `nori brainstorm --idea \"<idea>\" --root <repo> --json`.",
|
|
499
|
+
"- Start from a goal: `nori draft --goal \"<goal>\" --root <repo> --json`.",
|
|
500
|
+
"- Start from a chosen brainstorm candidate: `nori draft --from-brainstorm <brainstorm-id> --candidate <A|B|C> --root <repo> --json`.",
|
|
501
|
+
"- User approves criteria: `nori approve --root <repo> --summary \"<approval>\" --json`.",
|
|
502
|
+
"- User revises a criterion: `nori criterion update --root <repo> --criterion <id> --user-story ... --measurement ... --threshold ... --json`.",
|
|
503
|
+
"",
|
|
504
|
+
"## Rules",
|
|
505
|
+
"ACs must describe user actions or judgments, not implementation files, commands, modules, fields, tests, Skills, or technology choices.",
|
|
506
|
+
"Capability preferences belong in the Nori Profile, not user ACs.",
|
|
507
|
+
"Do not treat brainstorm output as a Nori Contract or completion evidence."
|
|
508
|
+
]
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: "nori-evidence",
|
|
512
|
+
description: "Record and judge OpenNori evidence while preserving agent freedom to choose verification methods.",
|
|
513
|
+
body: [
|
|
514
|
+
"## When to use",
|
|
515
|
+
"Use when the user asks to record validation as evidence, asks why an AC is passing, asks whether evidence is enough, confirms or waives an AC, or wants a verification attached to OpenNori.",
|
|
516
|
+
"",
|
|
517
|
+
"## Evidence Protocol",
|
|
518
|
+
"The agent may choose any useful verification method: tests, diff, screenshots, browser checks, logs, artifacts, URLs, AW doctor, human confirmation, or another reviewable signal.",
|
|
519
|
+
"When submitting evidence, explain basis, sources, reviewability, confidence, and limitations.",
|
|
520
|
+
"",
|
|
521
|
+
"## Command",
|
|
522
|
+
"`nori evidence add --root <repo> --criterion <id> --kind <kind> --summary \"...\" --result <passing|failing|blocked|waived> --basis <basis> --source '<json-or-label>' --source-command '<command>' --source-path '<path>' --source-url '<url>' --reviewability \"...\" --limitations \"...\" --json`",
|
|
523
|
+
"",
|
|
524
|
+
"Use multiple source flags when one AC is supported by several signals; prefer typed `--source-command`, `--source-path`, or `--source-url` when they fit, and use raw `--source` for anything else.",
|
|
525
|
+
"For high-risk passing evidence, use a strong evidence kind or explicit strong confidence only when justified.",
|
|
526
|
+
"Do not force evidence into a fixed adapter taxonomy."
|
|
527
|
+
]
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: "nori-capability-profile",
|
|
531
|
+
description: "Record and report OpenNori execution preferences such as required Skills, preferred stacks, avoided tools, and install policy.",
|
|
532
|
+
body: [
|
|
533
|
+
"## When to use",
|
|
534
|
+
"Use when the user says a task must use a Skill, prefers a technology stack, wants to avoid a tool/library, or requires asking before installs.",
|
|
535
|
+
"",
|
|
536
|
+
"## Commands",
|
|
537
|
+
"- Add preference: `nori profile add --root <repo> --type <skill|stack|constraint> --name \"<name>\" --strength <must|prefer|avoid> --purpose \"<why>\" --install-policy <existing_only|ask_before_install|allowed> --json`.",
|
|
538
|
+
"- Add compliance evidence: `nori profile evidence --root <repo> --item <item-id> --result <satisfied|violated|waived> --summary \"<evidence>\" --json`.",
|
|
539
|
+
"- Show profile: `nori profile show --root <repo> --json`.",
|
|
540
|
+
"",
|
|
541
|
+
"## Rules",
|
|
542
|
+
"Do not turn Skills or stack preferences into user ACs.",
|
|
543
|
+
"`must` and violated `avoid` items block completion unless satisfied or waived.",
|
|
544
|
+
"`prefer` should be reported but should not block completion by itself."
|
|
545
|
+
]
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: "nori-project-health",
|
|
549
|
+
description: "Install, uninstall, diagnose, and recover project-local OpenNori assets, manifest, and Skill Pack sync.",
|
|
550
|
+
body: [
|
|
551
|
+
"## When to use",
|
|
552
|
+
"Use when the user asks to install OpenNori, uninstall OpenNori, check whether OpenNori is ready, diagnose broken OpenNori state, inspect manifest, or sync project Skills.",
|
|
553
|
+
"",
|
|
554
|
+
"## Commands",
|
|
555
|
+
"- Preview install: `nori install --root <repo> --dry-run --json`.",
|
|
556
|
+
"- Install Skill Pack: `nori install --root <repo> --skill --json`.",
|
|
557
|
+
"- Preview destructive install: `nori install --root <repo> --skill --force --dry-run --json`.",
|
|
558
|
+
"- Confirm destructive install: `nori install --root <repo> --skill --force --confirm --json`.",
|
|
559
|
+
"- Doctor: `nori doctor --root <repo> --json`.",
|
|
560
|
+
"- Preview uninstall: `nori uninstall --root <repo> --dry-run --json`.",
|
|
561
|
+
"- Remove entry assets while preserving state: `nori uninstall --root <repo> --confirm --json`.",
|
|
562
|
+
"- Remove all OpenNori state only after explicit user acceptance: `nori uninstall --root <repo> --include-state --confirm --json`.",
|
|
563
|
+
"",
|
|
564
|
+
"## Rules",
|
|
565
|
+
"Always show dry-run plans before destructive writes.",
|
|
566
|
+
"Default uninstall preserves active goals, evidence, reports, archives, and brainstorms."
|
|
567
|
+
]
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
name: "nori-reporting",
|
|
571
|
+
description: "Summarize OpenNori status, reports, current gaps, user intervention, and acceptance evidence for humans.",
|
|
572
|
+
body: [
|
|
573
|
+
"## When to use",
|
|
574
|
+
"Use when the user asks whether work is complete, what remains, what they need to do, what changed, or asks for an OpenNori report.",
|
|
575
|
+
"",
|
|
576
|
+
"## Commands",
|
|
577
|
+
"- Resume: `nori resume --root <repo> --json`.",
|
|
578
|
+
"- Next gap: `nori next --root <repo> --json`.",
|
|
579
|
+
"- Status: `nori status --root <repo> --json`.",
|
|
580
|
+
"- Report: `nori report --root <repo> --json`.",
|
|
581
|
+
"- Changes: `nori changes --root <repo> --json`.",
|
|
582
|
+
"- List goals: `nori list --root <repo> --json`.",
|
|
583
|
+
"",
|
|
584
|
+
"## Rules",
|
|
585
|
+
"Lead with completion state, current gap, evidence basis, and required human intervention.",
|
|
586
|
+
"After reporting, follow `next_recommendation` / `next_actions` when the user has asked to continue, instead of asking the user what the next step is.",
|
|
587
|
+
"Summarize implementation details only as supporting evidence.",
|
|
588
|
+
"Never report complete unless all required ACs and blocking Nori Profile items are passing or waived."
|
|
589
|
+
]
|
|
590
|
+
}
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
function skillMarkdown(skill) {
|
|
594
|
+
return [
|
|
595
|
+
"---",
|
|
596
|
+
`name: ${skill.name}`,
|
|
597
|
+
`description: ${skill.description}`,
|
|
598
|
+
"---",
|
|
599
|
+
"",
|
|
600
|
+
...skill.body,
|
|
601
|
+
""
|
|
602
|
+
].join("\n");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function exportedSkillMarkdown() {
|
|
606
|
+
return skillMarkdown(SKILL_PACK[0]);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function skillPackMarkdowns() {
|
|
610
|
+
return Object.fromEntries(SKILL_PACK.map((skill) => [skill.name, skillMarkdown(skill)]));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function skillPackPath(root, skillName) {
|
|
614
|
+
return path.join(root, ".agents", "skills", skillName, "SKILL.md");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function skillPackInstallActions(root, { dryRun = false, force = false } = {}) {
|
|
618
|
+
const markdowns = skillPackMarkdowns();
|
|
619
|
+
return SKILL_PACK.map((skill) => writeIfSafe(
|
|
620
|
+
skillPackPath(root, skill.name),
|
|
621
|
+
markdowns[skill.name],
|
|
622
|
+
{ dryRun, force, kind: "skill" }
|
|
623
|
+
));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function upgradeActions(root, { requestedSkill = false } = {}) {
|
|
627
|
+
const existingManifest = safeReadManifest(root);
|
|
628
|
+
const actions = [];
|
|
629
|
+
const protocolPath = path.join(root, ".opennori", "protocol.md");
|
|
630
|
+
const protocolContent = protocolTemplate();
|
|
631
|
+
|
|
632
|
+
if (existingManifest) {
|
|
633
|
+
actions.push({
|
|
634
|
+
path: manifestPath(root),
|
|
635
|
+
action: existingManifest.opennori_version === PACKAGE_JSON.version && sameStringSet(existingManifest.capabilities, NORI_CAPABILITIES)
|
|
636
|
+
? "current"
|
|
637
|
+
: "update",
|
|
638
|
+
kind: "manifest",
|
|
639
|
+
managed: true,
|
|
640
|
+
from_version: existingManifest.opennori_version,
|
|
641
|
+
to_version: PACKAGE_JSON.version,
|
|
642
|
+
write: () => writeManifest(root)
|
|
643
|
+
});
|
|
644
|
+
} else {
|
|
645
|
+
actions.push({
|
|
646
|
+
path: manifestPath(root),
|
|
647
|
+
action: "missing",
|
|
648
|
+
kind: "manifest",
|
|
649
|
+
managed: true,
|
|
650
|
+
to_version: PACKAGE_JSON.version
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (fs.existsSync(protocolPath)) {
|
|
655
|
+
const currentHash = fileHash(protocolPath);
|
|
656
|
+
const expectedHash = createHash("sha256").update(protocolContent).digest("hex");
|
|
657
|
+
actions.push({
|
|
658
|
+
path: protocolPath,
|
|
659
|
+
action: currentHash === expectedHash ? "current" : "overwrite",
|
|
660
|
+
kind: "protocol",
|
|
661
|
+
managed: true,
|
|
662
|
+
write: () => {
|
|
663
|
+
fs.mkdirSync(path.dirname(protocolPath), { recursive: true });
|
|
664
|
+
fs.writeFileSync(protocolPath, protocolContent);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
} else {
|
|
668
|
+
actions.push({
|
|
669
|
+
path: protocolPath,
|
|
670
|
+
action: "missing",
|
|
671
|
+
kind: "protocol",
|
|
672
|
+
managed: true
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (requestedSkill) {
|
|
677
|
+
const markdowns = skillPackMarkdowns();
|
|
678
|
+
for (const skill of SKILL_PACK) {
|
|
679
|
+
const target = skillPackPath(root, skill.name);
|
|
680
|
+
if (!fs.existsSync(target)) {
|
|
681
|
+
actions.push({ path: target, action: "missing", kind: "skill", managed: true });
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const expectedHash = createHash("sha256").update(markdowns[skill.name]).digest("hex");
|
|
685
|
+
const currentHash = fileHash(target);
|
|
686
|
+
actions.push({
|
|
687
|
+
path: target,
|
|
688
|
+
action: currentHash === expectedHash ? "current" : "overwrite",
|
|
689
|
+
kind: "skill",
|
|
690
|
+
managed: true,
|
|
691
|
+
write: () => {
|
|
692
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
693
|
+
fs.writeFileSync(target, markdowns[skill.name]);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const manifestAction = actions.find((action) => action.kind === "manifest");
|
|
700
|
+
const refreshesManagedAssets = actions.some((action) => action.kind !== "manifest" && WRITING_UPGRADE_ACTIONS.has(action.action));
|
|
701
|
+
if (manifestAction && manifestAction.action === "current" && refreshesManagedAssets) {
|
|
702
|
+
manifestAction.action = "update";
|
|
703
|
+
manifestAction.reason = "OpenNori manifest will be refreshed after managed assets are upgraded.";
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return actions;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function writeIfSafe(filePath, content, { dryRun = false, force = false, kind = "file", managed = true } = {}) {
|
|
710
|
+
const exists = fs.existsSync(filePath);
|
|
711
|
+
const action = exists ? (force ? "overwrite" : "skip") : "create";
|
|
712
|
+
if (!dryRun && (!exists || force)) {
|
|
713
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
714
|
+
fs.writeFileSync(filePath, content);
|
|
715
|
+
}
|
|
716
|
+
return { path: filePath, action, kind, managed };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function ensureDir(dirPath, { dryRun = false, kind = "directory", managed = true } = {}) {
|
|
720
|
+
const exists = fs.existsSync(dirPath);
|
|
721
|
+
if (!dryRun && !exists) {
|
|
722
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
723
|
+
}
|
|
724
|
+
return { path: dirPath, action: exists ? "exists" : "create", kind, managed };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function protocolTemplate() {
|
|
728
|
+
const source = path.resolve(import.meta.dirname, "..", ".opennori", "protocol.md");
|
|
729
|
+
if (fs.existsSync(source)) return fs.readFileSync(source, "utf8");
|
|
730
|
+
return [
|
|
731
|
+
"# OpenNori Protocol",
|
|
732
|
+
"",
|
|
733
|
+
"Progress is determined by human-centered acceptance evidence, not by implementation steps.",
|
|
734
|
+
"",
|
|
735
|
+
"Use `nori init`, `nori resume`, `nori next`, `nori evidence add`, `nori evaluate`, `nori status`, and `nori report`.",
|
|
736
|
+
""
|
|
737
|
+
].join("\n");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function manifestPath(root) {
|
|
741
|
+
return path.join(root, ".opennori", "manifest.json");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function fileHash(filePath) {
|
|
745
|
+
if (!fs.existsSync(filePath)) return null;
|
|
746
|
+
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function projectSkillState(root) {
|
|
750
|
+
const skillPath = skillPackPath(root, "nori");
|
|
751
|
+
const exists = fs.existsSync(skillPath);
|
|
752
|
+
const expectedHash = createHash("sha256").update(exportedSkillMarkdown()).digest("hex");
|
|
753
|
+
const actualHash = fileHash(skillPath);
|
|
754
|
+
return {
|
|
755
|
+
installed: exists,
|
|
756
|
+
path: relativeTo(root, skillPath),
|
|
757
|
+
in_sync: exists ? actualHash === expectedHash : false,
|
|
758
|
+
expected_sha256: expectedHash,
|
|
759
|
+
actual_sha256: actualHash
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function projectSkillPackState(root) {
|
|
764
|
+
const markdowns = skillPackMarkdowns();
|
|
765
|
+
const skills = SKILL_PACK.map((skill) => {
|
|
766
|
+
const skillPath = skillPackPath(root, skill.name);
|
|
767
|
+
const exists = fs.existsSync(skillPath);
|
|
768
|
+
const expectedHash = createHash("sha256").update(markdowns[skill.name]).digest("hex");
|
|
769
|
+
const actualHash = fileHash(skillPath);
|
|
770
|
+
return {
|
|
771
|
+
name: skill.name,
|
|
772
|
+
path: relativeTo(root, skillPath),
|
|
773
|
+
installed: exists,
|
|
774
|
+
in_sync: exists ? actualHash === expectedHash : false,
|
|
775
|
+
expected_sha256: expectedHash,
|
|
776
|
+
actual_sha256: actualHash
|
|
777
|
+
};
|
|
778
|
+
});
|
|
779
|
+
return {
|
|
780
|
+
schema_version: "opennori/skill-pack-v1",
|
|
781
|
+
installed: skills.every((skill) => skill.installed),
|
|
782
|
+
in_sync: skills.every((skill) => skill.installed && skill.in_sync),
|
|
783
|
+
count: skills.length,
|
|
784
|
+
skills
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function skillSearchPaths(name) {
|
|
789
|
+
const home = process.env.HOME || "";
|
|
790
|
+
return [
|
|
791
|
+
path.join(home, ".agents", "skills", name, "SKILL.md"),
|
|
792
|
+
path.join(home, ".codex", "skills", name, "SKILL.md")
|
|
793
|
+
].filter(Boolean);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function stackIsPresent(root, name) {
|
|
797
|
+
const packageJsonPath = path.join(root, "package.json");
|
|
798
|
+
if (!fs.existsSync(packageJsonPath)) return null;
|
|
799
|
+
try {
|
|
800
|
+
const packageJson = readJson(packageJsonPath);
|
|
801
|
+
const dependencySets = [
|
|
802
|
+
packageJson.dependencies,
|
|
803
|
+
packageJson.devDependencies,
|
|
804
|
+
packageJson.peerDependencies,
|
|
805
|
+
packageJson.optionalDependencies
|
|
806
|
+
].filter(Boolean);
|
|
807
|
+
return dependencySets.some((dependencies) => Object.prototype.hasOwnProperty.call(dependencies, name));
|
|
808
|
+
} catch {
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function autoProfileChecks(root, ledger) {
|
|
814
|
+
const items = ledger.capability_profile?.items || [];
|
|
815
|
+
return items.map((item) => {
|
|
816
|
+
if (item.type === "skill") {
|
|
817
|
+
const paths = skillSearchPaths(item.name);
|
|
818
|
+
const foundPath = paths.find((candidate) => fs.existsSync(candidate));
|
|
819
|
+
const result = foundPath ? (item.strength === "avoid" ? "violated" : "satisfied") : (item.strength === "avoid" ? "satisfied" : "unknown");
|
|
820
|
+
return {
|
|
821
|
+
item_id: item.id,
|
|
822
|
+
type: item.type,
|
|
823
|
+
name: item.name,
|
|
824
|
+
strength: item.strength,
|
|
825
|
+
result,
|
|
826
|
+
basis: "local-skill-path",
|
|
827
|
+
summary: foundPath
|
|
828
|
+
? `Skill ${item.name} is available at ${foundPath}.`
|
|
829
|
+
: `Skill ${item.name} was not found in the standard local Skill paths.`,
|
|
830
|
+
sources: paths.map((candidate) => ({ type: "artifact", label: candidate, path: candidate, exists: fs.existsSync(candidate) })),
|
|
831
|
+
can_auto_record: result !== "unknown"
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (item.type === "stack") {
|
|
836
|
+
const present = stackIsPresent(root, item.name);
|
|
837
|
+
if (present === true) {
|
|
838
|
+
return {
|
|
839
|
+
item_id: item.id,
|
|
840
|
+
type: item.type,
|
|
841
|
+
name: item.name,
|
|
842
|
+
strength: item.strength,
|
|
843
|
+
result: item.strength === "avoid" ? "violated" : "satisfied",
|
|
844
|
+
basis: "package-json",
|
|
845
|
+
summary: `Stack ${item.name} is present in package.json dependencies.`,
|
|
846
|
+
sources: [{ type: "artifact", label: "package.json", path: path.join(root, "package.json") }],
|
|
847
|
+
can_auto_record: true
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
if (present === false) {
|
|
851
|
+
return {
|
|
852
|
+
item_id: item.id,
|
|
853
|
+
type: item.type,
|
|
854
|
+
name: item.name,
|
|
855
|
+
strength: item.strength,
|
|
856
|
+
result: item.strength === "avoid" ? "satisfied" : "unknown",
|
|
857
|
+
basis: "package-json",
|
|
858
|
+
summary: `Stack ${item.name} is not present in package.json dependencies.`,
|
|
859
|
+
sources: [{ type: "artifact", label: "package.json", path: path.join(root, "package.json") }],
|
|
860
|
+
can_auto_record: item.strength === "avoid"
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
item_id: item.id,
|
|
865
|
+
type: item.type,
|
|
866
|
+
name: item.name,
|
|
867
|
+
strength: item.strength,
|
|
868
|
+
result: "unknown",
|
|
869
|
+
basis: "package-json-unavailable",
|
|
870
|
+
summary: "No readable package.json was available for automatic stack checks.",
|
|
871
|
+
sources: [],
|
|
872
|
+
can_auto_record: false
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return {
|
|
877
|
+
item_id: item.id,
|
|
878
|
+
type: item.type,
|
|
879
|
+
name: item.name,
|
|
880
|
+
strength: item.strength,
|
|
881
|
+
result: "unknown",
|
|
882
|
+
basis: "agent-or-human-review-required",
|
|
883
|
+
summary: "Constraint items require agent evidence, human confirmation, or waiver.",
|
|
884
|
+
sources: [],
|
|
885
|
+
can_auto_record: false
|
|
886
|
+
};
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function recordAutoProfileChecks(ledger, checks) {
|
|
891
|
+
for (const check of checks.filter((entry) => entry.can_auto_record)) {
|
|
892
|
+
const item = ledger.capability_profile?.items?.find((entry) => entry.id === check.item_id);
|
|
893
|
+
const latest = item?.evidence?.at(-1);
|
|
894
|
+
if (latest?.result === check.result && latest?.summary === check.summary) continue;
|
|
895
|
+
addProfileEvidence(ledger, check.item_id, {
|
|
896
|
+
result: check.result,
|
|
897
|
+
summary: check.summary,
|
|
898
|
+
path: check.sources?.[0]?.path
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
return ledger;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function activeGoalSummaries(root) {
|
|
905
|
+
return findActivePairs(root).map((pair) => {
|
|
906
|
+
try {
|
|
907
|
+
const payload = readJson(pair.evidencePath);
|
|
908
|
+
return {
|
|
909
|
+
goal_id: pair.goalId,
|
|
910
|
+
status: payload.ledger?.status || "unknown",
|
|
911
|
+
current_gap: currentGap(payload.contract, payload.ledger),
|
|
912
|
+
acceptance_path: relativeTo(root, pair.acceptancePath),
|
|
913
|
+
evidence_path: relativeTo(root, pair.evidencePath),
|
|
914
|
+
recoverable: true
|
|
915
|
+
};
|
|
916
|
+
} catch (error) {
|
|
917
|
+
return {
|
|
918
|
+
goal_id: pair.goalId,
|
|
919
|
+
status: "unreadable",
|
|
920
|
+
current_gap: null,
|
|
921
|
+
acceptance_path: relativeTo(root, pair.acceptancePath),
|
|
922
|
+
evidence_path: relativeTo(root, pair.evidencePath),
|
|
923
|
+
recoverable: false,
|
|
924
|
+
error: error.message
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function managedFiles(root, skill = projectSkillState(root), { assumeManifestExists = false } = {}) {
|
|
931
|
+
const entries = [
|
|
932
|
+
{ path: ".opennori/manifest.json", kind: "manifest", required: true },
|
|
933
|
+
{ path: ".opennori/protocol.md", kind: "protocol", required: true },
|
|
934
|
+
...REQUIRED_NORI_DIRS.map((dir) => ({ path: `.opennori/${dir}`, kind: "directory", required: true }))
|
|
935
|
+
];
|
|
936
|
+
for (const packSkill of projectSkillPackState(root).skills.filter((entry) => entry.installed)) {
|
|
937
|
+
entries.push({ path: packSkill.path, kind: "skill", required: false });
|
|
938
|
+
}
|
|
939
|
+
return entries.map((entry) => ({
|
|
940
|
+
...entry,
|
|
941
|
+
exists: entry.path === ".opennori/manifest.json" && assumeManifestExists
|
|
942
|
+
? true
|
|
943
|
+
: fs.existsSync(path.join(root, entry.path))
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function safeReadManifest(root) {
|
|
948
|
+
try {
|
|
949
|
+
return readJson(manifestPath(root));
|
|
950
|
+
} catch {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function buildManifest(root, options = {}) {
|
|
956
|
+
const existing = safeReadManifest(root);
|
|
957
|
+
const skill = projectSkillState(root);
|
|
958
|
+
const skillPack = projectSkillPackState(root);
|
|
959
|
+
const now = new Date().toISOString();
|
|
960
|
+
return {
|
|
961
|
+
schema_version: MANIFEST_SCHEMA_VERSION,
|
|
962
|
+
protocol_version: PROTOCOL_VERSION,
|
|
963
|
+
opennori_version: PACKAGE_JSON.version,
|
|
964
|
+
created_at: existing?.created_at || now,
|
|
965
|
+
updated_at: now,
|
|
966
|
+
capabilities: NORI_CAPABILITIES,
|
|
967
|
+
managed_files: managedFiles(root, skill, options),
|
|
968
|
+
active_goals: activeGoalSummaries(root),
|
|
969
|
+
skill,
|
|
970
|
+
skill_pack: skillPack
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function writeManifest(root, { dryRun = false } = {}) {
|
|
975
|
+
const target = manifestPath(root);
|
|
976
|
+
const exists = fs.existsSync(target);
|
|
977
|
+
const manifest = buildManifest(root, { assumeManifestExists: !dryRun || exists });
|
|
978
|
+
if (!dryRun) {
|
|
979
|
+
writeJson(target, manifest);
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
path: target,
|
|
983
|
+
action: exists ? "update" : "create",
|
|
984
|
+
kind: "manifest",
|
|
985
|
+
managed: true,
|
|
986
|
+
manifest
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function inspectActiveGoals(root) {
|
|
991
|
+
const activeDir = path.join(root, ".opennori", "active");
|
|
992
|
+
const details = [];
|
|
993
|
+
const issues = [];
|
|
994
|
+
if (!fs.existsSync(activeDir)) return { details, issues };
|
|
995
|
+
|
|
996
|
+
const files = fs.readdirSync(activeDir);
|
|
997
|
+
const evidenceFiles = files.filter((fileName) => fileName.endsWith(".evidence.json"));
|
|
998
|
+
const acceptanceFiles = files.filter((fileName) => fileName.endsWith(".acceptance.md"));
|
|
999
|
+
const evidenceGoalIds = new Set(evidenceFiles.map((fileName) => fileName.replace(/\.evidence\.json$/, "")));
|
|
1000
|
+
|
|
1001
|
+
for (const fileName of acceptanceFiles) {
|
|
1002
|
+
const goalId = fileName.replace(/\.acceptance\.md$/, "");
|
|
1003
|
+
if (!evidenceGoalIds.has(goalId)) {
|
|
1004
|
+
issues.push({ goal_id: goalId, message: "Acceptance contract has no matching evidence record." });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
for (const fileName of evidenceFiles) {
|
|
1009
|
+
const goalId = fileName.replace(/\.evidence\.json$/, "");
|
|
1010
|
+
const acceptancePath = path.join(activeDir, `${goalId}.acceptance.md`);
|
|
1011
|
+
const evidencePath = path.join(activeDir, fileName);
|
|
1012
|
+
if (!fs.existsSync(acceptancePath)) {
|
|
1013
|
+
issues.push({ goal_id: goalId, message: "Evidence ledger has no matching Nori Contract." });
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const payload = readJson(evidencePath);
|
|
1018
|
+
const validationIssues = validateContract(payload.contract, payload.ledger);
|
|
1019
|
+
details.push({
|
|
1020
|
+
goal_id: goalId,
|
|
1021
|
+
status: payload.ledger?.status || "unknown",
|
|
1022
|
+
current_gap: currentGap(payload.contract, payload.ledger),
|
|
1023
|
+
acceptance_path: relativeTo(root, acceptancePath),
|
|
1024
|
+
evidence_path: relativeTo(root, evidencePath),
|
|
1025
|
+
recoverable: validationIssues.length === 0
|
|
1026
|
+
});
|
|
1027
|
+
for (const issue of validationIssues) {
|
|
1028
|
+
issues.push({ goal_id: goalId, message: issue.message, path: issue.path });
|
|
1029
|
+
}
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
issues.push({ goal_id: goalId, message: error.message });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return { details, issues };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function doctorCheck(name, condition, summary, recovery = undefined, severity = "needs-action") {
|
|
1039
|
+
const check = { name, ok: Boolean(condition), summary };
|
|
1040
|
+
if (!condition && recovery) check.recovery = recovery;
|
|
1041
|
+
if (!condition) check.severity = severity;
|
|
1042
|
+
return check;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function doctorRecoveryActions(checks, activeIssues = []) {
|
|
1046
|
+
const actions = checks
|
|
1047
|
+
.filter((check) => !check.ok && check.recovery)
|
|
1048
|
+
.map((check) => ({
|
|
1049
|
+
check: check.name,
|
|
1050
|
+
severity: check.severity || "needs-action",
|
|
1051
|
+
action: check.recovery
|
|
1052
|
+
}));
|
|
1053
|
+
|
|
1054
|
+
for (const issue of activeIssues) {
|
|
1055
|
+
actions.push({
|
|
1056
|
+
check: "active_goal_issue",
|
|
1057
|
+
severity: "broken",
|
|
1058
|
+
goal_id: issue.goal_id,
|
|
1059
|
+
path: issue.path,
|
|
1060
|
+
action: issue.path
|
|
1061
|
+
? `Inspect .opennori/active/${issue.goal_id}.evidence.json and fix ${issue.path}: ${issue.message}`
|
|
1062
|
+
: `Inspect .opennori/active/${issue.goal_id}.acceptance.md and .opennori/active/${issue.goal_id}.evidence.json: ${issue.message}`
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return actions;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function doctor(root) {
|
|
1070
|
+
const checks = [];
|
|
1071
|
+
const noriDir = path.join(root, ".opennori");
|
|
1072
|
+
const protocolPath = path.join(root, ".opennori", "protocol.md");
|
|
1073
|
+
const manifestFile = manifestPath(root);
|
|
1074
|
+
const active = inspectActiveGoals(root);
|
|
1075
|
+
|
|
1076
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
1077
|
+
checks.push(doctorCheck(
|
|
1078
|
+
"node_runtime",
|
|
1079
|
+
nodeMajor >= 20,
|
|
1080
|
+
`Node runtime is ${process.version}.`,
|
|
1081
|
+
"Use Node.js 20 or newer."
|
|
1082
|
+
));
|
|
1083
|
+
checks.push(doctorCheck(
|
|
1084
|
+
"opennori_directory",
|
|
1085
|
+
fs.existsSync(noriDir),
|
|
1086
|
+
fs.existsSync(noriDir) ? ".opennori directory exists." : ".opennori directory is missing.",
|
|
1087
|
+
"Run nori install --root <project> --json."
|
|
1088
|
+
));
|
|
1089
|
+
|
|
1090
|
+
for (const dir of REQUIRED_NORI_DIRS) {
|
|
1091
|
+
const dirPath = path.join(noriDir, dir);
|
|
1092
|
+
checks.push(doctorCheck(
|
|
1093
|
+
`dir_${dir}`,
|
|
1094
|
+
fs.existsSync(dirPath),
|
|
1095
|
+
fs.existsSync(dirPath) ? `.opennori/${dir} exists.` : `.opennori/${dir} is missing.`,
|
|
1096
|
+
"Run nori install --root <project> --json."
|
|
1097
|
+
));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
checks.push(doctorCheck(
|
|
1101
|
+
"protocol_file",
|
|
1102
|
+
fs.existsSync(protocolPath),
|
|
1103
|
+
fs.existsSync(protocolPath) ? ".opennori/protocol.md exists." : ".opennori/protocol.md is missing.",
|
|
1104
|
+
"Run nori install --root <project> --json."
|
|
1105
|
+
));
|
|
1106
|
+
|
|
1107
|
+
let manifest = null;
|
|
1108
|
+
let manifestReadable = false;
|
|
1109
|
+
try {
|
|
1110
|
+
manifest = readJson(manifestFile);
|
|
1111
|
+
manifestReadable = true;
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
checks.push(doctorCheck(
|
|
1114
|
+
"manifest_file",
|
|
1115
|
+
false,
|
|
1116
|
+
fs.existsSync(manifestFile) ? `.opennori/manifest.json is unreadable: ${error.message}` : ".opennori/manifest.json is missing.",
|
|
1117
|
+
"Run nori install --root <project> --json to create or refresh the OpenNori manifest.",
|
|
1118
|
+
fs.existsSync(manifestFile) ? "broken" : "needs-action"
|
|
1119
|
+
));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (manifestReadable) {
|
|
1123
|
+
checks.push(doctorCheck(
|
|
1124
|
+
"manifest_file",
|
|
1125
|
+
manifest.schema_version === MANIFEST_SCHEMA_VERSION,
|
|
1126
|
+
`.opennori/manifest.json uses schema ${manifest.schema_version || "<missing>"}.`,
|
|
1127
|
+
"Refresh the manifest with nori install --root <project> --json.",
|
|
1128
|
+
"broken"
|
|
1129
|
+
));
|
|
1130
|
+
checks.push(doctorCheck(
|
|
1131
|
+
"manifest_protocol",
|
|
1132
|
+
manifest.protocol_version === PROTOCOL_VERSION,
|
|
1133
|
+
`.opennori/manifest.json records protocol ${manifest.protocol_version || "<missing>"}.`,
|
|
1134
|
+
"Refresh the manifest with nori install --root <project> --json.",
|
|
1135
|
+
"broken"
|
|
1136
|
+
));
|
|
1137
|
+
checks.push(doctorCheck(
|
|
1138
|
+
"manifest_cli_version",
|
|
1139
|
+
manifest.opennori_version === PACKAGE_JSON.version,
|
|
1140
|
+
`.opennori/manifest.json records OpenNori version ${manifest.opennori_version || "<missing>"}.`,
|
|
1141
|
+
"Refresh the manifest with nori install --root <project> --json."
|
|
1142
|
+
));
|
|
1143
|
+
checks.push(doctorCheck(
|
|
1144
|
+
"manifest_capabilities",
|
|
1145
|
+
sameStringSet(manifest.capabilities, NORI_CAPABILITIES),
|
|
1146
|
+
Array.isArray(manifest.capabilities) ? "Manifest protocol capabilities are readable." : "Manifest protocol capabilities are missing.",
|
|
1147
|
+
"Refresh the manifest with nori install --root <project> --json."
|
|
1148
|
+
));
|
|
1149
|
+
|
|
1150
|
+
const currentGoals = new Set(active.details.filter((goal) => goal.recoverable).map((goal) => goal.goal_id));
|
|
1151
|
+
const manifestGoals = new Set((manifest.active_goals || []).map((goal) => goal.goal_id));
|
|
1152
|
+
const staleGoals = [
|
|
1153
|
+
...[...currentGoals].filter((goalId) => !manifestGoals.has(goalId)),
|
|
1154
|
+
...[...manifestGoals].filter((goalId) => !currentGoals.has(goalId))
|
|
1155
|
+
];
|
|
1156
|
+
checks.push(doctorCheck(
|
|
1157
|
+
"manifest_active_goals",
|
|
1158
|
+
staleGoals.length === 0,
|
|
1159
|
+
staleGoals.length === 0 ? "Manifest active goals match recoverable active goals." : `Manifest active goals differ: ${staleGoals.join(", ")}.`,
|
|
1160
|
+
"Run any OpenNori state-changing command, or run nori install --root <project> --json, to refresh the manifest."
|
|
1161
|
+
));
|
|
1162
|
+
|
|
1163
|
+
const missingManaged = (manifest.managed_files || [])
|
|
1164
|
+
.filter((entry) => entry.required !== false)
|
|
1165
|
+
.filter((entry) => !fs.existsSync(path.join(root, entry.path)))
|
|
1166
|
+
.map((entry) => entry.path);
|
|
1167
|
+
checks.push(doctorCheck(
|
|
1168
|
+
"managed_files",
|
|
1169
|
+
missingManaged.length === 0,
|
|
1170
|
+
missingManaged.length === 0 ? "Required OpenNori managed files are present." : `Missing managed files: ${missingManaged.join(", ")}.`,
|
|
1171
|
+
"Run nori install --root <project> --json."
|
|
1172
|
+
));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
checks.push(doctorCheck(
|
|
1176
|
+
"active_goals_recoverable",
|
|
1177
|
+
active.issues.length === 0,
|
|
1178
|
+
active.issues.length === 0 ? `${active.details.length} active goal(s) are recoverable.` : `${active.issues.length} active goal issue(s) found.`,
|
|
1179
|
+
"Inspect active_goal_issues, fix the reported .opennori/active/<goal>.acceptance.md and .opennori/active/<goal>.evidence.json pair, then rerun nori doctor --root <project> --json.",
|
|
1180
|
+
"broken"
|
|
1181
|
+
));
|
|
1182
|
+
|
|
1183
|
+
const skill = projectSkillState(root);
|
|
1184
|
+
const skillPack = projectSkillPackState(root);
|
|
1185
|
+
const manifestSkillInstalled = manifest?.skill?.installed === true;
|
|
1186
|
+
const skillOk = !skill.installed && !manifestSkillInstalled ? true : skill.installed && skill.in_sync;
|
|
1187
|
+
if (manifestReadable) {
|
|
1188
|
+
checks.push(doctorCheck(
|
|
1189
|
+
"manifest_skill_state",
|
|
1190
|
+
Boolean(manifest.skill) && manifest.skill.installed === skill.installed && manifest.skill.path === skill.path,
|
|
1191
|
+
"Manifest Skill state matches the project Skill location.",
|
|
1192
|
+
"Refresh the manifest with nori install --root <project> --json."
|
|
1193
|
+
));
|
|
1194
|
+
}
|
|
1195
|
+
checks.push(doctorCheck(
|
|
1196
|
+
"skill_sync",
|
|
1197
|
+
skillOk,
|
|
1198
|
+
skill.installed
|
|
1199
|
+
? (skill.in_sync ? "Project OpenNori Skill is installed and in sync." : "Project OpenNori Skill is installed but stale.")
|
|
1200
|
+
: "Project OpenNori Skill is not installed; this is optional unless the manifest expects it.",
|
|
1201
|
+
"Run nori install --root <project> --skill --force --json."
|
|
1202
|
+
));
|
|
1203
|
+
const manifestPackNames = new Set((manifest?.skill_pack?.skills || []).map((entry) => entry.name));
|
|
1204
|
+
const packNames = new Set(skillPack.skills.map((entry) => entry.name));
|
|
1205
|
+
const manifestPackMatches = !manifestReadable || (
|
|
1206
|
+
manifest?.skill_pack?.schema_version === "opennori/skill-pack-v1"
|
|
1207
|
+
&& sameStringSet([...manifestPackNames], [...packNames])
|
|
1208
|
+
);
|
|
1209
|
+
checks.push(doctorCheck(
|
|
1210
|
+
"skill_pack_manifest",
|
|
1211
|
+
manifestPackMatches,
|
|
1212
|
+
manifestPackMatches ? "Manifest Skill Pack state is readable." : "Manifest Skill Pack state is missing or stale.",
|
|
1213
|
+
"Refresh the manifest with nori install --root <project> --skill --json."
|
|
1214
|
+
));
|
|
1215
|
+
const packExpected = manifest?.skill_pack?.installed === true || skillPack.skills.some((entry) => entry.installed);
|
|
1216
|
+
const packOk = packExpected ? skillPack.installed && skillPack.in_sync : true;
|
|
1217
|
+
checks.push(doctorCheck(
|
|
1218
|
+
"skill_pack_sync",
|
|
1219
|
+
packOk,
|
|
1220
|
+
skillPack.installed
|
|
1221
|
+
? (skillPack.in_sync ? "OpenNori Skill Pack is installed and in sync." : "OpenNori Skill Pack is installed but stale.")
|
|
1222
|
+
: "OpenNori Skill Pack is not installed; this is optional unless the manifest expects it.",
|
|
1223
|
+
"Run nori install --root <project> --skill --force --json."
|
|
1224
|
+
));
|
|
1225
|
+
|
|
1226
|
+
const status = checks.every((check) => check.ok)
|
|
1227
|
+
? "ready"
|
|
1228
|
+
: checks.some((check) => !check.ok && check.severity === "broken")
|
|
1229
|
+
? "broken"
|
|
1230
|
+
: "needs-action";
|
|
1231
|
+
return {
|
|
1232
|
+
status,
|
|
1233
|
+
checks,
|
|
1234
|
+
recovery_actions: doctorRecoveryActions(checks, active.issues),
|
|
1235
|
+
active_goals: active.details,
|
|
1236
|
+
active_goal_issues: active.issues,
|
|
1237
|
+
manifest_path: manifestFile,
|
|
1238
|
+
skill,
|
|
1239
|
+
skill_pack: skillPack
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function brainstormPaths(root, brainstormId) {
|
|
1244
|
+
const dir = path.join(root, ".opennori", "brainstorms");
|
|
1245
|
+
return {
|
|
1246
|
+
jsonPath: path.join(dir, `${brainstormId}.json`),
|
|
1247
|
+
markdownPath: path.join(dir, `${brainstormId}.md`)
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function renderBrainstormMarkdown(brainstorm) {
|
|
1252
|
+
const lines = [
|
|
1253
|
+
`# ${brainstorm.id} Brainstorm`,
|
|
1254
|
+
"",
|
|
1255
|
+
"## Idea",
|
|
1256
|
+
"",
|
|
1257
|
+
brainstorm.idea,
|
|
1258
|
+
"",
|
|
1259
|
+
"## Rule",
|
|
1260
|
+
"",
|
|
1261
|
+
"This is a draft source, not a Nori Contract or completion evidence.",
|
|
1262
|
+
"",
|
|
1263
|
+
"## Candidates",
|
|
1264
|
+
""
|
|
1265
|
+
];
|
|
1266
|
+
|
|
1267
|
+
for (const candidate of brainstorm.candidates) {
|
|
1268
|
+
lines.push(
|
|
1269
|
+
`### ${candidate.id}. ${candidate.title}`,
|
|
1270
|
+
"",
|
|
1271
|
+
`User value: ${candidate.user_value}`,
|
|
1272
|
+
"",
|
|
1273
|
+
"Acceptance directions:",
|
|
1274
|
+
...candidate.acceptance_directions.map((direction) => `- ${direction}`),
|
|
1275
|
+
"",
|
|
1276
|
+
"Risks:",
|
|
1277
|
+
...candidate.risks.map((risk) => `- ${risk}`),
|
|
1278
|
+
""
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
lines.push("## Next", "", "User chooses a candidate or revises one before OpenNori draft.");
|
|
1283
|
+
return `${lines.join("\n")}\n`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function classifyChangedFile(filePath) {
|
|
1287
|
+
if (
|
|
1288
|
+
filePath.startsWith(".opennori/") ||
|
|
1289
|
+
filePath.startsWith("examples/")
|
|
1290
|
+
) {
|
|
1291
|
+
return "acceptance";
|
|
1292
|
+
}
|
|
1293
|
+
return "implementation";
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function gitChanges(root) {
|
|
1297
|
+
const result = spawnSync("git", ["status", "--short", "--untracked-files=all"], {
|
|
1298
|
+
cwd: root,
|
|
1299
|
+
encoding: "utf8"
|
|
1300
|
+
});
|
|
1301
|
+
if (result.status !== 0) {
|
|
1302
|
+
return { available: false, acceptance: [], implementation: [], raw_error: result.stderr.trim() };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const grouped = { available: true, acceptance: [], implementation: [] };
|
|
1306
|
+
for (const line of result.stdout.split("\n")) {
|
|
1307
|
+
if (!line.trim()) continue;
|
|
1308
|
+
const status = line.slice(0, 2).trim() || "modified";
|
|
1309
|
+
const rawPath = line.slice(3).trim();
|
|
1310
|
+
const filePath = rawPath.includes(" -> ") ? rawPath.split(" -> ").at(-1) : rawPath;
|
|
1311
|
+
grouped[classifyChangedFile(filePath)].push({ status, path: filePath });
|
|
1312
|
+
}
|
|
1313
|
+
return grouped;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function briefFromGoal(goal, goalId = undefined) {
|
|
1317
|
+
return {
|
|
1318
|
+
goal_id: goalId || undefined,
|
|
1319
|
+
goal,
|
|
1320
|
+
acceptance_basis: { status: "draft", summary: "Draft generated for user approval or revision." },
|
|
1321
|
+
criteria: DEFAULT_CRITERIA
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function buildBrainstorm(idea, explicitId = undefined) {
|
|
1326
|
+
const id = explicitId || slugify(idea.slice(0, 40));
|
|
1327
|
+
return {
|
|
1328
|
+
protocol_version: "opennori/brainstorm-v1",
|
|
1329
|
+
id,
|
|
1330
|
+
idea,
|
|
1331
|
+
status: "draft-source",
|
|
1332
|
+
candidates: BRAINSTORM_CANDIDATES,
|
|
1333
|
+
rule: "Brainstorm output is for choosing an acceptance direction. It is not a plan, a Nori Contract, or completion evidence."
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function briefFromBrainstorm(brainstorm, candidateId) {
|
|
1338
|
+
const candidate = brainstorm.candidates.find((item) => item.id === candidateId);
|
|
1339
|
+
if (!candidate) throw new Error(`Brainstorm candidate not found: ${candidateId}`);
|
|
1340
|
+
return {
|
|
1341
|
+
goal_id: slugify(`${brainstorm.id}-${candidate.id}`),
|
|
1342
|
+
goal: `${candidate.suggested_goal_template} 原始想法:${brainstorm.idea}`,
|
|
1343
|
+
acceptance_basis: {
|
|
1344
|
+
status: "draft",
|
|
1345
|
+
summary: `Draft generated from brainstorm ${brainstorm.id} candidate ${candidate.id}.`
|
|
1346
|
+
},
|
|
1347
|
+
criteria: candidate.acceptance_directions.map((direction, index) => ({
|
|
1348
|
+
id: `AC-${index + 1}`,
|
|
1349
|
+
user_story: direction,
|
|
1350
|
+
measurement: "用户查看 OpenNori draft、报告或目标结果后作出判断。",
|
|
1351
|
+
threshold: "用户能直接判断是否满足,不需要阅读实现步骤。",
|
|
1352
|
+
risk: index === 0 ? "medium" : "low"
|
|
1353
|
+
}))
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function savePair(acceptancePath, evidencePath, contract, ledger) {
|
|
1358
|
+
writeJson(evidencePath, { contract, ledger });
|
|
1359
|
+
syncAcceptanceMarkdown(acceptancePath, contract, ledger);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function inferRootFromAcceptancePath(acceptancePath) {
|
|
1363
|
+
const parts = path.resolve(acceptancePath).split(path.sep);
|
|
1364
|
+
const noriIndex = parts.lastIndexOf(".opennori");
|
|
1365
|
+
if (noriIndex <= 0) return process.cwd();
|
|
1366
|
+
return parts.slice(0, noriIndex).join(path.sep) || path.sep;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function refreshManifest(root) {
|
|
1370
|
+
if (fs.existsSync(path.join(root, ".opennori"))) {
|
|
1371
|
+
writeManifest(root);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function loadPair(args) {
|
|
1376
|
+
const explicitAcceptance = argValue(args, "--acceptance");
|
|
1377
|
+
const explicitEvidence = argValue(args, "--evidence");
|
|
1378
|
+
if (explicitAcceptance || explicitEvidence) {
|
|
1379
|
+
if (!explicitAcceptance || !explicitEvidence) {
|
|
1380
|
+
throw new Error("Both --acceptance and --evidence are required");
|
|
1381
|
+
}
|
|
1382
|
+
const acceptancePath = path.resolve(explicitAcceptance);
|
|
1383
|
+
const evidencePath = path.resolve(explicitEvidence);
|
|
1384
|
+
const payload = readJson(evidencePath);
|
|
1385
|
+
return {
|
|
1386
|
+
contract: payload.contract,
|
|
1387
|
+
ledger: payload.ledger,
|
|
1388
|
+
acceptancePath,
|
|
1389
|
+
evidencePath,
|
|
1390
|
+
root: inferRootFromAcceptancePath(acceptancePath)
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const root = resolveRoot(args);
|
|
1395
|
+
const goal = argValue(args, "--goal");
|
|
1396
|
+
const pairs = findActivePairs(root);
|
|
1397
|
+
const pair = goal ? pairs.find((item) => item.goalId === goal) : pairs[0];
|
|
1398
|
+
if (!pair) {
|
|
1399
|
+
throw new Error(`No active OpenNori goal found under ${root}`);
|
|
1400
|
+
}
|
|
1401
|
+
if (!goal && pairs.length > 1) {
|
|
1402
|
+
throw new Error("Multiple active OpenNori goals found. Pass --goal <goal-id> or explicit --acceptance/--evidence paths.");
|
|
1403
|
+
}
|
|
1404
|
+
const payload = readJson(pair.evidencePath);
|
|
1405
|
+
return {
|
|
1406
|
+
contract: payload.contract,
|
|
1407
|
+
ledger: payload.ledger,
|
|
1408
|
+
acceptancePath: pair.acceptancePath,
|
|
1409
|
+
evidencePath: pair.evidencePath,
|
|
1410
|
+
root
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
export async function main(args) {
|
|
1415
|
+
const command = args[0];
|
|
1416
|
+
if (!command || command === "--help" || command === "-h") {
|
|
1417
|
+
printJson(ok({ usage: TOP_LEVEL_USAGE, side_effect: "none" }));
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (wantsHelp(args)) {
|
|
1422
|
+
printJson(ok({ command: [command, args[1]].filter(Boolean).join(" "), usage: usageFor(args), side_effect: "none" }));
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (command === "doctor") {
|
|
1427
|
+
const root = resolveRoot(args);
|
|
1428
|
+
printJson(ok({
|
|
1429
|
+
name: "nori",
|
|
1430
|
+
root,
|
|
1431
|
+
...doctor(root),
|
|
1432
|
+
side_effect: "none"
|
|
1433
|
+
}));
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (command === "list") {
|
|
1438
|
+
const root = resolveRoot(args);
|
|
1439
|
+
const pairs = findActivePairs(root).map((pair) => {
|
|
1440
|
+
const payload = readJson(pair.evidencePath);
|
|
1441
|
+
return {
|
|
1442
|
+
goal_id: pair.goalId,
|
|
1443
|
+
status: payload.ledger?.status || "unknown",
|
|
1444
|
+
current_gap: currentGap(payload.contract, payload.ledger),
|
|
1445
|
+
acceptance_path: pair.acceptancePath,
|
|
1446
|
+
evidence_path: pair.evidencePath
|
|
1447
|
+
};
|
|
1448
|
+
});
|
|
1449
|
+
printJson(ok({ root, active_goals: pairs }));
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (command === "install") {
|
|
1454
|
+
const root = resolveRoot(args);
|
|
1455
|
+
const dryRun = hasFlag(args, "--dry-run");
|
|
1456
|
+
const force = hasFlag(args, "--force");
|
|
1457
|
+
const confirmed = hasFlag(args, "--confirm");
|
|
1458
|
+
const requestedSkill = hasFlag(args, "--skill");
|
|
1459
|
+
if (force && !dryRun && !confirmed) {
|
|
1460
|
+
printJson(fail(
|
|
1461
|
+
"confirm_required",
|
|
1462
|
+
"Install --force may overwrite existing OpenNori-managed files.",
|
|
1463
|
+
"Run nori install --root <project> --dry-run --force --json first, then rerun with --confirm if the destructive actions are acceptable."
|
|
1464
|
+
));
|
|
1465
|
+
process.exitCode = 1;
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const actions = [
|
|
1469
|
+
ensureDir(path.join(root, ".opennori", "active"), { dryRun }),
|
|
1470
|
+
ensureDir(path.join(root, ".opennori", "completed"), { dryRun }),
|
|
1471
|
+
ensureDir(path.join(root, ".opennori", "blocked"), { dryRun }),
|
|
1472
|
+
ensureDir(path.join(root, ".opennori", "reports"), { dryRun }),
|
|
1473
|
+
ensureDir(path.join(root, ".opennori", "brainstorms"), { dryRun }),
|
|
1474
|
+
writeIfSafe(path.join(root, ".opennori", "protocol.md"), protocolTemplate(), { dryRun, force, kind: "protocol" })
|
|
1475
|
+
];
|
|
1476
|
+
|
|
1477
|
+
if (requestedSkill) {
|
|
1478
|
+
actions.push(...skillPackInstallActions(root, { dryRun, force }));
|
|
1479
|
+
}
|
|
1480
|
+
const manifestAction = writeManifest(root, { dryRun });
|
|
1481
|
+
actions.push(manifestAction);
|
|
1482
|
+
const installPlan = buildInstallPlan(root, actions, { dryRun, force, requestedSkill });
|
|
1483
|
+
|
|
1484
|
+
printJson(ok({
|
|
1485
|
+
root,
|
|
1486
|
+
dry_run: dryRun,
|
|
1487
|
+
force,
|
|
1488
|
+
confirmed,
|
|
1489
|
+
install_plan: installPlan,
|
|
1490
|
+
actions: installPlan.actions,
|
|
1491
|
+
manifest: manifestAction.manifest
|
|
1492
|
+
}));
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (command === "uninstall") {
|
|
1497
|
+
const root = resolveRoot(args);
|
|
1498
|
+
const dryRun = hasFlag(args, "--dry-run");
|
|
1499
|
+
const confirmed = hasFlag(args, "--confirm");
|
|
1500
|
+
const includeState = hasFlag(args, "--include-state");
|
|
1501
|
+
const actions = buildUninstallActions(root, { includeState });
|
|
1502
|
+
const uninstallPlan = buildUninstallPlan(root, actions, { dryRun, includeState });
|
|
1503
|
+
|
|
1504
|
+
if (!dryRun && !confirmed) {
|
|
1505
|
+
printJson(fail(
|
|
1506
|
+
"confirm_required",
|
|
1507
|
+
"Uninstall removes OpenNori-managed project assets.",
|
|
1508
|
+
"Run nori uninstall --root <project> --dry-run --json first, then rerun with --confirm if the planned removals are acceptable."
|
|
1509
|
+
));
|
|
1510
|
+
process.exitCode = 1;
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (!dryRun) {
|
|
1515
|
+
applyUninstallActions(actions);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
printJson(ok({
|
|
1519
|
+
root,
|
|
1520
|
+
dry_run: dryRun,
|
|
1521
|
+
confirmed,
|
|
1522
|
+
include_state: includeState,
|
|
1523
|
+
uninstall_plan: uninstallPlan,
|
|
1524
|
+
actions: uninstallPlan.actions
|
|
1525
|
+
}));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (command === "upgrade") {
|
|
1530
|
+
const root = resolveRoot(args);
|
|
1531
|
+
const dryRun = hasFlag(args, "--dry-run");
|
|
1532
|
+
const confirmed = hasFlag(args, "--confirm");
|
|
1533
|
+
const requestedSkill = hasFlag(args, "--skill");
|
|
1534
|
+
const actions = upgradeActions(root, { requestedSkill });
|
|
1535
|
+
const upgradePlan = buildUpgradePlan(root, actions, { dryRun, requestedSkill });
|
|
1536
|
+
|
|
1537
|
+
if (!dryRun && !confirmed) {
|
|
1538
|
+
printJson(fail(
|
|
1539
|
+
"confirm_required",
|
|
1540
|
+
"Upgrade refreshes OpenNori manifest, protocol, and optionally Skill Pack assets.",
|
|
1541
|
+
"Run nori upgrade --root <project> --dry-run --json first, then rerun with --confirm if the planned updates are acceptable."
|
|
1542
|
+
));
|
|
1543
|
+
process.exitCode = 1;
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
if (!dryRun && actions.some((action) => action.action === "missing")) {
|
|
1548
|
+
printJson(fail(
|
|
1549
|
+
"install_required",
|
|
1550
|
+
"Upgrade found missing OpenNori entry assets.",
|
|
1551
|
+
"Run nori install --root <project> --dry-run --json before upgrading missing assets."
|
|
1552
|
+
));
|
|
1553
|
+
process.exitCode = 1;
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
if (!dryRun) {
|
|
1558
|
+
applyUpgradeActions(actions);
|
|
1559
|
+
writeManifest(root);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
printJson(ok({
|
|
1563
|
+
root,
|
|
1564
|
+
dry_run: dryRun,
|
|
1565
|
+
confirmed,
|
|
1566
|
+
upgrade_plan: upgradePlan,
|
|
1567
|
+
actions: upgradePlan.actions,
|
|
1568
|
+
manifest: dryRun ? buildManifest(root) : safeReadManifest(root)
|
|
1569
|
+
}));
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (command === "brainstorm") {
|
|
1574
|
+
const root = resolveRoot(args);
|
|
1575
|
+
const idea = String(argValue(args, "--idea", "")).trim();
|
|
1576
|
+
if (!idea) throw new Error("--idea is required");
|
|
1577
|
+
const brainstorm = buildBrainstorm(idea, argValue(args, "--id"));
|
|
1578
|
+
const paths = brainstormPaths(root, brainstorm.id);
|
|
1579
|
+
writeJson(paths.jsonPath, brainstorm);
|
|
1580
|
+
fs.mkdirSync(path.dirname(paths.markdownPath), { recursive: true });
|
|
1581
|
+
fs.writeFileSync(paths.markdownPath, renderBrainstormMarkdown(brainstorm));
|
|
1582
|
+
refreshManifest(root);
|
|
1583
|
+
printJson(ok(
|
|
1584
|
+
{
|
|
1585
|
+
brainstorm_id: brainstorm.id,
|
|
1586
|
+
status: brainstorm.status,
|
|
1587
|
+
idea: brainstorm.idea,
|
|
1588
|
+
candidates: brainstorm.candidates,
|
|
1589
|
+
brainstorm_path: paths.jsonPath,
|
|
1590
|
+
markdown_path: paths.markdownPath,
|
|
1591
|
+
is_acceptance_contract: false
|
|
1592
|
+
},
|
|
1593
|
+
[
|
|
1594
|
+
{ kind: "brainstorm_source", path: paths.jsonPath },
|
|
1595
|
+
{ kind: "brainstorm_markdown", path: paths.markdownPath }
|
|
1596
|
+
],
|
|
1597
|
+
[],
|
|
1598
|
+
["Ask the user to choose or revise a candidate before running nori draft."]
|
|
1599
|
+
));
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (command === "draft") {
|
|
1604
|
+
const root = resolveRoot(args);
|
|
1605
|
+
const brainstormId = argValue(args, "--from-brainstorm");
|
|
1606
|
+
let brief;
|
|
1607
|
+
if (brainstormId) {
|
|
1608
|
+
const candidateId = argValue(args, "--candidate");
|
|
1609
|
+
if (!candidateId) throw new Error("--candidate is required with --from-brainstorm");
|
|
1610
|
+
brief = briefFromBrainstorm(readJson(brainstormPaths(root, brainstormId).jsonPath), candidateId);
|
|
1611
|
+
} else {
|
|
1612
|
+
const goal = String(argValue(args, "--goal", "")).trim();
|
|
1613
|
+
if (!goal) throw new Error("--goal is required");
|
|
1614
|
+
brief = briefFromGoal(goal, argValue(args, "--goal-id"));
|
|
1615
|
+
}
|
|
1616
|
+
const contract = buildContractFromBrief(brief);
|
|
1617
|
+
const ledger = buildEvidenceLedger(contract);
|
|
1618
|
+
const issues = validateContract(contract, ledger);
|
|
1619
|
+
if (issues.length > 0) {
|
|
1620
|
+
printJson({ ...fail("invalid_acceptance", "Draft does not produce a valid OpenNori contract", "Rewrite ACs from the user's perspective"), issues });
|
|
1621
|
+
process.exitCode = 1;
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const paths = pathsForGoal(root, contract.goal_id);
|
|
1625
|
+
fs.mkdirSync(path.dirname(paths.acceptancePath), { recursive: true });
|
|
1626
|
+
fs.writeFileSync(paths.acceptancePath, renderAcceptanceMarkdown(contract, ledger));
|
|
1627
|
+
writeJson(paths.evidencePath, { contract, ledger });
|
|
1628
|
+
refreshManifest(root);
|
|
1629
|
+
printJson(ok(
|
|
1630
|
+
{
|
|
1631
|
+
goal_id: contract.goal_id,
|
|
1632
|
+
acceptance_basis: contract.acceptance_basis,
|
|
1633
|
+
acceptance_path: paths.acceptancePath,
|
|
1634
|
+
evidence_path: paths.evidencePath,
|
|
1635
|
+
criteria: contract.criteria,
|
|
1636
|
+
current_gap: currentGap(contract, ledger)
|
|
1637
|
+
},
|
|
1638
|
+
[
|
|
1639
|
+
{ kind: "draft_acceptance_contract", path: paths.acceptancePath },
|
|
1640
|
+
{ kind: "evidence_ledger", path: paths.evidencePath }
|
|
1641
|
+
],
|
|
1642
|
+
[],
|
|
1643
|
+
["Ask the user to approve or revise these acceptance criteria before implementation."]
|
|
1644
|
+
));
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (command === "init") {
|
|
1649
|
+
const briefPath = path.resolve(args[1] || "");
|
|
1650
|
+
const root = resolveRoot(args);
|
|
1651
|
+
const brief = readJson(briefPath);
|
|
1652
|
+
const contract = buildContractFromBrief(brief);
|
|
1653
|
+
const ledger = buildEvidenceLedger(contract);
|
|
1654
|
+
const issues = validateContract(contract, ledger);
|
|
1655
|
+
if (issues.length > 0) {
|
|
1656
|
+
printJson({ ...fail("invalid_acceptance", "Brief does not produce a valid OpenNori contract", "Rewrite ACs from the user's perspective"), issues });
|
|
1657
|
+
process.exitCode = 1;
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const paths = pathsForGoal(root, contract.goal_id);
|
|
1662
|
+
const evidencePayload = { contract, ledger };
|
|
1663
|
+
fs.mkdirSync(path.dirname(paths.acceptancePath), { recursive: true });
|
|
1664
|
+
fs.writeFileSync(paths.acceptancePath, renderAcceptanceMarkdown(contract, ledger));
|
|
1665
|
+
writeJson(paths.evidencePath, evidencePayload);
|
|
1666
|
+
refreshManifest(root);
|
|
1667
|
+
|
|
1668
|
+
printJson(ok(
|
|
1669
|
+
{
|
|
1670
|
+
goal_id: contract.goal_id,
|
|
1671
|
+
acceptance_path: paths.acceptancePath,
|
|
1672
|
+
evidence_path: paths.evidencePath,
|
|
1673
|
+
current_gap: currentGap(contract, ledger)
|
|
1674
|
+
},
|
|
1675
|
+
[
|
|
1676
|
+
{ kind: "acceptance_contract", path: paths.acceptancePath },
|
|
1677
|
+
{ kind: "evidence_ledger", path: paths.evidencePath }
|
|
1678
|
+
],
|
|
1679
|
+
[],
|
|
1680
|
+
["Run nori next --acceptance <path> --evidence <path> --json before choosing implementation work."]
|
|
1681
|
+
));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
if (command === "check") {
|
|
1686
|
+
const { contract, ledger } = loadPair(args);
|
|
1687
|
+
const issues = validateContract(contract, ledger);
|
|
1688
|
+
if (issues.length > 0) {
|
|
1689
|
+
printJson({ ...fail("invalid_acceptance", "Acceptance contract failed validation", "Fix reported issues before continuing"), issues });
|
|
1690
|
+
process.exitCode = 1;
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
printJson(ok({
|
|
1694
|
+
goal_id: contract.goal_id,
|
|
1695
|
+
workflow_status: ledger.status,
|
|
1696
|
+
current_gap: currentGap(contract, ledger),
|
|
1697
|
+
statuses: Object.fromEntries(Object.entries(ledger.criteria).map(([id, state]) => [id, state.status]))
|
|
1698
|
+
}));
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
if (command === "approve") {
|
|
1703
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1704
|
+
contract.acceptance_basis = {
|
|
1705
|
+
status: "approved",
|
|
1706
|
+
summary: argValue(args, "--summary", "User approved acceptance criteria."),
|
|
1707
|
+
approved_at: new Date().toISOString()
|
|
1708
|
+
};
|
|
1709
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
1710
|
+
savePair(acceptancePath, evidencePath, contract, ledger);
|
|
1711
|
+
refreshManifest(root);
|
|
1712
|
+
printJson(ok({
|
|
1713
|
+
goal_id: contract.goal_id,
|
|
1714
|
+
acceptance_basis: contract.acceptance_basis,
|
|
1715
|
+
workflow_status: ledger.status,
|
|
1716
|
+
current_gap: currentGap(contract, ledger)
|
|
1717
|
+
}));
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (command === "criterion" && args[1] === "update") {
|
|
1722
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1723
|
+
const criterionId = argValue(args, "--criterion");
|
|
1724
|
+
if (!criterionId) throw new Error("--criterion is required");
|
|
1725
|
+
const criterion = contract.criteria.find((item) => item.id === criterionId);
|
|
1726
|
+
if (!criterion) throw new Error(`Criterion not found: ${criterionId}`);
|
|
1727
|
+
|
|
1728
|
+
const before = {
|
|
1729
|
+
user_story: criterion.user_story,
|
|
1730
|
+
measurement: criterion.measurement,
|
|
1731
|
+
threshold: criterion.threshold,
|
|
1732
|
+
risk: criterion.risk
|
|
1733
|
+
};
|
|
1734
|
+
criterion.user_story = argValue(args, "--user-story", criterion.user_story);
|
|
1735
|
+
criterion.measurement = argValue(args, "--measurement", criterion.measurement);
|
|
1736
|
+
criterion.threshold = argValue(args, "--threshold", criterion.threshold);
|
|
1737
|
+
criterion.risk = argValue(args, "--risk", criterion.risk);
|
|
1738
|
+
const changed = (
|
|
1739
|
+
before.user_story !== criterion.user_story ||
|
|
1740
|
+
before.measurement !== criterion.measurement ||
|
|
1741
|
+
before.threshold !== criterion.threshold ||
|
|
1742
|
+
before.risk !== criterion.risk
|
|
1743
|
+
);
|
|
1744
|
+
if (changed && ledger.criteria[criterionId]) {
|
|
1745
|
+
ledger.criteria[criterionId] = {
|
|
1746
|
+
status: "unknown",
|
|
1747
|
+
confidence: "none",
|
|
1748
|
+
required: criterion.required !== false,
|
|
1749
|
+
risk: criterion.risk || "medium",
|
|
1750
|
+
evidence: []
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
contract.acceptance_basis = {
|
|
1754
|
+
status: "approved",
|
|
1755
|
+
summary: argValue(args, "--summary", `User revised ${criterionId}.`),
|
|
1756
|
+
approved_at: new Date().toISOString()
|
|
1757
|
+
};
|
|
1758
|
+
const issues = validateContract(contract, ledger);
|
|
1759
|
+
if (issues.length > 0) {
|
|
1760
|
+
printJson({ ...fail("invalid_acceptance", "Updated criterion failed validation", "Rewrite the criterion from the user's perspective"), issues });
|
|
1761
|
+
process.exitCode = 1;
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
1766
|
+
savePair(acceptancePath, evidencePath, contract, ledger);
|
|
1767
|
+
refreshManifest(root);
|
|
1768
|
+
printJson(ok({
|
|
1769
|
+
goal_id: contract.goal_id,
|
|
1770
|
+
criterion,
|
|
1771
|
+
acceptance_basis: contract.acceptance_basis,
|
|
1772
|
+
workflow_status: ledger.status,
|
|
1773
|
+
current_gap: currentGap(contract, ledger)
|
|
1774
|
+
}));
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (command === "profile" && args[1] === "add") {
|
|
1779
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1780
|
+
const item = {
|
|
1781
|
+
id: argValue(args, "--id"),
|
|
1782
|
+
type: argValue(args, "--type", "constraint"),
|
|
1783
|
+
name: argValue(args, "--name"),
|
|
1784
|
+
strength: argValue(args, "--strength", "prefer"),
|
|
1785
|
+
purpose: argValue(args, "--purpose", ""),
|
|
1786
|
+
scope: argValue(args, "--scope", ""),
|
|
1787
|
+
install_policy: argValue(args, "--install-policy", "ask_before_install")
|
|
1788
|
+
};
|
|
1789
|
+
addProfileItem(ledger, item);
|
|
1790
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
1791
|
+
savePair(acceptancePath, evidencePath, contract, ledger);
|
|
1792
|
+
refreshManifest(root);
|
|
1793
|
+
printJson(ok({
|
|
1794
|
+
goal_id: contract.goal_id,
|
|
1795
|
+
profile: ledger.capability_profile,
|
|
1796
|
+
compliance: profileCompliance(ledger),
|
|
1797
|
+
workflow_status: ledger.status,
|
|
1798
|
+
current_gap: currentGap(contract, ledger)
|
|
1799
|
+
}));
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if (command === "profile" && args[1] === "evidence") {
|
|
1804
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1805
|
+
const itemId = argValue(args, "--item");
|
|
1806
|
+
if (!itemId) throw new Error("--item is required");
|
|
1807
|
+
const evidence = {
|
|
1808
|
+
result: argValue(args, "--result", "satisfied"),
|
|
1809
|
+
summary: argValue(args, "--summary", ""),
|
|
1810
|
+
path: argValue(args, "--path")
|
|
1811
|
+
};
|
|
1812
|
+
if (!evidence.summary) throw new Error("--summary is required");
|
|
1813
|
+
addProfileEvidence(ledger, itemId, evidence);
|
|
1814
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
1815
|
+
savePair(acceptancePath, evidencePath, contract, ledger);
|
|
1816
|
+
refreshManifest(root);
|
|
1817
|
+
printJson(ok({
|
|
1818
|
+
goal_id: contract.goal_id,
|
|
1819
|
+
item: itemId,
|
|
1820
|
+
compliance: profileCompliance(ledger),
|
|
1821
|
+
workflow_status: ledger.status,
|
|
1822
|
+
current_gap: currentGap(contract, ledger)
|
|
1823
|
+
}));
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
if (command === "profile" && args[1] === "show") {
|
|
1828
|
+
const { contract, ledger } = loadPair(args);
|
|
1829
|
+
printJson(ok({
|
|
1830
|
+
goal_id: contract.goal_id,
|
|
1831
|
+
profile: ledger.capability_profile || { items: [], evidence: [] },
|
|
1832
|
+
compliance: profileCompliance(ledger),
|
|
1833
|
+
workflow_status: ledger.status,
|
|
1834
|
+
current_gap: currentGap(contract, ledger)
|
|
1835
|
+
}));
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
if (command === "profile" && args[1] === "check") {
|
|
1840
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1841
|
+
const record = hasFlag(args, "--record");
|
|
1842
|
+
const checks = autoProfileChecks(root, ledger);
|
|
1843
|
+
if (record) {
|
|
1844
|
+
recordAutoProfileChecks(ledger, checks);
|
|
1845
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
1846
|
+
savePair(acceptancePath, evidencePath, contract, ledger);
|
|
1847
|
+
refreshManifest(root);
|
|
1848
|
+
}
|
|
1849
|
+
printJson(ok({
|
|
1850
|
+
goal_id: contract.goal_id,
|
|
1851
|
+
recorded: record,
|
|
1852
|
+
checks,
|
|
1853
|
+
profile: ledger.capability_profile || { items: [], evidence: [] },
|
|
1854
|
+
compliance: profileCompliance(ledger),
|
|
1855
|
+
workflow_status: ledger.status,
|
|
1856
|
+
current_gap: currentGap(contract, ledger)
|
|
1857
|
+
}));
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
if (command === "resume") {
|
|
1862
|
+
const { contract, ledger, acceptancePath, evidencePath } = loadPair(args);
|
|
1863
|
+
const recommendation = nextRecommendation(contract, ledger);
|
|
1864
|
+
printJson(ok({
|
|
1865
|
+
goal_id: contract.goal_id,
|
|
1866
|
+
workflow_status: ledger.status,
|
|
1867
|
+
current_gap: currentGap(contract, ledger),
|
|
1868
|
+
completion: completionAnswer(contract, ledger),
|
|
1869
|
+
intervention: intervention(contract, ledger),
|
|
1870
|
+
next_recommendation: recommendation,
|
|
1871
|
+
acceptance_path: acceptancePath,
|
|
1872
|
+
evidence_path: evidencePath
|
|
1873
|
+
}, [], [], recommendation.actions));
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
if (command === "next") {
|
|
1878
|
+
const { contract, ledger } = loadPair(args);
|
|
1879
|
+
const recommendation = nextRecommendation(contract, ledger);
|
|
1880
|
+
printJson(ok({
|
|
1881
|
+
goal_id: contract.goal_id,
|
|
1882
|
+
current_gap: currentGap(contract, ledger),
|
|
1883
|
+
complete: currentGap(contract, ledger) === null,
|
|
1884
|
+
next_recommendation: recommendation
|
|
1885
|
+
}, [], [], recommendation.actions));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (command === "evidence" && args[1] === "add") {
|
|
1890
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1891
|
+
const criterionId = argValue(args, "--criterion");
|
|
1892
|
+
if (!criterionId) throw new Error("--criterion is required");
|
|
1893
|
+
const sources = evidenceSourcesFromArgs(args);
|
|
1894
|
+
const evidence = {
|
|
1895
|
+
kind: argValue(args, "--kind", "manual"),
|
|
1896
|
+
basis: argValue(args, "--basis"),
|
|
1897
|
+
summary: argValue(args, "--summary", ""),
|
|
1898
|
+
result: argValue(args, "--result", "passing"),
|
|
1899
|
+
confidence: argValue(args, "--confidence"),
|
|
1900
|
+
path: argValue(args, "--path"),
|
|
1901
|
+
sources,
|
|
1902
|
+
reviewability: argValue(args, "--reviewability"),
|
|
1903
|
+
limitations: argValue(args, "--limitations")
|
|
1904
|
+
};
|
|
1905
|
+
if (!evidence.summary) throw new Error("--summary is required");
|
|
1906
|
+
addEvidence(contract, ledger, criterionId, evidence);
|
|
1907
|
+
writeJson(evidencePath, { contract, ledger });
|
|
1908
|
+
syncAcceptanceMarkdown(acceptancePath, contract, ledger);
|
|
1909
|
+
refreshManifest(root);
|
|
1910
|
+
printJson(ok({
|
|
1911
|
+
goal_id: contract.goal_id,
|
|
1912
|
+
criterion: criterionId,
|
|
1913
|
+
criterion_status: ledger.criteria[criterionId].status,
|
|
1914
|
+
confidence: ledger.criteria[criterionId].confidence,
|
|
1915
|
+
latest_evidence: criterionStatusRows(contract, ledger).find((row) => row.id === criterionId)?.latest_evidence,
|
|
1916
|
+
gate: ledger.criteria[criterionId].evidence.at(-1)?.gate,
|
|
1917
|
+
workflow_status: ledger.status,
|
|
1918
|
+
current_gap: currentGap(contract, ledger)
|
|
1919
|
+
}));
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (command === "evaluate") {
|
|
1924
|
+
const { contract, ledger, acceptancePath, evidencePath, root } = loadPair(args);
|
|
1925
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
1926
|
+
writeJson(evidencePath, { contract, ledger });
|
|
1927
|
+
syncAcceptanceMarkdown(acceptancePath, contract, ledger);
|
|
1928
|
+
refreshManifest(root);
|
|
1929
|
+
printJson(ok({
|
|
1930
|
+
goal_id: contract.goal_id,
|
|
1931
|
+
workflow_status: ledger.status,
|
|
1932
|
+
current_gap: currentGap(contract, ledger)
|
|
1933
|
+
}));
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
if (command === "status") {
|
|
1938
|
+
const { contract, ledger } = loadPair(args);
|
|
1939
|
+
const recommendation = nextRecommendation(contract, ledger);
|
|
1940
|
+
printJson(ok({
|
|
1941
|
+
goal_id: contract.goal_id,
|
|
1942
|
+
workflow_status: ledger.status,
|
|
1943
|
+
current_gap: currentGap(contract, ledger),
|
|
1944
|
+
completion: completionAnswer(contract, ledger),
|
|
1945
|
+
intervention: intervention(contract, ledger),
|
|
1946
|
+
next_recommendation: recommendation,
|
|
1947
|
+
criteria: criterionStatusRows(contract, ledger)
|
|
1948
|
+
}, [], [], recommendation.actions));
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
if (command === "report") {
|
|
1953
|
+
const { contract, ledger, root } = loadPair(args);
|
|
1954
|
+
const output = path.resolve(argValue(args, "--output") || pathsForGoal(root, contract.goal_id).reportPath);
|
|
1955
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
1956
|
+
fs.writeFileSync(output, renderReport(contract, ledger));
|
|
1957
|
+
refreshManifest(root);
|
|
1958
|
+
const recommendation = nextRecommendation(contract, ledger);
|
|
1959
|
+
printJson(ok(
|
|
1960
|
+
{ goal_id: contract.goal_id, report_path: output, workflow_status: ledger.status, next_recommendation: recommendation },
|
|
1961
|
+
[{ kind: "acceptance_report", path: output }],
|
|
1962
|
+
[],
|
|
1963
|
+
recommendation.actions
|
|
1964
|
+
));
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
if (command === "context" && args[1] === "export") {
|
|
1969
|
+
const root = resolveRoot(args);
|
|
1970
|
+
const goal = argValue(args, "--goal");
|
|
1971
|
+
const pairs = findActivePairs(root);
|
|
1972
|
+
const pair = goal ? pairs.find((item) => item.goalId === goal) : pairs[0];
|
|
1973
|
+
if (!pair) throw new Error(`No active OpenNori goal found under ${root}`);
|
|
1974
|
+
if (!goal && pairs.length > 1) {
|
|
1975
|
+
throw new Error("Multiple active OpenNori goals found. Pass --goal <goal-id>.");
|
|
1976
|
+
}
|
|
1977
|
+
const context = buildContextExport(root, pair);
|
|
1978
|
+
const output = argValue(args, "--output");
|
|
1979
|
+
if (output) {
|
|
1980
|
+
const outputPath = path.resolve(output);
|
|
1981
|
+
writeJson(outputPath, context);
|
|
1982
|
+
printJson(ok(
|
|
1983
|
+
{ ...context, output_path: outputPath },
|
|
1984
|
+
[{ kind: "opennori_context_export", path: outputPath }],
|
|
1985
|
+
[],
|
|
1986
|
+
context.next_recommendation.actions
|
|
1987
|
+
));
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
printJson(ok(context, [], [], context.next_recommendation.actions));
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (command === "changes") {
|
|
1995
|
+
const root = resolveRoot(args);
|
|
1996
|
+
const pairs = findActivePairs(root).map((pair) => {
|
|
1997
|
+
const payload = readJson(pair.evidencePath);
|
|
1998
|
+
return {
|
|
1999
|
+
goal_id: pair.goalId,
|
|
2000
|
+
workflow_status: payload.ledger?.status || "unknown",
|
|
2001
|
+
current_gap: currentGap(payload.contract, payload.ledger)
|
|
2002
|
+
};
|
|
2003
|
+
});
|
|
2004
|
+
printJson(ok({
|
|
2005
|
+
root,
|
|
2006
|
+
active_goals: pairs,
|
|
2007
|
+
changed_files: gitChanges(root)
|
|
2008
|
+
}));
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (command === "archive") {
|
|
2013
|
+
const root = resolveRoot(args);
|
|
2014
|
+
const { contract, ledger, acceptancePath, evidencePath } = loadPair(args);
|
|
2015
|
+
recomputeWorkflowStatus(contract, ledger);
|
|
2016
|
+
if (ledger.status !== "complete" && ledger.status !== "blocked") {
|
|
2017
|
+
printJson(fail("not_archivable", `Goal ${contract.goal_id} is ${ledger.status}`, "Only complete or blocked OpenNori goals can be archived."));
|
|
2018
|
+
process.exitCode = 1;
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const archiveDir = ledger.status === "complete" ? "completed" : "blocked";
|
|
2023
|
+
const targetAcceptance = path.join(root, ".opennori", archiveDir, path.basename(acceptancePath));
|
|
2024
|
+
const targetEvidence = path.join(root, ".opennori", archiveDir, path.basename(evidencePath));
|
|
2025
|
+
const reportPath = pathsForGoal(root, contract.goal_id).reportPath;
|
|
2026
|
+
for (const target of [targetAcceptance, targetEvidence]) {
|
|
2027
|
+
if (fs.existsSync(target) && !hasFlag(args, "--force")) {
|
|
2028
|
+
printJson(fail("archive_target_exists", `Archive target exists: ${relativeTo(root, target)}`, "Pass --force or move the existing archive file."));
|
|
2029
|
+
process.exitCode = 1;
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
writeJson(evidencePath, { contract, ledger });
|
|
2035
|
+
syncAcceptanceMarkdown(acceptancePath, contract, ledger);
|
|
2036
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
2037
|
+
fs.writeFileSync(reportPath, renderReport(contract, ledger));
|
|
2038
|
+
fs.mkdirSync(path.dirname(targetAcceptance), { recursive: true });
|
|
2039
|
+
fs.renameSync(acceptancePath, targetAcceptance);
|
|
2040
|
+
fs.renameSync(evidencePath, targetEvidence);
|
|
2041
|
+
refreshManifest(root);
|
|
2042
|
+
printJson(ok(
|
|
2043
|
+
{
|
|
2044
|
+
goal_id: contract.goal_id,
|
|
2045
|
+
archived_as: archiveDir,
|
|
2046
|
+
acceptance_path: targetAcceptance,
|
|
2047
|
+
evidence_path: targetEvidence,
|
|
2048
|
+
report_path: reportPath
|
|
2049
|
+
},
|
|
2050
|
+
[
|
|
2051
|
+
{ kind: "archived_acceptance_contract", path: targetAcceptance },
|
|
2052
|
+
{ kind: "archived_evidence_ledger", path: targetEvidence },
|
|
2053
|
+
{ kind: "acceptance_report", path: reportPath }
|
|
2054
|
+
]
|
|
2055
|
+
));
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
if (command === "skill" && args[1] === "export") {
|
|
2060
|
+
if (hasFlag(args, "--pack")) {
|
|
2061
|
+
printJson(ok({
|
|
2062
|
+
schema_version: "opennori/skill-pack-v1",
|
|
2063
|
+
skills: SKILL_PACK.map((skill) => ({
|
|
2064
|
+
name: skill.name,
|
|
2065
|
+
skill_md: skillMarkdown(skill)
|
|
2066
|
+
}))
|
|
2067
|
+
}));
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
const skillName = argValue(args, "--name", "nori");
|
|
2071
|
+
const skill = SKILL_PACK.find((entry) => entry.name === skillName);
|
|
2072
|
+
if (!skill) {
|
|
2073
|
+
printJson(fail("unknown_skill", `Unknown OpenNori Skill: ${skillName}`, `Use one of: ${SKILL_PACK.map((entry) => entry.name).join(", ")}`));
|
|
2074
|
+
process.exitCode = 1;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
printJson(ok({ skill_name: skill.name, skill_md: skillMarkdown(skill) }));
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
printJson(fail("unknown_command", `Unknown command: ${args.join(" ")}`));
|
|
2082
|
+
process.exitCode = 2;
|
|
2083
|
+
}
|