@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.
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
- package/core/registry/contribution-points.json +1 -1
- package/core/registry/intake.json +3 -3
- package/core/registry/lifecycle.json +256 -5
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/settings.json +12 -12
- package/core/registry/storage.json +1 -2
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bin/doflow-run +18 -0
- package/core/shared/skills/do-design/SKILL.md +6 -2
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +7 -0
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +24 -12
- package/src/cli/runtime-commands.js +1 -1
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +5 -1
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +17 -5
- package/src/runtime/context-pack.js +48 -8
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +9 -4
- package/src/runtime/implementation-gate.js +10 -2
- package/src/runtime/intake/engine.js +11 -9
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +7 -5
- package/src/runtime/lifecycle/overview.js +32 -2
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/loop.js +14 -3
- package/src/runtime/roadmap/rows.js +2 -1
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +11 -4
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +6 -5
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/readiness.js +8 -4
- package/src/runtime/worker/supervisor.js +18 -6
- 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
|
|
6
|
-
> (Data Model and Sequence / Data Flow) and specs.md's interface-contract detail (§1). Reads
|
|
7
|
-
> the way a persisted shape and the flows that move through it
|
|
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`.
|
|
12
|
-
>
|
|
13
|
-
>
|
|
14
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
<!--
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
77
|
+
| `[table_name]` | [one-line responsibility] | [key] | [fk → table(key)] | [index] | [path] |
|
|
82
78
|
|
|
83
79
|
### UX / UI Specifications
|
|
84
|
-
- **Design Tokens & Cues:** [
|
|
85
|
-
- **Component States:** [loading, empty, populated, error
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
participant SVC as [service]
|
|
97
|
+
participant SVC as [component]
|
|
122
98
|
participant DB as [data store]
|
|
123
99
|
|
|
124
|
-
User->>
|
|
125
|
-
API->>SVC: [call]
|
|
126
|
-
|
|
100
|
+
User->>SVC: [request]
|
|
127
101
|
alt [failure condition]
|
|
128
|
-
SVC-->>
|
|
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-->>
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
action, an idempotency requirement — state them in a sentence beneath the diagram rather than
|
|
142
|
-
crowding the fence.]
|
|
142
|
+
**Transitions**
|
|
143
143
|
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
feature's flow involves a single actor. -->
|
|
152
|
+
**Terminal states:** [state, state]
|
|
157
153
|
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
| Entity | Retention | PII class | Migration | Source |
|
|
195
|
+
|---|---|---|---|---|
|
|
196
|
+
| [entity] | [period or rule] | [none, direct, indirect] | [step or none] | [Source] |
|
|
163
197
|
|
|
164
|
-
##
|
|
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
|
@@ -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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|