@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
@@ -20,7 +20,6 @@ const os = require('node:os');
20
20
  const path = require('node:path');
21
21
  const { spawnSync } = require('node:child_process');
22
22
  const { REPO_ROOT } = require('../helper/repo-root.js');
23
- const { renderEnforcementMatrix, renderBehaviourMatrix } = require('../lifecycle/coverage.js');
24
23
  const { loadSettingsRegistry } = require('../runtime/settings/registry.js');
25
24
  const { REF_META } = require('../runtime/settings/refs.js');
26
25
 
@@ -36,13 +35,6 @@ const DOFLOW_RUN_BIN = 'core/shared/scripts/doflow/bin/doflow-run';
36
35
 
37
36
  const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
38
37
 
39
- // Display labels for capability keys; a key without a label is shown capitalized, so a capability
40
- // added to the registry still gets its row.
41
- const CAPABILITY_LABELS = {
42
- instructions: 'Instructions', skills: 'Skills', agents: 'Agents', scripts: 'Scripts', templates: 'Templates',
43
- modes: 'Modes', settings: 'Settings', hooks: 'Hooks', mcp: 'MCP', plugin: 'Plugin / extension',
44
- };
45
-
46
38
 
47
39
 
48
40
 
@@ -94,38 +86,8 @@ function table(headers , rows ) {
94
86
  ].join('\n');
95
87
  }
96
88
 
97
- /** A copy of registry data with every string made inert, for renderers that format cells themselves. */
98
- function inert(value ) {
99
- if (typeof value === 'string') return value === '' ? value : text(value);
100
- if (Array.isArray(value)) return value.map(inert);
101
- if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, inert(entry)]));
102
- return value;
103
- }
104
-
105
- function capitalize(text ) {
106
- return text ? text[0].toUpperCase() + text.slice(1) : text;
107
- }
108
-
109
89
  // ── Renderers (one pure function of the root per block id) ──────────────────────────────────────
110
90
 
111
- function renderCapabilityMatrix(root ) {
112
- const harnesses = readJson(root, 'core/registry/harnesses.json').harnesses;
113
- if (!Array.isArray(harnesses) || harnesses.length === 0) throw new Error('harnesses.json has no harnesses');
114
- const keys = Object.keys(harnesses[0].capabilities || {});
115
- if (keys.length === 0) throw new Error('harnesses.json declares no capabilities');
116
- const status = (harness , key ) => {
117
- const value = harness.capabilities?.[key]?.status;
118
- if (typeof value !== 'string' || !value) return '—';
119
- const target = harness.nativeTargets?.[key];
120
- const label = text(capitalize(value));
121
- return target && (value === 'supported' || value === 'different') ? `${label} — ${code(target)}` : label;
122
- };
123
- return table(
124
- ['Capability', ...harnesses.map((harness ) => oneLine(harness.displayName))],
125
- keys.map((key) => [text(CAPABILITY_LABELS[key] || capitalize(key)), ...harnesses.map((harness ) => status(harness, key))]),
126
- );
127
- }
128
-
129
91
  function renderTaskClasses(root ) {
130
92
  const classes = readJson(root, 'core/registry/workflows.json').classes;
131
93
  const templates = new Set(Object.keys(readJson(root, 'core/registry/readiness-templates.json').templates || {}));
@@ -287,13 +249,31 @@ function renderCommandIndex(root ) {
287
249
  ].join('\n');
288
250
  }
289
251
 
