review-workspace 1.4.0 → 1.5.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.
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { exportHandoff } from "../handoff/export-handoff.js";
4
+ import { readQuestions } from "../questions/questions-log.js";
5
+ import { DEFAULT_LIMITS } from "../security/limits.js";
6
+ import { publishBundle } from "./publish.js";
7
+ import { readChain } from "./round.js";
8
+ /**
9
+ * Publishes a bundle and, only once the publish itself succeeds, exports its
10
+ * open change-request Comments as a hand-off file into the target repo's
11
+ * working tree. The round number comes from `chain.json` when present
12
+ * (round N>1), otherwise round 1 — independent of chaining, same as the
13
+ * hand-off export itself.
14
+ */
15
+ export async function publishBundleAndExportHandoff(bundlePath, options) {
16
+ const result = publishBundle(bundlePath, options.limits ?? DEFAULT_LIMITS);
17
+ if (!result.ok)
18
+ return result;
19
+ const document = JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
20
+ const round = readChain(bundlePath)?.round ?? 1;
21
+ const handoff = await exportHandoff({
22
+ bundlePath,
23
+ comparison: document.comparison,
24
+ round,
25
+ comments: readQuestions(bundlePath),
26
+ annotations: document.annotations ?? [],
27
+ ask: options.ask,
28
+ repoPathCachePath: options.repoPathCachePath,
29
+ });
30
+ return { ...result, handoff };
31
+ }
@@ -13,6 +13,13 @@ export function mrKeyFor(comparison) {
13
13
  function readReviewDocument(bundlePath) {
14
14
  return JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
15
15
  }
16
+ /** Reads a bundle's `chain.json`, if it has one — absent for every round-1 bundle. */
17
+ export function readChain(bundlePath) {
18
+ const path = join(bundlePath, 'chain.json');
19
+ if (!existsSync(path))
20
+ return undefined;
21
+ return JSON.parse(readFileSync(path, 'utf-8'));
22
+ }
16
23
  /**
17
24
  * Reads a bundle directory name shaped like `<name>-r{N}` and splits it into
18
25
  * the chain's base name and that bundle's round number. A name with no
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { validateBundle } from "./bundle/validate-bundle.js";
4
- import { publishBundle } from "./bundle/publish.js";
4
+ import { publishBundleAndExportHandoff } from "./bundle/publish-with-handoff.js";
5
5
  import { checkRound, scaffoldNextRound } from "./bundle/round.js";
6
+ import { askForRepoPathOnStdin } from "./handoff/ask-repo-path.js";
6
7
  import { startReviewServer } from "./server/create-server.js";
7
8
  const [, , command, bundlePath, ...rest] = process.argv;
8
9
  function fail(message) {
@@ -27,11 +28,14 @@ if (command === 'open') {
27
28
  }
28
29
  }
29
30
  else if (command === 'publish') {
30
- const result = publishBundle(bundlePath);
31
+ const result = await publishBundleAndExportHandoff(bundlePath, { ask: askForRepoPathOnStdin });
31
32
  if (!result.ok) {
32
33
  fail(`Publish rejected: ${result.blockingReason} — ${result.message}`);
33
34
  }
34
35
  console.log('Published.');
36
+ if (result.handoff?.written) {
37
+ console.log(`Hand-off file written: ${result.handoff.filePath}`);
38
+ }
35
39
  }
36
40
  else if (command === 'round') {
37
41
  const liveHead = flagValue('--live-head');
@@ -0,0 +1,17 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ /**
4
+ * Real interactive `AskForRepoPath` implementation for CLI/server use —
5
+ * prompts on stdin/stdout. Kept separate from `repo-path-cache.ts` so that
6
+ * module stays free of any actual I/O and easy to unit test with a stub `ask`.
7
+ */
8
+ export async function askForRepoPathOnStdin(repoSlug) {
9
+ const rl = createInterface({ input: stdin, output: stdout });
10
+ try {
11
+ const answer = await rl.question(`Local checkout path for ${repoSlug}: `);
12
+ return answer.trim();
13
+ }
14
+ finally {
15
+ rl.close();
16
+ }
17
+ }
@@ -0,0 +1,110 @@
1
+ import { resolve } from 'node:path';
2
+ const GENERAL_GROUP = '(general — no Target)';
3
+ function targetPath(target) {
4
+ return target.path;
5
+ }
6
+ function targetKey(target) {
7
+ switch (target.type) {
8
+ case 'file':
9
+ return `file:${target.path}`;
10
+ case 'binary':
11
+ return `binary:${target.path}`;
12
+ case 'hunk':
13
+ return `hunk:${target.path}:${target.hunkIndex}`;
14
+ case 'line':
15
+ return `line:${target.path}:${target.side}:${target.line}`;
16
+ }
17
+ }
18
+ // Same-type-same-Target only: a file-level comment does not pick up
19
+ // hunk-level Annotations on that file (or vice versa). Cross-granularity
20
+ // matching is a coarser question ("does this Annotation touch material this
21
+ // comment also concerns?") that issue #10 doesn't ask for — it wants the
22
+ // Annotation the comment was raised against, i.e. the same Target.
23
+ function targetsEqual(a, b) {
24
+ return targetKey(a) === targetKey(b);
25
+ }
26
+ function describeTarget(target) {
27
+ switch (target.type) {
28
+ case 'file':
29
+ return `File: ${target.path}`;
30
+ case 'hunk':
31
+ return `Hunk #${target.hunkIndex} in ${target.path}`;
32
+ case 'line':
33
+ return `Line ${target.line} (${target.side}) in ${target.path}`;
34
+ case 'binary':
35
+ return `Binary change: ${target.path}`;
36
+ }
37
+ }
38
+ function openChangeRequests(comments) {
39
+ return comments.filter((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
40
+ }
41
+ function groupByFile(comments) {
42
+ const byFile = new Map();
43
+ for (const comment of comments) {
44
+ const key = comment.target ? targetPath(comment.target) : GENERAL_GROUP;
45
+ const group = byFile.get(key) ?? [];
46
+ group.push(comment);
47
+ byFile.set(key, group);
48
+ }
49
+ return byFile;
50
+ }
51
+ function orderedFileKeys(byFile) {
52
+ return [...byFile.keys()].sort((a, b) => {
53
+ if (a === GENERAL_GROUP)
54
+ return 1;
55
+ if (b === GENERAL_GROUP)
56
+ return -1;
57
+ return a.localeCompare(b);
58
+ });
59
+ }
60
+ function metadataBlock(input) {
61
+ return [
62
+ '<!--',
63
+ 'review-workspace-handoff',
64
+ `bundlePath: ${resolve(input.bundlePath)}`,
65
+ `repository: ${input.comparison.repository ?? ''}`,
66
+ `mrNumber: ${input.comparison.number ?? ''}`,
67
+ `round: ${input.round}`,
68
+ '-->',
69
+ ];
70
+ }
71
+ /**
72
+ * Builds the hand-off Markdown a separate implementer-side skill parses.
73
+ * Content is raw: every open change-request Comment verbatim, grouped by
74
+ * file, with its exact Target and any Annotation raised against that same
75
+ * Target for context. This exact shape is a parsed contract — golden-tested,
76
+ * do not reformat without updating that consumer too.
77
+ */
78
+ export function buildHandoffMarkdown(input) {
79
+ const comments = openChangeRequests(input.comments);
80
+ const lines = [`# Change requests — round ${input.round}`, '', ...metadataBlock(input), ''];
81
+ if (comments.length === 0) {
82
+ lines.push('_No open change-request comments._');
83
+ return lines.join('\n') + '\n';
84
+ }
85
+ const byFile = groupByFile(comments);
86
+ for (const file of orderedFileKeys(byFile)) {
87
+ lines.push(`## ${file}`);
88
+ lines.push('');
89
+ const fileComments = [...(byFile.get(file) ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
90
+ for (const comment of fileComments) {
91
+ lines.push(comment.target ? `### ${describeTarget(comment.target)}` : '### (no Target)');
92
+ if (comment.target?.type === 'line') {
93
+ lines.push(`> ${comment.target.expectedText}`);
94
+ }
95
+ lines.push('');
96
+ lines.push(comment.body);
97
+ lines.push('');
98
+ const relatedAnnotations = comment.target
99
+ ? input.annotations.filter((a) => targetsEqual(a.target, comment.target))
100
+ : [];
101
+ for (const annotation of relatedAnnotations) {
102
+ lines.push(`**Annotation (${annotation.kind ?? 'note'}):** ${annotation.summary}`);
103
+ lines.push('');
104
+ }
105
+ lines.push('---');
106
+ lines.push('');
107
+ }
108
+ }
109
+ return lines.join('\n').replace(/\n{2,}$/, '\n');
110
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const ENTRY = '.review-feedback/';
4
+ const COVERING_PATTERNS = new Set(['.review-feedback/', '.review-feedback', '.review-feedback/**', '.review-feedback/*']);
5
+ function lineCoversHandoffDir(line) {
6
+ return COVERING_PATTERNS.has(line.trim());
7
+ }
8
+ /**
9
+ * Ensures the target repo's `.gitignore` excludes `.review-feedback/` —
10
+ * creating the file if it doesn't exist, appending the entry if the file
11
+ * exists but neither an exact nor a broader matching entry already covers it.
12
+ */
13
+ export function ensureGitignoreExcludesHandoff(repoPath) {
14
+ const gitignorePath = join(repoPath, '.gitignore');
15
+ if (!existsSync(gitignorePath)) {
16
+ writeFileSync(gitignorePath, `${ENTRY}\n`);
17
+ return;
18
+ }
19
+ const content = readFileSync(gitignorePath, 'utf-8');
20
+ if (content.split('\n').some(lineCoversHandoffDir))
21
+ return;
22
+ const withTrailingNewline = content.endsWith('\n') ? content : `${content}\n`;
23
+ writeFileSync(gitignorePath, `${withTrailingNewline}${ENTRY}\n`);
24
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { buildHandoffMarkdown } from "./build-handoff-markdown.js";
4
+ import { ensureGitignoreExcludesHandoff } from "./ensure-gitignore.js";
5
+ import { DEFAULT_REPO_PATH_CACHE_PATH, resolveRepoPath } from "./repo-path-cache.js";
6
+ /**
7
+ * Exports open change-request Comments as a self-describing hand-off
8
+ * Markdown file into the target repo's local working tree, and ensures
9
+ * `.review-feedback/` is gitignored there. A no-op when there are no open
10
+ * change-requests — this is what makes it safe to call after every publish.
11
+ */
12
+ export async function exportHandoff(options) {
13
+ const { bundlePath, comparison, round, comments, annotations, ask, repoPathCachePath } = options;
14
+ const hasOpenChangeRequest = comments.some((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
15
+ if (!hasOpenChangeRequest)
16
+ return { written: false };
17
+ if (!comparison.repository) {
18
+ throw new Error('comparison.repository is required to export a hand-off file');
19
+ }
20
+ const repoPath = await resolveRepoPath(comparison.repository, ask, repoPathCachePath ?? DEFAULT_REPO_PATH_CACHE_PATH);
21
+ const markdown = buildHandoffMarkdown({ bundlePath, comparison, round, comments, annotations });
22
+ const feedbackDir = join(repoPath, '.review-feedback');
23
+ mkdirSync(feedbackDir, { recursive: true });
24
+ const filePath = join(feedbackDir, `${comparison.number}-round${round}.md`);
25
+ writeFileSync(filePath, markdown);
26
+ ensureGitignoreExcludesHandoff(repoPath);
27
+ return { written: true, filePath, repoPath };
28
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const DEFAULT_REPO_PATH_CACHE_PATH = join(homedir(), '.claude', 'review-workspace-repo-paths.json');
5
+ function readCache(cachePath) {
6
+ if (!existsSync(cachePath))
7
+ return {};
8
+ return JSON.parse(readFileSync(cachePath, 'utf-8'));
9
+ }
10
+ function writeCache(cachePath, cache) {
11
+ mkdirSync(dirname(cachePath), { recursive: true });
12
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n');
13
+ }
14
+ /**
15
+ * Resolves a target repo's local checkout path for `repoSlug`. Reuses a
16
+ * cached answer as long as it still exists on disk; asks (via `ask`) and
17
+ * caches the answer otherwise — this covers both "never asked before" and
18
+ * "the cached checkout was moved or deleted" in the same branch.
19
+ */
20
+ export async function resolveRepoPath(repoSlug, ask, cachePath = DEFAULT_REPO_PATH_CACHE_PATH) {
21
+ const cache = readCache(cachePath);
22
+ const cached = cache[repoSlug];
23
+ if (cached && existsSync(cached))
24
+ return cached;
25
+ const answer = await ask(repoSlug);
26
+ writeCache(cachePath, { ...cache, [repoSlug]: answer });
27
+ return answer;
28
+ }
@@ -3,9 +3,10 @@ import { randomBytes } from 'node:crypto';
3
3
  import { createReadStream, existsSync, statSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { validateBundle } from "../bundle/validate-bundle.js";
6
- import { publishBundle } from "../bundle/publish.js";
6
+ import { publishBundleAndExportHandoff } from "../bundle/publish-with-handoff.js";
7
7
  import { watchBundle } from "./watch-bundle.js";
8
8
  import { raiseQuestion, readQuestions, resolveComment, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
9
+ import { askForRepoPathOnStdin } from "../handoff/ask-repo-path.js";
9
10
  import { readReviewState, writeReviewState } from "../review-state/review-state.js";
10
11
  import { render } from "../renderer/render.js";
11
12
  import { buildReport } from "../export/report.js";
@@ -71,6 +72,7 @@ function readJsonBody(req) {
71
72
  }
72
73
  export function startReviewServer(bundlePath, opts = {}) {
73
74
  const writeToken = opts.writeToken ?? generateWriteToken();
75
+ const askForRepoPath = opts.askForRepoPath ?? askForRepoPathOnStdin;
74
76
  // The served document is cached and only refreshed by the watcher below, so a
75
77
  // publish must actually trigger re-validation for /document to ever change —
76
78
  // this is what makes file watching load-bearing rather than a no-op wrapper
@@ -139,8 +141,9 @@ export function startReviewServer(bundlePath, opts = {}) {
139
141
  }
140
142
  }
141
143
  if (req.method === 'POST' && url.pathname === '/publish') {
142
- const result = publishBundle(bundlePath);
143
- respond(result.ok ? 200 : 422, result);
144
+ publishBundleAndExportHandoff(bundlePath, { ask: askForRepoPath, repoPathCachePath: opts.repoPathCachePath })
145
+ .then((result) => respond(result.ok ? 200 : 422, result))
146
+ .catch((err) => respond(500, { error: err instanceof Error ? err.message : 'publish failed' }));
144
147
  return;
145
148
  }
146
149
  if (req.method === 'POST' && url.pathname === '/questions') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "review-workspace",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A local, offline decision surface for reviewing a code Comparison — evidence-linked map of intent, behavior, risk, and observable effects.",
5
5
  "license": "MIT",
6
6
  "author": "Salih Sayer",