@dzhechkov/p-replicator 1.10.4 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +312 -76
- package/CHANGELOG.md +232 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +434 -24
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +123 -52
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +43 -6
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +574 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +40 -2
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-source-version.cjs — правка и вывод объявляют ВЕРСИЮ источника, на которой построены?
|
|
6
|
+
*
|
|
7
|
+
* NOT an event hook. Like its ten siblings in this directory it is a plain Node utility; nothing
|
|
8
|
+
* registers it in settings.json, because this package's hooks are NON-BLOCKING by contract (pinned
|
|
9
|
+
* by tests/unit/hooks-project-anchored.test.js, which requires exit 0) — a hook could only print, it
|
|
10
|
+
* could never refuse. Invoke it:
|
|
11
|
+
*
|
|
12
|
+
* node .claude/hooks/check-source-version.cjs [path-to-project]
|
|
13
|
+
*
|
|
14
|
+
* WHAT IS ALREADY PRESCRIBED, AND IS NOT RE-OPENED HERE. The half «take the status from the FILE,
|
|
15
|
+
* not from the worker's narrative» is already required: the coordinator checks each receipt for
|
|
16
|
+
* existence, non-emptiness and mtime freshness before merging, and the decisive field diagnosis
|
|
17
|
+
* there was an `ls -la` comparing mtimes taken BEFORE the run. That half stands. This file adds the
|
|
18
|
+
* one it does not reach.
|
|
19
|
+
*
|
|
20
|
+
* THE UNCOVERED HALF: FRESHNESS IS CHECKED ON THE RECEIPT, NEVER ON THE SOURCE THE RECEIPT WAS
|
|
21
|
+
* DERIVED FROM.
|
|
22
|
+
*
|
|
23
|
+
* A read copy is a snapshot of the MOMENT OF READING, not of the file. So an edit addressed by a
|
|
24
|
+
* literal string is a race BY CONSTRUCTION: read at T1, the file changes at T2, write at T3 against
|
|
25
|
+
* a string that no longer exists — or, worse, that now means something else. The field case is
|
|
26
|
+
* exact: an edit to `Refinement.md` died on an assertion because a worker had renumbered the guards
|
|
27
|
+
* between the read and the write. And the symmetric case is quieter and worse: three reports saying
|
|
28
|
+
* «the defect is still there» were written from stale copies, and each report was itself perfectly
|
|
29
|
+
* FRESH. A freshness check on the receipt cannot see that — it answers a neighbouring question and
|
|
30
|
+
* hands the answer over as if it were the asked one.
|
|
31
|
+
*
|
|
32
|
+
* WHY A HASH AND NOT AN INSTRUCTION. The obvious cure — a rule saying «re-read immediately before
|
|
33
|
+
* editing» — is UNVERIFIABLE, and that is not a quibble: inspecting the finished file cannot show
|
|
34
|
+
* whether a re-read happened, so the rule's failure is silent, which puts it on the weakest layer of
|
|
35
|
+
* this package's own ladder. A DECLARED VERSION is checkable by anyone at any later time. The
|
|
36
|
+
* promise is not; that is the whole argument for this shape.
|
|
37
|
+
*
|
|
38
|
+
* WHY A MISMATCH IS A REFUSAL WITHOUT MUTATION. When the declared version and the live file
|
|
39
|
+
* disagree, the correct action is to STOP — not to apply the edit against the newer text and hope.
|
|
40
|
+
* This checker embodies that literally: it never writes anything, anywhere. It reads, it decides,
|
|
41
|
+
* it exits. «Отказ без мутации» is a property of the tool, not an instruction to its user.
|
|
42
|
+
*
|
|
43
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
44
|
+
*
|
|
45
|
+
* It settles that every declared edit and every declared verdict names a source that EXISTS and
|
|
46
|
+
* whose live sha256 EQUALS the recorded one — that is, that nothing the run's conclusions rest on
|
|
47
|
+
* has moved since those conclusions were drawn. It does NOT decide whether the conclusion follows
|
|
48
|
+
* from the source, nor whether the worker really re-read anything: a matching hash proves the ground
|
|
49
|
+
* did not move, never that the reasoning standing on it is right. That stays layer 3.
|
|
50
|
+
*
|
|
51
|
+
* THE EXACT FORM OF `docs/source-versions.md` — kept here on purpose: this file is not part of the
|
|
52
|
+
* always-loaded corpus, so the long form costs nothing per run.
|
|
53
|
+
*
|
|
54
|
+
* **Правки и выводы:** да (да | нет — `нет` is a legitimate answer)
|
|
55
|
+
* **Проверка версий:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
56
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS)
|
|
57
|
+
*
|
|
58
|
+
* ## Выводы и правки
|
|
59
|
+
*
|
|
60
|
+
* | Что | Вид | Источник | Хеш источника |
|
|
61
|
+
* |---|---|---|---|
|
|
62
|
+
* | перенумеровать стражей | правка | docs/Refinement.md | <64 hex> |
|
|
63
|
+
* | дефект F1 всё ещё жив | вывод | docs/Refinement.md | <64 hex> |
|
|
64
|
+
*
|
|
65
|
+
* Exit codes — three, and the third is the point:
|
|
66
|
+
* 0 every edit and every verdict names a source that exists and still hashes to the declared
|
|
67
|
+
* value
|
|
68
|
+
* 1 a defect is PROVEN and named: a declared version that no longer matches the live file (the
|
|
69
|
+
* race, caught before the write), a source that does not exist, a row with no source or no
|
|
70
|
+
* hash, or an empty table under «Правки и выводы: да»
|
|
71
|
+
* 2 THE CHECK DID NOT RUN — no declaration file, an unrecognised value, a malformed hash,
|
|
72
|
+
* duplicate rows, or the legitimate answers «правок и выводов нет» and «проверка НЕ
|
|
73
|
+
* ВЫПОЛНЕНА, причина такая-то»
|
|
74
|
+
*
|
|
75
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
const fs = require('node:fs');
|
|
79
|
+
const path = require('node:path');
|
|
80
|
+
const crypto = require('node:crypto');
|
|
81
|
+
|
|
82
|
+
const DECL = path.join('docs', 'source-versions.md');
|
|
83
|
+
|
|
84
|
+
/** Are there edits or verdicts to answer for? A CLOSED set — `нет` is legitimate and exits 2. */
|
|
85
|
+
const HAS_WORK = { 'ДА': true, 'НЕТ': false };
|
|
86
|
+
|
|
87
|
+
/** Was the version review performed? CLOSED, and the negative answer is honest, not a failure —
|
|
88
|
+
* `honest-configuration` CFG-I4: an unreachable truth yields UNKNOWN, never a plausible value. */
|
|
89
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Why versions were not settled. CLOSED list — each entry names a DIFFERENT repair:
|
|
93
|
+
* источник-не-назван — the edits exist but nobody recorded what they were built on
|
|
94
|
+
* источник-недоступен — the source is outside this tree; get it, then pin it
|
|
95
|
+
* решение-отложено — take the decision (this is the one that becomes a lost update)
|
|
96
|
+
* вне-объёма — decide it is out of scope and record the decision
|
|
97
|
+
*/
|
|
98
|
+
const REASONS = ['источник-не-назван', 'источник-недоступен', 'решение-отложено', 'вне-объёма'];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What a row is. CLOSED, and the two halves are kept apart on purpose: an EDIT mutates a file and a
|
|
102
|
+
* VERDICT only asserts, but both are built on a snapshot and both are wrong in the same way when
|
|
103
|
+
* the snapshot has aged. The field case produced one of each — a failed edit, and three reports
|
|
104
|
+
* that were themselves perfectly fresh while their source was not.
|
|
105
|
+
*/
|
|
106
|
+
const KIND = { 'правка': 'edit', 'вывод': 'verdict' };
|
|
107
|
+
|
|
108
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
109
|
+
|
|
110
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
111
|
+
|
|
112
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
113
|
+
function cannotCheck(reason, hint) {
|
|
114
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
115
|
+
if (hint) say(' ' + hint);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
120
|
+
function proven(title, lines, tail) {
|
|
121
|
+
say('❌ ' + title);
|
|
122
|
+
for (const line of lines) say(' • ' + line);
|
|
123
|
+
if (tail) say(' ' + tail);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The value of a `**Label:** value` header line, or null when the label is absent entirely. */
|
|
128
|
+
function header(text, label) {
|
|
129
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
130
|
+
const m = re.exec(text);
|
|
131
|
+
if (!m) return null;
|
|
132
|
+
return m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
136
|
+
function closedHeader(text, label, map, what) {
|
|
137
|
+
const raw = header(text, label);
|
|
138
|
+
if (raw === null) {
|
|
139
|
+
cannotCheck('в объявлении нет строки `**' + label + ':**`',
|
|
140
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
141
|
+
}
|
|
142
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
144
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
145
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
146
|
+
}
|
|
147
|
+
return map[key];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The declaration table.
|
|
152
|
+
*
|
|
153
|
+
* A row is a markdown table row with at least four cells whose SECOND cell is one of the two kinds —
|
|
154
|
+
* so the header row and the `|---|` separator cannot be mistaken for data, and a row whose kind is
|
|
155
|
+
* misspelled is REPORTED rather than silently dropped. Dropping it silently would be the same class
|
|
156
|
+
* of defect the whole file is about: an unread declaration is indistinguishable from an absent one.
|
|
157
|
+
*/
|
|
158
|
+
function declRows(text) {
|
|
159
|
+
const rows = [];
|
|
160
|
+
for (const raw of text.split('\n')) {
|
|
161
|
+
const line = raw.trim();
|
|
162
|
+
if (!line.startsWith('|')) continue;
|
|
163
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
164
|
+
if (cells.length < 6) continue; // '' + 4 columns + ''
|
|
165
|
+
const what = cells[1];
|
|
166
|
+
if (!what || /^:?-+:?$/.test(what) || what.toLowerCase() === 'что') continue;
|
|
167
|
+
rows.push({
|
|
168
|
+
what,
|
|
169
|
+
kind: cells[2].toLowerCase(),
|
|
170
|
+
source: cells[3].replace(/^[`«"]|[`»"]$/g, '').trim(),
|
|
171
|
+
hash: cells[4].replace(/^[`«"]|[`»"]$/g, '').trim(),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function main() {
|
|
178
|
+
const root = process.argv[2] || '.';
|
|
179
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
180
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
181
|
+
|
|
182
|
+
const abs = path.join(root, DECL);
|
|
183
|
+
let text;
|
|
184
|
+
try {
|
|
185
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(DECL + ' существует, но это не файл');
|
|
186
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e && e.code === 'ENOENT') {
|
|
189
|
+
cannotCheck('нет файла ' + DECL,
|
|
190
|
+
'это значит, что вопрос о версии источника НЕ ЗАДАВАЛСЯ — а НЕ что правки построены на '
|
|
191
|
+
+ 'свежем чтении; прогон без правок и выводов отвечает `**Правки и выводы:** нет`, и это '
|
|
192
|
+
+ 'законный ответ');
|
|
193
|
+
}
|
|
194
|
+
cannotCheck('не читается ' + DECL + ': ' + ((e && e.message) || e));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 1. Is there anything built on a source at all? Nothing to pin → 2, never 0.
|
|
198
|
+
const hasWork = closedHeader(text, 'Правки и выводы', HAS_WORK,
|
|
199
|
+
'без этой строки «правок не было» неотличимо от «про источники не подумали»');
|
|
200
|
+
if (!hasWork) {
|
|
201
|
+
cannotCheck('объявление говорит «Правки и выводы: нет» — ни одна правка и ни один вывод не '
|
|
202
|
+
+ 'построены на прочитанном файле',
|
|
203
|
+
'это законный ответ, а не нарушение: закреплять версию не на чем');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. A named refusal is honest and exits 2.
|
|
207
|
+
const run = closedHeader(text, 'Проверка версий', RUN_STATUS,
|
|
208
|
+
'без этой строки «версии не сверяли» неотличимо от «сверили»');
|
|
209
|
+
if (run === 'not-done') {
|
|
210
|
+
const raw = header(text, 'Причина');
|
|
211
|
+
if (raw === null || raw === '') {
|
|
212
|
+
cannotCheck('проверка версий НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
213
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
214
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
215
|
+
}
|
|
216
|
+
const picked = REASONS.filter((r) => raw.includes(r));
|
|
217
|
+
if (picked.length !== 1) {
|
|
218
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
219
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
220
|
+
}
|
|
221
|
+
cannotCheck('проверка версий НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
|
|
222
|
+
'честное «неизвестно», а не «правки построены на актуальном чтении»; пока причина не '
|
|
223
|
+
+ 'закрыта, свежая квитанция может добросовестно излагать выводы из устаревшего источника');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. The rows.
|
|
227
|
+
const rows = declRows(text);
|
|
228
|
+
if (rows.length === 0) {
|
|
229
|
+
proven('объявлены правки и выводы, но ни один не назван',
|
|
230
|
+
['таблица `## Выводы и правки` пуста'],
|
|
231
|
+
'нельзя сверить версию источника у правки, которой нет в списке. Пустая таблица под «да» — '
|
|
232
|
+
+ 'доказанный пропуск, а не неизвестность.');
|
|
233
|
+
}
|
|
234
|
+
const keys = rows.map((r) => (r.what + '|' + r.source).toLowerCase());
|
|
235
|
+
const dupes = [...new Set(keys.filter((k, i) => keys.indexOf(k) !== i))];
|
|
236
|
+
if (dupes.length) {
|
|
237
|
+
cannotCheck('в таблице повторяются строки: ' + dupes.join(', '),
|
|
238
|
+
'одна правка над одним источником — одна строка; иначе одна сверка закрывает две разные');
|
|
239
|
+
}
|
|
240
|
+
const badKind = rows.filter((r) => !Object.prototype.hasOwnProperty.call(KIND, r.kind));
|
|
241
|
+
if (badKind.length) {
|
|
242
|
+
cannotCheck('нераспознанный `Вид`: '
|
|
243
|
+
+ badKind.map((r) => r.what + ' → ' + (r.kind || '(пусто)')).join(', '),
|
|
244
|
+
'допустимы ровно: ' + Object.keys(KIND).join(' | ')
|
|
245
|
+
+ ' — правка меняет файл, вывод утверждает о нём, и устаревают они одинаково');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const noSource = rows.filter((r) => !r.source || r.source === '—');
|
|
249
|
+
if (noSource.length) {
|
|
250
|
+
proven('источник не назван', noSource.map((r) => r.what),
|
|
251
|
+
'правка или вывод, не назвавшие файл, на котором построены, не могут быть сверены НИКОГДА: '
|
|
252
|
+
+ 'проверять нечего, а выглядит это как проверенное.');
|
|
253
|
+
}
|
|
254
|
+
const noHash = rows.filter((r) => !r.hash || r.hash === '—');
|
|
255
|
+
if (noHash.length) {
|
|
256
|
+
proven('версия источника не записана', noHash.map((r) => r.what + ' ← ' + r.source),
|
|
257
|
+
'без записанной версии «источник не менялся» и «источник переписали после чтения» '
|
|
258
|
+
+ 'выглядят одинаково: путь остаётся тем же в обоих случаях. Обещание перечитать проверить '
|
|
259
|
+
+ 'нельзя, записанный хеш — можно.');
|
|
260
|
+
}
|
|
261
|
+
const malformed = rows.filter((r) => !HEX64.test(r.hash));
|
|
262
|
+
if (malformed.length) {
|
|
263
|
+
cannotCheck('`Хеш источника` не похож на sha256: '
|
|
264
|
+
+ malformed.map((r) => r.what + ' → ' + r.hash).join(', '),
|
|
265
|
+
'нужны ровно 64 шестнадцатеричных знака — `sha256sum <источник>`');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 4. THE LOAD-BEARING CHECK. Compare each declared version against the live file. Nothing is
|
|
269
|
+
// written here, by construction: a mismatch is a refusal, and a refusal does not mutate.
|
|
270
|
+
const missing = [];
|
|
271
|
+
const moved = [];
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
const src = path.join(root, row.source);
|
|
274
|
+
let bytes;
|
|
275
|
+
try {
|
|
276
|
+
if (!fs.statSync(src).isFile()) { missing.push(row.what + ' ← ' + row.source); continue; }
|
|
277
|
+
bytes = fs.readFileSync(src);
|
|
278
|
+
} catch { missing.push(row.what + ' ← ' + row.source); continue; }
|
|
279
|
+
const live = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
280
|
+
if (live.toLowerCase() !== row.hash.toLowerCase()) {
|
|
281
|
+
moved.push(row.what + ' (' + KIND[row.kind] + ') ← ' + row.source
|
|
282
|
+
+ ': объявлено ' + row.hash.toLowerCase().slice(0, 12) + '…, на диске ' + live.slice(0, 12) + '…');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (missing.length) {
|
|
286
|
+
proven('источник назван, но его нет на диске', missing,
|
|
287
|
+
'ссылка указывает в пустоту, а правка при этом объявлена построенной на нём.');
|
|
288
|
+
}
|
|
289
|
+
if (moved.length) {
|
|
290
|
+
proven('источник изменился после чтения — правка ОТКЛОНЕНА, мутации нет', moved,
|
|
291
|
+
'прочитанная копия — снимок МОМЕНТА ЧТЕНИЯ, а не состояния файла: между чтением и записью '
|
|
292
|
+
+ 'источник переписали, и правка по литеральной строке попала бы либо мимо, либо в текст, '
|
|
293
|
+
+ 'который теперь значит другое. Симметрично и тише: свежий отчёт может добросовестно '
|
|
294
|
+
+ 'излагать вывод из устаревшего источника — квитанция при этом свежа, и её проверка '
|
|
295
|
+
+ 'отвечает не на тот вопрос. Перечитайте источник, перезапишите хеш и постройте правку '
|
|
296
|
+
+ 'заново — не применяйте её к новому тексту.');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const edits = rows.filter((r) => KIND[r.kind] === 'edit').length;
|
|
300
|
+
say('✅ ' + rows.length + ' объявлени(й) сверено с диском: ' + edits + ' правк(и), '
|
|
301
|
+
+ (rows.length - edits) + ' вывод(ов) — каждый источник на месте и не изменился с момента чтения');
|
|
302
|
+
say(' Ограничение: совпавший хеш доказывает, что почва НЕ СДВИНУЛАСЬ, — но не то, что вывод из '
|
|
303
|
+
+ 'неё следует и не то, что кто-то действительно перечитал файл. Это остаётся слоем 3.');
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
main();
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// Even an unexpected failure must not read as "clean".
|
|
311
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
312
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-swarm-receipts.cjs — did the parallel work units actually DELIVER, or is the coordinator
|
|
6
|
+
* about to aggregate silence?
|
|
7
|
+
*
|
|
8
|
+
* THE FAILURE THIS EXISTS FOR. A worker that died looks exactly like a worker that is still
|
|
9
|
+
* running: both are silent. Silence therefore reads as "in progress", and a coordinator can
|
|
10
|
+
* report in good faith that the review is running when no review exists. The cure is to make the
|
|
11
|
+
* RESULT of every unit a FILE at a named path: no file, no work — and that is a fact a machine can
|
|
12
|
+
* establish, not a feeling a reader has to trust. This utility is that machine.
|
|
13
|
+
*
|
|
14
|
+
* NOT an event hook. Like `check-ports.cjs`, `check-growth-trace.cjs` and `check-look-trace.cjs`,
|
|
15
|
+
* it lives here because this directory already carries plain Node utilities; nothing registers it
|
|
16
|
+
* in settings.json. That is deliberate and load-bearing: this package's hooks are NON-BLOCKING by
|
|
17
|
+
* contract (pinned by tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook
|
|
18
|
+
* could never refuse anything — it could only print. Invoke it:
|
|
19
|
+
*
|
|
20
|
+
* node .claude/hooks/check-swarm-receipts.cjs <path-to-receipts-manifest.json>
|
|
21
|
+
*
|
|
22
|
+
* Exit codes — three, and the third is the point:
|
|
23
|
+
* 0 every required receipt is a fresh, substantive, terminal file ending `Status: completed`
|
|
24
|
+
* 1 at least one unit is UNDELIVERED or delivered a `Status: failed` — refuse aggregation
|
|
25
|
+
* 2 THE CHECK DID NOT RUN — no manifest, malformed assignment, or an inconclusive probe
|
|
26
|
+
*
|
|
27
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance,
|
|
28
|
+
* which is the same substitution the whole contract exists to refuse. So an unreadable trace is
|
|
29
|
+
* exit 2, never 0; and a missing trace whose worker PID is still ALIVE is exit 2 as well — a live
|
|
30
|
+
* worker may extend waiting, but liveness has never been delivery.
|
|
31
|
+
*
|
|
32
|
+
* MANIFEST SHAPE (the coordinator writes it BEFORE dispatch, which is what makes freshness
|
|
33
|
+
* checkable at all — `launchMs` has to be recorded before the workers can write):
|
|
34
|
+
*
|
|
35
|
+
* {
|
|
36
|
+
* "runId": "<run-unique id>",
|
|
37
|
+
* "launchMs": 1756728000000, // Date.now() captured immediately before dispatch
|
|
38
|
+
* "units": [
|
|
39
|
+
* { "workUnitId": "api", "tracePath": "/abs/path/api.md", "pid": 12345 },
|
|
40
|
+
* { "workUnitId": "docs", "tracePath": "/abs/path/docs.md" }
|
|
41
|
+
* ]
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* `pid` is optional and can only ever move an ABSENT trace between "undelivered" and "still
|
|
45
|
+
* waiting". It can never turn a missing file into a delivery.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const fs = require('node:fs');
|
|
49
|
+
const path = require('node:path');
|
|
50
|
+
|
|
51
|
+
const TERMINAL = /^Status: (completed|failed)$/;
|
|
52
|
+
|
|
53
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
54
|
+
|
|
55
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
56
|
+
function cannotCheck(reason, hint) {
|
|
57
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
58
|
+
if (hint) say(' ' + hint);
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Is the worker behind this unit still alive?
|
|
64
|
+
*
|
|
65
|
+
* Three answers, because two would be a lie. `signal 0` tells us the process exists (`live`) or is
|
|
66
|
+
* gone (`dead`); anything else — EPERM on a process we do not own, a malformed pid — is `unknown`,
|
|
67
|
+
* and an unknown liveness must never be read as either.
|
|
68
|
+
*/
|
|
69
|
+
function liveness(pid) {
|
|
70
|
+
if (pid === undefined || pid === null) return 'unknown';
|
|
71
|
+
if (!Number.isInteger(pid) || pid <= 0) return 'unknown';
|
|
72
|
+
try { process.kill(pid, 0); return 'live'; } catch (e) {
|
|
73
|
+
return e && e.code === 'ESRCH' ? 'dead' : 'unknown';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The whole verdict for ONE unit, as a state plus a reason.
|
|
79
|
+
*
|
|
80
|
+
* The order of the checks is the contract, not a convenience. Assignment first: a relative or
|
|
81
|
+
* duplicated `TRACE_PATH` means the DISPATCH was wrong, and a wrong dispatch cannot produce a
|
|
82
|
+
* meaningful verdict about delivery. Then existence, then regular-file, then freshness, then
|
|
83
|
+
* substance, then the terminal marker LAST — because the marker is written last, so a file that
|
|
84
|
+
* has a body but no marker is a PARTIAL write, not a failure and not a success.
|
|
85
|
+
*/
|
|
86
|
+
function inspectReceipt(unit, launchMs) {
|
|
87
|
+
const { workUnitId, tracePath } = unit;
|
|
88
|
+
if (!workUnitId || typeof tracePath !== 'string' || !path.isAbsolute(tracePath)) {
|
|
89
|
+
return { state: 'inconclusive', reason: 'assignment' };
|
|
90
|
+
}
|
|
91
|
+
const alive = liveness(unit.pid);
|
|
92
|
+
let stat;
|
|
93
|
+
try { stat = fs.lstatSync(tracePath); } catch (error) {
|
|
94
|
+
if (error.code !== 'ENOENT') return { state: 'inconclusive', reason: 'unreadable' };
|
|
95
|
+
// A live worker may only EXTEND waiting. It can never stand in for the file.
|
|
96
|
+
if (alive === 'live') return { state: 'waiting', reason: 'positive-liveness-only' };
|
|
97
|
+
return { state: 'undelivered', reason: alive === 'dead' ? 'dead-worker' : 'missing' };
|
|
98
|
+
}
|
|
99
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return { state: 'undelivered', reason: 'not-regular' };
|
|
100
|
+
if (stat.mtimeMs <= launchMs) return { state: 'undelivered', reason: 'stale' };
|
|
101
|
+
let body;
|
|
102
|
+
try { body = fs.readFileSync(tracePath, 'utf-8'); } catch { return { state: 'inconclusive', reason: 'unreadable' }; }
|
|
103
|
+
// Whitespace is exactly as empty as nothing. A size check alone would pass a lone newline.
|
|
104
|
+
if (!body.trim()) return { state: 'undelivered', reason: 'empty' };
|
|
105
|
+
const lines = body.trimEnd().split(/\r?\n/);
|
|
106
|
+
const terminal = lines.at(-1);
|
|
107
|
+
if (!TERMINAL.test(terminal)) return { state: 'undelivered', reason: 'non-terminal' };
|
|
108
|
+
// A marker with nothing above it is a receipt for no work.
|
|
109
|
+
if (!lines.slice(0, -1).join('\n').trim()) return { state: 'undelivered', reason: 'empty-payload' };
|
|
110
|
+
return terminal === 'Status: completed'
|
|
111
|
+
? { state: 'completed', reason: 'terminal-receipt' }
|
|
112
|
+
: { state: 'failed', reason: 'delivered-failure' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readManifest(file) {
|
|
116
|
+
let raw;
|
|
117
|
+
try { raw = fs.readFileSync(file, 'utf-8'); } catch {
|
|
118
|
+
cannotCheck('не читается манифест квитанций: ' + file,
|
|
119
|
+
'манифест пишет КООРДИНАТОР до диспатча — его отсутствие значит, что рой запускали без учёта, '
|
|
120
|
+
+ 'а не что все отчитались');
|
|
121
|
+
}
|
|
122
|
+
let manifest;
|
|
123
|
+
try { manifest = JSON.parse(raw); } catch (e) {
|
|
124
|
+
cannotCheck('манифест не разбирается как JSON: ' + ((e && e.message) || e));
|
|
125
|
+
}
|
|
126
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
127
|
+
cannotCheck('манифест не является объектом');
|
|
128
|
+
}
|
|
129
|
+
if (!Number.isFinite(manifest.launchMs)) {
|
|
130
|
+
cannotCheck('в манифесте нет числового launchMs',
|
|
131
|
+
'без момента запуска нельзя отличить свежий след от файла, лежавшего здесь до роя');
|
|
132
|
+
}
|
|
133
|
+
const units = Array.isArray(manifest.units) ? manifest.units : null;
|
|
134
|
+
if (!units) cannotCheck('в манифесте нет массива units');
|
|
135
|
+
if (!units.length) {
|
|
136
|
+
cannotCheck('в манифесте ноль рабочих единиц',
|
|
137
|
+
'пустой рой нечего проверять — сказать «всё чисто» значило бы отчитаться о проверке, '
|
|
138
|
+
+ 'которой не было');
|
|
139
|
+
}
|
|
140
|
+
const ids = units.map((u) => u && u.workUnitId);
|
|
141
|
+
const paths = units.map((u) => u && u.tracePath);
|
|
142
|
+
const dupeIds = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
|
|
143
|
+
if (dupeIds.length) {
|
|
144
|
+
cannotCheck('WORK_UNIT_ID повторяется: ' + dupeIds.join(', '),
|
|
145
|
+
'один идентификатор на две единицы — одна квитанция зачлась бы за обе');
|
|
146
|
+
}
|
|
147
|
+
const dupePaths = [...new Set(paths.filter((p, i) => paths.indexOf(p) !== i))];
|
|
148
|
+
if (dupePaths.length) {
|
|
149
|
+
cannotCheck('TRACE_PATH повторяется: ' + dupePaths.join(', '),
|
|
150
|
+
'два работника на один путь — второй перезапишет первого, и потеря будет молчаливой');
|
|
151
|
+
}
|
|
152
|
+
return manifest;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function main() {
|
|
156
|
+
const file = process.argv[2];
|
|
157
|
+
if (!file) {
|
|
158
|
+
cannotCheck('не передан путь к манифесту квитанций',
|
|
159
|
+
'использование: node .claude/hooks/check-swarm-receipts.cjs <manifest.json>');
|
|
160
|
+
}
|
|
161
|
+
const manifest = readManifest(file);
|
|
162
|
+
const results = manifest.units.map((unit) => ({ unit, verdict: inspectReceipt(unit, manifest.launchMs) }));
|
|
163
|
+
|
|
164
|
+
const bad = results.filter((r) => r.verdict.state === 'undelivered' || r.verdict.state === 'failed');
|
|
165
|
+
const unknown = results.filter((r) => r.verdict.state === 'inconclusive' || r.verdict.state === 'waiting');
|
|
166
|
+
const ok = results.filter((r) => r.verdict.state === 'completed');
|
|
167
|
+
|
|
168
|
+
const line = (r) => ' • ' + (r.unit.workUnitId || '<без WORK_UNIT_ID>') + ' [' + r.verdict.state
|
|
169
|
+
+ '/' + r.verdict.reason + '] ' + (r.unit.tracePath || '<без TRACE_PATH>');
|
|
170
|
+
|
|
171
|
+
if (bad.length) {
|
|
172
|
+
say('❌ квитанции не собраны: ' + ok.length + ' из ' + results.length + ' завершены');
|
|
173
|
+
for (const r of bad) say(line(r));
|
|
174
|
+
for (const r of unknown) say(line(r));
|
|
175
|
+
say(' Сведение, синтез и завершение ЗАПРЕЩЕНЫ, пока каждая требуемая квитанция не '
|
|
176
|
+
+ 'terminal-completed. Доклад агента — не квитанция; тишина — не прогресс.');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
if (unknown.length) {
|
|
180
|
+
say('⚠️ проверка НЕ выполнена: ' + unknown.length + ' из ' + results.length
|
|
181
|
+
+ ' единиц не дали определённого ответа');
|
|
182
|
+
for (const r of unknown) say(line(r));
|
|
183
|
+
say(' Живой PID продлевает ожидание, но не заменяет файл; нечитаемый след — неизвестность, '
|
|
184
|
+
+ 'а не успех. Дождитесь следа или диагностируйте единицу и перезапустите проверку.');
|
|
185
|
+
process.exit(2);
|
|
186
|
+
}
|
|
187
|
+
say('✅ все ' + results.length + ' квитанций свежие, содержательные и завершены Status: completed');
|
|
188
|
+
say(' Ограничение: это доказывает, что работа ДОСТАВЛЕНА, а не что она верна.');
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
main();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Even an unexpected failure must not read as "clean".
|
|
196
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
197
|
+
}
|