devflow-kit 3.6.0 → 3.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/CHANGELOG.md +438 -1
- package/README.md +15 -11
- package/dist/agents/code.md +9 -15
- package/dist/agents/design.md +1 -1
- package/dist/agents/fix.md +11 -13
- package/dist/agents/git.md +1 -1
- package/dist/agents/harden.md +1 -1
- package/dist/agents/refactor.md +7 -10
- package/dist/agents/review.md +6 -4
- package/dist/agents/skim.md +2 -2
- package/dist/agents/triage.md +2 -2
- package/dist/cli/commands/capture.js +1 -1
- package/dist/cli/commands/curation-move-io.js +102 -0
- package/dist/cli/commands/curation-move-step.js +374 -0
- package/dist/cli/commands/{learning.js → curation.js} +302 -99
- package/dist/cli/commands/gate-level-prompts.js +426 -0
- package/dist/cli/commands/gates.js +299 -0
- package/dist/cli/commands/init-seed.js +16 -5
- package/dist/cli/commands/init.js +263 -54
- package/dist/cli/commands/memory.js +20 -2
- package/dist/cli/commands/prompt-io.js +37 -0
- package/dist/cli/commands/uninstall.js +81 -9
- package/dist/cli.js +4 -2
- package/dist/commands/bug-analysis.md +25 -36
- package/dist/commands/code-review.md +77 -73
- package/dist/commands/debug.md +5 -5
- package/dist/commands/dynamic-build.md +76 -83
- package/dist/commands/dynamic-plan.md +5 -5
- package/dist/commands/dynamic-profile.md +5 -5
- package/dist/commands/dynamic-tickets.md +5 -5
- package/dist/commands/explore.md +6 -6
- package/dist/commands/implement.md +68 -102
- package/dist/commands/plan.md +19 -29
- package/dist/commands/release.md +5 -5
- package/dist/commands/research.md +6 -6
- package/dist/commands/resolve.md +29 -31
- package/dist/commands/self-review.md +5 -13
- package/dist/core/assets.js +20 -20
- package/dist/core/curate-tuning-config.js +5 -6
- package/dist/core/curation-move-notice.js +84 -0
- package/dist/core/curation-move-skip.js +36 -0
- package/dist/core/curation-move.js +35 -0
- package/dist/core/curation-queue-cleanup.js +134 -0
- package/dist/core/curation-store.js +98 -0
- package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
- package/dist/core/echo-value.js +48 -0
- package/dist/core/evidence-policy.js +18 -6
- package/dist/core/feature-switch.js +95 -19
- package/dist/core/fs-atomic.js +1 -1
- package/dist/core/gate-level.js +221 -0
- package/dist/core/git.js +16 -14
- package/dist/core/ledger-root.js +82 -22
- package/dist/core/linked-path.js +4 -4
- package/dist/core/manifest-lock.js +238 -0
- package/dist/core/manifest.js +84 -24
- package/dist/core/migrations.js +1 -13
- package/dist/core/model-discovery.js +7 -1
- package/dist/core/observations.js +2 -2
- package/dist/core/plugins.js +15 -15
- package/dist/core/pool.js +26 -0
- package/dist/core/project-paths.js +103 -34
- package/dist/core/queue-drain.js +2 -2
- package/dist/core/tracked-areas.js +35 -0
- package/dist/core/tracker.js +6 -34
- package/dist/{learning-off → curation-off}/agents/code.md +9 -15
- package/dist/{learning-off → curation-off}/agents/fix.md +11 -13
- package/dist/{learning-off → curation-off}/agents/refactor.md +7 -10
- package/dist/{learning-off → curation-off}/agents/review.md +5 -3
- package/dist/{learning-off → curation-off}/agents/triage.md +1 -1
- package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
- package/dist/{learning-off → curation-off}/commands/code-review.md +74 -70
- package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
- package/dist/{learning-off → curation-off}/commands/dynamic-build.md +73 -80
- package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
- package/dist/{learning-off → curation-off}/commands/implement.md +65 -99
- package/dist/{learning-off → curation-off}/commands/plan.md +15 -25
- package/dist/{learning-off → curation-off}/commands/resolve.md +26 -28
- package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
- package/dist/hud/components/{learning-counts.js → curation-counts.js} +36 -17
- package/dist/hud/config.js +1 -1
- package/dist/hud/index.js +6 -6
- package/dist/hud/render.js +3 -3
- package/dist/skills/git/references/pr/validate-branch.md +8 -5
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +20 -14
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +20 -20
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +20 -20
- package/dist/targets/claude-code/{learning-install.js → curation-install.js} +99 -67
- package/dist/targets/claude-code/installer.js +15 -15
- package/dist/targets/claude-code/language-stamp.js +5 -5
- package/dist/targets/claude-code/post-install.js +2 -2
- package/dist/targets/claude-code/tracker-servers.js +1 -1
- package/package.json +3 -2
- package/src/assets/agents/_partials/_code.mds +16 -17
- package/src/assets/agents/code.mds +4 -7
- package/src/assets/agents/curate.md +12 -14
- package/src/assets/agents/debug.mds +6 -6
- package/src/assets/agents/design.mds +18 -18
- package/src/assets/agents/fix.mds +6 -5
- package/src/assets/agents/git.mds +1 -1
- package/src/assets/agents/harden.mds +7 -7
- package/src/assets/agents/knowledge.mds +23 -23
- package/src/assets/agents/refactor.mds +2 -2
- package/src/assets/agents/research.mds +15 -15
- package/src/assets/agents/review.mds +45 -43
- package/src/assets/agents/skim.mds +11 -11
- package/src/assets/agents/synthesize.md +2 -2
- package/src/assets/agents/triage.mds +22 -22
- package/src/assets/agents/validate.md +13 -26
- package/src/assets/commands/_partials/_decisions.mds +13 -13
- package/src/assets/commands/_partials/_engine.mds +8 -8
- package/src/assets/commands/_partials/_knowledge.mds +29 -15
- package/src/assets/commands/_partials/_preamble.mds +4 -4
- package/src/assets/commands/_partials/_settings.mds +3 -3
- package/src/assets/commands/_partials/_wave.mds +17 -24
- package/src/assets/commands/bug-analysis.mds +37 -48
- package/src/assets/commands/code-review.mds +90 -86
- package/src/assets/commands/debug.mds +14 -14
- package/src/assets/commands/dynamic-build.mds +78 -78
- package/src/assets/commands/dynamic-plan.mds +22 -22
- package/src/assets/commands/dynamic-profile.mds +2 -2
- package/src/assets/commands/dynamic-tickets.mds +8 -8
- package/src/assets/commands/explore.mds +20 -20
- package/src/assets/commands/implement.mds +92 -118
- package/src/assets/commands/plan.mds +41 -51
- package/src/assets/commands/release.mds +12 -12
- package/src/assets/commands/research.mds +19 -19
- package/src/assets/commands/resolve.mds +56 -50
- package/src/assets/commands/self-review.mds +20 -20
- package/src/assets/mds/git/_ops.mds +3 -1
- package/src/assets/mds/git/_ops_pr.mds +1 -1
- package/src/assets/mds/git/_ops_steps.mds +13 -7
- package/src/assets/mds/git/_pr.mds +7 -4
- package/src/assets/mds/tracker/_common.mds +23 -12
- package/src/assets/mds/tracker/_github.mds +2 -9
- package/src/assets/mds/tracker/_jira.mds +2 -15
- package/src/assets/mds/tracker/_linear.mds +2 -15
- package/src/assets/rules/quality.md +1 -0
- package/src/assets/scripts/ci-wait.cjs +22 -4
- package/src/assets/scripts/claude-md-audit.cjs +66 -51
- package/src/assets/scripts/hooks/background-memory-update +406 -97
- package/src/assets/scripts/hooks/capture-prompt +12 -12
- package/src/assets/scripts/hooks/capture-question +11 -11
- package/src/assets/scripts/hooks/capture-turn +12 -12
- package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
- package/src/assets/scripts/hooks/curation-move +293 -0
- package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
- package/src/assets/scripts/hooks/ensure-proxy +4 -4
- package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
- package/src/assets/scripts/hooks/git-marker +81 -0
- package/src/assets/scripts/hooks/json-helper.cjs +145 -51
- package/src/assets/scripts/hooks/json-parse +19 -4
- package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
- package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +242 -118
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
- package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +34 -22
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +259 -0
- package/src/assets/scripts/hooks/memory-permission +358 -0
- package/src/assets/scripts/hooks/memory-worker +1 -1
- package/src/assets/scripts/hooks/pre-compact-memory +6 -16
- package/src/assets/scripts/hooks/queue-append +228 -103
- package/src/assets/scripts/hooks/resolve-project-root +2 -2
- package/src/assets/scripts/hooks/session-start-context +145 -71
- package/src/assets/scripts/hooks/session-start-memory +27 -46
- package/src/assets/scripts/lib/project-config.cjs +31 -9
- package/src/assets/scripts/resolve-base.cjs +1043 -0
- package/src/assets/scripts/resolve-settings.cjs +238 -46
- package/src/assets/skills/agent-references/SKILL.md +9 -2
- package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +27 -0
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +24 -0
- package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
- package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
- package/src/assets/skills/apply-decisions/SKILL.md +1 -1
- package/src/assets/skills/docs-framework/SKILL.md +7 -7
- package/src/assets/skills/docs-framework/references/patterns.md +3 -4
- package/src/assets/skills/quality-gates/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +2 -2
- package/templates/lint-pack/.lint-pack.conf +86 -0
- package/templates/lint-pack/README.md +190 -0
- package/templates/lint-pack/lint-pack.sh +905 -0
- package/dist/core/learning-queue-cleanup.js +0 -27
- package/dist/core/learning-store.js +0 -61
- /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// src/assets/scripts/hooks/lib/tracked-areas.cjs
|
|
2
|
+
//
|
|
3
|
+
// Does git track any entry under one of devflow's data folders at a root? One
|
|
4
|
+
// `git ls-files` call over a fixed set of pathspecs answers `tracked`, `untracked` or
|
|
5
|
+
// `unknown`. The one-time ledger move and the backup sweep ask it before they lock, read
|
|
6
|
+
// or rename anything: a folder that git tracks is the repository's content, so they leave
|
|
7
|
+
// it alone.
|
|
8
|
+
//
|
|
9
|
+
// DESIGN: a leaf. It loads node built-ins and project-paths.cjs (which loads node
|
|
10
|
+
// built-ins only) and requires neither the store nor the move module (the move requires
|
|
11
|
+
// the store; the store never requires the move), so either of them, and a check for
|
|
12
|
+
// another folder, can use it. No function here prints or exits, and nothing throws:
|
|
13
|
+
// every failure is the answer `unknown`, with a cause from the closed set UNKNOWN_CAUSES.
|
|
14
|
+
//
|
|
15
|
+
// The folder names come from project-paths.cjs, which owns them: AREA_FOLDERS lists the
|
|
16
|
+
// data folders and the backup prefix names the folders the move leaves behind. To cover
|
|
17
|
+
// another folder, add its name to AREA_FOLDERS.
|
|
18
|
+
//
|
|
19
|
+
// The decision is the repository's index, never the disk. A root with no `.git` entry
|
|
20
|
+
// (a file or a folder) is not a git working tree and gets no git call. A root with one
|
|
21
|
+
// gets exactly one call, and every failure of that call (git missing, a timeout, a
|
|
22
|
+
// non-zero exit with nothing listed) is `unknown`: the caller cannot tell what git
|
|
23
|
+
// tracks and must change nothing. git's error text is never read.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const childProcess = require('child_process');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const { LEGACY_NAMES, CURATION_DIR_NAME } = require('./project-paths.cjs');
|
|
32
|
+
|
|
33
|
+
/** The data folders under `.devflow` that the check covers, by the names the path helpers hold. */
|
|
34
|
+
const AREA_FOLDERS = Object.freeze([LEGACY_NAMES.folder, CURATION_DIR_NAME]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The spellings a case-folding file system opens as `.devflow` beyond ASCII letter case.
|
|
38
|
+
* git's `icase` folds ASCII letters only, but APFS (the macOS default) compares names by
|
|
39
|
+
* full Unicode case folding, so it also opens `.devflow` (U+FB02 folds to "fl"). The code
|
|
40
|
+
* is written with an escape, so the code point is named.
|
|
41
|
+
*/
|
|
42
|
+
const DEVFLOW_SPELLINGS = Object.freeze(['.devflow', '.dev\u{FB02}ow']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pathspecs of the check, each matched without regard to ASCII letter case, whatever
|
|
46
|
+
* the file system or `core.ignorecase`, and each spelled every way DEVFLOW_SPELLINGS lists:
|
|
47
|
+
* - each folder of AREA_FOLDERS, as a literal path: an index entry at the folder, or
|
|
48
|
+
* anywhere below it, matches
|
|
49
|
+
* - an entry named like a backup folder (`<backup prefix>*`), and every entry below one
|
|
50
|
+
* (`<backup prefix>*` + `/**`): the move leaves its old folder there, and the sweep
|
|
51
|
+
* merges the queue files in it into the new folder. The prefix has no glob character.
|
|
52
|
+
* - an index entry named `.devflow` ITSELF, which git records only for a symlink, a
|
|
53
|
+
* gitlink (submodule) or a plain file, never for a directory: the character class
|
|
54
|
+
* keeps `glob` from matching as a leading directory, so the many files under a
|
|
55
|
+
* normal `.devflow/` are not listed. Committed content then stands at the path where
|
|
56
|
+
* the folders are opened.
|
|
57
|
+
*/
|
|
58
|
+
const TRACKED_AREA_PATHSPECS = Object.freeze([
|
|
59
|
+
...DEVFLOW_SPELLINGS.flatMap(dir => [
|
|
60
|
+
...AREA_FOLDERS.map(area => ':(icase,literal)' + dir + '/' + area),
|
|
61
|
+
':(icase,glob)' + dir + '/' + LEGACY_NAMES.backupPrefix + '*',
|
|
62
|
+
':(icase,glob)' + dir + '/' + LEGACY_NAMES.backupPrefix + '*/**',
|
|
63
|
+
]),
|
|
64
|
+
...DEVFLOW_SPELLINGS.map(dir => ':(icase,glob)[.]' + dir.slice(1)),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Why a check answered `unknown`, as one fixed word. The words come from this list
|
|
69
|
+
* alone: they name a spawn error code, a time out, a signal, an exit status or the step
|
|
70
|
+
* that failed, and never carry git's own text or a path.
|
|
71
|
+
*/
|
|
72
|
+
const UNKNOWN_CAUSES = Object.freeze({
|
|
73
|
+
rootNotAbsolute: 'root-not-absolute',
|
|
74
|
+
gitEntryUnreadable: 'git-entry-unreadable',
|
|
75
|
+
ceilingUnusable: 'ceiling-unusable',
|
|
76
|
+
checkFailed: 'check-failed',
|
|
77
|
+
gitNotFound: 'git-not-found',
|
|
78
|
+
gitTimedOut: 'git-timed-out',
|
|
79
|
+
gitStopped: 'git-stopped',
|
|
80
|
+
gitExitStatus: 'git-exit-status',
|
|
81
|
+
gitNotRun: 'git-not-run',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The longest the git call may run (ms). */
|
|
85
|
+
const TRACKED_AREAS_GIT_TIMEOUT_MS = 5000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The most output the git call may return, in bytes. A normal answer is empty; a
|
|
89
|
+
* listing past this bound (ENOBUFS) means entries exist, which reads as tracked.
|
|
90
|
+
*/
|
|
91
|
+
const TRACKED_AREAS_GIT_MAX_BUFFER = 4096;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The variables that make git read another repository, another index or other pathspec
|
|
95
|
+
* rules than the root's own. The call runs without them.
|
|
96
|
+
*/
|
|
97
|
+
const GIT_REDIRECT_VARIABLES = Object.freeze([
|
|
98
|
+
'GIT_DIR',
|
|
99
|
+
'GIT_WORK_TREE',
|
|
100
|
+
'GIT_INDEX_FILE',
|
|
101
|
+
'GIT_COMMON_DIR',
|
|
102
|
+
'GIT_OBJECT_DIRECTORY',
|
|
103
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
104
|
+
'GIT_NAMESPACE',
|
|
105
|
+
'GIT_LITERAL_PATHSPECS',
|
|
106
|
+
'GIT_GLOB_PATHSPECS',
|
|
107
|
+
'GIT_NOGLOB_PATHSPECS',
|
|
108
|
+
'GIT_ICASE_PATHSPECS',
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The environment of the git call: the process environment without the redirecting
|
|
113
|
+
* variables, with a fixed locale, no optional lock and a ceiling directory. Answers a new
|
|
114
|
+
* object. The ceiling is the folder that holds the root: git reads the root's own `.git`
|
|
115
|
+
* and never looks in a folder above it, so an empty or broken `.git` at the root is not
|
|
116
|
+
* answered from a work tree around the project.
|
|
117
|
+
*
|
|
118
|
+
* @param {NodeJS.ProcessEnv} base
|
|
119
|
+
* @param {string} ceiling - an absolute path with no list separator in it
|
|
120
|
+
* @returns {NodeJS.ProcessEnv}
|
|
121
|
+
*/
|
|
122
|
+
function trackedAreasGitEnv(base, ceiling) {
|
|
123
|
+
const env = { ...base };
|
|
124
|
+
for (const name of GIT_REDIRECT_VARIABLES) delete env[name];
|
|
125
|
+
env.LC_ALL = 'C';
|
|
126
|
+
env.GIT_OPTIONAL_LOCKS = '0';
|
|
127
|
+
env.GIT_CEILING_DIRECTORIES = ceiling;
|
|
128
|
+
return env;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The answer of one `git ls-files` call, from the result `spawnSync` gave, with the
|
|
133
|
+
* cause when the answer is `unknown` (null otherwise):
|
|
134
|
+
* tracked any output byte, whatever the exit status or error (the call listed an
|
|
135
|
+
* entry), and an output past the buffer cap (ENOBUFS)
|
|
136
|
+
* untracked exit 0 with nothing listed
|
|
137
|
+
* unknown every other result, with the cause: git-not-found (spawn error ENOENT),
|
|
138
|
+
* git-timed-out (ETIMEDOUT), git-not-run (another spawn error, or a result
|
|
139
|
+
* of the wrong shape), git-stopped (a signal ended it), git-exit-status (a
|
|
140
|
+
* non-zero exit with nothing listed)
|
|
141
|
+
* Pure: it reads only the fields `error.code`, `status`, `signal` and `stdout`.
|
|
142
|
+
*
|
|
143
|
+
* @param {unknown} result
|
|
144
|
+
* @returns {{ answer: 'tracked' | 'untracked' | 'unknown', cause: string | null }}
|
|
145
|
+
*/
|
|
146
|
+
function judgeTrackedAreas(result) {
|
|
147
|
+
if (result === null || typeof result !== 'object') return unknownBecause(UNKNOWN_CAUSES.gitNotRun);
|
|
148
|
+
const { error, status, signal, stdout } = /** @type {{ error?: { code?: unknown }, status?: unknown, signal?: unknown, stdout?: unknown }} */ (result);
|
|
149
|
+
if (error && error.code === 'ENOBUFS') return { answer: 'tracked', cause: null };
|
|
150
|
+
const listed = (typeof stdout === 'string' || Buffer.isBuffer(stdout)) && stdout.length > 0;
|
|
151
|
+
if (listed) return { answer: 'tracked', cause: null };
|
|
152
|
+
if (error) {
|
|
153
|
+
if (error.code === 'ENOENT') return unknownBecause(UNKNOWN_CAUSES.gitNotFound);
|
|
154
|
+
return unknownBecause(error.code === 'ETIMEDOUT' ? UNKNOWN_CAUSES.gitTimedOut : UNKNOWN_CAUSES.gitNotRun);
|
|
155
|
+
}
|
|
156
|
+
if (status === 0) return { answer: 'untracked', cause: null };
|
|
157
|
+
if (typeof status === 'number') return unknownBecause(UNKNOWN_CAUSES.gitExitStatus);
|
|
158
|
+
return unknownBecause(typeof signal === 'string' ? UNKNOWN_CAUSES.gitStopped : UNKNOWN_CAUSES.gitNotRun);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} cause - a value of UNKNOWN_CAUSES
|
|
163
|
+
* @returns {{ answer: 'unknown', cause: string }}
|
|
164
|
+
*/
|
|
165
|
+
function unknownBecause(cause) {
|
|
166
|
+
return { answer: 'unknown', cause };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The answer of {@link judgeTrackedAreas}, without its cause.
|
|
171
|
+
*
|
|
172
|
+
* @param {unknown} result
|
|
173
|
+
* @returns {'tracked' | 'untracked' | 'unknown'}
|
|
174
|
+
*/
|
|
175
|
+
function classifyTrackedAreas(result) {
|
|
176
|
+
return judgeTrackedAreas(result).answer;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether anything (a file, a folder, a link) stands at `target`: true, false, or null
|
|
181
|
+
* when the probe failed for a reason other than absence.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} target
|
|
184
|
+
* @returns {boolean | null}
|
|
185
|
+
*/
|
|
186
|
+
function entryExists(target) {
|
|
187
|
+
try {
|
|
188
|
+
fs.lstatSync(target);
|
|
189
|
+
return true;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR') ? false : null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether git tracks any entry under the covered folders at `root`, or an entry named
|
|
197
|
+
* `.devflow` itself (a link, a gitlink or a file). The index decides, not the disk, and
|
|
198
|
+
* a staged entry counts. The answer carries the cause (a value of UNKNOWN_CAUSES) when
|
|
199
|
+
* it is `unknown`, and null otherwise.
|
|
200
|
+
*
|
|
201
|
+
* untracked no `.git` entry at the root (no git call is made), or git listed nothing
|
|
202
|
+
* tracked git listed an entry
|
|
203
|
+
* unknown a `.git` entry stands and git did not answer (see judgeTrackedAreas),
|
|
204
|
+
* the `.git` entry could not be probed, or the root is not an absolute path
|
|
205
|
+
* string (no git call is made)
|
|
206
|
+
*
|
|
207
|
+
* The call is `git --no-literal-pathspecs -c core.fsmonitor=false ls-files -z -- <paths>`
|
|
208
|
+
* with the root as its working directory: an argv with no shell, a time bound and an
|
|
209
|
+
* output bound. `--no-literal-pathspecs` leads the argv, so a forced literal-pathspec
|
|
210
|
+
* setting cannot turn the magic off and hide an entry. Reading the index makes git run
|
|
211
|
+
* the command a repository's config names in `core.fsmonitor`, so the call turns that off
|
|
212
|
+
* for itself and stays a pure read. The folder above the root's real path is a ceiling
|
|
213
|
+
* directory (trackedAreasGitEnv); a parent path that holds the list separator can not be
|
|
214
|
+
* named as one, and answers `unknown`.
|
|
215
|
+
*
|
|
216
|
+
* @param {unknown} root - an absolute path
|
|
217
|
+
* @returns {{ answer: 'tracked' | 'untracked' | 'unknown', cause: string | null }}
|
|
218
|
+
*/
|
|
219
|
+
function inspectTrackedAreas(root) {
|
|
220
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) return unknownBecause(UNKNOWN_CAUSES.rootNotAbsolute);
|
|
221
|
+
try {
|
|
222
|
+
const gitEntry = entryExists(path.join(root, '.git'));
|
|
223
|
+
if (gitEntry === null) return unknownBecause(UNKNOWN_CAUSES.gitEntryUnreadable);
|
|
224
|
+
if (gitEntry === false) return { answer: 'untracked', cause: null };
|
|
225
|
+
const ceiling = path.dirname(fs.realpathSync(root));
|
|
226
|
+
if (ceiling.includes(path.delimiter)) return unknownBecause(UNKNOWN_CAUSES.ceilingUnusable);
|
|
227
|
+
const result = childProcess.spawnSync('git', ['--no-literal-pathspecs', '-c', 'core.fsmonitor=false', 'ls-files', '-z', '--', ...TRACKED_AREA_PATHSPECS], {
|
|
228
|
+
cwd: root,
|
|
229
|
+
env: trackedAreasGitEnv(process.env, ceiling),
|
|
230
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
231
|
+
timeout: TRACKED_AREAS_GIT_TIMEOUT_MS,
|
|
232
|
+
maxBuffer: TRACKED_AREAS_GIT_MAX_BUFFER,
|
|
233
|
+
windowsHide: true,
|
|
234
|
+
shell: false,
|
|
235
|
+
});
|
|
236
|
+
return judgeTrackedAreas(result);
|
|
237
|
+
} catch (_) {
|
|
238
|
+
return unknownBecause(UNKNOWN_CAUSES.checkFailed);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The answer of {@link inspectTrackedAreas}, without its cause.
|
|
244
|
+
*
|
|
245
|
+
* @param {unknown} root - an absolute path
|
|
246
|
+
* @returns {'tracked' | 'untracked' | 'unknown'}
|
|
247
|
+
*/
|
|
248
|
+
function checkTrackedAreas(root) {
|
|
249
|
+
return inspectTrackedAreas(root).answer;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
TRACKED_AREA_PATHSPECS,
|
|
254
|
+
UNKNOWN_CAUSES,
|
|
255
|
+
judgeTrackedAreas,
|
|
256
|
+
classifyTrackedAreas,
|
|
257
|
+
inspectTrackedAreas,
|
|
258
|
+
checkTrackedAreas,
|
|
259
|
+
};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# memory-permission — the memory worker's write permission, as one sourced helper.
|
|
4
|
+
#
|
|
5
|
+
# background-memory-update starts `claude -p` in a permission mode that denies every
|
|
6
|
+
# call no rule allows, with an allow rule for its staged file (WORKING-MEMORY.md.new),
|
|
7
|
+
# the only path the worker's write may reach, and a deny rule for every path under it.
|
|
8
|
+
# Allow rules in the user's own settings also apply to the run.
|
|
9
|
+
# This helper holds what the worker and session-start-memory share, so one bash 3.2 test
|
|
10
|
+
# run covers it (tests/shell-hooks-helpers.test.ts):
|
|
11
|
+
# path_occupied whether a file, a folder or a link, even a dangling one, stands on a path
|
|
12
|
+
# staged_path_safe whether a staged path can sit in those rules
|
|
13
|
+
# memory_settings_json the settings JSON that carries the rules
|
|
14
|
+
# memory_staged_remove the guarded removal of a folder or a link at the staged path
|
|
15
|
+
# memory_result_summary the fixed log line for a captured JSON result
|
|
16
|
+
# memory_notice_* the one-time notice: the worker writes it, a session start reads it
|
|
17
|
+
# memory_denied_seen the count of denied runs that raises the `denied` notice
|
|
18
|
+
#
|
|
19
|
+
# Source it after resolve-project-root (it needs df_no_symlink_below and df_file_below)
|
|
20
|
+
# and, for memory_result_summary, after json-parse. log() is used when the caller has one.
|
|
21
|
+
#
|
|
22
|
+
# Errexit safety: both hooks run under `set -e`, and a helper's last non-zero status
|
|
23
|
+
# aborts the caller. So each helper ends in an explicit `return 0` or a `return` with the
|
|
24
|
+
# status it means, link checks are predicates inside `if`, and a create that noclobber may
|
|
25
|
+
# refuse ends in `|| true`. Callers call a predicate inside an `if` or an `||` list.
|
|
26
|
+
#
|
|
27
|
+
# Character sets are spelled out in the patterns below: a range such as a-z collates by
|
|
28
|
+
# locale on bash 3.2.
|
|
29
|
+
|
|
30
|
+
# The reasons a notice can carry, and the file each one uses beside the memory files:
|
|
31
|
+
# path the project path cannot carry the rule, or the staged path is unusable
|
|
32
|
+
# denied Claude Code refused the worker's write in two runs with no successful update between them
|
|
33
|
+
MEMORY_NOTICE_REASONS="path denied"
|
|
34
|
+
|
|
35
|
+
# path_occupied <path> — returns 0 when a file, a folder or a link stands on <path>, and 1
|
|
36
|
+
# when nothing does. A link counts whatever it names, a dangling link included: the name is
|
|
37
|
+
# taken, and a create or a move onto it is not safe. Call it inside an `if` or an `||` list.
|
|
38
|
+
path_occupied() {
|
|
39
|
+
if [ -e "${1:-}" ] || [ -L "${1:-}" ]; then
|
|
40
|
+
return 0
|
|
41
|
+
fi
|
|
42
|
+
return 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# staged_path_safe <path> — returns 0 when <path> can sit in the Edit rules as it is.
|
|
46
|
+
# Sets STAGED_PATH_REFUSAL to "" when it can, `chars` when the path holds a character or
|
|
47
|
+
# a segment the rules cannot carry, and `protected` when a .git folder lies above it (the
|
|
48
|
+
# worker skips such a project and logs the reason). A .claude folder above it is fine: a
|
|
49
|
+
# checkout in .claude/worktrees gets its working memory updated. The rule text carries no
|
|
50
|
+
# quoting, so a character that has a meaning in a rule path is refused rather than quoted:
|
|
51
|
+
# * ? [ ] \ " { } and every control character, including newline, tab and DEL. A path that
|
|
52
|
+
# is not absolute, starts with //, or holds an empty, . or .. segment is refused too. ( and )
|
|
53
|
+
# are carried as they are. The path reaches a JSON string by plain concatenation, which is
|
|
54
|
+
# safe after this check: $, backtick and % are inert there, and " \ and controls cannot occur.
|
|
55
|
+
staged_path_safe() {
|
|
56
|
+
local _p="${1:-}"
|
|
57
|
+
STAGED_PATH_REFUSAL=""
|
|
58
|
+
case "$_p" in
|
|
59
|
+
/*) ;;
|
|
60
|
+
*) STAGED_PATH_REFUSAL="chars"; return 1 ;;
|
|
61
|
+
esac
|
|
62
|
+
case "$_p" in
|
|
63
|
+
//*|*//*|*/./*|*/../*|*/.|*/..) STAGED_PATH_REFUSAL="chars"; return 1 ;;
|
|
64
|
+
esac
|
|
65
|
+
case "$_p" in
|
|
66
|
+
*[[:cntrl:]]*) STAGED_PATH_REFUSAL="chars"; return 1 ;;
|
|
67
|
+
esac
|
|
68
|
+
case "$_p" in
|
|
69
|
+
*'*'*|*'?'*|*'['*|*']'*|*'\'*|*'"'*|*'{'*|*'}'*) STAGED_PATH_REFUSAL="chars"; return 1 ;;
|
|
70
|
+
esac
|
|
71
|
+
case "$_p" in
|
|
72
|
+
*/.git/*) STAGED_PATH_REFUSAL="protected"; return 1 ;;
|
|
73
|
+
esac
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# memory_settings_json <staged path> — prints the settings JSON for `claude --settings`:
|
|
78
|
+
# two rules and no hooks (// makes a rule path absolute):
|
|
79
|
+
# allow Edit(//<staged>) the staged file
|
|
80
|
+
# deny Edit(//<staged>/*) every path under the staged path
|
|
81
|
+
# Under dontAsk, any call no rule allows is refused, so the worker's write is limited to
|
|
82
|
+
# its staged file and to what allow rules in the user's own settings cover.
|
|
83
|
+
# Measured on Claude Code 2.1.269, 2.1.280, 2.1.286 and 2.1.299, both argv forms,
|
|
84
|
+
# against a local stand-in for the Messages API (2026-10-15): with these two rules
|
|
85
|
+
# the Write of the staged file succeeds, and a Write to <staged>/x, <staged>/a/b,
|
|
86
|
+
# <staged>/a/b/c/d/e, <staged>/.hidden or <staged>/../<name> is refused. The deny is
|
|
87
|
+
# `/*` and not `/**`: `/**` was measured to refuse the staged file too (2.1.269 and
|
|
88
|
+
# 2.1.299, 0 of 3 writes), and then no memory is written. A path with two pairs of ( ) in it
|
|
89
|
+
# (`obs ( )/proj (copy)`) carries both rules as they are: on 2.1.269 with the legacy argv
|
|
90
|
+
# and on 2.1.299 with the lean argv, the Write of the staged file succeeded and a Write to
|
|
91
|
+
# <staged>/x, <staged>/a/b, <staged>/.hidden, <staged>/../<name> and to a file in the
|
|
92
|
+
# project root was refused. Prints nothing and returns 1 for a path staged_path_safe
|
|
93
|
+
# refuses, so a rule is never built for an unchecked path.
|
|
94
|
+
memory_settings_json() {
|
|
95
|
+
local _p="${1:-}"
|
|
96
|
+
staged_path_safe "$_p" || return 1
|
|
97
|
+
printf '%s' '{"permissions":{"allow":["Edit(/'"$_p"')"],"deny":["Edit(/'"$_p"'/*)"]},"disableAllHooks":true}'
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# STAGED_REMOVE_MAX_LISTING — the most lines `ls -RA` may print for a folder at the staged
|
|
102
|
+
# path. A folder with a longer listing is not deleted.
|
|
103
|
+
STAGED_REMOVE_MAX_LISTING=256
|
|
104
|
+
|
|
105
|
+
# memory_staged_remove <memory dir> <root> <staged path> — makes the staged path empty
|
|
106
|
+
# when a folder or a link stands on it, with one delete of exactly that path. <root> is
|
|
107
|
+
# the project root as it resolves on disk. It deletes only when it can prove all of these,
|
|
108
|
+
# and otherwise deletes nothing, returns 1 and sets STAGED_REMOVE_REFUSAL to the keyword
|
|
109
|
+
# of the first fact it could not prove:
|
|
110
|
+
# args <memory dir>, <root> and <staged path> are all set, and both paths are absolute
|
|
111
|
+
# path <staged path> is exactly <memory dir>/WORKING-MEMORY.md.new
|
|
112
|
+
# parent <memory dir> is <root>/.devflow/memory, as a string and then on disk
|
|
113
|
+
# link no link sits on the way down from <root> to <memory dir>
|
|
114
|
+
# size a folder lists at most STAGED_REMOVE_MAX_LISTING lines of `ls -RA`
|
|
115
|
+
# rm the delete took effect: nothing stands on the staged path afterwards
|
|
116
|
+
# A link is removed as the link (`rm -f`, never `-r`), so what it names is never touched;
|
|
117
|
+
# a folder is removed with `rm -rf`, which unlinks a link inside it and never follows one.
|
|
118
|
+
# The path is a single quoted word, so no pattern expands. Sets STAGED_REMOVED to `link`,
|
|
119
|
+
# `dir`, or `none` (nothing removed). A regular file, or nothing, is left as it is and
|
|
120
|
+
# returns 0: the caller removes a stale staged file itself. Returns 0 when no folder and
|
|
121
|
+
# no link stands on the path afterwards. The checks and the delete are separate steps, so
|
|
122
|
+
# a process that swaps something in between is not stopped; such a process already runs as
|
|
123
|
+
# the user.
|
|
124
|
+
memory_staged_remove() {
|
|
125
|
+
local _dir="${1:-}" _root="${2:-}" _staged="${3:-}" _kind="" _phys="" _n=0
|
|
126
|
+
STAGED_REMOVED="none"
|
|
127
|
+
STAGED_REMOVE_REFUSAL=""
|
|
128
|
+
case "$_dir" in /?*) ;; *) STAGED_REMOVE_REFUSAL="args"; return 1 ;; esac
|
|
129
|
+
case "$_root" in /?*) ;; *) STAGED_REMOVE_REFUSAL="args"; return 1 ;; esac
|
|
130
|
+
case "$_staged" in /?*) ;; *) STAGED_REMOVE_REFUSAL="args"; return 1 ;; esac
|
|
131
|
+
if [ -L "$_staged" ]; then
|
|
132
|
+
_kind="link"
|
|
133
|
+
elif [ -d "$_staged" ]; then
|
|
134
|
+
_kind="dir"
|
|
135
|
+
else
|
|
136
|
+
return 0
|
|
137
|
+
fi
|
|
138
|
+
if [ "$_staged" != "$_dir/WORKING-MEMORY.md.new" ]; then
|
|
139
|
+
STAGED_REMOVE_REFUSAL="path"; return 1
|
|
140
|
+
fi
|
|
141
|
+
if [ "$_dir" != "$_root/.devflow/memory" ]; then
|
|
142
|
+
STAGED_REMOVE_REFUSAL="parent"; return 1
|
|
143
|
+
fi
|
|
144
|
+
if ! df_no_symlink_below "$_root" "$_dir"; then
|
|
145
|
+
STAGED_REMOVE_REFUSAL="link"; return 1
|
|
146
|
+
fi
|
|
147
|
+
_phys=$(cd -P -- "$_dir" 2>/dev/null && pwd -P) || _phys=""
|
|
148
|
+
if [ "$_phys" != "$_root/.devflow/memory" ]; then
|
|
149
|
+
STAGED_REMOVE_REFUSAL="parent"; return 1
|
|
150
|
+
fi
|
|
151
|
+
if [ "$_kind" = "dir" ]; then
|
|
152
|
+
_n=$(ls -RA -- "$_staged" 2>/dev/null | head -n $((STAGED_REMOVE_MAX_LISTING + 1)) | wc -l) || _n=0
|
|
153
|
+
_n=$((_n + 0))
|
|
154
|
+
if [ "$_n" -gt "$STAGED_REMOVE_MAX_LISTING" ]; then
|
|
155
|
+
STAGED_REMOVE_REFUSAL="size"; return 1
|
|
156
|
+
fi
|
|
157
|
+
rm -rf -- "$_staged" 2>/dev/null || true
|
|
158
|
+
else
|
|
159
|
+
rm -f -- "$_staged" 2>/dev/null || true
|
|
160
|
+
fi
|
|
161
|
+
if path_occupied "$_staged"; then
|
|
162
|
+
STAGED_REMOVE_REFUSAL="rm"; return 1
|
|
163
|
+
fi
|
|
164
|
+
STAGED_REMOVED="$_kind"
|
|
165
|
+
return 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# The most characters a result field may carry into the log line. A longer value reads
|
|
169
|
+
# `unknown` (a status code is left out).
|
|
170
|
+
MEMORY_RESULT_DENIALS_MAX_DIGITS=6
|
|
171
|
+
MEMORY_RESULT_TURNS_MAX_DIGITS=6
|
|
172
|
+
MEMORY_RESULT_STATUS_MAX_DIGITS=3
|
|
173
|
+
MEMORY_RESULT_SUBTYPE_MAX_CHARS=40
|
|
174
|
+
|
|
175
|
+
# _memory_field_within <digits|letters> <value> <max> — sets _MEMORY_FIELD to <value> when
|
|
176
|
+
# it is one or more characters of the named set and at most <max> of them, and to `unknown`
|
|
177
|
+
# otherwise. `digits` is 0-9; `letters` is a-z and _. Both are spelled out, since a range
|
|
178
|
+
# collates by locale on bash 3.2. A builtin only, so it forks nothing. Always returns 0.
|
|
179
|
+
_memory_field_within() {
|
|
180
|
+
local _set="${1:-}" _value="${2:-}" _max="${3:-0}"
|
|
181
|
+
_MEMORY_FIELD="unknown"
|
|
182
|
+
case "$_set" in
|
|
183
|
+
digits)
|
|
184
|
+
case "$_value" in ''|*[!0123456789]*) return 0 ;; esac
|
|
185
|
+
;;
|
|
186
|
+
letters)
|
|
187
|
+
case "$_value" in ''|*[!abcdefghijklmnopqrstuvwxyz_]*) return 0 ;; esac
|
|
188
|
+
;;
|
|
189
|
+
*) return 0 ;;
|
|
190
|
+
esac
|
|
191
|
+
if [ "${#_value}" -le "$_max" ]; then _MEMORY_FIELD="$_value"; fi
|
|
192
|
+
return 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# memory_result_summary <file> — reduces a `--output-format json` result file to a fixed
|
|
196
|
+
# line, and reads nothing else of it: the tool input and the result text, which can hold
|
|
197
|
+
# the memory body, are never read. Sets, in the caller's scope:
|
|
198
|
+
# MEMORY_RESULT_LINE Result: <subtype>, is_error=<true|false>, <n> turn(s),
|
|
199
|
+
# <d> permission denial(s)[, api_status=<digits>]
|
|
200
|
+
# or Result: not reported (empty|unparseable|no parser)
|
|
201
|
+
# MEMORY_RESULT_DENIALS the denial count as digits, or `unknown` when the result does
|
|
202
|
+
# not report one (an absent or non-array field is never 0)
|
|
203
|
+
# Every field is checked against its own shape, so text from the file cannot enter the
|
|
204
|
+
# line: a field that fails its check reads `unknown`. Always returns 0.
|
|
205
|
+
memory_result_summary() {
|
|
206
|
+
local _f="${1:-}" _sub="" _err="" _turns="" _den="" _status="" _api=""
|
|
207
|
+
MEMORY_RESULT_LINE=""
|
|
208
|
+
MEMORY_RESULT_DENIALS="unknown"
|
|
209
|
+
if [ "${_JSON_AVAILABLE:-false}" != "true" ]; then
|
|
210
|
+
MEMORY_RESULT_LINE="Result: not reported (no parser)"
|
|
211
|
+
return 0
|
|
212
|
+
fi
|
|
213
|
+
if [ -z "$_f" ] || [ ! -s "$_f" ]; then
|
|
214
|
+
MEMORY_RESULT_LINE="Result: not reported (empty)"
|
|
215
|
+
return 0
|
|
216
|
+
fi
|
|
217
|
+
_den=$(json_array_length_file "$_f" "permission_denials") || _den=""
|
|
218
|
+
if [ -z "$_den" ]; then
|
|
219
|
+
MEMORY_RESULT_LINE="Result: not reported (unparseable)"
|
|
220
|
+
return 0
|
|
221
|
+
fi
|
|
222
|
+
_memory_field_within digits "$_den" "$MEMORY_RESULT_DENIALS_MAX_DIGITS"; _den="$_MEMORY_FIELD"
|
|
223
|
+
_sub=$(json_string_field_file "$_f" "subtype") || _sub=""
|
|
224
|
+
_memory_field_within letters "$_sub" "$MEMORY_RESULT_SUBTYPE_MAX_CHARS"; _sub="$_MEMORY_FIELD"
|
|
225
|
+
_err=$(json_field_file "$_f" "is_error" "") || _err=""
|
|
226
|
+
case "$_err" in
|
|
227
|
+
true|false) ;;
|
|
228
|
+
*) _err="unknown" ;;
|
|
229
|
+
esac
|
|
230
|
+
_turns=$(json_field_file "$_f" "num_turns" "") || _turns=""
|
|
231
|
+
_memory_field_within digits "$_turns" "$MEMORY_RESULT_TURNS_MAX_DIGITS"; _turns="$_MEMORY_FIELD"
|
|
232
|
+
_status=$(json_field_file "$_f" "api_error_status" "") || _status=""
|
|
233
|
+
_memory_field_within digits "$_status" "$MEMORY_RESULT_STATUS_MAX_DIGITS"
|
|
234
|
+
if [ "$_MEMORY_FIELD" != "unknown" ]; then _api=", api_status=${_MEMORY_FIELD}"; fi
|
|
235
|
+
MEMORY_RESULT_LINE="Result: ${_sub}, is_error=${_err}, ${_turns} turn(s), ${_den} permission denial(s)${_api}"
|
|
236
|
+
MEMORY_RESULT_DENIALS="$_den"
|
|
237
|
+
return 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# memory_notice_write <memory dir> <root> <reason> — leaves a notice for the next session
|
|
241
|
+
# start, once per reason: only where neither the notice nor its `.shown` marker stands,
|
|
242
|
+
# and where no link sits on the way down from <root>. The builtin tests come first, since
|
|
243
|
+
# noclobber alone opens a link to a FIFO or a device; noclobber then makes the create
|
|
244
|
+
# itself exclusive. Content: the reason. Best effort: a refusal leaves nothing.
|
|
245
|
+
memory_notice_write() {
|
|
246
|
+
local _dir="${1:-}" _root="${2:-}" _reason="${3:-}" _file
|
|
247
|
+
case " $MEMORY_NOTICE_REASONS " in
|
|
248
|
+
*" $_reason "*) ;;
|
|
249
|
+
*) return 0 ;;
|
|
250
|
+
esac
|
|
251
|
+
[ -d "$_dir" ] || return 0
|
|
252
|
+
_file="$_dir/.worker-notice-$_reason"
|
|
253
|
+
if ! path_occupied "$_file" && ! path_occupied "$_file.shown" \
|
|
254
|
+
&& df_no_symlink_below "$_root" "$_file" "$_file.shown"; then
|
|
255
|
+
( umask 077; set -C; printf '%s\n' "$_reason" > "$_file" ) 2>/dev/null || true
|
|
256
|
+
fi
|
|
257
|
+
return 0
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# memory_notice_clear <memory dir> <root> <reason> — removes the notice, its `.shown`
|
|
261
|
+
# marker and, for `denied`, the one-denied-run marker, once the cause is gone. The
|
|
262
|
+
# files are removed only where no link sits on the way down from <root>.
|
|
263
|
+
memory_notice_clear() {
|
|
264
|
+
local _dir="${1:-}" _root="${2:-}" _reason="${3:-}" _file _mark
|
|
265
|
+
case " $MEMORY_NOTICE_REASONS " in
|
|
266
|
+
*" $_reason "*) ;;
|
|
267
|
+
*) return 0 ;;
|
|
268
|
+
esac
|
|
269
|
+
_file="$_dir/.worker-notice-$_reason"
|
|
270
|
+
_mark="$_dir/.worker-denied-once"
|
|
271
|
+
if path_occupied "$_file" || path_occupied "$_file.shown"; then
|
|
272
|
+
if df_no_symlink_below "$_root" "$_file" "$_file.shown"; then
|
|
273
|
+
rm -f "$_file" "$_file.shown" 2>/dev/null || true
|
|
274
|
+
fi
|
|
275
|
+
fi
|
|
276
|
+
if [ "$_reason" = "denied" ] && path_occupied "$_mark"; then
|
|
277
|
+
if df_no_symlink_below "$_root" "$_mark"; then
|
|
278
|
+
rm -f "$_mark" 2>/dev/null || true
|
|
279
|
+
fi
|
|
280
|
+
fi
|
|
281
|
+
return 0
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# memory_denied_seen <memory dir> <root> — records one run that Claude Code refused the
|
|
285
|
+
# worker's write in. The first leaves a marker; a second, with the marker still there,
|
|
286
|
+
# raises the `denied` notice. A run that swapped the memory in clears both
|
|
287
|
+
# (memory_notice_clear denied), so only two denied runs with no successful update between
|
|
288
|
+
# them count.
|
|
289
|
+
memory_denied_seen() {
|
|
290
|
+
local _dir="${1:-}" _root="${2:-}" _mark
|
|
291
|
+
[ -d "$_dir" ] || return 0
|
|
292
|
+
_mark="$_dir/.worker-denied-once"
|
|
293
|
+
if path_occupied "$_mark"; then
|
|
294
|
+
memory_notice_write "$_dir" "$_root" "denied"
|
|
295
|
+
elif df_no_symlink_below "$_root" "$_mark"; then
|
|
296
|
+
( umask 077; set -C; : > "$_mark" ) 2>/dev/null || true
|
|
297
|
+
fi
|
|
298
|
+
return 0
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
# memory_notice_text <reason> <log file> — sets MEMORY_NOTICE_TEXT to the message for a
|
|
302
|
+
# reason, naming <log file>, and to "" for a reason it has no message for. Always returns 0.
|
|
303
|
+
memory_notice_text() {
|
|
304
|
+
local _reason="${1:-}" _logfile="${2:-}"
|
|
305
|
+
local _where="The worker log is .background-memory-update.log under ~/.devflow/logs/, in the folder for the session that ran it. For this session: ${_logfile}"
|
|
306
|
+
MEMORY_NOTICE_TEXT=""
|
|
307
|
+
case "$_reason" in
|
|
308
|
+
path)
|
|
309
|
+
MEMORY_NOTICE_TEXT="Devflow: working memory is not updating because the worker cannot write its staged file at this project path. ${_where}"
|
|
310
|
+
;;
|
|
311
|
+
denied)
|
|
312
|
+
MEMORY_NOTICE_TEXT="Devflow: working memory is not updating because Claude Code refused the worker's write in two runs with no successful update between them. ${_where}"
|
|
313
|
+
;;
|
|
314
|
+
esac
|
|
315
|
+
return 0
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# memory_notice_read <memory dir> <root> <log file> — builds the message for each pending
|
|
319
|
+
# notice and moves each to its `.shown` marker, so a reason is shown once. Sets
|
|
320
|
+
# MEMORY_NOTICE_MESSAGE (empty when nothing is pending), joined by a space when more
|
|
321
|
+
# than one reason is pending. A notice is read only where no link sits on its path; one
|
|
322
|
+
# whose content does not fit its reason is moved to `.shown` unshown and logged.
|
|
323
|
+
# <log file> is named in the messages (memory_notice_text). Always returns 0.
|
|
324
|
+
memory_notice_read() {
|
|
325
|
+
local _dir="${1:-}" _root="${2:-}" _logfile="${3:-}"
|
|
326
|
+
local _reason _file _line _tag _extra _text
|
|
327
|
+
MEMORY_NOTICE_MESSAGE=""
|
|
328
|
+
for _reason in $MEMORY_NOTICE_REASONS; do
|
|
329
|
+
_file="$_dir/.worker-notice-$_reason"
|
|
330
|
+
df_file_below "$_root" "$_file" || continue
|
|
331
|
+
_line=""; _tag=""; _extra=""; _text=""
|
|
332
|
+
# stderr is redirected before stdin, so a notice that a concurrent session start moved
|
|
333
|
+
# away since the check above fails the open in silence.
|
|
334
|
+
IFS= read -r -n 200 _line 2>/dev/null < "$_file" || true
|
|
335
|
+
read -r _tag _extra <<< "$_line" || true
|
|
336
|
+
if [ "$_tag" = "$_reason" ] && [ -z "$_extra" ]; then
|
|
337
|
+
memory_notice_text "$_reason" "$_logfile"
|
|
338
|
+
_text="$MEMORY_NOTICE_TEXT"
|
|
339
|
+
fi
|
|
340
|
+
if [ -n "$_text" ]; then
|
|
341
|
+
if [ -n "$MEMORY_NOTICE_MESSAGE" ]; then
|
|
342
|
+
MEMORY_NOTICE_MESSAGE="${MEMORY_NOTICE_MESSAGE} ${_text}"
|
|
343
|
+
else
|
|
344
|
+
MEMORY_NOTICE_MESSAGE="$_text"
|
|
345
|
+
fi
|
|
346
|
+
elif declare -F log >/dev/null 2>&1; then
|
|
347
|
+
log "Worker notice for reason ${_reason} ignored: its content does not fit the reason"
|
|
348
|
+
fi
|
|
349
|
+
if df_no_symlink_below "$_root" "$_file" "$_file.shown"; then
|
|
350
|
+
if ! path_occupied "$_file.shown"; then
|
|
351
|
+
mv "$_file" "$_file.shown" 2>/dev/null || true
|
|
352
|
+
else
|
|
353
|
+
rm -f "$_file" 2>/dev/null || true
|
|
354
|
+
fi
|
|
355
|
+
fi
|
|
356
|
+
done
|
|
357
|
+
return 0
|
|
358
|
+
}
|
|
@@ -43,7 +43,7 @@ MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
|
|
|
43
43
|
|
|
44
44
|
# Memory gate: the machine switch, narrowed by this checkout's project.json /
|
|
45
45
|
# config.json `features.memory`, gates this hook entirely -- read by the helper
|
|
46
|
-
# every memory/
|
|
46
|
+
# every memory/curation gate shares (D-FEATURES-NARROW-ONLY, see queue-append).
|
|
47
47
|
source "$SCRIPT_DIR/queue-append" || { echo "memory-worker: failed to source queue-append" >&2; exit 1; }
|
|
48
48
|
queue_read_gates "$DEVFLOW_MANIFEST" "$PROJECT_ROOT"
|
|
49
49
|
MEMORY_ENABLED="$_QG_MEMORY"
|