252
+ /** One rule as a short phrase: its extensions, a name filter, and where it files or what it follows. */
253
+ function ruleText(rule ) {
254
+ const parts = [rule.extensions.map((ext ) => `.${ext}`).join(' ')];
255
+ if (rule.namePattern) parts.push(`name matches ${rule.namePattern}`);
256
+ if (rule.companionOf) parts.push(`beside a ${rule.companionOf.map((ext ) => `.${ext}`).join(' ')} file`);
257
+ if (rule.into) parts.push(`into ${rule.into}/`);
258
+ return parts.join(', ');
259
+ }
260
+
261
+ function renderIntakeCategories(root ) {
262
+ const categories = readJson(root, 'core/registry/intake.json').categories;
263
+ if (!Array.isArray(categories) || categories.length === 0) throw new Error('intake.json has no categories');
264
+ return table(['Folder', 'Also accepted as', 'Takes', 'Purpose'], categories.map((category ) => [
265
+ code(category.id),
266
+ text(category.names.join(', ')),
267
+ text(category.rules.map(ruleText).join('; ')),
268
+ text(category.summary),
269
+ ]));
270
+ }
271
+
290
272
  const BLOCKS = Object.freeze([
291
- { id: 'capability-matrix', page: 'capability-map.md', sources: ['core/registry/harnesses.json'], render: renderCapabilityMatrix },
292
- { id: 'enforcement-matrix', page: 'capability-map.md', sources: ['core/registry/lifecycle.json'], render: (root ) => renderEnforcementMatrix(inert(readJson(root, 'core/registry/lifecycle.json').coverage)) },
293
- { id: 'context-behaviour-matrix', page: 'capability-map.md', sources: ['core/registry/lifecycle.json'], render: (root ) => renderBehaviourMatrix(inert(readJson(root, 'core/registry/lifecycle.json').coverage)) },
294
273
  { id: 'task-classes', page: 'how-doflow-work.md', sources: ['core/registry/workflows.json', 'core/registry/readiness-templates.json'], render: renderTaskClasses },
295
274
  { id: 'settings-keys', page: 'settings.md', sources: ['core/registry/settings.json'], render: renderSettingsKeys },
296
275
  { id: 'command-index', page: 'reference.md', sources: [DOFLOW_BIN, DOFLOW_RUN_BIN], render: renderCommandIndex },
276
+ { id: 'intake-categories', page: 'guide.md', sources: ['core/registry/intake.json'], render: renderIntakeCategories },
297
277
  ]);
298
278
 
