changeledger 0.7.0 → 0.8.0

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/AGENTS.md CHANGED
@@ -6,11 +6,13 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
6
6
  <!-- changeledger -->
7
7
  > [!IMPORTANT]
8
8
  > This repo uses **ChangeLedger**. Immediately after reading this file — before
9
- > planning, investigating, or acting — run `changeledger context` directly and
10
- > read its complete output through the `CHANGELEDGER CONTEXT END` line. If that
11
- > line is missing, the output was truncated: stop and re-run the command
12
- > directly, without pipes or filters. If the command is unavailable, stop and
13
- > restore/install ChangeLedger; do not proceed from memory.
9
+ > planning, investigating, or acting — run `changeledger context` directly.
10
+ > On the first invocation, retain complete stdout through the `CHANGELEDGER CONTEXT END` line:
11
+ > no pipes, filters, summaries, previews or voluntary output limits. If the tool
12
+ > exposes an output budget, reserve enough for the whole response. A missing END
13
+ > after that is exceptional recovery: stop and re-run with a larger capture. If
14
+ > the command is unavailable, stop and restore/install ChangeLedger; do not
15
+ > proceed from memory.
14
16
  >
15
17
  > Do not create or modify files without an authorized change; the core context
16
18
  > defines the workflow, the task contexts, and the narrow operational exception.
@@ -27,7 +27,7 @@ import { registerRepo } from '../src/commands/register.mjs';
27
27
  import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
28
28
  import { view } from '../src/commands/view.mjs';
29
29
  import { findChangeledgerDir } from '../src/config.mjs';
30
- import { applyMigration, SUPPORTED_SCHEMA_VERSION } from '../src/config-migration.mjs';
30
+ import { applyMigration } from '../src/config-migration.mjs';
31
31
  import { nowUtc } from '../src/paths.mjs';
32
32
 
33
33
  const { version } = createRequire(import.meta.url)('../package.json');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Pure validator: takes a loaded repo ({ config, changes }) and returns
2
2
  // { errors, warnings }. No IO — the `changeledger check` command does the IO and printing.
3
3
 
4
+ import { parseChange } from './change.mjs';
4
5
  import { CANONICAL_STATUSES, canTransition, parseLogEvent } from './lifecycle.mjs';
5
6
  import { compareVersions, parseVersion, RELEASE_IMPACTS } from './release.mjs';
6
7
 
@@ -82,7 +83,7 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
82
83
 
83
84
  if (fm.status === 'done' && tasks.some((t) => t.state !== 'done')) {
84
85
  const pending = tasks.filter((t) => t.state !== 'done').length;
85
- warn(c, `status is "done" but ${pending} task(s) are not done`);
86
+ err(c, `status is "done" but ${pending} task(s) are not done`);
86
87
  }
87
88
  if (fm.status === 'blocked' && tasks.length && !tasks.some((t) => t.state === 'blocked')) {
88
89
  warn(c, 'status is "blocked" but no task is marked [!]');
@@ -131,6 +132,41 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
131
132
  return { errors, warnings };
132
133
  }
133
134
 
