agentxchain 0.8.7 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +123 -154
  2. package/bin/agentxchain.js +240 -8
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +16 -7
  13. package/scripts/agentxchain-autonudge.applescript +32 -5
  14. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  15. package/scripts/publish-from-tag.sh +88 -0
  16. package/scripts/release-postflight.sh +231 -0
  17. package/scripts/release-preflight.sh +167 -0
  18. package/scripts/run-autonudge.sh +1 -1
  19. package/src/adapters/claude-code.js +7 -14
  20. package/src/adapters/cursor-local.js +17 -16
  21. package/src/commands/accept-turn.js +160 -0
  22. package/src/commands/approve-completion.js +80 -0
  23. package/src/commands/approve-transition.js +85 -0
  24. package/src/commands/branch.js +2 -2
  25. package/src/commands/claim.js +84 -9
  26. package/src/commands/config.js +16 -0
  27. package/src/commands/dashboard.js +70 -0
  28. package/src/commands/doctor.js +9 -1
  29. package/src/commands/init.js +540 -5
  30. package/src/commands/migrate.js +348 -0
  31. package/src/commands/multi.js +549 -0
  32. package/src/commands/plugin.js +157 -0
  33. package/src/commands/reject-turn.js +204 -0
  34. package/src/commands/resume.js +389 -0
  35. package/src/commands/status.js +196 -3
  36. package/src/commands/step.js +947 -0
  37. package/src/commands/stop.js +65 -33
  38. package/src/commands/template-list.js +33 -0
  39. package/src/commands/template-set.js +279 -0
  40. package/src/commands/update.js +24 -3
  41. package/src/commands/validate.js +20 -11
  42. package/src/commands/verify.js +71 -0
  43. package/src/commands/watch.js +112 -25
  44. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  45. package/src/lib/adapters/local-cli-adapter.js +337 -0
  46. package/src/lib/adapters/manual-adapter.js +169 -0
  47. package/src/lib/blocked-state.js +94 -0
  48. package/src/lib/config.js +143 -12
  49. package/src/lib/context-compressor.js +121 -0
  50. package/src/lib/context-section-parser.js +220 -0
  51. package/src/lib/coordinator-acceptance.js +428 -0
  52. package/src/lib/coordinator-config.js +461 -0
  53. package/src/lib/coordinator-dispatch.js +276 -0
  54. package/src/lib/coordinator-gates.js +487 -0
  55. package/src/lib/coordinator-hooks.js +239 -0
  56. package/src/lib/coordinator-recovery.js +523 -0
  57. package/src/lib/coordinator-state.js +365 -0
  58. package/src/lib/cross-repo-context.js +247 -0
  59. package/src/lib/dashboard/bridge-server.js +284 -0
  60. package/src/lib/dashboard/file-watcher.js +93 -0
  61. package/src/lib/dashboard/state-reader.js +96 -0
  62. package/src/lib/dispatch-bundle.js +568 -0
  63. package/src/lib/dispatch-manifest.js +252 -0
  64. package/src/lib/filter-agents.js +12 -0
  65. package/src/lib/gate-evaluator.js +285 -0
  66. package/src/lib/generate-vscode.js +158 -68
  67. package/src/lib/governed-state.js +2139 -0
  68. package/src/lib/governed-templates.js +145 -0
  69. package/src/lib/hook-runner.js +788 -0
  70. package/src/lib/next-owner.js +61 -6
  71. package/src/lib/normalized-config.js +539 -0
  72. package/src/lib/notify.js +14 -12
  73. package/src/lib/plugin-config-schema.js +192 -0
  74. package/src/lib/plugins.js +692 -0
  75. package/src/lib/prompt-core.js +108 -0
  76. package/src/lib/protocol-conformance.js +291 -0
  77. package/src/lib/reference-conformance-adapter.js +717 -0
  78. package/src/lib/repo-observer.js +597 -0
  79. package/src/lib/repo.js +0 -31
  80. package/src/lib/safe-write.js +44 -0
  81. package/src/lib/schema.js +189 -0
  82. package/src/lib/schemas/turn-result.schema.json +205 -0
  83. package/src/lib/seed-prompt-polling.js +15 -73
  84. package/src/lib/seed-prompt.js +17 -63
  85. package/src/lib/token-budget.js +206 -0
  86. package/src/lib/token-counter.js +27 -0
  87. package/src/lib/turn-paths.js +67 -0
  88. package/src/lib/turn-result-validator.js +496 -0
  89. package/src/lib/validation.js +167 -19
  90. package/src/lib/verify-command.js +72 -0
  91. package/src/templates/governed/api-service.json +31 -0
  92. package/src/templates/governed/cli-tool.json +30 -0
  93. package/src/templates/governed/generic.json +10 -0
  94. package/src/templates/governed/web-app.json +30 -0
