@khoavu882/doflow 2.0.1 → 2.0.2

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 (93) hide show
  1. package/core/.claude-plugin/plugin.json +1 -1
  2. package/core/.codex-plugin/plugin.json +1 -1
  3. package/core/.plugin/plugin.json +1 -1
  4. package/core/harnesses/claude/hooks/session-end.sh +21 -1
  5. package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
  6. package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
  7. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
  8. package/core/registry/contribution-points.json +1 -1
  9. package/core/registry/intake.json +3 -3
  10. package/core/registry/lifecycle.json +256 -5
  11. package/core/registry/packages/doflow/doflow-package.json +2 -2
  12. package/core/registry/settings.json +12 -12
  13. package/core/registry/storage.json +1 -2
  14. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
  15. package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
  16. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
  17. package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
  18. package/core/shared/scripts/doflow/bin/doflow-run +18 -0
  19. package/core/shared/skills/do-design/SKILL.md +6 -2
  20. package/core/shared/skills/do-design/references/data_model_example.md +167 -0
  21. package/core/shared/templates/doflow/data-model-template.md +136 -102
  22. package/package.json +1 -1
  23. package/src/adapters/antigravity/index.js +54 -12
  24. package/src/adapters/codex/agents.js +15 -1
  25. package/src/adapters/codex/config.js +5 -2
  26. package/src/adapters/copilot/index.js +11 -4
  27. package/src/adapters/copy-tree.js +7 -0
  28. package/src/adapters/created-file.js +41 -0
  29. package/src/adapters/mcp-entries.js +11 -1
  30. package/src/adapters/opencode/index.js +32 -5
  31. package/src/adapters/pi/index.js +15 -4
  32. package/src/adapters/pi/mcp.js +44 -18
  33. package/src/cli/commands/remove.js +3 -1
  34. package/src/cli/commands/update.js +7 -3
  35. package/src/cli/index.js +24 -12
  36. package/src/cli/runtime-commands.js +1 -1
  37. package/src/cli/shared.js +5 -3
  38. package/src/helper/marker-merge.js +11 -7
  39. package/src/install/backup-set.js +7 -3
  40. package/src/install/backup.js +13 -7
  41. package/src/install/context.js +2 -1
  42. package/src/install/manifest.js +35 -11
  43. package/src/install/paths.js +38 -0
  44. package/src/install/targets.js +11 -2
  45. package/src/lifecycle/index.js +80 -3
  46. package/src/lifecycle/retired-harnesses.js +16 -1
  47. package/src/lifecycle/view.js +3 -2
  48. package/src/registry/index.js +21 -4
  49. package/src/release/docs-blocks.js +5 -1
  50. package/src/runtime/advice/doctor.js +2 -2
  51. package/src/runtime/brief-tools.js +2 -1
  52. package/src/runtime/capability-router.js +26 -3
  53. package/src/runtime/context/pointer.js +17 -5
  54. package/src/runtime/context-pack.js +48 -8
  55. package/src/runtime/dispatch/integrate.js +27 -8
  56. package/src/runtime/dispatch/loop.js +7 -0
  57. package/src/runtime/dispatch/verify-run.js +9 -3
  58. package/src/runtime/feature-pointer.js +10 -1
  59. package/src/runtime/health.js +9 -4
  60. package/src/runtime/implementation-gate.js +10 -2
  61. package/src/runtime/intake/engine.js +11 -9
  62. package/src/runtime/knowledge/code-retrieval.js +11 -5
  63. package/src/runtime/knowledge/semble-provider.js +12 -2
  64. package/src/runtime/lifecycle/cli.js +7 -5
  65. package/src/runtime/lifecycle/overview.js +32 -2
  66. package/src/runtime/lifecycle/release.js +16 -3
  67. package/src/runtime/lifecycle/retention.js +12 -15
  68. package/src/runtime/lifecycle/store-upkeep.js +13 -3
  69. package/src/runtime/mask.js +3 -3
  70. package/src/runtime/mcp-consultations.js +11 -5
  71. package/src/runtime/research-request.js +3 -1
  72. package/src/runtime/review/command.js +3 -7
  73. package/src/runtime/review/start.js +24 -10
  74. package/src/runtime/roadmap/loop.js +14 -3
  75. package/src/runtime/roadmap/rows.js +2 -1
  76. package/src/runtime/settings/files.js +3 -1
  77. package/src/runtime/settings/prepared-run.js +12 -2
  78. package/src/runtime/settings/resolve.js +13 -1
  79. package/src/runtime/settings/write.js +73 -2
  80. package/src/runtime/storage/registry.js +2 -2
  81. package/src/runtime/storage/roots.js +4 -18
  82. package/src/runtime/storage/rules.js +22 -46
  83. package/src/runtime/storage/work-status.js +14 -4
  84. package/src/runtime/task-scope.js +17 -2
  85. package/src/runtime/task-state.js +11 -4
  86. package/src/runtime/trace/analyze.js +4 -2
  87. package/src/runtime/trace/indicators.js +25 -20
  88. package/src/runtime/trace/spans.js +4 -5
  89. package/src/runtime/verb-view.js +6 -5
  90. package/src/runtime/worker/command.js +4 -2
  91. package/src/runtime/worker/readiness.js +8 -4
  92. package/src/runtime/worker/supervisor.js +18 -6
  93. package/src/runtime/workflow-orchestrator.js +6 -2
@@ -2,166 +2,200 @@
2
2
 