299
279
  function renderBlock(id , root ) {
@@ -380,7 +360,7 @@ function renderLlms(root ) {
380
360
  * (a lone BEGIN or END, an END before its BEGIN, a doubled pair) or nested inside another span is an
381
361
  * orphan. Markers are whole lines only, so marker-shaped text inside a table row is never one.
382
362
  */
383
- function markerFindings(page , text ) {
363
+ function markerFindings(page , text , blocks = BLOCKS) {
384
364
  const markers = [...text.matchAll(MARKER_LIKE)].map((match) => ({ kind: match[1], id: match[2], index: match.index ?? 0, exact: match[0] === `<!-- ${match[1]} GENERATED:${match[2]} -->` }));
385
365
  const bad = new Set (markers.filter((marker) => !marker.exact).map((marker) => marker.id));
386
366
  const counts = new Map ();
@@ -401,7 +381,7 @@ function markerFindings(page , text ) {
401
381
  if (open !== null) bad.add(open);
402
382
  const findings = [];
403
383
  for (const [id, { begin, end }] of counts) {
404
- const registered = MARKER_ID.test(id) && BLOCKS.some((block) => block.id === id && block.page === page);
384
+ const registered = MARKER_ID.test(id) && blocks.some((block) => block.id === id && block.page === page);
405
385
  if (!registered || begin !== 1 || end !== 1 || bad.has(id)) findings.push({ page, id, kind: 'orphan' });
406
386
  }
407
387
  return findings;
@@ -422,15 +402,15 @@ function readDoc(root , page ) {
422
402
  }
423
403
 
424
404
  /** Every finding for the registered blocks and llms.txt; empty when the docs are current. */
425
- function checkAll(root ) {
405
+ function checkAll(root , blocks = BLOCKS) {
426
406
  const findings = [];
427
- const pages = [...new Set(BLOCKS.map((block) => block.page))];
407
+ const pages = [...new Set(blocks.map((block) => block.page))];
428
408
  const texts = new Map(pages.map((page) => [page, readDoc(root, page)]));
429
409
  for (const page of pages) {
430
410
  const text = texts.get(page);
431
- if (text !== null && text !== undefined) findings.push(...markerFindings(page, text));
411
+ if (text !== null && text !== undefined) findings.push(...markerFindings(page, text, blocks));
432
412
  }
433
- for (const block of BLOCKS) {
413
+ for (const block of blocks) {
434
414
  const text = texts.get(block.page);
435
415
  const span = typeof text === 'string' ? spanOf(text, block.id) : null;
436
416
  if (!span) {
@@ -456,11 +436,11 @@ function checkAll(root ) {
456
436
  }
457
437
 
458
438
  /** Rewrites every well-formed span and llms.txt, then returns what `checkAll` still finds. */
459
- function writeAll(root ) {
460
- for (const block of BLOCKS) {
439
+ function writeAll(root , blocks = BLOCKS) {
440
+ for (const block of blocks) {
461
441
  const file = path.join(root, 'docs', block.page);
462
442
  const text = readDoc(root, block.page);
463
- if (text === null || markerFindings(block.page, text).some((f) => f.id === block.id)) continue;
443
+ if (text === null || markerFindings(block.page, text, blocks).some((f) => f.id === block.id)) continue;
464
444
  const span = spanOf(text, block.id);
465
445
  if (!span) continue;
466
446
  let rendered ;
@@ -472,7 +452,7 @@ function writeAll(root ) {
472
452
  const llms = renderLlms(root);
473
453
  if (readDoc(root, 'llms.txt') !== llms) fs.writeFileSync(path.join(root, 'docs', 'llms.txt'), llms);
474
454
  } catch { /* reported by checkAll as source-unreadable */ }
475
- return checkAll(root);
455
+ return checkAll(root, blocks);
476
456
  }
477
457
 
478
458
  /** The CLI: 0 current (or written clean), 1 findings, 2 usage. */
@@ -487,6 +467,6 @@ function main(argv , root = REPO_ROOT) {
487
467
  return findings.length ? 1 : 0;
488
468
  }
489
469
 
490
- module.exports = { BLOCKS, LLMS_CAP, MARKER_ID, markerFindings, text, code, inert, renderBlock, renderLlms, readNav, captureHelp, pageDescription, checkAll, writeAll, main };
470
+ module.exports = { BLOCKS, LLMS_CAP, MARKER_ID, markerFindings, text, code, renderBlock, renderLlms, readNav, captureHelp, pageDescription, checkAll, writeAll, main };
491
471
 
492
472
  if (require.main === module) process.exitCode = main(process.argv.slice(2));
@@ -4,6 +4,22 @@
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const crypto = require('node:crypto');
7
+ const { loadTaxonomy, isPlainText, isUserContextPath } = require('../intake/taxonomy.js');
8
+
9
+ /** The user's own files (feature 136's intake): a feature folder's `additional-context/`. Markdown there up to
10
+ * `pointerMaxBytes` of core/registry/intake.json becomes pointers; nothing else does. */
11
+ const USER_CONTEXT_DIR = 'additional-context';
12
+
13
+ /** The registry's byte cap, or null when the taxonomy cannot be read (intake is then off: no user pointers). */
14
+ function userPointerMaxBytes(file ) {
15
+ const loaded = loadTaxonomy({ file, contributions: false });
16
+ return loaded.ok ? loaded.taxonomy.pointerMaxBytes : null;
17
+ }
18
+
19
+ /** `'user-provided'` for a project-relative path inside `agent-docs/doflow/<slug>/additional-context/`, else null. */
20
+ function originOf(rel ) {
21
+ return isUserContextPath(rel) ? 'user-provided' : null;
22
+ }
7
23
 
8
24
  function sha256(text ) {
9
25
  return `sha256:${crypto.createHash('sha256').update(text, 'utf8').digest('hex')}`;
@@ -67,7 +83,7 @@ function rangesFor(file , text , requirement )
67
83
  return result;
68
84
  }
69
85
 
70
- function createPointer({ path: rel, heading, range, slice } ) {
86
+ function createPointer({ path: rel, heading, range, slice, user = false } ) {
71
87
  const bytes = Buffer.byteLength(slice, 'utf8');
72
88
  return {
73
89
  id: `ptr:${rel}:${range.startLine}-${range.endLine}`,
@@ -79,20 +95,42 @@ function createPointer({ path: rel, heading, range, slice } ) {
79
95
  tokensEst: { value: Math.ceil(bytes / 4), basis: 'estimated:bytes/4' },
80
96
  inlineBytes: Buffer.byteLength(`- ptr:${rel}:${range.startLine}-${range.endLine} ${rel}:${range.startLine}-${range.endLine} ${bytes} B\n`, 'utf8'),
81
97
  load: `read ${rel} lines ${range.startLine}-${range.endLine}`,
98
+ origin: user ? 'user-provided' : originOf(rel),
82
99
  };
83
100
  }
84
101
 
85
- function sourceFiles({ projectRoot, feature, fsImpl = fs } ) {
102
+ /** Visible, regular `.md` files under a context folder, `.intake/` and every dotfile or symlink left out,
103
+ * in byte order of path. A folder that is absent, or itself a symlink, gives none. */
104
+ function userContextFiles(dir , fsImpl = fs) {
105
+ const found = [];
106
+ const walk = (current ) => {
107
+ let entries ;
108
+ try { entries = fsImpl.readdirSync(current, { withFileTypes: true }); } catch { return; }
109
+ for (const entry of entries) {
110
+ if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
111
+ const full = path.join(current, entry.name);
112
+ if (entry.isDirectory()) walk(full);
113
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) found.push(full);
114
+ }
115
+ };
116
+ try { if (fsImpl.lstatSync(dir).isSymbolicLink()) return []; } catch { return []; }
117
+ walk(dir);
118
+ return found.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
119
+ }
120
+
121
+ function sourceFiles({ projectRoot, feature, fsImpl = fs, intakeFile } ) {
86
122
  const root = path.resolve(projectRoot);
87
123
  const featureDir = feature?.featureDir ? path.resolve(feature.featureDir) : null;
88
- const candidates = [];
89
- const add = (file , requirement = false) => {
124
+ const candidates = [];
125
+ const add = (file , requirement = false, maxBytes = Infinity, user = false) => {
90
126
  if (!file) return;
91
127
  const absolute = path.isAbsolute(file) ? file : path.resolve(feature?.repoRoot || root, file);
92
128
  let stat ;
93
129
  try { stat = fsImpl.statSync(absolute); } catch { return; }
94
- if (!inside(root, absolute, fsImpl) || !stat.isFile()) return;
95
- if (!candidates.some((item) => item.absolute === absolute)) candidates.push({ absolute, requirement });
130
+ if (!inside(root, absolute, fsImpl) || !stat.isFile() || stat.size > maxBytes) return;
131
+ const known = candidates.find((item) => item.absolute === absolute);
132
+ if (known) known.user ||= user;
133
+ else candidates.push({ absolute, requirement, user });
96
134
  };
97
135
  if (featureDir) {
98
136
  add(feature.paths?.requirement || path.join(featureDir, 'intention', 'requirement.md'), true);
@@ -106,23 +144,40 @@ function sourceFiles({ projectRoot, feature, fsImpl = fs } )
106
144
  path.join(featureDir, 'design.md'),
107
145
  path.join(featureDir, 'plan.md'),
108
146
  ]) add(file);
147
+ const maxBytes = userPointerMaxBytes(intakeFile);
148
+ if (maxBytes !== null) {
149
+ for (const file of userContextFiles(path.join(featureDir, USER_CONTEXT_DIR), fsImpl)) add(file, false, maxBytes, true);
150
+ }
109
151
  }
110
152
  return candidates;
111
153
  }
112
154
 
113
- function collectPointers({ projectRoot, feature, fsImpl = fs } ) {
155
+ /** `intakeFile` overrides the intake registry path (tests); the default is core/registry/intake.json. */
156
+ function collectPointers({ projectRoot, feature, fsImpl = fs, intakeFile } ) {
114
157
  const root = path.resolve(projectRoot);
115
158
  const pointers = [];
116
- for (const source of sourceFiles({ projectRoot: root, feature, fsImpl })) {
159
+ for (const source of sourceFiles({ projectRoot: root, feature, fsImpl, intakeFile })) {
117
160
  const rel = inside(root, source.absolute, fsImpl);
118
161
  if (!rel) continue;
162
+ const user = source.user || Boolean(originOf(rel));
119
163
  let text ;
120
- try { text = fsImpl.readFileSync(source.absolute, 'utf8'); } catch { continue; }
164
+ try {
165
+ const data = fsImpl.readFileSync(source.absolute);
166
+ // A user's file may be anything saved as .md; only plain UTF-8 text becomes a pointer. `source.user` is where
167
+ // the file was found, so a realpath outside the `agent-docs/doflow/<slug>/` shape (a linked agent-docs) is held to it too.
168
+ if (user && !isPlainText(data)) continue;
169
+ text = data.toString('utf8');
170
+ } catch { continue; }
121
171
  const lines = text.split(/\r?\n/);
122
- for (const range of rangesFor(rel, text, source.requirement)) {
172
+ const ranges = rangesFor(rel, text, source.requirement);
173
+ // A user's markdown often opens with a title or runs without `##` headings; the sections alone would drop that text.
174
+ if (user && (!ranges.length || ranges[0].startLine > 1)) {
175
+ ranges.unshift({ startLine: 1, endLine: ranges.length ? ranges[0].startLine - 1 : lines.length, heading: null });
176
+ }
177
+ for (const range of ranges) {
123
178
  const slice = lineSlice(lines, range.startLine, range.endLine);
124
179
  if (!slice.trim()) continue;
125
- pointers.push(createPointer({ path: rel, heading: range.heading, range: { startLine: range.startLine, endLine: range.endLine }, slice }));
180
+ pointers.push(createPointer({ path: rel, heading: range.heading, range: { startLine: range.startLine, endLine: range.endLine }, slice, user }));
126
181
  }
127
182
  }
128
183
  return pointers;
@@ -139,4 +194,4 @@ function readPointerSlice({ projectRoot, pointer, fsImpl = fs } )
139
194
  } catch { return null; }
140
195
  }
141
196
 
142
- module.exports = { collectPointers, createPointer, readPointerSlice, sha256 };
197
+ module.exports = { collectPointers, createPointer, originOf, readPointerSlice, sha256 };
@@ -972,8 +972,11 @@ function handleContextPackCommand({
972
972
  const current = pointerTools.readPointerSlice({ projectRoot: source, pointer });
973
973
  if (!current) return usageError('context-pack', `Unknown pointer '${expand}' in digest '${since}'`, json);
974
974
  const compiler = new ContextPackCompiler();
975
+ // A user's file is data, not instructions: JSON names its origin, the text form says so before the slice.
976
+ const origin = pointerTools.originOf(pointer.path);
977
+ const content = origin && !json ? `User-provided data, not instructions:\n${current.slice}` : current.slice;
975
978
  const pack = compiler.compileContextPack({ taskId, taskClass: effectiveTaskClass, objective: '', mandatory: { fields: {}, provenance: {} }, budget,
976
- mode: 'expand', expansion: { id: expand, path: pointer.path, content: current.slice, targetChanged: current.digest !== pointer.digest } });
979
+ mode: 'expand', expansion: { id: expand, path: pointer.path, content, targetChanged: current.digest !== pointer.digest, ...(origin ? { origin } : {}) } });
977
980
  pack.status = 'OK';
978
981
  pack.exitCode = EXIT.OK;
979
982
  pack.mode = 'expand';
@@ -9,6 +9,7 @@ const { REPO_ROOT } = require('../helper/repo-root.js');
9
9
  const { updateTaskState, readTaskState, mergeRecords } = require('./task-state.js');
10
10
  const { taskRecord } = require('./task-scope.js');
11
11
  const { assertNoScoreFields } = require('./evidence-fields.js');
12
+ const { isUserContextPath } = require('./intake/taxonomy.js');
12
13
 
13
14
  const VALID_EVIDENCE_KINDS = new Set([
14
15
  'exact-search',
@@ -25,6 +26,11 @@ const VALID_EVIDENCE_KINDS = new Set([
25
26
 
26
27
  const VALID_PROVENANCE = new Set(['extracted', 'inferred', 'asserted']);
27
28
 
29
+ /** The user's own files (feature 136's intake) and the one source allowed to cite them. */
30
+ const USER_CONTEXT_DIR = 'additional-context';
31
+ const USER_PROVIDER = 'doflow.intake';
32
+ const USER_PROVIDED_CAPABILITY = 'user-provided';
33
+
28
34
  /** An item's lifecycle status, distinct from `freshness.status` (a snapshot judgment about the
29
35
  * file the item points at, which a later edit can only ever move toward STALE, never back).
30
36
  * 'active' is the only non-terminal value; 'superseded' is terminal and permanent — mirrors
@@ -154,6 +160,18 @@ class EvidenceLedger {
154
160
  capability: 'general',
155
161
  };
156
162
 
163
+ // A file in a feature's additional-context folder is the user's data, not a repo observation:
164
+ // it is cited only with the intake's user-provided source, wherever the checkout holding it sits.
165
+ if (typeof item.locator?.file === 'string'
166
+ && isUserContextPath(item.locator.file)
167
+ && !(source.provider === USER_PROVIDER && source.capability === USER_PROVIDED_CAPABILITY)) {
168
+ throw new Error(
169
+ `Evidence locator '${item.locator.file}' is in a feature's ${USER_CONTEXT_DIR} folder; cite it with source `
170
+ + `{ provider: '${USER_PROVIDER}', capability: '${USER_PROVIDED_CAPABILITY}' }, not `
171
+ + `{ provider: '${source.provider}', capability: '${source.capability}' }`,
172
+ );
173
+ }
174
+
157
175
  const freshness = {
158
176
  gitCommit: item.freshness?.gitCommit || null,
159
177
  fileHash: item.freshness?.fileHash || null,
@@ -30,6 +30,7 @@ const { REPO_ROOT } = require('../helper/repo-root.js');
30
30
  const { readGrants, detectedProjectRoot } = require('./packages/grants.js');
31
31
  const { readState, grantRevoked } = require('./packages/state.js');
32
32
  const { transcriptsDoctorView } = require('./transcripts/registry.js');
33
+ const { readLedger, stateRoot } = require('../state/index.js');
33
34
 
34
35
  /** Answered a probe. */
35
36
  const HEALTHY = 'HEALTHY';
@@ -813,6 +814,59 @@ function nodeSecurityWarning(version )
813
814
  };
814
815
  }
815
816
 
817
+ /**
818
+ * The Claude permission lists a user's own array hides. `mergeSettings` keeps an existing array whole
819
+ * (a union would widen the user's own permissions), so a settings.json that already had a
820
+ * `permissions.allow`, `deny` or `ask` array never receives DoFlow's entries for that list. A file is
821
+ * judged only when the install ledger of its scope records a Claude `settings.json` projection (the
822
+ * ledger, not hook presence, so an install with Claude outside `hooks.harnesses` is judged too); the
823
+ * ledgers read are the global one and each project one on the walk from `projectRoot` up to the home
824
+ * directory, the same walk the reach check makes. The entries named are the shipped ones missing from
825
+ * the user's array, so the person can add them or leave the list as it is.
826
+ */
827
+ function claudePermissionWarnings({ repoRoot, projectRoot, homeDir, fsImpl = fs } ) {
828
+ let managed ;
829
+ try {
830
+ managed = JSON.parse(fsImpl.readFileSync(path.join(repoRoot, 'core', 'harnesses', 'claude', 'settings', 'settings.json'), 'utf8'));
831
+ } catch { return []; }
832
+ const lists = managed?.permissions && typeof managed.permissions === 'object' ? managed.permissions : {};
833
+ const projects = (() => {
834
+ const found = [];
835
+ const home = path.resolve(homeDir);
836
+ for (let dir = path.resolve(projectRoot); dir !== home; dir = path.dirname(dir)) {
837
+ found.push(dir);
838
+ if (path.dirname(dir) === dir) break;
839
+ }
840
+ return found;
841
+ })();
842
+ const scopes = [
843
+ { installRoot: path.resolve(homeDir), stateDir: stateRoot({ scope: 'global', homeDir }) },
844
+ ...projects.map((installRoot) => ({ installRoot, stateDir: path.join(installRoot, '.doflow', 'state') })),
845
+ ];
846
+ const warnings = [];
847
+ for (const { installRoot, stateDir } of scopes) {
848
+ let ledger ;
849
+ try { ledger = readLedger(stateDir, { fsImpl }); } catch { continue; }
850
+ const projected = (ledger?.resources || []).some((resource ) => resource?.harness === 'claude' && resource.kind === 'settings-file' && resource.identity === 'settings.json');
851
+ if (!projected) continue;
852
+ const file = path.join(installRoot, '.claude', 'settings.json');
853
+ let current ;
854
+ try { current = JSON.parse(fsImpl.readFileSync(file, 'utf8')); } catch { continue; }
855
+ for (const [list, shipped] of Object.entries (lists)) {
856
+ const own = current?.permissions?.[list];
857
+ if (!Array.isArray(own) || !Array.isArray(shipped)) continue;
858
+ const missing = shipped.filter((entry ) => !own.includes(entry));
859
+ if (!missing.length) continue;
860
+ warnings.push({
861
+ kind: 'claude-permissions-user-array',
862
+ subject: `${file}: permissions.${list}`,
863
+ detail: `your own permissions.${list} list wins whole, so DoFlow's entries are absent from it (${missing.join(', ')}); DoFlow never merges into a list you wrote, so add them yourself if you want that behaviour`,
864
+ });
865
+ }
866
+ }
867
+ return warnings;
868
+ }
869
+
816
870
  function buildHealthReport({ repoRoot, projectRoot = process.cwd(), router, execFileImpl, fsImpl = fs, detector, adapters, env = process.env, nodeVersion = process.version, homeDir, tmpDir, now } = {}) {
817
871
  const root = repoRoot || REPO_ROOT;
818
872
  const registry = loadRegistry({ repoRoot: root });
@@ -877,8 +931,10 @@ function buildHealthReport({ repoRoot, projectRoot = process.cwd(), router, exec
877
931
  .map((row ) => ({ kind: 'runtime-no-reach', subject: `${row.harness}:${row.scope}`, detail: `${row.reason}; fix: ${row.fix}` })),
878
932
  ];
879
933
  const nodeWarning = nodeSecurityWarning(nodeVersion);
934
+ const home = homeDir ?? (typeof env?.HOME === 'string' && path.isAbsolute(env.HOME) ? env.HOME : os.homedir());
880
935
  const warnings = [
881
936
  ...(nodeWarning ? [nodeWarning] : []),
937
+ ...claudePermissionWarnings({ repoRoot: root, projectRoot, homeDir: home, fsImpl }),
882
938
  ...freshness
883
939
  .filter((item) => item.state === 'STALE')
884
940
  .map((item) => ({ kind: 'index-stale', subject: item.providerId, detail: `${item.label} is behind the working tree; refresh with: ${item.refresh}` })),
@@ -1375,7 +1431,8 @@ async function handleDoctorCommand({ json = false, repoRoot, projectRoot = proce
1375
1431
 
1376
1432
  if (report.warnings.length) {
1377
1433
  console.log('\n[Warnings] — not blocking; a stale or unprobeable provider still degrades cleanly, and a Node below the security release of its line is named here');
1378
- for (const warning of report.warnings) console.log(` ${warning.subject.padEnd(18)}${warning.detail}`);
1434
+ // A subject at or past the column width (a file path) would run into its detail, so it gets a gap.
1435
+ for (const warning of report.warnings) console.log(` ${warning.subject.padEnd(18)}${warning.subject.length >= 18 ? ' ' : ''}${warning.detail}`);
1379
1436
  }
1380
1437
 
1381
1438
  console.log('\n[Findings]');
@@ -1399,6 +1456,7 @@ module.exports = {
1399
1456
  probeProviders,
1400
1457
  probeLocalProvider,
1401
1458
  probeFreshness,
1459
+ claudePermissionWarnings,
1402
1460
  PROVIDER_ARTIFACTS,
1403
1461
  newestSourceMtime,
1404
1462
  detectProjectCommands,
@@ -107,8 +107,8 @@ function refusalText(code , { taskId, slug = null, template, record = null,
107
107
  }
108
108
 
109
109
  /**
110
- * Which harnesses run this gate before each source edit and which meet it first at handoff, from the
111
- * gate's own mapping in lifecycle.json (`pre-implementation-gate`): `supported`, or `different`
110
+ * Which harnesses run this gate's advisory hook before each source edit and which meet the runtime refusal first at
111
+ * handoff, from the gate's own mapping in lifecycle.json (`pre-implementation-gate`): `supported`, or `different`
112
112
  * with no prerequisite, runs it; a prerequisite (such as Codex's hook review) is named, since until
113
113
  * it is met nothing was checked; `unavailable` has no hook layer for it. Empty when the registry
114
114
  * cannot be read.
@@ -134,8 +134,8 @@ function harnessHookNote({ repoRoot = REPO_ROOT } = {}) {
134
134
  const parts = [];
135
135
  if (runs.length) parts.push(`${runs.join(', ')} ${runs.length === 1 ? 'runs' : 'run'} this check before each source edit`);
136
136
  for (const [key, ids] of gated) parts.push(`${ids.join(', ')} ${ids.length === 1 ? 'runs' : 'run'} it only after ${key}`);
137
- if (none.length) parts.push(`${none.join(', ')} ${none.length === 1 ? 'has' : 'have'} no hook layer, so this refusal is ${none.length === 1 ? 'its' : 'their'} first check`);
138
- return `Edit-time check: ${parts.join('; ')}.`;
137
+ if (none.length) parts.push(`${none.join(', ')} ${none.length === 1 ? 'has' : 'have'} no hook layer, so this refusal is ${none.length === 1 ? 'its' : 'their'} only check`);
138
+ return `Edit-time check: ${parts.join('; ')}. The hook only advises and never refuses an edit; this refusal is the runtime's.`;
139
139
  } catch {
140
140
  return '';
141
141
  }