fix(lanes): keep stage detection honest across rollbacks and feature switches
Three defects let a lane's pipeline map claim progress that was not this
pass's work, all of them on the "going back" path a new request takes:
- `setStage` cleared the detection columns on a real transition but never
pruned the `stages` records AHEAD of the new stage. A lane that ran through
`done` and was then declared back at `plan` rendered `plan` as `current`
while `tests`/`review`/`done` still read `done`, carrying the previous
pass's evidence, and contradicting `progress` (computed from the declared
index alone). A backwards declaration now drops every record it can prove
is ahead; a key matching no node is unplaceable and left alone.
- `activateFeature` rewrote the live lane row for the incoming feature but
carried the outgoing one's `detected_*` across. A never-seen slug starts at
`stage='idle'`/`stages={}`, exactly the shape `inferredDisplayStage` renders
from detection, so the new feature opened painted with the old one's
progress — and forward-only rejected every real detection for it until the
carried-over one aged past DETECTION_TTL_MS. Now cleared, like every other
real transition; deliberately not archived, since detection is live-only.
- `codex-ingest` classified a rollout journal by raw `now - mtime`, which goes
negative for a future mtime (clock stepped back, NTP correction, a restore
that preserved timestamps) and read as "written milliseconds ago". A
finished thread's journal could replay as live work; a file claiming to be
from the future is now idle.
Also fixes `check-headers.sh`, which audited via `find` and so reported local
gitignored runtime state (a lane's own `.ccam/profile/hooks/*.sh`) that is not
this repo's source and can never be made compliant here. It now lists files
through git (tracked + untracked-but-not-ignored), so a file an agent just
wrote is still caught before it is staged while ignored state is not.
Tests: 1359 pass (4 new). Header audit exits 0.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# File Header Rules (binding for every coding agent)
|
# File Header Rules (binding for every coding agent)
|
||||||
|
|
||||||
- Every applicable source file (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css` — excluding `node_modules/`, `dist/`, `data/`, minified/vendored, snapshots) MUST start with the header comment: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
|
- Every applicable source file (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css` — excluding `node_modules/`, `dist/`, `build/`, `data/`, minified/vendored, snapshots, and anything gitignored) MUST start with the header comment: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
|
||||||
- Creating a new applicable file → write the header before any code (after the shebang in scripts).
|
- Creating a new applicable file → write the header before any code (after the shebang in scripts).
|
||||||
- Editing a file that lacks the header → add it in the same change; if the edit changes the file's purpose, update the overview.
|
- Editing a file that lacks the header → add it in the same change; if the edit changes the file's purpose, update the overview.
|
||||||
- Formats and the repo-wide audit script live in `skills/file-headers/` (`bash skills/file-headers/scripts/check-headers.sh` must exit 0).
|
- Formats and the repo-wide audit script live in `skills/file-headers/` (`bash skills/file-headers/scripts/check-headers.sh` must exit 0).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: b1734a4 (2026-08-27)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
_Last synced: 13b2bc4 (2026-09-03)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
@@ -24,6 +24,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
|||||||
- A lane's stage is **declared** by the driving Claude session (via `ccam stage <stage> [--evidence "..."]`), not inferred by the dashboard.
|
- A lane's stage is **declared** by the driving Claude session (via `ccam stage <stage> [--evidence "..."]`), not inferred by the dashboard.
|
||||||
- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/ccam.js`) answers in order: `--lane`/leading id → `CCAM_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work.
|
- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/ccam.js`) answers in order: `--lane`/leading id → `CCAM_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work.
|
||||||
- **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
- **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||||||
|
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
||||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
||||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||||
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
||||||
|
|||||||
+48
-2
@@ -529,7 +529,13 @@ by pipeline template:
|
|||||||
|
|
||||||
Archived features matter here: a lane's own `stages` only holds its current
|
Archived features matter here: a lane's own `stages` only holds its current
|
||||||
feature, so without them the history would reset every time a lane starts new
|
feature, so without them the history would reset every time a lane starts new
|
||||||
work. The Workflows page renders this as one bar per node (see
|
work. This is also why new work reported as a bare backwards `ccam stage` (see
|
||||||
|
"Declaring an EARLIER stage") contributes less here than the same work reported
|
||||||
|
under `ccam feature activate`: the backwards declaration prunes the records
|
||||||
|
ahead of it, so the previous pass leaves nothing behind to count, while a
|
||||||
|
feature switch archives them first. Neither ever counted *passes* — one lane row
|
||||||
|
contributes at most one record per node, however many times it ran — so this
|
||||||
|
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see
|
||||||
`client/src/components/workflows/LanePipelinePanel.tsx`).
|
`client/src/components/workflows/LanePipelinePanel.tsx`).
|
||||||
|
|
||||||
`?lane=<id>` narrows the same aggregation to one lane and its own archived
|
`?lane=<id>` narrows the same aggregation to one lane and its own archived
|
||||||
@@ -624,6 +630,39 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status
|
|||||||
|
|
||||||
- `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report.
|
- `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report.
|
||||||
|
|
||||||
|
### Declaring an EARLIER stage (going back)
|
||||||
|
|
||||||
|
A declaration may move a lane backwards — that is how a fix-loop re-entry, or a
|
||||||
|
new request landing on a lane that already reached `done`, is reported:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam stage plan --note "new request: export to CSV"
|
||||||
|
```
|
||||||
|
|
||||||
|
Declaring a stage that sits **before** the lane's current one does two extra
|
||||||
|
things beyond the ordinary transition:
|
||||||
|
|
||||||
|
- **Clears the detected columns**, like every real transition (see "Inferred vs.
|
||||||
|
declared" below).
|
||||||
|
- **Drops the `stages` records of every node ahead of the new one.** Those
|
||||||
|
records belong to the previous pass. Left in place, a lane declared back at
|
||||||
|
`plan` would render `plan` as `current` while `tests`/`review`/`done` still
|
||||||
|
read `done` — carrying the *old* work's evidence — and `progress` (computed
|
||||||
|
from the declared index alone) would contradict the map beside it. A gate
|
||||||
|
ahead of where the agent now says it is has not been passed for this pass; it
|
||||||
|
has to be re-earned and re-declared.
|
||||||
|
|
||||||
|
The node the declaration lands on keeps its own record (its evidence and
|
||||||
|
`result` survive), and `stage_since` restarts, so time-on-phase measures this
|
||||||
|
pass. Records stored under a key matching **no** node in the pipeline are left
|
||||||
|
untouched — their position is unknown, so they cannot be proven to be ahead;
|
||||||
|
for the same reason, declaring a stage that matches no node prunes nothing.
|
||||||
|
|
||||||
|
Going back this way is a *declaration*, not a reset: the lane, its worktree, and
|
||||||
|
its archived features are untouched. To start a genuinely separate piece of work
|
||||||
|
with its own archived pipeline, use `ccam feature activate <slug>` instead (see
|
||||||
|
"Per-feature state and archive").
|
||||||
|
|
||||||
### Which lane a CLI command addresses
|
### Which lane a CLI command addresses
|
||||||
|
|
||||||
Every cwd-resolving `ccam` command (`stage`, `lanes pipeline`, `feature
|
Every cwd-resolving `ccam` command (`stage`, `lanes pipeline`, `feature
|
||||||
@@ -760,9 +799,12 @@ This is a **deliberately different rule** from `worktree.js:slugify`'s branch-na
|
|||||||
1. Archives the current active feature (if any and if different from the target slug) — copies its live stage/status/notes to the archive
|
1. Archives the current active feature (if any and if different from the target slug) — copies its live stage/status/notes to the archive
|
||||||
2. Restores the target slug's saved pipeline onto the live lane row — so switching back to a past feature resumes its pipeline exactly where it left off
|
2. Restores the target slug's saved pipeline onto the live lane row — so switching back to a past feature resumes its pipeline exactly where it left off
|
||||||
3. Creates a fresh feature row for a never-seen slug (empty stage/status/notes)
|
3. Creates a fresh feature row for a never-seen slug (empty stage/status/notes)
|
||||||
|
4. Clears the live lane's detected columns (`detected_stage`/`detected_signal`/`detected_at`), the same way `ccam stage` and `clear` do
|
||||||
|
|
||||||
This means activating an archived feature twice resumes the same pipeline both times.
|
This means activating an archived feature twice resumes the same pipeline both times.
|
||||||
|
|
||||||
|
Step 4 matters because a detection belongs to the feature that produced it. A never-seen slug starts at `stage='idle'` with `stages={}`, which is exactly the shape `inferredDisplayStage` renders from detection — so a carried-over `detected_stage` would paint the brand-new feature's map with the previous one's progress, and (detection being forward-only) reject every real detection for the new feature until the old one aged past `DETECTION_TTL_MS`. Detection is live-only state and is deliberately **not** snapshotted onto the outgoing archive row: switching back to a feature restores what was declared, never what was inferred.
|
||||||
|
|
||||||
### The Workspace feature picker
|
### The Workspace feature picker
|
||||||
|
|
||||||
The Workspace page's feature picker (read-only) shows every archived and active feature for the lane. Selecting one displays its saved pipeline. **It never changes the live lane** — a read-only view matching the standing rule that "the console never writes a lane's stage". Changing the active feature requires the CLI or the API route, not a UI button.
|
The Workspace page's feature picker (read-only) shows every archived and active feature for the lane. Selecting one displays its saved pipeline. **It never changes the live lane** — a read-only view matching the standing rule that "the console never writes a lane's stage". Changing the active feature requires the CLI or the API route, not a UI button.
|
||||||
@@ -816,7 +858,11 @@ codex-only details:
|
|||||||
- Only bytes appended since the last pass are read. On the first sight of a
|
- Only bytes appended since the last pass are read. On the first sight of a
|
||||||
journal only its recent tail is read, and nothing at all when that file is
|
journal only its recent tail is read, and nothing at all when that file is
|
||||||
already idle — replaying a thread that finished days ago would paint stages
|
already idle — replaying a thread that finished days ago would paint stages
|
||||||
for work nobody is doing.
|
for work nobody is doing. "Idle" means the journal's mtime is more than two
|
||||||
|
minutes old **or in the future**: a file that claims to be from the future
|
||||||
|
(clock stepped back, NTP correction, a restore that preserved timestamps)
|
||||||
|
makes a raw `now - mtime` negative, which would otherwise read as "written
|
||||||
|
milliseconds ago" and replay a finished thread as live work.
|
||||||
- The same pass mirrors the lane's `status` between `idle` and `running` from
|
- The same pass mirrors the lane's `status` between `idle` and `running` from
|
||||||
the journal's mtime, under the identical guard hook ingestion uses (only a
|
the journal's mtime, under the identical guard hook ingestion uses (only a
|
||||||
lane with no `run_id`, and only from `idle`/`running` — never from
|
lane with no `run_id`, and only from `idle`/`running` — never from
|
||||||
|
|||||||
@@ -303,6 +303,22 @@ describe("codex ingest pass", () => {
|
|||||||
lanes.deleteLane(lane.id);
|
lanes.deleteLane(lane.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not replay a journal whose mtime is in the FUTURE", () => {
|
||||||
|
// A clock stepped back (or a restore that preserved timestamps) leaves a
|
||||||
|
// finished thread's mtime ahead of now. Raw `now - mtime` is then negative,
|
||||||
|
// which reads as "written milliseconds ago" and would replay it as live work.
|
||||||
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||||
|
const file = writeRollout("rollout-2026-08-27T10-00-00-fut.jsonl", cwd);
|
||||||
|
const future = Date.now() + 6 * 60 * 60 * 1000;
|
||||||
|
fs.utimesSync(file, future / 1000, future / 1000);
|
||||||
|
codex.__resetOffsets();
|
||||||
|
|
||||||
|
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||||
|
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||||
|
lanes.deleteLane(lane.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores a torn/partial JSONL line at the end of a file", () => {
|
it("ignores a torn/partial JSONL line at the end of a file", () => {
|
||||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
|
||||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||||
|
|||||||
@@ -102,6 +102,27 @@ describe("activateFeature / archiveActiveFeature", () => {
|
|||||||
assert.equal(features.getFeature(lane.id, "one").archived_at, null);
|
assert.equal(features.getFeature(lane.id, "one").archived_at, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switching features clears the outgoing feature's detection", () => {
|
||||||
|
// Without this, a brand-new slug (stage='idle', stages={}) renders its map
|
||||||
|
// from the PREVIOUS feature's detection via inferredDisplayStage, and
|
||||||
|
// forward-only rejects every real detection for the new feature until the
|
||||||
|
// carried-over one ages past DETECTION_TTL_MS.
|
||||||
|
const lane = makeLane();
|
||||||
|
features.activateFeature(lane.id, "one");
|
||||||
|
lanesLib.recordDetection(lane.id, { nodeId: "ship", signal: "`git push`" });
|
||||||
|
assert.equal(lanesLib.getLane(lane.id).detected_stage, "ship");
|
||||||
|
|
||||||
|
const { lane: updated } = features.activateFeature(lane.id, "two");
|
||||||
|
assert.equal(updated.detected_stage, null);
|
||||||
|
assert.equal(updated.detected_signal, null);
|
||||||
|
assert.equal(updated.detected_at, null);
|
||||||
|
|
||||||
|
// ...and a detection for the new feature lands immediately.
|
||||||
|
assert.deepEqual(lanesLib.recordDetection(lane.id, { nodeId: "plan", signal: "`Write`" }), {
|
||||||
|
written: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("archiveActiveFeature returns null and touches nothing when no feature is active", () => {
|
it("archiveActiveFeature returns null and touches nothing when no feature is active", () => {
|
||||||
const lane = makeLane();
|
const lane = makeLane();
|
||||||
assert.equal(features.archiveActiveFeature(lane.id), null);
|
assert.equal(features.archiveActiveFeature(lane.id), null);
|
||||||
|
|||||||
@@ -189,6 +189,39 @@ describe("lanes lib", () => {
|
|||||||
lanes.deleteLane(l.id);
|
lanes.deleteLane(l.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("declaring an EARLIER stage drops the records of every node ahead of it", () => {
|
||||||
|
const l = lanes.createLane({ cwd: "/tmp/wt/rollback" });
|
||||||
|
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
|
||||||
|
lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
|
||||||
|
lanes.setStage(l.id, { stage: "done", evidence: "shipped" });
|
||||||
|
|
||||||
|
// A new request arrives and the session declares back at `plan`. The old
|
||||||
|
// pass's tests/done records must not keep rendering `done` ahead of it.
|
||||||
|
const after = lanes.setStage(l.id, { stage: "plan" });
|
||||||
|
assert.equal(after.stages.tests, undefined);
|
||||||
|
assert.equal(after.stages.done, undefined);
|
||||||
|
assert.equal(after.stages.plan.evidence, "docs/plan.md"); // its own record survives
|
||||||
|
|
||||||
|
const nodes = lanes.lanePayload(after).pipeline_nodes;
|
||||||
|
assert.equal(nodes.find((n) => n.id === "plan").state, "current");
|
||||||
|
assert.equal(nodes.find((n) => n.id === "tests").state, "pending");
|
||||||
|
assert.equal(nodes.find((n) => n.id === "done").state, "pending");
|
||||||
|
lanes.deleteLane(l.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moving FORWARD keeps every earlier record, and an unplaceable stage prunes nothing", () => {
|
||||||
|
const l = lanes.createLane({ cwd: "/tmp/wt/rollback-fwd" });
|
||||||
|
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
|
||||||
|
const fwd = lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
|
||||||
|
assert.equal(fwd.stages.plan.evidence, "docs/plan.md");
|
||||||
|
|
||||||
|
// A stage matching no node has no position, so direction is unknown.
|
||||||
|
const typo = lanes.setStage(l.id, { stage: "implemnt" });
|
||||||
|
assert.equal(typo.stages.plan.evidence, "docs/plan.md");
|
||||||
|
assert.equal(typo.stages.tests.evidence, "npm test");
|
||||||
|
lanes.deleteLane(l.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
|
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
|
||||||
const outer = lanes.createLane({ cwd: "/tmp/wt" });
|
const outer = lanes.createLane({ cwd: "/tmp/wt" });
|
||||||
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
|
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
|
||||||
|
|||||||
@@ -266,7 +266,13 @@ function ingestCodexOnce({ broadcast } = {}) {
|
|||||||
if (!lane) continue;
|
if (!lane) continue;
|
||||||
|
|
||||||
const firstSight = !offsets.has(file.path);
|
const firstSight = !offsets.has(file.path);
|
||||||
const active = Date.now() - file.mtime < CODEX_ACTIVE_MS;
|
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
|
||||||
|
// preserved timestamps) makes the raw difference negative, which reads as
|
||||||
|
// "written milliseconds ago" and would let a finished thread's journal
|
||||||
|
// replay as live work. A file that claims to be from the future is not
|
||||||
|
// evidence of an agent working now, so treat it as idle.
|
||||||
|
const age = Date.now() - file.mtime;
|
||||||
|
const active = age >= 0 && age < CODEX_ACTIVE_MS;
|
||||||
let from = firstSight ? Math.max(0, file.size - TAIL_BYTES) : offsets.get(file.path);
|
let from = firstSight ? Math.max(0, file.size - TAIL_BYTES) : offsets.get(file.path);
|
||||||
// A stored offset past EOF means the file was truncated or a new thread took
|
// A stored offset past EOF means the file was truncated or a new thread took
|
||||||
// the same path. Restart from a bounded tail, never from 0: reading a
|
// the same path. Restart from a bounded tail, never from 0: reading a
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ function archiveActiveFeature(laneId) {
|
|||||||
* the live row already IS that feature's state, so there's nothing to
|
* the live row already IS that feature's state, so there's nothing to
|
||||||
* restore and nothing to archive.
|
* restore and nothing to archive.
|
||||||
*
|
*
|
||||||
|
* A real switch also clears `detected_stage`/`detected_signal`/`detected_at`,
|
||||||
|
* for the same reason `setStage` and `clearLane` do: a detection belongs to the
|
||||||
|
* feature that produced it. Carried across a switch it would both paint the new
|
||||||
|
* feature's map from the old one's activity (a fresh slug has `stage='idle'` and
|
||||||
|
* `stages={}`, so `inferredDisplayStage` renders that detection verbatim) AND —
|
||||||
|
* because `recordDetection` is forward-only — reject every real detection for
|
||||||
|
* the new feature until the old one ages past DETECTION_TTL_MS. Detection is
|
||||||
|
* live-only state, so it is deliberately NOT archived onto the outgoing row.
|
||||||
|
*
|
||||||
* @param {number} laneId
|
* @param {number} laneId
|
||||||
* @param {string} rawSlug - Canonicalized internally; the caller's raw input is never stored.
|
* @param {string} rawSlug - Canonicalized internally; the caller's raw input is never stored.
|
||||||
* @param {{title?: string}} [options]
|
* @param {{title?: string}} [options]
|
||||||
@@ -171,7 +180,8 @@ function activateFeature(laneId, rawSlug, options = {}) {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE lanes SET
|
`UPDATE lanes SET
|
||||||
stage = ?, stage_since = ?, status = ?, gate_decision = ?, ci_status = ?,
|
stage = ?, stage_since = ?, status = ?, gate_decision = ?, ci_status = ?,
|
||||||
stages = ?, links = ?, notes = ?, active_feature_id = ?, updated_at = ?
|
stages = ?, links = ?, notes = ?, active_feature_id = ?, updated_at = ?,
|
||||||
|
detected_stage = NULL, detected_signal = NULL, detected_at = NULL
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
target.stage,
|
target.stage,
|
||||||
|
|||||||
@@ -286,6 +286,17 @@ function reservedPorts(excludeLaneId = null) {
|
|||||||
* paint stale progress in the UI AND — because recordDetection is forward-only
|
* paint stale progress in the UI AND — because recordDetection is forward-only
|
||||||
* — silently reject every real detection for the new stage until the old one
|
* — silently reject every real detection for the new stage until the old one
|
||||||
* ages past DETECTION_TTL_MS.
|
* ages past DETECTION_TTL_MS.
|
||||||
|
*
|
||||||
|
* A transition BACKWARDS additionally drops the `stages` records of every node
|
||||||
|
* ahead of the new one, for the same reason and with the same consequence in
|
||||||
|
* the UI. Those records are the previous pass's — a lane that ran through
|
||||||
|
* `done` and is then declared back at `plan` for a new request would otherwise
|
||||||
|
* render `plan` as `current` while `tests`/`review`/`done` still read `done`,
|
||||||
|
* carrying the *old* work's evidence, and `progress` (computed from the
|
||||||
|
* declared index alone) would contradict the map it sits next to. A gate ahead
|
||||||
|
* of where the agent now says it is has not been passed for this pass; it has
|
||||||
|
* to be re-earned. Records under a key matching no node in the pipeline are
|
||||||
|
* left untouched — their position is unknown, so they cannot be proven ahead.
|
||||||
*/
|
*/
|
||||||
function setStage(id, { stage, status, evidence, note, result } = {}) {
|
function setStage(id, { stage, status, evidence, note, result } = {}) {
|
||||||
const lane = getLane(id);
|
const lane = getLane(id);
|
||||||
@@ -293,6 +304,15 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
|
|||||||
const next = stage || lane.stage;
|
const next = stage || lane.stage;
|
||||||
const changed = next !== lane.stage;
|
const changed = next !== lane.stage;
|
||||||
const stages = { ...lane.stages };
|
const stages = { ...lane.stages };
|
||||||
|
const pipeline = getPipeline(lane.pipeline);
|
||||||
|
const nextIdx = phaseIdx(pipeline, next);
|
||||||
|
const wentBack = changed && nextIdx > -1 && nextIdx < phaseIdx(pipeline, lane.stage);
|
||||||
|
if (wentBack) {
|
||||||
|
for (const key of Object.keys(stages)) {
|
||||||
|
const idx = phaseIdx(pipeline, key);
|
||||||
|
if (idx > nextIdx) delete stages[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
const prev = stages[next] || {};
|
const prev = stages[next] || {};
|
||||||
stages[next] = {
|
stages[next] = {
|
||||||
enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(),
|
enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(),
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ that is missing the header, add it as part of the same change.
|
|||||||
| -------- | -------- |
|
| -------- | -------- |
|
||||||
| `*.js`, `*.ts`, `*.tsx`, `*.cjs`, `*.mjs` | anything under `node_modules/`, `dist/`, `build/`, `data/` |
|
| `*.js`, `*.ts`, `*.tsx`, `*.cjs`, `*.mjs` | anything under `node_modules/`, `dist/`, `build/`, `data/` |
|
||||||
| `*.py`, `*.sh` | vendored/minified files (`*.min.js`) |
|
| `*.py`, `*.sh` | vendored/minified files (`*.min.js`) |
|
||||||
| `*.css` | generated files (they carry their own AUTO-GENERATED banner) |
|
| `*.css` | generated files (they carry their own AUTO-GENERATED banner), including the committed `mcp/build/` |
|
||||||
| | snapshots (`__snapshots__/`), lockfiles, JSON/YAML/Markdown |
|
| | snapshots (`__snapshots__/`), lockfiles, JSON/YAML/Markdown |
|
||||||
|
| | anything **gitignored** — local runtime state that happens to live in the checkout (a lane's own `.ccam/profile/hooks/*.sh`, a scratch venv) is not this repo's source and can never be made compliant here |
|
||||||
|
|
||||||
## Header formats by file type
|
## Header formats by file type
|
||||||
|
|
||||||
@@ -106,6 +107,11 @@ bash skills/file-headers/scripts/check-headers.sh
|
|||||||
Exit code `0` = fully compliant; `1` = the printed files are missing headers.
|
Exit code `0` = fully compliant; `1` = the printed files are missing headers.
|
||||||
Run it before finishing any change-set that adds files, and during reviews.
|
Run it before finishing any change-set that adds files, and during reviews.
|
||||||
|
|
||||||
|
It audits the files **git can see** — tracked plus untracked-but-not-ignored —
|
||||||
|
so a file you just wrote is caught before it is staged, while gitignored local
|
||||||
|
runtime state is not reported. It therefore needs to run inside a git work tree
|
||||||
|
and exits `1` with a message if it is not.
|
||||||
|
|
||||||
On every pull request, GitHub Actions runs
|
On every pull request, GitHub Actions runs
|
||||||
`skills/file-headers/scripts/check-headers-pr.sh` against only the
|
`skills/file-headers/scripts/check-headers-pr.sh` against only the
|
||||||
files changed in the PR diff (added, copied, renamed, or modified). Test locally
|
files changed in the PR diff (added, copied, renamed, or modified). Test locally
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
# check-headers.sh — audit the repo for applicable source files missing the
|
# check-headers.sh — audit the repo for applicable source files missing the
|
||||||
# mandatory copyright/authorship header (see skills/file-headers).
|
# mandatory copyright/authorship header (see skills/file-headers).
|
||||||
# Prints each non-compliant file; exits 0 when fully compliant, 1 otherwise.
|
# Prints each non-compliant file; exits 0 when fully compliant, 1 otherwise.
|
||||||
|
#
|
||||||
|
# The file list comes from git (tracked + untracked-but-not-ignored), never a
|
||||||
|
# raw `find`: the header rule governs this repository's SOURCE, and a `find`
|
||||||
|
# also walks local runtime state that happens to live in the checkout — a
|
||||||
|
# lane's own `.ccam/profile/hooks/*.sh`, a scratch venv — which is gitignored,
|
||||||
|
# differs per machine, and can never be made compliant here. Untracked files
|
||||||
|
# are still audited, so a file an agent just wrote is caught before it is
|
||||||
|
# staged. The extra excludes below are for paths git DOES track (or would list)
|
||||||
|
# but the rule exempts — notably `mcp/build/`, which is committed on purpose
|
||||||
|
# (plugin MCP servers start before anything could build them) yet is still
|
||||||
|
# generated tsc output, and snapshots.
|
||||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -9,20 +20,22 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
AUTHOR_MARK="@author Nguyễn Ngọc Trí Vĩ"
|
AUTHOR_MARK="@author Nguyễn Ngọc Trí Vĩ"
|
||||||
|
|
||||||
|
if ! git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "check-headers.sh: $ROOT is not a git work tree — nothing to audit against." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
missing=0
|
missing=0
|
||||||
while IFS= read -r f; do
|
while IFS= read -r f; do
|
||||||
if ! grep -q "$AUTHOR_MARK" "$f"; then
|
[ -f "$ROOT/$f" ] || continue # a staged deletion still lists
|
||||||
echo "MISSING HEADER: ${f#"$ROOT"/}"
|
if ! grep -q "$AUTHOR_MARK" "$ROOT/$f"; then
|
||||||
|
echo "MISSING HEADER: $f"
|
||||||
missing=1
|
missing=1
|
||||||
fi
|
fi
|
||||||
done < <(
|
done < <(
|
||||||
find "$ROOT" \
|
git -C "$ROOT" ls-files --cached --others --exclude-standard |
|
||||||
\( -name node_modules -o -name dist -o -name build -o -name .git \
|
grep -E '\.(js|ts|tsx|cjs|mjs|py|sh|css)$' |
|
||||||
-o -path "$ROOT/data" -o -path "$ROOT/monitoring/.bin" \
|
grep -vE '\.min\.js$|(^|/)(__snapshots__|node_modules|dist|build)/|^(data|monitoring/\.(bin|data))/' || true
|
||||||
-o -path "$ROOT/monitoring/.data" -o -name "__snapshots__" \) -prune -o \
|
|
||||||
-type f \( -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.cjs" \
|
|
||||||
-o -name "*.mjs" -o -name "*.py" -o -name "*.sh" -o -name "*.css" \) \
|
|
||||||
! -name "*.min.js" -print
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ "$missing" -eq 0 ]; then
|
if [ "$missing" -eq 0 ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user