devrites 6.1.2 → 6.1.3
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/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/docs/command-map.md +1 -1
- package/engine/commands.go +1 -1
- package/engine/help.go +5 -1
- package/engine/internal/acceptance/cli.go +14 -5
- package/engine/internal/acceptance/run.go +20 -5
- package/engine/internal/lib/apply_bundle.go +474 -0
- package/engine/internal/lib/diffscope.go +2 -1
- package/engine/main.go +4 -0
- package/pack/.claude/agents/devrites-plan-drafter.md +11 -2
- package/pack/.claude/agents/devrites-slice-wright.md +4 -1
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
- package/pack/.claude/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +2 -1
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -0
- package/pack/.claude/skills/rite-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-plan/SKILL.md +20 -9
- package/pack/.claude/skills/rite-polish/SKILL.md +6 -4
- package/pack/.claude/skills/rite-prove/SKILL.md +3 -2
- package/pack/.claude/skills/rite-review/SKILL.md +6 -1
- package/pack/.claude/skills/rite-seal/SKILL.md +2 -2
- package/pack/.claude/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/claude/agents/devrites-plan-drafter.md +11 -2
- package/pack/generated/claude/agents/devrites-slice-wright.md +4 -1
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
- package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +2 -1
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -0
- package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/claude/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/claude/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/claude/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/codex/agents/devrites-plan-drafter.toml +11 -2
- package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -1
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
- package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +2 -1
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -0
- package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/codex/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/codex/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/codex/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/devin/agents/devrites-plan-drafter.md +11 -2
- package/pack/generated/devin/agents/devrites-slice-wright.md +4 -1
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
- package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
- package/pack/generated/devin/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
- package/pack/generated/devin/skills/rite-build/reference/one-slice-cycle.md +2 -1
- package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +7 -0
- package/pack/generated/devin/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/devin/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/devin/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/devin/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/devin/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/devin/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/devin/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/devin/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/omp/agents/devrites-plan-drafter.md +11 -2
- package/pack/generated/omp/agents/devrites-slice-wright.md +4 -1
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
- package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
- package/pack/generated/omp/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
- package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +2 -1
- package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +7 -0
- package/pack/generated/omp/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/omp/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/omp/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/omp/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/omp/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/omp/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/omp/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/omp/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/pi/agents/devrites-plan-drafter.md +11 -2
- package/pack/generated/pi/agents/devrites-slice-wright.md +4 -1
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
- package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
- package/pack/generated/pi/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
- package/pack/generated/pi/skills/rite-build/reference/one-slice-cycle.md +2 -1
- package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +7 -0
- package/pack/generated/pi/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/pi/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/pi/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/pi/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/pi/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/pi/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/pi/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/pi/skills/rite-vet/SKILL.md +9 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [6.1.3](https://github.com/ViktorsBaikers/DevRites/compare/v6.1.2...v6.1.3) (2026-10-16)
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
* **devrites:** apply plan bundles in the engine and overlap phase work ([#109](https://github.com/ViktorsBaikers/DevRites/issues/109)) ([4394b0a](https://github.com/ViktorsBaikers/DevRites/commit/4394b0af21677da279be0ca5c359af728e482fd9))
|
|
10
|
+
|
|
5
11
|
## [6.1.2](https://github.com/ViktorsBaikers/DevRites/compare/v6.1.1...v6.1.2) (2026-10-16)
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ history can't lose it. After you clear the conversation, switch hosts, or hand
|
|
|
31
31
|
the feature to another engineer, the next session reads the same plan,
|
|
32
32
|
decisions, and evidence before it continues.
|
|
33
33
|
|
|
34
|
-
**Status:** [`v6.1.
|
|
34
|
+
**Status:** [`v6.1.3`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v6.1.3): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
35
35
|
|
|
36
36
|
This is the latest published release; `main` may contain unreleased work.
|
|
37
37
|
|
package/docs/command-map.md
CHANGED
|
@@ -44,7 +44,7 @@ The Go surface is intentionally closed and deterministic:
|
|
|
44
44
|
| Candidate and deterministic checks | `check candidate`, `check readiness [--emit-binding]`, `check seal`, `check slice <slug> <SLICE-ID>`, `check task-graph`, `check regression <slug> [--update]`, `check drift <slug> [--record]`, `check windows <slug>`, `check dup`, `check path-disjoint`, `parallel select`, `check skill-trust`, `check indexes`, `detect commands [--root <dir>] [--json]` | Candidate validates/hashes the strict project manifest; readiness checks phase files, the required `tasks.md` slice graph, or emits the vetted Build-input binding; seal checks final files/open gates, that graph and binding, then exact candidate bindings. `check slice` preflights one slice before wright dispatch — contract present, exact Writer allowlist paths, `Satisfies` AC ids resolvable, bounded scope — so a malformed slice fails before a writer context is spent. `check indexes` reports install manifest and optional code-index directory presence as JSON. `parallel select` returns the greedy path-disjoint subset ≤ cap from the supplied ready slices. `check regression` compares current workspace progress against the durable `regression-baseline.json` high-water mark and `--update` ratchets it at a checkpoint, so a lost slice, unchecked criterion, or reopened question fails closed instead of passing silently. `check windows` scans the feature diff for newly introduced deferral markers (TODO/FIXME/skipped test/stub idioms) and fails when any lacks a `windows.md` waiver row — silent deferrals block the seal. `check dup` reports advisory near-duplicate clusters that survive renaming (normalized token shingles, transitive clusters, distance-boosted rank, `*`-marked diff overlap, stable content hashes, `.devrites/dup-ignore` dismissal ledger) — leads for reviewer triage, never a gate. `check drift` attributes readiness-input drift to the exact artifact: `--record` snapshots per-input SHA-256 digests into `readiness-inputs.json` under the feature lock, and a later compare names each `changed`/`missing`/`added` input (falling back to the aggregate `eng-review.md` binding plus mtime candidates when no baseline exists) so a stale-binding repair starts at the right file — advisory, never a gate. `detect commands` resolves the repository's own test/lint/vet/build commands (Makefile targets, then `package.json` scripts, then language-manifest fallbacks, plus the lockfile-derived package manager) so `gates.md` `CHECK` lines and proof steps cite real wiring instead of invented commands — read-only, no installs, no execution. |
|
|
45
45
|
| Workspace observation | `observe summary`, `orient`, `observe slice <slug> <SLICE-ID>`, `next`, `handoff` | JSON workspace snapshot for agents (cursor, task graph, `artifact_budgets`, `bulk_files`); `orient` is an alias for `observe summary`. `observe slice` prints one `SLICE-###` section of `tasks.md` so Build reads a slice, not the file. `next` prints the minimal remaining lifecycle path plus advisory skips (e.g. clarify at zero open questions). `handoff` emits the deterministic resume record (cursor, `awaiting_human`, blocking question gates, ledger reduction with unmet/stale ids, dead ends, read-next order) — the `/rite-handoff` spine and compaction-fallback entry. |
|
|
46
46
|
| Context packets, dispatch waves, metrics, claims, notes | `context [slug] (--phase <p> \| --skill <name>) [--role <r>] [--trigger a,b]`, `dispatch <slug> <open\|start\|seal\|return\|status\|abandon>`, `metrics record`, `metrics summary`, `claim <add\|release\|list\|check>`, `note <add\|list\|check\|rm>` | `context` emits one deduplicated read-set bundle per phase/role/skill from each skill's `loads:` manifest — a dispatch target reads it instead of selecting files; `--skill` works without a workspace when `--out` is given. `dispatch` owns the launch-wave barrier (seal needs a handle per role; return needs seal) and auto-records dispatch/return metrics. `metrics` owns the append-only `metrics.jsonl` ledger. `claim` owns the append-only `.devrites/claims.jsonl` ledger — advisory, session-scoped file claims with TTL so concurrent same-tree sessions see each other's write intent before edits collide (conflict exits 3; never a substitute for one-writer-per-worktree). `note` owns the anchored `notes.md` ledger — notes bound to verbatim code quotes, regraded `exact`/`moved`/`stale`/`ambiguous`/`lost` as the code drifts; `check seal` refuses non-`exact` anchors. `check diff-scope` is the mechanical changed-paths ⊆ allowlist gate run before reviewer dispatch. |
|
|
47
|
-
| Atomic workspace state | `state resolve`, `state merge-manifest`, `state close`, `migrate [--dry-run] [--answer id=choice]` | Go owns answer/drop/batch resolution, the recorded predecessor-chain manifest union for a release milestone, transactional close, and fail-closed v5 schema normalization. |
|
|
47
|
+
| Atomic workspace state | `state resolve`, `state merge-manifest`, `state apply-bundle <slug> <bundle.json>... [--dry-run]`, `state close`, `migrate [--dry-run] [--answer id=choice]` | Go owns answer/drop/batch resolution, the recorded predecessor-chain manifest union for a release milestone, the all-or-nothing apply of plan-drafter hunk bundles (base-hash pins, unique non-overlapping anchors, no lost IDs, no new task-graph or budget defect, no appended dated delta section), transactional close, and fail-closed v5 schema normalization. |
|
|
48
48
|
| Acceptance ledger | `gates scaffold`, `gates status`, `gates run [--timeout N]`, `gates reverify [--timeout N]`, `gates lint [--strict]`, `gates attest <id> <note>`, `gates abandon <id> <why>` | Go owns the `gates.md` ledger: per-outcome gates whose runnable `CHECK`/`CWD` must exactly match a `test-plan.md` Build-entry preflight row, evidence bound to the gate definition digest (stale = unmet), `ABANDON:` handoffs, and oracle lint. Readiness/seal require a parse-clean, all-met ledger whenever `gates.md` is a required artifact. |
|
|
49
49
|
| Security | `secret-scan` | `/rite-ship`, safe hooks, or an operator scans staged blobs, stdin, or touched files. |
|
|
50
50
|
| Install/operator | `install`, `update`, `uninstall`, `version` | Direct update acquires latest; npm/bootstrap may supply local candidates; the engine performs manifest-owned local changes. |
|
package/engine/commands.go
CHANGED
|
@@ -34,7 +34,7 @@ func rootModeFor(command string, args []string) rootMode {
|
|
|
34
34
|
return rootLenient
|
|
35
35
|
case "state":
|
|
36
36
|
switch subcommand {
|
|
37
|
-
case "resolve", "merge-manifest", "close":
|
|
37
|
+
case "resolve", "merge-manifest", "apply-bundle", "close":
|
|
38
38
|
return rootStrict
|
|
39
39
|
}
|
|
40
40
|
return rootUnused
|
package/engine/help.go
CHANGED
|
@@ -30,7 +30,7 @@ const (
|
|
|
30
30
|
check dup [slug] [--all|--worktree|--staged|--base <ref>]
|
|
31
31
|
`
|
|
32
32
|
|
|
33
|
-
stateUsage = `usage: devrites-engine state <resolve|merge-manifest|close> ...
|
|
33
|
+
stateUsage = `usage: devrites-engine state <resolve|merge-manifest|apply-bundle|close> ...
|
|
34
34
|
|
|
35
35
|
state resolve <qid> "<answer>"
|
|
36
36
|
state resolve --drop <qid> ["<reason>"]
|
|
@@ -39,6 +39,7 @@ const (
|
|
|
39
39
|
state resolve --human --drop <qid> ["<reason>"]
|
|
40
40
|
state resolve --human --batch <file>
|
|
41
41
|
state merge-manifest <slug> [<predecessor>...]
|
|
42
|
+
state apply-bundle <slug> <bundle.json>... [--dry-run]
|
|
42
43
|
state close <slug>
|
|
43
44
|
`
|
|
44
45
|
|
|
@@ -86,6 +87,7 @@ const (
|
|
|
86
87
|
dupUsageLine = "usage: devrites-engine check dup [slug] [--all|--worktree|--staged|--base <ref>] [--min-lines n] [--threshold f] [--ignore-file <path>] [--limit n] [--exclude <csv>] [--phase p] [--cwd <dir>]"
|
|
87
88
|
resolveUsage = `usage: devrites-engine state resolve <qid> "<answer>" | state resolve --drop <qid> ["<reason>"] | state resolve --batch <file> | state resolve --human <qid> "<answer>" | state resolve --human --drop <qid> ["<reason>"] | state resolve --human --batch <file>`
|
|
88
89
|
mergeManifestUsage = "usage: devrites-engine state merge-manifest <slug> [<predecessor>...]"
|
|
90
|
+
applyBundleUsage = "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]"
|
|
89
91
|
closeUsage = "usage: devrites-engine state close <slug>"
|
|
90
92
|
observeSliceUsage = "usage: devrites-engine observe slice <slug> <SLICE-ID>"
|
|
91
93
|
observeSummaryUsage = "usage: devrites-engine observe summary [slug]"
|
|
@@ -286,6 +288,8 @@ func helpState(rest []string) string {
|
|
|
286
288
|
return resolveUsage
|
|
287
289
|
case "merge-manifest":
|
|
288
290
|
return mergeManifestUsage
|
|
291
|
+
case "apply-bundle":
|
|
292
|
+
return applyBundleUsage
|
|
289
293
|
case "close":
|
|
290
294
|
return closeUsage
|
|
291
295
|
default:
|
|
@@ -248,9 +248,19 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
|
|
|
248
248
|
return ExitBlocked
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
// The gates lock serializes check execution; the feature lock is held only
|
|
252
|
+
// to read inputs and per writeback, so dispatch and other workspace writers
|
|
253
|
+
// are not blocked for the whole (often minutes-long) run.
|
|
254
|
+
featureLock := func(fn func() error) error { return state.WithFeatureLock(root, slug, fn) }
|
|
255
|
+
lockErr := state.WithLock(ledgerPath+".lock", func() error {
|
|
256
|
+
var text string
|
|
257
|
+
var approved []ApprovedCommand
|
|
258
|
+
if err := featureLock(func() error {
|
|
259
|
+
var err error
|
|
260
|
+
text, err = ReadLedgerFile(ledgerPath)
|
|
261
|
+
approved = loadTestPlan(testPlanPath)
|
|
262
|
+
return err
|
|
263
|
+
}); err != nil {
|
|
254
264
|
return err
|
|
255
265
|
}
|
|
256
266
|
doc := ParseLedger(text)
|
|
@@ -260,8 +270,7 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
|
|
|
260
270
|
}
|
|
261
271
|
return nil
|
|
262
272
|
}
|
|
263
|
-
|
|
264
|
-
for _, result := range RunGates(context.Background(), project, ledgerPath, doc, approved, reverify, timeout) {
|
|
273
|
+
for _, result := range RunGates(context.Background(), project, ledgerPath, doc, approved, reverify, timeout, featureLock) {
|
|
265
274
|
switch {
|
|
266
275
|
case !result.Ran:
|
|
267
276
|
fmt.Fprintf(stdout, "gate %s: skipped (%s)\n", result.ID, result.Detail)
|
|
@@ -65,9 +65,19 @@ func ReadLedgerFile(ledgerPath string) (string, error) {
|
|
|
65
65
|
// Each pass writes definition-bound evidence; each failure clears the box and
|
|
66
66
|
// restores EVIDENCE: pending.
|
|
67
67
|
//
|
|
68
|
-
//
|
|
69
|
-
|
|
68
|
+
// Gates sharing one CHECK and CWD execute once per call; each matches its own
|
|
69
|
+
// EXPECT against that shared output. writeLock, when non-nil, wraps every
|
|
70
|
+
// ledger writeback so the caller need not hold the feature lock while checks
|
|
71
|
+
// execute; writeResult re-verifies the gate's oracle before it writes.
|
|
72
|
+
func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, approved []ApprovedCommand, reverify bool, timeout time.Duration, writeLock func(func() error) error) []GateResult {
|
|
70
73
|
var results []GateResult
|
|
74
|
+
outcomes := map[string]RunOutcome{}
|
|
75
|
+
write := func(gate *Gate, evidence string, checked bool) error {
|
|
76
|
+
if writeLock == nil {
|
|
77
|
+
return writeResult(ledgerPath, gate, evidence, checked)
|
|
78
|
+
}
|
|
79
|
+
return writeLock(func() error { return writeResult(ledgerPath, gate, evidence, checked) })
|
|
80
|
+
}
|
|
71
81
|
for _, gate := range doc.Gates {
|
|
72
82
|
if _, abandoned := doc.Abandoned[gate.ID]; abandoned {
|
|
73
83
|
results = append(results, GateResult{ID: gate.ID, Detail: "abandoned; handoff required"})
|
|
@@ -94,17 +104,22 @@ func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, appro
|
|
|
94
104
|
if gate.Cwd != "" {
|
|
95
105
|
cwd = filepath.Join(root, filepath.FromSlash(gate.Cwd))
|
|
96
106
|
}
|
|
97
|
-
|
|
107
|
+
key := gate.Check + "\x00" + cwd
|
|
108
|
+
outcome, ran := outcomes[key]
|
|
109
|
+
if !ran {
|
|
110
|
+
outcome = Execute(ctx, gate.Check, cwd, timeout)
|
|
111
|
+
outcomes[key] = outcome
|
|
112
|
+
}
|
|
98
113
|
if outcome.Err == nil && outcome.ExitCode == 0 && expectation.Matches(outcome.Output) {
|
|
99
114
|
evidence := FormatEvidence(gate, OutputDigest(outcome.Output), outcome.OutputBytes, time.Now().UTC().Format(time.RFC3339))
|
|
100
|
-
writeErr :=
|
|
115
|
+
writeErr := write(gate, evidence, true)
|
|
101
116
|
results = append(results, GateResult{
|
|
102
117
|
ID: gate.ID, Ran: true, Passed: true,
|
|
103
118
|
Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
|
|
104
119
|
Detail: resultDetail(writeErr, "pass"),
|
|
105
120
|
})
|
|
106
121
|
} else {
|
|
107
|
-
writeErr :=
|
|
122
|
+
writeErr := write(gate, "pending", false)
|
|
108
123
|
results = append(results, GateResult{
|
|
109
124
|
ID: gate.ID, Ran: true,
|
|
110
125
|
Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"io/fs"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
"regexp"
|
|
14
|
+
"slices"
|
|
15
|
+
"sort"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
19
|
+
"github.com/devrites/devrites/internal/fsutil"
|
|
20
|
+
"github.com/devrites/devrites/internal/state"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// maxBundleBytes bounds one drafter bundle read. A bundle carries hunks for a
|
|
24
|
+
// handful of planning artifacts, each capped at state.MaxArtifactBytes.
|
|
25
|
+
const maxBundleBytes = 8 << 20
|
|
26
|
+
|
|
27
|
+
// trackedIDRE matches the workspace ID families whose silent disappearance is
|
|
28
|
+
// a planning defect: an AC, slice, or horizon item never vanishes unrecorded.
|
|
29
|
+
var trackedIDRE = regexp.MustCompile(`\b(?:AC|REQ|HZN|SLICE|PF|INV|DEC|PROH|EDGE|DRIFT|ASM|Q|EVID)-\d+(?:-[A-Z][A-Z0-9]*)*\b`)
|
|
30
|
+
|
|
31
|
+
// datedHeadingRE finds a dated section heading. In the current-state planning
|
|
32
|
+
// artifacts it marks an appended repair delta that leaves the superseded clause
|
|
33
|
+
// beside its replacement, growing the file and the next review's surface.
|
|
34
|
+
var datedHeadingRE = regexp.MustCompile(`(?m)^#{2,4}\s.*\b\d{4}-\d{2}-\d{2}\b`)
|
|
35
|
+
|
|
36
|
+
var currentStatePlanning = map[string]bool{
|
|
37
|
+
"architecture.md": true, "plan.md": true, "tasks.md": true, "test-plan.md": true, "traceability.md": true,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// candidateBundle is the plan-drafter's candidate_files result. Unknown fields
|
|
41
|
+
// (acceptance_map, technical_decisions, ...) are the root's to reconcile and
|
|
42
|
+
// are ignored here.
|
|
43
|
+
type candidateBundle struct {
|
|
44
|
+
BaseHashes map[string]string `json:"base_hashes"`
|
|
45
|
+
CandidateFiles []candidateFile `json:"candidate_files"`
|
|
46
|
+
RetiredIDs []string `json:"retired_ids"`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type candidateFile struct {
|
|
50
|
+
Path string `json:"path"`
|
|
51
|
+
Operation string `json:"operation"`
|
|
52
|
+
Content *string `json:"content"`
|
|
53
|
+
Hunks []candidateHunk `json:"hunks"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type candidateHunk struct {
|
|
57
|
+
ID string `json:"id"`
|
|
58
|
+
OldText string `json:"old_text"`
|
|
59
|
+
NewText string `json:"new_text"`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// bundleTarget is every requested change to one workspace file, merged across
|
|
63
|
+
// bundles so anchor-disjoint drafter clusters apply against one shared base.
|
|
64
|
+
type bundleTarget struct {
|
|
65
|
+
rel string
|
|
66
|
+
op string // create | replace | patch
|
|
67
|
+
content string
|
|
68
|
+
hunks []candidateHunk
|
|
69
|
+
pins []string // base hashes named by each bundle that touches rel
|
|
70
|
+
before []byte
|
|
71
|
+
existed bool
|
|
72
|
+
after []byte
|
|
73
|
+
hunkSeen int
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// RunApplyBundle implements `devrites-engine state apply-bundle <slug>
|
|
77
|
+
// <bundle.json>... [--dry-run]`. It replaces the root's hand fold of drafter
|
|
78
|
+
// hunks: every bundle is validated against the current bytes (base hash pin,
|
|
79
|
+
// unique non-overlapping old_text, no lost tracked ID, no new task-graph
|
|
80
|
+
// problem, no new material budget overshoot) and then all files are written,
|
|
81
|
+
// or none are. Several bundles combine when their hunks are disjoint.
|
|
82
|
+
//
|
|
83
|
+
// Exit codes:
|
|
84
|
+
//
|
|
85
|
+
// 0 applied (or dry-run clean)
|
|
86
|
+
// 2 usage error
|
|
87
|
+
// 3 blocked: a bundle failed validation; nothing was written
|
|
88
|
+
// 1 internal/IO error
|
|
89
|
+
func RunApplyBundle(root string, args []string, stdout, stderr io.Writer) int {
|
|
90
|
+
dryRun := false
|
|
91
|
+
var positional []string
|
|
92
|
+
for _, arg := range args {
|
|
93
|
+
if arg == "--dry-run" {
|
|
94
|
+
dryRun = true
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if strings.HasPrefix(arg, "-") {
|
|
98
|
+
fmt.Fprintln(stderr, "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]")
|
|
99
|
+
return 2
|
|
100
|
+
}
|
|
101
|
+
positional = append(positional, arg)
|
|
102
|
+
}
|
|
103
|
+
if len(positional) < 2 {
|
|
104
|
+
fmt.Fprintln(stderr, "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]")
|
|
105
|
+
return 2
|
|
106
|
+
}
|
|
107
|
+
slug := positional[0]
|
|
108
|
+
workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
109
|
+
if err != nil {
|
|
110
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: workspace for %s: %v\n", slug, err)
|
|
111
|
+
return 3
|
|
112
|
+
}
|
|
113
|
+
if err := state.RequireWorkspaceSchema(root, slug); err != nil {
|
|
114
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: %v\n", err)
|
|
115
|
+
return 3
|
|
116
|
+
}
|
|
117
|
+
bundles := make([]candidateBundle, 0, len(positional)-1)
|
|
118
|
+
for _, name := range positional[1:] {
|
|
119
|
+
bundle, err := readCandidateBundle(name)
|
|
120
|
+
if err != nil {
|
|
121
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: bundle %s: %v\n", name, err)
|
|
122
|
+
return 3
|
|
123
|
+
}
|
|
124
|
+
bundles = append(bundles, bundle)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
code := 0
|
|
128
|
+
if err := state.WithFeatureLock(root, slug, func() error {
|
|
129
|
+
code = applyBundlesLocked(slug, workspace, bundles, dryRun, stdout, stderr)
|
|
130
|
+
return nil
|
|
131
|
+
}); err != nil {
|
|
132
|
+
fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
return code
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func readCandidateBundle(name string) (candidateBundle, error) {
|
|
139
|
+
var bundle candidateBundle
|
|
140
|
+
raw, err := readBoundedRegularFile(name, maxBundleBytes)
|
|
141
|
+
if err != nil {
|
|
142
|
+
return bundle, err
|
|
143
|
+
}
|
|
144
|
+
if err := json.Unmarshal(raw, &bundle); err != nil {
|
|
145
|
+
return bundle, fmt.Errorf("not a JSON candidate bundle: %w", err)
|
|
146
|
+
}
|
|
147
|
+
if len(bundle.CandidateFiles) == 0 {
|
|
148
|
+
return bundle, errors.New("candidate_files is empty")
|
|
149
|
+
}
|
|
150
|
+
return bundle, nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func applyBundlesLocked(slug, workspace string, bundles []candidateBundle, dryRun bool, stdout, stderr io.Writer) int {
|
|
154
|
+
ws, err := os.OpenRoot(workspace)
|
|
155
|
+
if err != nil {
|
|
156
|
+
fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
|
|
157
|
+
return 1
|
|
158
|
+
}
|
|
159
|
+
defer func() { _ = ws.Close() }()
|
|
160
|
+
|
|
161
|
+
targets, retired, problems := collectBundleTargets(slug, bundles)
|
|
162
|
+
if len(problems) == 0 {
|
|
163
|
+
problems = materializeTargets(ws, targets)
|
|
164
|
+
}
|
|
165
|
+
if len(problems) == 0 {
|
|
166
|
+
problems = checkBundleResult(targets, retired)
|
|
167
|
+
}
|
|
168
|
+
if len(problems) > 0 {
|
|
169
|
+
for _, problem := range problems {
|
|
170
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: %s\n", problem)
|
|
171
|
+
}
|
|
172
|
+
fmt.Fprintln(stderr, "apply-bundle: nothing written")
|
|
173
|
+
return 3
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
hunks := 0
|
|
177
|
+
for _, target := range targets {
|
|
178
|
+
hunks += len(target.hunks)
|
|
179
|
+
lines := state.CountLines(target.after)
|
|
180
|
+
budget := ""
|
|
181
|
+
if limit, ok := state.ArtifactLineBudget(filepath.Base(target.rel)); ok {
|
|
182
|
+
budget = fmt.Sprintf("/%d", limit)
|
|
183
|
+
}
|
|
184
|
+
fmt.Fprintf(stdout, "apply-bundle: %s %s %d hunk(s) sha256 %s -> %s lines %d%s\n",
|
|
185
|
+
target.rel, target.op, len(target.hunks), shortHash(target.before, target.existed), shortHash(target.after, true), lines, budget)
|
|
186
|
+
}
|
|
187
|
+
if dryRun {
|
|
188
|
+
fmt.Fprintf(stdout, "apply-bundle: dry-run clean: %d file(s), %d hunk(s); nothing written\n", len(targets), hunks)
|
|
189
|
+
return 0
|
|
190
|
+
}
|
|
191
|
+
if err := writeBundleTargets(ws, targets); err != nil {
|
|
192
|
+
fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
|
|
193
|
+
return 1
|
|
194
|
+
}
|
|
195
|
+
fmt.Fprintf(stdout, "apply-bundle: applied %d file(s), %d hunk(s)\n", len(targets), hunks)
|
|
196
|
+
return 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// collectBundleTargets merges every bundle's candidate files by workspace path.
|
|
200
|
+
// One path takes either one create/replace or any number of patch entries.
|
|
201
|
+
func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarget, map[string]bool, []string) {
|
|
202
|
+
var problems []string
|
|
203
|
+
byRel := map[string]*bundleTarget{}
|
|
204
|
+
retired := map[string]bool{}
|
|
205
|
+
prefix := ".devrites/work/" + slug + "/"
|
|
206
|
+
for bi, bundle := range bundles {
|
|
207
|
+
for _, id := range bundle.RetiredIDs {
|
|
208
|
+
retired[strings.TrimSpace(id)] = true
|
|
209
|
+
}
|
|
210
|
+
pins := map[string]string{}
|
|
211
|
+
for path, hash := range bundle.BaseHashes {
|
|
212
|
+
pins[strings.TrimPrefix(filepath.ToSlash(path), prefix)] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
|
|
213
|
+
}
|
|
214
|
+
for _, file := range bundle.CandidateFiles {
|
|
215
|
+
rel := strings.TrimPrefix(filepath.ToSlash(file.Path), prefix)
|
|
216
|
+
where := fmt.Sprintf("bundle %d %s", bi+1, file.Path)
|
|
217
|
+
if !filepath.IsLocal(filepath.FromSlash(rel)) || !strings.HasSuffix(rel, ".md") {
|
|
218
|
+
problems = append(problems, where+": path must be a Markdown file inside the "+slug+" workspace")
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
op := strings.ToLower(strings.TrimSpace(file.Operation))
|
|
222
|
+
switch op {
|
|
223
|
+
case "create", "replace":
|
|
224
|
+
if file.Content == nil {
|
|
225
|
+
problems = append(problems, where+": "+op+" needs content")
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
case "patch":
|
|
229
|
+
if len(file.Hunks) == 0 {
|
|
230
|
+
problems = append(problems, where+": patch needs hunks")
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
problems = append(problems, fmt.Sprintf("%s: unknown operation %q", where, file.Operation))
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
target, seen := byRel[rel]
|
|
238
|
+
if !seen {
|
|
239
|
+
target = &bundleTarget{rel: rel, op: op}
|
|
240
|
+
byRel[rel] = target
|
|
241
|
+
} else if op != "patch" || target.op != "patch" {
|
|
242
|
+
problems = append(problems, where+": conflicts with another candidate for the same file (only patch hunks combine)")
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if op != "create" {
|
|
246
|
+
pin, ok := pins[rel]
|
|
247
|
+
if !ok {
|
|
248
|
+
problems = append(problems, where+": base_hashes has no pin for this file")
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
target.pins = append(target.pins, pin)
|
|
252
|
+
}
|
|
253
|
+
if file.Content != nil {
|
|
254
|
+
target.content = *file.Content
|
|
255
|
+
}
|
|
256
|
+
target.hunks = append(target.hunks, file.Hunks...)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
targets := make([]*bundleTarget, 0, len(byRel))
|
|
260
|
+
for _, target := range byRel {
|
|
261
|
+
targets = append(targets, target)
|
|
262
|
+
}
|
|
263
|
+
sort.Slice(targets, func(i, j int) bool { return targets[i].rel < targets[j].rel })
|
|
264
|
+
return targets, retired, problems
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// materializeTargets reads each file's current bytes, verifies its pins, and
|
|
268
|
+
// computes the after bytes.
|
|
269
|
+
func materializeTargets(ws *os.Root, targets []*bundleTarget) []string {
|
|
270
|
+
var problems []string
|
|
271
|
+
for _, target := range targets {
|
|
272
|
+
before, existed, err := readBundleFile(ws, target.rel)
|
|
273
|
+
if err != nil {
|
|
274
|
+
problems = append(problems, fmt.Sprintf("%s: %v", target.rel, err))
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
target.before, target.existed = before, existed
|
|
278
|
+
if target.op == "create" {
|
|
279
|
+
if existed {
|
|
280
|
+
problems = append(problems, target.rel+": create target already exists")
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
target.after = []byte(target.content)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if !existed {
|
|
287
|
+
problems = append(problems, target.rel+": "+target.op+" target does not exist")
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
current := sha256Hex(before)
|
|
291
|
+
for _, pin := range target.pins {
|
|
292
|
+
if pin != current {
|
|
293
|
+
problems = append(problems, fmt.Sprintf("%s: stale base: pinned %s, current %s", target.rel, abbrev(pin), abbrev(current)))
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if target.op == "replace" {
|
|
298
|
+
target.after = []byte(target.content)
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
after, hunkProblems := applyHunks(string(before), target.hunks)
|
|
302
|
+
for _, problem := range hunkProblems {
|
|
303
|
+
problems = append(problems, target.rel+": "+problem)
|
|
304
|
+
}
|
|
305
|
+
target.after = []byte(after)
|
|
306
|
+
}
|
|
307
|
+
return problems
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// applyHunks replaces each hunk's old_text, which must occur exactly once in
|
|
311
|
+
// base and must not overlap another hunk's span.
|
|
312
|
+
func applyHunks(base string, hunks []candidateHunk) (string, []string) {
|
|
313
|
+
type span struct {
|
|
314
|
+
start, end int
|
|
315
|
+
hunk candidateHunk
|
|
316
|
+
}
|
|
317
|
+
var problems []string
|
|
318
|
+
spans := make([]span, 0, len(hunks))
|
|
319
|
+
for i, hunk := range hunks {
|
|
320
|
+
name := hunk.ID
|
|
321
|
+
if name == "" {
|
|
322
|
+
name = fmt.Sprintf("#%d", i+1)
|
|
323
|
+
}
|
|
324
|
+
hunk.ID = name
|
|
325
|
+
if hunk.OldText == "" {
|
|
326
|
+
problems = append(problems, "hunk "+name+": empty old_text")
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
start := strings.Index(base, hunk.OldText)
|
|
330
|
+
if start < 0 {
|
|
331
|
+
problems = append(problems, "hunk "+name+": old_text not found (stale anchor)")
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
// Search from start+1 so an overlapping second occurrence also counts.
|
|
335
|
+
if strings.Contains(base[start+1:], hunk.OldText) {
|
|
336
|
+
problems = append(problems, "hunk "+name+": old_text occurs more than once (non-unique anchor)")
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
spans = append(spans, span{start, start + len(hunk.OldText), hunk})
|
|
340
|
+
}
|
|
341
|
+
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
|
342
|
+
for i := 1; i < len(spans); i++ {
|
|
343
|
+
if spans[i].start < spans[i-1].end {
|
|
344
|
+
problems = append(problems, fmt.Sprintf("hunks %s and %s overlap", spans[i-1].hunk.ID, spans[i].hunk.ID))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if len(problems) > 0 {
|
|
348
|
+
return base, problems
|
|
349
|
+
}
|
|
350
|
+
var out strings.Builder
|
|
351
|
+
last := 0
|
|
352
|
+
for _, s := range spans {
|
|
353
|
+
out.WriteString(base[last:s.start])
|
|
354
|
+
out.WriteString(s.hunk.NewText)
|
|
355
|
+
last = s.end
|
|
356
|
+
}
|
|
357
|
+
out.WriteString(base[last:])
|
|
358
|
+
return out.String(), nil
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// checkBundleResult rejects an outcome the root would otherwise discover only
|
|
362
|
+
// at Vet: a tracked ID that vanished from every touched file without being
|
|
363
|
+
// listed in retired_ids, a new tasks.md graph problem, or a budgeted artifact
|
|
364
|
+
// that grew into material overshoot without a `Budget override:` line.
|
|
365
|
+
func checkBundleResult(targets []*bundleTarget, retired map[string]bool) []string {
|
|
366
|
+
var problems []string
|
|
367
|
+
beforeIDs, afterIDs := map[string]bool{}, map[string]bool{}
|
|
368
|
+
for _, target := range targets {
|
|
369
|
+
for _, id := range trackedIDRE.FindAllString(string(target.before), -1) {
|
|
370
|
+
beforeIDs[id] = true
|
|
371
|
+
}
|
|
372
|
+
for _, id := range trackedIDRE.FindAllString(string(target.after), -1) {
|
|
373
|
+
afterIDs[id] = true
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
var lost []string
|
|
377
|
+
for id := range beforeIDs {
|
|
378
|
+
if !afterIDs[id] && !retired[id] {
|
|
379
|
+
lost = append(lost, id)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if len(lost) > 0 {
|
|
383
|
+
slices.Sort(lost)
|
|
384
|
+
problems = append(problems, "IDs vanished from every touched file (list them in retired_ids if intended): "+strings.Join(lost, ", "))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for _, target := range targets {
|
|
388
|
+
name := filepath.Base(target.rel)
|
|
389
|
+
if name == "tasks.md" && filepath.Dir(target.rel) == "." {
|
|
390
|
+
old := map[string]bool{}
|
|
391
|
+
if target.existed {
|
|
392
|
+
for _, p := range state.ParseTaskGraph(target.before).Problems {
|
|
393
|
+
old[p] = true
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for _, p := range state.ParseTaskGraph(target.after).Problems {
|
|
397
|
+
if !old[p] {
|
|
398
|
+
problems = append(problems, "tasks.md: "+p)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if filepath.Dir(target.rel) == "." && currentStatePlanning[name] &&
|
|
403
|
+
len(datedHeadingRE.FindAllIndex(target.after, -1)) > len(datedHeadingRE.FindAllIndex(target.before, -1)) {
|
|
404
|
+
problems = append(problems, target.rel+": adds a dated delta section; rewrite the owning clause in place (rationale to decisions.md, closed narrative to history/)")
|
|
405
|
+
}
|
|
406
|
+
if filepath.Dir(target.rel) != "." || !state.ArtifactBudgetGateApplies(name) {
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
after, _ := state.ArtifactBudget(name, target.after)
|
|
410
|
+
before, _ := state.ArtifactBudget(name, target.before)
|
|
411
|
+
grew := !target.existed || after.Lines > before.Lines || after.Bytes > before.Bytes
|
|
412
|
+
if after.Material && !after.Override && grew {
|
|
413
|
+
problems = append(problems, fmt.Sprintf("%s: %d lines / %d bytes is a material overshoot of its %d-line budget; split or relocate instead of growing it",
|
|
414
|
+
target.rel, after.Lines, after.Bytes, after.LineBudget))
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return problems
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// writeBundleTargets writes every target atomically and, if one write fails,
|
|
421
|
+
// restores the files already written so the workspace never holds half a
|
|
422
|
+
// bundle.
|
|
423
|
+
func writeBundleTargets(ws *os.Root, targets []*bundleTarget) error {
|
|
424
|
+
for i, target := range targets {
|
|
425
|
+
if err := fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(target.rel), target.after, 0o644); err != nil {
|
|
426
|
+
for _, done := range targets[:i] {
|
|
427
|
+
if done.existed {
|
|
428
|
+
_ = fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(done.rel), done.before, 0o644)
|
|
429
|
+
} else {
|
|
430
|
+
_ = ws.Remove(filepath.FromSlash(done.rel))
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return fmt.Errorf("write %s: %w (earlier files restored)", target.rel, err)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return nil
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
func readBundleFile(ws *os.Root, rel string) ([]byte, bool, error) {
|
|
440
|
+
info, err := ws.Lstat(filepath.FromSlash(rel))
|
|
441
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
442
|
+
return nil, false, nil
|
|
443
|
+
}
|
|
444
|
+
if err != nil {
|
|
445
|
+
return nil, false, err
|
|
446
|
+
}
|
|
447
|
+
if !info.Mode().IsRegular() {
|
|
448
|
+
return nil, false, errors.New("not a regular file")
|
|
449
|
+
}
|
|
450
|
+
if info.Size() > state.MaxArtifactBytes {
|
|
451
|
+
return nil, false, fmt.Errorf("exceeds the %d-byte artifact cap", state.MaxArtifactBytes)
|
|
452
|
+
}
|
|
453
|
+
raw, err := ws.ReadFile(filepath.FromSlash(rel))
|
|
454
|
+
return raw, true, err
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
func sha256Hex(raw []byte) string {
|
|
458
|
+
sum := sha256.Sum256(raw)
|
|
459
|
+
return hex.EncodeToString(sum[:])
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func shortHash(raw []byte, exists bool) string {
|
|
463
|
+
if !exists {
|
|
464
|
+
return "(new)"
|
|
465
|
+
}
|
|
466
|
+
return abbrev(sha256Hex(raw))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
func abbrev(hash string) string {
|
|
470
|
+
if len(hash) > 12 {
|
|
471
|
+
return hash[:12]
|
|
472
|
+
}
|
|
473
|
+
return hash
|
|
474
|
+
}
|
|
@@ -177,7 +177,7 @@ func (o diffScopeOpts) changedPaths() ([]string, error) {
|
|
|
177
177
|
|
|
178
178
|
// engineMachineFiles are the feature-root files the engine itself writes:
|
|
179
179
|
// the dispatch ledger, metrics ledger, drift and regression baselines, the
|
|
180
|
-
// parallel lease, and the
|
|
180
|
+
// parallel lease, the feature lock, and the gate-execution lock.
|
|
181
181
|
var engineMachineFiles = map[string]bool{
|
|
182
182
|
"dispatch.json": true,
|
|
183
183
|
MetricsFile: true,
|
|
@@ -185,6 +185,7 @@ var engineMachineFiles = map[string]bool{
|
|
|
185
185
|
regressionBaselineFile: true,
|
|
186
186
|
"parallel-lease.md": true,
|
|
187
187
|
".lock": true,
|
|
188
|
+
"gates.md.lock": true,
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
// engineMachineFile reports whether rel, relative to the .devrites tree, is
|
package/engine/main.go
CHANGED
|
@@ -65,6 +65,8 @@ Usage:
|
|
|
65
65
|
devrites-engine gates <subcommand> <slug> Machine-checked gates.md ledger: scaffold/status/run/reverify/lint/attest/abandon
|
|
66
66
|
devrites-engine state resolve <qid> "<ans>" Resolve an open question and update state atomically
|
|
67
67
|
devrites-engine state merge-manifest <slug> [pred...] Fold the recorded predecessor chain's manifests into the release candidate manifest
|
|
68
|
+
devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]
|
|
69
|
+
Validate and apply plan-drafter hunk bundles atomically
|
|
68
70
|
devrites-engine state close <slug> Archive a shipped feature and clear ACTIVE
|
|
69
71
|
devrites-engine migrate <slug> [--dry-run] [--answer id=choice] Normalize an older-schema workspace to the current schema
|
|
70
72
|
devrites-engine secret-scan [--staged] [--stdin] [slug] Scan exact staged blobs, stdin, or touched files; HIGH blocks
|
|
@@ -237,6 +239,8 @@ func cmdState(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
237
239
|
return lib.Resolve(root, args[1:], stdout, stderr)
|
|
238
240
|
case "merge-manifest":
|
|
239
241
|
return lib.RunMergeManifest(root, args[1:], stdout, stderr)
|
|
242
|
+
case "apply-bundle":
|
|
243
|
+
return lib.RunApplyBundle(root, args[1:], stdout, stderr)
|
|
240
244
|
case "close":
|
|
241
245
|
return lib.CloseOut(root, args[1:], stdout, stderr)
|
|
242
246
|
default:
|