3
3
  **Feature:** [NNN-slug] · **Design:** ./design.md · **Maturity:** Draft · **Created:** [DATE]
4
4
 
5
- > Entity/data model and data-flow diagrams a design decided, split out of design.md's own narrative
6
- > (Data Model and Sequence / Data Flow) and specs.md's interface-contract detail (§1). Reads ./design.md; distinct from ./specs.md
7
- > the way a persisted shape and the flows that move through it are distinct from the contracts that
8
- > expose them.
5
+ > Entity/data model, lifecycles and data-flow diagrams a design decided, split out of design.md's own
6
+ > narrative (Data Model and Sequence / Data Flow) and specs.md's interface-contract detail (§1). Reads
7
+ > ./design.md; distinct from ./specs.md the way a persisted shape and the flows that move through it
8
+ > are distinct from the contracts that expose them.
9
9
  >
10
10
  > Structure follows `references/ARTIFACT_FORMAT.md`: the document-level `Maturity` is `Draft`,
11
- > `In review` or `Approved`. Neither §1 nor §2 below carries an `ID`-bearing index — a diagram is not
12
- > a normative, citable statement — so both stay free-form; §3 History is index-then-detail like
13
- > every other chain artifact's final section, and its item-level `Status` is only `Live` or
14
- > `Superseded → <ref>`.
11
+ > `In review` or `Approved`. The file has nine sections. Only §9 History carries an `ID`-bearing
12
+ > index (index-then-detail, item-level `Status` is `Live` or `Superseded → <ref>`); §1 to §8 are
13
+ > free-form, because a diagram or a table row is not a normative, citable statement.
14
+
15
+ <!-- Rules for every section below.
16
+ Source: each row of the §1 dictionary and of every table in §3 to §8 ends with a Source cell
17
+ holding exactly one of: FR-###, NFR-###, IC-###, code:<path>:<line>, proposed, UNKNOWN -> Open Point.
18
+ A `proposed` or `UNKNOWN -> Open Point` row is copied to design.md Open Points for Review.
19
+ Placeholders are bracketed and bare; fill them, never leave a row of placeholders in place.
20
+ A conditional section accepts "N/A: [why]" in place of its content.
21
+ Worked example, read only while filling this file: do-design/references/data_model_example.md -->
15
22
 
16
23
  ## 1. Data Model
17
24
 
18
- <!-- Free-form, NOT part of the indexed convention above — diagrams and schema tables are not
19
- ID-bearing normative statements, so this section stays exactly as specs-template.md's former
20
- §2 already was.
25
+ <!-- Free-form. Order: domain inventory, domain map, ER views, data dictionary, schemas, UX.
26
+ The domain map carries no attributes, so the shape of the domain is legible before any field
27
+ name. One ER view per bounded subdomain carries that subdomain's attributes, keys and
28
+ cardinalities alone; splitting must not drop an entity, a key or a constraint. -->
21
29
 
22
- The model is presented in two tiers. The conceptual domain map comes first and carries no
23
- attributes, so the shape of the domain is legible before any field name. One ER view per
24
- bounded subdomain follows, each carrying the attributes, keys and cardinalities of that
25
- subdomain alone. Splitting one diagram into views must not drop an entity, a key or a
26
- constraint the single diagram carried. -->
30
+ ### Domain inventory
31
+
32
+ | Domain | Entities | Stateful | Owner | Source |
33
+ |---|---|---|---|---|
34
+ | [domain] | [entity, entity] | [yes or no] | [owning component] | [Source] |
27
35
 
28
36
  ### Conceptual domain map
29
37
 
30
- <!-- The entities and the relationships between them, with no attributes. Skip with "N/A: [why]" if
31
- the feature persists no entity. -->
38
+ <!-- A flowchart of entities and relationships, no attributes. "N/A: [why]" if the feature stores no entity (persisted captures count as entities). -->
32
39
 
33
40
  ```mermaid
34
41
  flowchart LR
35
- USER["User"]
36
- POST["Post"]
37
- COMMENT["Comment"]
38
-
39
- USER -->|"creates"| POST
40
- POST -->|"contains"| COMMENT
42
+ A["[Entity]"] -->|"[relationship]"| B["[Entity]"]
41
43
  ```
42
44
 
43
45
  #### ER view: [subdomain]
44
46
 
45
- <!-- One heading of this form per bounded subdomain, each an erDiagram carrying the attributes,
46
- keys and cardinalities of that subdomain alone. Skip with "N/A: [why]" if the feature persists
47
- no entity. -->
47
+ <!-- One heading of this form per bounded subdomain, each an erDiagram. Every entity named here
48
+ needs a dictionary block below and a lifecycle block in §3. -->
48
49
 
49
50
  ```mermaid
50
51
  erDiagram
51
- USER ||--o{ POST : "creates"
52
- USER {
53
- uuid id PK
54
- string email UK
55
- string name
56
- timestamp created_at
57
- }
58
- POST ||--o{ COMMENT : "contains"
59
- POST {
60
- uuid id PK
61
- uuid user_id FK
62
- string title
63
- text content
64
- timestamp created_at
52
+ [ENTITY_A] ||--o{ [ENTITY_B] : "[relationship]"
53
+ [ENTITY_A] {
54
+ [type] [field] PK
65
55
  }
66
- COMMENT {
67
- uuid id PK
68
- uuid post_id FK
69
- uuid author_id FK
70
- text body
71
- timestamp created_at
56
+ [ENTITY_B] {
57
+ [type] [field] FK
72
58
  }
73
59
  ```
