Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8d8caf5
docs(plans): record the defects a launch surfaced
protonspy Sep 15, 2026
d1427ac
fix(mdblock): locate the block over a masked document, not raw text
protonspy Sep 15, 2026
f90aeca
fix(assets): the seeded dev container writes no git config into a rea…
protonspy Sep 15, 2026
94798f8
docs(notes): record what both launch defects cost
protonspy Sep 15, 2026
ed4b8f1
test(paths,render,devcontainer): cover the three packages that had no…
protonspy Sep 16, 2026
cca0900
test(git): drive the queries against real repositories, not just the …
protonspy Sep 16, 2026
5c82cf9
test(hooks): cover the settings splice, which had no tests at all
protonspy Sep 16, 2026
0e577c5
test(artifact): cover the delivery record, the seal, the schedule and…
protonspy Sep 16, 2026
b756fef
test(integrations): cover the binary halves against stand-ins on a re…
protonspy Sep 16, 2026
54c8f5d
test(validate): cover the delivery gate's findings and the set it run…
protonspy Sep 16, 2026
69fe6b4
test(cli): sweep the usage surface and broaden the map reads
protonspy Sep 16, 2026
c43f9fa
test(cli): cover the three ways prose gets into a patch, and what not…
protonspy Sep 16, 2026
9ef3066
test(core): cover the writes, the carry-over and the removes
protonspy Sep 16, 2026
df6b71a
test(cli): cover the scope fan-out and the one install decision
protonspy Sep 16, 2026
6a167a0
test(cli): cover the plan contract, the seal and the hostile name
protonspy Sep 16, 2026
2bd3850
test(validate,scaffold,ears): cover the silent paths and the plan sum…
protonspy Sep 16, 2026
9ac103c
test(cli): cover both output forms, the container reports and discove…
protonspy Sep 16, 2026
828ded2
test(cli): cover the Stop stage's cheap checks and the manifest path …
protonspy Sep 16, 2026
f6fbb2b
test(cli): a reason for every way Headroom and the graph degrade
protonspy Sep 16, 2026
777e440
test(artifact,validate): pin the numeric ordering and the codewiki an…
protonspy Sep 16, 2026
ae65fba
test(validate,cli): pin the link boundary, the ADR vocabulary and the…
protonspy Sep 16, 2026
97e3131
test(cli): cover map find's flags and the notes rollback
protonspy Sep 16, 2026
4916220
test(cli): cover what spec sync reconciles and what it refuses
protonspy Sep 16, 2026
c64b64b
test(cli): cover the scope round trip, the branch filter and the help…
protonspy Sep 16, 2026
c46abd6
test(cli): cover the check vocabulary, the graph refusal and the stal…
protonspy Sep 16, 2026
133cad8
docs(plans): close the plan — the floor is met
protonspy Sep 16, 2026
477cbd5
chore: remove a plan a test run created in the working tree
protonspy Sep 16, 2026
dfb536e
test(cli): make the sync lifecycle test hermetic, and record the four…
protonspy Sep 16, 2026
2a0fbdc
fix(artifact): resolve both sides of the workspace boundary the same way
protonspy Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*

# The workspace is a bind mount from the host, so its files arrive owned by a uid
# this container does not have, and git refuses a repository it thinks belongs to
# somebody else — the ownership check that exists because a differently-owned .git
# can carry a config or a hook that runs on the next ordinary git command
# (CVE-2022-24765). Scoped to where devcontainers mount a workspace and no further:
# this container forwards GH_TOKEN and mounts the agent's credential volume, so a
# bare `*` here would hand the next crafted .git in a dependency tree exactly those.
#
# Set in the image rather than in postCreateCommand, and that is forced from both
# ends: --global writes ~/.gitconfig, which is the read-only bind that carries your
# identity in ("Device or resource busy", which failed postCreate, `devcontainer up`
# and the whole sandbox, back onto the host), while --system writes /etc/gitconfig,
# which postCreate's non-root user cannot. The build runs as root and can.
#
# The path is a literal because ${containerWorkspaceFolder} is not resolved at build
# time. A workspace mounted somewhere else gets git's ordinary ownership message and
# one `git config --global --add safe.directory <path>` to run — visible, not silent.
RUN git config --system --add safe.directory '/workspaces/*'

# RTK is the slow layer: it is a Rust build, so this installs a toolchain, builds
# one binary and throws the toolchain away. Delete this block if you would rather
# have fast rebuilds than filtered command output — nothing else depends on it,
Expand All @@ -50,7 +69,6 @@ RUN export CARGO_HOME=/tmp/cargo RUSTUP_HOME=/tmp/rustup \
&& "$CARGO_HOME/bin/cargo" install --git https://github.com/rtk-ai/rtk --locked \
&& install -m 0755 "$CARGO_HOME/bin/rtk" /usr/local/bin/rtk \
&& rm -rf "$CARGO_HOME" "$RUSTUP_HOME"

# The project toolchain — exactly what `scc check` runs, and nothing a human would
# merely like to have. The four recorded gates are `go build ./...`,
# `golangci-lint fmt --diff`, `golangci-lint run` and `go run -C tools ./testreport`,
Expand Down Expand Up @@ -87,14 +105,12 @@ RUN curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${TARGETARCH}.tar
&& rm /tmp/go.tgz /tmp/go.tgz.sha256 \
&& ln -sf /usr/local/go/bin/go /usr/local/bin/go \
&& ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt

