devworkspace 0.2.1 → 0.2.2

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -3
  3. package/dist/agents/claude/content.js +3 -2
  4. package/dist/agents/claude/content.js.map +1 -1
  5. package/dist/cli/args.d.ts +16 -0
  6. package/dist/cli/args.js +5 -0
  7. package/dist/cli/args.js.map +1 -1
  8. package/dist/cli/help.js +30 -7
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/render.d.ts +7 -1
  11. package/dist/cli/render.js +64 -11
  12. package/dist/cli/render.js.map +1 -1
  13. package/dist/cli/run.js +47 -8
  14. package/dist/cli/run.js.map +1 -1
  15. package/dist/config/schemas.d.ts +5 -0
  16. package/dist/config/schemas.js +2 -0
  17. package/dist/config/schemas.js.map +1 -1
  18. package/dist/discovery/commands.js +17 -3
  19. package/dist/discovery/commands.js.map +1 -1
  20. package/dist/discovery/detect.d.ts +6 -0
  21. package/dist/discovery/detect.js +12 -6
  22. package/dist/discovery/detect.js.map +1 -1
  23. package/dist/discovery/docs.d.ts +10 -0
  24. package/dist/discovery/docs.js +52 -0
  25. package/dist/discovery/docs.js.map +1 -0
  26. package/dist/discovery/entrypoints.d.ts +20 -0
  27. package/dist/discovery/entrypoints.js +67 -0
  28. package/dist/discovery/entrypoints.js.map +1 -0
  29. package/dist/knowledge/facts.d.ts +59 -2
  30. package/dist/knowledge/facts.js +115 -17
  31. package/dist/knowledge/facts.js.map +1 -1
  32. package/dist/prompts/onboarding.js +6 -3
  33. package/dist/prompts/onboarding.js.map +1 -1
  34. package/dist/services/analysis.js +6 -0
  35. package/dist/services/analysis.js.map +1 -1
  36. package/dist/services/doctor.js +14 -3
  37. package/dist/services/doctor.js.map +1 -1
  38. package/dist/services/knowledge.d.ts +17 -0
  39. package/dist/services/knowledge.js +28 -2
  40. package/dist/services/knowledge.js.map +1 -1
  41. package/dist/services/refresh.js +5 -5
  42. package/dist/services/refresh.js.map +1 -1
  43. package/dist/services/status.d.ts +11 -2
  44. package/dist/services/status.js +11 -9
  45. package/dist/services/status.js.map +1 -1
  46. package/dist/services/tasks.d.ts +2 -0
  47. package/dist/services/tasks.js +20 -12
  48. package/dist/services/tasks.js.map +1 -1
  49. package/dist/services/verify.d.ts +47 -0
  50. package/dist/services/verify.js +172 -0
  51. package/dist/services/verify.js.map +1 -0
  52. package/dist/tasks/routing.js +8 -3
  53. package/dist/tasks/routing.js.map +1 -1
  54. package/dist/tasks/task.d.ts +49 -5
  55. package/dist/tasks/task.js +68 -11
  56. package/dist/tasks/task.js.map +1 -1
  57. package/dist/verification/safety.d.ts +38 -0
  58. package/dist/verification/safety.js +120 -0
  59. package/dist/verification/safety.js.map +1 -0
  60. package/dist/verification/scope.d.ts +19 -0
  61. package/dist/verification/scope.js +119 -0
  62. package/dist/verification/scope.js.map +1 -0
  63. package/docs/knowledge.md +2 -0
  64. package/docs/testing.md +25 -2
  65. package/package.json +1 -1