74
60
 
61
+ ### Data dictionary
62
+
63
+ #### Dictionary: [entity]
64
+
65
+ <!-- One block per ER entity, one row per field. -->
66
+
67
+ | Field | Type | Null | Rule | PII class | Source |
68
+ |---|---|---|---|---|---|
69
+ | [field] | [type] | [yes or no] | [constraint or format] | [none, direct, indirect] | [Source] |
70
+
75
71
  ### Database Schemas (ORM / DDL)
76
72
 
77
73
  <!-- The catalog of every table the views above draw, with its keys, indexes and ORM file. -->
78
74
 
79
75
  | Table | Purpose | Primary Key | Foreign Keys & Relations | Key Indexes | Schema / ORM File |
80
76
  |---|---|---|---|---|---|
81
- | `[table_name]` | [one-line table responsibility] | `id` (UUID) | `[user_id]` → `users(id)` | `idx_[table]_[col]` | `src/db/schema/[table].ts` |
77
+ | `[table_name]` | [one-line responsibility] | [key] | [fk → table(key)] | [index] | [path] |
82
78
 
83
79
  ### UX / UI Specifications
84
- - **Design Tokens & Cues:** [color tokens (e.g. Indigo for Work, Emerald for Personal), badge variants, typography]
85
- - **Component States:** [loading, empty, populated, error states]
80
+ - **Design Tokens & Cues:** [tokens, badge variants, typography]
81
+ - **Component States:** [loading, empty, populated, error]
86
82
 
87
83
  ## 2. Sequence / Data Flow
88
84
 
89
- <!-- Free-form, NOT part of the indexed convention — a diagram is not an ID-bearing normative
90
- statement, so this section stays free-form exactly as §1 does.
91
-
92
- One `sequenceDiagram` per non-trivial interaction the feature introduces. Scaffolding matches
93
- §1's depth deliberately: this is the chain's most-used diagram type, not an afterthought.
94
-
95
- `references/DIAGRAM_GUIDE.md` governs the lifeline ceiling. There is no message ceiling,
96
- because a sequence diagram lays out columnwise and cannot tangle however many messages it
97
- carries — so show the whole interaction rather than trimming it to look tidy.
98
-
99
- Conventions:
100
- - Declare every participant up front, in the order they first act, so the columns read
101
- left-to-right in causal order.
102
- - Use `actor` for a human or an external caller and `participant` for a component.
103
- - `->>` is a call, `-->>` is a return. Keep returns only where the returned value matters.
104
- - Put the failure path in an `alt` block. An interaction drawn only on its happy path hides
105
- the branch a reader most needs.
106
- - `Note over X,Y:` carries a constraint or an invariant, never narration of the line above.
107
- - Prefer `rect` over `par` to mark a region: `par` claims the steps happen simultaneously,
108
- which most groupings cannot support. Reserve `par` for genuine parallelism, `alt` for a
109
- branch.
85
+ <!-- Free-form. `references/DIAGRAM_GUIDE.md` governs lifeline ceilings; there is no message ceiling.
86
+ Declare participants in the order they first act. `->>` is a call, `-->>` a return. Put the
87
+ failure path in an `alt` block. Prefer `rect` over `par` unless steps are truly parallel.
88
+ "N/A: [why]" if the feature has no non-trivial interaction. -->
110
89
 
111
- Skip with "N/A: [why]" if the feature introduces no non-trivial interaction. -->
112
-
113
- ### [Interaction name]
90
+ ### Interaction: [name]
114
91
 
115
92
  [One sentence: what triggers this flow and what it ends with.]
116
93
 
117
94
  ```mermaid
118
95
  sequenceDiagram
119
96
  actor User
120
- participant API as [entry point]
121
- participant SVC as [service]
97
+ participant SVC as [component]
122
98
  participant DB as [data store]
123
99
 
124
- User->>API: [request]
125
- API->>SVC: [call]
126
-
100
+ User->>SVC: [request]
127
101
  alt [failure condition]
128
- SVC-->>API: [error shape]
129
- API-->>User: [status code and message]
102
+ SVC-->>User: [error]
130
103
  else [success]
131
104
  SVC->>DB: [read or write]
132
105
  DB-->>SVC: [result]
133
- SVC-->>API: [payload]
134
- API-->>User: [status code]
106
+ SVC-->>User: [response]
135
107
  end
108
+ ```
109
+
110
+ ### Swimlane flows
111
+
112
+ <!-- One "#### Flow: <name>" per end-to-end flow, each embedding its committed render from
113
+ design/c4/flow-<name>.png. The name matches the source file's stem; nothing keeps a manifest.
114
+ No inline text diagram here: Mermaid has no lane primitive. `references/DIAGRAM_GUIDE.md` §6
115
+ governs lanes. "N/A: [why]" when the flow has a single actor. -->
116
+
117
+ #### Flow: [name]
118
+
119
+ ![End-to-end flow: [name]](design/c4/flow-[name].png)
120
+
121
+ [One sentence: what triggers this flow and what it ends with.]
136
122
 
