ultra-builder-pro-cli 0.26.1 → 3.0.2

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 (75) hide show
  1. package/.ultra-template/contexts/TEMPLATE.md +64 -29
  2. package/.ultra-template/north-star.md +36 -30
  3. package/.ultra-template/project-brief.md +28 -0
  4. package/.ultra-template/specs/product.md +4 -0
  5. package/.ultra-template/specs/research-distillate.md +8 -0
  6. package/.ultra-template/tasks.json +1 -0
  7. package/.ultra-template/test-report.json +18 -1
  8. package/CHANGELOG.md +227 -1
  9. package/README.md +161 -59
  10. package/adapters/_shared/host-profile.cjs +68 -12
  11. package/adapters/_shared/plugin-core.cjs +4 -5
  12. package/adapters/_shared/runtime-assets.cjs +11 -2
  13. package/adapters/zcode.js +375 -0
  14. package/bin/delegate-worker.cjs +20 -0
  15. package/bin/delegate.cjs +94 -25
  16. package/bin/install.js +11 -6
  17. package/docs/ARCHITECTURE.md +109 -25
  18. package/docs/ARTIFACT-AUTHORITY.md +204 -30
  19. package/docs/DECISIONS.md +141 -10
  20. package/docs/PHILOSOPHY.md +61 -8
  21. package/docs/PLUGIN-ISOLATION-CONTRACT.md +6 -0
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +106 -13
  23. package/docs/SKILL-AUTHORING.md +14 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +211 -53
  25. package/docs/evals/adversarial-review-2026-08-14.md +84 -0
  26. package/docs/evals/zcode-automation-2026-08-14.md +103 -0
  27. package/hooks/README.md +31 -3
  28. package/hooks/_common.py +1699 -33
  29. package/hooks/adapters/zcode.py +227 -0
  30. package/hooks/compact_context.py +176 -17
  31. package/hooks/mid_workflow_recall.py +27 -5
  32. package/hooks/post_edit_guard.py +133 -23
  33. package/hooks/session_context.py +722 -11
  34. package/package.json +6 -3
  35. package/skills/ultra-change/SKILL.md +84 -64
  36. package/skills/ultra-change/references/change-contract.md +130 -32
  37. package/skills/ultra-change/references/execution-grant.md +109 -0
  38. package/skills/ultra-change/references/primary-transfer.md +273 -0
  39. package/skills/ultra-change/scripts/validate_primary_transfer.cjs +1928 -0
  40. package/skills/ultra-delegate/SKILL.md +38 -5
  41. package/skills/ultra-delegate/references/delegation-contract.md +21 -1
  42. package/skills/ultra-deliver/SKILL.md +123 -44
  43. package/skills/ultra-deliver/references/baseline-reconciliation.md +5 -3
  44. package/skills/ultra-dev/SKILL.md +173 -43
  45. package/skills/ultra-grilling/SKILL.md +19 -16
  46. package/skills/ultra-grilling/references/reframing.md +7 -3
  47. package/skills/ultra-init/SKILL.md +99 -87
  48. package/skills/ultra-init/references/brownfield-adoption.md +23 -40
  49. package/skills/ultra-init/scripts/init_project.cjs +968 -21
  50. package/skills/ultra-plan/SKILL.md +83 -46
  51. package/skills/ultra-plan/references/task-evidence-v2.md +300 -0
  52. package/skills/ultra-plan/scripts/validate_task_evidence.cjs +859 -0
  53. package/skills/ultra-research/SKILL.md +119 -68
  54. package/skills/ultra-research/references/{03-competitive-landscape.md → 03-alternatives.md} +6 -6
  55. package/skills/ultra-research/references/04-product-strategy.md +11 -2
  56. package/skills/ultra-research/references/21-features-scope.md +8 -0
  57. package/skills/ultra-research/references/22-success-metrics.md +9 -4
  58. package/skills/ultra-research/references/99-synthesis.md +25 -1
  59. package/skills/ultra-research/references/north-star-v2.md +168 -0
  60. package/skills/ultra-research/references/wayfinding.md +50 -0
  61. package/skills/ultra-research/scripts/validate_north_star.cjs +1174 -0
  62. package/skills/ultra-review/SKILL.md +201 -24
  63. package/skills/ultra-review/references/adversarial-evaluation.md +31 -0
  64. package/skills/ultra-review/references/spec.md +5 -0
  65. package/skills/ultra-review/references/unified-schema.md +146 -11
  66. package/skills/ultra-review/references/worker-packet.md +151 -8
  67. package/skills/ultra-review/scripts/review_wait.py +2653 -63
  68. package/skills/ultra-status/SKILL.md +146 -32
  69. package/skills/ultra-tdd/SKILL.md +14 -4
  70. package/skills/ultra-tdd/references/test-execution.md +6 -1
  71. package/skills/ultra-test/SKILL.md +157 -15
  72. package/skills/ultra-test/scripts/validate_review_transport.cjs +549 -0
  73. package/skills/ultra-test/scripts/worktree_digest.cjs +551 -29
  74. package/skills/ultra-think/SKILL.md +5 -10
  75. package/skills/ultra-think/references/interaction-boundary.md +2 -1
