@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.
Files changed (113) hide show
  1. package/README.md +4 -5
  2. package/bin/doflow.js +1 -1
  3. package/core/.claude-plugin/plugin.json +1 -1
  4. package/core/.codex-plugin/plugin.json +1 -1
  5. package/core/.plugin/plugin.json +1 -1
  6. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
  7. package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
  8. package/core/harnesses/claude/settings/settings.json +8 -9
  9. package/core/harnesses/codex/hooks/hooks.json +0 -10
  10. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
  11. package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
  12. package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
  13. package/core/harnesses/copilot/hooks/doflow.json +0 -6
  14. package/core/harnesses/kiro/agents/doflow.json +0 -10
  15. package/core/harnesses/kiro/hooks/doflow.json +0 -10
  16. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
  17. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
  18. package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
  19. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
  20. package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
  21. package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
  22. package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
  23. package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
  24. package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
  25. package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
  26. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
  27. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
  28. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
  29. package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
  30. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +1 -1
  31. package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
  32. package/core/registry/contracts.json +1 -1
  33. package/core/registry/contribution-points.json +5 -2
  34. package/core/registry/gates.json +5 -5
  35. package/core/registry/harnesses.json +9 -9
  36. package/core/registry/intake.json +53 -0
  37. package/core/registry/lifecycle.json +133 -711
  38. package/core/registry/package-templates/intake-category/files/category.json +8 -0
  39. package/core/registry/package-templates/intake-category/template.json +4 -0
  40. package/core/registry/packages/doflow/doflow-package.json +2 -2
  41. package/core/registry/rules.json +8 -8
  42. package/core/registry/settings.json +0 -4
  43. package/core/registry/storage.json +8 -0
  44. package/core/registry/telemetry.json +1 -1
  45. package/core/registry/verification.json +0 -5
  46. package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
  47. package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
  48. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
  49. package/core/shared/guidance/rules/RULES.md +2 -2
  50. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
  51. package/core/shared/scripts/doflow/bin/doflow-run +5 -2
  52. package/core/shared/skills/do/SKILL.md +3 -3
  53. package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
  54. package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
  55. package/core/shared/skills/do-code-review/SKILL.md +7 -4
  56. package/core/shared/skills/do-design/SKILL.md +5 -3
  57. package/core/shared/skills/do-diagnose/SKILL.md +4 -2
  58. package/core/shared/skills/do-document/SKILL.md +1 -0
  59. package/core/shared/skills/do-document/references/embedded-research.md +1 -1
  60. package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
  61. package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
  62. package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
  63. package/core/shared/skills/do-flow/SKILL.md +4 -4
  64. package/core/shared/skills/do-flow/references/task_classes.md +5 -5
  65. package/core/shared/skills/do-implement/SKILL.md +9 -7
  66. package/core/shared/skills/do-plan/SKILL.md +9 -6
  67. package/core/shared/workflows/do-flow-chain.md +2 -0
  68. package/package.json +1 -1
  69. package/src/adapters/claude/index.js +23 -2
  70. package/src/adapters/codex/hooks.js +39 -3
  71. package/src/adapters/codex/index.js +13 -0
  72. package/src/adapters/copy-tree.js +1 -2
  73. package/src/cli/commands/status.js +1 -1
  74. package/src/cli/index.js +20 -8
  75. package/src/cli/runtime-commands.js +12 -4
  76. package/src/lifecycle/coverage.js +1 -30
  77. package/src/lifecycle/hook-tier.js +2 -2
  78. package/src/release/docs-blocks.js +32 -52
  79. package/src/runtime/context/pointer.js +67 -12
  80. package/src/runtime/context-pack.js +4 -1
  81. package/src/runtime/evidence-ledger.js +18 -0
  82. package/src/runtime/health.js +59 -1
  83. package/src/runtime/implementation-gate.js +4 -4
  84. package/src/runtime/intake/engine.js +677 -0
  85. package/src/runtime/intake/index.js +136 -0
  86. package/src/runtime/intake/manifest.js +140 -0
  87. package/src/runtime/intake/taxonomy.js +206 -0
  88. package/src/runtime/lifecycle/cli.js +73 -5
  89. package/src/runtime/lifecycle/followup.js +32 -6
  90. package/src/runtime/lifecycle/overview.js +2 -1
  91. package/src/runtime/packages/points.js +10 -0
  92. package/src/runtime/roadmap/attendance.js +137 -0
  93. package/src/runtime/roadmap/gates.js +6 -3
  94. package/src/runtime/roadmap/loop.js +113 -30
  95. package/src/runtime/roadmap/merge.js +17 -8
  96. package/src/runtime/roadmap/rows.js +13 -4
  97. package/src/runtime/roadmap/seed.js +153 -41
  98. package/src/runtime/roadmap/worktree-cleanup.js +147 -0
  99. package/src/runtime/settings/registry.js +1 -1
  100. package/src/runtime/slots.js +1 -1
  101. package/src/runtime/task-classifier.js +39 -4
  102. package/src/runtime/task-state.js +2 -3
  103. package/src/runtime/verb-view.js +267 -0
  104. package/src/runtime/verification/engine.js +0 -45
  105. package/src/runtime/worker/launch.js +5 -37
  106. package/src/runtime/worker/readiness.js +2 -2
  107. package/src/runtime/workflow-orchestrator.js +33 -5
  108. package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
  109. package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
  110. package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
  111. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
  112. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
  113. 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(JSON.stringify(result, null, 2));
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
- result = followup.listFollowups({ root, state: flags.state });
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') result = buildOverview({ root, maintain: Boolean(flags.maintain), since: flags.since });
239
- else if (act === 'init') {
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
  }