@@ -0,0 +1,120 @@
1
+ import path from 'node:path';
2
+ import { DevWorkspaceError } from '../errors/index.js';
3
+ import { containsSecret } from '../security/redact.js';
4
+ import { safeReadText } from '../security/files.js';
5
+ import { atomicWriteJson, pathExists, readJsonFile } from '../utils/fs.js';
6
+ /** Dependencies that mean a package talks to a database or queue. */
7
+ const SERVICE_DEPS = {
8
+ pg: 'PostgreSQL',
9
+ postgres: 'PostgreSQL',
10
+ 'drizzle-orm': 'a SQL database',
11
+ prisma: 'a SQL database',
12
+ '@prisma/client': 'a SQL database',
13
+ typeorm: 'a SQL database',
14
+ sequelize: 'a SQL database',
15
+ knex: 'a SQL database',
16
+ mongoose: 'MongoDB',
17
+ mongodb: 'MongoDB',
18
+ redis: 'Redis',
19
+ ioredis: 'Redis',
20
+ bullmq: 'Redis',
21
+ bull: 'Redis',
22
+ };
23
+ /** Words that make a command destructive or schema-changing no matter which check it is filed under. */
24
+ const DESTRUCTIVE = /(?:^|[\s:/_-])(?:drop|truncate|flushall|flushdb|reset|seed|migrate)(?:$|[\s:/_.-])|db:push|--force|rm\s+-rf/i;
25
+ /** Checks whose job is to exercise real services. They need a declared test environment. */
26
+ const SERVICE_CHECKS = new Set(['integration', 'security', 'e2e']);
27
+ /** Read the package manifest next to a command: the script it runs and the services the package depends on. Never reads .env files. */
28
+ export async function readManifestEvidence(root, entry) {
29
+ const dir = entry.cwd && entry.cwd !== '.' ? entry.cwd : '';
30
+ const manifest = dir ? `${dir}/package.json` : 'package.json';
31
+ let scriptBody = null;
32
+ const services = new Set();
33
+ try {
34
+ const text = await safeReadText(root, manifest, 1024 * 1024);
35
+ const json = text ? JSON.parse(text) : null;
36
+ const script = /#scripts\.(.+)$/.exec(entry.source ?? '')?.[1];
37
+ if (json && script && typeof json.scripts?.[script] === 'string')
38
+ scriptBody = json.scripts[script];
39
+ for (const dep of Object.keys({ ...json?.dependencies, ...json?.devDependencies }))
40
+ if (SERVICE_DEPS[dep])
41
+ services.add(SERVICE_DEPS[dep]);
42
+ }
43
+ catch {
44
+ /* unreadable manifest: no evidence */
45
+ }
46
+ let hasCredentialFile = false;
47
+ for (const name of ['.env', '.env.local', '.env.test', '.env.development']) {
48
+ if (await pathExists(path.join(root, ...(dir ? [dir] : []), name)))
49
+ hasCredentialFile = true;
50
+ }
51
+ // "a SQL database" is only the fallback label for an ORM; drop it once a specific database is known.
52
+ if (services.size > 1)
53
+ services.delete('a SQL database');
54
+ return { scriptBody, services: [...services].sort(), hasCredentialFile };
55
+ }
56
+ /**
57
+ * Decide whether DevWorkspace may run a contract command by itself. Pure; no file is read here.
58
+ * - blocked: modifies project files, changes a schema/data set, or is destructive. Never run automatically.
59
+ * - needs-test-environment: exercises real services or may (a catch-all `test` in a package that uses a database).
60
+ * Runs only after the user declared a test environment for that package and check.
61
+ * - safe: typecheck, lint, build, unit tests and read-only format checks.
62
+ * A database is never judged safe from its name; DevWorkspace does not read connection settings at all.
63
+ */
64
+ export function classifyCommand(entry, evidence) {
65
+ const reasons = [];
66
+ const services = evidence.services;
67
+ const text = `${entry.command ?? ''} ${evidence.scriptBody ?? ''}`;
68
+ if (entry.mutates)
69
+ return { safety: 'blocked', reasons: ['it modifies project files; DevWorkspace never runs formatters or fixers'], services };
70
+ if (entry.id === 'migrations')
71
+ return { safety: 'blocked', reasons: ['it changes a database schema; DevWorkspace never runs migrations'], services };
72
+ if (DESTRUCTIVE.test(text))
73
+ return { safety: 'blocked', reasons: ['its definition mentions a destructive or schema-changing operation (drop/reset/seed/migrate/flush)'], services };
74
+ if (SERVICE_CHECKS.has(entry.id)) {
75
+ reasons.push(`${entry.id} tests exercise real services${services.length ? ` (the package uses ${services.join(', ')})` : ''}`);
76
+ }
77
+ else if (entry.id === 'unit' && services.length > 0 && /(?:^|\s)(?:run\s+)?test(?:\s|$)/.test(entry.command ?? '') && !/unit/.test(entry.command ?? '')) {
78
+ reasons.push(`the catch-all test script of a package that uses ${services.join(', ')} may include integration tests`);
79
+ }
80
+ if (reasons.length === 0)
81
+ return { safety: 'safe', reasons: [], services };
82
+ if (evidence.hasCredentialFile)
83
+ reasons.push('a credential file exists in the package (never read), so the database target cannot be verified');
84
+ else
85
+ reasons.push('the database target cannot be verified');
86
+ return { safety: 'needs-test-environment', reasons, services };
87
+ }
88
+ const file = (ws) => path.join(ws.localDir, 'test-environment.json');
89
+ export async function loadTestEnv(ws) {
90
+ if (!(await pathExists(file(ws))))
91
+ return [];
92
+ const raw = (await readJsonFile(file(ws)));
93
+ return Array.isArray(raw.declarations) ? raw.declarations : [];
94
+ }
95
+ export function assertSafeTarget(target) {
96
+ const t = target.trim();
97
+ if (t.length < 5 || t.length > 200)
98
+ throw new DevWorkspaceError('USAGE', '--target must be a short description (5-200 characters) of the test environment.');
99
+ if (containsSecret(t) || /[a-z][a-z0-9+.-]*:\/\/\S+/i.test(t) || /(?:password|passwd|pwd|token|secret)\s*[=:]/i.test(t)) {
100
+ throw new DevWorkspaceError('SECURITY', 'The target description looks like a connection string or credential; refusing to store it.', {
101
+ hint: 'Describe the environment in words (for example "disposable docker compose stack"), not how to connect to it.',
102
+ });
103
+ }
104
+ }
105
+ export async function saveDeclaration(ws, d) {
106
+ const all = (await loadTestEnv(ws)).filter((x) => x.package !== d.package);
107
+ await atomicWriteJson(file(ws), { schemaVersion: 1, declarations: [...all, d] });
108
+ }
109
+ export async function removeDeclaration(ws, pkg) {
110
+ const all = await loadTestEnv(ws);
111
+ const rest = all.filter((x) => x.package !== pkg);
112
+ if (rest.length === all.length)
113
+ return false;
114
+ await atomicWriteJson(file(ws), { schemaVersion: 1, declarations: rest });
115
+ return true;
116
+ }
117
+ export function isDeclared(decls, pkg, check) {
118
+ return decls.find((d) => d.package === pkg && d.checks.includes(check));
119
+ }
120
+ //# sourceMappingURL=safety.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safety.js","sourceRoot":"","sources":["../../src/verification/safety.ts"],"names":[],"mappings":"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","sourcesContent":["import path from 'node:path';\nimport type { CommandEntry } from '../config/schemas.js';\nimport { DevWorkspaceError } from '../errors/index.js';\nimport { containsSecret } from '../security/redact.js';\nimport { safeReadText } from '../security/files.js';\nimport { atomicWriteJson, pathExists, readJsonFile } from '../utils/fs.js';\nimport type { Ws } from '../core/workspace.js';\n\n/** Dependencies that mean a package talks to a database or queue. */\nconst SERVICE_DEPS: Record<string, string> = {\n pg: 'PostgreSQL',\n postgres: 'PostgreSQL',\n 'drizzle-orm': 'a SQL database',\n prisma: 'a SQL database',\n '@prisma/client': 'a SQL database',\n typeorm: 'a SQL database',\n sequelize: 'a SQL database',\n knex: 'a SQL database',\n mongoose: 'MongoDB',\n mongodb: 'MongoDB',\n redis: 'Redis',\n ioredis: 'Redis',\n bullmq: 'Redis',\n bull: 'Redis',\n};\n\n/** Words that make a command destructive or schema-changing no matter which check it is filed under. */\nconst DESTRUCTIVE = /(?:^|[\\s:/_-])(?:drop|truncate|flushall|flushdb|reset|seed|migrate)(?:$|[\\s:/_.-])|db:push|--force|rm\\s+-rf/i;\n/** Checks whose job is to exercise real services. They need a declared test environment. */\nconst SERVICE_CHECKS = new Set(['integration', 'security', 'e2e']);\n\nexport type Safety = 'safe' | 'needs-test-environment' | 'blocked';\n\nexport interface CommandSafety {\n safety: Safety;\n reasons: string[];\n /** Services the package is known to use (from its manifest), for the explanation only. */\n services: string[];\n}\n\nexport interface ManifestEvidence {\n scriptBody: string | null;\n services: string[];\n hasCredentialFile: boolean;\n}\n\n/** Read the package manifest next to a command: the script it runs and the services the package depends on. Never reads .env files. */\nexport async function readManifestEvidence(root: string, entry: CommandEntry): Promise<ManifestEvidence> {\n const dir = entry.cwd && entry.cwd !== '.' ? entry.cwd : '';\n const manifest = dir ? `${dir}/package.json` : 'package.json';\n let scriptBody: string | null = null;\n const services = new Set<string>();\n try {\n const text = await safeReadText(root, manifest, 1024 * 1024);\n const json = text ? (JSON.parse(text) as { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }) : null;\n const script = /#scripts\\.(.+)$/.exec(entry.source ?? '')?.[1];\n if (json && script && typeof json.scripts?.[script] === 'string') scriptBody = json.scripts[script]!;\n for (const dep of Object.keys({ ...json?.dependencies, ...json?.devDependencies })) if (SERVICE_DEPS[dep]) services.add(SERVICE_DEPS[dep]!);\n } catch {\n /* unreadable manifest: no evidence */\n }\n let hasCredentialFile = false;\n for (const name of ['.env', '.env.local', '.env.test', '.env.development']) {\n if (await pathExists(path.join(root, ...(dir ? [dir] : []), name))) hasCredentialFile = true;\n }\n // \"a SQL database\" is only the fallback label for an ORM; drop it once a specific database is known.\n if (services.size > 1) services.delete('a SQL database');\n return { scriptBody, services: [...services].sort(), hasCredentialFile };\n}\n\n/**\n * Decide whether DevWorkspace may run a contract command by itself. Pure; no file is read here.\n * - blocked: modifies project files, changes a schema/data set, or is destructive. Never run automatically.\n * - needs-test-environment: exercises real services or may (a catch-all `test` in a package that uses a database).\n * Runs only after the user declared a test environment for that package and check.\n * - safe: typecheck, lint, build, unit tests and read-only format checks.\n * A database is never judged safe from its name; DevWorkspace does not read connection settings at all.\n */\nexport function classifyCommand(entry: CommandEntry, evidence: ManifestEvidence): CommandSafety {\n const reasons: string[] = [];\n const services = evidence.services;\n const text = `${entry.command ?? ''} ${evidence.scriptBody ?? ''}`;\n if (entry.mutates) return { safety: 'blocked', reasons: ['it modifies project files; DevWorkspace never runs formatters or fixers'], services };\n if (entry.id === 'migrations') return { safety: 'blocked', reasons: ['it changes a database schema; DevWorkspace never runs migrations'], services };\n if (DESTRUCTIVE.test(text)) return { safety: 'blocked', reasons: ['its definition mentions a destructive or schema-changing operation (drop/reset/seed/migrate/flush)'], services };\n if (SERVICE_CHECKS.has(entry.id)) {\n reasons.push(`${entry.id} tests exercise real services${services.length ? ` (the package uses ${services.join(', ')})` : ''}`);\n } else if (entry.id === 'unit' && services.length > 0 && /(?:^|\\s)(?:run\\s+)?test(?:\\s|$)/.test(entry.command ?? '') && !/unit/.test(entry.command ?? '')) {\n reasons.push(`the catch-all test script of a package that uses ${services.join(', ')} may include integration tests`);\n }\n if (reasons.length === 0) return { safety: 'safe', reasons: [], services };\n if (evidence.hasCredentialFile) reasons.push('a credential file exists in the package (never read), so the database target cannot be verified');\n else reasons.push('the database target cannot be verified');\n return { safety: 'needs-test-environment', reasons, services };\n}\n\n// ---------- explicit test-environment declarations (machine-local, never committed) ----------\n\nexport interface TestEnvDeclaration {\n /** Package directory relative to the project root; \".\" for the root. */\n package: string;\n checks: string[];\n /** Human description of the target, e.g. \"docker compose test stack on this laptop\". Never a connection string. */\n target: string;\n declaredAt: string;\n}\n\ninterface TestEnvFile {\n schemaVersion: 1;\n declarations: TestEnvDeclaration[];\n}\n\nconst file = (ws: Ws): string => path.join(ws.localDir, 'test-environment.json');\n\nexport async function loadTestEnv(ws: Ws): Promise<TestEnvDeclaration[]> {\n if (!(await pathExists(file(ws)))) return [];\n const raw = (await readJsonFile(file(ws))) as Partial<TestEnvFile>;\n return Array.isArray(raw.declarations) ? raw.declarations : [];\n}\n\nexport function assertSafeTarget(target: string): void {\n const t = target.trim();\n if (t.length < 5 || t.length > 200) throw new DevWorkspaceError('USAGE', '--target must be a short description (5-200 characters) of the test environment.');\n if (containsSecret(t) || /[a-z][a-z0-9+.-]*:\\/\\/\\S+/i.test(t) || /(?:password|passwd|pwd|token|secret)\\s*[=:]/i.test(t)) {\n throw new DevWorkspaceError('SECURITY', 'The target description looks like a connection string or credential; refusing to store it.', {\n hint: 'Describe the environment in words (for example \"disposable docker compose stack\"), not how to connect to it.',\n });\n }\n}\n\nexport async function saveDeclaration(ws: Ws, d: TestEnvDeclaration): Promise<void> {\n const all = (await loadTestEnv(ws)).filter((x) => x.package !== d.package);\n await atomicWriteJson(file(ws), { schemaVersion: 1, declarations: [...all, d] } satisfies TestEnvFile);\n}\n\nexport async function removeDeclaration(ws: Ws, pkg: string): Promise<boolean> {\n const all = await loadTestEnv(ws);\n const rest = all.filter((x) => x.package !== pkg);\n if (rest.length === all.length) return false;\n await atomicWriteJson(file(ws), { schemaVersion: 1, declarations: rest } satisfies TestEnvFile);\n return true;\n}\n\nexport function isDeclared(decls: TestEnvDeclaration[], pkg: string, check: string): TestEnvDeclaration | undefined {\n return decls.find((d) => d.package === pkg && d.checks.includes(check));\n}\n"]}
@@ -0,0 +1,19 @@
1
+ import type { CommandEntry } from '../config/schemas.js';
2
+ import type { TaskScope } from '../tasks/task.js';
3
+ export type ChangeMap = Record<string, string>;
4
+ /** Files that differ from HEAD right now (modified, staged or untracked), project-relative, with a content hash. */
5
+ export declare function dirtyFiles(root: string): Promise<{
6
+ head: string | null;
7
+ files: ChangeMap;
8
+ } | null>;
9
+ /** Snapshot taken when a task is created: what is already modified is not the task's doing. */
10
+ export declare function captureScope(root: string, now: string): Promise<TaskScope>;
11
+ /** Files that changed since the task started, with their current hashes. Null when that cannot be determined. */
12
+ export declare function changedSince(root: string, scope: TaskScope): Promise<ChangeMap | null>;
13
+ /** Directories (relative to the project root) that have their own checks in the contract, longest first. */
14
+ export declare function packageDirs(contract: CommandEntry[]): string[];
15
+ /**
16
+ * Fingerprint of the changes inside each package. A change under a package belongs to it; a change outside every package
17
+ * (root config, shared tooling) belongs to all of them. "." covers everything. Returns null when nothing is known.
18
+ */
19
+ export declare function scopeByPackage(changes: ChangeMap | null, contract: CommandEntry[]): Record<string, string> | null;
@@ -0,0 +1,119 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { gitAvailable, parsePorcelainZ } from '../git/git.js';
4
+ import { isSensitiveFile } from '../security/files.js';
5
+ import { sha256File, sha256Text } from '../utils/fs.js';
6
+ import { run } from '../utils/proc.js';
7
+ const MAX_TRACKED_FILES = 2000;
8
+ const MAX_HASH_BYTES = 8 * 1024 * 1024;
9
+ /** Documentation and tool-managed folders never decide which package's checks apply. */
10
+ const NOT_CODE = /^(?:\.devworkspace|\.claude|graphify-out)\/|\.(?:md|mdx|rst|txt)$/i;
11
+ async function git(root, args) {
12
+ const r = await run('git', args, { cwd: root, timeoutMs: 30_000 });
13
+ return r.ok ? r.stdout : null;
14
+ }
15
+ async function fileFingerprint(root, rel) {
16
+ const full = path.join(root, ...rel.split('/'));
17
+ try {
18
+ const st = await fs.lstat(full);
19
+ if (!st.isFile())
20
+ return 'not-a-file';
21
+ if (st.size > MAX_HASH_BYTES)
22
+ return `large:${st.size}:${Math.floor(st.mtimeMs)}`;
23
+ return await sha256File(full);
24
+ }
25
+ catch {
26
+ return 'deleted';
27
+ }
28
+ }
29
+ /** Files that differ from HEAD right now (modified, staged or untracked), project-relative, with a content hash. */
30
+ export async function dirtyFiles(root) {
31
+ if (!(await gitAvailable()))
32
+ return null;
33
+ if ((await git(root, ['rev-parse', '--is-inside-work-tree']))?.trim() !== 'true')
34
+ return null;
35
+ const prefix = ((await git(root, ['rev-parse', '--show-prefix'])) ?? '').trim();
36
+ const head = ((await git(root, ['rev-parse', 'HEAD'])) ?? '').trim() || null;
37
+ const status = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
38
+ if (status === null)
39
+ return null;
40
+ const files = {};
41
+ for (const e of parsePorcelainZ(status)) {
42
+ if (e.xy === '!!' || !e.path.startsWith(prefix))
43
+ continue;
44
+ const rel = e.path.slice(prefix.length);
45
+ if (NOT_CODE.test(rel) || isSensitiveFile(rel))
46
+ continue;
47
+ if (Object.keys(files).length >= MAX_TRACKED_FILES)
48
+ break;
49
+ files[rel] = await fileFingerprint(root, rel);
50
+ }
51
+ return { head, files };
52
+ }
53
+ /** Snapshot taken when a task is created: what is already modified is not the task's doing. */
54
+ export async function captureScope(root, now) {
55
+ const d = await dirtyFiles(root);
56
+ return { capturedAt: now, head: d?.head ?? null, baseline: d?.files ?? {} };
57
+ }
58
+ /** Project-relative files committed since `head` (so committing during a task does not hide its changes). */
59
+ async function committedSince(root, head) {
60
+ const prefix = ((await git(root, ['rev-parse', '--show-prefix'])) ?? '').trim();
61
+ const out = await git(root, ['diff', '--name-only', '-z', head, 'HEAD']);
62
+ if (out === null)
63
+ return null;
64
+ return out.split('\0').filter((f) => f && f.startsWith(prefix)).map((f) => f.slice(prefix.length));
65
+ }
66
+ /** Files that changed since the task started, with their current hashes. Null when that cannot be determined. */
67
+ export async function changedSince(root, scope) {
68
+ const now = await dirtyFiles(root);
69
+ if (!now)
70
+ return null;
71
+ const changed = {};
72
+ for (const [rel, hash] of Object.entries(now.files))
73
+ if (scope.baseline[rel] !== hash)
74
+ changed[rel] = hash;
75
+ // A file that was modified at the start and has since been cleaned up (committed or reverted) also changed.
76
+ for (const rel of Object.keys(scope.baseline))
77
+ if (!(rel in now.files))
78
+ changed[rel] = await fileFingerprint(root, rel);
79
+ if (scope.head && now.head && scope.head !== now.head) {
80
+ const committed = await committedSince(root, scope.head);
81
+ if (committed === null)
82
+ return null;
83
+ for (const rel of committed)
84
+ if (!NOT_CODE.test(rel) && !isSensitiveFile(rel) && !(rel in changed))
85
+ changed[rel] = await fileFingerprint(root, rel);
86
+ }
87
+ return changed;
88
+ }
89
+ /** Directories (relative to the project root) that have their own checks in the contract, longest first. */
90
+ export function packageDirs(contract) {
91
+ const dirs = new Set();
92
+ for (const c of contract)
93
+ if (c.available && c.cwd && c.cwd !== '.')
94
+ dirs.add(c.cwd);
95
+ return [...dirs].sort((a, b) => b.length - a.length || a.localeCompare(b));
96
+ }
97
+ /**
98
+ * Fingerprint of the changes inside each package. A change under a package belongs to it; a change outside every package
99
+ * (root config, shared tooling) belongs to all of them. "." covers everything. Returns null when nothing is known.
100
+ */
101
+ export function scopeByPackage(changes, contract) {
102
+ if (!changes)
103
+ return null;
104
+ const files = Object.keys(changes).filter((f) => !NOT_CODE.test(f)).sort();
105
+ if (files.length === 0)
106
+ return null;
107
+ const dirs = packageDirs(contract);
108
+ const owner = (f) => dirs.find((d) => f === d || f.startsWith(`${d}/`)) ?? null;
109
+ const shared = files.filter((f) => owner(f) === null);
110
+ const line = (f) => `${f}:${changes[f]}`;
111
+ const out = { '.': sha256Text(files.map(line).join('\n')) };
112
+ for (const d of dirs) {
113
+ const mine = files.filter((f) => owner(f) === d);
114
+ if (mine.length > 0 || shared.length > 0)
115
+ out[d] = sha256Text([...mine, ...shared].sort().map(line).join('\n'));
116
+ }
117
+ return out;
118
+ }
119
+ //# sourceMappingURL=scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope.js","sourceRoot":"","sources":["../../src/verification/scope.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAEvC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAC/B,MAAM,cAAc,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AACvC,wFAAwF;AACxF,MAAM,QAAQ,GAAG,oEAAoE,CAAC;AAItF,KAAK,UAAU,GAAG,CAAC,IAAY,EAAE,IAAc;IAC7C,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC;IACnE,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AAChC,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,GAAW;IACtD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;IAChD,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;YAAE,OAAO,YAAY,CAAC;QACtC,IAAI,EAAE,CAAC,IAAI,GAAG,cAAc;YAAE,OAAO,SAAS,EAAE,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC;QAClF,OAAO,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;IAChC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,oHAAoH;AACpH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY;IAC3C,IAAI,CAAC,CAAC,MAAM,YAAY,EAAE,CAAC;QAAE,OAAO,IAAI,CAAC;IACzC,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,uBAAuB,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IAC9F,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,eAAe,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAChF,MAAM,IAAI,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,IAAI,CAAC;IAC7E,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,uBAAuB,CAAC,CAAC,CAAC;IAC5F,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACjC,MAAM,KAAK,GAAc,EAAE,CAAC;IAC5B,KAAK,MAAM,CAAC,IAAI,eAAe,CAAC,MAAM,CAAC,EAAE,CAAC;QACxC,IAAI,CAAC,CAAC,EAAE,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;YAAE,SAAS;QAC1D,MAAM,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACxC,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,eAAe,CAAC,GAAG,CAAC;YAAE,SAAS;QACzD,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,IAAI,iBAAiB;YAAE,MAAM;QAC1D,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AACzB,CAAC;AAED,+FAA+F;AAC/F,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY,EAAE,GAAW;IAC1D,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;IACjC,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,IAAI,IAAI,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,IAAI,EAAE,EAAE,CAAC;AAC9E,CAAC;AAED,6GAA6G;AAC7G,KAAK,UAAU,cAAc,CAAC,IAAY,EAAE,IAAY;IACtD,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,eAAe,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAChF,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IACzE,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9B,OAAO,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;AACrG,CAAC;AAED,iHAAiH;AACjH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY,EAAE,KAAgB;IAC/D,MAAM,GAAG,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;IACnC,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,MAAM,OAAO,GAAc,EAAE,CAAC;IAC9B,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;QAAE,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,IAAI;YAAE,OAAO,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IAC3G,4GAA4G;IAC5G,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC;QAAE,IAAI,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACxH,IAAI,KAAK,CAAC,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,EAAE,CAAC;QACtD,MAAM,SAAS,GAAG,MAAM,cAAc,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,SAAS,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QACpC,KAAK,MAAM,GAAG,IAAI,SAAS;YAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,OAAO,CAAC;gBAAE,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACtJ,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,4GAA4G;AAC5G,MAAM,UAAU,WAAW,CAAC,QAAwB;IAClD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,KAAK,MAAM,CAAC,IAAI,QAAQ;QAAE,IAAI,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,KAAK,GAAG;YAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IACrF,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,OAAyB,EAAE,QAAwB;IAChF,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC3E,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACpC,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;IACnC,MAAM,KAAK,GAAG,CAAC,CAAS,EAAiB,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC;IACvG,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;IACzD,MAAM,GAAG,GAA2B,EAAE,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;IACpF,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;QACjD,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,GAAG,IAAI,EAAE,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAClH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC","sourcesContent":["import { promises as fs } from 'node:fs';\nimport path from 'node:path';\nimport type { CommandEntry } from '../config/schemas.js';\nimport { gitAvailable, parsePorcelainZ } from '../git/git.js';\nimport { isSensitiveFile } from '../security/files.js';\nimport type { TaskScope } from '../tasks/task.js';\nimport { sha256File, sha256Text } from '../utils/fs.js';\nimport { run } from '../utils/proc.js';\n\nconst MAX_TRACKED_FILES = 2000;\nconst MAX_HASH_BYTES = 8 * 1024 * 1024;\n/** Documentation and tool-managed folders never decide which package's checks apply. */\nconst NOT_CODE = /^(?:\\.devworkspace|\\.claude|graphify-out)\\/|\\.(?:md|mdx|rst|txt)$/i;\n\nexport type ChangeMap = Record<string, string>;\n\nasync function git(root: string, args: string[]): Promise<string | null> {\n const r = await run('git', args, { cwd: root, timeoutMs: 30_000 });\n return r.ok ? r.stdout : null;\n}\n\nasync function fileFingerprint(root: string, rel: string): Promise<string> {\n const full = path.join(root, ...rel.split('/'));\n try {\n const st = await fs.lstat(full);\n if (!st.isFile()) return 'not-a-file';\n if (st.size > MAX_HASH_BYTES) return `large:${st.size}:${Math.floor(st.mtimeMs)}`;\n return await sha256File(full);\n } catch {\n return 'deleted';\n }\n}\n\n/** Files that differ from HEAD right now (modified, staged or untracked), project-relative, with a content hash. */\nexport async function dirtyFiles(root: string): Promise<{ head: string | null; files: ChangeMap } | null> {\n if (!(await gitAvailable())) return null;\n if ((await git(root, ['rev-parse', '--is-inside-work-tree']))?.trim() !== 'true') return null;\n const prefix = ((await git(root, ['rev-parse', '--show-prefix'])) ?? '').trim();\n const head = ((await git(root, ['rev-parse', 'HEAD'])) ?? '').trim() || null;\n const status = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);\n if (status === null) return null;\n const files: ChangeMap = {};\n for (const e of parsePorcelainZ(status)) {\n if (e.xy === '!!' || !e.path.startsWith(prefix)) continue;\n const rel = e.path.slice(prefix.length);\n if (NOT_CODE.test(rel) || isSensitiveFile(rel)) continue;\n if (Object.keys(files).length >= MAX_TRACKED_FILES) break;\n files[rel] = await fileFingerprint(root, rel);\n }\n return { head, files };\n}\n\n/** Snapshot taken when a task is created: what is already modified is not the task's doing. */\nexport async function captureScope(root: string, now: string): Promise<TaskScope> {\n const d = await dirtyFiles(root);\n return { capturedAt: now, head: d?.head ?? null, baseline: d?.files ?? {} };\n}\n\n/** Project-relative files committed since `head` (so committing during a task does not hide its changes). */\nasync function committedSince(root: string, head: string): Promise<string[] | null> {\n const prefix = ((await git(root, ['rev-parse', '--show-prefix'])) ?? '').trim();\n const out = await git(root, ['diff', '--name-only', '-z', head, 'HEAD']);\n if (out === null) return null;\n return out.split('\\0').filter((f) => f && f.startsWith(prefix)).map((f) => f.slice(prefix.length));\n}\n\n/** Files that changed since the task started, with their current hashes. Null when that cannot be determined. */\nexport async function changedSince(root: string, scope: TaskScope): Promise<ChangeMap | null> {\n const now = await dirtyFiles(root);\n if (!now) return null;\n const changed: ChangeMap = {};\n for (const [rel, hash] of Object.entries(now.files)) if (scope.baseline[rel] !== hash) changed[rel] = hash;\n // A file that was modified at the start and has since been cleaned up (committed or reverted) also changed.\n for (const rel of Object.keys(scope.baseline)) if (!(rel in now.files)) changed[rel] = await fileFingerprint(root, rel);\n if (scope.head && now.head && scope.head !== now.head) {\n const committed = await committedSince(root, scope.head);\n if (committed === null) return null;\n for (const rel of committed) if (!NOT_CODE.test(rel) && !isSensitiveFile(rel) && !(rel in changed)) changed[rel] = await fileFingerprint(root, rel);\n }\n return changed;\n}\n\n/** Directories (relative to the project root) that have their own checks in the contract, longest first. */\nexport function packageDirs(contract: CommandEntry[]): string[] {\n const dirs = new Set<string>();\n for (const c of contract) if (c.available && c.cwd && c.cwd !== '.') dirs.add(c.cwd);\n return [...dirs].sort((a, b) => b.length - a.length || a.localeCompare(b));\n}\n\n/**\n * Fingerprint of the changes inside each package. A change under a package belongs to it; a change outside every package\n * (root config, shared tooling) belongs to all of them. \".\" covers everything. Returns null when nothing is known.\n */\nexport function scopeByPackage(changes: ChangeMap | null, contract: CommandEntry[]): Record<string, string> | null {\n if (!changes) return null;\n const files = Object.keys(changes).filter((f) => !NOT_CODE.test(f)).sort();\n if (files.length === 0) return null;\n const dirs = packageDirs(contract);\n const owner = (f: string): string | null => dirs.find((d) => f === d || f.startsWith(`${d}/`)) ?? null;\n const shared = files.filter((f) => owner(f) === null);\n const line = (f: string): string => `${f}:${changes[f]}`;\n const out: Record<string, string> = { '.': sha256Text(files.map(line).join('\\n')) };\n for (const d of dirs) {\n const mine = files.filter((f) => owner(f) === d);\n if (mine.length > 0 || shared.length > 0) out[d] = sha256Text([...mine, ...shared].sort().map(line).join('\\n'));\n }\n return out;\n}\n"]}
package/docs/knowledge.md CHANGED
@@ -44,6 +44,8 @@ Uncertainty is first-class: record `INFERRED`/`LOW` or `UNKNOWN` rather than gue
44
44
 