@@ -0,0 +1,1174 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const crypto = require('node:crypto');
6
+ const path = require('node:path');
7
+ const { TextDecoder } = require('node:util');
8
+
9
+ const MAX_STDIN_BYTES = 8 * 1024 * 1024;
10
+ const STDIN_CHUNK_BYTES = 64 * 1024;
11
+
12
+ const REQUIRED_HEADINGS = [
13
+ 'Acceptance and Revision',
14
+ 'Problem Reality',
15
+ 'First-Principle Propositions',
16
+ 'Value Causal Chain',
17
+ 'North Star Outcomes',
18
+ 'Hard Constraints',
19
+ 'Explicit Exclusions',
20
+ 'Uncertainties and Revisit Triggers',
21
+ 'Research Trace',
22
+ ];
23
+
24
+ const V2_ONLY_HEADINGS = new Set([
25
+ 'Acceptance and Revision',
26
+ 'Problem Reality',
27
+ 'First-Principle Propositions',
28
+ 'Value Causal Chain',
29
+ 'North Star Outcomes',
30
+ 'Uncertainties and Revisit Triggers',
31
+ ]);
32
+
33
+ const LEGACY_V026_HEADINGS = ['Project Direction', 'North Star Outcome', 'Hard Constraints'];
34
+ const LEGACY_V026_ALLOWED = new Set([
35
+ ...LEGACY_V026_HEADINGS,
36
+ 'Explicit Exclusions',
37
+ 'Research Trace',
38
+ 'Notes for agents',
39
+ ]);
40
+ const LEGACY_ONE_LINE_ALLOWED = new Set([
41
+ 'One-line',
42
+ 'Hard Constraints',
43
+ 'Explicit Exclusions',
44
+ 'Research Trace',
45
+ ]);
46
+
47
+ const ENTRY_FIELDS = {
48
+ FP: ['Proposition', 'Evidence', 'Causal consequence', 'Falsifier or revisit trigger', 'Status'],
49
+ NS: ['Outcome', 'Observation method', 'Baseline', 'Target or expected change', 'Horizon', 'Anti-metric'],
50
+ HC: ['Protected value or threat', 'Constraint', 'Authority or evidence', 'Revisit condition'],
51
+ };
52
+
53
+ const OWNER_SECTION = {
54
+ FP: 'First-Principle Propositions',
55
+ NS: 'North Star Outcomes',
56
+ HC: 'Hard Constraints',
57
+ };
58
+
59
+ const UNRESEARCHED_ACCEPTANCE = new Map([
60
+ ['Schema', 'north-star-v2'],
61
+ ['Status', 'unresearched'],
62
+ ['Revision', 'none'],
63
+ ['Owner acceptance source', 'none'],
64
+ ['Acceptance time', 'not-recorded'],
65
+ ['Supersedes', 'none'],
66
+ ]);
67
+
68
+ const UNRESEARCHED_SECTION_BODIES = new Map([
69
+ ['Problem Reality', '- Reality: [NEEDS RESEARCH]\n- Evidence: [NEEDS RESEARCH]\n- Unknowns: [NEEDS RESEARCH]'],
70
+ ['First-Principle Propositions', '[NEEDS RESEARCH: Research creates stable proposition IDs only after owner acceptance.]'],
71
+ ['Value Causal Chain', '[NEEDS RESEARCH: map accepted principles through capability and behavior to outcomes.]'],
72
+ ['North Star Outcomes', '[NEEDS RESEARCH: do not create an outcome ID or metric during Init.]'],
73
+ ['Hard Constraints', '[NEEDS RESEARCH: do not create a constraint ID during Init.]'],
74
+ ['Explicit Exclusions', '- [NEEDS RESEARCH]'],
75
+ ['Uncertainties and Revisit Triggers', '- [NEEDS RESEARCH]'],
76
+ ['Research Trace', '- Project Brief: `project-brief.md`\n- Research runs: none\n- Sources and decisions: none'],
77
+ ]);
78
+
79
+ // Mechanical checksum of the complete packaged placeholder bytes. The owner-readable
80
+ // `.ultra-template/north-star.md` remains canonical; this digest makes every title,
81
+ // preamble, field, sentinel, interstitial byte, and trailing newline part of Init's
82
+ // exact unresearched grammar.
83
+ const UNRESEARCHED_CANONICAL_SHA256 = '7fb743650b3f520eb1880cd0cc5ef31957f0719d24585ca1447cb1e2cc7c3000';
84
+
85
+ function stripMarkup(value) {
86
+ return value.trim().replace(/^`|`$/g, '');
87
+ }
88
+
89
+ function renderedDocument(rawText) {
90
+ const renderedLines = [];
91
+ let fence = null;
92
+ for (const match of rawText.matchAll(/[^\n]*(?:\n|$)/gu)) {
93
+ if (!match[0]) continue;
94
+ const withoutNewline = match[0].endsWith('\n') ? match[0].slice(0, -1) : match[0];
95
+ const line = withoutNewline.endsWith('\r')
96
+ ? withoutNewline.slice(0, -1)
97
+ : withoutNewline;
98
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/u);
99
+ let rendered = true;
100
+ if (fence) {
101
+ rendered = false;
102
+ if (fenceMatch
103
+ && fenceMatch[1][0] === fence.marker
104
+ && fenceMatch[1].length >= fence.length
105
+ && /^[ \t]*$/u.test(fenceMatch[2])) {
106
+ fence = null;
107
+ }
108
+ } else if (fenceMatch
109
+ && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) {
110
+ rendered = false;
111
+ fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
112
+ }
113
+ renderedLines.push(
114
+ rendered ? match[0] : match[0].replace(/[^\r\n]/g, ' '),
115
+ );
116
+ }
117
+ return {
118
+ raw: rawText,
119
+ rendered: renderedLines.join(''),
120
+ };
121
+ }
122
+
123
+ function topLevelSections(renderedText) {
124
+ const matches = [...renderedText.matchAll(/^## ([^\n]+)\n/gmu)];
125
+ return matches.map((match, index) => ({
126
+ name: match[1].trim(),
127
+ start: match.index,
128
+ bodyStart: match.index + match[0].length,
129
+ end: index + 1 < matches.length ? matches[index + 1].index : renderedText.length,
130
+ body: renderedText.slice(
131
+ match.index + match[0].length,
132
+ index + 1 < matches.length ? matches[index + 1].index : renderedText.length,
133
+ ),
134
+ }));
135
+ }
136
+
137
+ function firstSections(sectionList) {
138
+ const found = new Map();
139
+ for (const section of sectionList) {
140
+ if (!found.has(section.name)) found.set(section.name, section.body);
141
+ }
142
+ return found;
143
+ }
144
+
145
+ function parsedFields(body) {
146
+ const found = new Map();
147
+ const counts = new Map();
148
+ for (const match of body.matchAll(/^- ([^:\n]+):\s*(.*)$/gmu)) {
149
+ const name = match[1].trim();
150
+ counts.set(name, (counts.get(name) || 0) + 1);
151
+ if (!found.has(name)) found.set(name, stripMarkup(match[2]));
152
+ }
153
+ return {
154
+ values: found,
155
+ duplicates: [...counts.entries()]
156
+ .filter(([, count]) => count > 1)
157
+ .map(([name]) => name),
158
+ };
159
+ }
160
+
161
+ function semanticEntries(body) {
162
+ const headings = [...body.matchAll(/^### ((FP|NS|HC)-[A-Za-z0-9][A-Za-z0-9._-]*)\b[^\n]*\n/gmu)];
163
+ return headings.map((match, index) => ({
164
+ id: match[1],
165
+ kind: match[2],
166
+ body: body.slice(
167
+ match.index + match[0].length,
168
+ index + 1 < headings.length ? headings[index + 1].index : body.length,
169
+ ),
170
+ }));
171
+ }
172
+
173
+ function diagnostic(code, message, location = null, severity = 'error') {
174
+ return { code, severity, message, location };
175
+ }
176
+
177
+ function headingSlug(heading) {
178
+ return heading
179
+ .trim()
180
+ .toLowerCase()
181
+ .replace(/[`*_~]/gu, '')
182
+ .replace(/[^\p{Letter}\p{Number}\s-]/gu, '')
183
+ .replace(/\s+/gu, '-')
184
+ .replace(/-+/gu, '-');
185
+ }
186
+
187
+ function renderedHeadings(renderedText) {
188
+ const headings = [];
189
+ for (const match of renderedText.matchAll(/[^\n]*(?:\n|$)/gu)) {
190
+ if (!match[0]) continue;
191
+ const withoutNewline = match[0].endsWith('\n') ? match[0].slice(0, -1) : match[0];
192
+ const line = withoutNewline.endsWith('\r')
193
+ ? withoutNewline.slice(0, -1)
194
+ : withoutNewline;
195
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/u);
196
+ if (heading) {
197
+ headings.push({
198
+ index: match.index,
199
+ bodyStart: match.index + match[0].length,
200
+ level: heading[1].length,
201
+ title: heading[2],
202
+ });
203
+ }
204
+ }
205
+ return headings;
206
+ }
207
+
208
+ function anchoredSections(renderedText, slug) {
209
+ const headings = renderedHeadings(renderedText);
210
+ return headings
211
+ .map((heading, index) => {
212
+ const next = headings.slice(index + 1).find(
213
+ (candidate) => candidate.level <= heading.level,
214
+ );
215
+ return {
216
+ slug: headingSlug(heading.title),
217
+ body: renderedText.slice(
218
+ heading.bodyStart,
219
+ next ? next.index : renderedText.length,
220
+ ),
221
+ };
222
+ })
223
+ .filter((section) => section.slug === slug);
224
+ }
225
+
226
+ function validateOwnerRecord(body, diagnostics) {
227
+ const values = new Map();
228
+ const counts = new Map();
229
+ for (const match of body.matchAll(/^- ([^:\n]+):[ \t]*(.*)$/gmu)) {
230
+ const name = match[1].trim();
231
+ counts.set(name, (counts.get(name) || 0) + 1);
232
+ if (!values.has(name)) values.set(name, stripMarkup(match[2]));
233
+ }
234
+ for (const name of [
235
+ 'Conversation scope',
236
+ 'Exact raw owner acceptance',
237
+ 'Agency boundary',
238
+ 'Time boundary',
239
+ 'Revision boundary',
240
+ ]) {
241
+ const location = `Owner Record.${name}`;
242
+ if (!values.has(name)) {
243
+ diagnostics.push(diagnostic(
244
+ 'owner_record_field_missing',
245
+ `Accepted Owner Record requires ${name}`,
246
+ location,
247
+ ));
248
+ } else if (!values.get(name)) {
249
+ diagnostics.push(diagnostic(
250
+ 'owner_record_field_empty',
251
+ `Accepted Owner Record requires nonempty ${name}`,
252
+ location,
253
+ ));
254
+ }
255
+ if ((counts.get(name) || 0) > 1) {
256
+ diagnostics.push(diagnostic(
257
+ 'duplicate_owner_record_field',
258
+ `Accepted Owner Record repeats ${name}`,
259
+ location,
260
+ ));
261
+ }
262
+ }
263
+ }
264
+
265
+ function gitBlobDigest(bytes) {
266
+ const header = Buffer.from(`blob ${bytes.length}\0`, 'utf8');
267
+ return crypto.createHash('sha1').update(header).update(bytes).digest('hex');
268
+ }
269
+
270
+ function pathIsWithin(file, directory) {
271
+ const relative = path.relative(directory, file);
272
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
273
+ }
274
+
275
+ function acceptedAuthorityContext(file, diagnostics) {
276
+ const canonical = path.resolve(file);
277
+ if (path.basename(canonical) !== 'north-star.md'
278
+ || path.basename(path.dirname(canonical)) !== '.ultra') {
279
+ diagnostics.push(diagnostic(
280
+ 'invalid_authority_path',
281
+ 'An accepted North Star authority must use the canonical repository path .ultra/north-star.md',
282
+ canonical,
283
+ ));
284
+ return null;
285
+ }
286
+ const root = path.dirname(path.dirname(canonical));
287
+ try {
288
+ const rootReal = fs.realpathSync(root);
289
+ const canonicalReal = fs.realpathSync(canonical);
290
+ const expectedReal = path.join(rootReal, '.ultra', 'north-star.md');
291
+ if (canonicalReal !== expectedReal || !pathIsWithin(canonicalReal, rootReal)) {
292
+ diagnostics.push(diagnostic(
293
+ 'authority_path_escape',
294
+ 'Canonical .ultra/north-star.md must resolve inside its repository without a symlink escape',
295
+ canonical,
296
+ ));
297
+ return null;
298
+ }
299
+ return { root, rootReal };
300
+ } catch {
301
+ diagnostics.push(diagnostic(
302
+ 'authority_path_unresolved',
303
+ 'Canonical .ultra/north-star.md could not be resolved for accepted publication',
304
+ canonical,
305
+ ));
306
+ return null;
307
+ }
308
+ }
309
+
310
+ function resolveBoundFile(context, relative, prefix, missingCode, escapeCode, diagnostics) {
311
+ const candidate = path.resolve(context.root, relative);
312
+ try {
313
+ const candidateReal = fs.realpathSync(candidate);
314
+ const expectedBase = path.join(context.rootReal, ...prefix.split('/').filter(Boolean));
315
+ if (!pathIsWithin(candidateReal, expectedBase)) {
316
+ diagnostics.push(diagnostic(
317
+ escapeCode,
318
+ `Bound path resolves outside ${prefix}`,
319
+ relative,
320
+ ));
321
+ return null;
322
+ }
323
+ return candidateReal;
324
+ } catch {
325
+ diagnostics.push(diagnostic(missingCode, 'Bound artifact does not exist', relative));
326
+ return null;
327
+ }
328
+ }
329
+
330
+ function decodeUtf8(bytes) {
331
+ let text;
332
+ try {
333
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
334
+ } catch {
335
+ return null;
336
+ }
337
+ return Buffer.from(text, 'utf8').equals(bytes) ? text : null;
338
+ }
339
+
340
+ function normalizedRepositoryPath(value, prefix, suffixPattern) {
341
+ if (typeof value !== 'string' || value.includes('\\') || path.posix.normalize(value) !== value) {
342
+ return false;
343
+ }
344
+ return value.startsWith(prefix) && suffixPattern.test(value);
345
+ }
346
+
347
+ function bindingField(renderedDecisionText, name, diagnostics) {
348
+ const expression = new RegExp(`^- ${name}:\\s*` + '`?([^\\n`]+)`?\\s*$', 'gmu');
349
+ const matches = [...renderedDecisionText.matchAll(expression)];
350
+ if (matches.length === 0) {
351
+ diagnostics.push(diagnostic(
352
+ 'binding_field_missing',
353
+ `Accepted owner decision requires ${name}`,
354
+ name,
355
+ ));
356
+ return null;
357
+ }
358
+ if (matches.length > 1) {
359
+ diagnostics.push(diagnostic(
360
+ 'duplicate_binding_field',
361
+ `Accepted owner decision repeats ${name}`,
362
+ name,
363
+ ));
364
+ }
365
+ return matches[0][1].trim();
366
+ }
367
+
368
+ function validateOwnerDecisionStatus(renderedDecisionText, diagnostics) {
369
+ const matches = [...renderedDecisionText.matchAll(/^> \*\*Status\*\*:\s*([^\n]+?)\s*$/gmu)];
370
+ if (matches.length === 0) {
371
+ diagnostics.push(diagnostic(
372
+ 'owner_decision_status_missing',
373
+ 'Accepted owner decision requires one authoritative Status field',
374
+ 'Status',
375
+ ));
376
+ return;
377
+ }
378
+ if (matches.length > 1) {
379
+ diagnostics.push(diagnostic(
380
+ 'duplicate_owner_decision_status',
381
+ 'Accepted owner decision must contain exactly one authoritative Status field',
382
+ 'Status',
383
+ ));
384
+ return;
385
+ }
386
+ if (stripMarkup(matches[0][1]) !== 'accepted') {
387
+ diagnostics.push(diagnostic(
388
+ 'owner_decision_not_accepted',
389
+ 'Owner decision Status must be accepted for accepted North Star publication',
390
+ 'Status',
391
+ ));
392
+ }
393
+ }
394
+
395
+ function validateAcceptanceBinding(file, bytes, ownerSource, diagnostics, sourceObservations) {
396
+ const binding = {
397
+ source: ownerSource || null,
398
+ content_sha256: null,
399
+ git_blob_digest: null,
400
+ snapshot: null,
401
+ };
402
+ const sourceMatch = typeof ownerSource === 'string'
403
+ ? ownerSource.match(/^(\.ultra\/decisions\/[A-Za-z0-9][A-Za-z0-9._-]*\.md)#([a-z0-9][a-z0-9-]*)$/u)
404
+ : null;
405
+ if (!sourceMatch
406
+ || !normalizedRepositoryPath(sourceMatch[1], '.ultra/decisions/', /\.md$/u)) {
407
+ diagnostics.push(diagnostic(
408
+ 'invalid_owner_source',
409
+ 'Owner acceptance source must be a normalized repository-relative .ultra/decisions/*.md#anchor reference',
410
+ 'Owner acceptance source',
411
+ ));
412
+ return binding;
413
+ }
414
+
415
+ const context = acceptedAuthorityContext(file, diagnostics);
416
+ if (!context) return binding;
417
+ const decisionFile = resolveBoundFile(
418
+ context,
419
+ sourceMatch[1],
420
+ '.ultra/decisions',
421
+ 'owner_source_missing',
422
+ 'owner_source_path_escape',
423
+ diagnostics,
424
+ );
425
+ if (!decisionFile) return binding;
426
+ let decisionText;
427
+ try {
428
+ const decisionBytes = fs.readFileSync(decisionFile);
429
+ sourceObservations.push({
430
+ role: 'decision',
431
+ path: sourceMatch[1],
432
+ sha256: crypto.createHash('sha256').update(decisionBytes).digest('hex'),
433
+ byte_length: decisionBytes.length,
434
+ });
435
+ decisionText = decodeUtf8(decisionBytes);
436
+ if (decisionText === null) {
437
+ diagnostics.push(diagnostic(
438
+ 'owner_source_invalid_utf8',
439
+ 'Owner acceptance source must be round-trip-safe UTF-8',
440
+ ownerSource,
441
+ ));
442
+ return binding;
443
+ }
444
+ } catch {
445
+ diagnostics.push(diagnostic('owner_source_missing', 'Owner acceptance source does not exist', ownerSource));
446
+ return binding;
447
+ }
448
+ const decisionDocument = renderedDocument(decisionText);
449
+ validateOwnerDecisionStatus(decisionDocument.rendered, diagnostics);
450
+ const ownerSections = anchoredSections(decisionDocument.rendered, sourceMatch[2]);
451
+ if (ownerSections.length !== 1) {
452
+ diagnostics.push(diagnostic(
453
+ 'owner_anchor_missing',
454
+ 'Owner acceptance source anchor must resolve exactly once',
455
+ ownerSource,
456
+ ));
457
+ } else {
458
+ validateOwnerRecord(ownerSections[0].body, diagnostics);
459
+ }
460
+
461
+ const expectedContent = bindingField(
462
+ decisionDocument.rendered,
463
+ 'North Star content SHA-256',
464
+ diagnostics,
465
+ );
466
+ const expectedBlob = bindingField(
467
+ decisionDocument.rendered,
468
+ 'North Star Git blob digest',
469
+ diagnostics,
470
+ );
471
+ const snapshotRelative = bindingField(
472
+ decisionDocument.rendered,
473
+ 'Accepted snapshot',
474
+ diagnostics,
475
+ );
476
+ const contentSha = crypto.createHash('sha256').update(bytes).digest('hex');
477
+ const blobDigest = gitBlobDigest(bytes);
478
+ binding.content_sha256 = expectedContent;
479
+ binding.git_blob_digest = expectedBlob;
480
+ binding.snapshot = snapshotRelative;
481
+
482
+ if (expectedContent && !/^[0-9a-f]{64}$/u.test(expectedContent)) {
483
+ diagnostics.push(diagnostic('invalid_binding_digest', 'North Star content SHA-256 must be 64 lowercase hexadecimal characters', 'North Star content SHA-256'));
484
+ } else if (expectedContent && expectedContent !== contentSha) {
485
+ diagnostics.push(diagnostic('content_digest_mismatch', 'Owner decision content SHA-256 does not match the accepted North Star bytes', 'North Star content SHA-256'));
486
+ }
487
+ if (expectedBlob && !/^[0-9a-f]{40}$/u.test(expectedBlob)) {
488
+ diagnostics.push(diagnostic('invalid_binding_digest', 'North Star Git blob digest must be 40 lowercase hexadecimal characters', 'North Star Git blob digest'));
489
+ } else if (expectedBlob && expectedBlob !== blobDigest) {
490
+ diagnostics.push(diagnostic('git_blob_digest_mismatch', 'Owner decision Git blob digest does not match the accepted North Star bytes', 'North Star Git blob digest'));
491
+ }
492
+
493
+ if (snapshotRelative
494
+ && !normalizedRepositoryPath(
495
+ snapshotRelative,
496
+ '.ultra/research/',
497
+ /\/[A-Za-z0-9][A-Za-z0-9._-]*\.accepted\.md$/u,
498
+ )) {
499
+ diagnostics.push(diagnostic(
500
+ 'invalid_snapshot_path',
501
+ 'Accepted snapshot must be a normalized .ultra/research/<run-id>/*.accepted.md path',
502
+ 'Accepted snapshot',
503
+ ));
504
+ } else if (snapshotRelative) {
505
+ const snapshotFile = resolveBoundFile(
506
+ context,
507
+ snapshotRelative,
508
+ '.ultra/research',
509
+ 'snapshot_missing',
510
+ 'snapshot_path_escape',
511
+ diagnostics,
512
+ );
513
+ if (snapshotFile) {
514
+ try {
515
+ const snapshot = fs.readFileSync(snapshotFile);
516
+ sourceObservations.push({
517
+ role: 'snapshot',
518
+ path: snapshotRelative,
519
+ sha256: crypto.createHash('sha256').update(snapshot).digest('hex'),
520
+ byte_length: snapshot.length,
521
+ });
522
+ if (!snapshot.equals(bytes)) {
523
+ diagnostics.push(diagnostic('snapshot_mismatch', 'Accepted snapshot is not byte-identical to the accepted North Star', snapshotRelative));
524
+ }
525
+ } catch {
526
+ diagnostics.push(diagnostic('snapshot_missing', 'Accepted snapshot does not exist', snapshotRelative));
527
+ }
528
+ }
529
+ }
530
+ return binding;
531
+ }
532
+
533
+ function sectionSpans(rawText, sectionList) {
534
+ const indexes = [...new Set(
535
+ sectionList.flatMap((section) => [section.bodyStart, section.end]),
536
+ )].sort((left, right) => left - right);
537
+ const byteOffsets = new Map();
538
+ let cursor = 0;
539
+ let byteOffset = 0;
540
+ for (const index of indexes) {
541
+ byteOffset += Buffer.byteLength(rawText.slice(cursor, index), 'utf8');
542
+ byteOffsets.set(index, byteOffset);
543
+ cursor = index;
544
+ }
545
+ const spans = Object.create(null);
546
+ for (const section of sectionList) {
547
+ if (Object.prototype.hasOwnProperty.call(spans, section.name)) continue;
548
+ spans[section.name] = {
549
+ body_start: byteOffsets.get(section.bodyStart),
550
+ body_end: byteOffsets.get(section.end),
551
+ };
552
+ }
553
+ return spans;
554
+ }
555
+
556
+ function classifyDocument(document) {
557
+ const sections = topLevelSections(document.rendered);
558
+ const names = sections.map((section) => section.name);
559
+ const hasV2Signal = /^- Schema:\s*`?north-star-v2`?\s*$/mu.test(document.rendered)
560
+ || names.some((name) => V2_ONLY_HEADINGS.has(name));
561
+ const hasLegacySignal = names.some((name) => [
562
+ 'One-line', 'Project Direction', 'North Star Outcome',
563
+ ].includes(name));
564
+ if (hasV2Signal && hasLegacySignal) return 'mixed';
565
+ if (hasV2Signal) {
566
+ if (names.length !== REQUIRED_HEADINGS.length
567
+ || names.some((name, index) => name !== REQUIRED_HEADINGS[index])) {
568
+ return 'unknown';
569
+ }
570
+ const acceptanceSection = sections[0];
571
+ const parsed = parsedFields(acceptanceSection ? acceptanceSection.body : '');
572
+ if (parsed.duplicates.includes('Schema') || parsed.duplicates.includes('Status')) return 'unknown';
573
+ if (parsed.values.get('Schema') !== 'north-star-v2') return 'unknown';
574
+ const status = parsed.values.get('Status');
575
+ return ['accepted', 'draft', 'unresearched'].includes(status) ? status : 'unknown';
576
+ }
577
+
578
+ const unique = new Set(names);
579
+ if (unique.size !== names.length) return 'unknown';
580
+ const oneLineOrder = ['One-line', 'Hard Constraints', 'Explicit Exclusions', 'Research Trace'];
581
+ if (names.length > 0
582
+ && names[0] === 'One-line'
583
+ && names.every((name) => oneLineOrder.includes(name))
584
+ && names.every((name, index) => index === 0 || oneLineOrder.indexOf(name) > oneLineOrder.indexOf(names[index - 1]))) {
585
+ return 'legacy';
586
+ }
587
+ const v026Order = [
588
+ 'Project Direction', 'North Star Outcome', 'Hard Constraints',
589
+ 'Explicit Exclusions', 'Research Trace', 'Notes for agents',
590
+ ];
591
+ if (LEGACY_V026_HEADINGS.every((name) => names.includes(name))
592
+ && names.every((name) => v026Order.includes(name))
593
+ && names.every((name, index) => index === 0 || v026Order.indexOf(name) > v026Order.indexOf(names[index - 1]))) {
594
+ return 'legacy';
595
+ }
596
+ return 'unknown';
597
+ }
598
+
599
+ function classifyText(text) {
600
+ return classifyDocument(renderedDocument(text));
601
+ }
602
+
603
+ function validateCausalChain(body, definitions, diagnostics) {
604
+ let validRows = 0;
605
+ const rows = body.split(/\r?\n/u).filter((line) => /^\s*\|.*\|\s*$/u.test(line));
606
+ for (const line of rows) {
607
+ const cells = line.trim().slice(1, -1).split('|').map((cell) => stripMarkup(cell));
608
+ if (cells.length === 5 && cells[0] === 'Chain') continue;
609
+ if (cells.length === 5 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell))) continue;
610
+ if (cells.length !== 5) {
611
+ diagnostics.push(diagnostic('invalid_causal_chain', 'Causal-chain rows require exactly five cells', 'Value Causal Chain'));
612
+ continue;
613
+ }
614
+ const [chainId, fpId, capability, behavior, nsId] = cells;
615
+ const rowValid = /^VC-[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(chainId)
616
+ && definitions.get(fpId) === 'FP'
617
+ && capability.length > 0
618
+ && behavior.length > 0
619
+ && definitions.get(nsId) === 'NS';
620
+ if (!rowValid) {
621
+ diagnostics.push(diagnostic(
622
+ 'invalid_causal_chain',
623
+ 'A causal-chain row requires a VC id, resolving FP and NS references, and nonempty capability and behavior',
624
+ chainId || 'Value Causal Chain',
625
+ ));
626
+ continue;
627
+ }
628
+ validRows += 1;
629
+ }
630
+ if (validRows === 0) {
631
+ diagnostics.push(diagnostic(
632
+ 'missing_causal_chain',
633
+ 'A draft or accepted revision requires at least one valid causal-chain row',
634
+ 'Value Causal Chain',
635
+ ));
636
+ }
637
+ }
638
+
639
+ function validateV2(file, document, bytes, initialDiagnostics = []) {
640
+ const diagnostics = [...initialDiagnostics];
641
+ const sectionList = topLevelSections(document.rendered);
642
+ const names = sectionList.map((section) => section.name);
643
+ const byHeading = firstSections(sectionList);
644
+
645
+ for (const heading of REQUIRED_HEADINGS) {
646
+ const count = names.filter((name) => name === heading).length;
647
+ if (count === 0) diagnostics.push(diagnostic('missing_heading', `Missing heading: ${heading}`, heading));
648
+ if (count > 1) diagnostics.push(diagnostic('duplicate_heading', `Duplicate heading: ${heading}`, heading));
649
+ }
650
+ for (const name of names) {
651
+ if (!REQUIRED_HEADINGS.includes(name)) {
652
+ diagnostics.push(diagnostic('unexpected_heading', `Unexpected v2 heading: ${name}`, name));
653
+ }
654
+ }
655
+ if (names.length !== REQUIRED_HEADINGS.length
656
+ || names.some((name, index) => name !== REQUIRED_HEADINGS[index])) {
657
+ diagnostics.push(diagnostic(
658
+ 'heading_order',
659
+ 'North Star v2 top-level headings must appear exactly once in the required order',
660
+ ));
661
+ }
662
+
663
+ const parsedAcceptance = parsedFields(byHeading.get('Acceptance and Revision') || '');
664
+ const acceptance = parsedAcceptance.values;
665
+ for (const name of parsedAcceptance.duplicates) {
666
+ diagnostics.push(diagnostic(
667
+ 'duplicate_field',
668
+ `Acceptance and Revision repeats ${name}`,
669
+ `Acceptance and Revision.${name}`,
670
+ ));
671
+ }
672
+ for (const name of [
673
+ 'Schema', 'Status', 'Revision', 'Owner acceptance source', 'Acceptance time', 'Supersedes',
674
+ ]) {
675
+ if (!acceptance.get(name)) {
676
+ diagnostics.push(diagnostic('missing_field', `Acceptance and Revision requires ${name}`, name));
677
+ }
678
+ }
679
+ if (acceptance.get('Schema') && acceptance.get('Schema') !== 'north-star-v2') {
680
+ diagnostics.push(diagnostic('invalid_schema', 'Schema must be north-star-v2', 'Schema'));
681
+ }
682
+ const status = acceptance.get('Status') || null;
683
+ if (status && !['unresearched', 'draft', 'accepted'].includes(status)) {
684
+ diagnostics.push(diagnostic('invalid_status', 'Status must be unresearched, draft, or accepted', 'Status'));
685
+ }
686
+
687
+ const definitions = new Map();
688
+ const ids = { FP: [], NS: [], HC: [] };
689
+ for (const section of sectionList) {
690
+ for (const entry of semanticEntries(section.body)) {
691
+ const { id, kind, body } = entry;
692
+ if (definitions.has(id)) {
693
+ diagnostics.push(diagnostic('duplicate_id', `Duplicate semantic ID: ${id}`, id));
694
+ continue;
695
+ }
696
+ definitions.set(id, kind);
697
+ ids[kind].push(id);
698
+ if (section.name !== OWNER_SECTION[kind]) {
699
+ diagnostics.push(diagnostic(
700
+ 'definition_wrong_section',
701
+ `${id} must be defined under ${OWNER_SECTION[kind]}`,
702
+ id,
703
+ ));
704
+ }
705
+ const parsedEntryFields = parsedFields(body);
706
+ const entryFields = parsedEntryFields.values;
707
+ for (const name of parsedEntryFields.duplicates) {
708
+ diagnostics.push(diagnostic('duplicate_field', `${id} repeats ${name}`, `${id}.${name}`));
709
+ }
710
+ for (const name of ENTRY_FIELDS[kind]) {
711
+ if (!entryFields.get(name)) {
712
+ diagnostics.push(diagnostic('missing_field', `${id} requires ${name}`, `${id}.${name}`));
713
+ }
714
+ }
715
+ if (status === 'accepted' && kind === 'FP' && entryFields.get('Status') !== 'accepted') {
716
+ diagnostics.push(diagnostic(
717
+ 'unaccepted_proposition',
718
+ `${id} Status must be accepted in an accepted revision`,
719
+ `${id}.Status`,
720
+ ));
721
+ }
722
+ }
723
+ }
724
+
725
+ if (status === 'unresearched') {
726
+ if (definitions.size > 0) {
727
+ diagnostics.push(diagnostic(
728
+ 'placeholder_has_semantic_ids',
729
+ 'An unresearched placeholder must not define FP, NS, or HC IDs',
730
+ ));
731
+ }
732
+ if (acceptance.get('Revision') && acceptance.get('Revision') !== 'none') {
733
+ diagnostics.push(diagnostic('unresearched_revision', 'An unresearched placeholder must use Revision none', 'Revision'));
734
+ }
735
+ if (acceptance.get('Owner acceptance source') && acceptance.get('Owner acceptance source') !== 'none') {
736
+ diagnostics.push(diagnostic('unresearched_owner_source', 'An unresearched placeholder must not claim owner acceptance', 'Owner acceptance source'));
737
+ }
738
+ const exactAcceptance = acceptance.size === UNRESEARCHED_ACCEPTANCE.size
739
+ && [...UNRESEARCHED_ACCEPTANCE].every(([name, value]) => acceptance.get(name) === value);
740
+ const exactSections = [...UNRESEARCHED_SECTION_BODIES].every(
741
+ ([name, body]) => (byHeading.get(name) || '').trim() === body,
742
+ );
743
+ const exactBytes = crypto.createHash('sha256').update(bytes).digest('hex')
744
+ === UNRESEARCHED_CANONICAL_SHA256;
745
+ if (!exactAcceptance || !exactSections || !exactBytes) {
746
+ diagnostics.push(diagnostic(
747
+ 'invalid_unresearched_placeholder',
748
+ 'An unresearched North Star must preserve the exact packaged placeholder bytes, fields, and sentinels',
749
+ ));
750
+ }
751
+ }
752
+ if (status === 'draft') {
753
+ if (acceptance.get('Owner acceptance source') && acceptance.get('Owner acceptance source') !== 'none') {
754
+ diagnostics.push(diagnostic(
755
+ 'draft_claims_owner_acceptance',
756
+ 'A mutable draft must use Owner acceptance source none',
757
+ 'Owner acceptance source',
758
+ ));
759
+ }
760
+ if (acceptance.get('Acceptance time') && acceptance.get('Acceptance time') !== 'not-recorded') {
761
+ diagnostics.push(diagnostic(
762
+ 'draft_claims_owner_acceptance',
763
+ 'A mutable draft cannot record an acceptance time',
764
+ 'Acceptance time',
765
+ ));
766
+ }
767
+ }
768
+ if (status === 'accepted') {
769
+ if (!acceptance.get('Revision') || acceptance.get('Revision') === 'none') {
770
+ diagnostics.push(diagnostic('accepted_revision_missing', 'An accepted revision must have a non-none Revision', 'Revision'));
771
+ }
772
+ if (!acceptance.get('Owner acceptance source') || acceptance.get('Owner acceptance source') === 'none') {
773
+ diagnostics.push(diagnostic('accepted_owner_source_missing', 'An accepted revision must cite a non-none owner source', 'Owner acceptance source'));
774
+ }
775
+ for (const kind of ['FP', 'NS', 'HC']) {
776
+ if (ids[kind].length === 0) {
777
+ diagnostics.push(diagnostic('missing_definition', `Accepted revision requires at least one ${kind} definition`, kind));
778
+ }
779
+ }
780
+ if (/\[(?:NEEDS CLARIFICATION|NEEDS RESEARCH(?:[^\]]*)?)\]/iu.test(document.rendered)) {
781
+ diagnostics.push(diagnostic('unresolved_placeholder', 'Accepted revision contains an unresolved repository placeholder'));
782
+ }
783
+ }
784
+
785
+ if (status === 'draft' || status === 'accepted') {
786
+ validateCausalChain(byHeading.get('Value Causal Chain') || '', definitions, diagnostics);
787
+ }
788
+
789
+ const referencePattern = /\b(?:FP|NS|HC)-[A-Za-z0-9][A-Za-z0-9._-]*\b/gu;
790
+ for (const id of new Set(document.rendered.match(referencePattern) || [])) {
791
+ if (!definitions.has(id)) {
792
+ diagnostics.push(diagnostic('dangling_reference', `Unresolved semantic reference: ${id}`, id));
793
+ }
794
+ }
795
+
796
+ for (const name of [
797
+ 'Problem Reality', 'Value Causal Chain', 'Explicit Exclusions',
798
+ 'Uncertainties and Revisit Triggers', 'Research Trace',
799
+ ]) {
800
+ if (byHeading.has(name) && !byHeading.get(name).trim()) {
801
+ diagnostics.push(diagnostic('empty_section', `Section must not be empty: ${name}`, name));
802
+ }
803
+ }
804
+
805
+ const sourceObservations = [];
806
+ const acceptanceBinding = status === 'accepted'
807
+ ? validateAcceptanceBinding(
808
+ file,
809
+ bytes,
810
+ acceptance.get('Owner acceptance source'),
811
+ diagnostics,
812
+ sourceObservations,
813
+ )
814
+ : null;
815
+
816
+ return {
817
+ $schema: 'ultra-north-star-validation-v1',
818
+ path: file,
819
+ kind: 'north-star-v2',
820
+ status,
821
+ revision: acceptance.get('Revision') || null,
822
+ classification: classifyDocument(document),
823
+ valid: diagnostics.every((item) => item.severity !== 'error'),
824
+ ids,
825
+ sections: sectionSpans(document.raw, sectionList),
826
+ acceptance_binding: acceptanceBinding,
827
+ source_observations: sourceObservations,
828
+ diagnostics,
829
+ };
830
+ }
831
+
832
+ function legacyReport(file, location, document, sectionList) {
833
+ return {
834
+ $schema: 'ultra-north-star-validation-v1',
835
+ path: file,
836
+ kind: 'legacy',
837
+ status: 'legacy_unadopted',
838
+ revision: null,
839
+ classification: 'legacy',
840
+ valid: true,
841
+ ids: { FP: [], NS: [], HC: [] },
842
+ sections: sectionSpans(document.raw, sectionList),
843
+ source_observations: [],
844
+ diagnostics: [diagnostic(
845
+ 'legacy_north_star',
846
+ 'Legacy authority is preserved; Research must propose an owner-accepted v2 replacement.',
847
+ location,
848
+ 'advisory',
849
+ )],
850
+ };
851
+ }
852
+
853
+ function unknownReport(file, message = 'North Star schema is unknown or malformed') {
854
+ return {
855
+ $schema: 'ultra-north-star-validation-v1',
856
+ path: file,
857
+ kind: 'unknown',
858
+ status: null,
859
+ revision: null,
860
+ classification: 'unknown',
861
+ valid: false,
862
+ ids: { FP: [], NS: [], HC: [] },
863
+ sections: {},
864
+ input: null,
865
+ source_observations: [],
866
+ diagnostics: [diagnostic('unknown_schema', message)],
867
+ };
868
+ }
869
+
870
+ function validateDecodedText(file, text, bytes) {
871
+ const resolved = path.resolve(file);
872
+ const document = renderedDocument(text);
873
+ const sectionList = topLevelSections(document.rendered);
874
+ const names = sectionList.map((section) => section.name);
875
+ const hasV2Marker = /^- Schema:\s*`?north-star-v2`?\s*$/mu.test(document.rendered)
876
+ || names.some((name) => V2_ONLY_HEADINGS.has(name));
877
+ const hasLegacySemanticHeading = names.some((name) => ['One-line', 'Project Direction', 'North Star Outcome'].includes(name));
878
+
879
+ let report;
880
+ if (hasV2Marker) {
881
+ const initial = [];
882
+ if (hasLegacySemanticHeading) {
883
+ initial.push(diagnostic(
884
+ 'mixed_schema',
885
+ 'North Star v2 cannot be combined with legacy One-line, Project Direction, or North Star Outcome headings',
886
+ ));
887
+ }
888
+ report = validateV2(resolved, document, bytes, initial);
889
+ } else if (classifyDocument(document) === 'legacy') {
890
+ report = legacyReport(
891
+ resolved,
892
+ names.includes('One-line') ? 'One-line' : 'Project Direction',
893
+ document,
894
+ sectionList,
895
+ );
896
+ } else if (hasLegacySemanticHeading && hasV2Marker) {
897
+ report = unknownReport(resolved, 'North Star combines v2 and legacy schema markers');
898
+ report.kind = 'mixed';
899
+ report.classification = 'mixed';
900
+ report.diagnostics[0].code = 'mixed_schema';
901
+ } else {
902
+ report = unknownReport(resolved);
903
+ }
904
+ report.input = {
905
+ path: resolved,
906
+ byte_length: bytes.length,
907
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
908
+ };
909
+ return report;
910
+ }
911
+
912
+ function validateText(file, text) {
913
+ return validateDecodedText(file, text, Buffer.from(text, 'utf8'));
914
+ }
915
+
916
+ function validateBytes(file, bytes) {
917
+ const resolved = path.resolve(file);
918
+ const text = decodeUtf8(bytes);
919
+ if (text === null) {
920
+ const report = unknownReport(resolved, 'North Star must be round-trip-safe UTF-8');
921
+ report.diagnostics[0].code = 'invalid_utf8';
922
+ report.input = {
923
+ path: resolved,
924
+ byte_length: bytes.length,
925
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
926
+ };
927
+ return report;
928
+ }
929
+ return validateDecodedText(resolved, text, bytes);
930
+ }
931
+
932
+ function inputFailureReport(file, code, message, byteLength = null) {
933
+ const resolved = path.resolve(file);
934
+ const report = unknownReport(resolved, message);
935
+ report.diagnostics[0].code = code;
936
+ report.input = {
937
+ path: resolved,
938
+ byte_length: byteLength,
939
+ sha256: null,
940
+ };
941
+ return report;
942
+ }
943
+
944
+ function sameFileObservation(left, right) {
945
+ return left.dev === right.dev
946
+ && left.ino === right.ino
947
+ && left.mode === right.mode
948
+ && left.size === right.size
949
+ && left.mtimeNs === right.mtimeNs
950
+ && left.ctimeNs === right.ctimeNs;
951
+ }
952
+
953
+ function readBoundedPath(file) {
954
+ const resolved = path.resolve(file);
955
+ let beforePath;
956
+ try {
957
+ beforePath = fs.lstatSync(resolved, { bigint: true });
958
+ } catch (error) {
959
+ return {
960
+ report: inputFailureReport(
961
+ resolved,
962
+ 'read_error',
963
+ `North Star path could not be inspected: ${error.message}`,
964
+ ),
965
+ };
966
+ }
967
+ if (beforePath.isSymbolicLink()) {
968
+ const report = inputFailureReport(
969
+ resolved,
970
+ 'input_symlink',
971
+ 'North Star path mode requires a regular non-symlink file',
972
+ );
973
+ if (path.basename(resolved) === 'north-star.md'
974
+ && path.basename(path.dirname(resolved)) === '.ultra') {
975
+ report.diagnostics.push(diagnostic(
976
+ 'authority_path_escape',
977
+ 'Canonical .ultra/north-star.md must not be a symlink',
978
+ resolved,
979
+ ));
980
+ }
981
+ return {
982
+ report,
983
+ };
984
+ }
985
+ if (!beforePath.isFile()) {
986
+ return {
987
+ report: inputFailureReport(
988
+ resolved,
989
+ 'input_not_regular',
990
+ 'North Star path mode requires a regular non-symlink file',
991
+ ),
992
+ };
993
+ }
994
+ if (beforePath.size > BigInt(MAX_STDIN_BYTES)) {
995
+ return {
996
+ report: inputFailureReport(
997
+ resolved,
998
+ 'input_too_large',
999
+ `North Star path exceeds the ${MAX_STDIN_BYTES}-byte snapshot limit`,
1000
+ Number(beforePath.size),
1001
+ ),
1002
+ };
1003
+ }
1004
+
1005
+ let descriptor;
1006
+ const chunks = [];
1007
+ const streamedDigest = crypto.createHash('sha256');
1008
+ let observed = 0;
1009
+ try {
1010
+ descriptor = fs.openSync(
1011
+ resolved,
1012
+ fs.constants.O_RDONLY
1013
+ | fs.constants.O_NONBLOCK
1014
+ | (fs.constants.O_NOFOLLOW || 0),
1015
+ );
1016
+ const beforeDescriptor = fs.fstatSync(descriptor, { bigint: true });
1017
+ if (!beforeDescriptor.isFile()
1018
+ || !sameFileObservation(beforePath, beforeDescriptor)) {
1019
+ return {
1020
+ report: inputFailureReport(
1021
+ resolved,
1022
+ 'input_changed',
1023
+ 'North Star changed before its stable path snapshot could be read; retry after workspace writes settle',
1024
+ ),
1025
+ };
1026
+ }
1027
+
1028
+ while (true) {
1029
+ const capacity = Math.min(
1030
+ STDIN_CHUNK_BYTES,
1031
+ MAX_STDIN_BYTES + 1 - observed,
1032
+ );
1033
+ const buffer = Buffer.allocUnsafe(capacity);
1034
+ const count = fs.readSync(descriptor, buffer, 0, capacity, null);
1035
+ if (count === 0) break;
1036
+ observed += count;
1037
+ if (observed > MAX_STDIN_BYTES) {
1038
+ return {
1039
+ report: inputFailureReport(
1040
+ resolved,
1041
+ 'input_too_large',
1042
+ `North Star path exceeds the ${MAX_STDIN_BYTES}-byte snapshot limit`,
1043
+ observed,
1044
+ ),
1045
+ };
1046
+ }
1047
+ const chunk = Buffer.from(buffer.subarray(0, count));
1048
+ chunks.push(chunk);
1049
+ streamedDigest.update(chunk);
1050
+ }
1051
+
1052
+ const afterDescriptor = fs.fstatSync(descriptor, { bigint: true });
1053
+ const afterPath = fs.lstatSync(resolved, { bigint: true });
1054
+ const bytes = Buffer.concat(chunks, observed);
1055
+ const streamedSha256 = streamedDigest.digest('hex');
1056
+ const retainedSha256 = crypto.createHash('sha256').update(bytes).digest('hex');
1057
+ if (afterPath.isSymbolicLink()
1058
+ || !afterPath.isFile()
1059
+ || !sameFileObservation(beforeDescriptor, afterDescriptor)
1060
+ || !sameFileObservation(afterDescriptor, afterPath)
1061
+ || BigInt(observed) !== afterDescriptor.size
1062
+ || streamedSha256 !== retainedSha256) {
1063
+ return {
1064
+ report: inputFailureReport(
1065
+ resolved,
1066
+ 'input_changed',
1067
+ 'North Star changed while its stable path snapshot was read; retry after workspace writes settle',
1068
+ ),
1069
+ };
1070
+ }
1071
+ return { bytes };
1072
+ } catch (error) {
1073
+ return {
1074
+ report: inputFailureReport(
1075
+ resolved,
1076
+ 'read_error',
1077
+ `North Star stable path snapshot could not be read: ${error.message}`,
1078
+ ),
1079
+ };
1080
+ } finally {
1081
+ if (descriptor !== undefined) fs.closeSync(descriptor);
1082
+ }
1083
+ }
1084
+
1085
+ function validate(file) {
1086
+ const resolved = path.resolve(file);
1087
+ const snapshot = readBoundedPath(resolved);
1088
+ return snapshot.report || validateBytes(resolved, snapshot.bytes);
1089
+ }
1090
+
1091
+ function readBoundedStdin() {
1092
+ const chunks = [];
1093
+ const digest = crypto.createHash('sha256');
1094
+ const buffer = Buffer.allocUnsafe(STDIN_CHUNK_BYTES);
1095
+ let byteLength = 0;
1096
+ let oversized = false;
1097
+ while (true) {
1098
+ const count = fs.readSync(0, buffer, 0, buffer.length, null);
1099
+ if (count === 0) break;
1100
+ const chunk = buffer.subarray(0, count);
1101
+ digest.update(chunk);
1102
+ byteLength += count;
1103
+ if (!oversized && byteLength <= MAX_STDIN_BYTES) {
1104
+ chunks.push(Buffer.from(chunk));
1105
+ } else if (!oversized) {
1106
+ oversized = true;
1107
+ chunks.length = 0;
1108
+ }
1109
+ }
1110
+ return {
1111
+ bytes: oversized ? null : Buffer.concat(chunks, byteLength),
1112
+ byteLength,
1113
+ sha256: digest.digest('hex'),
1114
+ oversized,
1115
+ };
1116
+ }
1117
+
1118
+ function writeReport(report) {
1119
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1120
+ if (!report.valid) process.exitCode = 1;
1121
+ }
1122
+
1123
+ function main(argv) {
1124
+ const stdinMode = argv.length === 3
1125
+ && argv[0] === '--stdin'
1126
+ && argv[1] === '--path'
1127
+ && Boolean(argv[2]);
1128
+ const pathMode = argv.length === 1 && Boolean(argv[0]);
1129
+ if (!stdinMode && !pathMode) {
1130
+ process.stderr.write(
1131
+ 'usage: validate_north_star.cjs <north-star-path>\n'
1132
+ + ' or: validate_north_star.cjs --stdin --path <canonical-north-star-path>\n',
1133
+ );
1134
+ process.exitCode = 2;
1135
+ return;
1136
+ }
1137
+ const file = stdinMode ? argv[2] : argv[0];
1138
+ try {
1139
+ if (stdinMode) {
1140
+ const resolved = path.resolve(file);
1141
+ const snapshot = readBoundedStdin();
1142
+ if (snapshot.oversized) {
1143
+ const report = unknownReport(
1144
+ resolved,
1145
+ `North Star stdin exceeds the ${MAX_STDIN_BYTES}-byte snapshot limit`,
1146
+ );
1147
+ report.diagnostics[0].code = 'input_too_large';
1148
+ report.input = {
1149
+ path: resolved,
1150
+ byte_length: snapshot.byteLength,
1151
+ sha256: snapshot.sha256,
1152
+ };
1153
+ writeReport(report);
1154
+ return;
1155
+ }
1156
+ writeReport(validateBytes(resolved, snapshot.bytes));
1157
+ return;
1158
+ }
1159
+ writeReport(validate(file));
1160
+ } catch (error) {
1161
+ const report = unknownReport(path.resolve(file), error.message);
1162
+ report.diagnostics[0].code = 'read_error';
1163
+ writeReport(report);
1164
+ }
1165
+ }
1166
+
1167
+ if (require.main === module) main(process.argv.slice(2));
1168
+
1169
+ module.exports = {
1170
+ classifyText,
1171
+ validate,
1172
+ validateBytes,
1173
+ validateText,
1174
+ };