harness-everything 0.32.0 → 0.34.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.
Files changed (103) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/CHANGELOG.md +33 -0
  4. package/README.md +3 -2
  5. package/ci/fixtures/doc-lifecycle/prd-revisions/config.json +9 -0
  6. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/config.json +20 -0
  7. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/expected.json +12 -0
  8. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r001.md +20 -0
  9. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r002.md +26 -0
  10. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r003.md +26 -0
  11. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/valid/docs/prd/graph/r001.md +20 -0
  12. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/valid/docs/prd/graph/r002.md +26 -0
  13. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r001.body.md +39 -0
  14. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-dropped.body.md +35 -0
  15. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-incomplete.body.md +37 -0
  16. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-one-replaced.body.md +39 -0
  17. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-overlap.body.md +39 -0
  18. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/config.json +1 -0
  19. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/docs/adr/GRPH-001.md +8 -0
  20. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/docs/adr/GRPH-002.md +9 -0
  21. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/expected.json +1 -0
  22. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/config.json +1 -0
  23. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/docs/adr/GRPH-001.md +8 -0
  24. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/docs/adr/GRPH-002.md +9 -0
  25. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/expected.json +1 -0
  26. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/config.json +1 -0
  27. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/docs/adr/GRPH-010.md +9 -0
  28. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/docs/adr/legacy/GRPH-007.md +8 -0
  29. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/expected.json +1 -0
  30. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/config.json +1 -0
  31. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/docs/adr/GRAPH-007.md +9 -0
  32. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/docs/adr/legacy/GRPH-007.md +8 -0
  33. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/expected.json +1 -0
  34. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/config.json +1 -0
  35. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/docs/adr/legacy/v1/GRPH-007.md +8 -0
  36. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/docs/adr/legacy/v2/GRPH-007.md +8 -0
  37. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/expected.json +1 -0
  38. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/config.json +1 -0
  39. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-003.md +9 -0
  40. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-004.md +9 -0
  41. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-005.md +9 -0
  42. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-006.md +9 -0
  43. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-007.md +9 -0
  44. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/expected.json +1 -0
  45. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/config.json +1 -0
  46. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/docs/adr/GRPH-020.md +8 -0
  47. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/docs/adr/GRPH-021.md +9 -0
  48. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/expected.json +1 -0
  49. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/config.json +1 -0
  50. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/docs/adr/GRPH-003.md +8 -0
  51. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/docs/prd/graph.md +10 -0
  52. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/expected.json +1 -0
  53. package/ci/mechanism-2aw-project-docs-config.test.js +759 -0
  54. package/ci/mechanism-2ax-doc-lifecycle.test.js +1061 -0
  55. package/ci/mechanism-2ay-prd-revisions.test.js +2891 -0
  56. package/ci/mechanism-2az-step-type-registry.test.js +86 -0
  57. package/ci/openai-plugin-package.test.js +1 -1
  58. package/ci/release-consistency-check.js +26 -9
  59. package/ci/release-consistency-check.test.js +30 -0
  60. package/ci/router-workflow-plan.test.js +36 -0
  61. package/code-review/SKILL.md +41 -0
  62. package/code-review/references/review-playbook.md +40 -0
  63. package/code-review/templates/finding.template.md +33 -0
  64. package/docs/document-lifecycle.md +206 -0
  65. package/docs/openai-plugin.md +1 -1
  66. package/docs/workflows/code-review.md +58 -0
  67. package/evals/code-review/eval.yaml +26 -0
  68. package/evals/code-review/tasks/negative-grill-me.yaml +16 -0
  69. package/evals/code-review/tasks/negative-security-review.yaml +16 -0
  70. package/evals/code-review/tasks/negative-tdd.yaml +16 -0
  71. package/evals/code-review/tasks/negative-verification-loop.yaml +16 -0
  72. package/evals/code-review/tasks/positive-pr-review.yaml +16 -0
  73. package/evals/code-review/tasks/positive-revised-head-rereview.yaml +16 -0
  74. package/harness-everything/SKILL.md +1 -1
  75. package/harness-everything/references/skill-registry.md +1 -0
  76. package/harness-everything/scripts/doc-lifecycle.js +2401 -0
  77. package/harness-everything/scripts/project-docs-resolver.js +764 -0
  78. package/harness-everything/scripts/routing-keywords.json +7 -0
  79. package/hooks/scripts/lib/binding-resolver.js +1 -0
  80. package/multi-agent-workspace/SKILL.md +1 -1
  81. package/multi-agent-workspace/scripts/project-docs-resolver.js +437 -30
  82. package/opencode-plugin/plugin.json +1 -1
  83. package/package.json +2 -2
  84. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  85. package/plugins/harness-everything/hooks/scripts/lib/binding-resolver.js +1 -0
  86. package/plugins/harness-everything/plugin.json +1 -1
  87. package/plugins/harness-everything/skills/code-review/SKILL.md +41 -0
  88. package/plugins/harness-everything/skills/code-review/references/review-playbook.md +40 -0
  89. package/plugins/harness-everything/skills/code-review/templates/finding.template.md +33 -0
  90. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  91. package/plugins/harness-everything/skills/harness-everything/references/skill-registry.md +1 -0
  92. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +2401 -0
  93. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +764 -0
  94. package/plugins/harness-everything/skills/harness-everything/scripts/routing-keywords.json +7 -0
  95. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  96. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +437 -30
  97. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  98. package/plugins/harness-everything/skills/to-spec/scripts/check-project-docs.js +168 -54
  99. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +749 -96
  100. package/sbom.cdx.json +7 -7
  101. package/to-spec/SKILL.md +1 -1
  102. package/to-spec/scripts/check-project-docs.js +168 -54
  103. package/to-spec/scripts/project-docs-resolver.js +749 -96