45
45
  Each fact lists the repository files that evidence it and stores their SHA-256 at the time of recording. `devworkspace refresh` re-hashes those sources: a changed or removed source marks the fact `STALE` with a reason. Only stale facts are handed to the agent in the refresh prompt (incremental, not a full re-analysis). The agent re-checks and runs `devworkspace fact verify <key>` (re-hash, mark verified), corrects the fact, or removes it. Facts without sources cannot be proven stale, so cite files.
46
46
 
47
+ Generated files are not evidence by themselves: re-running `analyze` rewrites `.devworkspace/analysis/*`, which must not invalidate unrelated facts. A fact may cite a section of the discovery report as `.devworkspace/analysis/discovery.json#ci` (fingerprint of that section only), and a bare `discovery.json` citation is narrowed to the section named by the fact's key (`ci.none` depends on `ci`). Any other generated file is ignored. `status`, `refresh --dry-run` and `doctor` all use the same read-only check, so they agree, and each stale fact says which evidence changed. A fact recorded before a dependency was tracked adopts its current fingerprint instead of going stale.
48
+
47
49
  Content hashing is deliberately conservative: *any* byte change to a cited file triggers re-validation. It over-reports rather than silently trusting old context.
48
50
 
49
51
  Deterministic (`detector`) facts are simply re-derived on every `analyze`/`refresh`; ones that no longer apply are removed (they are derived data and come back if the evidence returns), so they never produce a warning nobody can clear.
