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:
2026-09-03 15:29:08 +07:00
parent 13b2bc4ab6
commit 8a812a174b
11 changed files with 188 additions and 16 deletions
+1 -1
View File
@@ -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).
+2 -1
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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" });
+21
View File
@@ -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);
+33
View File
@@ -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" });
+7 -1
View File
@@ -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
+11 -1
View File
@@ -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,
+20
View File
@@ -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(),
+7 -1
View File
@@ -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
+22 -9
View File
@@ -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