137
- Note over SVC,DB: [invariant that must hold across this step]
123
+ ## 3. Lifecycles
124
+
125
+ > **Authoritative:** this section governs [the state, rule or outcome].
126
+
127
+ <!-- Every entity state lives here and nowhere else. Other sections link to the entity by name and
128
+ restate no state (ARTIFACT_FORMAT.md §11). One block per ER entity.
129
+ Stateful entity: a stateDiagram-v2 of at most 10 states, a Transitions table, Illegal
130
+ transitions and terminal states. Every diagram state (except [*]) appears in the From or To
131
+ column. Above 10 states, split the entity or group states into a composite state.
132
+ Stateless entity: the single line "N/A: stateless, [why]". -->
133
+
134
+ #### Lifecycle: [entity]
135
+
136
+ ```mermaid
137
+ stateDiagram-v2
138
+ [*] --> [State]
139
+ [State] --> [State]: [event]
138
140
  ```
139
141
 
140
- [If the failure branch has consequences the diagram cannot carry — a retry policy, a compensating
141
- action, an idempotency requirement — state them in a sentence beneath the diagram rather than
142
- crowding the fence.]
142
+ **Transitions**
143
143
 
144
- ### Flow: [name]
144
+ | From | Event | Guard | Actor | Side effect | To | Emitted event | Source |
145
+ |---|---|---|---|---|---|---|---|
146
+ | [state] | [event] | [condition] | [who triggers] | [effect] | [state] | [event or none] | [Source] |
145
147
 
146
- <!-- One "#### Flow: <name>" heading per end-to-end flow, each embedding its committed render from
147
- design/c4/flow-<name>.png. The heading name matches its source file's stem, which is the only
148
- index -- nothing maintains a manifest, because a manifest could disagree with the directory.
148
+ **Illegal transitions**
149
149
 
150
- This slot carries NO inline text diagram, and that asymmetry is forced by grammar rather than
151
- chosen: Mermaid has no lane primitive, and approximating lanes with subgraph grouping would
152
- carry no lane semantics and so would misrepresent the diagram it stood in for. The authored
153
- .puml remains readable text under review, so the flow's logic is still diffable.
150
+ - [From] → [To]: [why it is refused]
154
151
 
155
- `references/DIAGRAM_GUIDE.md` §6 governs the lane conventions. Skip with "N/A: [why]" when the
156
- feature's flow involves a single actor. -->
152
+ **Terminal states:** [state, state]
157
153
 
158
- ![End-to-end flow: [name]](design/c4/flow-[name].png)
154
+ #### Lifecycle: [entity]
155
+
156
+ N/A: stateless, [why]
157
+
158
+ ## 4. Cross-entity coupling
159
+
160
+ <!-- Conditional: two or more actors; otherwise "N/A: [why]". Link to the §2 flow by name; restate
161
+ no picture and no state. -->
162
+
163
+ | Trigger entity and transition | Effect on entity | Via event | Source |
164
+ |---|---|---|---|
165
+ | [entity: From → To] | [entity: effect] | [event] | [Source] |
166
+
167
+ ## 5. Invariants
168
+
169
+ <!-- Mandatory. A statement that must hold at all times, and where it is enforced. -->
170
+
171
+ | Invariant | Holds over | Enforced by | Source |
172
+ |---|---|---|---|
173
+ | [statement] | [entities or fields] | [constraint, code path, or check] | [Source] |
174
+
175
+ ## 6. Ownership / CRUD matrix
176
+
177
+ | Entity | Owner | Create | Read | Update | Delete | Source |
178
+ |---|---|---|---|---|---|---|
179
+ | [entity] | [component] | [who] | [who] | [who] | [who] | [Source] |
180
+
181
+ ## 7. Event catalogue
182
+
183
+ <!-- Domain events this feature emits; external stream events it only consumes are not listed here.
184
+ "N/A: [why]" when no domain event is emitted. -->
185
+
186
+ | Event | Emitted by | Payload | Consumers | Source |
187
+ |---|---|---|---|---|
188
+ | [event] | [component] | [fields] | [components] | [Source] |
189
+
190
+ ## 8. Retention, PII and migration
191
+
192
+ <!-- Conditional: "N/A: [why]" when nothing is retained, personal or migrated. -->
159
193
 
160
- [One sentence: what triggers this flow and what it ends with. Any consequence the lanes cannot
161
- carry — a retry policy, a compensating action, an idempotency requirement — belongs here rather
162
- than crowded into the diagram.]
194
+ | Entity | Retention | PII class | Migration | Source |
195
+ |---|---|---|---|---|
196
+ | [entity] | [period or rule] | [none, direct, indirect] | [step or none] | [Source] |
163
197
 
164
- ## 3. History
198
+ ## 9. History
165
199
 
166
200
  <!-- Superseded data-model entries move here; the index row above stays as a tombstone with
167
201
  `Superseded → <ref>`. -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khoavu882/doflow",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "DoFlow config installer — npx CLI for the registry-driven multi-harness install/update/remove lifecycle",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -26,6 +26,7 @@ const { MARKER_START, MARKER_END, removeMarkedSection } = require('../../helper/
26
26
  const { planTree, applyTree, removeTree, verifyTree, resolveTransform, copyTreeAssets, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, readJson, sourceDirFor } = require('../copy-tree.js');
27
27
  const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
28
28
  const { renderGuidancePointer } = require('../guidance-pointer.js');
29
+ const { replacementManagesFile } = require('../../lifecycle/retired-harnesses.js');
29
30
  const { readMcpFiles, planMcpEntries, verifyMcpEntries, ownedMcpIds, writeMcpEntries } = require('../mcp-entries.js');
30
31
 
