@khoavu882/doflow 2.0.0-beta.4 → 2.0.0-beta.5
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/core/.antigravity-plugin/plugin.json +0 -1
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +7 -17
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +7 -17
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +7 -18
- package/core/harnesses/claude/hooks/session-start.sh +5 -3
- package/core/harnesses/claude/hooks/stop-check.sh +5 -3
- package/core/harnesses/claude/hooks/subagent-audit.sh +5 -5
- package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -3
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +7 -15
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +7 -15
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +7 -16
- package/core/harnesses/codex/hooks/session-start.sh +5 -3
- package/core/harnesses/codex/hooks/stop-check.sh +6 -5
- package/core/harnesses/codex/hooks/subagent-audit.sh +17 -6
- package/core/harnesses/codex/hooks/user-prompt-submit.sh +5 -4
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +7 -18
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +7 -18
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +7 -19
- package/core/harnesses/gemini/hooks/session-start.sh +5 -3
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +20 -5
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +20 -5
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +20 -5
- package/core/harnesses/kiro/hooks/session-start.sh +11 -5
- package/core/harnesses/kiro/hooks/stop-check.sh +21 -30
- package/core/harnesses/pi/extensions/doflow-guard.js +159 -0
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +27 -11
- package/core/harnesses/shared/hooks/adapters/antigravity.js +14 -57
- package/core/harnesses/shared/hooks/bridge/bridge.sh +184 -0
- package/core/harnesses/shared/hooks/bridge/envelope.jq +60 -0
- package/core/harnesses/shared/hooks/bridge/respond.sh +62 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +4 -2
- package/core/harnesses/shared/hooks/policies/contract.sh +75 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +39 -4
- package/core/harnesses/shared/hooks/policies/lib.sh +4 -2
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +46 -34
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +75 -25
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +67 -73
- package/core/harnesses/shared/hooks/policies/session-context.sh +37 -7
- package/core/harnesses/shared/hooks/policies/stop-check.sh +42 -15
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +35 -5
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +40 -7
- package/core/harnesses/shared/hooks/stream-hook-runner.js +69 -181
- package/core/registry/assets.json +19 -0
- package/core/registry/contracts.json +2 -2
- package/core/registry/harnesses.json +196 -10
- package/core/registry/lifecycle.json +2626 -7
- package/core/registry/storage.json +24 -1
- package/core/shared/scripts/doflow/bin/doflow-run +10 -6
- package/core/shared/skills/do-design/SKILL.md +6 -6
- package/core/shared/skills/do-execute-plan/SKILL.md +11 -10
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +2 -0
- package/core/shared/skills/do-implement/SKILL.md +3 -2
- package/core/shared/skills/do-plan/SKILL.md +7 -8
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +83 -47
- package/src/adapters/claude/index.js +15 -1
- package/src/adapters/codex/hooks.js +40 -5
- package/src/adapters/codex/index.js +52 -9
- package/src/adapters/copy-tree.js +4 -4
- package/src/adapters/gemini/hooks.js +57 -16
- package/src/adapters/gemini/index.js +57 -18
- package/src/adapters/kiro/index.js +15 -1
- package/src/adapters/pi/hooks.js +119 -0
- package/src/adapters/pi/index.js +34 -9
- package/src/cli/commands/install.js +1 -1
- package/src/cli/commands/update.js +1 -1
- package/src/cli/index.js +17 -5
- package/src/cli/runtime-commands.js +51 -12
- package/src/lifecycle/coverage.js +57 -0
- package/src/lifecycle/hook-step.js +61 -0
- package/src/lifecycle/index.js +28 -14
- package/src/registry/index.js +136 -7
- package/src/runtime/candidate.js +505 -0
- package/src/runtime/claims.js +14 -5
- package/src/runtime/cli-result.js +11 -4
- package/src/runtime/cli.js +35 -268
- package/src/runtime/context/budget.js +203 -0
- package/src/runtime/context/compose.js +1367 -0
- package/src/runtime/context/delivery-store.js +102 -0
- package/src/runtime/context/mandatory.js +594 -0
- package/src/runtime/context/pointer.js +142 -0
- package/src/runtime/context-pack.js +1029 -244
- package/src/runtime/evidence-fields.js +39 -0
- package/src/runtime/evidence-ledger.js +7 -0
- package/src/runtime/freshness.js +44 -7
- package/src/runtime/outcome.js +1 -1
- package/src/runtime/readiness.js +83 -13
- package/src/runtime/retrieval-plan.js +422 -73
- package/src/runtime/retrieval-record.js +223 -0
- package/src/runtime/scaffold/artifacts.js +3 -0
- package/src/runtime/source-version.js +289 -0
- package/src/runtime/task-attempts.js +1 -1
- package/src/runtime/task-scope.js +28 -3
- package/src/runtime/task-state.js +72 -7
- package/core/.antigravity-plugin/hooks.json +0 -39
- package/core/harnesses/shared/hooks/adapters/arg-fields.js +0 -19
- package/core/harnesses/shared/hooks/adapters/gemini.js +0 -114
- package/core/harnesses/shared/hooks/hooks.json +0 -47
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
|
|
4
|
+
|
|
5
|
+
const { measureFreshness } = require('./freshness.js');
|
|
6
|
+
const { measureTreeState, sourceVersionFor } = require('./source-version.js');
|
|
7
|
+
const { VALID_EVIDENCE_KINDS, VALID_PROVENANCE } = require('./evidence-ledger.js');
|
|
8
|
+
const { resolveLocator, describeResolution } = require('./locator-resolve.js');
|
|
9
|
+
const { assertNoScoreFields } = require('./evidence-fields.js');
|
|
10
|
+
const { readPlan, resolveNeed, activeAttemptRef, declaredIntentList } = require('./retrieval-record.js');
|
|
11
|
+
const { freshnessEntry, sourceModeLookup } = require('./retrieval-plan.js');
|
|
12
|
+
const { CapabilityRouter } = require('./capability-router.js');
|
|
13
|
+
const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
14
|
+
|
|
15
|
+
function getSourceModeOf(ctx ) {
|
|
16
|
+
if (ctx?.sourceModeOf) return ctx.sourceModeOf;
|
|
17
|
+
const router = new CapabilityRouter({ repoRoot: REPO_ROOT });
|
|
18
|
+
return sourceModeLookup(router.capabilities);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getProviderFreshness(provider , root , ctx ) {
|
|
22
|
+
const cache = ctx?.providerFreshness;
|
|
23
|
+
if (cache && cache.has(provider)) {
|
|
24
|
+
return cache.get(provider);
|
|
25
|
+
}
|
|
26
|
+
const sourceModeOf = getSourceModeOf(ctx);
|
|
27
|
+
const entry = freshnessEntry(provider, root, sourceModeOf);
|
|
28
|
+
if (cache) {
|
|
29
|
+
cache.set(provider, entry);
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
const CANDIDATE_FIELDS = [
|
|
69
|
+
'locator',
|
|
70
|
+
'provider',
|
|
71
|
+
'capability',
|
|
72
|
+
'need',
|
|
73
|
+
'sourceVersion',
|
|
74
|
+
'rank',
|
|
75
|
+
'score',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Every field an evidence item may carry here. Anything else is refused, never dropped. */
|
|
79
|
+
const EVIDENCE_ITEM_FIELDS = ['kind', 'provenance', 'source', 'locator', 'content', 'taskId', 'establishes', 'observation', 'need', 'absence'];
|
|
80
|
+
const EVIDENCE_SOURCE_FIELDS = ['provider', 'capability'];
|
|
81
|
+
const EVIDENCE_LOCATOR_FIELDS = ['file', 'line', 'symbol', 'uri'];
|
|
82
|
+
const EVIDENCE_OBSERVATION_FIELDS = ['command', 'exitCode'];
|
|
83
|
+
|
|
84
|
+
/** Kinds that record an execution and may carry a typed observation. */
|
|
85
|
+
const OBSERVATION_KINDS = new Set(['test-result', 'runtime-observation']);
|
|
86
|
+
|
|
87
|
+
/** Kinds that are by definition not a direct read of the repository. FR-007's second sentence. */
|
|
88
|
+
const NON_REPOSITORY_KINDS = new Set(['generated-analysis', 'user-statement']);
|
|
89
|
+
|
|
90
|
+
function queryRefRefusal(taskId , repoRoot , slug = null) {
|
|
91
|
+
const plan = readPlan(repoRoot, taskId, slug);
|
|
92
|
+
const list = declaredIntentList(plan);
|
|
93
|
+
return `the runtime stamps it from the need. Re-run with --need <intent> (in a batch, "need": "<intent>"). Declared intents for task '${taskId}': ${list}.`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fields a caller might plausibly send that this boundary refuses by name, with the reason.
|
|
97
|
+
* A named refusal beats the generic unknown-field error: each of these would otherwise be
|
|
98
|
+
* silently discarded by `addEvidence`, and a silent discard reads as a successful write. */
|
|
99
|
+
const EVIDENCE_REFUSED_FIELDS = new Map ([
|
|
100
|
+
['id', 'evidence ids are minted by the ledger — a caller-chosen id can overwrite an unrelated record'],
|
|
101
|
+
['freshness', 'freshness is measured here from the working tree at write time, never asserted by the writer'],
|
|
102
|
+
['supports', 'link evidence to a claim with `claim --action link`, which updates both sides; a list written here would leave the claim unaware of it'],
|
|
103
|
+
['contradicts', 'link evidence to a claim with `claim --action link`, which updates both sides; a list written here would leave the claim unaware of it'],
|
|
104
|
+
['stage', 'the evidence record has no stage field — the batch *is* the stage boundary, so a stage name recorded here would be dropped'],
|
|
105
|
+
['status', "lifecycle status starts 'active' and changes only through `evidence --action supersede` — asserting it here would let a caller declare an item superseded without naming what replaces it"],
|
|
106
|
+
['queryRef', queryRefRefusal],
|
|
107
|
+
['attemptRef', (taskId ) => `it is read from the active task attempt. Claim the task with doflow task-attempt --action claim --task ${taskId}, then re-run without attemptRef.`],
|
|
108
|
+
['result', 'an empty answer is recorded with --absence --need <intent> (in a batch, "absence": true), which also measures the index freshness.'],
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const EVIDENCE_REFUSED_SOURCE_FIELDS = new Map ([
|
|
112
|
+
['queryRef', queryRefRefusal],
|
|
113
|
+
['attemptRef', (taskId ) => `it is read from the active task attempt. Claim the task with doflow task-attempt --action claim --task ${taskId}, then re-run without attemptRef.`],
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalises a locator given either as an object or as a `file[:line]` / URI string.
|
|
118
|
+
* @param {Object|string} raw
|
|
119
|
+
* @returns {Object}
|
|
120
|
+
*/
|
|
121
|
+
function parseLocator(raw ) {
|
|
122
|
+
if (typeof raw === 'string') {
|
|
123
|
+
const text = raw.trim();
|
|
124
|
+
if (text === '') throw new Error('locator is empty');
|
|
125
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return { uri: text };
|
|
126
|
+
const withLine = text.match(/^(.+):(\d+)$/);
|
|
127
|
+
if (withLine) return { file: withLine[1], line: Number(withLine[2]) };
|
|
128
|
+
return { file: text };
|
|
129
|
+
}
|
|
130
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
131
|
+
throw new Error("locator must be an object, or a 'path/to/file[:line]' or URI string");
|
|
132
|
+
}
|
|
133
|
+
const locator = {};
|
|
134
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
135
|
+
if (!EVIDENCE_LOCATOR_FIELDS.includes(key)) {
|
|
136
|
+
throw new Error(`unknown locator field '${key}'. Accepted: ${EVIDENCE_LOCATOR_FIELDS.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
locator[key] = value;
|
|
139
|
+
}
|
|
140
|
+
if (locator.line !== undefined && (!Number.isInteger(locator.line) || locator.line < 1)) {
|
|
141
|
+
throw new Error(`locator.line must be a positive integer, got '${locator.line}'`);
|
|
142
|
+
}
|
|
143
|
+
for (const key of ['file', 'symbol', 'uri']) {
|
|
144
|
+
if (locator[key] !== undefined && (typeof locator[key] !== 'string' || locator[key].trim() === '')) {
|
|
145
|
+
throw new Error(`locator.${key} must be a non-empty string`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!locator.file && !locator.uri) throw new Error('a locator needs at least a file or a uri');
|
|
149
|
+
return locator;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validates one raw evidence item and returns the record fields the ledger will store.
|
|
154
|
+
*
|
|
155
|
+
* Throws on anything it cannot account for. Every rule here traces to FR-007 or to the
|
|
156
|
+
* vacuous-record family: a record with no statement and no locator asserts nothing, and storing it
|
|
157
|
+
* inflates the evidence count that the readiness gate then reads.
|
|
158
|
+
*
|
|
159
|
+
* @param {Object} raw
|
|
160
|
+
* @param {string} taskId the task the batch is being written to
|
|
161
|
+
* @param {string} taskId the task the batch is being written to
|
|
162
|
+
* @param {string} repoRoot root a relative locator resolves against
|
|
163
|
+
* @param {string|null} [slug] feature slug
|
|
164
|
+
* @returns {{kind:string, provenance:string, source:Object, locator:Object, content:(string|null)}}
|
|
165
|
+
*/
|
|
166
|
+
function validateEvidenceItem(raw , taskId , repoRoot , slug = null) {
|
|
167
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('an evidence item must be a JSON object');
|
|
168
|
+
assertNoScoreFields(raw, '');
|
|
169
|
+
|
|
170
|
+
for (const key of Object.keys(raw)) {
|
|
171
|
+
if (EVIDENCE_REFUSED_FIELDS.has(key)) {
|
|
172
|
+
const val = EVIDENCE_REFUSED_FIELDS.get(key);
|
|
173
|
+
const reason = typeof val === 'function' ? val(taskId, repoRoot, slug) : val;
|
|
174
|
+
throw new Error(`'${key}' is not accepted here: ${reason}`);
|
|
175
|
+
}
|
|
176
|
+
if (!EVIDENCE_ITEM_FIELDS.includes(key)) {
|
|
177
|
+
throw new Error(`unknown evidence field '${key}'. Accepted: ${EVIDENCE_ITEM_FIELDS.join(', ')}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (raw.taskId !== undefined && raw.taskId !== taskId) {
|
|
182
|
+
throw new Error(`item names task '${raw.taskId}' but this write targets '${taskId}' — one write, one task`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const isAbsence = Boolean(raw.absence);
|
|
186
|
+
if (isAbsence) {
|
|
187
|
+
if (raw.need === undefined || raw.need === null || raw.need === '') {
|
|
188
|
+
const plan = readPlan(repoRoot, taskId, slug);
|
|
189
|
+
const list = declaredIntentList(plan);
|
|
190
|
+
throw new Error(`--absence needs --need <intent>: an empty answer means nothing without the query it answered. Declared intents for task '${taskId}': ${list}.`);
|
|
191
|
+
}
|
|
192
|
+
if (raw.locator !== undefined && raw.locator !== null) {
|
|
193
|
+
throw new Error('an absence record carries no locator: it states that the provider found nothing. Drop --locator, or record what was found as an ordinary item with --need <intent>.');
|
|
194
|
+
}
|
|
195
|
+
if (raw.provenance !== 'extracted') {
|
|
196
|
+
throw new Error("an absence record must be 'extracted': it is the provider's answer as you read it. Re-run with --provenance extracted.");
|
|
197
|
+
}
|
|
198
|
+
if (raw.observation !== undefined && raw.observation !== null) {
|
|
199
|
+
throw new Error('an absence record carries no observation: it records a lookup, not an execution. Drop the observation.');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const kinds = [...VALID_EVIDENCE_KINDS].sort().join(', ');
|
|
204
|
+
if (typeof raw.kind !== 'string' || raw.kind.trim() === '') throw new Error(`kind is required. Valid: ${kinds}`);
|
|
205
|
+
if (!VALID_EVIDENCE_KINDS.has(raw.kind)) throw new Error(`unknown evidence kind '${raw.kind}'. Valid: ${kinds}`);
|
|
206
|
+
|
|
207
|
+
const provenances = [...VALID_PROVENANCE].sort().join(', ');
|
|
208
|
+
if (typeof raw.provenance !== 'string' || raw.provenance.trim() === '') {
|
|
209
|
+
throw new Error('provenance is required and has no default here — defaulting it would file model '
|
|
210
|
+
+ `analysis as repository fact, the one distinction FR-007 makes binding. Valid: ${provenances}`);
|
|
211
|
+
}
|
|
212
|
+
if (!VALID_PROVENANCE.has(raw.provenance)) throw new Error(`unknown provenance '${raw.provenance}'. Valid: ${provenances}`);
|
|
213
|
+
if (NON_REPOSITORY_KINDS.has(raw.kind) && raw.provenance === 'extracted') {
|
|
214
|
+
throw new Error(`kind '${raw.kind}' cannot carry provenance 'extracted': it is not a read of the `
|
|
215
|
+
+ "repository, and labelling it as one is exactly how model analysis stops being distinguishable "
|
|
216
|
+
+ "from fact. Use 'inferred' (derived by analysis) or 'asserted' (stated by a person).");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (raw.source === undefined) {
|
|
220
|
+
throw new Error('source is required: which provider answered, under which capability. '
|
|
221
|
+
+ "It has no default — 'unknown/general' is a plausible-looking answer to a question nobody asked.");
|
|
222
|
+
}
|
|
223
|
+
if (!raw.source || typeof raw.source !== 'object' || Array.isArray(raw.source)) {
|
|
224
|
+
throw new Error('source must be an object with provider and capability');
|
|
225
|
+
}
|
|
226
|
+
assertNoScoreFields(raw.source, 'source');
|
|
227
|
+
for (const key of Object.keys(raw.source)) {
|
|
228
|
+
if (EVIDENCE_REFUSED_SOURCE_FIELDS.has(key)) {
|
|
229
|
+
const val = EVIDENCE_REFUSED_SOURCE_FIELDS.get(key);
|
|
230
|
+
const reason = typeof val === 'function' ? val(taskId, repoRoot, slug) : val;
|
|
231
|
+
throw new Error(`'${key}' is not accepted here: ${reason}`);
|
|
232
|
+
}
|
|
233
|
+
if (!EVIDENCE_SOURCE_FIELDS.includes(key)) {
|
|
234
|
+
throw new Error(`unknown source field '${key}'. Accepted: ${EVIDENCE_SOURCE_FIELDS.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const key of EVIDENCE_SOURCE_FIELDS) {
|
|
238
|
+
if (typeof raw.source[key] !== 'string' || raw.source[key].trim() === '') {
|
|
239
|
+
throw new Error(`source.${key} is required and must be a non-empty string`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const locator = (isAbsence || raw.locator === undefined) ? null : parseLocator(raw.locator);
|
|
244
|
+
// An item carrying an observation is anchored by the command it records — that command is what
|
|
245
|
+
// the next worker re-runs to check it — so the locator becomes optional there and only there.
|
|
246
|
+
if (!isAbsence && raw.provenance === 'extracted' && !locator && raw.observation === undefined) {
|
|
247
|
+
throw new Error("provenance 'extracted' requires a locator — a fact read from the repository "
|
|
248
|
+
+ 'must name where it was read, or the next worker cannot check it and cannot tell when it goes stale');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// A locator that parses is not a locator that resolves (FR-004). Shape validation alone let an
|
|
252
|
+
// item naming line 799 of a 28-line file be recorded as support and counted by the readiness
|
|
253
|
+
// gate. The refusal names what the file actually offers, so the writer can correct it here rather
|
|
254
|
+
// than discover it at the gate.
|
|
255
|
+
if (!isAbsence && raw.provenance === 'extracted' && locator) {
|
|
256
|
+
const resolution = resolveLocator({ locator, repoRoot });
|
|
257
|
+
if (!resolution.resolved) {
|
|
258
|
+
throw new Error(`the locator does not resolve: ${describeResolution(locator, resolution)}. `
|
|
259
|
+
+ "An 'extracted' item asserts a read of the repository, so a locator that points at nothing "
|
|
260
|
+
+ 'records a fact nobody can check');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (raw.content !== undefined && typeof raw.content !== 'string') throw new Error('content must be a string');
|
|
265
|
+
const content = typeof raw.content === 'string' && raw.content.trim() !== '' ? raw.content : null;
|
|
266
|
+
if (raw.provenance !== 'extracted' && !content) {
|
|
267
|
+
throw new Error(`provenance '${raw.provenance}' requires content — an inferred or asserted item `
|
|
268
|
+
+ 'is the statement itself, so a record without one records nothing while still counting as evidence');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Requirement bindings: which readiness-template requirements this item claims to establish.
|
|
272
|
+
// Declared by the writer, judged by the gate — the gate counts an item toward a requirement only
|
|
273
|
+
// when it is named here, so a structural item can no longer satisfy `architecture_mapped` and
|
|
274
|
+
// `blast_radius` at once by category alone (review R1).
|
|
275
|
+
let establishes = [];
|
|
276
|
+
if (raw.establishes !== undefined) {
|
|
277
|
+
const list = typeof raw.establishes === 'string' ? raw.establishes.split(',').map((s ) => s.trim()) : raw.establishes;
|
|
278
|
+
if (!Array.isArray(list) || list.length === 0 || list.some((r) => typeof r !== 'string' || r.trim() === '')) {
|
|
279
|
+
throw new Error("'establishes' must be a requirement id, a comma-separated list of them, or a non-empty array of non-empty strings");
|
|
280
|
+
}
|
|
281
|
+
establishes = list.map((s) => s.trim());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Typed observation: the execution behind a test-result or runtime-observation. Command and exit
|
|
285
|
+
// status are the two facts the gate can actually grade; tree identity and observation time are
|
|
286
|
+
// measured into freshness at this same write. Only an 'extracted' item may carry one — an
|
|
287
|
+
// observation asserts an execution happened, and an inferred item is by definition not one.
|
|
288
|
+
let observation = null;
|
|
289
|
+
if (raw.observation !== undefined) {
|
|
290
|
+
if (!raw.observation || typeof raw.observation !== 'object' || Array.isArray(raw.observation)) {
|
|
291
|
+
throw new Error('observation must be an object with command and exitCode');
|
|
292
|
+
}
|
|
293
|
+
for (const key of Object.keys(raw.observation)) {
|
|
294
|
+
if (!EVIDENCE_OBSERVATION_FIELDS.includes(key)) {
|
|
295
|
+
throw new Error(`unknown observation field '${key}'. Accepted: ${EVIDENCE_OBSERVATION_FIELDS.join(', ')}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (typeof raw.observation.command !== 'string' || raw.observation.command.trim() === '') {
|
|
299
|
+
throw new Error('observation.command is required: the exact command whose exit status is being recorded');
|
|
300
|
+
}
|
|
301
|
+
if (!Number.isInteger(raw.observation.exitCode) || raw.observation.exitCode < 0) {
|
|
302
|
+
throw new Error(`observation.exitCode must be a non-negative integer, got '${raw.observation.exitCode}'`);
|
|
303
|
+
}
|
|
304
|
+
if (!OBSERVATION_KINDS.has(raw.kind)) {
|
|
305
|
+
throw new Error(`kind '${raw.kind}' cannot carry an observation — only ${[...OBSERVATION_KINDS].join(', ')} record an execution`);
|
|
306
|
+
}
|
|
307
|
+
if (raw.provenance !== 'extracted') {
|
|
308
|
+
throw new Error(`provenance '${raw.provenance}' cannot carry an observation: an observation asserts a command `
|
|
309
|
+
+ 'was actually run, which is a read of the world, not an inference or an assertion. '
|
|
310
|
+
+ "Either record the execution as 'extracted', or drop the observation.");
|
|
311
|
+
}
|
|
312
|
+
observation = { command: raw.observation.command.trim(), exitCode: raw.observation.exitCode };
|
|
313
|
+
}
|
|
314
|
+
if (raw.kind === 'test-result' && raw.provenance === 'extracted' && !observation) {
|
|
315
|
+
throw new Error("an 'extracted' test-result requires an observation ({command, exitCode}) — a test result "
|
|
316
|
+
+ 'without the command and exit status behind it is a claim about an execution nobody can check, '
|
|
317
|
+
+ 'and it is exactly how a failed baseline was once accepted as a passing one');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let needIntent = null;
|
|
321
|
+
if (raw.need !== undefined) {
|
|
322
|
+
const list = Array.isArray(raw.need) ? raw.need : [raw.need];
|
|
323
|
+
const intents = [];
|
|
324
|
+
for (const entry of list) {
|
|
325
|
+
if (typeof entry !== 'string') {
|
|
326
|
+
throw new Error('need must be an intent string');
|
|
327
|
+
}
|
|
328
|
+
for (const token of entry.split(',')) {
|
|
329
|
+
const trimmed = token.trim();
|
|
330
|
+
if (trimmed !== '') intents.push(trimmed);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (intents.length !== 1) {
|
|
334
|
+
throw new Error(`--need takes one intent on evidence --action add, got ${intents.length}. Record one write per need.`);
|
|
335
|
+
}
|
|
336
|
+
needIntent = intents[0];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
kind: raw.kind,
|
|
341
|
+
provenance: raw.provenance,
|
|
342
|
+
source: { provider: raw.source.provider, capability: raw.source.capability },
|
|
343
|
+
locator: isAbsence ? {} : (locator || {}),
|
|
344
|
+
content,
|
|
345
|
+
establishes,
|
|
346
|
+
observation,
|
|
347
|
+
...(needIntent !== null ? { need: needIntent } : {}),
|
|
348
|
+
...(isAbsence ? { absence: true, result: 'EMPTY' } : {}),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function shortHash(hash ) {
|
|
353
|
+
if (!hash) return 'absent';
|
|
354
|
+
const clean = hash.startsWith('sha256:') ? hash.slice('sha256:'.length) : hash;
|
|
355
|
+
return clean.slice(0, 7);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function toEvidence(
|
|
359
|
+
input ,
|
|
360
|
+
ctx
|
|
361
|
+
) {
|
|
362
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
363
|
+
throw new Error("input must be an object with 'candidate' or 'item'");
|
|
364
|
+
}
|
|
365
|
+
if (!ctx || typeof ctx !== 'object') {
|
|
366
|
+
throw new Error('ctx must be an object');
|
|
367
|
+
}
|
|
368
|
+
if (!ctx.taskId || typeof ctx.taskId !== 'string' || ctx.taskId.trim() === '') {
|
|
369
|
+
throw new Error('taskId is required');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const taskId = ctx.taskId;
|
|
373
|
+
const root = ctx.root || process.cwd();
|
|
374
|
+
const treeState = ctx.treeState || measureTreeState(root);
|
|
375
|
+
|
|
376
|
+
let rawItem ;
|
|
377
|
+
|
|
378
|
+
if ('candidate' in input && input.candidate !== undefined) {
|
|
379
|
+
const candidate = input.candidate;
|
|
380
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
381
|
+
throw new Error('candidate must be an object');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const key of Object.keys(candidate)) {
|
|
385
|
+
if (!CANDIDATE_FIELDS.includes(key) && key !== 'absence') {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`unknown candidate field '${key}'. Accepted: ${CANDIDATE_FIELDS.join(', ')}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (
|
|
393
|
+
candidate.sourceVersion &&
|
|
394
|
+
candidate.sourceVersion.fileHash !== null &&
|
|
395
|
+
candidate.sourceVersion.fileHash !== undefined
|
|
396
|
+
) {
|
|
397
|
+
let loc = candidate.locator;
|
|
398
|
+
if (typeof loc === 'string') {
|
|
399
|
+
loc = parseLocator(loc);
|
|
400
|
+
}
|
|
401
|
+
const sv = sourceVersionFor(root, loc, treeState);
|
|
402
|
+
const measuredHash = sv.fileHash;
|
|
403
|
+
const searchedHash = candidate.sourceVersion.fileHash;
|
|
404
|
+
if (searchedHash !== measuredHash) {
|
|
405
|
+
const file = loc?.file || 'unknown';
|
|
406
|
+
throw new Error(
|
|
407
|
+
`the file changed between the search and this record (${file}: searched ${shortHash(searchedHash)}, now ${shortHash(measuredHash)}). Re-run the search and record the new result.`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const source = {};
|
|
413
|
+
if (candidate.provider !== undefined) source.provider = candidate.provider;
|
|
414
|
+
if (candidate.capability !== undefined) source.capability = candidate.capability;
|
|
415
|
+
|
|
416
|
+
rawItem = {
|
|
417
|
+
kind: ctx.kind,
|
|
418
|
+
provenance: ctx.provenance,
|
|
419
|
+
source,
|
|
420
|
+
locator: candidate.locator,
|
|
421
|
+
content: ctx.content,
|
|
422
|
+
establishes: ctx.establishes,
|
|
423
|
+
};
|
|
424
|
+
const absence = candidate.absence ?? ctx.absence;
|
|
425
|
+
if (absence !== undefined) rawItem.absence = absence;
|
|
426
|
+
if (candidate.need !== undefined) {
|
|
427
|
+
rawItem.need = candidate.need;
|
|
428
|
+
}
|
|
429
|
+
} else if ('item' in input && input.item !== undefined) {
|
|
430
|
+
rawItem = input.item;
|
|
431
|
+
} else {
|
|
432
|
+
throw new Error("toEvidence requires either 'candidate' or 'item'");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const validated = validateEvidenceItem(rawItem, taskId, root, ctx.slug);
|
|
436
|
+
const sv = sourceVersionFor(root, validated.locator, treeState);
|
|
437
|
+
|
|
438
|
+
const source = { ...validated.source };
|
|
439
|
+
|
|
440
|
+
if (validated.need) {
|
|
441
|
+
const plan = ctx.plan !== undefined ? ctx.plan : readPlan(root, taskId, ctx.slug);
|
|
442
|
+
const resolved = resolveNeed(plan, validated.need, taskId);
|
|
443
|
+
if (!resolved.ok) {
|
|
444
|
+
throw new Error(resolved.message);
|
|
445
|
+
}
|
|
446
|
+
source.queryRef = resolved.queryRef;
|
|
447
|
+
delete validated.need;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const attemptRef = ctx.attemptRef !== undefined
|
|
451
|
+
? ctx.attemptRef
|
|
452
|
+
: activeAttemptRef(root, taskId, ctx.slug, undefined, ctx.attemptRoot);
|
|
453
|
+
if (attemptRef) {
|
|
454
|
+
source.attemptRef = attemptRef;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let freshness ;
|
|
458
|
+
if (validated.absence) {
|
|
459
|
+
delete validated.absence;
|
|
460
|
+
const absenceTreeState = ctx.absenceTreeState || treeState;
|
|
461
|
+
const probe = getProviderFreshness(source.provider, ctx.probeRoot || root, ctx);
|
|
462
|
+
let status = 'UNKNOWN';
|
|
463
|
+
if (probe?.state === 'FRESH' || probe?.state === 'NOT_APPLICABLE') {
|
|
464
|
+
status = 'FRESH';
|
|
465
|
+
} else if (probe?.state === 'STALE') {
|
|
466
|
+
status = 'STALE';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!absenceTreeState || absenceTreeState.gitCommit === null) {
|
|
470
|
+
status = 'UNKNOWN';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
freshness = {
|
|
474
|
+
gitCommit: absenceTreeState ? absenceTreeState.gitCommit : null,
|
|
475
|
+
fileHash: null,
|
|
476
|
+
isDirty: null,
|
|
477
|
+
observedAt: new Date().toISOString(),
|
|
478
|
+
status,
|
|
479
|
+
};
|
|
480
|
+
if (absenceTreeState && absenceTreeState.gitCommit !== null && absenceTreeState.fingerprint) {
|
|
481
|
+
freshness.treeFingerprint = absenceTreeState.fingerprint;
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
freshness = {
|
|
485
|
+
...measureFreshness(root, validated.locator, sv.gitCommit),
|
|
486
|
+
isDirty: sv.isDirty,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
...validated,
|
|
492
|
+
taskId,
|
|
493
|
+
source,
|
|
494
|
+
freshness,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
module.exports = {
|
|
499
|
+
CANDIDATE_FIELDS,
|
|
500
|
+
toEvidence,
|
|
501
|
+
parseLocator,
|
|
502
|
+
validateEvidenceItem,
|
|
503
|
+
EVIDENCE_REFUSED_FIELDS,
|
|
504
|
+
EVIDENCE_REFUSED_SOURCE_FIELDS,
|
|
505
|
+
};
|
package/src/runtime/claims.js
CHANGED
|
@@ -193,22 +193,28 @@ class ClaimsManager {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
let hasFreshSupport = false;
|
|
196
|
-
let hasFreshContradiction = false;
|
|
197
196
|
let hasStaleSupport = false;
|
|
197
|
+
let hasFreshContradiction = false;
|
|
198
|
+
let hasUnknownContradiction = false;
|
|
198
199
|
|
|
199
200
|
for (const evId of claim.supportingEvidence) {
|
|
200
201
|
const ev = this.evidenceLedger ? this.evidenceLedger.getEvidence(evId) : null;
|
|
201
202
|
if (!ev || ev.freshness?.status === 'STALE') {
|
|
202
203
|
hasStaleSupport = true;
|
|
203
|
-
} else {
|
|
204
|
+
} else if (ev.freshness?.status === 'FRESH') {
|
|
204
205
|
hasFreshSupport = true;
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
for (const evId of claim.contradictingEvidence) {
|
|
209
210
|
const ev = this.evidenceLedger ? this.evidenceLedger.getEvidence(evId) : null;
|
|
210
|
-
if (ev
|
|
211
|
-
|
|
211
|
+
if (ev) {
|
|
212
|
+
const status = ev.freshness?.status;
|
|
213
|
+
if (status === 'FRESH') {
|
|
214
|
+
hasFreshContradiction = true;
|
|
215
|
+
} else if (status !== 'STALE') {
|
|
216
|
+
hasUnknownContradiction = true;
|
|
217
|
+
}
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
220
|
|
|
@@ -219,9 +225,12 @@ class ClaimsManager {
|
|
|
219
225
|
// settled. Support that has merely gone stale still counts as support, so a claim that once
|
|
220
226
|
// had backing stays 'conflicted' rather than being declared false on a technicality.
|
|
221
227
|
claim.status = claim.supportingEvidence.length === 0 ? 'rejected' : 'conflicted';
|
|
228
|
+
} else if (hasUnknownContradiction) {
|
|
229
|
+
// A contradiction whose freshness is unknown cannot prove the claim false, so it never yields 'rejected' (DEC-016).
|
|
230
|
+
claim.status = claim.supportingEvidence.length > 0 ? 'conflicted' : 'hypothesis';
|
|
222
231
|
} else if (hasFreshSupport) {
|
|
223
232
|
claim.status = 'supported';
|
|
224
|
-
} else if (hasStaleSupport
|
|
233
|
+
} else if (hasStaleSupport) {
|
|
225
234
|
claim.status = 'invalidated';
|
|
226
235
|
} else {
|
|
227
236
|
claim.status = 'hypothesis';
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* it, and requires only the mask module.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const EXIT = Object.freeze({
|
|
15
|
+
OK: 0,
|
|
16
|
+
EMPTY: 1,
|
|
17
|
+
USAGE: 2,
|
|
18
|
+
OVERFLOW: 3,
|
|
19
|
+
});
|
|
20
|
+
|
|
14
21
|
/**
|
|
15
22
|
* Sets the process exit code.
|
|
16
23
|
* @param {number} code
|
|
@@ -46,10 +53,10 @@ function captureCaught(error , command , exit ) {
|
|
|
46
53
|
* @returns {number} 2 (USAGE error exit code)
|
|
47
54
|
*/
|
|
48
55
|
function usageError(verb , message , json , error ) {
|
|
49
|
-
if (error !== undefined) captureCaught(error, verb,
|
|
50
|
-
if (json) console.log(JSON.stringify({ ok: false, status: 'USAGE', exitCode:
|
|
56
|
+
if (error !== undefined) captureCaught(error, verb, EXIT.USAGE);
|
|
57
|
+
if (json) console.log(JSON.stringify({ ok: false, status: 'USAGE', exitCode: EXIT.USAGE, error: 'usage', summary: message }, null, 2));
|
|
51
58
|
else console.error(`doflow ${verb}: ${message}`);
|
|
52
|
-
return finishRuntime(
|
|
59
|
+
return finishRuntime(EXIT.USAGE);
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
module.exports = { finishRuntime, usageError, captureCaught };
|
|
62
|
+
module.exports = { finishRuntime, usageError, captureCaught, EXIT };
|