@@ -1,5 +1,24 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { validateStagedTurnResult, STAGING_PATH } from './turn-result-validator.js';
4
+ import { getActiveTurn } from './governed-state.js';
5
+
6
+ const DEFAULT_REQUIRED_FILES = [
7
+ '.planning/PROJECT.md',
8
+ '.planning/REQUIREMENTS.md',
9
+ '.planning/ROADMAP.md',
10
+ '.planning/PM_SIGNOFF.md',
11
+ '.planning/qa/TEST-COVERAGE.md',
12
+ '.planning/qa/BUGS.md',
13
+ '.planning/qa/UX-AUDIT.md',
14
+ '.planning/qa/ACCEPTANCE-MATRIX.md',
15
+ '.planning/qa/REGRESSION-LOG.md',
16
+ ];
17
+
18
+ const PROTOCOL_FILES = [
19
+ 'lock.json',
20
+ 'state.json'
21
+ ];
3
22
 
4
23
  export function validateProject(root, config, opts = {}) {
5
24
  const mode = opts.mode || 'full';
@@ -9,23 +28,17 @@ export function validateProject(root, config, opts = {}) {
9
28
  const errors = [];
10
29
  const warnings = [];
11
30
 
12
- const mustExist = [
13
- '.planning/PROJECT.md',
14
- '.planning/REQUIREMENTS.md',
15
- '.planning/ROADMAP.md',
16
- '.planning/PM_SIGNOFF.md',
17
- '.planning/qa/TEST-COVERAGE.md',
18
- '.planning/qa/BUGS.md',
19
- '.planning/qa/UX-AUDIT.md',
20
- '.planning/qa/ACCEPTANCE-MATRIX.md',
21
- '.planning/qa/REGRESSION-LOG.md',
31
+ const customRequired = config.rules?.required_files;
32
+ const planningFiles = Array.isArray(customRequired) ? customRequired : DEFAULT_REQUIRED_FILES;
33
+
34
+ const dynamicFiles = [
22
35
  talkFile,
23
- 'state.md',
24
- 'history.jsonl',
25
- 'lock.json',
26
- 'state.json'
36
+ config.state_file || 'state.md',
37
+ config.history_file || 'history.jsonl',
27
38
  ];
28
39
 
40
+ const mustExist = [...planningFiles, ...dynamicFiles, ...PROTOCOL_FILES];
41
+
29
42
  for (const rel of mustExist) {
30
43
  if (!existsSync(join(root, rel))) {
31
44
  errors.push(`Missing required file: ${rel}`);
@@ -68,6 +81,108 @@ export function validateProject(root, config, opts = {}) {
68
81
  return { ok: errors.length === 0, mode, errors, warnings };
69
82
  }
70
83
 
84
+ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
85
+ const mode = opts.mode || 'full';
86
+ const expectedRole = opts.expectedAgent || null;
87
+ const errors = [];
88
+ const warnings = [];
89
+
90
+ const mustExist = [
91
+ config.files?.state || '.agentxchain/state.json',
92
+ config.files?.history || '.agentxchain/history.jsonl',
93
+ ];
94
+
95
+ for (const rel of mustExist) {
96
+ if (!existsSync(join(root, rel))) {
97
+ errors.push(`Missing required file: ${rel}`);
98
+ }
99
+ }
100
+
101
+ const prompts = rawConfig?.prompts && typeof rawConfig.prompts === 'object' ? rawConfig.prompts : {};
102
+ for (const [roleId, rel] of Object.entries(prompts)) {
103
+ if (typeof rel !== 'string' || !rel.trim()) {
104
+ errors.push(`Prompt path for role "${roleId}" must be a non-empty string.`);
105
+ continue;
106
+ }
107
+ if (!existsSync(join(root, rel))) {
108
+ errors.push(`Missing prompt file for role "${roleId}": ${rel}`);
109
+ }
110
+ }
111
+
112
+ for (const [gateId, gate] of Object.entries(config.gates || {})) {
113
+ for (const rel of gate.requires_files || []) {
114
+ if (!existsSync(join(root, rel))) {
115
+ errors.push(`Gate "${gateId}" requires missing file: ${rel}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ const statePath = join(root, config.files?.state || '.agentxchain/state.json');
121
+ const state = readJson(statePath);
122
+ if (!state) {
123
+ errors.push(`Unable to parse ${config.files?.state || '.agentxchain/state.json'}.`);
124
+ } else {
125
+ if (state.phase && config.routing && !config.routing[state.phase]) {
126
+ errors.push(`State phase "${state.phase}" is not defined in routing.`);
127
+ }
128
+
129
+ if (state.phase_gate_status && typeof state.phase_gate_status === 'object') {
130
+ for (const gateId of Object.keys(state.phase_gate_status)) {
131
+ if (!config.gates?.[gateId]) {
132
+ errors.push(`state.phase_gate_status references unknown gate "${gateId}".`);
133
+ }
134
+ }
135
+ }
136
+
137
+ if (mode === 'turn') {
138
+ const activeTurn = getActiveTurn(state) || state.current_turn;
139
+ if (!activeTurn) {
140
+ errors.push('Governed turn validation requires an active turn.');
141
+ } else if (expectedRole && activeTurn.assigned_role !== expectedRole) {
142
+ errors.push(`Current turn role "${activeTurn.assigned_role}" does not match expected "${expectedRole}".`);
143
+ }
144
+ }
145
+
146
+ if (!getActiveTurn(state) && !state.current_turn) {
147
+ warnings.push('No active turn present in governed state. The run may be idle or paused.');
148
+ }
149
+ }
150
+
151
+ const historyPath = join(root, config.files?.history || '.agentxchain/history.jsonl');
152
+ const historyLines = readJsonLines(historyPath);
153
+ if (historyLines.error) {
154
+ errors.push(historyLines.error);
155
+ } else if (historyLines.lines.length === 0) {
156
+ warnings.push(`${config.files?.history || '.agentxchain/history.jsonl'} has no accepted turn entries yet.`);
157
+ } else {
158
+ const last = historyLines.lines[historyLines.lines.length - 1];
159
+ if (last && typeof last === 'object') {
160
+ if (!last.turn_id) warnings.push('Last governed history entry has no turn_id.');
161
+ if (!last.role) warnings.push('Last governed history entry has no role.');
162
+ if (!last.status) warnings.push('Last governed history entry has no status.');
163
+ }
164
+ }
165
+
166
+ // ── Staged turn-result validation (the acceptance boundary) ─────────────
167
+ if (mode === 'turn' && state) {
168
+ const stagingAbs = join(root, STAGING_PATH);
169
+ if (!existsSync(stagingAbs)) {
170
+ warnings.push(`No staged turn result found at ${STAGING_PATH}. Agent has not yet emitted a turn result.`);
171
+ } else {
172
+ const turnValidation = validateStagedTurnResult(root, state, config);
173
+ if (!turnValidation.ok) {
174
+ errors.push(
175
+ `Staged turn result failed at stage "${turnValidation.stage}" (${turnValidation.error_class}):`,
176
+ ...turnValidation.errors.map(e => ` • ${e}`)
177
+ );
178
+ }
179
+ warnings.push(...(turnValidation.warnings || []));
180
+ }
181
+ }
182
+
183
+ return { ok: errors.length === 0, mode, errors, warnings };
184
+ }
185
+
71
186
  function validatePhaseArtifacts(root) {
72
187
  const result = { errors: [], warnings: [] };
73
188
  const phasesDir = join(root, '.planning', 'phases');
@@ -98,9 +213,9 @@ function validatePhaseArtifacts(root) {
98
213
 
99
214
  function validateHistory(root, config, opts) {
100
215
  const result = { errors: [], warnings: [] };
101
- const historyPath = join(root, 'history.jsonl');
216
+ const historyPath = join(root, config.history_file || 'history.jsonl');
102
217
  if (!existsSync(historyPath)) {
103
- result.errors.push('history.jsonl is missing.');
218
+ result.errors.push(`${config.history_file || 'history.jsonl'} is missing.`);
104
219
  return result;
105
220
  }
106
221
 
@@ -111,9 +226,9 @@ function validateHistory(root, config, opts) {
111
226
 
112
227
  if (lines.length === 0) {
113
228
  if (opts.requireEntry) {
114
- result.errors.push('history.jsonl has no entries.');
229
+ result.errors.push(`${config.history_file || 'history.jsonl'} has no entries.`);
115
230
  } else {
116
- result.warnings.push('history.jsonl has no entries yet.');
231
+ result.warnings.push(`${config.history_file || 'history.jsonl'} has no entries yet.`);
117
232
  }
118
233
  return result;
119
234
  }
@@ -123,7 +238,7 @@ function validateHistory(root, config, opts) {
123
238
  try {
124
239
  last = JSON.parse(lastRaw);
125
240
  } catch {
126
- result.errors.push('Last history.jsonl entry is not valid JSON.');
241
+ result.errors.push(`Last ${config.history_file || 'history.jsonl'} entry is not valid JSON.`);
127
242
  return result;
128
243
  }
129
244
 
@@ -203,3 +318,36 @@ function safeReadDir(path) {
203
318
  return [];
204
319
  }
205
320
  }
321
+
322
+ function readJson(path) {
323
+ if (!existsSync(path)) return null;
324
+ try {
325
+ return JSON.parse(readFileSync(path, 'utf8'));
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ function readJsonLines(path) {
332
+ if (!existsSync(path)) {
333
+ return { lines: [], error: `${path.split('/').pop()} is missing.` };
334
+ }
335
+
336
+ try {
337
+ const raw = readFileSync(path, 'utf8');
338
+ const lines = raw
339
+ .split(/\r?\n/)
340
+ .map(line => line.trim())
341
+ .filter(Boolean)
342
+ .map((line, index) => {
343
+ try {
344
+ return JSON.parse(line);
345
+ } catch {
346
+ throw new Error(`Invalid JSONL entry at line ${index + 1}.`);
347
+ }
348
+ });
349
+ return { lines, error: null };
350
+ } catch (err) {
351
+ return { lines: [], error: err.message };
352
+ }
353
+ }
@@ -0,0 +1,72 @@
1
+ import { spawnSync } from 'child_process';
2
+
3
+ export function parseCommandArgs(input) {
4
+ if (Array.isArray(input)) {
5
+ return input.filter(part => typeof part === 'string' && part.length > 0);
6
+ }
7
+
8
+ if (typeof input !== 'string' || !input.trim()) {
9
+ return [];
10
+ }
11
+
12
+ const out = [];
13
+ let current = '';
14
+ let quote = null;
15
+ let escape = false;
16
+
17
+ for (const char of input.trim()) {
18
+ if (escape) {
19
+ current += char;
20
+ escape = false;
21
+ continue;
22
+ }
23
+ if (char === '\\') {
24
+ escape = true;
25
+ continue;
26
+ }
27
+ if (quote) {
28
+ if (char === quote) {
29
+ quote = null;
30
+ } else {
31
+ current += char;
32
+ }
33
+ continue;
34
+ }
35
+ if (char === '"' || char === "'") {
36
+ quote = char;
37
+ continue;
38
+ }
39
+ if (/\s/.test(char)) {
40
+ if (current) {
41
+ out.push(current);
42
+ current = '';
43
+ }
44
+ continue;
45
+ }
46
+ current += char;
47
+ }
48
+
49
+ if (current) {
50
+ out.push(current);
51
+ }
52
+
53
+ return out;
54
+ }
55
+
56
+ export function runConfiguredVerify(config, root) {
57
+ const args = parseCommandArgs(config?.rules?.verify_command);
58
+ if (args.length === 0) {
59
+ return { ok: true, skipped: true, command: null };
60
+ }
61
+
62
+ const result = spawnSync(args[0], args.slice(1), {
63
+ cwd: root,
64
+ stdio: 'inherit'
65
+ });
66
+
67
+ return {
68
+ ok: result.status === 0,
69
+ skipped: false,
70
+ command: args.join(' ')
71
+ };
72
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "api-service",
3
+ "display_name": "API Service",
4
+ "description": "Governed scaffold for backend services with explicit API, operational, and failure-budget planning.",
5
+ "version": "1",
6
+ "protocol_compatibility": ["1.0", "1.1"],
7
+ "planning_artifacts": [
8
+ {
9
+ "filename": "api-contract.md",
10
+ "content_template": "# API Contract — {{project_name}}\n\n## Consumers\n- Primary caller:\n- Authentication expectations:\n- Backward-compatibility policy:\n\n## Endpoints\n| Method | Path | Purpose | Auth | Status |\n|--------|------|---------|------|--------|\n| | | | | |\n\n## Error Cases\n| Scenario | HTTP Status | Error Shape | Recovery |\n|----------|-------------|-------------|----------|\n| | | | |\n"
11
+ },
12
+ {
13
+ "filename": "operational-readiness.md",
14
+ "content_template": "# Operational Readiness — {{project_name}}\n\n## Runtime Dependencies\n- Data stores:\n- Queues / cron jobs:\n- External APIs:\n\n## Observability\n- Required logs:\n- Metrics / alerts:\n- Smoke checks:\n\n## Recovery\n- Rollback plan:\n- On-call notes:\n- Known operational risks:\n"
15
+ },
16
+ {
17
+ "filename": "error-budget.md",
18
+ "content_template": "# Error Budget — {{project_name}}\n\n## Service Objective\n- Target availability / reliability:\n- Measurement window:\n\n## Failure Modes\n| Failure mode | User impact | Detection | Mitigation |\n|--------------|-------------|-----------|------------|\n| | | | |\n\n## Escalation Thresholds\n- Immediate ship blocker:\n- Needs mitigation before release:\n- Post-release follow-up allowed when:\n"
19
+ }
20
+ ],
21
+ "prompt_overrides": {
22
+ "pm": "Define the external contract, compatibility expectations, and operational constraints before the team treats the API as ready for implementation.",
23
+ "dev": "Treat schema changes, API contract drift, and migration safety as first-class implementation risks. Call out any hidden operational coupling.",
24
+ "qa": "Verify API contract conformance, error handling, auth failures, and rollback safety. Do not sign off without explicit coverage of unhappy paths."
25
+ },
26
+ "acceptance_hints": [
27
+ "API contract reviewed and endpoints listed",
28
+ "Error cases enumerated with recovery expectations",
29
+ "Verification command covers automated tests or smoke checks"
30
+ ]
31
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "id": "cli-tool",
3
+ "display_name": "CLI Tool",
4
+ "description": "Governed scaffold for command-line tools with command-surface, platform, and distribution planning.",
5
+ "version": "1",
6
+ "protocol_compatibility": ["1.0", "1.1"],
7
+ "planning_artifacts": [
8
+ {
9
+ "filename": "command-surface.md",
10
+ "content_template": "# Command Surface — {{project_name}}\n\n## Primary Commands\n| Command | Purpose | Inputs | Output / Side Effects |\n|---------|---------|--------|------------------------|\n| | | | |\n\n## Flags And Options\n| Command | Flag | Meaning | Default |\n|---------|------|---------|---------|\n| | | | |\n\n## Failure UX\n- Expected error messages:\n- Help / usage fallback:\n- Safe retry behavior:\n"
11
+ },
12
+ {
13
+ "filename": "platform-support.md",
14
+ "content_template": "# Platform Support — {{project_name}}\n\n## Supported Environments\n- Operating systems:\n- Shells / terminals:\n- Node / runtime versions:\n\n## Compatibility Risks\n| Surface | Risk | Mitigation |\n|---------|------|------------|\n| | | |\n\n## Manual Checks\n- Install path verified on:\n- Non-interactive behavior verified on:\n- Path / permission edge cases:\n"
15
+ },
16
+ {
17
+ "filename": "distribution-checklist.md",
18
+ "content_template": "# Distribution Checklist — {{project_name}}\n\n## Packaging\n- Package metadata reviewed:\n- Published files audited:\n- Version / changelog ready:\n\n## Install Paths\n- Global install path:\n- Local invocation path:\n- Upgrade / rollback path:\n\n## Release Risks\n- Breaking command changes:\n- Shell completion / docs gaps:\n- Known install blockers:\n"
19
+ }
20
+ ],
21
+ "prompt_overrides": {
22
+ "dev": "Treat help text, error messaging, shell compatibility, and safe invocation paths as product behavior, not polish.",
23
+ "qa": "Audit command UX, help output, failure messages, install or invocation paths, and shell/platform compatibility before sign-off."
24
+ },
25
+ "acceptance_hints": [
26
+ "Command help audited for every user-facing command touched",
27
+ "Install or invocation path checked on the intended runtime",
28
+ "Failure-mode UX reviewed for invalid flags or missing inputs"
29
+ ]
30
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "generic",
3
+ "display_name": "Generic",
4
+ "description": "Default governed scaffold with the baseline planning artifacts and no project-type-specific guidance.",
5
+ "version": "1",
6
+ "protocol_compatibility": ["1.0", "1.1"],
7
+ "planning_artifacts": [],
8
+ "prompt_overrides": {},
9
+ "acceptance_hints": []
10
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "id": "web-app",
3
+ "display_name": "Web App",
4
+ "description": "Governed scaffold for web products with user-flow, UI, and browser-support planning artifacts.",
5
+ "version": "1",
6
+ "protocol_compatibility": ["1.0", "1.1"],
7
+ "planning_artifacts": [
8
+ {
9
+ "filename": "user-flows.md",
10
+ "content_template": "# User Flows — {{project_name}}\n\n## Primary Flows\n| Flow | User goal | Entry point | Success state |\n|------|-----------|-------------|---------------|\n| | | | |\n\n## Failure And Recovery\n| Flow | Failure state | Recovery path |\n|------|---------------|---------------|\n| | | |\n"
11
+ },
12
+ {
13
+ "filename": "ui-acceptance.md",
14
+ "content_template": "# UI Acceptance — {{project_name}}\n\n## Screens / States\n| Surface | Happy path expectations | Empty / error states | Notes |\n|---------|-------------------------|----------------------|-------|\n| | | | |\n\n## Accessibility And Copy\n- Keyboard expectations:\n- Contrast / readability notes:\n- Copy review focus:\n"
15
+ },
16
+ {
17
+ "filename": "browser-support.md",
18
+ "content_template": "# Browser Support — {{project_name}}\n\n## Target Environments\n- Desktop browsers:\n- Mobile browsers:\n- Minimum viewport assumptions:\n\n## Compatibility Risks\n| Browser / device | Risk | Mitigation |\n|------------------|------|------------|\n| | | |\n\n## Smoke Checklist\n- Primary flow works on desktop:\n- Primary flow works on mobile:\n- Known degraded experiences:\n"
19
+ }
20
+ ],
21
+ "prompt_overrides": {
22
+ "pm": "Define the primary user flows and the minimum browser or device support up front. Vague UX scope will cause downstream churn.",
23
+ "qa": "Verify primary user flows, responsive behavior, accessibility smoke checks, and copy regressions. Do not treat visual breakage as a minor issue."
24
+ },
25
+ "acceptance_hints": [
26
+ "Primary user flow reviewed end to end",
27
+ "Mobile and desktop behavior checked explicitly",
28
+ "Accessibility or copy regressions noted before ship request"
29
+ ]
30
+ }