@@ -611,6 +611,13 @@
611
611
  "bindings": [
612
612
  "zoom-out"
613
613
  ]
614
+ },
615
+ {
616
+ "id": "review",
617
+ "bindings": [
618
+ "code-review"
619
+ ],
620
+ "regex": "\\b(?:code[- ]review|pr review)\\b|\\b(?:re-)?review\\s+(?:(?:(?:this|the|my)\\s+)?(?:pr\\b|pull request\\b|diff\\b|(?:finished\\s+)?implementation\\b(?!\\s+plan\\b))|the\\s+(?:fix\\b|new\\s+head\\b))|程式碼審查|代码审查"
614
621
  }
615
622
  ]
616
623
  }
@@ -4,7 +4,7 @@ description: "Scaffold a verified multi-agent workspace and select bounded speci
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.13.0
7
+ version: 0.34.0
8
8
  ---
9
9
 
10
10
  # Multi-Agent Workspace
@@ -17,6 +17,15 @@ const PROJECT_DOC_FIELDS = Object.freeze({
17
17
  domain: ['domainLocation', 'domains', 'contextLocation', 'contexts', 'domain'],
18
18
  architecture: ['architectureLocation', 'architectures', 'architecture']
19
19
  });
20
+ const PROJECT_DOCS_CONFIG = path.join('.agents', 'harness-everything', 'project-docs.json');
21
+ const PROJECT_DOC_ROLES = Object.freeze(['prd', 'adr', 'ticket', 'reference', 'log', 'report']);
22
+ const PROJECT_DOC_CONFIG_KEYS = new Set([
23
+ 'governed', 'domains', 'legacy', 'derived', 'tickets',
24
+ 'docLocation', 'tracker', 'issueDefinition', 'contextMap', 'contextMapPath',
25
+ 'decisionLocation', 'decisions', 'adrLocation', 'adr',
26
+ 'domainLocation', 'contextLocation', 'contexts', 'domain',
27
+ 'architectureLocation', 'architectures', 'architecture'
28
+ ]);
20
29
  const UNBOUND_WORKSPACE_KEY = 'unbound-workspace';
21
30
 