31
32
  const HARNESS = 'antigravity';
@@ -229,8 +230,9 @@ function managedInstruction(existing , rendered , { name = 'AGENTS.md',
229
230
  if (start === -1 && predecessor) start = existing.indexOf(PREDECESSOR_MARKER_START);
230
231
  const end = existing.indexOf(MARKER_END);
231
232
  if (start === -1 && end === -1) {
232
- const separator = existing === '' || existing.endsWith('\n') ? '\n' : '\n\n';
233
- const content = `${existing}${separator}${rendered}`;
233
+ // Always exactly one '\n' before the span, so strippedInstruction can take back exactly that one
234
+ // byte whatever the file ended with (a file with no final newline just gets the span on the next line).
235
+ const content = `${existing}\n${rendered}`;
234
236
  return { ok: true, operation: 'merge', content };
235
237
  }
236
238
  if (start === -1 || end === -1 || end < start) return { ok: false, conflict: `${name} has malformed DoFlow managed-section markers` };
@@ -245,7 +247,11 @@ function strippedInstruction(existing ) {
245
247
  if (start === -1 || end === -1 || end < start) return null;
246
248
  let after = end + MARKER_END.length;
247
249
  if (existing[after] === '\n') after += 1;
248
- return `${existing.slice(0, start)}${existing.slice(after)}`;
250
+ const before = existing.slice(0, start);
251
+ const rest = existing.slice(after);
252
+ // Exact inverse of managedInstruction's append: a span at the end of the file goes with the one '\n'
253
+ // put before it. A span with the person's text after it was placed, not appended; it keeps its before.
254
+ return `${rest === '' && before.endsWith('\n') ? before.slice(0, -1) : before}${rest}`;
249
255
  }
250
256
 
251
257
  // ---- copy-tree components (skills, agents, locator) ----
@@ -318,6 +324,45 @@ function planTrees({ assets, paths, scope, neutralResources, removing, repoRoot,
318
324
  return { changes, conflicts, targets, notices: [...siblingReplacedNotices(treeResults), ...notices] };
319
325
  }
320
326
 
327
+ // ---- legacy Gemini shim ----
328
+
329
+ /** sha256 of every `doflow-run` locator the retired Gemini adapter wrote to ~/.gemini/bin, which
330
+ * moved to ~/.gemini/config/bin in f7928c48. Each is a blob of core/harnesses/shared/locator/doflow-run
331
+ * that was current while the adapter still projected to ~/.gemini/bin (blob: commit that introduced
332
+ * it): 22340592: e575b259, 34bb6c6c: 1fbab132, 344ebd52: a3c15cdb. Hashes only; the bodies stay in git. */
333
+ const LEGACY_GEMINI_SHIM_SHA256 = Object.freeze([
334
+ 'bee62b0a97b9365fba95541a2d2979b1dd57f63a13e0d7bcc6077147664008a4',
335
+ '66530de094c6ec22bda9219b19f9c02e6a076068aa671b5eb6dff463d06f966a',
336
+ '9b5cae2a84d7486deff8ecabe477c06af5802d77af6efa72476897f08b8864d6',
337
+ ]);
338
+
339
+ /** The one change that retires a stale ~/.gemini/bin/doflow-run: global scope only, and only a
340
+ * regular file (never a link) whose bytes equal a shipped legacy shim. Anything else at that path
341
+ * is the person's and is left alone. No ledger row owns the file, so the change carries
342
+ * `legacyRetirement` and the lifecycle journals it as a swept tombstone (src/lifecycle/index.js). */
343
+ function planLegacyGeminiShim({ assets, paths, scope, fsImpl = fs } ) {
344
+ const none = { changes: [], notices: [] };
345
+ const locator = scope === 'global' ? (assets ?? []).find((asset ) => asset.id === 'locator.doflow') : null;
346
+ const currentDir = locator && treeDestFor(locator, paths, scope);
347
+ if (!currentDir) return none;
348
+ const target = path.join(paths.root, '.gemini', 'bin', 'doflow-run');
349
+ let digest;
350
+ try {
351
+ if (!fsImpl.lstatSync(target).isFile()) return none;
352
+ digest = sha256File(fsImpl, target);
353
+ } catch { return none; }
354
+ if (!LEGACY_GEMINI_SHIM_SHA256.includes(digest)) return none;
355
+ return {
356
+ changes: [{
357
+ assetId: locator.id, target, operation: 'remove', kind: 'copy-tree-file', identity: 'bin/doflow-run',
358
+ ownershipIdentity: `doflow:${HARNESS}:legacy:locator.doflow:bin/doflow-run`,
359
+ fingerprint: digest, projection: { renderer: 'copy-tree' },
360
+ legacyRetirement: { toTarget: path.join(currentDir, 'doflow-run') },
361
+ }],
362
+ notices: ['removed ~/.gemini/bin/doflow-run: an unmodified older DoFlow shim, replaced by ~/.gemini/config/bin/doflow-run'],
363
+ };
364
+ }
365
+
321
366
  function runTreeChanges(changes , mode ) {
322
367
  // Route by what each change IS, not by which verb invoked us: remove() delegates here with a
323
368
  // plan full of operation:'remove' changes, and applyTree deliberately skips those — so routing
@@ -367,17 +412,13 @@ function instructionTarget({ paths, assets, registry } ) {
367
412
  if (!paths.instruction) return null;
368
413
  if (scopeOf(paths) === 'project') return paths.instruction;
369
414
  if (!(assets ?? []).some((asset ) => asset.id === POINTER_ASSET_ID)) return null;
370
- const declaredBy = (harness ) => {
371
- if (harness?.id === HARNESS || !harness?.paths?.instruction) return false;
372
- try { return resolveHarnessPaths({ instruction: harness.paths.instruction }, { scope: 'global', scopeRoot: paths.root, homeDir: paths.root }).instruction === paths.instruction; } catch { return false; }
373
- };
374
- return (registry?.harnesses ?? []).some(declaredBy) ? null : paths.instruction;
415
+ return replacementManagesFile({ registry, harnessId: HARNESS, surface: 'instruction', target: paths.instruction, scope: 'global', scopeRoot: paths.root }) ? paths.instruction : null;
375
416
  }
376
417
 
377
418
  /** How this run stands to a section it took over (IC-007): the lifecycle's handover verdict for the
378
419
  * file, this harness's own row on it, and whether a person-edited file stays as it is (`retained`:
379
420
  * edited before the handover, or kept since, and no --force). `predecessor` lets the retired
380
- * harness's span count as the section to replace; `keptRow` is a row recorded as kept. A handover
421
+ * harness's span count as the section to replace; `keptRow` is any own row on the file. A handover
381
422
  * file whose DoFlow span already is this harness's render was written by DoFlow itself (a run that
382
423
  * threw after that write and before its ledger write), so it does not count as edited. */
383
424
  function instructionState({ target, context, resources, paths, fsImpl = fs } ) {
@@ -391,7 +432,7 @@ function instructionState({ target, context, resources, paths, fsImpl = fs }
391
432
  };
392
433
  const edited = (handover?.edited === true && !ownWrite()) || ownRow?.retainedEdit === true;
393
434
  return { predecessor: Boolean(handover) || ownRow?.retainedEdit === true, retained: edited && context.force !== true,
394
- keptRow: ownRow?.retainedEdit === true };
435
+ keptRow: ownRow !== null };
395
436
  }
396
437
 
397
438
  function instructionsChange(target , fields ) {
@@ -492,6 +533,7 @@ function plan(options = {}, impl = {}) {
492
533
  const instructions = planInstructions({ paths, target: instructionFile, removing, fsImpl,
493
534
  state: instructionState({ target: instructionFile, context, resources: neutralResources, paths, fsImpl }) });
494
535
  const trees = planTrees({ assets: options.assets, paths, scope, neutralResources, removing, repoRoot: context.repoRoot, force: context.force === true, fsImpl });
536
+ const legacyShim = removing ? { changes: [], notices: [] } : planLegacyGeminiShim({ assets: options.assets, paths, scope, fsImpl });
495
537
  const mcp = planMcpEntries({
496
538
  ...mcpEntriesInput({ paths, mcp: selectedServers, mcpAdoptable: options.mcpAdoptable, resources: neutralResources }),
497
539
  files: discovery.mcpSnapshot, removing,
@@ -501,14 +543,14 @@ function plan(options = {}, impl = {}) {
501
543
  const hooksPlan = context.hooksOff === true && !removing ? { changes: [], conflicts: [] }
502
544
  : planHooks({ paths, scope, neutralResources, removing, repoRoot: context.repoRoot, fsImpl });
503
545
 
504
- const changes = [...instructions.changes, ...trees.changes, ...mcp.changes, ...hooksPlan.changes];
546
+ const changes = [...instructions.changes, ...trees.changes, ...legacyShim.changes, ...mcp.changes, ...hooksPlan.changes];
505
547
  const conflicts = [...instructions.conflicts, ...trees.conflicts, ...mcp.conflicts, ...hooksPlan.conflicts];
506
548
  return {
507
549
  changes,
508
550
  conflicts,
509
551
  prerequisites: [],
510
552
  requiredNativeResources: changes,
511
- notices: [...instructions.notices, ...trees.notices, ...mcp.notices],
553
+ notices: [...instructions.notices, ...trees.notices, ...legacyShim.notices, ...mcp.notices],
512
554
  };
513
555
  }
514
556
 
@@ -194,6 +194,17 @@ function planCodexAgents({ scope, codexDir, projectRoot, sourceDir, sourceVersio
194
194
  else if (current === null || current !== agent.source) changes.push({ type: current === null ? 'create' : 'update', identity, file, content: agent.source });
195
195
  nextManagedResources.push(resourceFor(agent, scope, sourceVersion));
196
196
  }
197
+ // An owned agent the source no longer ships (removed, or downgraded out of the selection): the ledger
198
+ // row is dropped above, so the file has to be dealt with here or it stays in ~/.codex/agents for good.
199
+ // An unmodified file is removed; one the user changed is released (ownership dropped, file kept).
200
+ const shipped = new Set(agents.map((agent ) => `agent:${agent.name}`));
201
+ for (const [identity, record] of owned) {
202
+ if (shipped.has(identity) || !/^agent:[a-z][a-z0-9-]*$/.test(identity)) continue;
203
+ const file = destinationFor(directory, identity.slice('agent:'.length));
204
+ if (!fsImpl.existsSync(file)) continue;
205
+ if (fingerprint(fsImpl.readFileSync(file, 'utf8')) === record.fingerprint) changes.push({ type: 'remove', identity, file });
206
+ else changes.push({ type: 'remove', identity, file, release: true });
207
+ }
197
208
  if (conflicts.length) return { ok: false, status: 'conflict', directory, agents, changes: [], conflicts, managedResources };
198
209
  return {
199
210
  ok: true, status: changes.length ? 'change' : 'unchanged', directory, agents, changes, conflicts: [],
@@ -203,7 +214,10 @@ function planCodexAgents({ scope, codexDir, projectRoot, sourceDir, sourceVersio
203
214
 
204
215
  function applyCodexAgents(plan , { dryRun = false, fsImpl = fs } = {}) {
205
216
  if (!plan.ok || dryRun || plan.status === 'unchanged') return { ...plan, applied: false };
206
- for (const change of plan.changes) atomicWrite(change.file, change.content, fsImpl);
217
+ for (const change of plan.changes) {
218
+ if (change.type !== 'remove') atomicWrite(change.file, change.content, fsImpl);
219
+ else if (!change.release) fsImpl.rmSync(change.file, { force: true });
220
+ }
207
221
  return { ...plan, applied: true };
208
222
  }
209
223
 
@@ -202,8 +202,11 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
202
202
  if (nextLines.length && nextLines.at(-1) !== '') nextLines.push('');
203
203
  nextLines.push(`[${table}]`, ...lines);
204
204
  }
205
- let content = nextLines.join('\n');
206
- if (content && !content.endsWith('\n')) content += '\n';
205
+ // The file keeps its own line ending (the first one it uses) and its own final-newline state; only a file
206
+ // that was empty or ended in a newline gets one, so a remove can hand back the user's exact bytes.
207
+ const eol = /\r?\n/.exec(original)?.[0] ?? '\n';
208
+ let content = nextLines.join(eol);
209
+ if (content && !content.endsWith('\n') && (original === '' || original.endsWith('\n'))) content += eol;
207
210
  const invalid = newViolation({ file, original, content, identities: changes.map((change ) => change.identity) });
208
211
  if (invalid) return { ok: false, status: 'conflict', file, original, changes: [], conflicts: [invalid], managedResources };
209
212
  const nextManagedResources = managedResources.filter((resource ) => !isOwnedRecord(resource, scope) || desired.has(resource.identity));
@@ -36,6 +36,7 @@ const path = require('node:path');
36
36
  const { MARKER_START, MARKER_END } = require('../../helper/marker-merge.js');
37
37
  const { planTree, applyTree, removeTree, verifyTree, copyTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, sourceDirFor, resolveTransform } = require('../copy-tree.js');
38
38
  const { renderGuidancePointer } = require('../guidance-pointer.js');
39
+ const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
39
40
  const { readMcpFiles, planMcpEntries, verifyMcpEntries, ownedMcpIds, writeMcpEntries } = require('../mcp-entries.js');
40
41
  const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
41
42
 
@@ -108,7 +109,8 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
108
109
  * file it doesn't fully own is a decision for the file's owner, not a guess for the installer. Add
109
110
  * an empty marker pair to opt in. */
110
111
  function managedInstruction(existing , rendered ) {
111
- if (existing === null) return { ok: true, operation: 'create', content: rendered };
112
+ // A file holding only whitespace has nothing to protect and counts as absent.
113
+ if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
112
114
  const start = existing.indexOf(MARKER_START);
113
115
  const end = existing.indexOf(MARKER_END);
114
116
  if (start === -1 && end === -1) return { ok: false, conflict: `${INSTRUCTION_FILE} exists without a DoFlow managed section` };
@@ -132,7 +134,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
132
134
  /** Plan the single instructions change (or none). Global scope always yields no change and no
133
135
  * conflict — the user-level ~/.copilot/copilot-instructions.md is not projected, so there is no
134
136
  * global target, the same way other adapters skip a capability they do not project at a scope. */
135
- function planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, fsImpl } ) {
137
+ function planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, ledger, fsImpl } ) {
136
138
  if (!found.paths.instruction) return { changes: [], conflicts: [] };
137
139
  // The marker-managed instruction file is one specific asset: exclude tree-projected rule
138
140
  // assets (copilot-rule-instructions), whose `source` is a directory, not a file.
@@ -148,6 +150,8 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
148
150
  assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
149
151
  content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
150
152
  fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'copilot-instructions' },
153
+ // A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
154
+ ...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}),
151
155
  }],
152
156
  conflicts: [],
153
157
  };
@@ -305,7 +309,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
305
309
  const conflicts = [];
306
310
  const removing = context.operation === 'remove';
307
311
 
308
- const instructions = planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, fsImpl });
312
+ const instructions = planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, ledger, fsImpl });
309
313
  changes.push(...instructions.changes);
