@khoavu882/doflow 2.0.0 → 2.0.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/README.md +4 -5
- package/bin/doflow.js +1 -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 +4 -1
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/claude/settings/settings.json +8 -9
- package/core/harnesses/codex/hooks/hooks.json +0 -10
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
- package/core/harnesses/copilot/hooks/doflow.json +0 -6
- package/core/harnesses/kiro/agents/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
- package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
- package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
- package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
- package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
- package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
- package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
- package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +1 -1
- package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
- package/core/registry/contracts.json +1 -1
- package/core/registry/contribution-points.json +5 -2
- package/core/registry/gates.json +5 -5
- package/core/registry/harnesses.json +9 -9
- package/core/registry/intake.json +53 -0
- package/core/registry/lifecycle.json +133 -711
- package/core/registry/package-templates/intake-category/files/category.json +8 -0
- package/core/registry/package-templates/intake-category/template.json +4 -0
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/rules.json +8 -8
- package/core/registry/settings.json +0 -4
- package/core/registry/storage.json +8 -0
- package/core/registry/telemetry.json +1 -1
- package/core/registry/verification.json +0 -5
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
- package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
- package/core/shared/guidance/rules/RULES.md +2 -2
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
- package/core/shared/scripts/doflow/bin/doflow-run +5 -2
- package/core/shared/skills/do/SKILL.md +3 -3
- package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
- package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
- package/core/shared/skills/do-code-review/SKILL.md +7 -4
- package/core/shared/skills/do-design/SKILL.md +5 -3
- package/core/shared/skills/do-diagnose/SKILL.md +4 -2
- package/core/shared/skills/do-document/SKILL.md +1 -0
- package/core/shared/skills/do-document/references/embedded-research.md +1 -1
- package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
- package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
- package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
- package/core/shared/skills/do-flow/SKILL.md +4 -4
- package/core/shared/skills/do-flow/references/task_classes.md +5 -5
- package/core/shared/skills/do-implement/SKILL.md +9 -7
- package/core/shared/skills/do-plan/SKILL.md +9 -6
- package/core/shared/workflows/do-flow-chain.md +2 -0
- package/package.json +1 -1
- package/src/adapters/claude/index.js +23 -2
- package/src/adapters/codex/hooks.js +39 -3
- package/src/adapters/codex/index.js +13 -0
- package/src/adapters/copy-tree.js +1 -2
- package/src/cli/commands/status.js +1 -1
- package/src/cli/index.js +20 -8
- package/src/cli/runtime-commands.js +12 -4
- package/src/lifecycle/coverage.js +1 -30
- package/src/lifecycle/hook-tier.js +2 -2
- package/src/release/docs-blocks.js +32 -52
- package/src/runtime/context/pointer.js +67 -12
- package/src/runtime/context-pack.js +4 -1
- package/src/runtime/evidence-ledger.js +18 -0
- package/src/runtime/health.js +59 -1
- package/src/runtime/implementation-gate.js +4 -4
- package/src/runtime/intake/engine.js +677 -0
- package/src/runtime/intake/index.js +136 -0
- package/src/runtime/intake/manifest.js +140 -0
- package/src/runtime/intake/taxonomy.js +206 -0
- package/src/runtime/lifecycle/cli.js +73 -5
- package/src/runtime/lifecycle/followup.js +32 -6
- package/src/runtime/lifecycle/overview.js +2 -1
- package/src/runtime/packages/points.js +10 -0
- package/src/runtime/roadmap/attendance.js +137 -0
- package/src/runtime/roadmap/gates.js +6 -3
- package/src/runtime/roadmap/loop.js +113 -30
- package/src/runtime/roadmap/merge.js +17 -8
- package/src/runtime/roadmap/rows.js +13 -4
- package/src/runtime/roadmap/seed.js +153 -41
- package/src/runtime/roadmap/worktree-cleanup.js +147 -0
- package/src/runtime/settings/registry.js +1 -1
- package/src/runtime/slots.js +1 -1
- package/src/runtime/task-classifier.js +39 -4
- package/src/runtime/task-state.js +2 -3
- package/src/runtime/verb-view.js +267 -0
- package/src/runtime/verification/engine.js +0 -45
- package/src/runtime/worker/launch.js +5 -37
- package/src/runtime/worker/readiness.js +2 -2
- package/src/runtime/workflow-orchestrator.js +33 -5
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `intake` verb (136 IC-001): `doflow-run intake --action <sort|preview|undo|list|file-prompt>`. This module
|
|
5
|
+
* resolves the feature folder, runs the engine's action and maps its result to an exit status; it never
|
|
6
|
+
* prompts. Exit 0 = done (a sort with zero moves too), 1 = a finding to act on (a sort or undo that skipped or
|
|
7
|
+
* failed an entry, or an undo with nothing left to undo), 2 = the call cannot proceed (no active feature, an
|
|
8
|
+
* unknown action, no input for `file-prompt`, a manifest from a newer schema, a lock that could not be taken).
|
|
9
|
+
* `--json` prints the engine's object unmodified.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const nodeFs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { resolveActiveFeature } = require('../feature-resolve.js');
|
|
15
|
+
const { finishRuntime, usageError } = require('../cli-result.js');
|
|
16
|
+
const { loadTaxonomy, isPlainText } = require('./taxonomy.js');
|
|
17
|
+
const { IntakeUsageError } = require('./manifest.js');
|
|
18
|
+
const { previewSort, sortFolder, undoRun, listFolder, filePrompt, CONTEXT_DIR } = require('./engine.js');
|
|
19
|
+
|
|
20
|
+
const VALID_ACTIONS = ['sort', 'preview', 'undo', 'list', 'file-prompt'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The bytes of `--file`, opened without blocking and checked on the open descriptor: a regular file (a link to one
|
|
24
|
+
* is followed; a FIFO, a device or a folder is refused, and a FIFO is never waited on) of at most `maxBytes`.
|
|
25
|
+
*/
|
|
26
|
+
function readRegularFile(file , maxBytes , fsImpl ) {
|
|
27
|
+
const fd = fsImpl.openSync(path.resolve(file), nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NONBLOCK || 0));
|
|
28
|
+
try {
|
|
29
|
+
const stat = fsImpl.fstatSync(fd);
|
|
30
|
+
if (!stat.isFile()) throw new IntakeUsageError(`--file ${file} is not a regular file; file-prompt reads only a regular text file`);
|
|
31
|
+
if (stat.size > maxBytes) throw new IntakeUsageError(`--file ${file} is ${stat.size} bytes; file-prompt takes at most ${maxBytes}`);
|
|
32
|
+
return fsImpl.readFileSync(fd);
|
|
33
|
+
} finally {
|
|
34
|
+
fsImpl.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** At most `maxBytes + 1` bytes of stdin: one over the cap is enough to refuse, and a huge stream is never held whole. */
|
|
39
|
+
function readStdinCapped(maxBytes , fsImpl ) {
|
|
40
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
41
|
+
let length = 0;
|
|
42
|
+
while (length < buffer.length) {
|
|
43
|
+
let read ;
|
|
44
|
+
try {
|
|
45
|
+
read = fsImpl.readSync(0, buffer, length, buffer.length - length, null);
|
|
46
|
+
} catch (error ) {
|
|
47
|
+
if (error.code === 'EOF') break; // Windows reports the end of a closed pipe as an error
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (read === 0) break;
|
|
51
|
+
length += read;
|
|
52
|
+
}
|
|
53
|
+
return buffer.subarray(0, length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The prompt text as bytes, unchanged: from `--stdin` or from `--file <path>`. Either way it must be plain text
|
|
58
|
+
* (no NUL byte, valid UTF-8) of at most `maxBytes`, so a binary or a huge file is never filed as a prompt.
|
|
59
|
+
*/
|
|
60
|
+
function readInput({ stdin, file } , fsImpl , maxBytes ) {
|
|
61
|
+
if (!stdin && !file) throw new IntakeUsageError('--action file-prompt needs the text from --stdin or --file <path>');
|
|
62
|
+
if (stdin && file) throw new IntakeUsageError('--stdin and --file cannot be combined');
|
|
63
|
+
const from = stdin ? 'stdin' : `--file ${file}`;
|
|
64
|
+
let bytes ;
|
|
65
|
+
try {
|
|
66
|
+
bytes = stdin ? readStdinCapped(maxBytes, fsImpl) : readRegularFile(file, maxBytes, fsImpl);
|
|
67
|
+
} catch (error ) {
|
|
68
|
+
if (error instanceof IntakeUsageError) throw error;
|
|
69
|
+
throw new IntakeUsageError(`cannot read ${from}: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
if (bytes.length > maxBytes) throw new IntakeUsageError(`${from} is ${stdin ? `over ${maxBytes}` : bytes.length} bytes; file-prompt takes at most ${maxBytes}`);
|
|
72
|
+
if (!isPlainText(bytes)) throw new IntakeUsageError(`${from} is not plain text (it holds a NUL byte or is not valid UTF-8); file-prompt files text only`);
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Runs one action and returns its result without printing.
|
|
78
|
+
* @returns {{exitCode:number, result?:Object, usage?:string}}
|
|
79
|
+
*/
|
|
80
|
+
function runIntake({ action, projectRoot, slug = null, flags = {}, now = new Date(), fsImpl = nodeFs, taxonomyOptions = {} } ) {
|
|
81
|
+
try {
|
|
82
|
+
if (!VALID_ACTIONS.includes(action)) throw new IntakeUsageError(`unknown --action '${action}'. Valid: ${VALID_ACTIONS.join(', ')}`);
|
|
83
|
+
const feature = resolveActiveFeature({ projectRoot, slug });
|
|
84
|
+
if (feature.error) throw new IntakeUsageError(feature.message);
|
|
85
|
+
const loaded = loadTaxonomy({ projectRoot: feature.repoRoot, ...taxonomyOptions, fsImpl });
|
|
86
|
+
if (!loaded.ok) throw new IntakeUsageError(`the intake categories cannot be used (${loaded.reason}): ${loaded.detail}`);
|
|
87
|
+
const common = { contextDir: path.join(feature.featureFolder, CONTEXT_DIR), taxonomy: loaded.taxonomy, now, fsImpl };
|
|
88
|
+
let result ;
|
|
89
|
+
if (action === 'sort') result = sortFolder(common);
|
|
90
|
+
else if (action === 'preview') result = previewSort(common);
|
|
91
|
+
else if (action === 'list') result = listFolder(common);
|
|
92
|
+
else if (action === 'undo') result = undoRun({ ...common, runId: flags.runId || null });
|
|
93
|
+
else result = filePrompt({ ...common, bytes: readInput(flags, fsImpl, loaded.taxonomy.pointerMaxBytes), stage: flags.stage || null });
|
|
94
|
+
const finding = (Array.isArray(result.skipped) && result.skipped.length > 0) || (Array.isArray(result.kept) && result.kept.length > 0) || result.state === 'nothing-to-undo';
|
|
95
|
+
return { exitCode: finding ? 1 : 0, result };
|
|
96
|
+
} catch (error ) {
|
|
97
|
+
if (error instanceof IntakeUsageError) return { exitCode: 2, usage: error.message };
|
|
98
|
+
// Another writer holds the manifest lock: the call cannot proceed now (IC-001), so it is usage, not a finding.
|
|
99
|
+
if (/^Could not lock/.test(error?.message || '')) return { exitCode: 2, usage: error.message };
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function humanLines(result ) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
if (result.state === 'absent') return [`no ${CONTEXT_DIR} folder in this feature; nothing to ${result.action}`];
|
|
107
|
+
if (result.action === 'sort' || result.action === 'preview') {
|
|
108
|
+
for (const move of result.moves) lines.push(`${result.action === 'preview' ? 'would move' : 'moved'} ${move.from} -> ${move.to} [${move.category}]`);
|
|
109
|
+
for (const skip of result.skipped) lines.push(`skipped ${skip.path} (${skip.reason}${skip.detail ? `: ${skip.detail}` : ''})`);
|
|
110
|
+
lines.push(`${result.moves.length} ${result.action === 'preview' ? 'to move' : 'moved'}, ${result.skipped.length} skipped, ${result.unmatched.length} unmatched${result.runId ? `; undo with --action undo --run-id ${result.runId}` : ''}`);
|
|
111
|
+
} else if (result.action === 'undo') {
|
|
112
|
+
if (result.state === 'nothing-to-undo') return ['no run left to undo'];
|
|
113
|
+
for (const move of result.restored) lines.push(`restored ${move.to} -> ${move.from}`);
|
|
114
|
+
for (const skip of result.skipped) lines.push(`skipped ${skip.to} (${skip.reason}${skip.detail ? `: ${skip.detail}` : ''})`);
|
|
115
|
+
for (const move of result.kept) lines.push(`kept ${move.to} (failed move from ${move.from}${move.reason ? `: ${move.reason}` : ''})`);
|
|
116
|
+
for (const dir of result.removedDirs) lines.push(`removed empty folder ${dir}`);
|
|
117
|
+
lines.push(`${result.restored.length} restored, ${result.skipped.length} skipped${result.kept.length ? `, ${result.kept.length} kept` : ''} (${result.runId})`);
|
|
118
|
+
} else if (result.action === 'list') {
|
|
119
|
+
for (const file of result.files) lines.push(`${file.path} ${file.category ?? '-'} ${file.kind} ${file.bytes} bytes ${file.unreadable ? 'unreadable' : file.sha256}${file.pointer ? ' pointer' : ''}`);
|
|
120
|
+
lines.push(`${result.files.length} file${result.files.length === 1 ? '' : 's'}`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(`filed ${result.path} (${result.bytes} bytes, ${result.sha256}; ${result.runId})`);
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `intake` verb. Exit 0 = done, 1 = a finding, 2 = usage. `--json` prints the result object unmodified. */
|
|
128
|
+
function handleIntakeCommand({ action = 'sort', projectRoot, slug = null, json = false, flags = {} } = {}) {
|
|
129
|
+
const run = runIntake({ action, projectRoot: projectRoot || process.cwd(), slug, flags });
|
|
130
|
+
if (run.usage !== undefined) return usageError('intake', run.usage, json);
|
|
131
|
+
if (json) console.log(JSON.stringify(run.result, null, 2));
|
|
132
|
+
else for (const line of humanLines(run.result)) console.log(line);
|
|
133
|
+
return finishRuntime(run.exitCode);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { handleIntakeCommand, runIntake, readInput, humanLines, VALID_ACTIONS };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intake manifest (136 IC-007): `additional-context/.intake/manifest.json`, the one record of every sort
|
|
5
|
+
* and every filed prompt. It is rewritten whole through a temporary file and a rename, under the
|
|
6
|
+
* `acquireLock` lock `task-state.ts` provides for its own files, the way `decision-register.ts` writes its
|
|
7
|
+
* register. The reader refuses what it cannot trust (invalid JSON, a newer schema, a path that leaves the
|
|
8
|
+
* folder) with an `IntakeUsageError` and never rewrites such a file: the manifest is evidence of what moved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const nodeFs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const nodeCrypto = require('node:crypto');
|
|
14
|
+
const { acquireLock } = require('../task-state.js');
|
|
15
|
+
|
|
16
|
+
const MANIFEST_SCHEMA = 1;
|
|
17
|
+
const INTAKE_DIR = '.intake';
|
|
18
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
19
|
+
const STATES = ['planned', 'done', 'failed', 'undone', 'undo-skipped'];
|
|
20
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
21
|
+
|
|
22
|
+
/** A caller mistake or an input the engine refuses to guess at (exit 2). */
|
|
23
|
+
class IntakeUsageError extends Error {}
|
|
24
|
+
|
|
25
|
+
function manifestFile(contextDir ) { return path.join(contextDir, INTAKE_DIR, MANIFEST_FILE); }
|
|
26
|
+
|
|
27
|
+
/** True for a relative path of plain segments: nothing absolute, no `..`, no hidden segment, no backslash. */
|
|
28
|
+
function isConfinedPath(rel ) {
|
|
29
|
+
if (typeof rel !== 'string' || rel === '' || rel.includes('\\') || rel.includes('\0')) return false;
|
|
30
|
+
return rel.split('/').every((segment ) => segment !== '' && !segment.startsWith('.'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refuses a `.intake` that is a link or a plain file: the lock and the manifest would be written wherever it
|
|
35
|
+
* points. An absent `.intake` is fine (it is created as a real folder).
|
|
36
|
+
*/
|
|
37
|
+
function assertIntakeDir(contextDir , fsImpl = nodeFs) {
|
|
38
|
+
const dir = path.join(contextDir, INTAKE_DIR);
|
|
39
|
+
let stat ;
|
|
40
|
+
try { stat = fsImpl.lstatSync(dir); } catch (error ) {
|
|
41
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new IntakeUsageError(`${dir} is not a real folder; intake writes its lock and manifest only inside a real ${INTAKE_DIR} folder. Nothing was written.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function badManifest(file , why ) {
|
|
48
|
+
return new IntakeUsageError(`${file} ${why}; the intake manifest is never rewritten when it cannot be trusted. Restore it or move it aside.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateMove(move , file , where ) {
|
|
52
|
+
const ok = move && typeof move === 'object'
|
|
53
|
+
&& (move.from === null || isConfinedPath(move.from))
|
|
54
|
+
&& isConfinedPath(move.to)
|
|
55
|
+
&& typeof move.sha256 === 'string' && HASH.test(move.sha256)
|
|
56
|
+
&& Number.isInteger(move.bytes) && move.bytes >= 0
|
|
57
|
+
&& typeof move.at === 'string'
|
|
58
|
+
&& STATES.includes(move.state)
|
|
59
|
+
&& (move.reason === null || typeof move.reason === 'string');
|
|
60
|
+
if (!ok) throw badManifest(file, `has a malformed or unconfined move at ${where}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The manifest, or an empty one when none exists. Throws IntakeUsageError for a file it cannot trust. */
|
|
64
|
+
function readManifest(contextDir , fsImpl = nodeFs) {
|
|
65
|
+
assertIntakeDir(contextDir, fsImpl);
|
|
66
|
+
const file = manifestFile(contextDir);
|
|
67
|
+
if (!fsImpl.existsSync(file)) return { schema: MANIFEST_SCHEMA, runs: [] };
|
|
68
|
+
let data ;
|
|
69
|
+
try {
|
|
70
|
+
data = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
71
|
+
} catch (error ) {
|
|
72
|
+
throw badManifest(file, `is not valid JSON (${error.message})`);
|
|
73
|
+
}
|
|
74
|
+
if (!data || typeof data !== 'object' || !Number.isInteger(data.schema) || !Array.isArray(data.runs)) {
|
|
75
|
+
throw badManifest(file, 'is not an intake manifest');
|
|
76
|
+
}
|
|
77
|
+
if (data.schema > MANIFEST_SCHEMA) {
|
|
78
|
+
throw new IntakeUsageError(`${file} is schema ${data.schema} and this DoFlow reads schema ${MANIFEST_SCHEMA}; upgrade DoFlow. The manifest was not touched.`);
|
|
79
|
+
}
|
|
80
|
+
if (data.schema !== MANIFEST_SCHEMA) throw badManifest(file, `has schema ${data.schema}`);
|
|
81
|
+
data.runs.forEach((run , i ) => {
|
|
82
|
+
const ok = run && typeof run === 'object' && typeof run.id === 'string' && ['sort', 'file-prompt'].includes(run.action)
|
|
83
|
+
&& Array.isArray(run.createdDirs) && run.createdDirs.every(isConfinedPath) && Array.isArray(run.moves);
|
|
84
|
+
if (!ok) throw badManifest(file, `has a malformed run at runs[${i}]`);
|
|
85
|
+
run.moves.forEach((move , j ) => validateMove(move, file, `runs[${i}].moves[${j}]`));
|
|
86
|
+
});
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Writes the manifest whole: temp file, then rename, so a reader sees the old file or the new one. `assertHeld`
|
|
92
|
+
* (from `withManifestLock`) is checked between the two: a writer that stalled past the lock's staleness and was
|
|
93
|
+
* reclaimed writes nothing (the way `updateTaskState` does).
|
|
94
|
+
*/
|
|
95
|
+
function writeManifest(contextDir , manifest , fsImpl = nodeFs, assertHeld = () => {}) {
|
|
96
|
+
const file = manifestFile(contextDir);
|
|
97
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
fsImpl.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
101
|
+
assertHeld();
|
|
102
|
+
fsImpl.renameSync(tmp, file);
|
|
103
|
+
} catch (error ) {
|
|
104
|
+
try { fsImpl.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs `fn` holding the manifest's lock. A lock that cannot be taken throws the lock error unchanged. `fn` gets
|
|
111
|
+
* `assertHeld`, which throws an `IntakeUsageError` (exit 2) once the lock was reclaimed by another writer; the
|
|
112
|
+
* engine calls it before every manifest rename and every move of a user's file, so nothing more is written or moved.
|
|
113
|
+
*/
|
|
114
|
+
function withManifestLock (contextDir , fn , fsImpl = nodeFs) {
|
|
115
|
+
assertIntakeDir(contextDir, fsImpl);
|
|
116
|
+
fsImpl.mkdirSync(path.join(contextDir, INTAKE_DIR), { recursive: true });
|
|
117
|
+
// The first check and the mkdir are two calls: a link put at `.intake` in between would make mkdir succeed
|
|
118
|
+
// silently and the lock land wherever it points. Looked at again here. Ceiling accepted: a swap after this
|
|
119
|
+
// look and before the lock is taken is one syscall wide; Node has no openat to close it.
|
|
120
|
+
assertIntakeDir(contextDir, fsImpl);
|
|
121
|
+
const release = acquireLock(fsImpl, manifestFile(contextDir));
|
|
122
|
+
const assertHeld = () => {
|
|
123
|
+
if (typeof release.held === 'function' && !release.held()) {
|
|
124
|
+
throw new IntakeUsageError(`the intake lock on ${manifestFile(contextDir)} was taken over by another writer (this run stalled too long); nothing further was written or moved. Run the command again.`);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
return fn(assertHeld);
|
|
129
|
+
} finally {
|
|
130
|
+
release();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `run-<UTC yyyymmddThhmmssZ>-<4 hex>`. */
|
|
135
|
+
function newRunId(now ) {
|
|
136
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
137
|
+
return `run-${stamp}-${nodeCrypto.randomBytes(2).toString('hex')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { readManifest, writeManifest, withManifestLock, newRunId, manifestFile, isConfinedPath, assertIntakeDir, IntakeUsageError, MANIFEST_SCHEMA, INTAKE_DIR };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intake taxonomy (136 IC-005): `core/registry/intake.json` read on its own, not through `loadRegistry`,
|
|
5
|
+
* so a bad file turns intake off with a reason and never fails an installer command (the transcripts pattern,
|
|
6
|
+
* `src/runtime/transcripts/registry.js`). `validateTaxonomy` checks the closed shape and the id and name
|
|
7
|
+
* uniqueness; `loadTaxonomy` returns the core categories in file order, then the valid `intake-category`
|
|
8
|
+
* contributions of active packages (IC-006). A rule matches on a file's name and extension only, never on its content.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
14
|
+
|
|
15
|
+
const INTAKE_FILE = 'core/registry/intake.json';
|
|
16
|
+
|
|
17
|
+
const TOP_KEYS = ['version', 'description', 'recentWindowSeconds', 'pointerMaxBytes', 'textExtensions', 'promptCategory', 'categories'];
|
|
18
|
+
const CATEGORY_KEYS = ['id', 'names', 'summary', 'rules'];
|
|
19
|
+
const RULE_KEYS = ['extensions', 'namePattern', 'into', 'companionOf'];
|
|
20
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
21
|
+
const EXTENSION = /^[a-z0-9]+$/;
|
|
22
|
+
const NAME_MAX = 40;
|
|
23
|
+
const INTO_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
24
|
+
|
|
25
|
+
function isObject(value ) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unknownKeys(value , allowed , where , errors ) {
|
|
30
|
+
for (const key of Object.keys(value)) {
|
|
31
|
+
if (!allowed.includes(key)) errors.push(`${where}.${key} is not an allowed key (allowed: ${allowed.join(', ')})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extensionList(value , where , errors , { required } ) {
|
|
36
|
+
if (!Array.isArray(value) || (required && value.length === 0)) {
|
|
37
|
+
errors.push(`${where} must be ${required ? 'a non-empty' : 'an'} array of lower-case extensions without a dot`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const ext of value) {
|
|
41
|
+
if (typeof ext !== 'string' || !EXTENSION.test(ext)) errors.push(`${where} entry ${JSON.stringify(ext)} must be a lower-case extension without a dot`);
|
|
42
|
+
}
|
|
43
|
+
if (new Set(value).size !== value.length) errors.push(`${where} names an extension twice`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateRule(rule , where , errors ) {
|
|
47
|
+
if (!isObject(rule)) { errors.push(`${where} must be an object`); return; }
|
|
48
|
+
unknownKeys(rule, RULE_KEYS, where, errors);
|
|
49
|
+
extensionList(rule.extensions, `${where}.extensions`, errors, { required: true });
|
|
50
|
+
if (Object.hasOwn(rule, 'namePattern')) {
|
|
51
|
+
if (typeof rule.namePattern !== 'string' || rule.namePattern === '') {
|
|
52
|
+
errors.push(`${where}.namePattern must be a non-empty regular expression string`);
|
|
53
|
+
} else {
|
|
54
|
+
try { new RegExp(rule.namePattern, 'i'); } catch (error ) { errors.push(`${where}.namePattern does not compile (${error.message})`); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (Object.hasOwn(rule, 'into')) {
|
|
58
|
+
const into = rule.into;
|
|
59
|
+
const segments = typeof into === 'string' ? into.split('/') : [];
|
|
60
|
+
if (typeof into !== 'string' || segments.some((segment ) => !INTO_SEGMENT.test(segment) || segment === '..')) {
|
|
61
|
+
errors.push(`${where}.into must be a relative sub-path of plain names, with no '..', no leading dot and no empty segment`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (Object.hasOwn(rule, 'companionOf')) {
|
|
65
|
+
extensionList(rule.companionOf, `${where}.companionOf`, errors, { required: true });
|
|
66
|
+
if (Object.hasOwn(rule, 'into')) errors.push(`${where} has companionOf, so it follows its companion and takes no into`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The shape errors of one category (IC-005, and IC-006's contributed entry): closed keys, a lower-kebab `id`
|
|
72
|
+
* and `names`, a summary, and valid rules. Uniqueness across categories is the caller's, who sees them all.
|
|
73
|
+
*/
|
|
74
|
+
function validateCategory(category , where ) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (!isObject(category)) return [`${where} must be an object`];
|
|
77
|
+
unknownKeys(category, CATEGORY_KEYS, where, errors);
|
|
78
|
+
for (const key of CATEGORY_KEYS) {
|
|
79
|
+
if (!Object.hasOwn(category, key)) errors.push(`${where}.${key} is missing`);
|
|
80
|
+
}
|
|
81
|
+
const kebab = (value ) => typeof value === 'string' && value.length <= NAME_MAX && KEBAB.test(value);
|
|
82
|
+
if (Object.hasOwn(category, 'id') && !kebab(category.id)) errors.push(`${where}.id must be lower-kebab, at most ${NAME_MAX} characters`);
|
|
83
|
+
if (Object.hasOwn(category, 'names')) {
|
|
84
|
+
if (!Array.isArray(category.names) || !category.names.every(kebab)) errors.push(`${where}.names must be an array of lower-kebab names, at most ${NAME_MAX} characters each`);
|
|
85
|
+
}
|
|
86
|
+
if (Object.hasOwn(category, 'summary') && (typeof category.summary !== 'string' || category.summary.trim() === '')) {
|
|
87
|
+
errors.push(`${where}.summary must be a non-empty string`);
|
|
88
|
+
}
|
|
89
|
+
if (Object.hasOwn(category, 'rules')) {
|
|
90
|
+
if (!Array.isArray(category.rules)) errors.push(`${where}.rules must be an array`);
|
|
91
|
+
else category.rules.forEach((rule , i ) => validateRule(rule, `${where}.rules[${i}]`, errors));
|
|
92
|
+
}
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Checks the registry's shape, then that every id and name is used once across all categories. */
|
|
97
|
+
function validateTaxonomy(registry ) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
if (!isObject(registry)) return { ok: false, errors: ['registry must be an object'] };
|
|
100
|
+
unknownKeys(registry, TOP_KEYS, 'registry', errors);
|
|
101
|
+
for (const key of TOP_KEYS) {
|
|
102
|
+
if (!Object.hasOwn(registry, key)) errors.push(`registry.${key} is missing`);
|
|
103
|
+
}
|
|
104
|
+
if (Object.hasOwn(registry, 'version') && registry.version !== 1) errors.push('version must be 1');
|
|
105
|
+
if (Object.hasOwn(registry, 'description') && (typeof registry.description !== 'string' || registry.description.trim() === '')) {
|
|
106
|
+
errors.push('description must be a non-empty string');
|
|
107
|
+
}
|
|
108
|
+
if (Object.hasOwn(registry, 'recentWindowSeconds') && !(Number.isInteger(registry.recentWindowSeconds) && registry.recentWindowSeconds >= 0)) {
|
|
109
|
+
errors.push('recentWindowSeconds must be an integer of 0 or more');
|
|
110
|
+
}
|
|
111
|
+
if (Object.hasOwn(registry, 'pointerMaxBytes') && !(Number.isInteger(registry.pointerMaxBytes) && registry.pointerMaxBytes > 0)) {
|
|
112
|
+
errors.push('pointerMaxBytes must be a positive integer');
|
|
113
|
+
}
|
|
114
|
+
if (Object.hasOwn(registry, 'textExtensions')) extensionList(registry.textExtensions, 'textExtensions', errors, { required: true });
|
|
115
|
+
if (!Array.isArray(registry.categories) || registry.categories.length === 0) {
|
|
116
|
+
errors.push('categories must be a non-empty array');
|
|
117
|
+
return { ok: false, errors };
|
|
118
|
+
}
|
|
119
|
+
registry.categories.forEach((category , i ) => errors.push(...validateCategory(category, `categories[${i}]`)));
|
|
120
|
+
const owner = new Map ();
|
|
121
|
+
const claim = (name , id ) => {
|
|
122
|
+
if (typeof name !== 'string') return;
|
|
123
|
+
if (owner.has(name)) errors.push(`'${name}' is used by both '${owner.get(name)}' and '${id}'; ids and names are unique across categories`);
|
|
124
|
+
else owner.set(name, String(id));
|
|
125
|
+
};
|
|
126
|
+
for (const category of registry.categories) {
|
|
127
|
+
if (!isObject(category)) continue;
|
|
128
|
+
claim(category.id, category.id);
|
|
129
|
+
if (Array.isArray(category.names)) for (const name of category.names) claim(name, category.id);
|
|
130
|
+
}
|
|
131
|
+
const prompt = registry.categories.find((category ) => isObject(category) && category.id === registry.promptCategory);
|
|
132
|
+
if (!prompt) errors.push(`promptCategory '${registry.promptCategory}' is not a category id`);
|
|
133
|
+
else if (Array.isArray(prompt.rules) && prompt.rules.length > 0) errors.push(`the prompt category '${prompt.id}' must have no rules`);
|
|
134
|
+
return { ok: errors.length === 0, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns `{ ok: true, taxonomy }` with the core categories in file order, each marked `origin: 'core'`, followed by
|
|
139
|
+
* the categories active packages contribute (IC-006), or `{ ok: false, reason: 'config-invalid', detail }` when the
|
|
140
|
+
* core file cannot be read or fails its shape. A contribution is tried after every core category, in ascending
|
|
141
|
+
* qualified-id order; its `id` becomes the qualified id, which is also its folder name. One whose id or names
|
|
142
|
+
* collide with a core category or an earlier contribution is left out and named in `problems`. The core list never
|
|
143
|
+
* depends on the package view: `contributionsFor` yields nothing when the point registry fails to load.
|
|
144
|
+
* `contributions: false` skips the package view. `view`, `homeDir`, `env`, `projectRoot` and `tmpDir` reach it.
|
|
145
|
+
*/
|
|
146
|
+
function loadTaxonomy({ repoRoot = REPO_ROOT, file, fsImpl = fs, contributions = true, problems, view, homeDir, env, projectRoot, tmpDir } = {}) {
|
|
147
|
+
const target = file || path.join(path.resolve(repoRoot), INTAKE_FILE);
|
|
148
|
+
let registry ;
|
|
149
|
+
try {
|
|
150
|
+
registry = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
|
|
151
|
+
} catch (error ) {
|
|
152
|
+
return { ok: false, reason: 'config-invalid', detail: `${target}: ${error.message}` };
|
|
153
|
+
}
|
|
154
|
+
const validation = validateTaxonomy(registry);
|
|
155
|
+
if (!validation.ok) return { ok: false, reason: 'config-invalid', detail: validation.errors.join('; ') };
|
|
156
|
+
const categories = registry.categories.map((category ) => ({ ...category, origin: 'core' }));
|
|
157
|
+
if (contributions) {
|
|
158
|
+
const used = new Set (categories.flatMap((category ) => [category.id, ...category.names]));
|
|
159
|
+
const { contributionsFor } = require('../packages/contributions.js');
|
|
160
|
+
for (const contribution of contributionsFor('intake-category', 1, { repoRoot, fsImpl, view, homeDir, env, projectRoot, tmpDir, problems })) {
|
|
161
|
+
const id = contribution.qualifiedId;
|
|
162
|
+
const clash = [id, ...contribution.data.names].find((name ) => used.has(name));
|
|
163
|
+
if (clash !== undefined) {
|
|
164
|
+
problems?.push(`intake-category contribution '${id}' is left out: '${clash}' is already the id or a name of another category`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const name of [id, ...contribution.data.names]) used.add(name);
|
|
168
|
+
categories.push({ ...contribution.data, id, origin: id });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, taxonomy: { ...registry, categories } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The folder a category files into: the first of its id and known names that is already a folder in the
|
|
176
|
+
* context folder, else its id (FR-003, IC-005). `existing` is the set of folder names found there.
|
|
177
|
+
*/
|
|
178
|
+
function categoryFolder(category , existing ) {
|
|
179
|
+
return [category.id, ...category.names].find((name ) => existing.has(name)) ?? category.id;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True for bytes a text file can hold: no NUL byte and valid UTF-8. A binary saved as `.md` fails it. */
|
|
183
|
+
function isPlainText(buffer ) {
|
|
184
|
+
if (buffer.includes(0)) return false;
|
|
185
|
+
try {
|
|
186
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* True for a `/`-separated path inside a feature's user-context folder: `agent-docs/doflow/<slug>/additional-context/`
|
|
195
|
+
* anywhere in it (a main checkout, a linked worktree or a relative path between them). A folder of that name
|
|
196
|
+
* elsewhere in a project (`src/additional-context/`) is not the user's. The path is normalised first (a backslash
|
|
197
|
+
* becomes `/`, then `.`, `//` and `a/..` are resolved), so `x/./additional-context/` and `x/y/../additional-context/`
|
|
198
|
+
* are read as what they name and `additional-context/../../a.ts` as `a.ts`. A `..` left after that is only a leading
|
|
199
|
+
* one, the relative path between two checkouts, which the rule above keeps.
|
|
200
|
+
*/
|
|
201
|
+
function isUserContextPath(file ) {
|
|
202
|
+
const parts = path.posix.normalize(file.replace(/\\/g, '/')).split('/');
|
|
203
|
+
return parts.some((part, i) => part === 'agent-docs' && parts[i + 1] === 'doflow' && Boolean(parts[i + 2]) && parts[i + 3] === 'additional-context');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { isUserContextPath, isPlainText, loadTaxonomy, validateTaxonomy, validateCategory, categoryFolder, INTAKE_FILE };
|
|
@@ -19,10 +19,36 @@ const { buildOverview, initFeature, featureStatus, activeFeature } = require('./
|
|
|
19
19
|
const { recordFeature, clearSlug } = require('../feature-pointer.js');
|
|
20
20
|
const { releaseFeatures, recordMerged } = require('./release.js');
|
|
21
21
|
const goal = require('./goal.js');
|
|
22
|
+
const { ViewUsageError, resolveView, projectBrief, viewLines, withEnvelope, serializeJson } = require('../verb-view.js');
|
|
22
23
|
|
|
23
24
|
const FOLLOWUP_ACTIONS = ['add', 'list', 'take', 'settle', 'promote', 'report'];
|
|
24
25
|
const LIFECYCLE_ACTIONS = ['overview', 'init', 'release', 'status', 'merged'];
|
|
25
26
|
const GOAL_ACTIONS = ['add', 'item', 'check', 'link', 'list', 'done'];
|
|
27
|
+
/** The brief `followup --action list` answer (feature 137, IC-004): the fields that let a caller choose an item. */
|
|
28
|
+
const FOLLOWUP_LIST_BRIEF = Object.freeze({
|
|
29
|
+
verb: 'followup',
|
|
30
|
+
action: 'list',
|
|
31
|
+
carry: Object.freeze(['state', 'ids', 'limit']),
|
|
32
|
+
keep: Object.freeze([
|
|
33
|
+
'$.ok', '$.action', '$.state', '$.count', '$.items[].id', '$.items[].state', '$.items[].statement',
|
|
34
|
+
'$.conflicts', '$.unreadable', '$.unreadableReasons',
|
|
35
|
+
]),
|
|
36
|
+
});
|
|
37
|
+
/** The brief `lifecycle --action overview` answer (feature 137, IC-006): keep paths, so a new full field is dropped until named here. */
|
|
38
|
+
const OVERVIEW_BRIEF = Object.freeze({
|
|
39
|
+
verb: 'lifecycle',
|
|
40
|
+
action: 'overview',
|
|
41
|
+
carry: Object.freeze(['maintain', 'since']),
|
|
42
|
+
keep: Object.freeze([
|
|
43
|
+
'$.ok', '$.mode', '$.releaseMode', '$.integrationRef', '$.gitError', '$.followups.open', '$.followups.shown',
|
|
44
|
+
'$.followups.items[].id', '$.followups.items[].statement', '$.followups.items[].source', '$.followups.items[].pending',
|
|
45
|
+
'$.intents', '$.goals[].goal', '$.goals[].outcome', '$.goals[].items', '$.goals[].proposeDone', '$.goals[].nudges',
|
|
46
|
+
'$.goals[].conflicts', '$.conflicts', '$.unreadable', '$.unreadableReasons', '$.failures', '$.reason', '$.note',
|
|
47
|
+
'$.integrationBehind', '$.pending', '$.next',
|
|
48
|
+
]),
|
|
49
|
+
});
|
|
50
|
+
/** A brief list answers one page of this many items unless `--limit` says otherwise. */
|
|
51
|
+
const BRIEF_PAGE = 10;
|
|
26
52
|
/** The flags each goal action takes, besides --goal, --channel and --json; any other one given is a usage error. */
|
|
27
53
|
const GOAL_FLAGS = {
|
|
28
54
|
add: ['statement', 'item'], item: ['text'], check: ['item', 'evidence', 'unmet'],
|
|
@@ -40,6 +66,7 @@ function followupLines(result ) {
|
|
|
40
66
|
case 'list':
|
|
41
67
|
if (result.items.length === 0) lines.push(`No ${result.state === 'all' ? '' : `${result.state} `}follow-ups.`);
|
|
42
68
|
for (const i of result.items) lines.push(`${i.id} ${i.state}${i.takenBy ? ` by ${i.takenBy}` : ''}${i.intent ? ` -> ${i.intent}` : ''} (${sourceText(i.source)}, ${i.added}) ${i.statement}`);
|
|
69
|
+
for (const line of result.next) lines.push(`next: ${line}`);
|
|
43
70
|
break;
|
|
44
71
|
case 'take': lines.push(`${result.feature} took ${result.ids.join(', ')}`); break;
|
|
45
72
|
case 'settle': lines.push(`settled ${result.ids.join(', ')} as ${result.as}`); break;
|
|
@@ -129,7 +156,7 @@ function goalLines(r ) {
|
|
|
129
156
|
|
|
130
157
|
/** Prints a result and sets the exit status; shared by the verbs. */
|
|
131
158
|
function emit(result , json , lines ) {
|
|
132
|
-
if (json) console.log(
|
|
159
|
+
if (json) console.log(serializeJson(result));
|
|
133
160
|
else if (result.ok === false) console.log(printSafe(`${result.finding}: ${result.message}`));
|
|
134
161
|
else for (const line of lines(result)) console.log(printSafe(line));
|
|
135
162
|
return finishRuntime(result.ok === false ? 1 : 0);
|
|
@@ -140,7 +167,7 @@ function guarded(verb , action , json , fn ) {
|
|
|
140
167
|
try {
|
|
141
168
|
return fn();
|
|
142
169
|
} catch (error ) {
|
|
143
|
-
if (error instanceof followup.FollowupUsageError || error instanceof StoreUnsafeError) return usageError(verb, error.message, json);
|
|
170
|
+
if (error instanceof followup.FollowupUsageError || error instanceof ViewUsageError || error instanceof StoreUnsafeError) return usageError(verb, error.message, json);
|
|
144
171
|
if (error instanceof StoreLockedError) return emit({ ok: false, action, finding: 'store-locked', message: error.message }, json, () => []);
|
|
145
172
|
throw error;
|
|
146
173
|
}
|
|
@@ -157,6 +184,29 @@ function refuseGlobal(verb , global , json ) {
|
|
|
157
184
|
return global ? usageError(verb, `${verb} works on the project store at the repository root and refuses -g`, json) : null;
|
|
158
185
|
}
|
|
159
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Prints a `followup --action list` answer in the view it asked for (IC-004). Brief projects the page onto the keep
|
|
189
|
+
* list; a full call with no cursor and no limit is the service object, untouched. Every other answer carries the
|
|
190
|
+
* envelope and the next lines. Text mode shows the page's items in the usual format and the next lines only when
|
|
191
|
+
* the page is not the whole list.
|
|
192
|
+
*/
|
|
193
|
+
function emitList(result , view , flags , json ) {
|
|
194
|
+
if (result.ok === false) return emit(result, json, followupLines);
|
|
195
|
+
const paged = result.shown !== undefined;
|
|
196
|
+
const carried = { state: flags.state, ids: flags.ids, limit: flags.limit };
|
|
197
|
+
const lines = !paged ? [] : [
|
|
198
|
+
...viewLines('page', { spec: FOLLOWUP_LIST_BRIEF, carried, nextCursor: result.nextCursor, view }),
|
|
199
|
+
...(view === 'brief' ? viewLines('page-full', { spec: FOLLOWUP_LIST_BRIEF, carried, cursor: flags.cursor, shown: result.shown, json: Boolean(json) }) : []),
|
|
200
|
+
];
|
|
201
|
+
if (!json) return emit({ ...result, next: result.shown < result.count ? lines : [] }, false, followupLines);
|
|
202
|
+
if (view === 'brief') {
|
|
203
|
+
const body = projectBrief(result, FOLLOWUP_LIST_BRIEF.keep);
|
|
204
|
+
return emit(withEnvelope(body, { view, shown: result.shown, nextCursor: result.nextCursor, next: lines }), true, followupLines);
|
|
205
|
+
}
|
|
206
|
+
if (!paged) return emit(result, true, followupLines);
|
|
207
|
+
return emit(withEnvelope(result, { view, shown: result.shown, nextCursor: result.nextCursor, next: lines }), true, followupLines);
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
/**
|
|
161
211
|
* `followup` verb. Actions: add, list, take, settle, promote, report.
|
|
162
212
|
* @param {Object} options
|
|
@@ -175,6 +225,12 @@ function handleFollowupCommand({ action, cwd, global = false, slug = null, json
|
|
|
175
225
|
if (flags[name] !== undefined && flags[name] !== false) throw new followup.FollowupUsageError(`--${name} applies to --action report only`);
|
|
176
226
|
}
|
|
177
227
|
}
|
|
228
|
+
if (action !== 'list') {
|
|
229
|
+
for (const name of ['view', 'cursor', 'limit']) {
|
|
230
|
+
if (flags[name] !== undefined && flags[name] !== false) throw new followup.FollowupUsageError(`--${name} applies to --action list only`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const view = action === 'list' ? resolveView(flags.view, 'brief') : null;
|
|
178
234
|
for (const name of ['tag', 'confirm', 'exclude']) {
|
|
179
235
|
if (flags[name] !== undefined && flags[name] !== false) throw new followup.FollowupUsageError(`--${name} applies to the lifecycle verb's --action release only, not to followup`);
|
|
180
236
|
}
|
|
@@ -198,7 +254,9 @@ function handleFollowupCommand({ action, cwd, global = false, slug = null, json
|
|
|
198
254
|
defaults: { stage: flags.stage, slug, source: flags.source, taskClass: flags.taskClass, taskId: flags.taskId, release: flags.release, batch },
|
|
199
255
|
});
|
|
200
256
|
} else if (action === 'list') {
|
|
201
|
-
|
|
257
|
+
return emitList(followup.listFollowups({
|
|
258
|
+
root, state: flags.state, ids: flags.ids, cursor: flags.cursor, limit: flags.limit, defaultLimit: view === 'brief' ? BRIEF_PAGE : null,
|
|
259
|
+
}), view, flags, json);
|
|
202
260
|
} else if (action === 'take') {
|
|
203
261
|
result = followup.takeFollowups({ root, ids: flags.ids, slug, channel: flags.channel });
|
|
204
262
|
} else if (action === 'settle') {
|
|
@@ -230,13 +288,23 @@ function handleLifecycleCommand({ action, cwd, global = false, slug = null, json
|
|
|
230
288
|
if (flags[name] !== undefined && flags[name] !== false) throw new followup.FollowupUsageError(`--${name} applies to --action release only`);
|
|
231
289
|
}
|
|
232
290
|
}
|
|
291
|
+
if (act !== 'overview' && flags.view !== undefined && flags.view !== false) throw new followup.FollowupUsageError('--view applies to --action overview only');
|
|
292
|
+
const view = act === 'overview' ? resolveView(flags.view, 'full') : null;
|
|
233
293
|
if (flags.reason !== undefined && act !== 'merged') throw new followup.FollowupUsageError('--reason applies to --action merged only');
|
|
234
294
|
const root = projectRoot(cwd || process.cwd());
|
|
235
295
|
const refusedStore = upkeep('lifecycle', act, root);
|
|
236
296
|
if (refusedStore) return emit(refusedStore, json, lifecycleLines);
|
|
237
297
|
let result;
|
|
238
|
-
if (act === 'overview')
|
|
239
|
-
|
|
298
|
+
if (act === 'overview') {
|
|
299
|
+
result = buildOverview({ root, maintain: Boolean(flags.maintain), since: flags.since });
|
|
300
|
+
// The text answer is the same in both views (IC-001); only the JSON brief is cut to one page of items. Maintain
|
|
301
|
+
// keeps its 50: that loop settles every pending item. The next lines name the paged list and carry no full-view line.
|
|
302
|
+
if (view === 'brief' && json) {
|
|
303
|
+
const items = flags.maintain ? result.followups.items : result.followups.items.slice(0, BRIEF_PAGE);
|
|
304
|
+
const brief = { ...result, followups: { ...result.followups, shown: items.length, items } };
|
|
305
|
+
return emit(withEnvelope(projectBrief(brief, OVERVIEW_BRIEF.keep), { view }), true, lifecycleLines);
|
|
306
|
+
}
|
|
307
|
+
} else if (act === 'init') {
|
|
240
308
|
result = initFeature({ root, slug, take: flags.take, intent: flags.intent, goal: flags.goal });
|
|
241
309
|
if (result.ok) recordFeature({ storeRoot: root, cwd: cwd || process.cwd(), slug });
|
|
242
310
|
}
|