22
31
  function getWorkspaceRoot(start, allowNonGit = false) {
@@ -63,20 +72,373 @@ function getRepoManifestHomes(workspaceRoot) {
63
72
  return PLATFORM_HOMES.map(home => path.join(workspaceRoot, home));
64
73
  }
65
74
 
66
- function readProjectDocs(workspaceRoot) {
75
+ function isPlainObject(value) {
76
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
77
+ const prototype = Object.getPrototypeOf(value);
78
+ return prototype === Object.prototype || prototype === null;
79
+ }
80
+
81
+ function projectDocsConfigPath(workspaceRoot) {
82
+ return path.join(workspaceRoot, PROJECT_DOCS_CONFIG);
83
+ }
84
+
85
+ function normalizedHostHome(activeHost) {
86
+ if (typeof activeHost !== 'string') return null;
87
+ const name = activeHost.trim().toLowerCase().replace(/^\./, '');
88
+ return PLATFORM_HOMES.find(home => home.slice(1) === name) || null;
89
+ }
90
+
91
+ function readHostProjectDocsConfigs(workspaceRoot) {
67
92
  const manifests = [];
68
- for (const home of getRepoManifestHomes(workspaceRoot)) {
93
+ for (let index = 0; index < PLATFORM_HOMES.length; index++) {
94
+ const homeName = PLATFORM_HOMES[index];
95
+ const home = path.join(workspaceRoot, homeName);
69
96
  const file = path.join(home, 'harness-everything', 'manifest.json');
70
97
  if (!fs.existsSync(file)) continue;
71
98
  const data = readJson(file);
72
- if (data && data.projectDocs && typeof data.projectDocs === 'object') {
73
- manifests.push({ file, projectDocs: data.projectDocs });
99
+ if (data && data.projectDocs && isPlainObject(data.projectDocs)) {
100
+ manifests.push({ file, home: homeName, projectDocs: data.projectDocs });
101
+ }
102
+ }
103
+ return manifests;
104
+ }
105
+
106
+ function inspectHostProjectDocs(workspaceRoot) {
107
+ const entries = [];
108
+ for (const home of PLATFORM_HOMES) {
109
+ const file = path.join(workspaceRoot, home, 'harness-everything', 'manifest.json');
110
+ if (!fs.existsSync(file)) continue;
111
+ const entry = { file, home, projectDocs: null, warnings: [], diagnostics: [], hasProjectDocs: false };
112
+ let manifest;
113
+ try {
114
+ manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
115
+ } catch {
116
+ entry.diagnostics.push({
117
+ code: 'invalid-host-config',
118
+ severity: 'error',
119
+ path: configSource(workspaceRoot, file),
120
+ message: `host manifest at ${configSource(workspaceRoot, file)} is not valid JSON`
121
+ });
122
+ entries.push(entry);
123
+ continue;
74
124
  }
125
+ if (!isPlainObject(manifest)) {
126
+ entry.diagnostics.push({
127
+ code: 'invalid-host-config',
128
+ severity: 'error',
129
+ path: configSource(workspaceRoot, file),
130
+ message: `host manifest at ${configSource(workspaceRoot, file)} must contain a JSON object`
131
+ });
132
+ entries.push(entry);
133
+ continue;
134
+ }
135
+ if (!Object.prototype.hasOwnProperty.call(manifest, 'projectDocs')) {
136
+ entries.push(entry);
137
+ continue;
138
+ }
139
+ entry.hasProjectDocs = true;
140
+ if (!isPlainObject(manifest.projectDocs)) {
141
+ entry.diagnostics.push({
142
+ code: 'invalid-host-config',
143
+ severity: 'error',
144
+ path: configSource(workspaceRoot, file),
145
+ message: `projectDocs in ${configSource(workspaceRoot, file)} must be a JSON object`
146
+ });
147
+ entries.push(entry);
148
+ continue;
149
+ }
150
+
151
+ const projectDocs = { ...manifest.projectDocs };
152
+ if (typeof projectDocs.domains === 'string') {
153
+ if (typeof projectDocs.domainLocation !== 'string' || !projectDocs.domainLocation.trim()) {
154
+ projectDocs.domainLocation = projectDocs.domains;
155
+ }
156
+ delete projectDocs.domains;
157
+ entry.warnings.push({
158
+ code: 'deprecated-domains-string',
159
+ severity: 'warning',
160
+ path: configSource(workspaceRoot, file),
161
+ message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
162
+ });
163
+ }
164
+
165
+ const validation = validateDocsConfig(projectDocs);
166
+ const source = configSource(workspaceRoot, file);
167
+ entry.warnings.push(...validation.warnings.map(item => ({ ...item, path: source })));
168
+ if (!validation.valid) {
169
+ const details = validation.diagnostics.map(item => item.message).join('; ');
170
+ entry.diagnostics.push({
171
+ code: 'invalid-host-config',
172
+ severity: 'error',
173
+ path: source,
174
+ message: `projectDocs in ${source} failed validation${details ? `: ${details}` : ''}`
175
+ });
176
+ entries.push(entry);
177
+ continue;
178
+ }
179
+ entry.projectDocs = projectDocs;
180
+ entries.push(entry);
75
181
  }
76
- const complete = manifests.find(entry =>
77
- ['docLocation', 'tracker', 'issueDefinition'].every(field => String(entry.projectDocs[field] || '').trim())
78
- );
79
- return complete || manifests[0] || null;
182
+ return entries;
183
+ }
184
+
185
+ function readProjectDocs(workspaceRoot, options = {}) {
186
+ const resolvedRoot = workspaceRoot || getWorkspaceRoot();
187
+ if (!resolvedRoot) throw new Error('cannot resolve project docs outside a git workspace; pass a workspace root explicitly');
188
+ const root = path.resolve(resolvedRoot);
189
+ const selection = resolveDocsConfig(root, options);
190
+ const firstSource = selection.sources && selection.sources[0];
191
+ const file = firstSource ? path.resolve(root, ...firstSource.split('/')) : null;
192
+ return {
193
+ file,
194
+ projectDocs: selection.config,
195
+ source: selection.status,
196
+ status: selection.status,
197
+ invalid: selection.status === 'invalid',
198
+ sources: selection.sources || [],
199
+ warnings: selection.warnings || [],
200
+ diagnostics: selection.diagnostics || []
201
+ };
202
+ }
203
+
204
+ function isRepoRelativePath(value) {
205
+ if (typeof value !== 'string' || !value.trim()) return false;
206
+ const normalized = value.trim().replace(/\\/g, '/');
207
+ if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return false;
208
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return false;
209
+ return !normalized.split('/').includes('..');
210
+ }
211
+
212
+ // Config globs must match the shape the matcher canonicalizes against:
213
+ // forward slashes, repo-relative, no "./" or "../" leading segments. A
214
+ // non-canonical value would validate but silently match nothing.
215
+ function getPatternIssue(value) {
216
+ if (typeof value !== 'string' || !value.trim()) return 'must be a non-empty string';
217
+ if (value.includes('\\')) return 'must use forward slashes (backslashes are not allowed)';
218
+ if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return 'must be repo-relative (no leading "/")';
219
+ if (value.startsWith('./') || value === '.') return 'must be canonical (no leading "./")';
220
+ if (value.startsWith('../')) return 'must be canonical (no leading "../")';
221
+ if (value.split('/').includes('..')) return 'must not contain ".." segments';
222
+ if (value.split('/').includes('.')) return 'must not contain "." path segments';
223
+ if (value.split('/').includes('')) return 'must not contain empty path segments (no "//" or trailing "/")';
224
+ return null;
225
+ }
226
+
227
+ function validateDocsConfig(config) {
228
+ const diagnostics = [];
229
+ const warnings = [];
230
+ function invalid(code, message) {
231
+ diagnostics.push({ code, severity: 'error', path: PROJECT_DOCS_CONFIG.replace(/\\/g, '/'), message });
232
+ }
233
+
234
+ if (!isPlainObject(config)) {
235
+ invalid('invalid-config-root', 'project-docs.json must contain a JSON object');
236
+ return { valid: false, diagnostics, warnings };
237
+ }
238
+
239
+ for (const key of Object.keys(config).sort()) {
240
+ if (!PROJECT_DOC_CONFIG_KEYS.has(key)) {
241
+ warnings.push({
242
+ code: 'unknown-config-key',
243
+ severity: 'warning',
244
+ path: PROJECT_DOCS_CONFIG.replace(/\\/g, '/'),
245
+ message: `unknown project-docs.json key: ${key}`
246
+ });
247
+ }
248
+ }
249
+
250
+ if (Object.prototype.hasOwnProperty.call(config, 'governed')) {
251
+ if (!Array.isArray(config.governed)) {
252
+ invalid('invalid-governed', 'governed must be an array');
253
+ } else {
254
+ config.governed.forEach((entry, index) => {
255
+ if (!isPlainObject(entry) || getPatternIssue(entry.path)) {
256
+ const issue = isPlainObject(entry) ? getPatternIssue(entry.path) : 'must be an object with a path';
257
+ invalid('invalid-governed-entry', `governed[${index}].path ${issue} (must be a canonical repo-relative glob like "docs/**"); got ${JSON.stringify(entry && entry.path)}`);
258
+ return;
259
+ }
260
+ if (!PROJECT_DOC_ROLES.includes(entry.role)) {
261
+ invalid('invalid-governed-role', `governed[${index}].role must be one of ${PROJECT_DOC_ROLES.join(', ')}`);
262
+ }
263
+ });
264
+ }
265
+ }
266
+
267
+ if (Object.prototype.hasOwnProperty.call(config, 'domains')) {
268
+ if (!Array.isArray(config.domains)) {
269
+ invalid('invalid-domains', 'domains must be an array');
270
+ } else {
271
+ const seen = new Set();
272
+ config.domains.forEach((domain, index) => {
273
+ if (typeof domain !== 'string' || !/^[A-Z][A-Z0-9]{1,9}$/.test(domain)) {
274
+ invalid('invalid-domain', `domains[${index}] must match ^[A-Z][A-Z0-9]{1,9}$`);
275
+ } else if (seen.has(domain)) {
276
+ invalid('duplicate-domain', `domains contains duplicate value ${domain}`);
277
+ }
278
+ seen.add(domain);
279
+ });
280
+ }
281
+ }
282
+
283
+ if (Object.prototype.hasOwnProperty.call(config, 'legacy')) {
284
+ if (!Array.isArray(config.legacy)) {
285
+ invalid('invalid-legacy', 'legacy must be an array of repo-relative globs');
286
+ } else {
287
+ config.legacy.forEach((pattern, index) => {
288
+ const issue = getPatternIssue(pattern);
289
+ if (issue) invalid('invalid-legacy-entry', `legacy[${index}] ${issue} (must be a canonical repo-relative glob like "docs/**"); got ${JSON.stringify(pattern)}`);
290
+ });
291
+ }
292
+ }
293
+
294
+ if (Object.prototype.hasOwnProperty.call(config, 'derived')) {
295
+ if (!Array.isArray(config.derived)) {
296
+ invalid('invalid-derived', 'derived must be an array');
297
+ } else {
298
+ config.derived.forEach((entry, index) => {
299
+ if (!isPlainObject(entry) || getPatternIssue(entry.path) ||
300
+ typeof entry.generator !== 'string' || !entry.generator.trim()) {
301
+ const issue = isPlainObject(entry) ? (getPatternIssue(entry.path) || null) : null;
302
+ if (issue) {
303
+ invalid('invalid-derived-entry', `derived[${index}].path ${issue} (must be a canonical repo-relative path like "docs/gen/index.md"); got ${JSON.stringify(entry && entry.path)}`);
304
+ } else {
305
+ invalid('invalid-derived-entry', `derived[${index}] must have non-empty path and generator strings`);
306
+ }
307
+ }
308
+ });
309
+ }
310
+ }
311
+
312
+ if (Object.prototype.hasOwnProperty.call(config, 'tickets') &&
313
+ config.tickets !== 'tracker' && !isRepoRelativePath(config.tickets)) {
314
+ invalid('invalid-tickets', 'tickets must be "tracker" or a repo-relative path');
315
+ }
316
+
317
+ return { valid: diagnostics.length === 0, diagnostics, warnings };
318
+ }
319
+
320
+ function canonicalize(value) {
321
+ if (Array.isArray(value)) return value.map(canonicalize);
322
+ if (!isPlainObject(value)) return value;
323
+ const normalized = {};
324
+ for (const key of Object.keys(value).sort()) normalized[key] = canonicalize(value[key]);
325
+ return normalized;
326
+ }
327
+
328
+ function canonicalJson(value) {
329
+ return JSON.stringify(canonicalize(value));
330
+ }
331
+
332
+ function configSource(workspaceRoot, file) {
333
+ return relativePath(workspaceRoot, file);
334
+ }
335
+
336
+ function resolveDocsConfig(workspaceRoot, options = {}) {
337
+ const resolvedRoot = workspaceRoot || getWorkspaceRoot();
338
+ if (!resolvedRoot) throw new Error('cannot resolve project docs outside a git workspace; pass a workspace root explicitly');
339
+ const root = path.resolve(resolvedRoot);
340
+ const projectFile = projectDocsConfigPath(root);
341
+ if (fs.existsSync(projectFile)) {
342
+ const source = configSource(root, projectFile);
343
+ let config;
344
+ try {
345
+ config = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
346
+ } catch {
347
+ return {
348
+ status: 'invalid',
349
+ sources: [source],
350
+ config: null,
351
+ warnings: [],
352
+ diagnostics: [{ code: 'invalid-project-config-json', severity: 'error', path: source, message: 'project-docs.json must contain valid JSON' }]
353
+ };
354
+ }
355
+
356
+ const validation = validateDocsConfig(config);
357
+ const diagnostics = validation.diagnostics.map(item => ({ ...item, path: source }));
358
+ const warnings = validation.warnings.map(item => ({ ...item, path: source }));
359
+ if (!validation.valid) {
360
+ return { status: 'invalid', sources: [source], config: null, warnings, diagnostics };
361
+ }
362
+
363
+ const hosts = inspectHostProjectDocs(root);
364
+ const hostDiagnostics = hosts.flatMap(entry => entry.diagnostics);
365
+ const hostWarnings = hosts.flatMap(entry => entry.warnings);
366
+ const validHosts = hosts.filter(entry => entry.projectDocs);
367
+ const differing = validHosts.filter(entry => canonicalJson(entry.projectDocs) !== canonicalJson(config));
368
+ if (differing.length) {
369
+ warnings.push({
370
+ code: 'project-overrides-host',
371
+ severity: 'warning',
372
+ path: source,
373
+ message: 'project-docs.json wholly overrides differing host projectDocs values',
374
+ candidates: differing.map(entry => configSource(root, entry.file))
375
+ });
376
+ }
377
+ return { status: 'project', sources: [source], config, warnings: [...warnings, ...hostWarnings], diagnostics: [...diagnostics, ...hostDiagnostics] };
378
+ }
379
+
380
+ const hosts = inspectHostProjectDocs(root);
381
+ const hostDiagnostics = hosts.flatMap(entry => entry.diagnostics);
382
+ const hostWarnings = hosts.flatMap(entry => entry.warnings);
383
+ const activeHome = normalizedHostHome(options.activeHost);
384
+ if (activeHome) {
385
+ const activeHost = hosts.find(entry => entry.home === activeHome);
386
+ const activeFile = activeHost ? activeHost.file : path.join(root, activeHome, 'harness-everything', 'manifest.json');
387
+ const activeSource = configSource(root, activeFile);
388
+ if (activeHost && activeHost.diagnostics.length) {
389
+ return {
390
+ status: 'invalid',
391
+ sources: [activeSource],
392
+ config: null,
393
+ warnings: [...hostWarnings],
394
+ diagnostics: [...hostDiagnostics]
395
+ };
396
+ }
397
+ if (!activeHost || !activeHost.hasProjectDocs) {
398
+ return {
399
+ status: 'none',
400
+ sources: [activeSource],
401
+ config: null,
402
+ warnings: [
403
+ ...hostWarnings,
404
+ {
405
+ code: 'missing-project-docs-config',
406
+ severity: 'warning',
407
+ path: activeSource,
408
+ message: `active host has no projectDocs config; add .agents/harness-everything/project-docs.json`
409
+ }
410
+ ],
411
+ diagnostics: [...hostDiagnostics]
412
+ };
413
+ }
414
+ return {
415
+ status: 'host',
416
+ sources: [activeSource],
417
+ config: activeHost.projectDocs,
418
+ warnings: [...hostWarnings],
419
+ diagnostics: [...hostDiagnostics]
420
+ };
421
+ }
422
+
423
+ const validHosts = hosts.filter(entry => entry.projectDocs);
424
+ if (!validHosts.length) return { status: 'none', sources: [], config: null, warnings: hostWarnings, diagnostics: hostDiagnostics };
425
+ const sources = validHosts.map(entry => configSource(root, entry.file));
426
+ const firstConfig = validHosts[0].projectDocs;
427
+ const equal = validHosts.every(entry => canonicalJson(entry.projectDocs) === canonicalJson(firstConfig));
428
+ if (equal) return { status: 'host', sources, config: firstConfig, warnings: hostWarnings, diagnostics: hostDiagnostics };
429
+ return {
430
+ status: 'ambiguous',
431
+ sources,
432
+ config: null,
433
+ warnings: hostWarnings,
434
+ diagnostics: [{
435
+ code: 'ambiguous-host-project-docs',
436
+ severity: 'error',
437
+ path: sources[0],
438
+ message: 'host projectDocs configurations differ and no active host selects one',
439
+ candidates: sources
440
+ }, ...hostDiagnostics]
441
+ };
80
442
  }
81
443
 
82
444
  function cleanConfiguredPath(value) {
@@ -230,8 +592,9 @@ function resolveProjectDocs(workspaceRoot, options = {}) {
230
592
  const resolvedRoot = workspaceRoot || getWorkspaceRoot();
231
593
  if (!resolvedRoot) throw new Error('cannot resolve project docs outside a git workspace; pass --workspace <path> explicitly');
232
594
  const root = path.resolve(resolvedRoot);
233
- const manifest = readProjectDocs(root);
234
- const projectDocs = manifest ? manifest.projectDocs : {};
595
+ const manifest = readProjectDocs(root, options);
596
+ const hasValidConfig = manifest.status === 'project' || manifest.status === 'host';
597
+ const projectDocs = hasValidConfig ? manifest.projectDocs || {} : {};
235
598
  const contextMap = parseContextMap(root, projectDocs.contextMap || projectDocs.contextMapPath);
236
599
  const paths = {};
237
600
 
@@ -272,7 +635,11 @@ function resolveProjectDocs(workspaceRoot, options = {}) {
272
635
  logs: path.join(runtimeRoot, 'logs'),
273
636
  roles: path.join(runtimeRoot, 'roles')
274
637
  },
275
- projectDocs: manifest ? { ...projectDocs, manifestPath: manifest.file } : null,
638
+ projectDocsStatus: manifest.status,
639
+ projectDocsSources: manifest.sources,
640
+ projectDocsWarnings: manifest.warnings,
641
+ projectDocsDiagnostics: manifest.diagnostics,
642
+ projectDocs: hasValidConfig && manifest.file ? { ...projectDocs, manifestPath: manifest.file } : null,
276
643
  contextMap: contextMap ? { path: contextMap.path, links: contextMap.links } : null,
277
644
  paths,
278
645
  // Plural aliases make the contract readable to callers that treat these
@@ -283,14 +650,40 @@ function resolveProjectDocs(workspaceRoot, options = {}) {
283
650
  };
284
651
  }
285
652
 
286
- function detectProjectDocsConventions(workspaceRoot) {
653
+ function detectProjectDocsConventions(workspaceRoot, options = {}) {
654
+ const REQUIRED_CONFIG_FIELDS = ['docLocation', 'tracker', 'issueDefinition'];
287
655
  const resolvedRoot = workspaceRoot || getWorkspaceRoot();
288
656
  if (!resolvedRoot) return {};
289
657
  const root = path.resolve(resolvedRoot);
290
- const configured = readProjectDocs(root);
658
+ const selection = resolveDocsConfig(root, options);
659
+ if (selection.status === 'invalid' || selection.status === 'ambiguous') {
660
+ return {
661
+ status: selection.status,
662
+ sources: selection.sources || [],
663
+ warnings: selection.warnings || [],
664
+ diagnostics: selection.diagnostics || [],
665
+ missing: []
666
+ };
667
+ }
668
+ const configured = selection.config || {};
291
669
  const inferred = {};
292
- if (configured && configured.projectDocs && configured.projectDocs.docLocation) {
293
- inferred.docLocation = configured.projectDocs.docLocation;
670
+ for (const field of REQUIRED_CONFIG_FIELDS) {
671
+ if (typeof configured[field] === 'string' && configured[field].trim()) inferred[field] = configured[field];
672
+ }
673
+ // Heuristic completion is a resource for UNSELECTED workspaces only: a
674
+ // project or selected-host configuration is authoritative for exactly the
675
+ // fields it declares, so missing selected fields are reported instead of
676
+ // being silently authorized from workspace conventions.
677
+ if (selection.status !== 'none') {
678
+ const missing = REQUIRED_CONFIG_FIELDS.filter(field => !inferred[field]);
679
+ return {
680
+ ...inferred,
681
+ status: selection.status,
682
+ sources: selection.sources || [],
683
+ warnings: selection.warnings || [],
684
+ diagnostics: selection.diagnostics || [],
685
+ missing
686
+ };
294
687
  }
295
688
  if (!inferred.docLocation) {
296
689
  for (const dir of ['docs/roadmaps', 'docs/reference', 'docs/specs', 'docs', 'doc']) {
@@ -300,23 +693,35 @@ function detectProjectDocsConventions(workspaceRoot) {
300
693
  }
301
694
  }
302
695
  }
303
- if (pathIsDir(path.join(root, '.github', 'ISSUE_TEMPLATE'))) {
304
- inferred.tracker = 'GitHub Issues via .github/ISSUE_TEMPLATE/';
305
- } else if (pathIsDir(path.join(root, 'docs', 'roadmaps'))) {
306
- inferred.tracker = 'docs/roadmaps/ local markdown tickets';
307
- } else if (pathIsDir(path.join(root, 'tasks', 'tickets'))) {
308
- inferred.tracker = 'tasks/tickets/ local markdown tickets';
309
- } else if (pathIsDir(path.join(root, '.scratch'))) {
310
- inferred.tracker = '.scratch/ local markdown workspace';
311
- } else if (inferred.docLocation) {
312
- inferred.tracker = `${inferred.docLocation}tickets/ local markdown tickets`;
696
+ if (!inferred.tracker) {
697
+ if (pathIsDir(path.join(root, '.github', 'ISSUE_TEMPLATE'))) {
698
+ inferred.tracker = 'GitHub Issues via .github/ISSUE_TEMPLATE/';
699
+ } else if (pathIsDir(path.join(root, 'docs', 'roadmaps'))) {
700
+ inferred.tracker = 'docs/roadmaps/ local markdown tickets';
701
+ } else if (pathIsDir(path.join(root, 'tasks', 'tickets'))) {
702
+ inferred.tracker = 'tasks/tickets/ local markdown tickets';
703
+ } else if (pathIsDir(path.join(root, '.scratch'))) {
704
+ inferred.tracker = '.scratch/ local markdown workspace';
705
+ } else if (inferred.docLocation) {
706
+ inferred.tracker = `${inferred.docLocation}tickets/ local markdown tickets`;
707
+ }
313
708
  }
314
- if (inferred.tracker && inferred.tracker.includes('ISSUE_TEMPLATE')) {
315
- inferred.issueDefinition = '.github/ISSUE_TEMPLATE/ template structure';
316
- } else if (inferred.tracker) {
317
- inferred.issueDefinition = 'Title + acceptance criteria + Status: ready-for-agent';
709
+ if (!inferred.issueDefinition) {
710
+ if (inferred.tracker && inferred.tracker.includes('ISSUE_TEMPLATE')) {
711
+ inferred.issueDefinition = '.github/ISSUE_TEMPLATE/ template structure';
712
+ } else if (inferred.tracker) {
713
+ inferred.issueDefinition = 'Title + acceptance criteria + Status: ready-for-agent';
714
+ }
318
715
  }
319
- return inferred;
716
+ const missing = REQUIRED_CONFIG_FIELDS.filter(field => !inferred[field]);
717
+ return {
718
+ ...inferred,
719
+ status: selection.status,
720
+ sources: selection.sources || [],
721
+ warnings: selection.warnings || [],
722
+ diagnostics: selection.diagnostics || [],
723
+ missing
724
+ };
320
725
  }
321
726
 
322
727
  function isIgnored(workspaceRoot, target) {
@@ -347,6 +752,8 @@ module.exports = {
347
752
  getRuntimeRoot,
348
753
  getRepoManifestHomes,
349
754
  readProjectDocs,
755
+ resolveDocsConfig,
756
+ validateDocsConfig,
350
757
  parseContextMap,
351
758
  resolveProjectDocs,
352
759
  detectProjectDocsConventions,
@@ -4,7 +4,7 @@ description: "Turn the conversation into a written spec — feature PRD, CLI/API
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.30.4
7
+ version: 0.34.0
8
8
  ---
9
9
 
10
10
  # To Spec