# `go install` at a pinned version rather than the upstream install script: the
# version is the one CI enforces, and piping a remote script into a shell is a thing
# this project declines to do on somebody's behalf (see docs/stack.md). GOBIN puts
# the binary where every shell looks, as above.
RUN GOBIN=/usr/local/bin go install \
github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_VERSION}

# The module and build caches under the node user's home, since that is who runs the
# gate; a root-owned cache is the failure that looks like a network problem the first
# time the agent runs `go build`.
Expand Down
7 changes: 6 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}"
},

"postCreateCommand": "scc version && git config --global --add safe.directory ${containerWorkspaceFolder}",
// Just a smoke test that the agent's toolchain is on PATH. Nothing here writes
// git config: ~/.gitconfig is the read-only bind above, and `git config --global`
// against it fails with "Device or resource busy" — which failed postCreate, which
// failed `devcontainer up`, which dropped the launch back onto the host outside any
// sandbox at all. safe.directory is set in the image instead, with --system.
"postCreateCommand": "scc version",

"customizations": {
"vscode": {
Expand Down
5 changes: 5 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,8 @@ over this file answers with the example above as well as with the notes. -->
- n-0004 2026-09-15 #gotcha @tools/go.mod — tools/ is a nested module so the root's ./... never sees it — a helper in the main module would land in the coverage profile at 0%
- n-0005 2026-09-15 #gotcha @.devcontainer/Dockerfile — bash -lc re-runs /etc/profile and rebuilds PATH, discarding every Docker ENV, so container tooling must be linked into /usr/local/bin
- n-0006 2026-09-15 #gotcha @internal/scaffold — scc update deletes a managed file but never prunes the directory it emptied, and the skills validator then reports skill.missing-skill-md on the empty dir
<!-- n-0007 removed -->
- n-0008 2026-09-15 #gotcha @.devcontainer/devcontainer.json — postCreateCommand cannot run git config --global: ~/.gitconfig is a read-only bind, git fails Device or resource busy, and devcontainer up takes the sandbox down with it
- n-0009 2026-09-15 #gotcha @internal/mdblock/mdblock.go @internal/rtk/rtk.go @internal/mdscan/mdscan.go — a marker written in prose or a code span is not a block — mdblock and rtk.ForeignBlock search mdscan.Mask, and the entry file that documents the markers is why
- n-0010 2026-09-15 #ceiling @internal/mdscan/mdscan.go — mdscan.Mask hides fenced blocks and inline code spans only — a marker in a 4-space-indented block or a blockquote still reads as a live block; lift it by teaching Mask those two constructs
- n-0011 2026-09-15 #gotcha @internal/artifact/artifact.go — artifact.Within must resolve both sides the same way — resolving only the root made a not-yet-created path inside the workspace read as an escape on macOS and on Windows 8.3 paths
50 changes: 40 additions & 10 deletions internal/artifact/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,9 +514,20 @@ func Resolve(root, arg string) ([]string, error) {
//
// Symlinks are evaluated on both sides before the comparison, because a temporary
// directory is itself a symlink on macOS (/var → /private/var) and a purely
// lexical test would call every file under one an escape. Where a side cannot be
// evaluated its cleaned absolute path is used, which still catches the `..` that
// this exists for.
// lexical test would call every file under one an escape.
//
// **Both sides are resolved the same way, and that is the whole of it.** Resolving
// only what exists is what broke: a target that is not on disk yet — the ordinary
// case for a file about to be written — failed EvalSymlinks and stayed lexical
// while the root beside it was resolved, so the two were no longer comparable and
// a path plainly inside the workspace came back as an escape. Measured on macOS
// (/var vs /private/var) and on Windows, where a path handed over with an 8.3
// short name (RUNNER~1) resolves to its long form on one side only.
//
// So each side resolves its deepest existing ancestor and re-attaches the rest.
// The part that does not exist cannot be a symlink — there is nothing there to be
// one — so nothing is assumed about it, and the `..` this exists to catch is still
// caught: it is resolved away by Abs before any of this runs.
func Within(root, target string) bool {
r, err := filepath.Abs(root)
if err != nil {
Expand All @@ -526,19 +537,38 @@ func Within(root, target string) bool {
if err != nil {
return false
}
if p, err := filepath.EvalSymlinks(r); err == nil {
r = p
}
if p, err := filepath.EvalSymlinks(t); err == nil {
t = p
}
rel, err := filepath.Rel(r, t)
rel, err := filepath.Rel(resolveExisting(r), resolveExisting(t))
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// resolveExisting evaluates symlinks on the deepest existing ancestor of p and
// re-attaches whatever was below it, so a path that is not on disk yet compares on
// the same terms as one that is.
//
// A path with no existing ancestor at all comes back as it was given, which is the
// answer for a volume that is not mounted and for the root of a filesystem that
// does not exist.
func resolveExisting(p string) string {
rest := ""
for cur := p; ; {
if resolved, err := filepath.EvalSymlinks(cur); err == nil {
if rest == "" {
return resolved
}
return filepath.Join(resolved, rest)
}
parent := filepath.Dir(cur)
if parent == cur {
return p
}
rest = filepath.Join(filepath.Base(cur), rest)
cur = parent
}
}

// Scan loads every artifact in the workspace: each plan, then each spec's three
// files in phase order. It is the index every other read is a narrowing of.
func Scan(root string) ([]*Artifact, error) {
Expand Down
Loading
Loading