310
314
  conflicts.push(...instructions.conflicts);
311
315
 
@@ -365,6 +369,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
365
369
  if (change.operation !== 'remove') continue;
366
370
  if (change.projection?.renderer === 'copy-tree' || change.kind === 'mcp-server') continue;
367
371
  if (typeof change.content !== 'string') continue;
372
+ if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
368
373
  writeChange(change, fsImpl);
369
374
  removed += 1;
370
375
  }
@@ -389,9 +394,11 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
389
394
  status: hasSection ? 'managed' : 'absent', target: found.paths.instruction,
390
395
  ownershipIdentity: `${HARNESS}:instructions:managed-section` });
391
396
  if (hasSection) {
397
+ const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity: `${HARNESS}:instructions:managed-section`, target: found.paths.instruction, before: discovery?.instruction });
392
398
  resources.push({ assetId, target: found.paths.instruction,
393
399
  ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
394
- sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'copilot-instructions' } });
400
+ sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'copilot-instructions' },
401
+ ...(origin ? { origin } : {}) });
395
402
  }
396
403
  }
397
404
 
@@ -661,6 +661,13 @@ function applyTree({ changes = [], fsImpl = fs, transform } )
661
661
  if (change.operation === 'remove') continue;
662
662
  fsImpl.mkdirSync(path.dirname(change.target), { recursive: true });
