@dzhechkov/keysarium 1.7.7 → 1.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +21 -9
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/package.json +2 -2
- package/sbom.json +38 -8
- package/templates/.claude/skills/feature-adr/SKILL.md +79 -1
- package/templates/.claude/skills/feature-adr/modules/03.5-ideation-swarm.md +129 -0
- package/templates/.claude/skills/feature-adr/modules/06-implementation-plan.md +10 -0
- package/templates/.claude/skills/feature-adr/modules/07-code.md +29 -0
- package/templates/.claude/skills/feature-adr/modules/08-qe.md +162 -3
- package/templates/.claude/skills/feature-adr/scripts/build-coder-context.mjs +201 -0
- package/templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs +210 -28
- package/templates/.claude/skills/feature-adr/scripts/check-review-convergence.mjs +316 -0
|
@@ -23,6 +23,156 @@ sonnet (analytical evaluation)
|
|
|
23
23
|
|
|
24
24
|
## Protocol
|
|
25
25
|
|
|
26
|
+
## Review convergence gate (Plain and native Workflow)
|
|
27
|
+
|
|
28
|
+
Review follows **initial review → host rework → focused verification → closure**, or explicit owner
|
|
29
|
+
escalation. A grade/GO label does not establish closure. Run the same installed Node gate before
|
|
30
|
+
planning (phase `ideation`) and before Step-8 completion/delivery (phase `qe`), including resume.
|
|
31
|
+
|
|
32
|
+
The host prepares a measured artifact snapshot BEFORE independent review. Resolve the executable
|
|
33
|
+
from this installed skill's `scripts/check-review-convergence.mjs`; do not use a scratch replacement.
|
|
34
|
+
For example, from the execution project, using the actual originating role/family:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
node "$FEATURE_ADR_SKILL/scripts/check-review-convergence.mjs" prepare \
|
|
38
|
+
--repo "$EXECUTION_PROJECT" --feature "features/$FEATURE_SLUG" --phase ideation \
|
|
39
|
+
--reviewers '[{"id":"qcsd","family":"owner-exception"}]'
|
|
40
|
+
node "$FEATURE_ADR_SKILL/scripts/check-review-convergence.mjs" evaluate \
|
|
41
|
+
--repo "$EXECUTION_PROJECT" --feature "features/$FEATURE_SLUG" --phase ideation
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The ideation example above is illustrative; QCSD uses its three actual core role entries. For QE use `--phase qe` and the actual `qe-primary` reviewer, plus `qe-precision` when configured.
|
|
45
|
+
`family` is `codex`, `claude`, or an explicitly authorized `owner-exception`. Existing family/routing
|
|
46
|
+
policy remains binding; this receipt does not authorize an exception. Only the host calls `prepare`.
|
|
47
|
+
It retains originating reviewer roles and prior condition meanings in the existing feature checkpoint
|
|
48
|
+
`.fa-state/review-convergence-<phase>-host.json`; neither the latest report nor an empty request array
|
|
49
|
+
can replace that lineage. Do not edit/delete host lineage to obtain a clean bootstrap. A new nonce is
|
|
50
|
+
issued when measured bytes/path membership change; repeated preparation cannot remove conditions. Pending changed paths accumulate across every prepare, including path additions/deletions, until an established closure; failed/refused evaluations never clear them.
|
|
51
|
+
|
|
52
|
+
The snapshot measures requirements, every direct ADR, architecture, and other available design
|
|
53
|
+
inputs. QE also measures the plan, ideation report, every declared target, and host-discovered Git
|
|
54
|
+
changes/additions/deletions against `.fa-state/base-ref` (HEAD when absent). Receipt/checkpoint files,
|
|
55
|
+
QE report and generated `architecture/map.json` are outputs excluded to avoid circular hashes.
|
|
56
|
+
Explicit planned deletions carry a null digest. Traversal, symlinks and non-files refuse. The host
|
|
57
|
+
must include relevant unchanged dependencies in declared targets; hashing cannot discover semantic
|
|
58
|
+
dependencies. Parallel Workflow design may change after ideation review: that stale review pauses
|
|
59
|
+
before planning and needs focused review of the settled snapshot.
|
|
60
|
+
|
|
61
|
+
Each independent reviewer writes its OWN entry, preserving other reviewer entries and the actual
|
|
62
|
+
author handoff, in `.fa-state/review-convergence-<phase>-review.json`. A genuinely clean initial
|
|
63
|
+
review needs no invented condition or extra review. Its complete receipt shape is:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"schema": "fa-review-convergence-1", "phase": "ideation",
|
|
68
|
+
"nonce": "<copy host snapshot nonce>", "revision": "<copy host snapshot revision>",
|
|
69
|
+
"author": null,
|
|
70
|
+
"reviews": [{
|
|
71
|
+
"reviewer": "qcsd", "family": "owner-exception", "independent": true, "verdict": "clean",
|
|
72
|
+
"conditions": [], "verifications": [],
|
|
73
|
+
"newRisks": {"assessment": "none identified after complete scope review", "evidence": "<actual review evidence>", "conditions": []},
|
|
74
|
+
"authorAssessmentChecked": false, "deltaVerification": null
|
|
75
|
+
}]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`verdict` is `clean`, `conditional`, or `no-go`. Conditions have closed fields
|
|
80
|
+
`{id, owner, severity, classification, requirement, scope, evidence}`: stable unique ID, originating
|
|
81
|
+
role ID, BLOCKER/CRITICAL/HIGH/WARNING/SUGGESTION, structural/wording, exact acceptance meaning,
|
|
82
|
+
nonempty repo-relative scope paths, and original evidence. Carry every prior own condition unchanged
|
|
83
|
+
on every pass. Reviewer verifications have closed fields
|
|
84
|
+
`{id, revision, classification, evidence, implementationVerified}`. Only the originating independent
|
|
85
|
+
role can verify its own condition at the current revision; author/lead/scribe assertions cannot.
|
|
86
|
+
|
|
87
|
+
Rework requires the actual author object
|
|
88
|
+
`{addressed, changedScope, classification, delta, evidence, newRisks}`. Addressed IDs name prior
|
|
89
|
+
conditions; changedScope includes ALL host-measured changes and affected dependencies. Delta/evidence
|
|
90
|
+
explain the actual fix. Both author and reviewer `newRisks` require explicit assessment/evidence and a
|
|
91
|
+
conditions array, including an explicit none-identified assessment when empty. The reviewer must set
|
|
92
|
+
`authorAssessmentChecked:true` and supply independent
|
|
93
|
+
`deltaVerification:{classification,evidence,implementationVerified}`. Structural changes affect
|
|
94
|
+
behavior, contracts, safeguards, architecture or acceptance meaning and require affected implementation
|
|
95
|
+
and test verification. Wording changes affect expression only; the independent reviewer checks that
|
|
96
|
+
classification, so an author's wording label cannot weaken structural verification.
|
|
97
|
+
|
|
98
|
+
Focused scope is outstanding conditions plus actual changes and affected dependencies. New serious
|
|
99
|
+
risks always expand that scope. Retain ALL conditions and serious primary/precision findings until
|
|
100
|
+
own-reviewer verification; a token/round ceiling escalates unresolved risk, never approves it.
|
|
101
|
+
|
|
102
|
+
Gate exits: 0 with `prepared` (prepare only) or `closed` (evaluate only); 1 `unresolved`; 3
|
|
103
|
+
`not-established`. Inspect the structured reasons/unresolved IDs. Missing executable/report, malformed
|
|
104
|
+
or contradictory output, foreign/duplicate/omitted conditions, stale phase/nonce/manifest or unavailable
|
|
105
|
+
reviewer refuses. Native Workflow strictly checks stdout AND exit before planning and before
|
|
106
|
+
completion/delivery/success tags. Supported native/Mode-B/fallback/precision reviewers receive this
|
|
107
|
+
contract; the read-only Codex mode-A route cannot accept it and pauses for a supported independent
|
|
108
|
+
review. A scribe never creates reviewer evidence from grade. Historical resume without this contract
|
|
109
|
+
needs fresh evidence; resume rechecks bytes and every configured route, including unwitnessed precision.
|
|
110
|
+
|
|
111
|
+
Host prepare first reads the existing reviewer receipt, including valid partial own entries written
|
|
112
|
+
before an agent/transport failure. It preserves independently written conditions under their original
|
|
113
|
+
role/family before binding any actual fallback. This imports findings, never closure or author/scribe
|
|
114
|
+
claims. A stale receipt cannot originate new host conditions; known prior meanings remain immutable.
|
|
115
|
+
`pending-reviewer-receipt-needs-origin-review` pauses before native fallback dispatch when pending
|
|
116
|
+
evidence cannot be reconciled. Keep the original receipt and host lineage. Have its originating
|
|
117
|
+
reviewer repair malformed/foreign fields or refresh unknown stale own findings at the unchanged
|
|
118
|
+
host identity/current snapshot. Repeat prepare for the selected actual base-role families, complete
|
|
119
|
+
the returned full reviewer roster with actual own verification, then evaluate and resume the same
|
|
120
|
+
slug. No receipt/lineage deletion or relabeling is a recovery action. The existing QE checkpoint
|
|
121
|
+
validator rejects a paused non-QE result, so same-slug resume re-enters only that reviewer stage;
|
|
122
|
+
unrelated coder checkpoints remain eligible for reuse.
|
|
123
|
+
|
|
124
|
+
Bind the actual fallback family with host prepare BEFORE reviewer dispatch. An unused role may change family; once review evidence originated, its role/family and prior conditions remain required, and the new family receives a distinct `<base-id>:<family>` role. Reviewers read the returned full roster and never relabel old ownership.
|
|
125
|
+
|
|
126
|
+
On refusal, the host settles artifacts, calls prepare with the selected actual base-role families, supplies
|
|
127
|
+
the author handoff, requests focused own-condition verification, then evaluates and resumes the same
|
|
128
|
+
slug. This is host-driven repair, not an automatic repair loop. Existing Confirmation, amendment,
|
|
129
|
+
scope, testing, independent-family and budget gates remain conjunctive. This gate establishes receipt
|
|
130
|
+
consistency and current artifact binding, not semantic truth or authenticated reviewer identity.
|
|
131
|
+
|
|
132
|
+
For a same-slug QE resume blocked by historical serious findings, read-only mode A, or an
|
|
133
|
+
unwitnessed precision append, preserve the complete original checkpoint, findings and grades.
|
|
134
|
+
Prepare returns `checkpointDigest`: SHA-256 of `JSON.stringify` of the latest `qe` result in
|
|
135
|
+
`.fa-state/checkpoints.jsonl`. Each actual originating reviewer supplies optional closed-field
|
|
136
|
+
`checkpointVerification:{checkpointDigest,evidence,supported:true,route,findings,reportDigest}`
|
|
137
|
+
in its own review entry, in addition to the ordinary current-revision independent verification.
|
|
138
|
+
`route` is native/mode-b/fallback and evidence describes the actual supported focused review;
|
|
139
|
+
mode A, author/scribe flags, or the old grade cannot establish this proof. `findings` maps EVERY
|
|
140
|
+
original serious gap with `{digest,conditionId}`, where digest is SHA-256(JSON.stringify(gap))
|
|
141
|
+
and conditionId names that reviewer's preserved serious condition with its current structural
|
|
142
|
+
implementation verification. Both primary and precision origins must verify their own findings.
|
|
143
|
+
Use `reportDigest:null` normally; an unwitnessed precision append requires that originating
|
|
144
|
+
precision reviewer to verify the actual complete `08_qe_report.md` and supply its SHA-256.
|
|
145
|
+
The report must contain Primary QE pass, Precision QE pass, and Combined Step-8 grade sections.
|
|
146
|
+
Then evaluate with the same phase/repo/feature and re-invoke the same slug. Only a closed gate
|
|
147
|
+
with complete checkpoint binding can reconcile historical refusals on an actual resumed QE stage;
|
|
148
|
+
live findings, changed checkpoints/artifacts and a failed Confirmation gate still block delivery.
|
|
149
|
+
This procedure runs no coder or automatic repair loop, and retains the historical result for audit.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
### Bridge identity for plain Step 8
|
|
153
|
+
|
|
154
|
+
When the plain host invokes the existing Claude review bridge, take round number, pipeline run and
|
|
155
|
+
task from the current round receipt/state in the execution project. Pass the fields actually present:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
dz qe-bridge --family claude --slug "$FEATURE_SLUG" --project "$EXECUTION_PROJECT" \
|
|
159
|
+
--round "$CURRENT_ROUND" --round-run "$CURRENT_ROUND_RUN" --task "$CURRENT_TASK" \
|
|
160
|
+
--coder-family codex
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Do not mint missing identifiers or point `--project` at a separate learning brain. Explicit identity
|
|
164
|
+
must agree with one readable open round before any probe or review child; conflicts refuse with exit 2.
|
|
165
|
+
The bridge freezes that snapshot for the signoff. Signoff `roundRun` is the pipeline run; its existing
|
|
166
|
+
`runId` identifies the separate bridge invocation and audit filenames. No-flags standalone reviews
|
|
167
|
+
retain best-effort lookup and visibly unbound provenance when authority is absent or unavailable.
|
|
168
|
+
|
|
169
|
+
Round close validates every present identity field before selection. Full, partial and genuinely
|
|
170
|
+
identity-free window assurance appear as `round-run-task`, `partial-identity` and `legacy-window`.
|
|
171
|
+
Malformed or foreign claims cannot become a manual-close fallback. This contract governs the
|
|
172
|
+
existing plain bridge and control-review caller; native Workflow QE already runs its own review and
|
|
173
|
+
must not invoke a second bridge to manufacture this receipt. Local fake-child tests do not establish
|
|
174
|
+
a live Claude model roundtrip or secure every native Codex QE receipt.
|
|
175
|
+
|
|
26
176
|
### 1. Smoke Tests (All tiers)
|
|
27
177
|
|
|
28
178
|
```
|
|
@@ -32,7 +182,7 @@ sonnet (analytical evaluation)
|
|
|
32
182
|
- [ ] Type checker passes (if configured)
|
|
33
183
|
```
|
|
34
184
|
|
|
35
|
-
For S-tier:
|
|
185
|
+
For S-tier: passing smoke tests still requires the review-convergence contract above before checkpoint/completion; a smoke pass alone is not closure.
|
|
36
186
|
|
|
37
187
|
### 2. Test Generation (M+)
|
|
38
188
|
|
|
@@ -205,7 +355,9 @@ list), grep for unfinished-stub markers: `TODO` / `FIXME` / `HACK` / `XXX` / `PL
|
|
|
205
355
|
file:line, UNLESS the line carries an inline `no-stubs: <reason>` waiver WITH a non-empty reason, or
|
|
206
356
|
`.dz/guard.json` `stubWaivers` lists the path WITH a reason. A REASONLESS waiver is itself a HIGH gap,
|
|
207
357
|
never an exemption. Cross-check mechanically: `dz guard check --op publish --json` runs the same scan
|
|
208
|
-
as the SOFT `no-stubs` rule over the working-tree diff.
|
|
358
|
+
as the SOFT `no-stubs` rule over the working-tree diff.
|
|
359
|
+
After the Step-7 code has landed, run `dz guard check --op code --json` and treat a HARD `block` verdict
|
|
360
|
+
as a HIGH finding naming the drifted file. When you QUOTE a marker in `08_qe_report.md`,
|
|
209
361
|
backtick it so the report itself scans clean (the claim-check forbidden-phrase convention). Record the
|
|
210
362
|
verdict in the ADR Fitness section.
|
|
211
363
|
|
|
@@ -311,6 +463,9 @@ Compile all findings into a structured report:
|
|
|
311
463
|
✅ READY FOR MERGE | ❌ NEEDS FIXES | ⚠️ CONDITIONAL APPROVAL
|
|
312
464
|
```
|
|
313
465
|
|
|
466
|
+
Settle a cross-family re-QE debt with `dz reqe --slug <s> --done --report <08b>`.
|
|
467
|
+
For `dz reqe --done`, exit 3 means the review settled but named BLOCKER/HIGH findings — stop and surface them to the owner.
|
|
468
|
+
|
|
314
469
|
### 7.1 Findings ledger (machine-readable)
|
|
315
470
|
|
|
316
471
|
Prose is for people; `dz score`/`dz recap` need a machine-readable surface too (qe-findings-record,
|
|
@@ -373,9 +528,13 @@ resume guard at all before this).
|
|
|
373
528
|
Otherwise — QE ran fresh in this pass — after `08_qe_report.md` is written and the grade is final, run:
|
|
374
529
|
|
|
375
530
|
```bash
|
|
376
|
-
dz feature-adr-record --kind ledger --stage qe --slug <slug> --row '<json>' --
|
|
531
|
+
dz feature-adr-record --kind ledger --stage qe --slug <slug> --row '<json>' --json
|
|
377
532
|
```
|
|
378
533
|
|
|
534
|
+
fix-round-1 (Codex r1 HIGH finding 4, ADR-001 D5): no `--auto` here. `--auto` is the trusted marker
|
|
535
|
+
of an AUTOMATED pipeline run and requires an experiment envelope that the manual path does not have;
|
|
536
|
+
this plain-mode row is written without it.
|
|
537
|
+
|
|
379
538
|
`<json>` carries the same fields the ultracode pipeline writes for this row: `reviewer` (the model
|
|
380
539
|
that reviewed, or `null` if unknown — never guessed), `reviewerFamily` (`claude`|`codex`|`null` when
|
|
381
540
|
the reviewer identity is not one of the two known families — never guessed as `claude`), `qeRole`
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One parser for workflow and plain Step 7. Importing this module performs no IO.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const SCHEMA = 'fa-coder-context-1';
|
|
9
|
+
const MAX_DOCUMENTS = 64;
|
|
10
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_TOTAL_BYTES = 1024 * 1024;
|
|
12
|
+
const MAX_PROMPT_BYTES = 96 * 1024;
|
|
13
|
+
const sha = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
14
|
+
const kindOf = (path) => path === '01_requirements.md' ? 'requirements'
|
|
15
|
+
: path === '06_implementation_plan.md' ? 'plan'
|
|
16
|
+
: /^03_adr\/[0-9]{3}-[^/\\\x00-\x1f]+\.md$/.test(path) ? 'adr' : null;
|
|
17
|
+
const diagnostic = (source, reason, section) => ({ source, reason, severity: 'error', ...(section ? { section } : {}) });
|
|
18
|
+
|
|
19
|
+
// One lexical pass recognizes comments only outside Markdown code/escapes, retaining offsets.
|
|
20
|
+
function markdown(text) {
|
|
21
|
+
const headings = [];
|
|
22
|
+
const visible = [];
|
|
23
|
+
let fence = null;
|
|
24
|
+
let comment = false;
|
|
25
|
+
let inlineEnd = -1;
|
|
26
|
+
let offset = 0;
|
|
27
|
+
for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
28
|
+
const clean = line.replace(/\r?\n$/, '').replace(/^\uFEFF/, '');
|
|
29
|
+
const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(clean);
|
|
30
|
+
if (fence) {
|
|
31
|
+
if (delimiter && delimiter[1][0] === fence.char && delimiter[1].length >= fence.length && delimiter[2].trim() === '') fence = null;
|
|
32
|
+
else visible.push(line);
|
|
33
|
+
} else if (!comment && inlineEnd <= offset && delimiter && !(delimiter[1][0] === '`' && delimiter[2].includes('`'))) {
|
|
34
|
+
fence = { char: delimiter[1][0], length: delimiter[1].length };
|
|
35
|
+
} else {
|
|
36
|
+
const wasComment = comment;
|
|
37
|
+
let masked = '';
|
|
38
|
+
for (let i = 0; i < line.length;) {
|
|
39
|
+
if (comment) {
|
|
40
|
+
if (line.startsWith('-->', i)) { masked += ' '; i += 3; comment = false; }
|
|
41
|
+
else { masked += /[\r\n]/.test(line[i]) ? line[i] : ' '; i++; }
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (offset + i < inlineEnd) { masked += line[i++]; continue; }
|
|
45
|
+
// An escaped '<' cannot start a comment; paired backslashes still allow the next opener.
|
|
46
|
+
if (line[i] === '\\' && i + 1 < line.length && /[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]/.test(line[i + 1])) {
|
|
47
|
+
masked += line.slice(i, i + 2); i += 2; continue;
|
|
48
|
+
}
|
|
49
|
+
if (line[i] === '`') {
|
|
50
|
+
const run = /^`+/.exec(line.slice(i))[0];
|
|
51
|
+
const rest = text.slice(offset + i + run.length);
|
|
52
|
+
// Code spans can wrap within a paragraph, but cannot hide a subsequent block heading/fence.
|
|
53
|
+
const boundary = /\n[ \t]*(?:\r?\n|#{1,6}[ \t]|`{3,}|~{3,})/.exec(rest);
|
|
54
|
+
const paragraph = boundary ? rest.slice(0, boundary.index) : rest;
|
|
55
|
+
const close = [...paragraph.matchAll(/`+/g)].find((match) => match[0].length === run.length);
|
|
56
|
+
if (close) inlineEnd = offset + i + run.length + close.index + run.length;
|
|
57
|
+
masked += run; i += run.length; continue;
|
|
58
|
+
}
|
|
59
|
+
if (line.startsWith('<!--', i)) { comment = true; masked += ' '; i += 4; continue; }
|
|
60
|
+
masked += line[i++];
|
|
61
|
+
}
|
|
62
|
+
const heading = !wasComment && /^ {0,3}(#{1,6})[ \t]+(.+?)\s*$/.exec(masked.replace(/\r?\n$/, '').replace(/^\uFEFF/, ''));
|
|
63
|
+
if (heading) headings.push({ depth: heading[1].length, title: heading[2].replace(/[ \t]+#+[ \t]*$/, '').trim(), start: offset, body: offset + line.length });
|
|
64
|
+
else visible.push(masked);
|
|
65
|
+
}
|
|
66
|
+
offset += line.length;
|
|
67
|
+
}
|
|
68
|
+
return { headings, unclosed: fence !== null, unclosedComment: comment, substantive: visible.join('').trim() !== '' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pure: consumes already-read documents only, and hashes the same snapshot used for the block. */
|
|
72
|
+
export function buildCoderContext({ tier, documents }) {
|
|
73
|
+
const diagnostics = [];
|
|
74
|
+
const sources = [];
|
|
75
|
+
const blocks = [];
|
|
76
|
+
let bounded = true;
|
|
77
|
+
if (!['S', 'M', 'L', 'XL'].includes(tier)) diagnostics.push(diagnostic('tier', 'invalid-tier'));
|
|
78
|
+
if (!Array.isArray(documents)) documents = [];
|
|
79
|
+
if (documents.length > MAX_DOCUMENTS) { diagnostics.push(diagnostic('documents', 'document-limit')); bounded = false; }
|
|
80
|
+
const sorted = [...documents].sort((a, b) => String(a?.path).localeCompare(String(b?.path), 'en'));
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
let total = 0;
|
|
83
|
+
for (const doc of sorted.slice(0, MAX_DOCUMENTS)) {
|
|
84
|
+
const path = doc?.path;
|
|
85
|
+
const kind = typeof path === 'string' && path.length <= 512 ? kindOf(path) : null;
|
|
86
|
+
if (!kind || typeof doc.text !== 'string') { diagnostics.push(diagnostic(String(path ?? 'documents'), 'invalid-source')); bounded = false; continue; }
|
|
87
|
+
if (seen.has(path)) { diagnostics.push(diagnostic(path, 'duplicate-source')); bounded = false; continue; }
|
|
88
|
+
seen.add(path);
|
|
89
|
+
const bytes = Buffer.byteLength(doc.text, 'utf8'); total += bytes;
|
|
90
|
+
const status = bytes > MAX_FILE_BYTES ? 'oversized' : 'read';
|
|
91
|
+
sources.push({ path, kind, bytes, digest: status === 'read' ? sha(doc.text) : null, status });
|
|
92
|
+
if (status !== 'read') { diagnostics.push(diagnostic(path, 'file-limit')); bounded = false; continue; }
|
|
93
|
+
const parsed = markdown(doc.text);
|
|
94
|
+
if (parsed.unclosed) diagnostics.push(diagnostic(path, 'unclosed-fence'));
|
|
95
|
+
if (parsed.unclosedComment) diagnostics.push(diagnostic(path, 'unclosed-comment'));
|
|
96
|
+
if (kind !== 'adr') {
|
|
97
|
+
if (!parsed.substantive) diagnostics.push(diagnostic(path, 'empty-section', 'body'));
|
|
98
|
+
blocks.push('\n\n### Source: ' + path + ' — body\n' + doc.text);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const section of ['Decision', 'Confirmation']) {
|
|
102
|
+
const matches = parsed.headings.filter((h) => h.depth >= 2 && h.title.toLowerCase() === section.toLowerCase());
|
|
103
|
+
if (matches.length !== 1) { diagnostics.push(diagnostic(path, matches.length ? 'duplicate-section' : 'missing-section', section)); continue; }
|
|
104
|
+
const heading = matches[0];
|
|
105
|
+
const next = parsed.headings.find((h) => h.start > heading.start && h.depth <= heading.depth);
|
|
106
|
+
const body = doc.text.slice(heading.body, next ? next.start : doc.text.length);
|
|
107
|
+
if (!markdown(body).substantive) diagnostics.push(diagnostic(path, 'empty-section', section));
|
|
108
|
+
blocks.push('\n\n### Source: ' + path + ' — ' + section + '\n' + body);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (total > MAX_TOTAL_BYTES) { diagnostics.push(diagnostic('documents', 'aggregate-limit')); bounded = false; }
|
|
112
|
+
for (const path of ['01_requirements.md', '06_implementation_plan.md']) {
|
|
113
|
+
if (!seen.has(path)) {
|
|
114
|
+
sources.push({ path, kind: kindOf(path), bytes: 0, digest: null, status: 'missing' });
|
|
115
|
+
diagnostics.push(diagnostic(path, 'missing-source'));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (tier !== 'S' && !sources.some((s) => s.kind === 'adr')) diagnostics.push(diagnostic('03_adr', 'missing-source'));
|
|
119
|
+
let promptBlock = '\n\n## Current coder context (literal source snapshot)' + blocks.join('');
|
|
120
|
+
if (tier === 'S' && !sources.some((s) => s.kind === 'adr')) promptBlock += '\n\nADR sources: none selected (tier S).';
|
|
121
|
+
if (Buffer.byteLength(promptBlock, 'utf8') > MAX_PROMPT_BYTES) {
|
|
122
|
+
diagnostics.push(diagnostic('promptBlock', 'prompt-limit')); promptBlock = '';
|
|
123
|
+
}
|
|
124
|
+
return { schema: SCHEMA, status: diagnostics.length ? 'incomplete' : 'complete', promptBlock,
|
|
125
|
+
digest: bounded ? sha(JSON.stringify([SCHEMA, tier, sorted.map((d) => [d.path, d.text])])) : null,
|
|
126
|
+
sources: sources.sort((a, b) => a.path.localeCompare(b.path, 'en')), diagnostics };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hostContext(feature, tier) {
|
|
130
|
+
const failures = [];
|
|
131
|
+
const documents = [];
|
|
132
|
+
const failedSources = [];
|
|
133
|
+
let root;
|
|
134
|
+
const fail = (path, reason, status = 'unreadable', bytes = 0) => {
|
|
135
|
+
failures.push(diagnostic(path, reason));
|
|
136
|
+
failedSources.push({ path, kind: kindOf(path) ?? 'adr', bytes, digest: null, status });
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
if (lstatSync(feature).isSymbolicLink() || !lstatSync(feature).isDirectory()) throw new Error('invalid-root');
|
|
140
|
+
root = realpathSync(feature);
|
|
141
|
+
} catch { return { schema: SCHEMA, status: 'unavailable', promptBlock: '', digest: null, sources: [], diagnostics: [diagnostic(feature, 'invalid-root')] }; }
|
|
142
|
+
let paths = ['01_requirements.md', '06_implementation_plan.md'];
|
|
143
|
+
const adrDir = join(root, '03_adr');
|
|
144
|
+
try {
|
|
145
|
+
const stat = lstatSync(adrDir);
|
|
146
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fail('03_adr', 'invalid-file', 'invalid');
|
|
147
|
+
else paths.push(...readdirSync(adrDir).filter((name) => /^[0-9]{3}-.+\.md$/.test(name)).sort().map((name) => '03_adr/' + name));
|
|
148
|
+
} catch (error) { if (error.code !== 'ENOENT') fail('03_adr', 'read-failure'); }
|
|
149
|
+
if (paths.length > MAX_DOCUMENTS) return { schema: SCHEMA, status: 'incomplete', promptBlock: '', digest: null, sources: [], diagnostics: [diagnostic('documents', 'document-limit')] };
|
|
150
|
+
let total = 0;
|
|
151
|
+
for (const path of paths) {
|
|
152
|
+
let fd;
|
|
153
|
+
try {
|
|
154
|
+
const file = join(root, path);
|
|
155
|
+
const stat = lstatSync(file);
|
|
156
|
+
if (stat.isSymbolicLink() || !stat.isFile()) { fail(path, 'invalid-file', 'invalid'); continue; }
|
|
157
|
+
const actual = realpathSync(file);
|
|
158
|
+
const rel = relative(root, actual);
|
|
159
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) { fail(path, 'outside-root', 'invalid'); continue; }
|
|
160
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
161
|
+
const opened = fstatSync(fd);
|
|
162
|
+
// Compare the opened descriptor with the contained file checked before open.
|
|
163
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev || realpathSync(file) !== actual) { fail(path, 'invalid-file', 'invalid'); continue; }
|
|
164
|
+
const chunks = []; let bytes = 0;
|
|
165
|
+
while (true) {
|
|
166
|
+
const chunk = Buffer.alloc(Math.min(16384, MAX_FILE_BYTES + 1 - bytes));
|
|
167
|
+
const count = readSync(fd, chunk, 0, chunk.length, null);
|
|
168
|
+
if (count === 0) break;
|
|
169
|
+
bytes += count; total += count; chunks.push(chunk.subarray(0, count));
|
|
170
|
+
if (bytes > MAX_FILE_BYTES || total > MAX_TOTAL_BYTES) break;
|
|
171
|
+
}
|
|
172
|
+
if (bytes > MAX_FILE_BYTES) { fail(path, 'file-limit', 'oversized', bytes); continue; }
|
|
173
|
+
if (total > MAX_TOTAL_BYTES) { fail(path, 'aggregate-limit', 'oversized', bytes); break; }
|
|
174
|
+
try { documents.push({ path, text: new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)) }); }
|
|
175
|
+
catch { fail(path, 'invalid-utf8', 'invalid', bytes); }
|
|
176
|
+
} catch (error) { fail(path, error.code === 'ENOENT' ? 'missing-source' : 'read-failure', error.code === 'ENOENT' ? 'missing' : 'unreadable'); }
|
|
177
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
178
|
+
}
|
|
179
|
+
const result = buildCoderContext({ tier, documents });
|
|
180
|
+
if (failures.length) {
|
|
181
|
+
const failed = new Set(failedSources.map((s) => s.path));
|
|
182
|
+
result.sources = result.sources.filter((s) => !failed.has(s.path)).concat(failedSources).sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
183
|
+
result.diagnostics = result.diagnostics.filter((d) => !(failed.has(d.source) && d.reason === 'missing-source')).concat(failures);
|
|
184
|
+
result.status = failures.every((d) => ['file-limit', 'aggregate-limit'].includes(d.reason)) ? 'incomplete' : 'unavailable';
|
|
185
|
+
result.digest = null;
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function main(argv) {
|
|
191
|
+
const feature = argv[0];
|
|
192
|
+
const tier = argv.find((arg) => arg.startsWith('--tier='))?.slice(7);
|
|
193
|
+
let result;
|
|
194
|
+
if (!feature || !['S', 'M', 'L', 'XL'].includes(tier) || argv.some((arg, i) => i > 0 && arg !== '--tier=' + tier)) {
|
|
195
|
+
result = { schema: SCHEMA, status: 'unavailable', promptBlock: '', digest: null, sources: [], diagnostics: [diagnostic('arguments', 'invalid-arguments')] };
|
|
196
|
+
} else result = hostContext(resolve(feature), tier);
|
|
197
|
+
console.log(JSON.stringify(result));
|
|
198
|
+
return result.status === 'complete' ? 0 : 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exitCode = main(process.argv.slice(2));
|