@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
|
@@ -38,16 +38,15 @@
|
|
|
38
38
|
* emits are counts of records.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
const fs = require('node:fs');
|
|
42
|
-
const path = require('node:path');
|
|
43
41
|
const { CapabilityRouter } = require('./capability-router.js');
|
|
44
42
|
const { EvidenceLedger, assertSafeTaskId } = require('./evidence-ledger.js');
|
|
45
|
-
const {
|
|
43
|
+
const { FreshnessValidator } = require('./freshness.js');
|
|
46
44
|
const { probeFreshness } = require('./health.js');
|
|
45
|
+
const { planPath, readPlan, updatePlan, moveCorruptPlanAside, mintPlanId, activeAttemptRef, resolveNeed, bindEvidence, deriveOutcome } = require('./retrieval-record.js');
|
|
47
46
|
// One definition of the score refusal, applied at a second boundary. See the note on
|
|
48
|
-
// EVIDENCE_SCORE_FIELDS in
|
|
47
|
+
// EVIDENCE_SCORE_FIELDS in src/runtime/evidence-fields.js: relevance is a property of a search, not of a fact, and a plan
|
|
49
48
|
// that recorded one would hand the next worker a confidence dressed as a measurement.
|
|
50
|
-
const { assertNoScoreFields } = require('./
|
|
49
|
+
const { assertNoScoreFields } = require('./evidence-fields.js');
|
|
51
50
|
const { finishRuntime, usageError } = require('./cli-result.js');
|
|
52
51
|
const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
53
52
|
|
|
@@ -73,6 +72,18 @@ const RESULTS = Object.freeze([RETRIEVED, EMPTY, UNREACHED, UNVERIFIED]);
|
|
|
73
72
|
const NOT_APPLICABLE = 'NOT_APPLICABLE';
|
|
74
73
|
const FRESHNESS_STATES = Object.freeze(['FRESH', 'STALE', 'UNKNOWN', NOT_APPLICABLE]);
|
|
75
74
|
|
|
75
|
+
// ── attempt reason codes (IC-013) ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const ATTEMPT_REASON_CODES = Object.freeze([
|
|
78
|
+
'provider-unavailable',
|
|
79
|
+
'policy-bypass',
|
|
80
|
+
'provider-error',
|
|
81
|
+
'timeout',
|
|
82
|
+
'empty-answer',
|
|
83
|
+
'index-stale',
|
|
84
|
+
'index-unverifiable',
|
|
85
|
+
]);
|
|
86
|
+
|
|
76
87
|
/**
|
|
77
88
|
* Design §4's mapping table, as data. It maps only the *empty* answer: a provider that returned
|
|
78
89
|
* something is RETRIEVED whatever the age of its index, because the thing it returned is a
|
|
@@ -91,6 +102,13 @@ const EMPTY_ANSWER_BY_FRESHNESS = Object.freeze({
|
|
|
91
102
|
[NOT_APPLICABLE]: EMPTY,
|
|
92
103
|
});
|
|
93
104
|
|
|
105
|
+
function requirePlanForUpdate(disk , taskId ) {
|
|
106
|
+
if (disk === null) {
|
|
107
|
+
throw new Error(`the retrieval plan for task ${taskId} was removed during the update; re-declare it`);
|
|
108
|
+
}
|
|
109
|
+
return disk;
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
/**
|
|
95
113
|
* What an empty answer becomes, given the freshness of the index behind it.
|
|
96
114
|
* @param {string} freshnessState one of FRESHNESS_STATES
|
|
@@ -107,50 +125,6 @@ function resultForEmptyAnswer(freshnessState ) {
|
|
|
107
125
|
return mapped;
|
|
108
126
|
}
|
|
109
127
|
|
|
110
|
-
// ── the record ────────────────────────────────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Where a task's plan lives, under the invoking project's state root.
|
|
114
|
-
*
|
|
115
|
-
* The task id is validated by the evidence ledger's own rule, unchanged: it becomes a filename, so
|
|
116
|
-
* it must never be able to name a path.
|
|
117
|
-
* @param {string} projectRoot
|
|
118
|
-
* @param {string} taskId
|
|
119
|
-
* @param {string|null} [slug] the feature the task belongs to, when not the branch's (task-scope.js)
|
|
120
|
-
* @returns {string}
|
|
121
|
-
*/
|
|
122
|
-
function planPath(projectRoot , taskId , slug = null) {
|
|
123
|
-
const dir = taskStoreDir({ projectRoot, store: 'retrieval', taskId: assertSafeTaskId(taskId), slug });
|
|
124
|
-
return path.join(dir, `${taskId}.json`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* @param {string} projectRoot
|
|
129
|
-
* @param {string} taskId
|
|
130
|
-
* @returns {Object|null} the record, or null when no plan was declared
|
|
131
|
-
*/
|
|
132
|
-
function readPlan(projectRoot , taskId , slug = null) {
|
|
133
|
-
const file = planPath(projectRoot, taskId, slug);
|
|
134
|
-
if (!fs.existsSync(file)) return null;
|
|
135
|
-
try {
|
|
136
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
137
|
-
} catch (error ) {
|
|
138
|
-
throw new Error(`the retrieval plan at '${file}' is not readable: ${error.message}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* @param {string} projectRoot
|
|
144
|
-
* @param {string} taskId
|
|
145
|
-
* @param {Object} record
|
|
146
|
-
* @returns {string} the file written
|
|
147
|
-
*/
|
|
148
|
-
function writePlan(projectRoot , taskId , record , slug = null) {
|
|
149
|
-
const file = planPath(projectRoot, taskId, slug);
|
|
150
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
151
|
-
fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
152
|
-
return file;
|
|
153
|
-
}
|
|
154
128
|
|
|
155
129
|
/**
|
|
156
130
|
* The `sourceMode` each provider id declares, as a lookup the plan builds once per declare.
|
|
@@ -186,8 +160,8 @@ function sourceModeLookup(capabilities ) {
|
|
|
186
160
|
* absent, every provider without an artifact layout is treated as unknown (fail closed)
|
|
187
161
|
* @returns {{state: string, artifact: string|null, refresh: string|null, label: string|null, reason: string|null}}
|
|
188
162
|
*/
|
|
189
|
-
function freshnessEntry(providerId ,
|
|
190
|
-
const probe = probeFreshness(providerId, { root:
|
|
163
|
+
function freshnessEntry(providerId , sourceRoot , sourceModeOf = () => null) {
|
|
164
|
+
const probe = probeFreshness(providerId, { root: sourceRoot });
|
|
191
165
|
if (!probe) {
|
|
192
166
|
// `probeFreshness` returns null for a provider it has no artifact layout for. What that means
|
|
193
167
|
// depends on how the provider answers: a `live` provider reads current files or commands, so
|
|
@@ -274,8 +248,8 @@ function evidenceIdsFor(need , items ) {
|
|
|
274
248
|
*
|
|
275
249
|
* @returns {number} exit code
|
|
276
250
|
*/
|
|
277
|
-
function declarePlan({ router, projectRoot, taskId, stage, intents, json, slug } ) {
|
|
278
|
-
const needs
|
|
251
|
+
function declarePlan({ router, projectRoot, sourceRoot, taskId, stage, intents, json, slug } ) {
|
|
252
|
+
const needs = [];
|
|
279
253
|
for (const intent of intents) {
|
|
280
254
|
let resolution;
|
|
281
255
|
try {
|
|
@@ -303,18 +277,35 @@ function declarePlan({ router, projectRoot, taskId, stage, intents, json, slug }
|
|
|
303
277
|
const sourceModeOf = sourceModeLookup(router.capabilities);
|
|
304
278
|
for (const need of needs) {
|
|
305
279
|
if (!need.provider || providers[need.provider]) continue;
|
|
306
|
-
providers[need.provider] = freshnessEntry(need.provider,
|
|
280
|
+
providers[need.provider] = freshnessEntry(need.provider, sourceRoot, sourceModeOf);
|
|
307
281
|
}
|
|
308
282
|
|
|
309
|
-
const
|
|
283
|
+
const planId = mintPlanId();
|
|
284
|
+
const attemptRef = activeAttemptRef(projectRoot, taskId, slug);
|
|
285
|
+
for (const need of needs) {
|
|
286
|
+
need.queryRef = `${planId}#${need.intent}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const record = {
|
|
310
290
|
version: 1,
|
|
291
|
+
planId,
|
|
292
|
+
binding: 'query',
|
|
311
293
|
taskId,
|
|
312
294
|
stage: stage || null,
|
|
313
295
|
declaredAt: new Date().toISOString(),
|
|
296
|
+
attemptRef,
|
|
314
297
|
providers,
|
|
315
298
|
needs,
|
|
316
299
|
};
|
|
317
|
-
|
|
300
|
+
let file ;
|
|
301
|
+
try {
|
|
302
|
+
file = updatePlan(projectRoot, taskId, slug, () => record, undefined, () => {
|
|
303
|
+
const corruptFile = moveCorruptPlanAside(projectRoot, taskId, slug);
|
|
304
|
+
if (corruptFile) console.log(`Moved unreadable retrieval plan to ${corruptFile}; declaring a new plan.`);
|
|
305
|
+
});
|
|
306
|
+
} catch (error ) {
|
|
307
|
+
return usageError('retrieval-plan', error.message, json, error);
|
|
308
|
+
}
|
|
318
309
|
|
|
319
310
|
const unresolvable = needs.filter((need) => !need.provider).map((need) => need.intent);
|
|
320
311
|
if (json) {
|
|
@@ -326,12 +317,13 @@ function declarePlan({ router, projectRoot, taskId, stage, intents, json, slug }
|
|
|
326
317
|
console.log('═'.repeat(78));
|
|
327
318
|
console.log(`Stage: ${record.stage || 'not stated'}`);
|
|
328
319
|
console.log('─'.repeat(78));
|
|
329
|
-
console.log('Intent'.padEnd(26) + 'Provider'.padEnd(22) + 'Index');
|
|
320
|
+
console.log('Intent'.padEnd(26) + 'Provider'.padEnd(22) + 'Index'.padEnd(16) + 'QueryRef');
|
|
330
321
|
for (const need of needs) {
|
|
331
322
|
console.log(
|
|
332
323
|
need.intent.padEnd(26)
|
|
333
324
|
+ (need.provider || 'none — unresolvable').padEnd(22)
|
|
334
|
-
+ freshnessOf(record, need)
|
|
325
|
+
+ freshnessOf(record, need).padEnd(16)
|
|
326
|
+
+ (need.queryRef || ''),
|
|
335
327
|
);
|
|
336
328
|
}
|
|
337
329
|
printProviderIndexes(record);
|
|
@@ -367,8 +359,8 @@ function declarePlan({ router, projectRoot, taskId, stage, intents, json, slug }
|
|
|
367
359
|
*
|
|
368
360
|
* @returns {number} exit code
|
|
369
361
|
*/
|
|
370
|
-
function reportPlan({ projectRoot, taskId, reached, json, slug } ) {
|
|
371
|
-
let record;
|
|
362
|
+
function reportPlan({ projectRoot, sourceRoot, taskId, reached, json, slug, repoRoot } ) {
|
|
363
|
+
let record ;
|
|
372
364
|
try {
|
|
373
365
|
record = readPlan(projectRoot, taskId, slug);
|
|
374
366
|
} catch (error ) {
|
|
@@ -395,17 +387,121 @@ function reportPlan({ projectRoot, taskId, reached, json, slug } ) {
|
|
|
395
387
|
} catch (error ) {
|
|
396
388
|
return usageError('retrieval-plan', error.message, json, error);
|
|
397
389
|
}
|
|
390
|
+
new FreshnessValidator({ repoRoot: projectRoot, treeRoot: sourceRoot }).validateLedgerFreshness(ledger);
|
|
398
391
|
const items = ledger.queryEvidence({ taskId });
|
|
399
392
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
393
|
+
const isQueryBound = Boolean(record.planId);
|
|
394
|
+
let binding = isQueryBound ? bindEvidence(record, items) : null;
|
|
395
|
+
let reportSourceModeOf = null;
|
|
396
|
+
const probeByProvider = new Map ();
|
|
397
|
+
if (record.planId) {
|
|
398
|
+
const initialBinding = bindEvidence(record, items);
|
|
399
|
+
const missingProviders = new Set ();
|
|
400
|
+
for (const need of record.needs) {
|
|
401
|
+
for (const item of initialBinding.byNeed.get(need.intent) || []) {
|
|
402
|
+
if (item.result !== 'EMPTY' && !record.providers?.[item.source?.provider]) {
|
|
403
|
+
if (item.source?.provider) missingProviders.add(item.source.provider);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (missingProviders.size > 0) {
|
|
408
|
+
const reportRouter = new CapabilityRouter({ repoRoot: repoRoot || REPO_ROOT });
|
|
409
|
+
reportSourceModeOf = sourceModeLookup(reportRouter.capabilities);
|
|
410
|
+
for (const providerId of missingProviders) {
|
|
411
|
+
probeByProvider.set(providerId, module.exports._probe(providerId, sourceRoot, reportSourceModeOf));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
405
414
|
}
|
|
406
415
|
|
|
407
|
-
|
|
408
|
-
|
|
416
|
+
let file ;
|
|
417
|
+
try {
|
|
418
|
+
file = updatePlan(projectRoot, taskId, slug, (disk ) => {
|
|
419
|
+
const current = requirePlanForUpdate(disk, taskId);
|
|
420
|
+
if (current.planId) {
|
|
421
|
+
const currentBinding = bindEvidence(current, items);
|
|
422
|
+
binding = currentBinding;
|
|
423
|
+
for (const need of current.needs) {
|
|
424
|
+
const bound = currentBinding.byNeed.get(need.intent) || [];
|
|
425
|
+
if (!Array.isArray(need.attempts)) need.attempts = [];
|
|
426
|
+
for (const attempt of need.attempts) {
|
|
427
|
+
if (attempt.recordedBy === 'derived') attempt.evidenceIds = [];
|
|
428
|
+
}
|
|
429
|
+
const groups = new Map ();
|
|
430
|
+
for (const item of bound) {
|
|
431
|
+
const absent = item.result === 'EMPTY';
|
|
432
|
+
const status = absent
|
|
433
|
+
? (item.freshness?.status === 'UNKNOWN' ? UNVERIFIED : EMPTY)
|
|
434
|
+
: RETRIEVED;
|
|
435
|
+
const providerKey = item.source?.provider ?? '__no_provider__';
|
|
436
|
+
const freshness = item.freshness?.status || 'UNKNOWN';
|
|
437
|
+
const key = `${providerKey}\u0000${status}\u0000${freshness}`;
|
|
438
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
439
|
+
const group = groups.get(key);
|
|
440
|
+
if (group) group.push(item);
|
|
441
|
+
}
|
|
442
|
+
const countedAttempts = need.attempts.filter((attempt ) =>
|
|
443
|
+
!(attempt.recordedBy === 'derived' && attempt.basis !== 'report-need'
|
|
444
|
+
&& (!Array.isArray(attempt.evidenceIds) || attempt.evidenceIds.length === 0)));
|
|
445
|
+
if (reached.has(need.intent) && countedAttempts.length === 0 && bound.length === 0) {
|
|
446
|
+
const provider = need.provider;
|
|
447
|
+
const indexState = provider ? (current.providers?.[provider]?.state || 'UNKNOWN') : NOT_APPLICABLE;
|
|
448
|
+
groups.set(`${provider ?? '__no_provider__'}\u0000${resultForEmptyAnswer(indexState)}\u0000${indexState}`, []);
|
|
449
|
+
}
|
|
450
|
+
for (const [key, group] of groups) {
|
|
451
|
+
const [providerKey, status, freshness] = key.split('\u0000');
|
|
452
|
+
const provider = providerKey === '__no_provider__' ? null : providerKey;
|
|
453
|
+
let attempt = need.attempts.find((entry ) => entry.recordedBy === 'derived'
|
|
454
|
+
&& entry.provider === provider && entry.status === status && entry.indexState === freshness);
|
|
455
|
+
if (attempt) {
|
|
456
|
+
attempt.evidenceIds = group.map((item ) => item.id);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (status === RETRIEVED && provider && !current.providers?.[provider] && probeByProvider.has(provider)) {
|
|
460
|
+
current.providers ||= {};
|
|
461
|
+
current.providers[provider] = probeByProvider.get(provider);
|
|
462
|
+
}
|
|
463
|
+
const providerState = provider ? current.providers?.[provider]?.state : null;
|
|
464
|
+
const indexState = status === RETRIEVED
|
|
465
|
+
? (providerState || 'UNKNOWN')
|
|
466
|
+
: (freshness || providerState || 'UNKNOWN');
|
|
467
|
+
const reason = status === RETRIEVED ? null
|
|
468
|
+
: indexState === 'STALE' ? 'index-stale'
|
|
469
|
+
: indexState === 'UNKNOWN' ? 'index-unverifiable' : 'empty-answer';
|
|
470
|
+
const n = need.attempts.reduce((max , entry ) => Math.max(max, entry.n || 0), 0) + 1;
|
|
471
|
+
need.attempts.push({
|
|
472
|
+
n, provider, capability: need.capability,
|
|
473
|
+
role: provider === null || provider === need.provider ? 'primary' : 'fallback',
|
|
474
|
+
status, reason, note: null, indexState,
|
|
475
|
+
attemptRef: current.attemptRef || null,
|
|
476
|
+
evidenceIds: group.map((item ) => item.id),
|
|
477
|
+
recordedAt: new Date().toISOString(), recordedBy: 'derived',
|
|
478
|
+
...(reached.has(need.intent) && group.length === 0 ? { basis: 'report-need' } : {}),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const outcome = deriveOutcome(need);
|
|
482
|
+
need.result = outcome.result;
|
|
483
|
+
need.resolvedBy = outcome.resolvedBy;
|
|
484
|
+
need.viaFallback = outcome.viaFallback;
|
|
485
|
+
need.evidenceIds = need.attempts.filter((entry ) => entry.status === RETRIEVED
|
|
486
|
+
&& !(entry.recordedBy === 'derived' && (!Array.isArray(entry.evidenceIds) || entry.evidenceIds.length === 0)))
|
|
487
|
+
.flatMap((entry ) => entry.evidenceIds || []);
|
|
488
|
+
}
|
|
489
|
+
record = current;
|
|
490
|
+
} else {
|
|
491
|
+
for (const need of current.needs) {
|
|
492
|
+
need.evidenceIds = evidenceIdsFor(need, items);
|
|
493
|
+
if (need.evidenceIds.length > 0) need.result = RETRIEVED;
|
|
494
|
+
else if (reached.has(need.intent)) need.result = resultForEmptyAnswer(freshnessOf(current, need));
|
|
495
|
+
else need.result = UNREACHED;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
current.reportedAt = new Date().toISOString();
|
|
499
|
+
record = current;
|
|
500
|
+
return current;
|
|
501
|
+
});
|
|
502
|
+
} catch (error ) {
|
|
503
|
+
return usageError('retrieval-plan', error.message, json, error);
|
|
504
|
+
}
|
|
409
505
|
|
|
410
506
|
// Derived views, computed at emit and never stored on a need — a need holds a provider id and
|
|
411
507
|
// nothing about that provider's index (plan D7).
|
|
@@ -430,6 +526,18 @@ function reportPlan({ projectRoot, taskId, reached, json, slug } ) {
|
|
|
430
526
|
unreached,
|
|
431
527
|
unverified,
|
|
432
528
|
stale,
|
|
529
|
+
...(record.planId ? {
|
|
530
|
+
planId: record.planId,
|
|
531
|
+
binding: record.binding,
|
|
532
|
+
unbound: {
|
|
533
|
+
count: binding.unbound.length,
|
|
534
|
+
ids: binding.unbound.slice(0, 5).map((item ) => item.id),
|
|
535
|
+
causes: binding.unbound.reduce((counts , item ) => {
|
|
536
|
+
counts[item.cause] = (counts[item.cause] || 0) + 1;
|
|
537
|
+
return counts;
|
|
538
|
+
}, {}),
|
|
539
|
+
},
|
|
540
|
+
} : {}),
|
|
433
541
|
}, null, 2));
|
|
434
542
|
return finishRuntime(incomplete ? 1 : 0);
|
|
435
543
|
}
|
|
@@ -447,8 +555,21 @@ function reportPlan({ projectRoot, taskId, reached, json, slug } ) {
|
|
|
447
555
|
+ freshness.padEnd(16)
|
|
448
556
|
+ need.result + (freshness === 'STALE' ? ' (stale index)' : ''),
|
|
449
557
|
);
|
|
558
|
+
if (record.planId) {
|
|
559
|
+
const attempts = need.attempts || [];
|
|
560
|
+
if (attempts.length > 1 || (attempts.length === 1 && attempts[0].status !== RETRIEVED)) {
|
|
561
|
+
for (const attempt of attempts) {
|
|
562
|
+
console.log(` Attempt #${attempt.n} ${attempt.role} ${attempt.provider} — ${attempt.status}`
|
|
563
|
+
+ (attempt.reason ? ` (${attempt.reason})` : '')
|
|
564
|
+
+ (attempt.evidenceIds?.length ? ` [${attempt.evidenceIds.join(', ')}]` : ''));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
450
568
|
}
|
|
451
569
|
printProviderIndexes(record);
|
|
570
|
+
if (record.planId && binding.unbound.length > 0) {
|
|
571
|
+
console.log(`${binding.unbound.length} item(s) not bound to a declared need are not counted; re-record them with --need <intent>.`);
|
|
572
|
+
}
|
|
452
573
|
console.log('─'.repeat(78));
|
|
453
574
|
// NFR-004: the skip is stated as prominently as the failure, on its own line and named.
|
|
454
575
|
if (unreached.length > 0) {
|
|
@@ -464,6 +585,206 @@ function reportPlan({ projectRoot, taskId, reached, json, slug } ) {
|
|
|
464
585
|
return finishRuntime(incomplete ? 1 : 0);
|
|
465
586
|
}
|
|
466
587
|
|
|
588
|
+
/**
|
|
589
|
+
* `--action attempt` — record an attempt against a declared need (IC-012, IC-013).
|
|
590
|
+
*
|
|
591
|
+
* Rules:
|
|
592
|
+
* - The action appends one attempt to the need, under the plan lock (IC-020).
|
|
593
|
+
* - `indexState` is re-probed for `--provider` with `freshnessEntry` at record time.
|
|
594
|
+
* - `--status EMPTY` is mapped through `resultForEmptyAnswer(indexState)`, so an EMPTY
|
|
595
|
+
* from an UNKNOWN index is stored as UNVERIFIED with reason `index-unverifiable`.
|
|
596
|
+
* - The action writes no evidence and never changes declared fields.
|
|
597
|
+
* - Exits 0 when written, 2 on any refusal.
|
|
598
|
+
*
|
|
599
|
+
* @returns {number} exit code
|
|
600
|
+
*/
|
|
601
|
+
function attemptPlan({
|
|
602
|
+
router,
|
|
603
|
+
projectRoot,
|
|
604
|
+
taskId,
|
|
605
|
+
intents,
|
|
606
|
+
provider,
|
|
607
|
+
attemptStatus,
|
|
608
|
+
reason,
|
|
609
|
+
note,
|
|
610
|
+
json,
|
|
611
|
+
slug,
|
|
612
|
+
sourceRoot,
|
|
613
|
+
} ) {
|
|
614
|
+
if (intents.length !== 1) {
|
|
615
|
+
return usageError(
|
|
616
|
+
'retrieval-plan',
|
|
617
|
+
`--need takes one intent on retrieval-plan --action attempt, got ${intents.length}. Record one write per need.`,
|
|
618
|
+
json,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const intent = intents[0];
|
|
622
|
+
|
|
623
|
+
let record ;
|
|
624
|
+
try {
|
|
625
|
+
record = readPlan(projectRoot, taskId, slug);
|
|
626
|
+
} catch (error ) {
|
|
627
|
+
return usageError('retrieval-plan', error.message, json, error);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const resolved = resolveNeed(record, intent, taskId);
|
|
631
|
+
if (!resolved.ok) {
|
|
632
|
+
return usageError('retrieval-plan', resolved.message, json);
|
|
633
|
+
}
|
|
634
|
+
const declaredNeed = resolved.need;
|
|
635
|
+
|
|
636
|
+
if (attemptStatus === undefined || attemptStatus === null || attemptStatus === '') {
|
|
637
|
+
return usageError('retrieval-plan', '--status is required: EMPTY, UNREACHED or UNVERIFIED', json);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (attemptStatus === 'RETRIEVED') {
|
|
641
|
+
return usageError(
|
|
642
|
+
'retrieval-plan',
|
|
643
|
+
`RETRIEVED is never recorded by hand: it is derived from evidence bound to the need. Record what was found with: doflow evidence --task-id ${taskId} --action add --need ${intent} ...`,
|
|
644
|
+
json,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (attemptStatus !== 'EMPTY' && attemptStatus !== 'UNREACHED' && attemptStatus !== 'UNVERIFIED') {
|
|
649
|
+
return usageError(
|
|
650
|
+
'retrieval-plan',
|
|
651
|
+
`unknown --status '${attemptStatus}'. Valid here: EMPTY, UNREACHED, UNVERIFIED. A provider that could not be asked is UNREACHED with --reason provider-unavailable or policy-bypass.`,
|
|
652
|
+
json,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const REASON_CODES_LIST = ATTEMPT_REASON_CODES.join(', ');
|
|
657
|
+
|
|
658
|
+
if (reason === undefined || reason === null || reason === '') {
|
|
659
|
+
return usageError(
|
|
660
|
+
'retrieval-plan',
|
|
661
|
+
`--status ${attemptStatus} needs --reason <code>: a lookup that returned no locator must say why. Codes: ${REASON_CODES_LIST}.`,
|
|
662
|
+
json,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!ATTEMPT_REASON_CODES.includes(reason)) {
|
|
667
|
+
return usageError(
|
|
668
|
+
'retrieval-plan',
|
|
669
|
+
`unknown --reason '${reason}'. Codes: ${REASON_CODES_LIST}.`,
|
|
670
|
+
json,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const allowedReasons = {
|
|
675
|
+
EMPTY: ['empty-answer', 'index-stale', 'index-unverifiable'],
|
|
676
|
+
UNREACHED: ['provider-unavailable', 'policy-bypass', 'provider-error', 'timeout'],
|
|
677
|
+
UNVERIFIED: ['index-unverifiable'],
|
|
678
|
+
};
|
|
679
|
+
if (!allowedReasons[attemptStatus].includes(reason)) {
|
|
680
|
+
return usageError('retrieval-plan', `--reason '${reason}' is not allowed for --status ${attemptStatus}. Allowed: ${allowedReasons[attemptStatus].join(', ')}.`, json);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const capEntry = router.capabilities ? router.capabilities[declaredNeed.capability] : null;
|
|
684
|
+
const registeredProviders = capEntry && Array.isArray(capEntry.providers)
|
|
685
|
+
? capEntry.providers.map((p ) => p.id).filter(Boolean)
|
|
686
|
+
: [];
|
|
687
|
+
const regList = registeredProviders.join(', ');
|
|
688
|
+
|
|
689
|
+
if (provider === undefined || provider === null || String(provider).trim() === '') {
|
|
690
|
+
return usageError(
|
|
691
|
+
'retrieval-plan',
|
|
692
|
+
`--provider is required: an attempt is one provider's try at one need. Providers registered under ${declaredNeed.capability}: ${regList}.`,
|
|
693
|
+
json,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (!registeredProviders.includes(provider)) {
|
|
698
|
+
return usageError(
|
|
699
|
+
'retrieval-plan',
|
|
700
|
+
`provider '${provider}' is not registered under capability '${declaredNeed.capability}' (need '${intent}'). Registered: ${regList}. A fallback is another provider of the same capability, for example native.rg.`,
|
|
701
|
+
json,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (note !== undefined && note !== null && String(note).length > 160) {
|
|
706
|
+
return usageError(
|
|
707
|
+
'retrieval-plan',
|
|
708
|
+
`--note is at most 160 characters (got ${String(note).length}): it is a hint for a reader, and the runtime never reads it.`,
|
|
709
|
+
json,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const sourceModeOf = sourceModeLookup(router.capabilities);
|
|
714
|
+
const probe = freshnessEntry(provider, sourceRoot, sourceModeOf);
|
|
715
|
+
const indexState = probe.state;
|
|
716
|
+
|
|
717
|
+
let finalStatus = attemptStatus;
|
|
718
|
+
let finalReason = reason;
|
|
719
|
+
|
|
720
|
+
if (attemptStatus === 'EMPTY') {
|
|
721
|
+
finalStatus = resultForEmptyAnswer(indexState);
|
|
722
|
+
if (finalStatus === 'UNVERIFIED') {
|
|
723
|
+
finalReason = 'index-unverifiable';
|
|
724
|
+
} else if (indexState === 'STALE') {
|
|
725
|
+
finalReason = 'index-stale';
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const activeRef = activeAttemptRef(projectRoot, taskId, slug);
|
|
730
|
+
let savedAttempt = null;
|
|
731
|
+
let file ;
|
|
732
|
+
try {
|
|
733
|
+
file = updatePlan(projectRoot, taskId, slug, (disk ) => {
|
|
734
|
+
const current = requirePlanForUpdate(disk, taskId);
|
|
735
|
+
const targetNeed = current.needs ? current.needs.find((n ) => n.intent === intent) : null;
|
|
736
|
+
if (!targetNeed) {
|
|
737
|
+
throw new Error(`need '${intent}' missing from disk plan during update`);
|
|
738
|
+
}
|
|
739
|
+
if (!Array.isArray(targetNeed.attempts)) {
|
|
740
|
+
targetNeed.attempts = [];
|
|
741
|
+
}
|
|
742
|
+
const n = targetNeed.attempts.length + 1;
|
|
743
|
+
const role = provider === targetNeed.provider ? 'primary' : 'fallback';
|
|
744
|
+
const attempt = {
|
|
745
|
+
n,
|
|
746
|
+
provider,
|
|
747
|
+
capability: targetNeed.capability,
|
|
748
|
+
role,
|
|
749
|
+
status: finalStatus,
|
|
750
|
+
reason: finalReason,
|
|
751
|
+
note: note !== undefined && note !== null ? String(note) : null,
|
|
752
|
+
indexState,
|
|
753
|
+
attemptRef: activeRef,
|
|
754
|
+
evidenceIds: [],
|
|
755
|
+
recordedAt: new Date().toISOString(),
|
|
756
|
+
recordedBy: 'caller',
|
|
757
|
+
};
|
|
758
|
+
targetNeed.attempts.push(attempt);
|
|
759
|
+
savedAttempt = attempt;
|
|
760
|
+
return current;
|
|
761
|
+
});
|
|
762
|
+
} catch (error ) {
|
|
763
|
+
return usageError('retrieval-plan', error.message, json, error);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (json) {
|
|
767
|
+
console.log(JSON.stringify({
|
|
768
|
+
action: 'attempt',
|
|
769
|
+
taskId,
|
|
770
|
+
intent,
|
|
771
|
+
stateFile: file,
|
|
772
|
+
attempt: savedAttempt,
|
|
773
|
+
}, null, 2));
|
|
774
|
+
return finishRuntime(0);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
console.log(`\nDoFlow Retrieval Attempt recorded [Task: ${taskId}]:`);
|
|
778
|
+
console.log('═'.repeat(78));
|
|
779
|
+
console.log(`Need: ${intent} Attempt: #${savedAttempt.n} Role: ${savedAttempt.role}`);
|
|
780
|
+
console.log(`Provider: ${savedAttempt.provider} Status: ${savedAttempt.status} Reason: ${savedAttempt.reason}`);
|
|
781
|
+
if (savedAttempt.note) console.log(`Note: ${savedAttempt.note}`);
|
|
782
|
+
console.log('─'.repeat(78));
|
|
783
|
+
console.log(`Recorded attempt #${savedAttempt.n} to ${file}`);
|
|
784
|
+
console.log('═'.repeat(78) + '\n');
|
|
785
|
+
return finishRuntime(0);
|
|
786
|
+
}
|
|
787
|
+
|
|
467
788
|
/** The provider index block both actions print: state, what was located, and the remedy. */
|
|
468
789
|
function printProviderIndexes(record ) {
|
|
469
790
|
const entries = Object.entries (record.providers || {});
|
|
@@ -504,11 +825,12 @@ function parseIntents(raw ) {
|
|
|
504
825
|
* @param {string} [options.stage] the stage id declaring the plan
|
|
505
826
|
* @param {boolean} [options.json=false]
|
|
506
827
|
* @param {string} [options.repoRoot] the DoFlow package root, holding the capability registries
|
|
507
|
-
* @param {string} [options.stateRoot] the project the task belongs to; its state
|
|
828
|
+
* @param {string} [options.stateRoot] the project the task belongs to; its state
|
|
829
|
+
* @param {string} [options.sourceRoot] the checkout whose source and index freshness are measured
|
|
508
830
|
* @returns {number} exit code
|
|
509
831
|
*/
|
|
510
832
|
function handleRetrievalPlanCommand(options = {}) {
|
|
511
|
-
const { taskId, action = 'report', need, stage, json = false, repoRoot, stateRoot, slug } = options;
|
|
833
|
+
const { taskId, action = 'report', need, stage, provider, attemptStatus, reason, note, json = false, repoRoot, stateRoot, sourceRoot: requestedSourceRoot, slug } = options;
|
|
512
834
|
|
|
513
835
|
// NFR-001, at the boundary where a caller's input first becomes a record. `bin/doflow.js` already
|
|
514
836
|
// refuses every score-shaped flag name on the command line; this is the same rule applied to the
|
|
@@ -523,13 +845,14 @@ function handleRetrievalPlanCommand(options = {}) {
|
|
|
523
845
|
// accepts it as an alias for its own default rather than reporting a usage error on an argument
|
|
524
846
|
// the caller never typed — `verify` and `claim` already do exactly this.
|
|
525
847
|
const resolvedAction = action === 'status' ? 'report' : action;
|
|
526
|
-
if (resolvedAction !== 'declare' && resolvedAction !== 'report') {
|
|
527
|
-
return usageError('retrieval-plan', `unknown --action '${action}'. Valid: declare, report (default)`, json);
|
|
848
|
+
if (resolvedAction !== 'declare' && resolvedAction !== 'report' && resolvedAction !== 'attempt') {
|
|
849
|
+
return usageError('retrieval-plan', `unknown --action '${action}'. Valid: declare, report (default), attempt`, json);
|
|
528
850
|
}
|
|
529
851
|
|
|
530
852
|
// The project the task belongs to owns both the state and the index whose age is being measured.
|
|
531
853
|
// The package root owns the capability registries, and is a different directory in every install.
|
|
532
854
|
const projectRoot = stateRoot || process.cwd();
|
|
855
|
+
const sourceRoot = requestedSourceRoot || projectRoot;
|
|
533
856
|
const packageRoot = repoRoot || REPO_ROOT;
|
|
534
857
|
|
|
535
858
|
try {
|
|
@@ -555,10 +878,32 @@ function handleRetrievalPlanCommand(options = {}) {
|
|
|
555
878
|
} catch (error ) {
|
|
556
879
|
return usageError('retrieval-plan', error.message, json, error);
|
|
557
880
|
}
|
|
558
|
-
return declarePlan({ router, projectRoot, taskId, stage, intents, json, slug });
|
|
881
|
+
return declarePlan({ router, projectRoot, sourceRoot, taskId, stage, intents, json, slug });
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (resolvedAction === 'attempt') {
|
|
885
|
+
let router;
|
|
886
|
+
try {
|
|
887
|
+
router = new CapabilityRouter({ repoRoot: packageRoot });
|
|
888
|
+
} catch (error ) {
|
|
889
|
+
return usageError('retrieval-plan', error.message, json, error);
|
|
890
|
+
}
|
|
891
|
+
return attemptPlan({
|
|
892
|
+
router,
|
|
893
|
+
projectRoot,
|
|
894
|
+
sourceRoot,
|
|
895
|
+
taskId,
|
|
896
|
+
intents,
|
|
897
|
+
provider,
|
|
898
|
+
attemptStatus,
|
|
899
|
+
reason,
|
|
900
|
+
note,
|
|
901
|
+
json,
|
|
902
|
+
slug,
|
|
903
|
+
});
|
|
559
904
|
}
|
|
560
905
|
|
|
561
|
-
return reportPlan({ projectRoot, taskId, reached: new Set(intents), json, slug });
|
|
906
|
+
return reportPlan({ projectRoot, sourceRoot, taskId, reached: new Set(intents), json, slug, repoRoot: packageRoot });
|
|
562
907
|
}
|
|
563
908
|
|
|
564
909
|
module.exports = {
|
|
@@ -575,4 +920,8 @@ module.exports = {
|
|
|
575
920
|
NOT_APPLICABLE,
|
|
576
921
|
FRESHNESS_STATES,
|
|
577
922
|
EMPTY_ANSWER_BY_FRESHNESS,
|
|
923
|
+
ATTEMPT_REASON_CODES,
|
|
924
|
+
applyReport: (disk , taskId ) => requirePlanForUpdate(disk, taskId),
|
|
925
|
+
applyAttempt: (disk , taskId ) => requirePlanForUpdate(disk, taskId),
|
|
926
|
+
_probe: (providerId , root , sourceModeOf ) => freshnessEntry(providerId, root, sourceModeOf),
|
|
578
927
|
};
|