663
663
  const sourceStat = fsImpl.statSync(change.source);
664
+ // A destination symlink (`install -f` plans over one) is replaced by the regular file, never written through:
665
+ // its target, which may be a file outside the install, keeps its bytes and mode.
666
+ try {
667
+ if (fsImpl.lstatSync(change.target).isSymbolicLink()) fsImpl.unlinkSync(change.target);
668
+ } catch (error ) {
669
+ if (error.code !== 'ENOENT') throw error;
670
+ }
664
671
  if (mapContent) {
665
672
  // Re-derive the bytes from the source at write time instead of copying it: the transform,
666
673
  // not the source file, is what the recorded fingerprint describes. Keeps recovery retries
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // Shared by the adapters whose instruction file (and opencode.json) is a file DoFlow may have
4
+ // created: the ledger row records `origin: { created: 'file' }` when the file was absent before the
5
+ // install, so a removal that leaves nothing but whitespace can delete exactly the files DoFlow made
6
+ // and keep every file that existed beforehand. Same record Pi's mcp.json rows carry (pi/mcp.ts).
7
+ const fs = require('node:fs');
8
+
9
+ /** Whitespace is not content: a file holding only whitespace carries none of the user's text. */
10
+ function isBlank(text ) { return typeof text === 'string' && text.trim() === ''; }
11
+
12
+ function ownedRow(ledger , harness , ownershipIdentity , target ) {
13
+ return (ledger?.resources || []).find((row ) => row.harness === harness
14
+ && row.ownershipIdentity === ownershipIdentity && row.target === target);
15
+ }
16
+
17
+ /** Whether the ledger row for `ownershipIdentity` at `target` says DoFlow created that file. */
18
+ function createdByDoFlow(ledger , harness , ownershipIdentity , target ) {
19
+ return ownedRow(ledger, harness, ownershipIdentity, target)?.origin?.created === 'file';
20
+ }
21
+
22
+ /** The origin to record on the row verify emits. A row already in the ledger passes its origin on
23
+ * (absent for rows an older version wrote, which are never guessed at). With no row, `before` is
24
+ * the file as plan read it: `null` means absent, so this install creates it. `undefined` means the
25
+ * caller has no pre-apply read (a status verify), and nothing is recorded. */
26
+ function creationOrigin({ ledger, harness, ownershipIdentity, target, before } ) {
27
+ const row = ownedRow(ledger, harness, ownershipIdentity, target);
28
+ if (row) return row.origin;
29
+ return before === null ? { created: 'file' } : undefined;
30
+ }
31
+
32
+ /** Delete `target` unless it is a symlink (the link is the user's, not DoFlow's). */
33
+ function removeCreatedFile(target , fsImpl = fs) {
34
+ try {
35
+ if (fsImpl.lstatSync(target).isSymbolicLink()) return false;
36
+ fsImpl.rmSync(target);
37
+ return true;
38
+ } catch { return false; }
39
+ }
40
+
41
+ module.exports = { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile };
@@ -281,15 +281,25 @@ function writePath(file , fsImpl ) {
281
281
  try { return fsImpl.realpathSync(file); } catch { return path.resolve(path.dirname(file), fsImpl.readlinkSync(file)); }
282
282
  }
283
283
 
284
+ /** The indent the file already uses (its first indented line, tabs included), else two spaces. */
285
+ function indentOf(file , fsImpl ) {
286
+ try {
287
+ const found = /^\s*[{[][ \t]*\r?\n([ \t]+)\S/.exec(fsImpl.readFileSync(file, 'utf8'));
288
+ if (found) return found[1].slice(0, 10);
289
+ } catch { /* absent or unreadable: the default */ }
290
+ return ' ';
291
+ }
292
+
284
293
  /** Write a JSON document atomically, keeping the existing file's permission bits: an MCP file kept
285
294
  * private for its tokens stays so. Creates missing directories; never deletes a file. */
286
295
  function writeMcpJson(file , doc , { fsImpl = fs } = {}) {
287
296
  const dest = writePath(file, fsImpl);
288
297
  fsImpl.mkdirSync(path.dirname(dest), { recursive: true });
289
298
  const mode = fsImpl.existsSync(dest) ? fsImpl.statSync(dest).mode & 0o7777 : null;
299
+ const indent = indentOf(dest, fsImpl);
290
300
  const temp = path.join(path.dirname(dest), `.${path.basename(dest)}.${process.pid}.${Date.now()}.tmp`);
291
301
  try {
292
- fsImpl.writeFileSync(temp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', ...(mode === null ? {} : { mode }) });
302
+ fsImpl.writeFileSync(temp, `${JSON.stringify(doc, null, indent)}\n`, { encoding: 'utf8', flag: 'wx', ...(mode === null ? {} : { mode }) });
293
303
  if (mode !== null) fsImpl.chmodSync(temp, mode);
294
304
  fsImpl.renameSync(temp, dest);
295
305
  } finally {