package/docs/testing.md CHANGED
@@ -21,9 +21,27 @@ Re-running `analyze` keeps any command with `origin: "user"` (confirmed by a per
21
21
 
22
22
  Sources: `package.json` scripts (matched by common names), plus conventions for Python (pytest/ruff/mypy/black/alembic), Go, Rust, Maven and Gradle. npm's default `"no test specified"` placeholder is ignored.
23
23
 
24
+ Formatters are listed too. A `format` script that rewrites files is recorded with `"mutates": true` and `required: "no"`; a `format:check` (or a `format` that only checks) is read-only. DevWorkspace never runs a command marked `mutates`.
25
+
24
26
  ### Commands are untrusted input
25
27
 
26
- Everything marked `origin: "repository-script"` comes from files in the repository. DevWorkspace **never runs these commands itself**; the agent does, and the generated instructions tell it to read what a script does before running unfamiliar ones, especially where scripts might deploy, migrate or write over the network.
28
+ Everything marked `origin: "repository-script"` comes from files in the repository. `analyze`, `status`, `doctor` and `refresh` never run any of them. Only `devworkspace task run` executes a contract command, as an argument vector (never through a shell), and only after the safety rules below. The generated instructions still tell the agent to read what an unfamiliar script does first.
29
+
30
+ ### What `task run` will and will not run
31
+
32
+ | Command | Runs? |
33
+ | --- | --- |
34
+ | typecheck, lint, build, unit tests, read-only format checks | yes |
35
+ | migrations; anything whose definition mentions drop, truncate, reset, seed, migrate, flush; any command marked `mutates` | **never** |
36
+ | integration, security and e2e checks; a catch-all `test` script in a package that depends on a database or Redis | only after you declare a test environment for that package and check |
37
+
38
+ DevWorkspace does not read `.env` files or connection settings, so it **cannot verify which database a test would use**, and it does not guess from names (a database called `test` is not treated as safe). The only way to allow such a check is an explicit, local declaration by a person:
39
+
40
+ ```bash
41
+ devworkspace testenv declare --package backend --check integration --target "disposable docker compose stack" --yes
42
+ ```
43
+
44
+ This records your assertion in `.devworkspace/local/test-environment.json` (git-ignored). Targets that look like connection strings or credentials are refused. A refused check is recorded as `COULD_NOT_RUN`, never as a pass.
27
45
 
28
46
  ## Task-specific verification
29
47
 
@@ -32,9 +50,14 @@ Questions need none; docs need only documentation checks; bugs, features and ref
32
50
  ```bash
33
51
  devworkspace task tests <id> --phase baseline --passed 214 --failed 3 # before changing code
34
52
  devworkspace task tests <id> --phase after --passed 218 --failed 3
35
- devworkspace task verify <id> unit pass
53
+ devworkspace task run <id> typecheck # executes the contract command for every package the task changed
54
+ devworkspace task verify <id> review pass # a report; recorded as a claim, not as an executed result
36
55
  ```
37
56
 
57
+ ### Package-aware completion
58
+
59
+ A task records, when it is created, which files were already modified. When you complete it, DevWorkspace works out which files changed since, assigns them to packages (the `cwd` directories of the contract; a change outside every package counts for all of them) and requires **executed** evidence for each applicable check **in each changed package**, taken after the last change. A backend-only build is not evidence for frontend changes. A pass that was only reported with `task verify` is a claim and does not satisfy the gate unless you add `--reason user-override`. Tasks created by older versions keep the original rules.
60
+
38
61
  Pre-existing failures are recorded, not blamed on new work. Regressions block completion.
39
62
 
40
63
  ## Failure reporting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devworkspace",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Project-local AI engineering workspace: deterministic repository discovery, portable project knowledge, tasks, decisions and verification gates for AI coding agents.",
5
5
  "type": "module",
6
6
  "bin": {