@mytegroupinc/myte-core 0.0.47 → 0.0.50

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.
@@ -2,11 +2,52 @@
2
2
  "use strict";
3
3
 
4
4
  const fs = require("node:fs");
5
- const os = require("node:os");
6
- const path = require("node:path");
7
- const { spawnSync } = require("node:child_process");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { spawnSync } = require("node:child_process");
8
+ const {
9
+ createCertificationManifest,
10
+ recordCertificationArtifact,
11
+ validateCertificationManifest,
12
+ } = require("../lib/certification-manifest");
13
+ const { readYamlFile } = require("../cli");
8
14
 
9
- const CLI_PATH = path.resolve(__dirname, "..", "cli.js");
15
+ const CLI_PATH = path.resolve(__dirname, "..", "cli.js");
16
+ const LIVE_SCENARIO_MATRIX = [
17
+ { id: "doctor", kind: "transport", mutation: "none", required: true },
18
+ { id: "config", kind: "scope", mutation: "none", required: true },
19
+ { id: "create-independent-prds", kind: "write", mutation: "test_feedback", required: true },
20
+ { id: "create-three-document-prd", kind: "write", mutation: "test_feedback", required: true },
21
+ { id: "feedback-sync", kind: "read/local-sync", mutation: "local_temp_only", required: true },
22
+ { id: "feedback-get-history", kind: "read", mutation: "none", required: true },
23
+ { id: "feedback-comment", kind: "write", mutation: "test_comment", required: true },
24
+ { id: "feedback-comment-sync-roundtrip", kind: "read/local-sync", mutation: "local_temp_only", required: true },
25
+ {
26
+ id: "feedback-comment-attachment-sync",
27
+ kind: "web-write/read/local-sync",
28
+ mutation: "test_comment_attachment",
29
+ required: "when --require-comment-attachment-sync is set",
30
+ },
31
+ { id: "feedback-stale-edit-guard", kind: "conflict-safety", mutation: "test_feedback", required: true },
32
+ { id: "feedback-forced-stale-override", kind: "owner-conflict-override", mutation: "test_feedback", required: true },
33
+ { id: "feedback-move-undo", kind: "governed-write", mutation: "test_feedback", required: true },
34
+ { id: "feedback-validate-submit", kind: "review", mutation: "test_review", required: true },
35
+ { id: "feedback-review-list-detail", kind: "read", mutation: "none", required: true },
36
+ { id: "feedback-request-changes-revise", kind: "review", mutation: "test_review", required: true },
37
+ { id: "feedback-reject-cancel", kind: "review", mutation: "test_review", required: true },
38
+ { id: "feedback-batch-review", kind: "governed-write", mutation: "test_review", required: true },
39
+ { id: "feedback-document-refinement", kind: "review", mutation: "test_feedback", required: true },
40
+ { id: "feedback-prd-versions-diff", kind: "read", mutation: "none", required: true },
41
+ { id: "feedback-batch-move-archive", kind: "governed-write", mutation: "test_feedback", required: true },
42
+ { id: "project-key-unarchive-denial", kind: "negative-permission", mutation: "none", required: true },
43
+ {
44
+ id: "owner-delegate-collaborator-matrix",
45
+ kind: "role-permission",
46
+ mutation: "test_feedback",
47
+ required: "when --require-role-matrix is set",
48
+ },
49
+ { id: "exact-id-cleanup", kind: "cleanup", mutation: "archive_test_feedback", required: true },
50
+ ];
10
51
 