135
+ // Reuses the scoped validator for a selected change, optionally replacing its
136
+ // in-memory text with a candidate that has not been written yet. Callers gate
137
+ // mutations on errors only; warnings remain informational by contract.
138
+ export function checkSelectedChange(repo, id, candidateText) {
139
+ let changes = repo.changes;
140
+ if (candidateText !== undefined) {
141
+ const index = changes.findIndex((c) => String(c.frontmatter?.id) === String(id));
142
+ if (index !== -1) {
143
+ const current = changes[index];
144
+ const candidate = { ...current, text: candidateText, ...parseChange(candidateText) };
145
+ changes = changes.with(index, candidate);
146
+ }
147
+ }
148
+ return checkRepo({ ...repo, changes }, { id });
149
+ }
150
+
151
+ export function assertSelectedChangeValid(repo, id, candidateText) {
152
+ const { errors } = checkSelectedChange(repo, id, candidateText);
153
+ if (!errors.length) return;
154
+ throw new Error(
155
+ `change ${id} failed scoped validation:\n${errors.map((error) => `- ${error.message}`).join('\n')}`,
156
+ );
157
+ }
158
+
159
+ // Validates one already-resolved file without loading or parsing siblings. This
160
+ // is the write-gate path for operations whose scope is exactly one change.
161
+ export function assertChangeTextValid(config, name, text) {
162
+ const parsed = parseChange(text);
163
+ const id = parsed.frontmatter.id;
164
+ assertSelectedChangeValid(
165
+ { config, changes: [{ name, text, ...parsed }], specs: [], releases: [] },
166
+ id,
167
+ );
168
+ }
169
+
134
170
  function checkReleases(releases, changesById, err) {
135
171
  const seenVersions = new Set();
136
172
  const releasedChanges = new Map();
@@ -2,18 +2,21 @@
2
2
  // (list/show). Files remain the source of truth; these are optional helpers
3
3
  // that inject correct timestamps/markers and validate transitions.
4
4
 
5
+ import fs from 'node:fs';
5
6
  import path from 'node:path';
6
- import { mutateFileAtomic } from '../atomic-write.mjs';
7
+ import { mutateFileAtomic, withFileLock } from '../atomic-write.mjs';
7
8
  import { parseChange } from '../change.mjs';
9
+ import { assertChangeTextValid } from '../check.mjs';
8
10
  import { ownerHandle as defaultOwnerHandle } from '../git.mjs';
9
11
  import { assertTransition } from '../lifecycle.mjs';
10
12
  import { nowUtc } from '../paths.mjs';
13
+ import { resolveReleasesDir } from '../release.mjs';
11
14
  import { loadRepo, resolveChange } from '../repo.mjs';
12
15
  import { appendLog, setArchived, setOwner, setStatus, setTask } from '../writer.mjs';
13
16
 
14
17
  function locate(cwd, id) {
15
- const { config, file } = resolveChange(cwd, id);
16
- return { config, file };
18
+ const { config, file, repoRoot } = resolveChange(cwd, id);
19
+ return { config, file, repoRoot };
17
20
  }
18
21
 
19
22
  export function status(
@@ -37,6 +40,9 @@ export function status(
37
40
  const autoOwner = newStatus === 'in-progress' ? ownerHandle(path.dirname(file)) : '';
38
41
  mutateFileAtomic(file, (text) => {
39
42
  const fm = parseChange(text).frontmatter;
43
+ if (fm.status === 'done' && newStatus === 'in-progress') {
44
+ throw new Error('only the human-facing viewer can reopen a done change');
45
+ }
40
46
  // Validate the move before any in-memory mutation, so an illegal transition
41
47
  // leaves the file byte-for-byte unchanged. The review gate reads review_required
42
48
  // from the change's type.
@@ -126,17 +132,51 @@ export function validation(id, verdict, { reason } = {}, cwd = process.cwd()) {
126
132
  reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
127
133
  });
128
134
  text = setStatus(text, target);
129
- return appendLog(
135
+ text = appendLog(
130
136
  text,
131
137
  nowUtc(),
132
138
  verdict === 'pass'
133
139
  ? 'validation → done (human accepted)'
134
140
  : `validation → in-progress (human rejected): ${reason}`,
135
141
  );
142
+ if (verdict === 'pass') assertChangeTextValid(config, path.basename(file), text);
143
+ return text;
136
144
  });
137
145
  return file;
138
146
  }
139
147
 
148
+ // Human-only correction path while `done` is still provisional. Graduation,
149
+ // skip, archive and release membership are durable boundaries and fail closed.
150
+ export function reopen(id, reason, cwd = process.cwd()) {
151
+ if (!String(reason ?? '').trim()) throw new Error('reopen requires a reason');
152
+ const { config, file, repoRoot } = locate(cwd, id);
153
+ const releasesDir = resolveReleasesDir(repoRoot);
154
+ fs.mkdirSync(releasesDir, { recursive: true });
155
+ return withFileLock(path.join(releasesDir, '.history'), () => {
156
+ const released = loadRepo(cwd).releases.some((release) =>
157
+ (release.changes ?? []).some((changeId) => String(changeId) === String(id)),
158
+ );
159
+ mutateFileAtomic(file, (text) => {
160
+ const change = { ...parseChange(text), text };
161
+ const fm = change.frontmatter;
162
+ if (fm.status !== 'done')
163
+ throw new Error(`reopen requires status done (current: ${fm.status})`);
164
+ if (fm.reviewed === true) throw new Error('cannot reopen: graduation is already reviewed');
165
+ if (hasGraduationResolution(change))
166
+ throw new Error('cannot reopen: graduation is already resolved');
167
+ if (fm.archived === true) throw new Error('cannot reopen: change is archived');
168
+ if (released) throw new Error('cannot reopen: change belongs to a recorded release');
169
+ assertTransition('done', 'in-progress', {
170
+ type: fm.type,
171
+ reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
172
+ });
173
+ text = setStatus(text, 'in-progress');
174
+ return appendLog(text, nowUtc(), `status: done → in-progress (human reopened): ${reason}`);
175
+ });
176
+ return file;
177
+ });
178
+ }
179
+
140
180
  // name '-' clears the owner.
141
181
  export function owner(id, name, cwd = process.cwd()) {
142
182
  const { file } = locate(cwd, id);
@@ -26,7 +26,7 @@ const STATUS_CONTEXT = {
26
26
  discarded: { mode: 'discarded', fragments: ['discarded'] },
27
27
  };
28
28
  const INCREMENTAL_NOTICE = `This incremental context extends the complete core context already read.
29
- If you have not read the core output through its \`CHANGELEDGER CONTEXT END\` line, stop and run \`changeledger context\` directly before proceeding.`;
29
+ Its one-pass full-capture rule applies here; a partial view is invalid.`;
30
30
 
31
31
  function fragment(name) {
32
32
  return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
@@ -73,7 +73,7 @@ function changePolicyBlock(config, type) {
73
73
 
74
74
  // One line per local dependency (id, title, status); external `project:id`
75
75
  // references stay references, never pretending local resolution.
76
- function dependencyBlock(config, repoRoot, dependsOn, cwd) {
76
+ function dependencyBlock(dependsOn, cwd) {
77
77
  if (!Array.isArray(dependsOn) || dependsOn.length === 0) return undefined;
78
78
  const lines = dependsOn.map((raw) => {
79
79
  const dep = String(raw);
@@ -140,7 +140,7 @@ export function buildContext(input, cwd = process.cwd()) {
140
140
  changeText: text,
141
141
  changeId: id,
142
142
  policy: changePolicyBlock(config, type),
143
- dependencies: dependencyBlock(config, resolved.repoRoot, dependsOn, cwd),
143
+ dependencies: dependencyBlock(dependsOn, cwd),
144
144
  });
145
145
  }
146
146
 
@@ -6,6 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { mutateFileAtomic, writeFileAtomic } from '../atomic-write.mjs';
8
8
  import { parseChange } from '../change.mjs';
9
+ import { assertChangeTextValid } from '../check.mjs';
9
10
  import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
10
11
  import { nowUtc } from '../paths.mjs';
11
12
  import { resolveChange } from '../repo.mjs';
@@ -30,11 +31,23 @@ function requireDone(changeText) {
30
31
  return change;
31
32
  }
32
33
 
34
+ function requireGraduationReady(config, changeFile, changeText) {
35
+ const change = requireDone(changeText);
36
+ assertChangeTextValid(config, path.basename(changeFile), changeText);
37
+ return change;
38
+ }
39
+
33
40
  export function scaffoldSpec(id, slug, cwd = process.cwd()) {
34
- const { file: changeFile, specsDir, specName, specFile } = graduationTarget(id, slug, cwd);
41
+ const {
42
+ config,
43
+ file: changeFile,
44
+ specsDir,
45
+ specName,
46
+ specFile,
47
+ } = graduationTarget(id, slug, cwd);
48
+ const change = requireGraduationReady(config, changeFile, fs.readFileSync(changeFile, 'utf8'));
35
49
  if (fs.existsSync(specFile)) throw new Error(`Spec "${specName}" already exists`);
36
50
 
37
- const change = requireDone(fs.readFileSync(changeFile, 'utf8'));
38
51
  const seedStage =
39
52
  change.stages.find((stage) => stage.key === 'specification') ??
40
53
  change.stages.find((stage) => stage.key === 'proposal');
@@ -65,14 +78,15 @@ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
65
78
  if (!into) {
66
79
  throw new Error('graduation mode required: use --new, --into, or --skip');
67
80
  }
68
- const { file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
81
+ const { config, file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
82
+ requireGraduationReady(config, changeFile, fs.readFileSync(changeFile, 'utf8'));
69
83
 
70
84
  if (!fs.existsSync(specFile)) {
71
85
  throw new Error(`Spec "${specName}" does not exist — use --new to create a scaffold`);
72
86
  }
73
87
 
74
88
  mutateFileAtomic(changeFile, (changeText) => {
75
- requireDone(changeText);
89
+ requireGraduationReady(config, changeFile, changeText);
76
90
  const specText = fs.readFileSync(specFile, 'utf8');
77
91
  if (specText.includes(SPEC_SCAFFOLD_MARKER)) {
78
92
  throw new Error(
@@ -91,12 +105,10 @@ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
91
105
  // Marks a done change's graduation as reviewed without creating a spec (e.g. a
92
106
  // bug/chore with no persistent truth). Records the reason in the Log.
93
107
  export function skipGraduation(id, reason, cwd = process.cwd()) {
94
- const { file: changeFile } = resolveChange(cwd, id);
108
+ const { config, file: changeFile } = resolveChange(cwd, id);
95
109
  const message = reason ? `graduation skipped: ${reason}` : 'graduation skipped';
96
110
  mutateFileAtomic(changeFile, (text) => {
97
- const change = parseChange(text);
98
- if (change.frontmatter.status !== 'done')
99
- throw new Error('only done changes can be graduated/skipped');
111
+ requireGraduationReady(config, changeFile, text);
100
112
 
101
113
  text = appendLog(text, nowUtc(), message);
102
114
  return setReviewed(text, true);
@@ -41,7 +41,11 @@ function parseViewArgs(args) {
41
41
  return { localOnly, port };
42
42
  }
43
43
 
44
- export async function view(args = [], cwd = process.cwd()) {
44
+ export async function view(
45
+ args = [],
46
+ cwd = process.cwd(),
47
+ { openBrowser: shouldOpen = true } = {},
48
+ ) {
45
49
  const { localOnly, port: requestedPort } = parseViewArgs(args);
46
50
  resolveProjects(cwd, localOnly); // fail fast if local mode outside a repo
47
51
 
@@ -54,7 +58,7 @@ export async function view(args = [], cwd = process.cwd()) {
54
58
  const port = await listen(server, host, requestedPort);
55
59
  const url = `http://${host}:${port}`;
56
60
  console.log(`ChangeLedger viewer → ${url} (Ctrl+C to stop)`);
57
- openBrowser(url);
61
+ if (shouldOpen) openBrowser(url);
58
62
  return server;
59
63
  }
60
64
 
package/src/contract.mjs CHANGED
@@ -37,11 +37,13 @@ const LEGACY_CONTRACT_HASHES = new Set([
37
37
  export const REFERENCE = `${MARKER}
38
38
  > [!IMPORTANT]
39
39
  > This repo uses **ChangeLedger**. Immediately after reading this file — before
40
- > planning, investigating, or acting — run \`changeledger context\` directly and
41
- > read its complete output through the \`CHANGELEDGER CONTEXT END\` line. If that
42
- > line is missing, the output was truncated: stop and re-run the command
43
- > directly, without pipes or filters. If the command is unavailable, stop and
44
- > restore/install ChangeLedger; do not proceed from memory.
40
+ > planning, investigating, or acting — run \`changeledger context\` directly.
41
+ > On the first invocation, retain complete stdout through the \`CHANGELEDGER CONTEXT END\` line:
42
+ > no pipes, filters, summaries, previews or voluntary output limits. If the tool
43
+ > exposes an output budget, reserve enough for the whole response. A missing END
44
+ > after that is exceptional recovery: stop and re-run with a larger capture. If
45
+ > the command is unavailable, stop and restore/install ChangeLedger; do not
46
+ > proceed from memory.
45
47
  >
46
48
  > Do not create or modify files without an authorized change; the core context
47
49
  > defines the workflow, the task contexts, and the narrow operational exception.
package/src/lifecycle.mjs CHANGED
@@ -20,7 +20,8 @@ export const CANONICAL_STATUSES = [
20
20
  // absent and therefore rejected. `in-review` is optional by type;
21
21
  // `in-validation` is the universal human gate before done. Review or validation
22
22
  // may route back to in-progress, while review may also block. `discarded` is a
23
- // terminal tombstone reachable only before either closing gate.
23
+ // terminal tombstone reachable only before either closing gate. `done` has one
24
+ // policy-gated human reopen edge; generic agent commands do not own it.
24
25
  const TRANSITIONS = {
25
26
  draft: ['approved', 'discarded'],
26
27
  approved: ['in-progress', 'discarded'],
@@ -28,7 +29,7 @@ const TRANSITIONS = {
28
29
  'in-review': ['in-validation', 'in-progress', 'blocked'],
29
30
  'in-validation': ['done', 'in-progress'],
30
31
  blocked: ['in-progress', 'discarded'],
31
- done: [],
32
+ done: ['in-progress'],
32
33
  discarded: [],
33
34
  };
34
35
 
package/src/metrics.mjs CHANGED
@@ -42,8 +42,8 @@ function transitions(change) {
42
42
  return events;
43
43
  }
44
44
 
45
- // Time spent in each state: [{ state, ms }]. The final state runs until `now`
46
- // (or until it reached `done`).
45
+ // Time spent in each state: [{ state, ms }]. A provisional `done` interval is
46
+ // retained in the event stream but excluded from active-state duration.
47
47
  export function statusTimeline(change, now) {
48
48
  const events = transitions(change);
49
49
  if (!events.length) return [];
@@ -53,7 +53,7 @@ export function statusTimeline(change, now) {
53
53
  const endIso = events[i + 1]?.at ?? now;
54
54
  const end = Date.parse(endIso);
55
55
  if (Number.isNaN(start) || Number.isNaN(end)) continue;
56
- // `done` is terminal there is no meaningful dwell time in it.
56
+ // `done` is outside active work, including a provisional interval before reopen.
57
57
  if (events[i].state === 'done') continue;
58
58
  segs.push({ state: events[i].state, ms: Math.max(0, end - start) });
59
59
  }
@@ -3,8 +3,13 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { mutateFileAtomic } from '../atomic-write.mjs';
6
+ import { parseChange } from '../change.mjs';
6
7
  import { checkRepo } from '../check.mjs';
7
- import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
8
+ import {
9
+ reopen as applyReopen,
10
+ status as applyStatusCmd,
11
+ validation as applyValidation,
12
+ } from '../commands/agent.mjs';
8
13
  import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
9
14
  import {
10
15
  buildMigration,
@@ -14,7 +19,7 @@ import {
14
19
  import { computeMetrics } from '../metrics.mjs';
15
20
  import { nowUtc } from '../paths.mjs';
16
21
  import { listProjects, remove, update } from '../registry.mjs';
17
- import { loadRepo, loadRepoWithConfig } from '../repo.mjs';
22
+ import { loadRepo, loadRepoWithConfig, resolveChange } from '../repo.mjs';
18
23
  import { parseYaml } from '../yaml.mjs';
19
24
 
20
25
  // Serializes a loaded repo into the flat shape the UI consumes.
@@ -115,10 +120,12 @@ export function changeStatus(projects, { project, id, status, reason }) {
115
120
  // the UI is bypassable.
116
121
  let current;
117
122
  try {
118
- const change = loadRepo(proj.path).changes.find((c) => String(c.frontmatter.id) === String(id));
119
- if (!change) return { code: 404, body: { error: `no change with id "${id}"` } };
120
- current = change.frontmatter.status;
123
+ const { file } = resolveChange(proj.path, id);
124
+ current = parseChange(fs.readFileSync(file, 'utf8')).frontmatter.status;
121
125
  } catch (e) {
126
+ if (/^No change with id /.test(e.message)) {
127
+ return { code: 404, body: { error: `no change with id "${id}"` } };
128
+ }
122
129
  return { code: 400, body: { error: e.message } };
123
130
  }
124
131
  try {
@@ -128,11 +135,14 @@ export function changeStatus(projects, { project, id, status, reason }) {
128
135
  applyValidation(id, 'pass', {}, proj.path);
129
136
  } else if (current === 'in-validation' && status === 'in-progress') {
130
137
  applyValidation(id, 'fail', { reason }, proj.path);
138
+ } else if (current === 'done' && status === 'in-progress') {
139
+ applyReopen(id, reason, proj.path);
131
140
  } else {
132
141
  return {
133
142
  code: 403,
134
143
  body: {
135
- error: 'the viewer only allows draft → approved and in-validation → done|in-progress',
144
+ error:
145
+ 'the viewer only allows draft → approved, in-validation → done|in-progress, and eligible done → in-progress',
136
146
  },
137
147
  };
138
148
  }
@@ -2,6 +2,8 @@ export const VIEWER_STATE_KEY = 'changeledger.viewer-state.v1';
2
2
 
3
3
  const VALID_VIEWS = new Set(['board', 'table', 'graph', 'specs', 'metrics', 'projects']);
4
4
  const VALID_SORT_KEYS = new Set(['id', 'title', 'type', 'status', 'progress', 'deps']);
5
+ const VALID_DETAIL_MODES = new Set(['side', 'floating']);
6
+ const VALID_DETAIL_SIZES = new Set(['compact', 'wide', 'full']);
5
7
  let storage = null;
6
8
 
7
9
  const emptyProjectFilters = () => ({
@@ -31,6 +33,8 @@ export const state = {
31
33
  projectsList: [],
32
34
  localOnly: false,
33
35
  globalMode: false,
36
+ detailMode: 'side',
37
+ detailSize: 'wide',
34
38
  };
35
39
 
36
40
  function currentProjectFilters() {
@@ -71,6 +75,8 @@ export function serializeViewerState() {
71
75
  sortKey: state.sortKey,
72
76
  sortDir: state.sortDir,
73
77
  projects: state.projectFilters,
78
+ detailMode: state.detailMode,
79
+ detailSize: state.detailSize,
74
80
  };
75
81
  }
76
82
 
@@ -99,6 +105,8 @@ export function restoreViewerState(storageLike) {
99
105
  if (typeof snapshot.text === 'string') state.filters.text = snapshot.text;
100
106
  if (typeof snapshot.sortKey === 'string') state.sortKey = snapshot.sortKey;
101
107
  if (snapshot.sortDir === 1 || snapshot.sortDir === -1) state.sortDir = snapshot.sortDir;
108
+ state.detailMode = VALID_DETAIL_MODES.has(snapshot.detailMode) ? snapshot.detailMode : 'side';
109
+ state.detailSize = VALID_DETAIL_SIZES.has(snapshot.detailSize) ? snapshot.detailSize : 'wide';
102
110
  if (
103
111
  snapshot.projects &&
104
112
  typeof snapshot.projects === 'object' &&
@@ -224,3 +232,10 @@ export function toggleGlobalMode() {
224
232
  persistViewerState();
225
233
  return state.globalMode;
226
234
  }
235
+
236
+ export function setDetailPresentation(mode, size) {
237
+ if (VALID_DETAIL_MODES.has(mode)) state.detailMode = mode;
238
+ if (VALID_DETAIL_SIZES.has(size)) state.detailSize = size;
239
+ persistViewerState();
240
+ return { mode: state.detailMode, size: state.detailSize };
241
+ }
@@ -19,6 +19,7 @@ import {
19
19
  normalizeRepoState,
20
20
  restoreViewerState,
21
21
  selectProject,
22
+ setDetailPresentation,
22
23
  setOwnerFilter,
23
24
  setRepo,
24
25
  setSortKey,
@@ -36,7 +37,7 @@ import { boardStatuses, isVisible, passesTombstones } from './state.js';
36
37
  import { html, render as litRender, nothing } from './templates.js';
37
38
  import {
38
39
  card,
39
- closeButton,
40
+ detailToolbar,
40
41
  sortIndicator,
41
42
  specBody,
42
43
  stageBlock,
@@ -50,6 +51,8 @@ export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
50
51
  export { boardStatuses, isVisible, passesTombstones } from './state.js';
51
52
  export {
52
53
  card,
54
+ detailPresentationControls,
55
+ detailToolbar,
53
56
  sortIndicator,
54
57
  stageBlock,
55
58
  statusSummary,
@@ -312,6 +315,73 @@ export async function runValidationSubmission({ root, request, onSuccess }) {
312
315
  return true;
313
316
  }
314
317
 
318
+ export function reopenPanel(status) {
319
+ if (status !== 'done') return nothing;
320
+ return html`<section class="validation-actions" aria-labelledby="reopen-title">
321
+ <div class="validation-copy">
322
+ <span class="validation-kicker">Lifecycle correction</span>
323
+ <h2 id="reopen-title">Reopen completed change</h2>
324
+ <p>Return this change to active work while preserving why its completion was reconsidered.</p>
325
+ </div>
326
+ <div class="reopen-controls">
327
+ <div class="rejection-field">
328
+ <label for="reopen-reason">Reason for reopening</label>
329
+ <input
330
+ id="reopen-reason"
331
+ data-reopen-reason
332
+ type="text"
333
+ placeholder="What requires more work?"
334
+ />
335
+ <p class="validation-error" role="alert" hidden></p>
336
+ </div>
337
+ <button type="button" class="button button-danger" data-reopen>Reopen change</button>
338
+ </div>
339
+ </section>`;
340
+ }
341
+
342
+ export function bindReopenAction({ root, request, onSuccess }) {
343
+ const button = root.querySelector('[data-reopen]');
344
+ if (!button) return;
345
+ button.onclick = async () => {
346
+ const input = root.querySelector('[data-reopen-reason]');
347
+ const reason = input?.value.trim();
348
+ if (!reason) {
349
+ showValidationError(root, 'A reopening reason is required.');
350
+ input?.focus();
351
+ return false;
352
+ }
353
+ return runValidationSubmission({
354
+ root,
355
+ request: () => request(reason),
356
+ onSuccess,
357
+ });
358
+ };
359
+ }
360
+
361
+ export function applyDetailPresentation(root = document) {
362
+ const overlay = root.querySelector('#overlay');
363
+ const detail = root.querySelector('#detail');
364
+ if (!overlay || !detail) return;
365
+ overlay.dataset.detailMode = state.detailMode;
366
+ detail.dataset.detailSize = state.detailSize;
367
+ }
368
+
369
+ export function bindDetailPresentation(root = document) {
370
+ root.querySelectorAll('[data-detail-setting]').forEach((button) => {
371
+ button.onclick = () => {
372
+ const setting = button.dataset.detailSetting;
373
+ setDetailPresentation(
374
+ setting === 'mode' ? button.dataset.detailValue : state.detailMode,
375
+ setting === 'size' ? button.dataset.detailValue : state.detailSize,
376
+ );
377
+ applyDetailPresentation(root);
378
+ root.querySelectorAll(`[data-detail-setting="${setting}"]`).forEach((option) => {
379
+ option.setAttribute('aria-pressed', String(option === button));
380
+ });
381
+ };
382
+ });
383
+ }
384
+
315
385
  async function submitValidation(id, status, reason) {
316
386
  const root = $('#detail');
317
387
  await runValidationSubmission({
@@ -325,6 +395,28 @@ async function submitValidation(id, status, reason) {
325
395
  });
326
396
  }
327
397
 
398
+ export function resetDetailScroll(
399
+ detail,
400
+ schedule = globalThis.requestAnimationFrame?.bind(globalThis),
401
+ ) {
402
+ const reset = () => {
403
+ detail.scrollTo?.({ top: 0, left: 0, behavior: 'instant' });
404
+ detail.scrollTop = 0;
405
+ };
406
+ reset();
407
+ schedule?.(reset);
408
+ }
409
+
410
+ export function scrollToStage(stage) {
411
+ stage.scrollIntoView({ behavior: 'auto', block: 'start' });
412
+ }
413
+
414
+ function renderOpenedDetail(content) {
415
+ const detail = $('#detail');
416
+ litRender(content, detail);
417
+ resetDetailScroll(detail);
418
+ }
419
+
328
420
  function openDetail(id) {
329
421
  const c = state.repo.changes.find((x) => String(x.id) === String(id));
330
422
  if (!c) return;
@@ -334,14 +426,11 @@ function openDetail(id) {
334
426
  ? html`<span class="pill ext" data-extdep=${d} style="cursor:pointer">depends on ${d}</span>`
335
427
  : html`<span class="pill" data-dep=${d} style="cursor:pointer">depends on #${d}</span>`;
336
428
  });
337
- const pipeline = c.stages.map(
338
- (s) => html`<span class="stage-chip" data-go=${`stage-${s.key}`}>${s.heading}</span>`,
339
- );
340
429
  const stages = c.stages.map((s) => stageBlock(c, s));
341
430
 
342
- litRender(
431
+ renderOpenedDetail(
343
432
  html`
344
- ${closeButton()}
433
+ ${detailToolbar(state.detailMode, state.detailSize, c.stages)}
345
434
  <h1>${c.title}</h1>
346
435
  <div class="detail-meta">
347
436
  <span class="pill">#${c.id}</span>
@@ -352,16 +441,18 @@ function openDetail(id) {
352
441
  ${deps}
353
442
  </div>
354
443
  ${c.status === 'in-validation' ? validationPanel() : nothing}
355
- <div class="pipeline">${pipeline}</div>
444
+ ${reopenPanel(c.status)}
356
445
  ${stages}
357
446
  <div id="git-section"></div>`,
358
- $('#detail'),
359
447
  );
360
448
 
361
449
  resetValidationState($('#detail'));
362
450
 
363
451
  const overlay = $('#overlay');
364
452
  overlay.classList.remove('hidden');
453
+ document.documentElement.classList.add('detail-open');
454
+ applyDetailPresentation();
455
+ bindDetailPresentation();
365
456
  $('#detail').querySelector('.close').onclick = closeDetail;
366
457
  const accept = $('#detail').querySelector('[data-validation="pass"]');
367
458
  if (accept) accept.onclick = () => submitValidation(c.id, 'done');
@@ -378,14 +469,22 @@ function openDetail(id) {
378
469
  submitValidation(c.id, 'in-progress', reason);
379
470
  };
380
471
  }
472
+ bindReopenAction({
473
+ root: $('#detail'),
474
+ request: (reason) => postStatus(state.currentProject, c.id, 'in-progress', reason),
475
+ onSuccess: async () => {
476
+ invalidateCache();
477
+ await load();
478
+ openDetail(c.id);
479
+ },
480
+ });
381
481
  overlay.onclick = (e) => {
382
482
  if (e.target === overlay) closeDetail();
383
483
  };
384
484
  $('#detail')
385
485
  .querySelectorAll('[data-go]')
386
486
  .forEach((el) => {
387
- el.onclick = () =>
388
- $(`#${el.dataset.go}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
487
+ el.onclick = () => scrollToStage($(`#${el.dataset.go}`));
389
488
  });
390
489
  $('#detail')
391
490
  .querySelectorAll('[data-dep]')
@@ -441,6 +540,7 @@ async function loadGitRefs(id) {
441
540
 
442
541
  function closeDetail() {
443
542
  $('#overlay').classList.add('hidden');
543
+ document.documentElement.classList.remove('detail-open');
444
544
  }
445
545
 
446
546
  let diagramLightbox = null;
@@ -584,9 +684,9 @@ function renderSpecs() {
584
684
  }
585
685
 
586
686
  function openSpec(s) {
587
- litRender(
687
+ renderOpenedDetail(
588
688
  html`
589
- ${closeButton()}
689
+ ${detailToolbar(state.detailMode, state.detailSize)}
590
690
  <h1>${s.title}</h1>
591
691
  <div class="detail-meta">
592
692
  <span class="pill">spec</span>
@@ -594,10 +694,12 @@ function openSpec(s) {
594
694
  ${(s.tags || []).map((t) => html`<span class="pill">${t}</span>`)}
595
695
  </div>
596
696
  ${specBody(s.body)}`,
597
- $('#detail'),
598
697
  );
599
698
  const overlay = $('#overlay');
600
699
  overlay.classList.remove('hidden');
700
+ document.documentElement.classList.add('detail-open');
701
+ applyDetailPresentation();
702
+ bindDetailPresentation();
601
703
  const detail = $('#detail');
602
704
  detail.querySelector('.close').onclick = closeDetail;
603
705
  overlay.onclick = (e) => {
@@ -23,12 +23,34 @@
23
23
  --status-discarded: #9aa0aa;
24
24
  --status-muted: #8b929e;
25
25
  font-family: "Avenir Next", Avenir, ui-sans-serif, sans-serif;
26
+ color-scheme: dark;
26
27
  }
27
28
 
28
29
  * {
29
30
  box-sizing: border-box;
30
31
  }
31
32
 
33
+ html,
34
+ body {
35
+ scrollbar-width: thin;
36
+ scrollbar-color: #3a414e transparent;
37
+ }
38
+ html::-webkit-scrollbar,
39
+ body::-webkit-scrollbar {
40
+ width: 8px;
41
+ height: 8px;
42
+ }
43
+ html::-webkit-scrollbar-track,
44
+ body::-webkit-scrollbar-track {
45
+ background: transparent;
46
+ }
47
+ html::-webkit-scrollbar-thumb,
48
+ body::-webkit-scrollbar-thumb {
49
+ background: #3a414e;
50
+ border: 2px solid var(--bg);
51
+ border-radius: 999px;
52
+ }
53
+
32
54
  body {
33
55
  margin: 0;
34
56
  background: var(--bg);
@@ -527,16 +549,31 @@ body {
527
549
  padding: 16px;
528
550
  overflow-x: auto;
529
551
  align-items: flex-start;
552
+ }
553
+ .board,
554
+ .detail,
555
+ .detail pre,
556
+ .detail table {
530
557
  scrollbar-width: thin;
531
558
  scrollbar-color: #3a414e transparent;
532
559
  }
533
- .board::-webkit-scrollbar {
560
+ .board::-webkit-scrollbar,
561
+ .detail::-webkit-scrollbar,
562
+ .detail pre::-webkit-scrollbar,
563
+ .detail table::-webkit-scrollbar {
564
+ width: 8px;
534
565
  height: 8px;
535
566
  }
536
- .board::-webkit-scrollbar-track {
567
+ .board::-webkit-scrollbar-track,
568
+ .detail::-webkit-scrollbar-track,
569
+ .detail pre::-webkit-scrollbar-track,
570
+ .detail table::-webkit-scrollbar-track {
537
571
  background: transparent;
538
572
  }
539
- .board::-webkit-scrollbar-thumb {
573
+ .board::-webkit-scrollbar-thumb,
574
+ .detail::-webkit-scrollbar-thumb,
575
+ .detail pre::-webkit-scrollbar-thumb,
576
+ .detail table::-webkit-scrollbar-thumb {
540
577
  background: #3a414e;
541
578
  border: 2px solid var(--bg);
542
579
  border-radius: 999px;
@@ -545,10 +582,10 @@ body {
545
582
  background: var(--panel);
546
583
  border: 1px solid var(--line);
547
584
  border-radius: 12px;
548
- width: clamp(238px, calc((100vw - 116px) / 7), 320px);
549
- min-width: 238px;
550
- max-width: 320px;
551
- flex: 0 0 clamp(238px, calc((100vw - 116px) / 7), 320px);
585
+ width: clamp(190px, calc((100vw - 140px) / 6), 400px);
586
+ min-width: 190px;
587
+ max-width: 400px;
588
+ flex: 0 0 clamp(190px, calc((100vw - 140px) / 6), 400px);
552
589
  }
553
590
  .column-head {
554
591
  display: flex;
@@ -593,6 +630,8 @@ body {
593
630
  font-family: ui-monospace, monospace;
594
631
  color: var(--muted);
595
632
  font-size: 12px;
633
+ min-width: 0;
634
+ overflow-wrap: anywhere;
596
635
  }
597
636
  .type-tag {
598
637
  font-size: 10px;
@@ -625,6 +664,7 @@ body {
625
664
  .card-title {
626
665
  font-weight: 600;
627
666
  line-height: 1.3;
667
+ overflow-wrap: anywhere;
628
668
  }
629
669
  .progress {
630
670
  margin-top: 9px;
@@ -640,6 +680,7 @@ body {
640
680
  }
641
681
  .card-meta {
642
682
  display: flex;
683
+ flex-wrap: wrap;
643
684
  gap: 10px;
644
685
  margin-top: 7px;
645
686
  font-size: 11px;
@@ -689,6 +730,13 @@ body {
689
730
  gap: 10px;
690
731
  margin-top: 15px;
691
732
  }
733
+ .reopen-controls {
734
+ display: grid;
735
+ grid-template-columns: minmax(190px, 1fr) auto;
736
+ align-items: end;
737
+ gap: 10px;
738
+ margin-top: 15px;
739
+ }
692
740
  .rejection-field {
693
741
  display: flex;
694
742
  flex-direction: column;
@@ -750,6 +798,7 @@ body {
750
798
  }
751
799
  .owner {
752
800
  font-weight: 600;
801
+ overflow-wrap: anywhere;
753
802
  }
754
803
  .card.archived {
755
804
  opacity: 0.55;
@@ -847,21 +896,107 @@ body {
847
896
  }
848
897
 
849
898
  /* Overlay + detail */
899
+ .detail-open {
900
+ overflow: hidden;
901
+ }
850
902
  .overlay {
851
903
  position: fixed;
852
904
  inset: 0;
853
905
  background: rgba(0, 0, 0, 0.55);
854
906
  display: flex;
855
907
  justify-content: flex-end;
908
+ align-items: stretch;
856
909
  z-index: 20;
857
910
  }
858
911
  .detail {
859
- width: min(720px, 92vw);
912
+ --detail-width: 960px;
913
+ container-type: inline-size;
914
+ position: relative;
915
+ width: min(var(--detail-width), calc(100vw - 48px));
860
916
  height: 100%;
861
917
  background: var(--bg);
862
918
  border-left: 1px solid var(--line);
863
919
  overflow-y: auto;
920
+ overflow-anchor: none;
864
921
  padding: 22px 26px 60px;
922
+ box-shadow: -24px 0 70px rgba(0, 0, 0, 0.28);
923
+ }
924
+ .detail[data-detail-size="compact"] {
925
+ --detail-width: 720px;
926
+ }
927
+ .detail[data-detail-size="wide"] {
928
+ --detail-width: 960px;
929
+ }
930
+ .detail[data-detail-size="full"] {
931
+ --detail-width: 1280px;
932
+ }
933
+ .overlay[data-detail-mode="floating"] {
934
+ align-items: center;
935
+ justify-content: center;
936
+ padding: 28px;
937
+ }
938
+ .overlay[data-detail-mode="floating"] .detail {
939
+ width: min(var(--detail-width), calc(100vw - 56px));
940
+ height: min(92vh, calc(100vh - 56px));
941
+ border: 1px solid var(--line);
942
+ border-radius: 16px;
943
+ box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48);
944
+ }
945
+ .detail-toolbar {
946
+ position: sticky;
947
+ top: -22px;
948
+ z-index: 3;
949
+ display: grid;
950
+ grid-template-columns: auto minmax(0, 1fr) auto;
951
+ align-items: center;
952
+ gap: 12px;
953
+ margin: -22px -26px 20px;
954
+ padding: 14px 26px 10px;
955
+ border-bottom: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
956
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
957
+ backdrop-filter: blur(14px);
958
+ }
959
+ .detail-presentation {
960
+ display: flex;
961
+ flex-wrap: wrap;
962
+ gap: 8px;
963
+ width: fit-content;
964
+ }
965
+ .detail-choice {
966
+ display: inline-flex;
967
+ padding: 3px;
968
+ border: 1px solid var(--line);
969
+ border-radius: 9px;
970
+ background: var(--panel);
971
+ }
972
+ .detail-option {
973
+ min-height: 28px;
974
+ padding: 4px 9px;
975
+ border: 0;
976
+ border-radius: 6px;
977
+ background: transparent;
978
+ color: var(--muted);
979
+ font: inherit;
980
+ font-size: 11px;
981
+ cursor: pointer;
982
+ }
983
+ .detail-option[aria-pressed="true"] {
984
+ background: var(--panel-2);
985
+ color: var(--text);
986
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 42%, var(--line));
987
+ }
988
+ .detail-option:focus-visible {
989
+ outline: 2px solid var(--accent);
990
+ outline-offset: 2px;
991
+ }
992
+ .detail pre {
993
+ max-width: 100%;
994
+ overflow-x: auto;
995
+ }
996
+ .detail table {
997
+ display: block;
998
+ max-width: 100%;
999
+ overflow-x: auto;
865
1000
  }
866
1001
  .detail h1 {
867
1002
  font-size: 22px;
@@ -909,19 +1044,27 @@ body {
909
1044
  outline-offset: 3px;
910
1045
  }
911
1046
  .close {
912
- position: absolute;
913
- top: 16px;
914
- right: 22px;
1047
+ justify-self: end;
1048
+ }
1049
+ .detail-toolbar-close {
1050
+ grid-column: 3;
915
1051
  }
916
1052
 
917
1053
  .pipeline {
918
1054
  display: flex;
919
- flex-wrap: wrap;
1055
+ min-width: 0;
1056
+ flex-wrap: nowrap;
920
1057
  gap: 6px;
921
- margin: 16px 0;
1058
+ margin: 0;
1059
+ padding: 3px 2px;
1060
+ overflow-x: auto;
1061
+ overscroll-behavior-x: contain;
1062
+ scrollbar-width: thin;
922
1063
  }
923
1064
  .stage-chip {
1065
+ flex: 0 0 auto;
924
1066
  font-size: 12px;
1067
+ font-family: inherit;
925
1068
  padding: 5px 11px;
926
1069
  border-radius: 999px;
927
1070
  background: var(--panel);
@@ -937,6 +1080,7 @@ body {
937
1080
  .stage {
938
1081
  border-top: 1px solid var(--line);
939
1082
  padding-top: 14px;
1083
+ scroll-margin-top: 72px;
940
1084
  margin-top: 18px;
941
1085
  }
942
1086
  .stage h2 {
@@ -1235,7 +1379,8 @@ body {
1235
1379
  width: 100%;
1236
1380
  flex: 0 0 auto;
1237
1381
  }
1238
- .validation-controls {
1382
+ .validation-controls,
1383
+ .reopen-controls {
1239
1384
  grid-template-columns: 1fr;
1240
1385
  align-items: stretch;
1241
1386
  }
@@ -1255,7 +1400,32 @@ body {
1255
1400
  }
1256
1401
  .detail {
1257
1402
  width: 100vw;
1403
+ max-width: none;
1404
+ height: 100vh;
1405
+ max-height: none;
1258
1406
  padding: 18px 16px 60px;
1407
+ border: 0;
1408
+ border-radius: 0;
1409
+ }
1410
+ .overlay[data-detail-mode] {
1411
+ align-items: stretch;
1412
+ justify-content: stretch;
1413
+ padding: 0;
1414
+ }
1415
+ .overlay[data-detail-mode] .detail {
1416
+ width: 100vw;
1417
+ height: 100vh;
1418
+ max-height: none;
1419
+ border: 0;
1420
+ border-radius: 0;
1421
+ }
1422
+ .detail-toolbar {
1423
+ top: -18px;
1424
+ margin: -18px -16px 16px;
1425
+ padding: 10px 16px 8px;
1426
+ }
1427
+ .detail-option {
1428
+ padding-inline: 7px;
1259
1429
  }
1260
1430
  .grid th,
1261
1431
  .grid td {
@@ -1263,6 +1433,27 @@ body {
1263
1433
  }
1264
1434
  }
1265
1435
 
1436
+ /* Detail toolbar stacks its stage navigation onto its own row whenever the
1437
+ detail panel itself lacks room, independent of the surrounding viewport —
1438
+ this also covers the "Compact" width preset, not just narrow screens. */
1439
+ @container (max-width: 680px) {
1440
+ .detail-toolbar {
1441
+ grid-template-columns: minmax(0, 1fr) auto;
1442
+ gap: 8px;
1443
+ }
1444
+ .detail-toolbar-close {
1445
+ grid-column: 2;
1446
+ grid-row: 1;
1447
+ }
1448
+ .pipeline {
1449
+ grid-column: 1 / -1;
1450
+ grid-row: 2;
1451
+ }
1452
+ .stage {
1453
+ scroll-margin-top: 126px;
1454
+ }
1455
+ }
1456
+
1266
1457
  /* config editor tabs and form (113924) */
1267
1458
  .config-section {
1268
1459
  border-bottom: 1px solid var(--line);
@@ -36,6 +36,58 @@ export function closeButton(label = 'Close detail', extraClass = '') {
36
36
  </button>`;
37
37
  }
38
38
 
39
+ export function detailPresentationControls(mode = 'side', size = 'wide') {
40
+ const options = (values, selected, attr) =>
41
+ values.map(
42
+ ([value, label]) => html`<button
43
+ type="button"
44
+ class="detail-option"
45
+ data-detail-setting=${attr}
46
+ data-detail-value=${value}
47
+ aria-pressed=${String(selected === value)}
48
+ >${label}</button>`,
49
+ );
50
+ return html`<div class="detail-presentation" aria-label="Detail presentation">
51
+ <div class="detail-choice" role="group" aria-label="Layout">
52
+ ${options(
53
+ [
54
+ ['side', 'Side panel'],
55
+ ['floating', 'Floating modal'],
56
+ ],
57
+ mode,
58
+ 'mode',
59
+ )}
60
+ </div>
61
+ <div class="detail-choice" role="group" aria-label="Width">
62
+ ${options(
63
+ [
64
+ ['compact', 'Compact'],
65
+ ['wide', 'Wide'],
66
+ ['full', 'Full'],
67
+ ],
68
+ size,
69
+ 'size',
70
+ )}
71
+ </div>
72
+ </div>`;
73
+ }
74
+
75
+ export function detailToolbar(mode = 'side', size = 'wide', stages = []) {
76
+ const navigation = stages.length
77
+ ? html`<nav class="pipeline" aria-label="Change sections">
78
+ ${stages.map(
79
+ (stage) =>
80
+ html`<button type="button" class="stage-chip" data-go=${`stage-${stage.key}`}>${stage.heading}</button>`,
81
+ )}
82
+ </nav>`
83
+ : nothing;
84
+ return html`<div class="detail-toolbar" aria-label="Detail tools">
85
+ ${detailPresentationControls(mode, size)}
86
+ ${navigation}
87
+ ${closeButton('Close detail', 'detail-toolbar-close')}
88
+ </div>`;
89
+ }
90
+
39
91
  export function validationPanel() {
40
92
  return html`<section class="validation-actions" aria-labelledby="validation-title">
41
93
  <div class="validation-copy">
@@ -39,6 +39,12 @@ not necessarily that a spec was created.
39
39
  The graduation link remains derivable from the Log marker `graduado a spec`,
40
40
  which carries the spec link, rather than from the boolean flag.
41
41
 
42
+ After `--into` or `--skip`, create one final closure commit that coalesces any
43
+ pending `in-review → in-validation → done` ledger updates with the graduation
44
+ decision and durable spec edit. Do not create separate commits whose only
45
+ content is one of those transitions. If no lifecycle state is pending, the
46
+ graduation or skip itself remains the meaningful closure evidence.
47
+
42
48
  Operational inspection and visibility:
43
49
 
44
50
  - `changeledger list [--status S] [--type T] [--json]`
@@ -47,4 +53,4 @@ Operational inspection and visibility:
47
53
 
48
54
  Use Mermaid where it communicates persistent relationships better than prose.
49
55
  After closure, share a brief retrospective. New work needs a newly authorized
50
- change; `done` never reopens.
56
+ change; graduated, skipped, archived or released work never reopens.
@@ -5,11 +5,16 @@ reflection. Work is planned and documented before code is written.
5
5
 
6
6
  ## Read complete context before acting
7
7
 
8
- Running `changeledger context` is discovery, not compliance by itself. Read the
9
- complete output through the `CHANGELEDGER CONTEXT END` line, then follow the
10
- current mode. If that line is missing, the output was truncated. Stop and re-run
11
- the command directly, without pipes or filters, before creating or modifying
12
- files.
8
+ Running `changeledger context` is discovery, not compliance by itself. Capture the first invocation completely in one pass
9
+ and read through the `CHANGELEDGER CONTEXT END` line, then follow the current mode. Never request a preview, summary
10
+ or voluntary line, byte or token cap; if the tool exposes an output budget,
11
+ reserve enough for the whole response. A missing END after this deliberate full
12
+ capture is exceptional recovery: stop and re-run with a larger capture before
13
+ planning or acting on the partial output.
14
+
15
+ While the complete core remains available in the active conversation, a new
16
+ human message alone does not trigger a reload. Load only the specialized mode or
17
+ change-id context required by a real task or lifecycle transition.
13
18
 
14
19
  1. Work starts with conversation. Read-only investigation may clarify a request,
15
20
  but create no change or implementation artifact until there is enough clarity
@@ -19,12 +24,12 @@ files.
19
24
  agent decides how to divide and execute work within that authorized scope.
20
25
  3. Capture every authorized change in `.changeledger/changes/`. The document
21
26
  wins when code and documentation disagree.
22
- 4. Never implement a `draft`. After approval, work one change at a time on a
23
- non-main branch and commit the approved change document before code.
27
+ 4. Never implement a `draft`. After approval, implement one change at a time on
28
+ a non-main branch and commit the approved change document before code.
24
29
  5. Keep lifecycle, tasks, ownership and Log current while working.
25
30
  6. For types that require review, use a fresh clean-context reviewer before
26
31
  human validation.
27
- 7. Stop at `in-validation`. The agent never accepts on the human's behalf.
32
+ 7. `in-validation` stops only that change; the agent never accepts on the human's behalf, but may start another approved change unless it or its `depends_on` chain (direct or transitive) reaches an `in-validation` change.
28
33
  8. After human acceptance, reload `changeledger context <id>` for the `done`
29
34
  change, then graduate persistent truth (a new spec is a two-step `--new`
30
35
  then `--into`) or run `changeledger graduate <id> --skip [reason]`; archive
@@ -59,6 +64,7 @@ in-progress → in-validation → done [no review required]
59
64
  in-review → in-progress [review retry]
60
65
  in-review → blocked → in-progress [review escalation]
61
66
  in-validation → in-progress [human rejection]
67
+ done → in-progress [human reopen before durable closure]
62
68
  (draft | approved | in-progress | blocked) → discarded
63
69
  ```
64
70
 
@@ -68,15 +74,16 @@ in-validation → in-progress [human rejection]
68
74
  - `in-review`: independent review required.
69
75
  - `in-validation`: stop and wait for human acceptance or rejection.
70
76
  - `blocked`: an impediment or decision needs resolution.
71
- - `done`: terminal; the human accepted the complete result.
77
+ - `done`: the human accepted the complete result; provisional until durable closure.
72
78
  - `discarded`: terminal tombstone; never reopen it.
73
79
 
74
80
  `changeledger status <id> <status>` enforces agent-owned transitions and does not accept `done` or `discarded`.
75
- The viewer owns `draft → approved` and `in-validation → done|in-progress`; the
76
- agent performs the other non-terminal moves. Use
81
+ The viewer owns `draft → approved` and `in-validation → done|in-progress`, plus
82
+ eligible `done → in-progress` with a reason; the agent performs other moves. Use
77
83
  `changeledger discard <id> "<reason>"`: the discard reason is required and
78
- logged, and dependencies remain resolvable. `done` and `discarded` never reopen;
79
- later reconsideration needs a newly authorized change.
84
+ logged, and dependencies remain resolvable. `discarded` never reopens. A `done`
85
+ change can reopen only to finish its original scope before graduation/skip,
86
+ archive or release; after durable closure, later work needs a new change.
80
87
 
81
88
  ## Context modes
82
89
 
@@ -21,7 +21,8 @@ owns, what it returns and how the result integrates.
21
21
  - Verification may be delegated when it catches risk without merely repeating
22
22
  the implementer's work.
23
23
  - Configured review is special: a fresh clean-context subagent is a correctness
24
- requirement, not an optimization.
24
+ requirement, not an optimization — and read-only: it reports, the
25
+ orchestrator alone records the verdict.
25
26
 
26
27
  ## Do not over-shard
27
28
 
@@ -17,10 +17,17 @@ to a work branch or ask the human before continuing. Inspect the worktree first.
17
17
  unrelated changes exist, do not include them silently; ask the human whether to
18
18
  stash, commit, ignore or include them before changing the worktree.
19
19
 
20
- Commit the approved change documentation before touching implementation code.
21
- Implement one change at a time. Commit a completed unit before continuing when
22
- another task, change or edit of the same surface could make attribution
23
- ambiguous; do not wait until the end to reconstruct mixed diffs.
20
+ After `approved in-progress`, create a baseline commit of the approved change
21
+ document before code. Implement one change at a time, even while another
22
+ already-delivered change waits in `in-validation`.
23
+
24
+ Commit completed units with their tasks and Log when later work could obscure
25
+ attribution. Do not create a dedicated commit for a lifecycle-only transition.
26
+ Coalesce it with the nearest meaningful commit; for example, include
27
+ `in-progress → in-review` with the final implementation unit. Do not wait until
28
+ the end to reconstruct mixed diffs. If a handoff precedes the next
29
+ meaningful commit, one consolidated checkpoint persists pending state; record
30
+ why and never create one per transition.
24
31
 
25
32
  Commit messages use the canonical shape:
26
33
 
@@ -44,25 +51,32 @@ Useful mutation commands:
44
51
  - `changeledger task <id> done|block <n> [reason]`
45
52
  - `changeledger log <id> "<message>"`
46
53
  - `changeledger owner <id> <name|->`
54
+ - `changeledger review <id> pass|fail`
47
55
  - `changeledger check [id]`
48
56
 
49
57
  When implementation and every task are complete, move to `in-review` if the
50
- type requires independent review; otherwise move to `in-validation` and stop.
58
+ type requires independent review, loading `changeledger context review`
59
+ once first — it stays valid for the rest of the cycle, do not reload it just
60
+ to record the verdict unless context was lost (compaction, a new session) —
61
+ and recording the delegate's verdict yourself with `review <id> pass|fail`
62
+ (never `log`+`status`). Otherwise move to `in-validation` and stop — its
63
+ closing has no CLI, only the human does it.
51
64
 
52
65
  ## Correction isolation
53
66
 
54
67
  After review `fail --retry`, keep the candidate correction uncommitted while a
55
68
  fresh clean-context reviewer checks it. If it fails again, iterate on that same
56
69
  diff. Do not start another task or change while a correction waits: the
57
- worktree is its isolation boundary. After `pass`, commit the confirmed correction
58
- with its related ledger truth before asking for human validation.
70
+ worktree is its isolation boundary. After `pass`, commit the confirmed correction,
71
+ tests and ledger before human validation; this is meaningful correction evidence,
72
+ not a status-only commit.
59
73
 
60
74
  After human rejection (`in-validation → in-progress`), run
61
75
  `changeledger context <id>` before modifying implementation; keep the correction
62
76
  uncommitted until the human confirms it fixes the reported failure. Do not start
63
77
  another task or change while a correction waits; iterate on
64
- the same diff if it does not. After human acceptance, graduate or record a skip,
65
- then commit the correction with its ledger truth.
78
+ the same diff if it does not. After human acceptance, graduate or record a skip
79
+ and include correction plus ledger in the final closure commit.
66
80
 
67
81
  These exceptions prevent false fix attempts from becoming permanent history;
68
82
  they do not relax intermediate commits for already verified units.
@@ -21,6 +21,11 @@ Record exactly one verdict:
21
21
  - `changeledger review <id> fail --block "<reason>"` — correction requires scope
22
22
  or product judgment; move to `blocked` for the human.
23
23
 
24
+ A review verdict alone needs no commit. A pass leaves `in-validation` for
25
+ closure unless it confirms uncommitted correction; then correction, tests and
26
+ ledger form a commit. Retry keeps the diff isolated. Handoff may use the
27
+ implementation contract's checkpoint.
28
+
24
29
  After `fail --retry`, the correction remains uncommitted until another fresh
25
30
  reviewer passes it. After the transition, run `changeledger context <id>` before
26
31
  modifying implementation. After pass, commit correction + ledger before asking
@@ -3,8 +3,22 @@
3
3
  Implementation and required review are complete. Do not modify the result or
4
4
  mark it done. Ask the human to test the whole change in the viewer.
5
5
 
6
+ This stop is scoped to this change: the agent may start the next approved
7
+ change unless its `depends_on` chain, direct or transitive, reaches this or
8
+ another `in-validation` change. If every remaining approved change is
9
+ blocked, it stops entirely and does not invent work or touch delivered
10
+ results.
11
+
6
12
  Acceptance reaches `done`. Rejection requires a reason and returns the same
7
13
  change to `in-progress`; run `changeledger context <id>` before modifying
8
14
  implementation, update Specification/Plan as needed and repeat review when
9
- configured. The agent never accepts on the human's behalf. `done` and `discarded`
10
- never reopen.
15
+ configured. The agent never accepts on the human's behalf. Before graduation,
16
+ skip, archive or release, the human may reopen `done` with a reason only to
17
+ complete the original authorized scope; broader behavior needs a new change.
18
+ `discarded` never reopens.
19
+
20
+ The validation transition alone does not require a dedicated commit. After
21
+ acceptance, resolve graduation or skip first; the close overlay then requires one
22
+ final closure commit containing the pending lifecycle Log and graduation truth.
23
+ After rejection, follow correction isolation instead of committing an
24
+ unconfirmed attempt.