11
52
  function parseArgs(argv) {
12
53
  const args = { _: [] };
@@ -28,19 +69,32 @@ function parseArgs(argv) {
28
69
  return args;
29
70
  }
30
71
 
31
- function requireConfirm(args) {
32
- if (!args["confirm-live"]) {
33
- throw new Error("Refusing live feedback mutations. Re-run with --confirm-live after backend/web deploy.");
72
+ function requireConfirm(args) {
73
+ if (!args["confirm-live"]) {
74
+ throw new Error("Refusing live feedback mutations. Re-run with --confirm-live after backend/web deploy.");
34
75
  }
35
- if (!process.env.MYTE_API_KEY && !process.env.MYTE_PROJECT_API_KEY) {
36
- throw new Error("Missing MYTE_API_KEY or MYTE_PROJECT_API_KEY.");
37
- }
38
- }
39
-
40
- function runCli(cliArgs, cwd, { allowFailure = false } = {}) {
41
- const result = spawnSync(process.execPath, [CLI_PATH, ...cliArgs], {
42
- cwd,
43
- env: process.env,
76
+ if (
77
+ !process.env.MYTE_OWNER_API_KEY
78
+ && !process.env.MYTE_API_KEY
79
+ && !process.env.MYTE_PROJECT_API_KEY
80
+ ) {
81
+ throw new Error("Missing MYTE_OWNER_API_KEY, MYTE_API_KEY, or MYTE_PROJECT_API_KEY.");
82
+ }
83
+ }
84
+
85
+ function runCli(cliArgs, cwd, { allowFailure = false, apiKey } = {}) {
86
+ const env = { ...process.env };
87
+ delete env.MYTE_OWNER_API_KEY;
88
+ delete env.MYTE_DELEGATE_API_KEY;
89
+ delete env.MYTE_COLLABORATOR_API_KEY;
90
+ delete env.MYTE_WEB_ACCESS_TOKEN;
91
+ if (apiKey) {
92
+ env.MYTE_API_KEY = apiKey;
93
+ env.MYTE_PROJECT_API_KEY = apiKey;
94
+ }
95
+ const result = spawnSync(process.execPath, [CLI_PATH, ...cliArgs], {
96
+ cwd,
97
+ env,
44
98
  encoding: "utf8",
45
99
  stdio: ["ignore", "pipe", "pipe"],
46
100
  });
@@ -59,8 +113,75 @@ function runCli(cliArgs, cwd, { allowFailure = false } = {}) {
59
113
  result.stderr || stdout || "(no output)",
60
114
  ].join("\n"));
61
115
  }
62
- return { status: result.status, stdout: parsed, stderr: String(result.stderr || "").trim() };
63
- }
116
+ return { status: result.status, stdout: parsed, stderr: String(result.stderr || "").trim() };
117
+ }
118
+
119
+ function resolveHarnessApiBase(args) {
120
+ const raw = String(
121
+ args["base-url"]
122
+ || process.env.MYTE_API_BASE
123
+ || "https://api.myte.dev",
124
+ ).trim().replace(/\/+$/, "");
125
+ return raw.endsWith("/api") ? raw : `${raw}/api`;
126
+ }
127
+
128
+ async function createWebFeedbackCommentWithAttachment({
129
+ apiBase,
130
+ accessToken,
131
+ feedbackId,
132
+ projectId,
133
+ namespace,
134
+ timeoutMs,
135
+ }) {
136
+ const attachmentName = `${namespace}-comment-context.md`;
137
+ const attachmentContent = [
138
+ `# ${namespace} Comment Attachment`,
139
+ "",
140
+ "This disposable document proves that a web-created Feedback comment attachment",
141
+ "remains associated with its comment and becomes readable after Project Assistant sync.",
142
+ "",
143
+ ].join("\n");
144
+ const controller = new AbortController();
145
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
146
+ let response;
147
+ let body;
148
+ try {
149
+ response = await fetch(`${apiBase}/feedbacks/${feedbackId}/comments`, {
150
+ method: "POST",
151
+ headers: {
152
+ Authorization: `Bearer ${accessToken}`,
153
+ "Content-Type": "application/json",
154
+ },
155
+ body: JSON.stringify({
156
+ project_id: projectId,
157
+ content: `${namespace} web attachment sync certification comment.`,
158
+ attachments: [
159
+ {
160
+ name: attachmentName,
161
+ data: `data:text/markdown;base64,${Buffer.from(attachmentContent, "utf8").toString("base64")}`,
162
+ size: Buffer.byteLength(attachmentContent, "utf8"),
163
+ type: "text/markdown",
164
+ },
165
+ ],
166
+ }),
167
+ signal: controller.signal,
168
+ });
169
+ body = await response.json();
170
+ } finally {
171
+ clearTimeout(timeout);
172
+ }
173
+ if (!response.ok || body?.status !== "success" || !body?.data?.comment_id) {
174
+ throw new Error(
175
+ `Web Feedback comment attachment fixture failed (${response.status}): ${body?.message || "unknown response"}`,
176
+ );
177
+ }
178
+ return {
179
+ commentId: String(body.data.comment_id),
180
+ commentContent: String(body.data.content || ""),
181
+ attachmentName,
182
+ attachmentContent,
183
+ };
184
+ }
64
185
 
65
186
  function assert(condition, message) {
66
187
  if (!condition) {
@@ -68,7 +189,7 @@ function assert(condition, message) {
68
189
  }
69
190
  }
70
191
 
71
- function writePrd(workspace, title, index) {
192
+ function writePrd(workspace, title, index) {
72
193
  const filePath = path.join(workspace, `feedback-live-harness-${index}.md`);
73
194
  fs.writeFileSync(
74
195
  filePath,
@@ -87,25 +208,117 @@ function writePrd(workspace, title, index) {
87
208
  ].join("\n"),
88
209
  "utf8",
89
210
  );
90
- return filePath;
91
- }
211
+ return filePath;
212
+ }
213
+
214
+ function writeCreateApproval(workspace, runId, prdFiles) {
215
+ const approvalPath = path.join(workspace, `feedback-live-harness-${runId}-approval.md`);
216
+ fs.writeFileSync(
217
+ approvalPath,
218
+ [
219
+ "# Myte Feedback Certification Approval",
220
+ "",
221
+ `run_id: ${runId}`,
222
+ "command: myte create-prd",
223
+ `batch_count: ${prdFiles.length}`,
224
+ "targets:",
225
+ ...prdFiles.map((filePath) => `- ${path.basename(filePath)}`),
226
+ "proposed_change: Create disposable, namespaced TEST Feedback records for post-deploy certification.",
227
+ "reason: Verify the published CLI and deployed Feedback workflow before exact-ID archival.",
228
+ "",
229
+ ].join("\n"),
230
+ "utf8",
231
+ );
232
+ return approvalPath;
233
+ }
234
+
235
+ function writeCommentApproval(workspace, runId, feedbackId) {
236
+ const filePath = path.join(workspace, `feedback-live-harness-${runId}-comment.md`);
237
+ fs.writeFileSync(
238
+ filePath,
239
+ [
240
+ `# ${runId} Feedback certification comment`,
241
+ "",
242
+ `feedback_id: ${feedbackId}`,
243
+ `run_id: ${runId}`,
244
+ "proposed_change: Record a disposable, namespaced certification comment.",
245
+ "reason: Verify the project-scoped Feedback comment route and idempotency controls.",
246
+ "",
247
+ ].join("\n"),
248
+ "utf8",
249
+ );
250
+ return filePath;
251
+ }
252
+
253
+ function responseRequestId(payload) {
254
+ return String(
255
+ payload?.request?.request_id
256
+ || payload?.request?._id
257
+ || payload?.request_id
258
+ || payload?._id
259
+ || "",
260
+ ).trim();
261
+ }
262
+
263
+ function responseEventId(payload) {
264
+ return String(
265
+ payload?.event?.event_id
266
+ || payload?.event?._id
267
+ || payload?.event_id
268
+ || "",
269
+ ).trim();
270
+ }
271
+
272
+ function responseVersionId(version) {
273
+ return String(version?.version_id || version?._id || "").trim();
274
+ }
275
+
276
+ function assertDocumentSetMaterialized(workspace, feedbackId, expectedTitles) {
277
+ const root = path.join(
278
+ workspace,
279
+ "MyteCommandCenter",
280
+ "PRD",
281
+ "feedback-sync",
282
+ feedbackId,
283
+ );
284
+ assert(fs.existsSync(root), `Missing synchronized PRD document directory for ${feedbackId}.`);
285
+ const files = fs.readdirSync(root)
286
+ .filter((name) => name.toLowerCase().endsWith(".md"))
287
+ .sort();
288
+ assert(files.length === expectedTitles.length, `Expected ${expectedTitles.length} synchronized PRD files, got ${files.length}.`);
289
+ files.forEach((name, index) => {
290
+ const content = fs.readFileSync(path.join(root, name), "utf8");
291
+ assert(
292
+ content.includes(`# ${expectedTitles[index]}`),
293
+ `Synchronized document ${name} did not preserve its expected title.`,
294
+ );
295
+ });
296
+ return files;
297
+ }
298
+
299
+ function writeCertificationManifest(manifestPath, manifest) {
300
+ validateCertificationManifest(manifest);
301
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
302
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
303
+ }
92
304
 
93
- function loadFeedbackManifest(workspace) {
305
+ function loadFeedbackManifest(workspace) {
94
306
  const manifestPath = path.join(workspace, "MyteCommandCenter", "data", "feedback.yml");
95
307
  if (!fs.existsSync(manifestPath)) {
96
308
  return { items: [] };
97
- }
98
- const text = fs.readFileSync(manifestPath, "utf8");
99
- try {
100
- const parsed = JSON.parse(text);
101
- const items = []
102
- .concat(Array.isArray(parsed.items) ? parsed.items : [])
103
- .concat(Array.isArray(parsed.queue) ? parsed.queue : []);
104
- return { items };
105
- } catch (_err) {
106
- // Older local files may still be YAML-like. Keep a small fallback parser so
107
- // the harness can validate either shape without adding runtime deps.
108
- }
309
+ }
310
+ try {
311
+ const parsed = readYamlFile(manifestPath);
312
+ if (parsed && typeof parsed === "object") {
313
+ return {
314
+ ...parsed,
315
+ items: Array.isArray(parsed.items) ? parsed.items : [],
316
+ };
317
+ }
318
+ } catch (_err) {
319
+ // Keep a small fallback parser for a partially written legacy snapshot.
320
+ }
321
+ const text = fs.readFileSync(manifestPath, "utf8");
109
322
  const items = [];
110
323
  let current = null;
111
324
  for (const line of text.split(/\r?\n/)) {
@@ -127,8 +340,62 @@ function loadFeedbackManifest(workspace) {
127
340
  }
128
341
  }
129
342
  if (current) items.push(current);
130
- return { items };
131
- }
343
+ return { items };
344
+ }
345
+
346
+ function findFeedbackItem(workspace, feedbackId) {
347
+ const manifest = loadFeedbackManifest(workspace);
348
+ return (manifest.items || []).find(
349
+ (item) => String(item?.feedback_id || item?.id || "") === String(feedbackId),
350
+ ) || null;
351
+ }
352
+
353
+ function assertSynchronizedComment({
354
+ workspace,
355
+ feedbackId,
356
+ commentId,
357
+ expectedContent,
358
+ expectedAttachment,
359
+ }) {
360
+ const item = findFeedbackItem(workspace, feedbackId);
361
+ assert(item, `Feedback ${feedbackId} was missing while validating synchronized comments.`);
362
+ const turn = (item.conversation_turns || []).find(
363
+ (candidate) => String(candidate?.comment_id || "") === String(commentId),
364
+ );
365
+ assert(turn, `Comment ${commentId} was not linked to Feedback ${feedbackId} after sync.`);
366
+ assert(
367
+ String(turn.content || "").includes(expectedContent),
368
+ `Comment ${commentId} did not preserve its expected content after sync.`,
369
+ );
370
+
371
+ if (!expectedAttachment) return;
372
+ const attachment = (turn.attachments || []).find(
373
+ (candidate) => String(candidate?.name || "") === expectedAttachment.name,
374
+ );
375
+ assert(
376
+ attachment,
377
+ `Comment ${commentId} did not preserve attachment ${expectedAttachment.name}.`,
378
+ );
379
+ assert(
380
+ attachment.context_status === "readable",
381
+ `Comment attachment ${expectedAttachment.name} was not synchronized as readable context.`,
382
+ );
383
+ assert(
384
+ attachment.local_file,
385
+ `Comment attachment ${expectedAttachment.name} did not receive a local file reference.`,
386
+ );
387
+ const commandCenterRoot = path.resolve(workspace, "MyteCommandCenter");
388
+ const localPath = path.resolve(commandCenterRoot, String(attachment.local_file));
389
+ assert(
390
+ localPath.startsWith(`${commandCenterRoot}${path.sep}`),
391
+ `Comment attachment local path escaped MyteCommandCenter: ${attachment.local_file}`,
392
+ );
393
+ assert(fs.existsSync(localPath), `Synchronized comment attachment file is missing: ${localPath}`);
394
+ assert(
395
+ fs.readFileSync(localPath, "utf8").includes(expectedAttachment.contentMarker),
396
+ `Synchronized comment attachment ${expectedAttachment.name} lost its readable content.`,
397
+ );
398
+ }
132
399
 
133
400
  function findFeedbackState(workspace, feedbackId) {
134
401
  const manifest = loadFeedbackManifest(workspace);
@@ -163,9 +430,52 @@ function assertFeedbackAbsent(workspace, feedbackIds) {
163
430
  }
164
431
  }
165
432
 
166
- function main() {
167
- const args = parseArgs(process.argv.slice(2));
168
- requireConfirm(args);
433
+ async function main() {
434
+ const args = parseArgs(process.argv.slice(2));
435
+ if (!args["confirm-live"]) {
436
+ console.log(JSON.stringify({
437
+ ok: true,
438
+ dry_run: true,
439
+ message: "No network, project mutation, or local certification artifact was created.",
440
+ required_live_flag: "--confirm-live",
441
+ optional_strict_role_flag: "--require-role-matrix",
442
+ optional_comment_attachment_flag: "--require-comment-attachment-sync",
443
+ required_role_environment: [
444
+ "MYTE_OWNER_API_KEY",
445
+ "MYTE_DELEGATE_API_KEY",
446
+ "MYTE_COLLABORATOR_API_KEY",
447
+ ],
448
+ comment_attachment_environment: "MYTE_WEB_ACCESS_TOKEN",
449
+ scenario_matrix: LIVE_SCENARIO_MATRIX,
450
+ }, null, 2));
451
+ return;
452
+ }
453
+ requireConfirm(args);
454
+
455
+ const ownerApiKey = String(
456
+ process.env.MYTE_OWNER_API_KEY
457
+ || process.env.MYTE_API_KEY
458
+ || process.env.MYTE_PROJECT_API_KEY
459
+ || "",
460
+ ).trim();
461
+ const delegateApiKey = String(process.env.MYTE_DELEGATE_API_KEY || "").trim();
462
+ const collaboratorApiKey = String(process.env.MYTE_COLLABORATOR_API_KEY || "").trim();
463
+ const webAccessToken = String(process.env.MYTE_WEB_ACCESS_TOKEN || "").trim();
464
+ process.env.MYTE_API_KEY = ownerApiKey;
465
+ process.env.MYTE_PROJECT_API_KEY = ownerApiKey;
466
+ if (
467
+ args["require-role-matrix"]
468
+ && (!ownerApiKey || !delegateApiKey || !collaboratorApiKey)
469
+ ) {
470
+ throw new Error(
471
+ "--require-role-matrix requires MYTE_OWNER_API_KEY, MYTE_DELEGATE_API_KEY, and MYTE_COLLABORATOR_API_KEY.",
472
+ );
473
+ }
474
+ if (args["require-comment-attachment-sync"] && !webAccessToken) {
475
+ throw new Error(
476
+ "--require-comment-attachment-sync requires an ephemeral MYTE_WEB_ACCESS_TOKEN for a user assigned to the target project.",
477
+ );
478
+ }
169
479
 
170
480
  const workspace = path.resolve(
171
481
  args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-feedback-full-harness-")),
@@ -177,40 +487,487 @@ function main() {
177
487
  baseArgs.push("--base-url", String(args["base-url"]));
178
488
  }
179
489
 
180
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
181
- const reason = String(args.reason || "Disposable live Feedback harness verification");
182
- const createdFeedbackIds = [];
183
- const reviewRequestIds = [];
184
-
185
- try {
186
- const prdFiles = [];
187
- for (let index = 1; index <= 5; index += 1) {
188
- const title = `TEST Feedback Harness ${stamp} ${index}`;
189
- prdFiles.push(writePrd(workspace, title, index));
190
- }
191
-
192
- const create = runCli([
193
- "create-prd",
490
+ const runId = String(args["run-id"] || new Date().toISOString().replace(/[:.]/g, "-"));
491
+ const namespace = `MYTE_TEST_${runId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
492
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
493
+ const reason = String(args.reason || "Disposable live Feedback harness verification");
494
+ const createdFeedbackIds = [];
495
+ const reviewRequestIds = [];
496
+ const reviewArtifactByRequestId = new Map();
497
+ let documentSetFeedbackId = null;
498
+ let documentSetRequestId = null;
499
+ const roleMatrix = {
500
+ required: Boolean(args["require-role-matrix"]),
501
+ owner: true,
502
+ delegate: Boolean(delegateApiKey),
503
+ collaborator: Boolean(collaboratorApiKey),
504
+ same_project: true,
505
+ collaborator_review_denied: null,
506
+ collaborator_direct_apply_denied: null,
507
+ collaborator_archive_denied: null,
508
+ collaborator_writes_used: false,
509
+ owner_review_used: false,
510
+ delegate_review_used: false,
511
+ };
512
+ let commentAttachmentSyncVerified = false;
513
+ const manifestPath = path.resolve(
514
+ args.manifest
515
+ || path.join(workspace, "MyteCommandCenter", "certification", `${runId}.json`),
516
+ );
517
+ const manifest = createCertificationManifest({
518
+ runId,
519
+ namespace,
520
+ environment: String(args.environment || "dev"),
521
+ });
522
+ manifest.state = "running";
523
+ writeCertificationManifest(manifestPath, manifest);
524
+
525
+ try {
526
+ const doctor = runCli(
527
+ ["doctor", "--json", "--timeout-ms", String(args["timeout-ms"] || 60000), ...baseArgs],
528
+ workspace,
529
+ { apiKey: ownerApiKey },
530
+ ).stdout;
531
+ assert(doctor.ok === true, `Myte doctor failed at ${doctor.failure_layer || "unknown"} layer.`);
532
+
533
+ const config = runCli(
534
+ ["config", "--json", ...baseArgs],
535
+ workspace,
536
+ { apiKey: ownerApiKey },
537
+ ).stdout;
538
+ manifest.project_id = String(
539
+ config.project_id || config.project?.id || config.project?._id || "",
540
+ ) || null;
541
+ for (const [role, apiKey] of [
542
+ ["delegate", delegateApiKey],
543
+ ["collaborator", collaboratorApiKey],
544
+ ]) {
545
+ if (!apiKey) continue;
546
+ const roleConfig = runCli(
547
+ ["config", "--json", ...baseArgs],
548
+ workspace,
549
+ { apiKey },
550
+ ).stdout;
551
+ const roleProjectId = String(
552
+ roleConfig.project_id || roleConfig.project?.id || roleConfig.project?._id || "",
553
+ ).trim();
554
+ if (!manifest.project_id || roleProjectId !== manifest.project_id) {
555
+ roleMatrix.same_project = false;
556
+ throw new Error(`${role} certification key is not scoped to the owner key's project.`);
557
+ }
558
+ }
559
+ writeCertificationManifest(manifestPath, manifest);
560
+
561
+ const prdFiles = [];
562
+ for (let index = 1; index <= 5; index += 1) {
563
+ const title = `${namespace} Feedback ${stamp} ${index}`;
564
+ prdFiles.push(writePrd(workspace, title, index));
565
+ }
566
+ const createApprovalPath = writeCreateApproval(workspace, runId, prdFiles);
567
+
568
+ const create = runCli([
569
+ "create-prd",
194
570
  ...prdFiles,
195
- "--description",
196
- "Disposable TEST Feedback created by the live Feedback harness.",
197
- "--json",
571
+ "--description",
572
+ `Disposable ${namespace} Feedback created by the live certification harness.`,
573
+ "--confirm-write",
574
+ "--approval-artifact",
575
+ createApprovalPath,
576
+ "--json",
198
577
  ...baseArgs,
199
578
  ], workspace).stdout;
200
579
 
201
580
  const createdItems = Array.isArray(create.items) ? create.items : [];
202
581
  for (const item of createdItems) {
203
- if (item.status === "created" && item.feedback_id) {
204
- createdFeedbackIds.push(String(item.feedback_id));
205
- }
206
- }
207
- assert(createdFeedbackIds.length === 5, `Expected 5 created TEST feedback items, got ${createdFeedbackIds.length}: ${JSON.stringify(create, null, 2)}`);
208
-
209
- runCli(["feedback-sync", "--json", ...baseArgs], workspace);
210
- assertFeedbackPresent(workspace, createdFeedbackIds, "todo");
211
-
212
- for (const feedbackId of createdFeedbackIds) {
213
- const draft = runCli([
582
+ if (item.status === "created" && item.feedback_id) {
583
+ createdFeedbackIds.push(String(item.feedback_id));
584
+ recordCertificationArtifact(manifest, {
585
+ kind: "feedback",
586
+ id: String(item.feedback_id),
587
+ metadata: { source: "create-prd", cleanup: "archive" },
588
+ });
589
+ }
590
+ }
591
+ writeCertificationManifest(manifestPath, manifest);
592
+ assert(createdFeedbackIds.length === 5, `Expected 5 created TEST feedback items, got ${createdFeedbackIds.length}: ${JSON.stringify(create, null, 2)}`);
593
+
594
+ const documentSetTitles = [
595
+ `${namespace} Document Set Overview`,
596
+ `${namespace} Document Set Harness`,
597
+ `${namespace} Document Set Security`,
598
+ ];
599
+ const documentSetFiles = documentSetTitles.map((title, index) =>
600
+ writePrd(workspace, title, 101 + index),
601
+ );
602
+ const documentSetApprovalPath = writeCreateApproval(
603
+ workspace,
604
+ `${runId}-document-set`,
605
+ documentSetFiles,
606
+ );
607
+ const documentSetCreate = runCli([
608
+ "create-prd",
609
+ ...documentSetFiles,
610
+ "--document-set",
611
+ "--title",
612
+ `${namespace} Three Document PRD`,
613
+ "--description",
614
+ `Disposable ${namespace} three-document PRD certification record.`,
615
+ "--confirm-write",
616
+ "--approval-artifact",
617
+ documentSetApprovalPath,
618
+ "--json",
619
+ ...baseArgs,
620
+ ], workspace).stdout;
621
+ documentSetFeedbackId = String(documentSetCreate.feedback_id || "");
622
+ assert(documentSetFeedbackId, `Document-set creation did not return feedback_id: ${JSON.stringify(documentSetCreate, null, 2)}`);
623
+ assert(documentSetCreate.document_count === 3, `Expected three PRD documents: ${JSON.stringify(documentSetCreate, null, 2)}`);
624
+ assert(Array.isArray(documentSetCreate.documents) && documentSetCreate.documents.length === 3, "Document-set metadata did not contain three documents.");
625
+ recordCertificationArtifact(manifest, {
626
+ kind: "feedback",
627
+ id: documentSetFeedbackId,
628
+ metadata: {
629
+ source: "create-prd-document-set",
630
+ document_count: 3,
631
+ cleanup: "archive",
632
+ },
633
+ });
634
+ for (const document of documentSetCreate.documents) {
635
+ if (!document.document_id) continue;
636
+ recordCertificationArtifact(manifest, {
637
+ kind: "object",
638
+ id: String(document.document_id),
639
+ metadata: {
640
+ object_type: "prd_document",
641
+ feedback_id: documentSetFeedbackId,
642
+ cleanup: "retained_with_archived_parent",
643
+ },
644
+ });
645
+ }
646
+ writeCertificationManifest(manifestPath, manifest);
647
+
648
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
649
+ assertFeedbackPresent(workspace, createdFeedbackIds, "todo");
650
+ assertFeedbackPresent(workspace, [documentSetFeedbackId], "todo");
651
+ assertDocumentSetMaterialized(
652
+ workspace,
653
+ documentSetFeedbackId,
654
+ documentSetTitles,
655
+ );
656
+
657
+ const collaboratorWriteKey = collaboratorApiKey || ownerApiKey;
658
+ const operationalReviewKey = delegateApiKey || ownerApiKey;
659
+ roleMatrix.collaborator_writes_used = Boolean(collaboratorApiKey);
660
+ const commentApprovalPath = writeCommentApproval(
661
+ workspace,
662
+ runId,
663
+ createdFeedbackIds[0],
664
+ );
665
+ const comment = runCli([
666
+ "feedback",
667
+ "comment",
668
+ "--feedback-id",
669
+ createdFeedbackIds[0],
670
+ "--body-file",
671
+ commentApprovalPath,
672
+ "--confirm-write",
673
+ "--approval-artifact",
674
+ commentApprovalPath,
675
+ "--no-sync",
676
+ "--json",
677
+ ...baseArgs,
678
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
679
+ assert(comment.comment_id, `Feedback comment did not return comment_id: ${JSON.stringify(comment, null, 2)}`);
680
+ recordCertificationArtifact(manifest, {
681
+ kind: "comment",
682
+ id: String(comment.comment_id),
683
+ metadata: {
684
+ feedback_id: createdFeedbackIds[0],
685
+ cleanup: "retained_with_archived_parent",
686
+ },
687
+ });
688
+ let webAttachmentFixture = null;
689
+ if (webAccessToken) {
690
+ webAttachmentFixture = await createWebFeedbackCommentWithAttachment({
691
+ apiBase: resolveHarnessApiBase(args),
692
+ accessToken: webAccessToken,
693
+ feedbackId: createdFeedbackIds[0],
694
+ projectId: manifest.project_id,
695
+ namespace,
696
+ timeoutMs: Number(args["timeout-ms"] || 60000),
697
+ });
698
+ recordCertificationArtifact(manifest, {
699
+ kind: "comment",
700
+ id: webAttachmentFixture.commentId,
701
+ metadata: {
702
+ feedback_id: createdFeedbackIds[0],
703
+ attachment_name: webAttachmentFixture.attachmentName,
704
+ cleanup: "retained_with_archived_parent",
705
+ },
706
+ });
707
+ }
708
+
709
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
710
+ assertSynchronizedComment({
711
+ workspace,
712
+ feedbackId: createdFeedbackIds[0],
713
+ commentId: String(comment.comment_id),
714
+ expectedContent: runId,
715
+ });
716
+ if (webAttachmentFixture) {
717
+ assertSynchronizedComment({
718
+ workspace,
719
+ feedbackId: createdFeedbackIds[0],
720
+ commentId: webAttachmentFixture.commentId,
721
+ expectedContent: namespace,
722
+ expectedAttachment: {
723
+ name: webAttachmentFixture.attachmentName,
724
+ contentMarker: `${namespace} Comment Attachment`,
725
+ },
726
+ });
727
+ commentAttachmentSyncVerified = true;
728
+ }
729
+
730
+ const staleGuardFeedbackId = createdFeedbackIds[4];
731
+ const staleDraft = runCli([
732
+ "feedback",
733
+ "edit",
734
+ "--feedback-id",
735
+ staleGuardFeedbackId,
736
+ "--title",
737
+ `${namespace} stale local proposal`,
738
+ "--reason",
739
+ "Harness creates this artifact before a remote revision.",
740
+ "--json",
741
+ ...baseArgs,
742
+ ], workspace).stdout;
743
+ assert(staleDraft.artifact_path, "Stale-edit guard did not create its initial local artifact.");
744
+
745
+ const staleRemoteMove = runCli([
746
+ "feedback",
747
+ "move",
748
+ "--feedback-id",
749
+ staleGuardFeedbackId,
750
+ "--from-state",
751
+ "todo",
752
+ "--to-state",
753
+ "in_progress",
754
+ "--reason",
755
+ "Harness advances the server revision after the local artifact was created.",
756
+ "--no-sync",
757
+ "--json",
758
+ ...baseArgs,
759
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
760
+ const staleRemoteMoveEventId = responseEventId(staleRemoteMove);
761
+ assert(staleRemoteMoveEventId, "Stale-edit guard move did not return an event id.");
762
+
763
+ const staleValidation = runCli([
764
+ "feedback",
765
+ "validate",
766
+ "--file",
767
+ staleDraft.artifact_path,
768
+ "--json",
769
+ ...baseArgs,
770
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
771
+ assert(staleValidation.ok === true && staleValidation.valid === false, "A stale local Feedback artifact unexpectedly validated.");
772
+ assert(
773
+ (staleValidation.errors || []).some((error) => error?.code === "snapshot_mismatch"),
774
+ `Stale artifact did not return snapshot_mismatch: ${JSON.stringify(staleValidation, null, 2)}`,
775
+ );
776
+
777
+ const staleGuardUndo = runCli([
778
+ "feedback",
779
+ "undo",
780
+ "--feedback-id",
781
+ staleGuardFeedbackId,
782
+ "--event-id",
783
+ staleRemoteMoveEventId,
784
+ "--reason",
785
+ "Harness restores the disposable card after stale-artifact certification.",
786
+ "--no-sync",
787
+ "--json",
788
+ ...baseArgs,
789
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
790
+ assert(
791
+ String(staleGuardUndo.feedback_state || "") === "todo",
792
+ `Stale-edit guard cleanup did not restore todo: ${JSON.stringify(staleGuardUndo, null, 2)}`,
793
+ );
794
+
795
+ const forcedStaleApply = runCli([
796
+ "feedback",
797
+ "apply",
798
+ "--file",
799
+ staleDraft.artifact_path,
800
+ "--force",
801
+ "--no-sync",
802
+ "--json",
803
+ ...baseArgs,
804
+ ], workspace, { apiKey: ownerApiKey }).stdout;
805
+ assert(
806
+ String(forcedStaleApply.feedback?.title || "") === `${namespace} stale proposal`,
807
+ `Owner forced stale override did not apply the intended title: ${JSON.stringify(forcedStaleApply, null, 2)}`,
808
+ );
809
+ assert(
810
+ (forcedStaleApply.warnings || []).some(
811
+ (warning) => warning?.code === "snapshot_mismatch_forced",
812
+ ),
813
+ `Owner forced stale override did not report snapshot_mismatch_forced: ${JSON.stringify(forcedStaleApply, null, 2)}`,
814
+ );
815
+ assert(
816
+ forcedStaleApply.history?.force === true
817
+ && Boolean(String(forcedStaleApply.history?.reason || "").trim()),
818
+ `Owner forced stale override was not explicitly audited with a reason: ${JSON.stringify(forcedStaleApply, null, 2)}`,
819
+ );
820
+
821
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
822
+ const freshDraft = runCli([
823
+ "feedback",
824
+ "edit",
825
+ "--feedback-id",
826
+ staleGuardFeedbackId,
827
+ "--title",
828
+ `${namespace} fresh proposal after resync`,
829
+ "--reason",
830
+ "Harness confirms a fresh artifact can replace the rejected stale proposal.",
831
+ "--json",
832
+ ...baseArgs,
833
+ ], workspace).stdout;
834
+ const freshValidation = runCli([
835
+ "feedback",
836
+ "validate",
837
+ "--file",
838
+ freshDraft.artifact_path,
839
+ "--json",
840
+ ...baseArgs,
841
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
842
+ assert(
843
+ freshValidation.ok === true && freshValidation.valid !== false,
844
+ `Fresh artifact did not validate after resync: ${JSON.stringify(freshValidation, null, 2)}`,
845
+ );
846
+
847
+ if (collaboratorApiKey) {
848
+ const directApplyDenied = runCli([
849
+ "feedback",
850
+ "apply",
851
+ "--file",
852
+ freshDraft.artifact_path,
853
+ "--no-sync",
854
+ "--json",
855
+ ...baseArgs,
856
+ ], workspace, { allowFailure: true, apiKey: collaboratorApiKey }).stdout;
857
+ roleMatrix.collaborator_direct_apply_denied =
858
+ directApplyDenied.ok === false && directApplyDenied.status === 403;
859
+ assert(
860
+ roleMatrix.collaborator_direct_apply_denied,
861
+ `Regular collaborator direct apply was not denied: ${JSON.stringify(directApplyDenied, null, 2)}`,
862
+ );
863
+
864
+ const collaboratorArchiveDenied = runCli([
865
+ "feedback",
866
+ "move",
867
+ "--feedback-id",
868
+ staleGuardFeedbackId,
869
+ "--from-state",
870
+ "todo",
871
+ "--to-state",
872
+ "archived",
873
+ "--reason",
874
+ "Harness expects regular collaborator archive to be denied.",
875
+ "--no-sync",
876
+ "--json",
877
+ ...baseArgs,
878
+ ], workspace, { allowFailure: true, apiKey: collaboratorApiKey }).stdout;
879
+ roleMatrix.collaborator_archive_denied =
880
+ collaboratorArchiveDenied.ok === false && collaboratorArchiveDenied.status === 403;
881
+ assert(
882
+ roleMatrix.collaborator_archive_denied,
883
+ `Regular collaborator archive was not denied: ${JSON.stringify(collaboratorArchiveDenied, null, 2)}`,
884
+ );
885
+ }
886
+
887
+ const directMove = runCli([
888
+ "feedback",
889
+ "move",
890
+ "--feedback-id",
891
+ createdFeedbackIds[0],
892
+ "--from-state",
893
+ "todo",
894
+ "--to-state",
895
+ "in_progress",
896
+ "--reason",
897
+ "Harness verifies a collaborator-safe active-state move.",
898
+ "--no-sync",
899
+ "--json",
900
+ ...baseArgs,
901
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
902
+ const directMoveEventId = responseEventId(directMove);
903
+ assert(directMoveEventId, `Feedback move did not return an event id: ${JSON.stringify(directMove, null, 2)}`);
904
+ recordCertificationArtifact(manifest, {
905
+ kind: "feedback_event",
906
+ id: directMoveEventId,
907
+ metadata: {
908
+ feedback_id: createdFeedbackIds[0],
909
+ action: "move",
910
+ cleanup: "retain_audit",
911
+ },
912
+ });
913
+
914
+ const undo = runCli([
915
+ "feedback",
916
+ "undo",
917
+ "--feedback-id",
918
+ createdFeedbackIds[0],
919
+ "--event-id",
920
+ directMoveEventId,
921
+ "--reason",
922
+ "Harness verifies exact event undo.",
923
+ "--json",
924
+ ...baseArgs,
925
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
926
+ assert(
927
+ String(undo.feedback_state || "") === "todo",
928
+ `Feedback undo did not restore todo: ${JSON.stringify(undo, null, 2)}`,
929
+ );
930
+ const undoEventId = responseEventId(undo);
931
+ if (undoEventId) {
932
+ recordCertificationArtifact(manifest, {
933
+ kind: "feedback_event",
934
+ id: undoEventId,
935
+ metadata: {
936
+ feedback_id: createdFeedbackIds[0],
937
+ action: "undo",
938
+ cleanup: "retain_audit",
939
+ },
940
+ });
941
+ }
942
+
943
+ const getAfterUndo = runCli([
944
+ "feedback",
945
+ "get",
946
+ "--feedback-id",
947
+ createdFeedbackIds[0],
948
+ "--json",
949
+ ...baseArgs,
950
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
951
+ assert(
952
+ String(getAfterUndo.feedback?.feedback_state || "") === "todo",
953
+ `Feedback get did not return the restored state: ${JSON.stringify(getAfterUndo, null, 2)}`,
954
+ );
955
+ const historyAfterUndo = runCli([
956
+ "feedback",
957
+ "history",
958
+ "--feedback-id",
959
+ createdFeedbackIds[0],
960
+ "--json",
961
+ ...baseArgs,
962
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
963
+ assert(
964
+ Array.isArray(historyAfterUndo.events) && historyAfterUndo.events.length >= 2,
965
+ "Feedback history did not include move and undo evidence.",
966
+ );
967
+ writeCertificationManifest(manifestPath, manifest);
968
+
969
+ for (const feedbackId of createdFeedbackIds) {
970
+ const draft = runCli([
214
971
  "feedback",
215
972
  "status",
216
973
  "--feedback-id",
@@ -221,23 +978,177 @@ function main() {
221
978
  reason,
222
979
  "--json",
223
980
  ...baseArgs,
224
- ], workspace).stdout;
225
- assert(draft.artifact_path, `feedback status did not return artifact_path for ${feedbackId}`);
226
-
227
- const submit = runCli([
981
+ ], workspace).stdout;
982
+ assert(draft.artifact_path, `feedback status did not return artifact_path for ${feedbackId}`);
983
+
984
+ const validation = runCli([
985
+ "feedback",
986
+ "validate",
987
+ "--file",
988
+ draft.artifact_path,
989
+ "--json",
990
+ ...baseArgs,
991
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
992
+ assert(
993
+ validation.ok === true && validation.valid !== false,
994
+ `feedback validate failed for ${feedbackId}: ${JSON.stringify(validation, null, 2)}`,
995
+ );
996
+
997
+ const submit = runCli([
228
998
  "feedback",
229
999
  "submit",
230
- "--file",
231
- draft.artifact_path,
232
- "--json",
233
- ...baseArgs,
234
- ], workspace).stdout;
235
- const requestId = String(submit.request?.request_id || submit.request?._id || "");
1000
+ "--file",
1001
+ draft.artifact_path,
1002
+ "--confirm-write",
1003
+ "--approval-artifact",
1004
+ draft.artifact_path,
1005
+ "--json",
1006
+ ...baseArgs,
1007
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1008
+ const requestId = responseRequestId(submit);
236
1009
  assert(requestId, `feedback submit did not return request id for ${feedbackId}: ${JSON.stringify(submit, null, 2)}`);
237
- reviewRequestIds.push(requestId);
238
- }
239
-
240
- const blocked = runCli([
1010
+ reviewRequestIds.push(requestId);
1011
+ reviewArtifactByRequestId.set(requestId, draft.artifact_path);
1012
+ recordCertificationArtifact(manifest, {
1013
+ kind: "review_request",
1014
+ id: requestId,
1015
+ metadata: { feedback_id: feedbackId },
1016
+ });
1017
+ writeCertificationManifest(manifestPath, manifest);
1018
+ }
1019
+
1020
+ const openReviews = runCli([
1021
+ "feedback",
1022
+ "reviews",
1023
+ "--status",
1024
+ "open",
1025
+ "--json",
1026
+ ...baseArgs,
1027
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1028
+ const openRequestIds = new Set(
1029
+ (openReviews.requests || []).map((request) => responseRequestId(request)).filter(Boolean),
1030
+ );
1031
+ assert(
1032
+ reviewRequestIds.every((requestId) => openRequestIds.has(requestId)),
1033
+ "Feedback review list did not include every submitted certification request.",
1034
+ );
1035
+ const reviewDetail = runCli([
1036
+ "feedback",
1037
+ "reviews",
1038
+ "--request-id",
1039
+ reviewRequestIds[0],
1040
+ "--json",
1041
+ ...baseArgs,
1042
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1043
+ assert(
1044
+ responseRequestId(reviewDetail) === reviewRequestIds[0],
1045
+ "Feedback review detail returned the wrong request.",
1046
+ );
1047
+
1048
+ if (collaboratorApiKey) {
1049
+ const deniedReview = runCli([
1050
+ "feedback",
1051
+ "review",
1052
+ "--request-id",
1053
+ reviewRequestIds[0],
1054
+ "--action",
1055
+ "approve",
1056
+ "--reason",
1057
+ "Harness expects regular collaborator approval to be denied.",
1058
+ "--no-sync",
1059
+ "--json",
1060
+ ...baseArgs,
1061
+ ], workspace, { allowFailure: true, apiKey: collaboratorApiKey }).stdout;
1062
+ roleMatrix.collaborator_review_denied =
1063
+ deniedReview.ok === false && deniedReview.status === 403;
1064
+ assert(
1065
+ roleMatrix.collaborator_review_denied,
1066
+ `Regular collaborator review was not denied as expected: ${JSON.stringify(deniedReview, null, 2)}`,
1067
+ );
1068
+ }
1069
+
1070
+ const requestChanges = runCli([
1071
+ "feedback",
1072
+ "review",
1073
+ "--request-id",
1074
+ reviewRequestIds[0],
1075
+ "--action",
1076
+ "request_changes",
1077
+ "--reason",
1078
+ "Harness requests a submitter revision.",
1079
+ "--verification-evidence",
1080
+ "Initial validation completed; revision path remains to be certified.",
1081
+ "--no-sync",
1082
+ "--json",
1083
+ ...baseArgs,
1084
+ ], workspace, { apiKey: operationalReviewKey }).stdout;
1085
+ assert(
1086
+ String(requestChanges.request?.status || requestChanges.status || "") === "needs_changes",
1087
+ `Request-changes did not enter needs_changes: ${JSON.stringify(requestChanges, null, 2)}`,
1088
+ );
1089
+
1090
+ const revised = runCli([
1091
+ "feedback",
1092
+ "revise",
1093
+ "--request-id",
1094
+ reviewRequestIds[0],
1095
+ "--file",
1096
+ reviewArtifactByRequestId.get(reviewRequestIds[0]),
1097
+ "--confirm-write",
1098
+ "--approval-artifact",
1099
+ reviewArtifactByRequestId.get(reviewRequestIds[0]),
1100
+ "--json",
1101
+ ...baseArgs,
1102
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1103
+ assert(
1104
+ responseRequestId(revised) === reviewRequestIds[0],
1105
+ `Feedback revise returned the wrong request: ${JSON.stringify(revised, null, 2)}`,
1106
+ );
1107
+
1108
+ const rejected = runCli([
1109
+ "feedback",
1110
+ "review",
1111
+ "--request-id",
1112
+ reviewRequestIds[1],
1113
+ "--action",
1114
+ "reject",
1115
+ "--reason",
1116
+ "Harness verifies terminal rejection without applying changes.",
1117
+ "--verification-evidence",
1118
+ "Negative review path certified.",
1119
+ "--no-sync",
1120
+ "--json",
1121
+ ...baseArgs,
1122
+ ], workspace, { apiKey: operationalReviewKey }).stdout;
1123
+ assert(
1124
+ String(rejected.request?.status || rejected.status || "") === "rejected",
1125
+ `Feedback rejection did not become terminal: ${JSON.stringify(rejected, null, 2)}`,
1126
+ );
1127
+
1128
+ const cancelled = runCli([
1129
+ "feedback",
1130
+ "review",
1131
+ "--request-id",
1132
+ reviewRequestIds[2],
1133
+ "--action",
1134
+ "cancel",
1135
+ "--reason",
1136
+ "Harness submitter cancels a disposable review.",
1137
+ "--no-sync",
1138
+ "--json",
1139
+ ...baseArgs,
1140
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1141
+ assert(
1142
+ String(cancelled.request?.status || cancelled.status || "") === "cancelled",
1143
+ `Feedback cancellation did not become terminal: ${JSON.stringify(cancelled, null, 2)}`,
1144
+ );
1145
+
1146
+ const pendingApprovalRequestIds = [
1147
+ reviewRequestIds[0],
1148
+ reviewRequestIds[3],
1149
+ reviewRequestIds[4],
1150
+ ];
1151
+ const blocked = runCli([
241
1152
  "feedback",
242
1153
  "move",
243
1154
  "--feedback-ids",
@@ -256,28 +1167,69 @@ function main() {
256
1167
  assert(blocked.status === 409, `Expected 409 pending review block, got ${blocked.status}.`);
257
1168
  assert(blocked.data?.code === "pending_feedback_review", `Expected pending_feedback_review, got ${JSON.stringify(blocked, null, 2)}.`);
258
1169
 
259
- const review = runCli([
260
- "feedback",
261
- "review",
262
- "--request-ids",
263
- reviewRequestIds.join(","),
264
- "--action",
265
- "approve",
266
- "--reason",
267
- "Harness batch approval.",
268
- "--json",
269
- ...baseArgs,
270
- ], workspace).stdout;
271
- assert(review.ok !== false && review.updated_count === 5, `batch review failed: ${JSON.stringify(review, null, 2)}`);
272
-
273
- runCli(["feedback-sync", "--json", ...baseArgs], workspace);
274
- assertFeedbackPresent(workspace, createdFeedbackIds, "completed");
275
-
276
- const reopen = runCli([
1170
+ const ownerApproval = runCli([
1171
+ "feedback",
1172
+ "review",
1173
+ "--request-id",
1174
+ reviewRequestIds[0],
1175
+ "--action",
1176
+ "approve",
1177
+ "--reason",
1178
+ "Harness owner approval.",
1179
+ "--verification-evidence",
1180
+ "Stale-state, validation, role denial, request-changes, and revision checks passed.",
1181
+ "--deployment-evidence",
1182
+ "Dev post-deploy certification run; artifacts will be archived.",
1183
+ "--no-sync",
1184
+ "--json",
1185
+ ...baseArgs,
1186
+ ], workspace, { apiKey: ownerApiKey }).stdout;
1187
+ roleMatrix.owner_review_used = true;
1188
+ assert(
1189
+ String(ownerApproval.request?.status || ownerApproval.status || "") === "applied",
1190
+ `Owner approval did not apply: ${JSON.stringify(ownerApproval, null, 2)}`,
1191
+ );
1192
+
1193
+ const batchApprovalRequestIds = [
1194
+ reviewRequestIds[3],
1195
+ reviewRequestIds[4],
1196
+ ];
1197
+ const review = runCli([
1198
+ "feedback",
1199
+ "review",
1200
+ "--request-ids",
1201
+ batchApprovalRequestIds.join(","),
1202
+ "--action",
1203
+ "approve",
1204
+ "--reason",
1205
+ "Harness batch approval.",
1206
+ "--verification-evidence",
1207
+ "Validation, role checks, request-changes, and direct board guards passed.",
1208
+ "--deployment-evidence",
1209
+ "Dev post-deploy certification run; artifacts will be archived.",
1210
+ "--json",
1211
+ ...baseArgs,
1212
+ ], workspace, { apiKey: operationalReviewKey }).stdout;
1213
+ roleMatrix.delegate_review_used = Boolean(delegateApiKey);
1214
+ assert(review.ok !== false && review.updated_count === 2, `batch review failed: ${JSON.stringify(review, null, 2)}`);
1215
+
1216
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
1217
+ assertFeedbackPresent(
1218
+ workspace,
1219
+ [createdFeedbackIds[0], createdFeedbackIds[3], createdFeedbackIds[4]],
1220
+ "completed",
1221
+ );
1222
+ assertFeedbackPresent(
1223
+ workspace,
1224
+ [createdFeedbackIds[1], createdFeedbackIds[2]],
1225
+ "todo",
1226
+ );
1227
+
1228
+ const reopen = runCli([
277
1229
  "feedback",
278
1230
  "move",
279
1231
  "--feedback-ids",
280
- createdFeedbackIds.join(","),
1232
+ [createdFeedbackIds[0], createdFeedbackIds[3], createdFeedbackIds[4]].join(","),
281
1233
  "--from-state",
282
1234
  "completed",
283
1235
  "--to-state",
@@ -287,29 +1239,206 @@ function main() {
287
1239
  "--json",
288
1240
  ...baseArgs,
289
1241
  ], workspace).stdout;
290
- assert(reopen.ok !== false && reopen.updated_count === 5, `batch active move failed: ${JSON.stringify(reopen, null, 2)}`);
291
-
292
- runCli(["feedback-sync", "--json", ...baseArgs], workspace);
293
- assertFeedbackPresent(workspace, createdFeedbackIds, "in_progress");
1242
+ assert(reopen.ok !== false && reopen.updated_count === 3, `batch active move failed: ${JSON.stringify(reopen, null, 2)}`);
1243
+
1244
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
1245
+ assertFeedbackPresent(
1246
+ workspace,
1247
+ [createdFeedbackIds[0], createdFeedbackIds[3], createdFeedbackIds[4]],
1248
+ "in_progress",
1249
+ );
294
1250
 
295
1251
  const archive = runCli([
296
1252
  "feedback",
297
- "move",
298
- "--feedback-ids",
299
- createdFeedbackIds.join(","),
300
- "--from-state",
301
- "in_progress",
302
- "--to-state",
303
- "archived",
1253
+ "move",
1254
+ "--feedback-ids",
1255
+ createdFeedbackIds.join(","),
1256
+ "--to-state",
1257
+ "archived",
304
1258
  "--reason",
305
1259
  "Harness cleanup archive.",
306
1260
  "--json",
307
1261
  ...baseArgs,
308
- ], workspace).stdout;
309
- assert(archive.ok !== false && archive.updated_count === 5, `batch archive failed: ${JSON.stringify(archive, null, 2)}`);
310
-
311
- runCli(["feedback-sync", "--json", ...baseArgs], workspace);
312
- assertFeedbackAbsent(workspace, createdFeedbackIds);
1262
+ ], workspace).stdout;
1263
+ assert(archive.ok !== false && archive.updated_count === 5, `batch archive failed: ${JSON.stringify(archive, null, 2)}`);
1264
+
1265
+ const selectedDocument = documentSetCreate.documents[1];
1266
+ const selectedDocumentId = String(selectedDocument?.document_id || "").trim();
1267
+ assert(selectedDocumentId, "Three-document PRD did not return a second document id.");
1268
+ const revisedDocumentTitle = `${namespace} Document Set Harness Revised`;
1269
+ const revisedDocumentPath = writePrd(
1270
+ workspace,
1271
+ revisedDocumentTitle,
1272
+ 202,
1273
+ );
1274
+ const documentDraft = runCli([
1275
+ "feedback",
1276
+ "edit",
1277
+ "--feedback-id",
1278
+ documentSetFeedbackId,
1279
+ "--prd-file",
1280
+ revisedDocumentPath,
1281
+ "--document-id",
1282
+ selectedDocumentId,
1283
+ "--reason",
1284
+ "Harness verifies document-scoped refinement.",
1285
+ "--json",
1286
+ ...baseArgs,
1287
+ ], workspace).stdout;
1288
+ assert(documentDraft.artifact_path, "Document-scoped edit did not create a review artifact.");
1289
+ const documentValidation = runCli([
1290
+ "feedback",
1291
+ "validate",
1292
+ "--file",
1293
+ documentDraft.artifact_path,
1294
+ "--json",
1295
+ ...baseArgs,
1296
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1297
+ assert(
1298
+ documentValidation.ok === true && documentValidation.valid !== false,
1299
+ `Document-scoped refinement did not validate: ${JSON.stringify(documentValidation, null, 2)}`,
1300
+ );
1301
+ const documentSubmit = runCli([
1302
+ "feedback",
1303
+ "submit",
1304
+ "--file",
1305
+ documentDraft.artifact_path,
1306
+ "--confirm-write",
1307
+ "--approval-artifact",
1308
+ documentDraft.artifact_path,
1309
+ "--json",
1310
+ ...baseArgs,
1311
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1312
+ documentSetRequestId = responseRequestId(documentSubmit);
1313
+ assert(documentSetRequestId, "Document-scoped refinement did not return a review request id.");
1314
+ recordCertificationArtifact(manifest, {
1315
+ kind: "review_request",
1316
+ id: documentSetRequestId,
1317
+ metadata: {
1318
+ feedback_id: documentSetFeedbackId,
1319
+ document_id: selectedDocumentId,
1320
+ },
1321
+ });
1322
+ const documentApproval = runCli([
1323
+ "feedback",
1324
+ "review",
1325
+ "--request-id",
1326
+ documentSetRequestId,
1327
+ "--action",
1328
+ "approve",
1329
+ "--reason",
1330
+ "Harness approves one document without replacing its siblings.",
1331
+ "--verification-evidence",
1332
+ "Document-set validation and sibling-preservation checks passed.",
1333
+ "--deployment-evidence",
1334
+ "Dev post-deploy certification only.",
1335
+ "--no-sync",
1336
+ "--json",
1337
+ ...baseArgs,
1338
+ ], workspace, { apiKey: operationalReviewKey }).stdout;
1339
+ assert(
1340
+ String(documentApproval.request?.status || documentApproval.status || "") === "applied",
1341
+ `Document-scoped approval did not apply: ${JSON.stringify(documentApproval, null, 2)}`,
1342
+ );
1343
+
1344
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
1345
+ const synchronizedDocumentRoot = path.join(
1346
+ workspace,
1347
+ "MyteCommandCenter",
1348
+ "PRD",
1349
+ "feedback-sync",
1350
+ documentSetFeedbackId,
1351
+ );
1352
+ const synchronizedDocuments = fs.readdirSync(synchronizedDocumentRoot)
1353
+ .filter((name) => name.toLowerCase().endsWith(".md"))
1354
+ .sort();
1355
+ assert(synchronizedDocuments.length === 3, "Document refinement changed the document count.");
1356
+ assert(
1357
+ fs.readFileSync(path.join(synchronizedDocumentRoot, synchronizedDocuments[0]), "utf8")
1358
+ .includes(`# ${documentSetTitles[0]}`),
1359
+ "Document refinement replaced the first sibling document.",
1360
+ );
1361
+ assert(
1362
+ fs.readFileSync(path.join(synchronizedDocumentRoot, synchronizedDocuments[1]), "utf8")
1363
+ .includes(`# ${revisedDocumentTitle}`),
1364
+ "Document refinement did not update the selected second document.",
1365
+ );
1366
+ assert(
1367
+ fs.readFileSync(path.join(synchronizedDocumentRoot, synchronizedDocuments[2]), "utf8")
1368
+ .includes(`# ${documentSetTitles[2]}`),
1369
+ "Document refinement replaced the third sibling document.",
1370
+ );
1371
+
1372
+ const versions = runCli([
1373
+ "feedback",
1374
+ "prd-versions",
1375
+ "--feedback-id",
1376
+ documentSetFeedbackId,
1377
+ "--json",
1378
+ ...baseArgs,
1379
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1380
+ assert(
1381
+ Array.isArray(versions.versions) && versions.versions.length >= 2,
1382
+ `Document refinement did not retain PRD versions: ${JSON.stringify(versions, null, 2)}`,
1383
+ );
1384
+ const activeVersionId = String(versions.active_prd_version_id || "").trim();
1385
+ const targetVersion =
1386
+ versions.versions.find((version) => responseVersionId(version) !== activeVersionId)
1387
+ || versions.versions[0];
1388
+ const targetVersionId = responseVersionId(targetVersion);
1389
+ assert(targetVersionId, "PRD version list did not return a target version id.");
1390
+ const versionDiff = runCli([
1391
+ "feedback",
1392
+ "prd-diff",
1393
+ "--feedback-id",
1394
+ documentSetFeedbackId,
1395
+ "--version-id",
1396
+ targetVersionId,
1397
+ "--document-id",
1398
+ selectedDocumentId,
1399
+ "--json",
1400
+ ...baseArgs,
1401
+ ], workspace, { apiKey: collaboratorWriteKey }).stdout;
1402
+ assert(
1403
+ String(versionDiff.document_id || "") === selectedDocumentId,
1404
+ `PRD diff was not scoped to the selected document: ${JSON.stringify(versionDiff, null, 2)}`,
1405
+ );
1406
+ for (const version of versions.versions) {
1407
+ const versionId = responseVersionId(version);
1408
+ if (!versionId) continue;
1409
+ recordCertificationArtifact(manifest, {
1410
+ kind: "object",
1411
+ id: versionId,
1412
+ metadata: {
1413
+ object_type: "prd_version",
1414
+ feedback_id: documentSetFeedbackId,
1415
+ cleanup: "retain_audit",
1416
+ },
1417
+ });
1418
+ }
1419
+ writeCertificationManifest(manifestPath, manifest);
1420
+
1421
+ const archiveDocumentSet = runCli([
1422
+ "feedback",
1423
+ "move",
1424
+ "--feedback-id",
1425
+ documentSetFeedbackId,
1426
+ "--from-state",
1427
+ "todo",
1428
+ "--to-state",
1429
+ "archived",
1430
+ "--reason",
1431
+ "Harness cleanup archive for three-document PRD.",
1432
+ "--json",
1433
+ ...baseArgs,
1434
+ ], workspace).stdout;
1435
+ assert(
1436
+ archiveDocumentSet.ok !== false,
1437
+ `document-set archive failed: ${JSON.stringify(archiveDocumentSet, null, 2)}`,
1438
+ );
1439
+
1440
+ runCli(["feedback-sync", "--json", ...baseArgs], workspace);
1441
+ assertFeedbackAbsent(workspace, [...createdFeedbackIds, documentSetFeedbackId]);
313
1442
 
314
1443
  const unarchive = runCli([
315
1444
  "feedback",
@@ -327,29 +1456,90 @@ function main() {
327
1456
  ...baseArgs,
328
1457
  ], workspace, { allowFailure: true }).stdout;
329
1458
  assert(unarchive.ok === false, "Project-key Feedback unarchive unexpectedly succeeded.");
330
- assert(unarchive.status === 403, `Expected project-key unarchive to return 403, got ${unarchive.status}.`);
331
- assert(unarchive.data?.code === "feedback_unarchive_not_supported_project_api", `Unexpected unarchive error: ${JSON.stringify(unarchive, null, 2)}`);
332
-
333
- console.log(JSON.stringify({
334
- ok: true,
335
- workspace,
336
- created_feedback_ids: createdFeedbackIds,
337
- review_request_ids: reviewRequestIds,
338
- pending_review_batch_block_verified: true,
339
- batch_review_verified: true,
340
- batch_active_move_verified: true,
341
- batch_archive_verified: true,
342
- archived_sync_exclusion_verified: true,
1459
+ assert(unarchive.status === 403, `Expected project-key unarchive to return 403, got ${unarchive.status}.`);
1460
+ assert(unarchive.data?.code === "feedback_unarchive_not_supported_project_api", `Unexpected unarchive error: ${JSON.stringify(unarchive, null, 2)}`);
1461
+
1462
+ if (roleMatrix.required) {
1463
+ assert(roleMatrix.same_project, "Certification role keys were not scoped to one project.");
1464
+ assert(roleMatrix.collaborator_writes_used, "Regular collaborator writes were not certified.");
1465
+ assert(roleMatrix.collaborator_review_denied, "Collaborator approval denial was not certified.");
1466
+ assert(roleMatrix.collaborator_direct_apply_denied, "Collaborator direct-apply denial was not certified.");
1467
+ assert(roleMatrix.collaborator_archive_denied, "Collaborator archive denial was not certified.");
1468
+ assert(roleMatrix.owner_review_used, "Owner approval was not used during strict role certification.");
1469
+ assert(roleMatrix.delegate_review_used, "Delegate approval was not used during strict role certification.");
1470
+ }
1471
+ if (args["require-comment-attachment-sync"]) {
1472
+ assert(
1473
+ commentAttachmentSyncVerified,
1474
+ "Web-created Feedback comment attachment did not survive Project Assistant sync.",
1475
+ );
1476
+ }
1477
+ manifest.checks = LIVE_SCENARIO_MATRIX.map((scenario) => ({
1478
+ id: scenario.id,
1479
+ status: scenario.id === "owner-delegate-collaborator-matrix"
1480
+ ? (
1481
+ roleMatrix.required
1482
+ ? "passed"
1483
+ : delegateApiKey && collaboratorApiKey
1484
+ ? "passed_optional"
1485
+ : "optional_not_requested"
1486
+ )
1487
+ : scenario.id === "feedback-comment-attachment-sync"
1488
+ ? (
1489
+ commentAttachmentSyncVerified
1490
+ ? "passed"
1491
+ : "optional_not_requested"
1492
+ )
1493
+ : "passed",
1494
+ checked_at: new Date().toISOString(),
1495
+ }));
1496
+ manifest.state = "cleaned";
1497
+ manifest.completed_at = new Date().toISOString();
1498
+ manifest.updated_at = manifest.completed_at;
1499
+ writeCertificationManifest(manifestPath, manifest);
1500
+
1501
+ console.log(JSON.stringify({
1502
+ ok: true,
1503
+ run_id: runId,
1504
+ namespace,
1505
+ workspace,
1506
+ manifest_path: manifestPath,
1507
+ created_feedback_ids: createdFeedbackIds,
1508
+ document_set_feedback_id: documentSetFeedbackId,
1509
+ document_set_review_request_id: documentSetRequestId,
1510
+ review_request_ids: reviewRequestIds,
1511
+ role_matrix: roleMatrix,
1512
+ comment_verified: true,
1513
+ comment_sync_roundtrip_verified: true,
1514
+ comment_attachment_sync_verified: commentAttachmentSyncVerified,
1515
+ stale_edit_rejection_and_resync_verified: true,
1516
+ forced_stale_override_verified: true,
1517
+ move_undo_history_verified: true,
1518
+ validation_verified: true,
1519
+ request_changes_revision_verified: true,
1520
+ rejection_and_cancellation_verified: true,
1521
+ pending_review_batch_block_verified: true,
1522
+ batch_review_verified: true,
1523
+ batch_active_move_verified: true,
1524
+ batch_archive_verified: true,
1525
+ three_document_prd_verified: true,
1526
+ document_scoped_refinement_verified: true,
1527
+ prd_version_diff_verified: true,
1528
+ archived_sync_exclusion_verified: true,
343
1529
  project_key_unarchive_block_verified: true,
344
1530
  }, null, 2));
345
- } catch (err) {
346
- if (createdFeedbackIds.length) {
347
- try {
1531
+ } catch (err) {
1532
+ const cleanupFeedbackIds = [
1533
+ ...createdFeedbackIds,
1534
+ ...(documentSetFeedbackId ? [documentSetFeedbackId] : []),
1535
+ ];
1536
+ if (cleanupFeedbackIds.length) {
1537
+ try {
348
1538
  runCli([
349
1539
  "feedback",
350
- "move",
351
- "--feedback-ids",
352
- createdFeedbackIds.join(","),
1540
+ "move",
1541
+ "--feedback-ids",
1542
+ cleanupFeedbackIds.join(","),
353
1543
  "--to-state",
354
1544
  "archived",
355
1545
  "--reason",
@@ -360,12 +1550,29 @@ function main() {
360
1550
  } catch (_cleanupErr) {
361
1551
  // Keep the original error. Cleanup is best effort and must not hide failure evidence.
362
1552
  }
363
- }
364
- const failure = {
365
- ok: false,
366
- workspace,
367
- created_feedback_ids: createdFeedbackIds,
368
- review_request_ids: reviewRequestIds,
1553
+ }
1554
+ manifest.state = "cleanup_required";
1555
+ manifest.failure = {
1556
+ message: String(err?.message || err).slice(0, 2000),
1557
+ recorded_at: new Date().toISOString(),
1558
+ };
1559
+ manifest.updated_at = new Date().toISOString();
1560
+ try {
1561
+ writeCertificationManifest(manifestPath, manifest);
1562
+ } catch (_manifestErr) {
1563
+ // Preserve the primary harness failure.
1564
+ }
1565
+ const failure = {
1566
+ ok: false,
1567
+ run_id: runId,
1568
+ namespace,
1569
+ workspace,
1570
+ manifest_path: manifestPath,
1571
+ created_feedback_ids: createdFeedbackIds,
1572
+ document_set_feedback_id: documentSetFeedbackId,
1573
+ document_set_review_request_id: documentSetRequestId,
1574
+ review_request_ids: reviewRequestIds,
1575
+ role_matrix: roleMatrix,
369
1576
  message: err?.message || String(err),
370
1577
  };
371
1578
  console.error(JSON.stringify(failure, null, 2));
@@ -373,4 +1580,10 @@ function main() {
373
1580
  }
374
1581
  }
375
1582
 
376
- main();
1583
+ main().catch((err) => {
1584
+ console.error(JSON.stringify({
1585
+ ok: false,
1586
+ message: err?.message || String(err),
1587
+ }, null, 2));
1588
+ process.exitCode = 1;
1589
+ });